From e01686f7158105243dcb9959a4da8e4d3dc8f161 Mon Sep 17 00:00:00 2001 From: John Carter Date: Sun, 4 Aug 2019 10:44:39 +1200 Subject: [PATCH] Reformat with black (#928) Also * changed isort config to match black (see https://black.readthedocs.io/en/stable/the_black_code_style.html?highlight=.isort.cfg#how-black-wraps-lines ) * changed editorconfig + isort to space instead of tabs * updated most other files to replace tab with 4 space. * set flake8 limit to 88 to match black default Resolves #926 --- .editorconfig | 6 +- HISTORY.rst | 16 +- djstripe/__init__.py | 31 +- djstripe/admin.py | 531 +- djstripe/checks.py | 333 +- djstripe/context_managers.py | 23 +- djstripe/contrib/__init__.py | 2 +- djstripe/contrib/rest_framework/__init__.py | 2 +- .../contrib/rest_framework/permissions.py | 28 +- .../contrib/rest_framework/serializers.py | 26 +- djstripe/contrib/rest_framework/urls.py | 18 +- djstripe/contrib/rest_framework/views.py | 129 +- djstripe/decorators.py | 55 +- djstripe/enums.py | 712 +-- djstripe/event_handlers.py | 473 +- djstripe/exceptions.py | 11 +- djstripe/fields.py | 140 +- ...djstripe_clear_expired_idempotency_keys.py | 6 +- .../commands/djstripe_init_customers.py | 20 +- .../commands/djstripe_sync_customers.py | 32 +- .../commands/djstripe_sync_models.py | 171 +- .../djstripe_sync_plans_from_stripe.py | 14 +- djstripe/managers.py | 150 +- djstripe/middleware.py | 144 +- djstripe/migrations/0001_initial.py | 5609 +++++++++-------- .../migrations/0002_auto_20180627_1121.py | 100 +- ...7_2328_squashed_0004_auto_20190227_2114.py | 2843 +++++---- .../migrations/0004_auto_20190612_0850.py | 204 +- .../migrations/0005_auto_20190710_1023.py | 12 +- .../migrations/0006_auto_20190729_1329.py | 1264 ++-- djstripe/mixins.py | 42 +- djstripe/models/__init__.py | 106 +- djstripe/models/base.py | 1237 ++-- djstripe/models/billing.py | 2557 ++++---- djstripe/models/checkout.py | 192 +- djstripe/models/connect.py | 1032 +-- djstripe/models/core.py | 3514 ++++++----- djstripe/models/payment_methods.py | 991 +-- djstripe/models/sigma.py | 57 +- djstripe/models/webhooks.py | 332 +- djstripe/settings.py | 262 +- djstripe/signals.py | 282 +- djstripe/sync.py | 22 +- .../templates/djstripe/admin/change_form.html | 12 +- djstripe/urls.py | 20 +- djstripe/utils.py | 195 +- djstripe/views.py | 40 +- djstripe/webhooks.py | 92 +- docs/conf.py | 69 +- .../examples/manually_syncing_with_stripe.py | 26 +- docs/usage/examples/test_docs_examples.py | 42 +- docs/usage/manually_syncing_with_stripe.rst | 4 +- manage.py | 20 +- setup.cfg | 36 +- setup.py | 4 +- tests/__init__.py | 2064 +++--- tests/apps/example/apps.py | 2 +- tests/apps/example/forms.py | 12 +- .../commands/regenerate_test_fixtures.py | 1371 ++-- .../example/templates/payment_intent.html | 280 +- .../templates/purchase_subscription.html | 270 +- .../purchase_subscription_success.html | 14 +- tests/apps/example/urls.py | 22 +- tests/apps/example/views.py | 217 +- tests/apps/testapp/models.py | 18 +- tests/apps/testapp/urls.py | 14 +- tests/apps/testapp_content/urls.py | 2 +- tests/apps/testapp_namespaced/urls.py | 2 +- ..._txn_fake_ch_fakefakefakefakefake0001.json | 46 +- .../card_card_fakefakefakefakefake0001.json | 52 +- .../card_card_fakefakefakefakefake0002.json | 52 +- .../card_card_fakefakefakefakefake0005.json | 52 +- .../charge_ch_fakefakefakefakefake0001.json | 212 +- .../fixtures/customer_cus_4QWKsZuuTHcs7X.json | 250 +- .../fixtures/customer_cus_4UbFSo9tl62jqj.json | 602 +- .../fixtures/customer_cus_6lsBvm5rJ0zyHc.json | 634 +- .../invoice_in_fakefakefakefakefake0001.json | 236 +- ...nt_intent_pi_fakefakefakefakefake0001.json | 308 +- ...ayment_method_pm_fakefakefakefake0001.json | 86 +- tests/fixtures/plan_gold21323.json | 40 +- tests/fixtures/plan_silver41294.json | 40 +- tests/fixtures/product_prod_fake1.json | 40 +- .../source_src_fakefakefakefakefake0001.json | 86 +- ...cription_sub_fakefakefakefakefake0001.json | 190 +- ...cription_sub_fakefakefakefakefake0002.json | 190 +- ...cription_sub_fakefakefakefakefake0003.json | 190 +- ...cription_sub_fakefakefakefakefake0004.json | 212 +- tests/settings.py | 130 +- tests/templates/base.html | 8 +- tests/test_account.py | 149 +- tests/test_admin.py | 38 +- tests/test_api_keys.py | 124 +- tests/test_card.py | 337 +- tests/test_charge.py | 1668 ++--- tests/test_context_managers.py | 24 +- .../test_rest_framework_permissions.py | 56 +- tests/test_contrib/test_serializers.py | 213 +- tests/test_contrib/test_views.py | 298 +- tests/test_coupon.py | 153 +- tests/test_customer.py | 2832 +++++---- tests/test_decorators.py | 98 +- tests/test_django.py | 4 +- tests/test_enums.py | 6 +- tests/test_event.py | 317 +- tests/test_event_handlers.py | 1333 ++-- tests/test_fields.py | 6 +- tests/test_idempotency_keys.py | 46 +- tests/test_invoice.py | 2180 +++---- tests/test_invoiceitem.py | 567 +- tests/test_managers.py | 507 +- tests/test_middleware.py | 299 +- tests/test_mixins.py | 100 +- tests/test_payment_method.py | 75 +- tests/test_plan.py | 497 +- tests/test_settings.py | 255 +- tests/test_source.py | 102 +- tests/test_stripe_model.py | 28 +- tests/test_subscription.py | 1343 ++-- tests/test_sync.py | 98 +- tests/test_utils.py | 177 +- tests/test_webhooks.py | 745 +-- tests/test_zz_jsonfield.py | 32 +- tests/urls.py | 32 +- tox.ini | 71 +- 124 files changed, 24326 insertions(+), 22719 deletions(-) diff --git a/.editorconfig b/.editorconfig index ae63f3d5fd..bef91edf18 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,7 +5,11 @@ root = true charset = utf-8 end_of_line = lf indent_size = 4 -indent_style = tab +indent_style = space quote_type = double insert_final_newline = true trim_trailing_whitespace = true +max_line_length = 88 + +[*.json] +insert_final_newline = false diff --git a/HISTORY.rst b/HISTORY.rst index d0a63ffc03..4378afe49c 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -36,12 +36,12 @@ Changes from API 2019-02-19: - Special handling of the icon and logo fields: - - Renamed ``Account.business_logo`` to ``Account.branding_icon`` - (note that in Stripe's API ``Account.business_logo`` was renamed to ``Account.settings.branding_icon``, - and ``Account.business_logo_large`` (which we didn't have a field for) was renamed to ``Account.settings.branding_logo``) - - Added deprecated property for ``Account.business_logo`` - - Added ``Account.branding_logo`` as a ForeignKey - - Populate ``Account.branding_icon`` and ``.branding_logo`` from the new ``Account.settings.branding.icon`` and ``.logo`` + - Renamed ``Account.business_logo`` to ``Account.branding_icon`` + (note that in Stripe's API ``Account.business_logo`` was renamed to ``Account.settings.branding_icon``, + and ``Account.business_logo_large`` (which we didn't have a field for) was renamed to ``Account.settings.branding_logo``) + - Added deprecated property for ``Account.business_logo`` + - Added ``Account.branding_logo`` as a ForeignKey + - Populate ``Account.branding_icon`` and ``.branding_logo`` from the new ``Account.settings.branding.icon`` and ``.logo`` Changes from API 2019-03-14: @@ -566,9 +566,9 @@ BIG HUGE NOTE - DON'T OVERLOOK THIS ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. warning:: - Subscription and InvoiceItem migration is not possible because old records don't have Stripe IDs (so we can't sync them). Our approach is to delete all local subscription and invoiceitem objects and re-sync them from Stripe. + Subscription and InvoiceItem migration is not possible because old records don't have Stripe IDs (so we can't sync them). Our approach is to delete all local subscription and invoiceitem objects and re-sync them from Stripe. - We 100% recommend you create a backup of your database before performing this upgrade. + We 100% recommend you create a backup of your database before performing this upgrade. Other changes diff --git a/djstripe/__init__.py b/djstripe/__init__.py index 51eb864802..97d3455685 100644 --- a/djstripe/__init__.py +++ b/djstripe/__init__.py @@ -10,19 +10,24 @@ class DjstripeAppConfig(AppConfig): - """ - An AppConfig for dj-stripe which loads system checks - and event handlers once Django is ready. - """ + """ + An AppConfig for dj-stripe which loads system checks + and event handlers once Django is ready. + """ - name = "djstripe" + name = "djstripe" - def ready(self): - import stripe - from . import checks, event_handlers # noqa: Register the checks and event handlers + def ready(self): + import stripe + from . import ( # noqa: Register the checks and event handlers + checks, + event_handlers, + ) - # Set app info - # https://stripe.com/docs/building-plugins#setappinfo - stripe.set_app_info( - "dj-stripe", version=__version__, url="https://github.com/dj-stripe/dj-stripe" - ) + # Set app info + # https://stripe.com/docs/building-plugins#setappinfo + stripe.set_app_info( + "dj-stripe", + version=__version__, + url="https://github.com/dj-stripe/dj-stripe", + ) diff --git a/djstripe/admin.py b/djstripe/admin.py index dbb8066913..b59377f0ff 100644 --- a/djstripe/admin.py +++ b/djstripe/admin.py @@ -7,382 +7,399 @@ class BaseHasSourceListFilter(admin.SimpleListFilter): - title = "source presence" - parameter_name = "has_source" + title = "source presence" + parameter_name = "has_source" - def lookups(self, request, model_admin): - """ - Return a list of tuples. + def lookups(self, request, model_admin): + """ + Return a list of tuples. - The first element in each tuple is the coded value for the option that will - appear in the URL query. The second element is the - human-readable name for the option that will appear - in the right sidebar. - source: https://docs.djangoproject.com/en/1.10/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter - """ - return (("yes", "Has a source"), ("no", "Has no source")) + The first element in each tuple is the coded value for the option that will + appear in the URL query. The second element is the + human-readable name for the option that will appear + in the right sidebar. + source: + https://docs.djangoproject.com/en/1.10/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter + """ + return (("yes", "Has a source"), ("no", "Has no source")) - def queryset(self, request, queryset): - """ - Return the filtered queryset based on the value provided in the query string. + def queryset(self, request, queryset): + """ + Return the filtered queryset based on the value provided in the query string. - source: https://docs.djangoproject.com/en/1.10/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter - """ - filter_args = {self._filter_arg_key: None} + source: + https://docs.djangoproject.com/en/1.10/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter + """ + filter_args = {self._filter_arg_key: None} - if self.value() == "yes": - return queryset.exclude(**filter_args) - if self.value() == "no": - return queryset.filter(**filter_args) + if self.value() == "yes": + return queryset.exclude(**filter_args) + if self.value() == "no": + return queryset.filter(**filter_args) class CustomerHasSourceListFilter(BaseHasSourceListFilter): - _filter_arg_key = "default_source" + _filter_arg_key = "default_source" class InvoiceCustomerHasSourceListFilter(BaseHasSourceListFilter): - _filter_arg_key = "customer__default_source" + _filter_arg_key = "customer__default_source" class CustomerSubscriptionStatusListFilter(admin.SimpleListFilter): - """A SimpleListFilter used with Customer admin.""" - - title = "subscription status" - parameter_name = "sub_status" - - def lookups(self, request, model_admin): - """ - Return a list of tuples. - - The first element in each tuple is the coded value for the option that will - appear in the URL query. The second element is the - human-readable name for the option that will appear - in the right sidebar. - source: https://docs.djangoproject.com/en/1.10/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter - """ - statuses = [ - [x, x.replace("_", " ").title()] - for x in models.Subscription.objects.values_list("status", flat=True).distinct() - ] - statuses.append(["none", "No Subscription"]) - return statuses - - def queryset(self, request, queryset): - """ - Return the filtered queryset based on the value provided in the query string. - - source: https://docs.djangoproject.com/en/1.10/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter - """ - if self.value() is None: - return queryset.all() - else: - return queryset.filter(subscriptions__status=self.value()).distinct() + """A SimpleListFilter used with Customer admin.""" + + title = "subscription status" + parameter_name = "sub_status" + + def lookups(self, request, model_admin): + """ + Return a list of tuples. + + The first element in each tuple is the coded value for the option that will + appear in the URL query. The second element is the + human-readable name for the option that will appear + in the right sidebar. + source: + https://docs.djangoproject.com/en/1.10/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter + """ + statuses = [ + [x, x.replace("_", " ").title()] + for x in models.Subscription.objects.values_list( + "status", flat=True + ).distinct() + ] + statuses.append(["none", "No Subscription"]) + return statuses + + def queryset(self, request, queryset): + """ + Return the filtered queryset based on the value provided in the query string. + + source: + https://docs.djangoproject.com/en/1.10/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_filter + """ + if self.value() is None: + return queryset.all() + else: + return queryset.filter(subscriptions__status=self.value()).distinct() @admin.register(models.IdempotencyKey) class IdempotencyKeyAdmin(admin.ModelAdmin): - list_display = ("uuid", "action", "created", "is_expired", "livemode") - list_filter = ("livemode",) - search_fields = ("uuid", "action") + list_display = ("uuid", "action", "created", "is_expired", "livemode") + list_filter = ("livemode",) + search_fields = ("uuid", "action") - def has_add_permission(self, request): - return False + def has_add_permission(self, request): + return False @admin.register(models.WebhookEventTrigger) class WebhookEventTriggerAdmin(admin.ModelAdmin): - list_display = ( - "created", - "event", - "remote_ip", - "processed", - "valid", - "exception", - "djstripe_version", - ) - list_filter = ("created", "valid", "processed") - raw_id_fields = ("event",) - - def reprocess(self, request, queryset): - for trigger in queryset: - if not trigger.valid: - self.message_user(request, "Skipped invalid trigger {}".format(trigger)) - continue - - trigger.process() - - def has_add_permission(self, request): - return False + list_display = ( + "created", + "event", + "remote_ip", + "processed", + "valid", + "exception", + "djstripe_version", + ) + list_filter = ("created", "valid", "processed") + raw_id_fields = ("event",) + + def reprocess(self, request, queryset): + for trigger in queryset: + if not trigger.valid: + self.message_user(request, "Skipped invalid trigger {}".format(trigger)) + continue + + trigger.process() + + def has_add_permission(self, request): + return False class StripeModelAdmin(admin.ModelAdmin): - """Base class for all StripeModel-based model admins""" + """Base class for all StripeModel-based model admins""" - change_form_template = "djstripe/admin/change_form.html" + change_form_template = "djstripe/admin/change_form.html" - def get_list_display(self, request): - return ("id",) + self.list_display + ("created", "livemode") + def get_list_display(self, request): + return ("id",) + self.list_display + ("created", "livemode") - def get_list_filter(self, request): - return self.list_filter + ("created", "livemode") + def get_list_filter(self, request): + return self.list_filter + ("created", "livemode") - def get_readonly_fields(self, request, obj=None): - return self.readonly_fields + ("id", "created") + def get_readonly_fields(self, request, obj=None): + return self.readonly_fields + ("id", "created") - def get_search_fields(self, request): - return self.search_fields + ("id",) + def get_search_fields(self, request): + return self.search_fields + ("id",) - def get_fieldsets(self, request, obj=None): - common_fields = ("livemode", "id", "created") - # Have to remove the fields from the common set, otherwise they'll show up twice. - fields = [f for f in self.get_fields(request, obj) if f not in common_fields] - return ((None, {"fields": common_fields}), (self.model.__name__, {"fields": fields})) + def get_fieldsets(self, request, obj=None): + common_fields = ("livemode", "id", "created") + # Have to remove the fields from the common set, + # otherwise they'll show up twice. + fields = [f for f in self.get_fields(request, obj) if f not in common_fields] + return ( + (None, {"fields": common_fields}), + (self.model.__name__, {"fields": fields}), + ) class SubscriptionInline(admin.StackedInline): - """A TabularInline for use models.Subscription.""" + """A TabularInline for use models.Subscription.""" - model = models.Subscription - extra = 0 - readonly_fields = ("id", "created") - show_change_link = True + model = models.Subscription + extra = 0 + readonly_fields = ("id", "created") + show_change_link = True class SubscriptionItemInline(admin.StackedInline): - """A TabularInline for use models.Subscription.""" + """A TabularInline for use models.Subscription.""" - model = models.SubscriptionItem - extra = 0 - readonly_fields = ("id", "created") - show_change_link = True + model = models.SubscriptionItem + extra = 0 + readonly_fields = ("id", "created") + show_change_link = True class InvoiceItemInline(admin.StackedInline): - """A TabularInline for use InvoiceItem.""" + """A TabularInline for use InvoiceItem.""" - model = models.InvoiceItem - extra = 0 - readonly_fields = ("id", "created") - raw_id_fields = ("customer", "subscription", "plan") - show_change_link = True + model = models.InvoiceItem + extra = 0 + readonly_fields = ("id", "created") + raw_id_fields = ("customer", "subscription", "plan") + show_change_link = True @admin.register(models.Account) class AccountAdmin(StripeModelAdmin): - list_display = ("business_url", "country", "default_currency") - list_filter = ("details_submitted",) - search_fields = ("business_name", "display_name", "business_url") - raw_id_fields = ("branding_icon",) + list_display = ("business_url", "country", "default_currency") + list_filter = ("details_submitted",) + search_fields = ("business_name", "display_name", "business_url") + raw_id_fields = ("branding_icon",) @admin.register(models.Charge) class ChargeAdmin(StripeModelAdmin): - list_display = ( - "customer", - "amount", - "description", - "paid", - "disputed", - "refunded", - "fee", - ) - search_fields = ("customer__id", "invoice__id") - list_filter = ("status", "paid", "refunded", "captured") - raw_id_fields = ("customer", "dispute", "invoice", "source", "transfer") + list_display = ( + "customer", + "amount", + "description", + "paid", + "disputed", + "refunded", + "fee", + ) + search_fields = ("customer__id", "invoice__id") + list_filter = ("status", "paid", "refunded", "captured") + raw_id_fields = ("customer", "dispute", "invoice", "source", "transfer") @admin.register(models.Coupon) class CouponAdmin(StripeModelAdmin): - list_display = ( - "amount_off", - "percent_off", - "duration", - "duration_in_months", - "redeem_by", - "max_redemptions", - "times_redeemed", - ) - list_filter = ("duration", "redeem_by") - radio_fields = {"duration": admin.HORIZONTAL} + list_display = ( + "amount_off", + "percent_off", + "duration", + "duration_in_months", + "redeem_by", + "max_redemptions", + "times_redeemed", + ) + list_filter = ("duration", "redeem_by") + radio_fields = {"duration": admin.HORIZONTAL} @admin.register(models.Customer) class CustomerAdmin(StripeModelAdmin): - raw_id_fields = ("subscriber", "default_source", "coupon") - list_display = ( - "subscriber", - "email", - "currency", - "default_source", - "coupon", - "balance", - "business_vat_id", - ) - list_filter = (CustomerHasSourceListFilter, CustomerSubscriptionStatusListFilter) - search_fields = ("email", "description") - inlines = (SubscriptionInline,) + raw_id_fields = ("subscriber", "default_source", "coupon") + list_display = ( + "subscriber", + "email", + "currency", + "default_source", + "coupon", + "balance", + "business_vat_id", + ) + list_filter = (CustomerHasSourceListFilter, CustomerSubscriptionStatusListFilter) + search_fields = ("email", "description") + inlines = (SubscriptionInline,) @admin.register(models.Dispute) class DisputeAdmin(StripeModelAdmin): - list_display = ("reason", "status", "amount", "currency", "is_charge_refundable") - list_filter = ("is_charge_refundable", "reason", "status") + list_display = ("reason", "status", "amount", "currency", "is_charge_refundable") + list_filter = ("is_charge_refundable", "reason", "status") - def has_add_permission(self, request): - return False + def has_add_permission(self, request): + return False @admin.register(models.Event) class EventAdmin(StripeModelAdmin): - list_display = ("type", "request_id") - list_filter = ("type", "created") - search_fields = ("request_id",) + list_display = ("type", "request_id") + list_filter = ("type", "created") + search_fields = ("request_id",) - def has_add_permission(self, request): - return False + def has_add_permission(self, request): + return False @admin.register(models.FileUpload) class FileUploadAdmin(StripeModelAdmin): - list_display = ("purpose", "size", "type") - list_filter = ("purpose", "type") - search_fields = ("filename",) + list_display = ("purpose", "size", "type") + list_filter = ("purpose", "type") + search_fields = ("filename",) @admin.register(models.PaymentIntent) class PaymentIntentAdmin(StripeModelAdmin): - list_display = ( - "id", - "customer", - "amount", - "currency", - "description", - "amount_capturable", - "amount_received", - "receipt_email", - ) - search_fields = ("customer__id", "invoice__id") + list_display = ( + "id", + "customer", + "amount", + "currency", + "description", + "amount_capturable", + "amount_received", + "receipt_email", + ) + search_fields = ("customer__id", "invoice__id") @admin.register(models.SetupIntent) class SetupIntentAdmin(StripeModelAdmin): - list_display = ( - "id", - "created", - "customer", - "description", - "on_behalf_of", - "payment_method", - "payment_method_types", - "status", - ) - list_filter = ("status",) - search_fields = ("customer__id", "status") + list_display = ( + "id", + "created", + "customer", + "description", + "on_behalf_of", + "payment_method", + "payment_method_types", + "status", + ) + list_filter = ("status",) + search_fields = ("customer__id", "status") @admin.register(models.Invoice) class InvoiceAdmin(StripeModelAdmin): - list_display = ( - "customer", - "number", - "paid", - "forgiven", - "closed", - "period_start", - "period_end", - "subtotal", - "tax", - "tax_percent", - "total", - ) - list_filter = ( - "paid", - "forgiven", - "closed", - "attempted", - "created", - "due_date", - "period_start", - "period_end", - ) - raw_id_fields = ("customer", "charge", "subscription") - search_fields = ("customer__id", "number", "receipt_number") - inlines = (InvoiceItemInline,) + list_display = ( + "customer", + "number", + "paid", + "forgiven", + "closed", + "period_start", + "period_end", + "subtotal", + "tax", + "tax_percent", + "total", + ) + list_filter = ( + "paid", + "forgiven", + "closed", + "attempted", + "created", + "due_date", + "period_start", + "period_end", + ) + raw_id_fields = ("customer", "charge", "subscription") + search_fields = ("customer__id", "number", "receipt_number") + inlines = (InvoiceItemInline,) @admin.register(models.Plan) class PlanAdmin(StripeModelAdmin): - radio_fields = {"interval": admin.HORIZONTAL} + radio_fields = {"interval": admin.HORIZONTAL} - def save_model(self, request, obj, form, change): - """Update or create objects using our custom methods that sync with Stripe.""" - if change: - obj.update_name() - else: - models.Plan.get_or_create(**form.cleaned_data) + def save_model(self, request, obj, form, change): + """Update or create objects using our custom methods that sync with Stripe.""" + if change: + obj.update_name() + else: + models.Plan.get_or_create(**form.cleaned_data) - def get_readonly_fields(self, request, obj=None): - """Return extra readonly_fields.""" - readonly_fields = super().get_readonly_fields(request, obj) + def get_readonly_fields(self, request, obj=None): + """Return extra readonly_fields.""" + readonly_fields = super().get_readonly_fields(request, obj) - if obj: - readonly_fields += ( - "amount", - "currency", - "interval", - "interval_count", - "trial_period_days", - ) + if obj: + readonly_fields += ( + "amount", + "currency", + "interval", + "interval_count", + "trial_period_days", + ) - return readonly_fields + return readonly_fields @admin.register(models.Product) class ProductAdmin(StripeModelAdmin): - list_display = ("name", "type", "active", "url", "statement_descriptor") - list_filter = ("type", "active", "shippable") - search_fields = ("name", "statement_descriptor") + list_display = ("name", "type", "active", "url", "statement_descriptor") + list_filter = ("type", "active", "shippable") + search_fields = ("name", "statement_descriptor") @admin.register(models.Refund) class RefundAdmin(StripeModelAdmin): - list_display = ("amount", "currency", "charge", "reason", "status", "failure_reason") - list_filter = ("reason", "status") - search_fields = ("receipt_number",) + list_display = ( + "amount", + "currency", + "charge", + "reason", + "status", + "failure_reason", + ) + list_filter = ("reason", "status") + search_fields = ("receipt_number",) @admin.register(models.Source) class SourceAdmin(StripeModelAdmin): - raw_id_fields = ("customer",) - list_display = ("customer", "type", "status", "amount", "currency", "usage", "flow") - list_filter = ("type", "status", "usage", "flow") + raw_id_fields = ("customer",) + list_display = ("customer", "type", "status", "amount", "currency", "usage", "flow") + list_filter = ("type", "status", "usage", "flow") @admin.register(models.PaymentMethod) class PaymentMethodAdmin(StripeModelAdmin): - raw_id_fields = ("customer",) - list_display = ("customer", "billing_details") - list_filter = ("customer",) + raw_id_fields = ("customer",) + list_display = ("customer", "billing_details") + list_filter = ("customer",) @admin.register(models.Subscription) class SubscriptionAdmin(StripeModelAdmin): - raw_id_fields = ("customer",) - list_display = ("customer", "status") - list_filter = ("status", "cancel_at_period_end") + raw_id_fields = ("customer",) + list_display = ("customer", "status") + list_filter = ("status", "cancel_at_period_end") - inlines = (SubscriptionItemInline,) + inlines = (SubscriptionItemInline,) - def cancel_subscription(self, request, queryset): - """Cancel a subscription.""" - for subscription in queryset: - subscription.cancel() + def cancel_subscription(self, request, queryset): + """Cancel a subscription.""" + for subscription in queryset: + subscription.cancel() - cancel_subscription.short_description = "Cancel selected subscriptions" + cancel_subscription.short_description = "Cancel selected subscriptions" - actions = (cancel_subscription,) + actions = (cancel_subscription,) @admin.register(models.Transfer) class TransferAdmin(StripeModelAdmin): - list_display = ("amount", "description") + list_display = ("amount", "description") diff --git a/djstripe/checks.py b/djstripe/checks.py index 4564022098..f34f200496 100644 --- a/djstripe/checks.py +++ b/djstripe/checks.py @@ -8,206 +8,217 @@ @checks.register("djstripe") def check_stripe_api_key(app_configs=None, **kwargs): - """Check the user has configured API live/test keys correctly.""" - from . import settings as djstripe_settings + """Check the user has configured API live/test keys correctly.""" + from . import settings as djstripe_settings - messages = [] + messages = [] - if not djstripe_settings.STRIPE_SECRET_KEY: - msg = "Could not find a Stripe API key." - hint = "Add STRIPE_TEST_SECRET_KEY and STRIPE_LIVE_SECRET_KEY to your settings." - messages.append(checks.Critical(msg, hint=hint, id="djstripe.C001")) - elif djstripe_settings.STRIPE_LIVE_MODE: - if not djstripe_settings.LIVE_API_KEY.startswith(("sk_live_", "rk_live_")): - msg = "Bad Stripe live API key." - hint = 'STRIPE_LIVE_SECRET_KEY should start with "sk_live_"' - messages.append(checks.Critical(msg, hint=hint, id="djstripe.C002")) - else: - if not djstripe_settings.TEST_API_KEY.startswith(("sk_test_", "rk_test_")): - msg = "Bad Stripe test API key." - hint = 'STRIPE_TEST_SECRET_KEY should start with "sk_test_"' - messages.append(checks.Critical(msg, hint=hint, id="djstripe.C003")) + if not djstripe_settings.STRIPE_SECRET_KEY: + msg = "Could not find a Stripe API key." + hint = "Add STRIPE_TEST_SECRET_KEY and STRIPE_LIVE_SECRET_KEY to your settings." + messages.append(checks.Critical(msg, hint=hint, id="djstripe.C001")) + elif djstripe_settings.STRIPE_LIVE_MODE: + if not djstripe_settings.LIVE_API_KEY.startswith(("sk_live_", "rk_live_")): + msg = "Bad Stripe live API key." + hint = 'STRIPE_LIVE_SECRET_KEY should start with "sk_live_"' + messages.append(checks.Critical(msg, hint=hint, id="djstripe.C002")) + else: + if not djstripe_settings.TEST_API_KEY.startswith(("sk_test_", "rk_test_")): + msg = "Bad Stripe test API key." + hint = 'STRIPE_TEST_SECRET_KEY should start with "sk_test_"' + messages.append(checks.Critical(msg, hint=hint, id="djstripe.C003")) - return messages + return messages def validate_stripe_api_version(version): - """ - Check the API version is formatted correctly for Stripe. + """ + Check the API version is formatted correctly for Stripe. - The expected format is an iso8601 date: `YYYY-MM-DD` + The expected format is an iso8601 date: `YYYY-MM-DD` - :param version: The version to set for the Stripe API. - :type version: ``str`` - :returns bool: Whether the version is formatted correctly. - """ - return date_re.match(version) + :param version: The version to set for the Stripe API. + :type version: ``str`` + :returns bool: Whether the version is formatted correctly. + """ + return date_re.match(version) @checks.register("djstripe") def check_stripe_api_version(app_configs=None, **kwargs): - """Check the user has configured API version correctly.""" - from . import settings as djstripe_settings + """Check the user has configured API version correctly.""" + from . import settings as djstripe_settings - messages = [] - default_version = djstripe_settings.DEFAULT_STRIPE_API_VERSION - version = djstripe_settings.get_stripe_api_version() + messages = [] + default_version = djstripe_settings.DEFAULT_STRIPE_API_VERSION + version = djstripe_settings.get_stripe_api_version() - if not validate_stripe_api_version(version): - msg = "Invalid Stripe API version: {}".format(version) - hint = "STRIPE_API_VERSION should be formatted as: YYYY-MM-DD" - messages.append(checks.Critical(msg, hint=hint, id="djstripe.C004")) + if not validate_stripe_api_version(version): + msg = "Invalid Stripe API version: {}".format(version) + hint = "STRIPE_API_VERSION should be formatted as: YYYY-MM-DD" + messages.append(checks.Critical(msg, hint=hint, id="djstripe.C004")) - if version != default_version: - msg = ( - "The Stripe API version has a non-default value of '{}'. " - "Non-default versions are not explicitly supported, and may " - "cause compatibility issues.".format(version) - ) - hint = "Use the dj-stripe default for Stripe API version: {}".format(default_version) - messages.append(checks.Warning(msg, hint=hint, id="djstripe.W001")) + if version != default_version: + msg = ( + "The Stripe API version has a non-default value of '{}'. " + "Non-default versions are not explicitly supported, and may " + "cause compatibility issues.".format(version) + ) + hint = "Use the dj-stripe default for Stripe API version: {}".format( + default_version + ) + messages.append(checks.Warning(msg, hint=hint, id="djstripe.W001")) - return messages + return messages @checks.register("djstripe") def check_native_jsonfield_postgres_engine(app_configs=None, **kwargs): - """ - Check that the DJSTRIPE_USE_NATIVE_JSONFIELD isn't set unless Postgres is in use. - """ - from . import settings as djstripe_settings - - messages = [] - error_msg = "DJSTRIPE_USE_NATIVE_JSONFIELD is not compatible with engine {engine} for database {name}" - - if djstripe_settings.USE_NATIVE_JSONFIELD: - for db_name, db_config in settings.DATABASES.items(): - # Hi there. - # You may be reading this because you are using Postgres, but - # dj-stripe is not detecting that correctly. For example, maybe you - # are using multiple databases with different engines, or you have - # your own backend. As long as you are certain you can support jsonb, - # you can use the SILENCED_SYSTEM_CHECKS setting to ignore this check. - engine = db_config.get("ENGINE", "") - if "postgresql" not in engine and "postgis" not in engine: - messages.append( - checks.Critical( - error_msg.format(name=repr(db_name), engine=repr(engine)), - hint="Switch to Postgres, or unset DJSTRIPE_USE_NATIVE_JSONFIELD", - id="djstripe.C005", - ) - ) - - return messages + """ + Check that the DJSTRIPE_USE_NATIVE_JSONFIELD isn't set unless Postgres is in use. + """ + from . import settings as djstripe_settings + + messages = [] + error_msg = ( + "DJSTRIPE_USE_NATIVE_JSONFIELD is not compatible with engine {engine} " + "for database {name}" + ) + + if djstripe_settings.USE_NATIVE_JSONFIELD: + for db_name, db_config in settings.DATABASES.items(): + # Hi there. + # You may be reading this because you are using Postgres, but + # dj-stripe is not detecting that correctly. For example, maybe you + # are using multiple databases with different engines, or you have + # your own backend. As long as you are certain you can support jsonb, + # you can use the SILENCED_SYSTEM_CHECKS setting to ignore this check. + engine = db_config.get("ENGINE", "") + if "postgresql" not in engine and "postgis" not in engine: + messages.append( + checks.Critical( + error_msg.format(name=repr(db_name), engine=repr(engine)), + hint="Switch to Postgres, or unset " + "DJSTRIPE_USE_NATIVE_JSONFIELD", + id="djstripe.C005", + ) + ) + + return messages @checks.register("djstripe") def check_stripe_api_host(app_configs=None, **kwargs): - """ - Check that STRIPE_API_HOST is not being used in production. - """ - from django.conf import settings + """ + Check that STRIPE_API_HOST is not being used in production. + """ + from django.conf import settings - messages = [] + messages = [] - if not settings.DEBUG and hasattr(settings, "STRIPE_API_HOST"): - messages.append( - checks.Warning( - "STRIPE_API_HOST should not be set in production! This is most likely unintended.", - hint="Remove STRIPE_API_HOST from your Django settings.", - id="djstripe.W002", - ) - ) + if not settings.DEBUG and hasattr(settings, "STRIPE_API_HOST"): + messages.append( + checks.Warning( + "STRIPE_API_HOST should not be set in production! " + "This is most likely unintended.", + hint="Remove STRIPE_API_HOST from your Django settings.", + id="djstripe.W002", + ) + ) - return messages + return messages @checks.register("djstripe") def check_webhook_secret(app_configs=None, **kwargs): - """ - Check that DJSTRIPE_WEBHOOK_SECRET looks correct - """ - from . import settings as djstripe_settings + """ + Check that DJSTRIPE_WEBHOOK_SECRET looks correct + """ + from . import settings as djstripe_settings - messages = [] + messages = [] - secret = djstripe_settings.WEBHOOK_SECRET - if secret and not secret.startswith("whsec_"): - messages.append( - checks.Warning( - "DJSTRIPE_WEBHOOK_SECRET does not look valid", - hint="It should start with whsec_...", - id="djstripe.W003", - ) - ) + secret = djstripe_settings.WEBHOOK_SECRET + if secret and not secret.startswith("whsec_"): + messages.append( + checks.Warning( + "DJSTRIPE_WEBHOOK_SECRET does not look valid", + hint="It should start with whsec_...", + id="djstripe.W003", + ) + ) - return messages + return messages @checks.register("djstripe") def check_webhook_validation(app_configs=None, **kwargs): - """ - Check that DJSTRIPE_WEBHOOK_VALIDATION is valid - """ - from . import settings as djstripe_settings - - messages = [] - - validation_options = ("verify_signature", "retrieve_event") - - if djstripe_settings.WEBHOOK_VALIDATION is None: - messages.append( - checks.Warning( - "Webhook validation is disabled, this is a security risk if the webhook view is enabled", - hint="Set DJSTRIPE_WEBHOOK_VALIDATION to one of {}".format( - ", ".join(validation_options) - ), - id="djstripe.W004", - ) - ) - elif djstripe_settings.WEBHOOK_VALIDATION == "verify_signature": - if not djstripe_settings.WEBHOOK_SECRET: - messages.append( - checks.Critical( - "DJSTRIPE_WEBHOOK_VALIDATION='verify_signature' but DJSTRIPE_WEBHOOK_SECRET is not set", - hint="Set DJSTRIPE_WEBHOOK_SECRET or set DJSTRIPE_WEBHOOK_VALIDATION='retrieve_event'", - id="djstripe.C006", - ) - ) - elif djstripe_settings.WEBHOOK_VALIDATION not in validation_options: - messages.append( - checks.Critical( - "DJSTRIPE_WEBHOOK_VALIDATION is invalid", - hint="Set DJSTRIPE_WEBHOOK_VALIDATION to one of {} or None".format( - ", ".join(validation_options) - ), - id="djstripe.C007", - ) - ) - - return messages + """ + Check that DJSTRIPE_WEBHOOK_VALIDATION is valid + """ + from . import settings as djstripe_settings + + messages = [] + + validation_options = ("verify_signature", "retrieve_event") + + if djstripe_settings.WEBHOOK_VALIDATION is None: + messages.append( + checks.Warning( + "Webhook validation is disabled, this is a security risk if the " + "webhook view is enabled", + hint="Set DJSTRIPE_WEBHOOK_VALIDATION to one of {}".format( + ", ".join(validation_options) + ), + id="djstripe.W004", + ) + ) + elif djstripe_settings.WEBHOOK_VALIDATION == "verify_signature": + if not djstripe_settings.WEBHOOK_SECRET: + messages.append( + checks.Critical( + "DJSTRIPE_WEBHOOK_VALIDATION='verify_signature' " + "but DJSTRIPE_WEBHOOK_SECRET is not set", + hint="Set DJSTRIPE_WEBHOOK_SECRET or set " + "DJSTRIPE_WEBHOOK_VALIDATION='retrieve_event'", + id="djstripe.C006", + ) + ) + elif djstripe_settings.WEBHOOK_VALIDATION not in validation_options: + messages.append( + checks.Critical( + "DJSTRIPE_WEBHOOK_VALIDATION is invalid", + hint="Set DJSTRIPE_WEBHOOK_VALIDATION to one of {} or None".format( + ", ".join(validation_options) + ), + id="djstripe.C007", + ) + ) + + return messages @checks.register("djstripe") def check_subscriber_key_length(app_configs=None, **kwargs): - """ - Check that DJSTRIPE_SUBSCRIBER_CUSTOMER_KEY fits in metadata. - - Docs: https://stripe.com/docs/api#metadata - """ - from . import settings as djstripe_settings - - messages = [] - - key = djstripe_settings.SUBSCRIBER_CUSTOMER_KEY - key_size = len(str(key)) - if key and key_size > 40: - messages.append( - checks.Error( - "DJSTRIPE_SUBSCRIBER_CUSTOMER_KEY must be no more than 40 characters long", - hint="Current value: %r (%i characters)" % (key, key_size), - id="djstripe.E001", - ) - ) - - return messages + """ + Check that DJSTRIPE_SUBSCRIBER_CUSTOMER_KEY fits in metadata. + + Docs: https://stripe.com/docs/api#metadata + """ + from . import settings as djstripe_settings + + messages = [] + + key = djstripe_settings.SUBSCRIBER_CUSTOMER_KEY + key_size = len(str(key)) + if key and key_size > 40: + messages.append( + checks.Error( + "DJSTRIPE_SUBSCRIBER_CUSTOMER_KEY must be no more than " + "40 characters long", + hint="Current value: %r (%i characters)" % (key, key_size), + id="djstripe.E001", + ) + ) + + return messages diff --git a/djstripe/context_managers.py b/djstripe/context_managers.py index 632005723e..4c76a20add 100644 --- a/djstripe/context_managers.py +++ b/djstripe/context_managers.py @@ -8,16 +8,17 @@ @contextmanager def stripe_temporary_api_version(version, validate=True): - """ - Temporarily replace the global api_version used in stripe API calls with the given value. + """ + Temporarily replace the global api_version used in stripe API calls with + the given value. - The original value is restored as soon as context exits. - """ - old_version = djstripe_settings.get_stripe_api_version() + The original value is restored as soon as context exits. + """ + old_version = djstripe_settings.get_stripe_api_version() - try: - djstripe_settings.set_stripe_api_version(version, validate=validate) - yield - finally: - # Validation is bypassed since we're restoring a previous value. - djstripe_settings.set_stripe_api_version(old_version, validate=False) + try: + djstripe_settings.set_stripe_api_version(version, validate=validate) + yield + finally: + # Validation is bypassed since we're restoring a previous value. + djstripe_settings.set_stripe_api_version(old_version, validate=False) diff --git a/djstripe/contrib/__init__.py b/djstripe/contrib/__init__.py index 1ee2030916..1496d72d5c 100644 --- a/djstripe/contrib/__init__.py +++ b/djstripe/contrib/__init__.py @@ -1,5 +1,5 @@ """ .. module:: dj-stripe.contrib. - :synopsis: Extra modules not pertaining to core logic of dj-stripe. + :synopsis: Extra modules not pertaining to core logic of dj-stripe. """ diff --git a/djstripe/contrib/rest_framework/__init__.py b/djstripe/contrib/rest_framework/__init__.py index 752a289510..f895572d85 100644 --- a/djstripe/contrib/rest_framework/__init__.py +++ b/djstripe/contrib/rest_framework/__init__.py @@ -1,5 +1,5 @@ """ .. module:: dj-stripe.contrib.rest_framework. - :synopsis: The dj-stripe REST API. + :synopsis: The dj-stripe REST API. """ diff --git a/djstripe/contrib/rest_framework/permissions.py b/djstripe/contrib/rest_framework/permissions.py index 5d40f6aca3..75b136ff6f 100644 --- a/djstripe/contrib/rest_framework/permissions.py +++ b/djstripe/contrib/rest_framework/permissions.py @@ -1,7 +1,7 @@ """ .. module:: dj-stripe.contrib.rest_framework.permissions. - :synopsis: dj-stripe - Permissions to be used with the dj-stripe REST API. + :synopsis: dj-stripe - Permissions to be used with the dj-stripe REST API. .. moduleauthor:: @kavdev, @pydanny @@ -13,19 +13,21 @@ class DJStripeSubscriptionPermission(BasePermission): - """A permission to be used when wanting to permit users with active subscriptions.""" + """ + A permission to be used when wanting to permit users with active subscriptions. + """ - def has_permission(self, request, view): - """ - Check if the subscriber has an active subscription. + def has_permission(self, request, view): + """ + Check if the subscriber has an active subscription. - Returns false if: - * a subscriber isn't passed through the request + Returns false if: + * a subscriber isn't passed through the request - See ``utils.subscriber_has_active_subscription`` for more rules. + See ``utils.subscriber_has_active_subscription`` for more rules. - """ - try: - subscriber_has_active_subscription(subscriber_request_callback(request)) - except AttributeError: - return False + """ + try: + subscriber_has_active_subscription(subscriber_request_callback(request)) + except AttributeError: + return False diff --git a/djstripe/contrib/rest_framework/serializers.py b/djstripe/contrib/rest_framework/serializers.py index 6f7412e21c..4a337bfce5 100644 --- a/djstripe/contrib/rest_framework/serializers.py +++ b/djstripe/contrib/rest_framework/serializers.py @@ -1,7 +1,7 @@ """ .. module:: dj-stripe.contrib.rest_framework.serializers. - :synopsis: dj-stripe - Serializers to be used with the dj-stripe REST API. + :synopsis: dj-stripe - Serializers to be used with the dj-stripe REST API. .. moduleauthor:: Philippe Luickx (@philippeluickx) @@ -14,19 +14,21 @@ class SubscriptionSerializer(ModelSerializer): - """A serializer used for the Subscription model.""" + """A serializer used for the Subscription model.""" - class Meta: - """Model class options.""" + class Meta: + """Model class options.""" - model = Subscription - fields = "__all__" + model = Subscription + fields = "__all__" class CreateSubscriptionSerializer(serializers.Serializer): - """A serializer used to create a Subscription.""" - - stripe_token = serializers.CharField(max_length=200) - plan = serializers.CharField(max_length=50) - charge_immediately = serializers.NullBooleanField(required=False) - tax_percent = serializers.DecimalField(required=False, max_digits=5, decimal_places=2) + """A serializer used to create a Subscription.""" + + stripe_token = serializers.CharField(max_length=200) + plan = serializers.CharField(max_length=50) + charge_immediately = serializers.NullBooleanField(required=False) + tax_percent = serializers.DecimalField( + required=False, max_digits=5, decimal_places=2 + ) diff --git a/djstripe/contrib/rest_framework/urls.py b/djstripe/contrib/rest_framework/urls.py index cfa4b39e69..fc84e32416 100644 --- a/djstripe/contrib/rest_framework/urls.py +++ b/djstripe/contrib/rest_framework/urls.py @@ -1,18 +1,18 @@ """ .. module:: dj-stripe.contrib.rest_framework.urls. - :synopsis: URL routes for the dj-stripe REST API. + :synopsis: URL routes for the dj-stripe REST API. .. moduleauthor:: Philippe Luickx (@philippeluickx) Wire this into the root URLConf this way:: - url( - r'^api/v1/stripe/', - include('djstripe.contrib.rest_framework.urls', namespace="rest_djstripe") - ), - # url can be changed - # Call to 'djstripe.contrib.rest_framework.urls' and 'namespace' must stay as is + url( + r'^api/v1/stripe/', + include('djstripe.contrib.rest_framework.urls', namespace="rest_djstripe") + ), + # url can be changed + # Call to 'djstripe.contrib.rest_framework.urls' and 'namespace' must stay as is """ @@ -23,6 +23,6 @@ app_name = "djstripe_rest_framework" urlpatterns = [ - # REST api - url(r"^subscription/$", views.SubscriptionRestView.as_view(), name="subscription") + # REST api + url(r"^subscription/$", views.SubscriptionRestView.as_view(), name="subscription") ] diff --git a/djstripe/contrib/rest_framework/views.py b/djstripe/contrib/rest_framework/views.py index 092c689d14..672cdae244 100644 --- a/djstripe/contrib/rest_framework/views.py +++ b/djstripe/contrib/rest_framework/views.py @@ -1,7 +1,7 @@ """ .. module:: dj-stripe.contrib.rest_framework.views. - :synopsis: Views for the dj-stripe REST API. + :synopsis: Views for the dj-stripe REST API. .. moduleauthor:: Philippe Luickx (@philippeluickx) @@ -18,66 +18,67 @@ class SubscriptionRestView(APIView): - """API Endpoints for the Subscription object.""" - - permission_classes = (IsAuthenticated,) - - def get(self, request, **kwargs): - """ - Return the customer's valid subscriptions. - - Returns with status code 200. - """ - customer, _created = Customer.get_or_create( - subscriber=subscriber_request_callback(self.request) - ) - - serializer = SubscriptionSerializer(customer.subscription) - return Response(serializer.data) - - def post(self, request, **kwargs): - """ - Create a new current subscription for the user. - - Returns with status code 201. - """ - serializer = CreateSubscriptionSerializer(data=request.data) - - if serializer.is_valid(): - try: - customer, _created = Customer.get_or_create( - subscriber=subscriber_request_callback(self.request) - ) - customer.add_card(serializer.data["stripe_token"]) - charge_immediately = serializer.data.get("charge_immediately") - if charge_immediately is None: - charge_immediately = True - - customer.subscribe(serializer.data["plan"], charge_immediately) - return Response(serializer.data, status=status.HTTP_201_CREATED) - except Exception: - # TODO: Better error messages - return Response( - "Something went wrong processing the payment.", status=status.HTTP_400_BAD_REQUEST - ) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, **kwargs): - """ - Mark the customers current subscription as cancelled. - - Returns with status code 204. - """ - try: - customer, _created = Customer.get_or_create( - subscriber=subscriber_request_callback(self.request) - ) - customer.subscription.cancel(at_period_end=CANCELLATION_AT_PERIOD_END) - - return Response(status=status.HTTP_204_NO_CONTENT) - except Exception: - return Response( - "Something went wrong cancelling the subscription.", - status=status.HTTP_400_BAD_REQUEST, - ) + """API Endpoints for the Subscription object.""" + + permission_classes = (IsAuthenticated,) + + def get(self, request, **kwargs): + """ + Return the customer's valid subscriptions. + + Returns with status code 200. + """ + customer, _created = Customer.get_or_create( + subscriber=subscriber_request_callback(self.request) + ) + + serializer = SubscriptionSerializer(customer.subscription) + return Response(serializer.data) + + def post(self, request, **kwargs): + """ + Create a new current subscription for the user. + + Returns with status code 201. + """ + serializer = CreateSubscriptionSerializer(data=request.data) + + if serializer.is_valid(): + try: + customer, _created = Customer.get_or_create( + subscriber=subscriber_request_callback(self.request) + ) + customer.add_card(serializer.data["stripe_token"]) + charge_immediately = serializer.data.get("charge_immediately") + if charge_immediately is None: + charge_immediately = True + + customer.subscribe(serializer.data["plan"], charge_immediately) + return Response(serializer.data, status=status.HTTP_201_CREATED) + except Exception: + # TODO: Better error messages + return Response( + "Something went wrong processing the payment.", + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, **kwargs): + """ + Mark the customers current subscription as cancelled. + + Returns with status code 204. + """ + try: + customer, _created = Customer.get_or_create( + subscriber=subscriber_request_callback(self.request) + ) + customer.subscription.cancel(at_period_end=CANCELLATION_AT_PERIOD_END) + + return Response(status=status.HTTP_204_NO_CONTENT) + except Exception: + return Response( + "Something went wrong cancelling the subscription.", + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/djstripe/decorators.py b/djstripe/decorators.py index c5a464fe15..824005f7e5 100644 --- a/djstripe/decorators.py +++ b/djstripe/decorators.py @@ -12,40 +12,41 @@ def subscriber_passes_pay_test(test_func, plan=None, pay_page=SUBSCRIPTION_REDIRECT): - """ - Decorator for views that checks the subscriber passes the given test for a "Paid Feature". + """ + Decorator for views that checks the subscriber passes the given test for a + "Paid Feature". - Redirects to `pay_page` if necessary. The test should be a callable - that takes the subscriber object and returns True if the subscriber passes. - """ + Redirects to `pay_page` if necessary. The test should be a callable + that takes the subscriber object and returns True if the subscriber passes. + """ - def decorator(view_func): - @wraps(view_func, assigned=available_attrs(view_func)) - def _wrapped_view(request, *args, **kwargs): - if test_func(subscriber_request_callback(request), plan): - return view_func(request, *args, **kwargs) + def decorator(view_func): + @wraps(view_func, assigned=available_attrs(view_func)) + def _wrapped_view(request, *args, **kwargs): + if test_func(subscriber_request_callback(request), plan): + return view_func(request, *args, **kwargs) - if not pay_page: - raise ImproperlyConfigured("DJSTRIPE_SUBSCRIPTION_REDIRECT is not set.") + if not pay_page: + raise ImproperlyConfigured("DJSTRIPE_SUBSCRIPTION_REDIRECT is not set.") - return redirect(pay_page) + return redirect(pay_page) - return _wrapped_view + return _wrapped_view - return decorator + return decorator def subscription_payment_required( - function=None, plan=None, pay_page=SUBSCRIPTION_REDIRECT + function=None, plan=None, pay_page=SUBSCRIPTION_REDIRECT ): - """ - Decorator for views that require subscription payment. - - Redirects to `pay_page` if necessary. - """ - actual_decorator = subscriber_passes_pay_test( - subscriber_has_active_subscription, plan=plan, pay_page=pay_page - ) - if function: - return actual_decorator(function) - return actual_decorator + """ + Decorator for views that require subscription payment. + + Redirects to `pay_page` if necessary. + """ + actual_decorator = subscriber_passes_pay_test( + subscriber_has_active_subscription, plan=plan, pay_page=pay_page + ) + if function: + return actual_decorator(function) + return actual_decorator diff --git a/djstripe/enums.py b/djstripe/enums.py index 50247969dc..65274f1b23 100644 --- a/djstripe/enums.py +++ b/djstripe/enums.py @@ -5,514 +5,516 @@ class EnumMetaClass(type): - @classmethod - def __prepare__(self, name, bases): - return OrderedDict() - - def __new__(self, name, bases, classdict): - members = [] - keys = {} - choices = OrderedDict() - for key, value in classdict.items(): - if key.startswith("__"): - continue - members.append(key) - if isinstance(value, tuple): - value, alias = value - keys[alias] = key - else: - alias = None - keys[alias or key] = key - choices[alias or key] = value - - for k, v in keys.items(): - classdict[v] = k - - classdict["__choices__"] = choices - classdict["__members__"] = members - - # Note: Differences between Python 2.x and Python 3.x force us to - # explicitly use unicode here, and to explicitly sort the list. In - # Python 2.x, class members are unordered and so the ordering will - # vary on different systems based on internal hashing. Without this - # Django will continually require new no-op migrations. - classdict["choices"] = tuple( - (str(k), str(v)) for k, v in sorted(choices.items(), key=operator.itemgetter(0)) - ) - - return type.__new__(self, name, bases, classdict) + @classmethod + def __prepare__(self, name, bases): + return OrderedDict() + + def __new__(self, name, bases, classdict): + members = [] + keys = {} + choices = OrderedDict() + for key, value in classdict.items(): + if key.startswith("__"): + continue + members.append(key) + if isinstance(value, tuple): + value, alias = value + keys[alias] = key + else: + alias = None + keys[alias or key] = key + choices[alias or key] = value + + for k, v in keys.items(): + classdict[v] = k + + classdict["__choices__"] = choices + classdict["__members__"] = members + + # Note: Differences between Python 2.x and Python 3.x force us to + # explicitly use unicode here, and to explicitly sort the list. In + # Python 2.x, class members are unordered and so the ordering will + # vary on different systems based on internal hashing. Without this + # Django will continually require new no-op migrations. + classdict["choices"] = tuple( + (str(k), str(v)) + for k, v in sorted(choices.items(), key=operator.itemgetter(0)) + ) + + return type.__new__(self, name, bases, classdict) class Enum(metaclass=EnumMetaClass): - pass + pass class ApiErrorCode(Enum): - """ - Charge failure error codes. - - https://stripe.com/docs/error-codes - """ - - account_already_exists = _("Account already exists") - account_country_invalid_address = _("Account country invalid address") - account_invalid = _("Account invalid") - account_number_invalid = _("Account number invalid") - alipay_upgrade_required = _("Alipay upgrade required") - amount_too_large = _("Amount too large") - amount_too_small = _("Amount too small") - api_key_expired = _("Api key expired") - balance_insufficient = _("Balance insufficient") - bank_account_exists = _("Bank account exists") - bank_account_unusable = _("Bank account unusable") - bank_account_unverified = _("Bank account unverified") - bitcoin_upgrade_required = _("Bitcoin upgrade required") - card_declined = _("Card was declined") - charge_already_captured = _("Charge already captured") - charge_already_refunded = _("Charge already refunded") - charge_disputed = _("Charge disputed") - charge_exceeds_source_limit = _("Charge exceeds source limit") - charge_expired_for_capture = _("Charge expired for capture") - country_unsupported = _("Country unsupported") - coupon_expired = _("Coupon expired") - customer_max_subscriptions = _("Customer max subscriptions") - email_invalid = _("Email invalid") - expired_card = _("Expired card") - idempotency_key_in_use = _("Idempotency key in use") - incorrect_address = _("Incorrect address") - incorrect_cvc = _("Incorrect security code") - incorrect_number = _("Incorrect number") - incorrect_zip = _("ZIP code failed validation") - instant_payouts_unsupported = _("Instant payouts unsupported") - invalid_card_type = _("Invalid card type") - invalid_charge_amount = _("Invalid charge amount") - invalid_cvc = _("Invalid security code") - invalid_expiry_month = _("Invalid expiration month") - invalid_expiry_year = _("Invalid expiration year") - invalid_number = _("Invalid number") - invalid_source_usage = _("Invalid source usage") - invoice_no_customer_line_items = _("Invoice no customer line items") - invoice_no_subscription_line_items = _("Invoice no subscription line items") - invoice_not_editable = _("Invoice not editable") - invoice_upcoming_none = _("Invoice upcoming none") - livemode_mismatch = _("Livemode mismatch") - missing = _("No card being charged") - not_allowed_on_standard_account = _("Not allowed on standard account") - order_creation_failed = _("Order creation failed") - order_required_settings = _("Order required settings") - order_status_invalid = _("Order status invalid") - order_upstream_timeout = _("Order upstream timeout") - out_of_inventory = _("Out of inventory") - parameter_invalid_empty = _("Parameter invalid empty") - parameter_invalid_integer = _("Parameter invalid integer") - parameter_invalid_string_blank = _("Parameter invalid string blank") - parameter_invalid_string_empty = _("Parameter invalid string empty") - parameter_missing = _("Parameter missing") - parameter_unknown = _("Parameter unknown") - parameters_exclusive = _("Parameters exclusive") - payment_intent_authentication_failure = _("Payment intent authentication failure") - payment_intent_incompatible_payment_method = _( - "Payment intent incompatible payment method" - ) - payment_intent_invalid_parameter = _("Payment intent invalid parameter") - payment_intent_payment_attempt_failed = _("Payment intent payment attempt failed") - payment_intent_unexpected_state = _("Payment intent unexpected state") - payment_method_unactivated = _("Payment method unactivated") - payment_method_unexpected_state = _("Payment method unexpected state") - payouts_not_allowed = _("Payouts not allowed") - platform_api_key_expired = _("Platform api key expired") - postal_code_invalid = _("Postal code invalid") - processing_error = _("Processing error") - product_inactive = _("Product inactive") - rate_limit = _("Rate limit") - resource_already_exists = _("Resource already exists") - resource_missing = _("Resource missing") - routing_number_invalid = _("Routing number invalid") - secret_key_required = _("Secret key required") - sepa_unsupported_account = _("SEPA unsupported account") - shipping_calculation_failed = _("Shipping calculation failed") - sku_inactive = _("SKU inactive") - state_unsupported = _("State unsupported") - tax_id_invalid = _("Tax id invalid") - taxes_calculation_failed = _("Taxes calculation failed") - testmode_charges_only = _("Testmode charges only") - tls_version_unsupported = _("TLS version unsupported") - token_already_used = _("Token already used") - token_in_use = _("Token in use") - transfers_not_allowed = _("Transfers not allowed") - upstream_order_creation_failed = _("Upstream order creation failed") - url_invalid = _("URL invalid") - - # deprecated - invalid_swipe_data = _("Invalid swipe data") + """ + Charge failure error codes. + + https://stripe.com/docs/error-codes + """ + + account_already_exists = _("Account already exists") + account_country_invalid_address = _("Account country invalid address") + account_invalid = _("Account invalid") + account_number_invalid = _("Account number invalid") + alipay_upgrade_required = _("Alipay upgrade required") + amount_too_large = _("Amount too large") + amount_too_small = _("Amount too small") + api_key_expired = _("Api key expired") + balance_insufficient = _("Balance insufficient") + bank_account_exists = _("Bank account exists") + bank_account_unusable = _("Bank account unusable") + bank_account_unverified = _("Bank account unverified") + bitcoin_upgrade_required = _("Bitcoin upgrade required") + card_declined = _("Card was declined") + charge_already_captured = _("Charge already captured") + charge_already_refunded = _("Charge already refunded") + charge_disputed = _("Charge disputed") + charge_exceeds_source_limit = _("Charge exceeds source limit") + charge_expired_for_capture = _("Charge expired for capture") + country_unsupported = _("Country unsupported") + coupon_expired = _("Coupon expired") + customer_max_subscriptions = _("Customer max subscriptions") + email_invalid = _("Email invalid") + expired_card = _("Expired card") + idempotency_key_in_use = _("Idempotency key in use") + incorrect_address = _("Incorrect address") + incorrect_cvc = _("Incorrect security code") + incorrect_number = _("Incorrect number") + incorrect_zip = _("ZIP code failed validation") + instant_payouts_unsupported = _("Instant payouts unsupported") + invalid_card_type = _("Invalid card type") + invalid_charge_amount = _("Invalid charge amount") + invalid_cvc = _("Invalid security code") + invalid_expiry_month = _("Invalid expiration month") + invalid_expiry_year = _("Invalid expiration year") + invalid_number = _("Invalid number") + invalid_source_usage = _("Invalid source usage") + invoice_no_customer_line_items = _("Invoice no customer line items") + invoice_no_subscription_line_items = _("Invoice no subscription line items") + invoice_not_editable = _("Invoice not editable") + invoice_upcoming_none = _("Invoice upcoming none") + livemode_mismatch = _("Livemode mismatch") + missing = _("No card being charged") + not_allowed_on_standard_account = _("Not allowed on standard account") + order_creation_failed = _("Order creation failed") + order_required_settings = _("Order required settings") + order_status_invalid = _("Order status invalid") + order_upstream_timeout = _("Order upstream timeout") + out_of_inventory = _("Out of inventory") + parameter_invalid_empty = _("Parameter invalid empty") + parameter_invalid_integer = _("Parameter invalid integer") + parameter_invalid_string_blank = _("Parameter invalid string blank") + parameter_invalid_string_empty = _("Parameter invalid string empty") + parameter_missing = _("Parameter missing") + parameter_unknown = _("Parameter unknown") + parameters_exclusive = _("Parameters exclusive") + payment_intent_authentication_failure = _("Payment intent authentication failure") + payment_intent_incompatible_payment_method = _( + "Payment intent incompatible payment method" + ) + payment_intent_invalid_parameter = _("Payment intent invalid parameter") + payment_intent_payment_attempt_failed = _("Payment intent payment attempt failed") + payment_intent_unexpected_state = _("Payment intent unexpected state") + payment_method_unactivated = _("Payment method unactivated") + payment_method_unexpected_state = _("Payment method unexpected state") + payouts_not_allowed = _("Payouts not allowed") + platform_api_key_expired = _("Platform api key expired") + postal_code_invalid = _("Postal code invalid") + processing_error = _("Processing error") + product_inactive = _("Product inactive") + rate_limit = _("Rate limit") + resource_already_exists = _("Resource already exists") + resource_missing = _("Resource missing") + routing_number_invalid = _("Routing number invalid") + secret_key_required = _("Secret key required") + sepa_unsupported_account = _("SEPA unsupported account") + shipping_calculation_failed = _("Shipping calculation failed") + sku_inactive = _("SKU inactive") + state_unsupported = _("State unsupported") + tax_id_invalid = _("Tax id invalid") + taxes_calculation_failed = _("Taxes calculation failed") + testmode_charges_only = _("Testmode charges only") + tls_version_unsupported = _("TLS version unsupported") + token_already_used = _("Token already used") + token_in_use = _("Token in use") + transfers_not_allowed = _("Transfers not allowed") + upstream_order_creation_failed = _("Upstream order creation failed") + url_invalid = _("URL invalid") + + # deprecated + invalid_swipe_data = _("Invalid swipe data") class AccountType(Enum): - standard = _("Standard") - express = _("Express") - custom = _("Custom") + standard = _("Standard") + express = _("Express") + custom = _("Custom") class BalanceTransactionStatus(Enum): - available = _("Available") - pending = _("Pending") + available = _("Available") + pending = _("Pending") class BalanceTransactionType(Enum): - adjustment = _("Adjustment") - application_fee = _("Application fee") - application_fee_refund = _("Application fee refund") - charge = _("Charge") - network_cost = _("Network cost") - payment = _("Payment") - payment_failure_refund = _("Payment failure refund") - payment_refund = _("Payment refund") - payout = _("Payout") - payout_cancel = _("Payout cancellation") - payout_failure = _("Payout failure") - refund = _("Refund") - stripe_fee = _("Stripe fee") - transfer = _("Transfer") - transfer_refund = _("Transfer refund") - validation = _("Validation") + adjustment = _("Adjustment") + application_fee = _("Application fee") + application_fee_refund = _("Application fee refund") + charge = _("Charge") + network_cost = _("Network cost") + payment = _("Payment") + payment_failure_refund = _("Payment failure refund") + payment_refund = _("Payment refund") + payout = _("Payout") + payout_cancel = _("Payout cancellation") + payout_failure = _("Payout failure") + refund = _("Refund") + stripe_fee = _("Stripe fee") + transfer = _("Transfer") + transfer_refund = _("Transfer refund") + validation = _("Validation") class BankAccountHolderType(Enum): - individual = _("Individual") - company = _("Company") + individual = _("Individual") + company = _("Company") class BankAccountStatus(Enum): - new = _("New") - validated = _("Validated") - verified = _("Verified") - verification_failed = _("Verification failed") - errored = _("Errored") + new = _("New") + validated = _("Validated") + verified = _("Verified") + verification_failed = _("Verification failed") + errored = _("Errored") class BusinessType(Enum): - individual = _("Individual") - company = _("Company") + individual = _("Individual") + company = _("Company") class CaptureMethod(Enum): - automatic = _("Automatic") - manual = _("Manual") + automatic = _("Automatic") + manual = _("Manual") class CardCheckResult(Enum): - pass_ = (_("Pass"), "pass") - fail = _("Fail") - unavailable = _("Unavailable") - unchecked = _("Unchecked") + pass_ = (_("Pass"), "pass") + fail = _("Fail") + unavailable = _("Unavailable") + unchecked = _("Unchecked") class CardBrand(Enum): - AmericanExpress = (_("American Express"), "American Express") - DinersClub = (_("Diners Club"), "Diners Club") - Discover = _("Discover") - JCB = _("JCB") - MasterCard = _("MasterCard") - UnionPay = _("UnionPay") - Visa = _("Visa") - Unknown = _("Unknown") + AmericanExpress = (_("American Express"), "American Express") + DinersClub = (_("Diners Club"), "Diners Club") + Discover = _("Discover") + JCB = _("JCB") + MasterCard = _("MasterCard") + UnionPay = _("UnionPay") + Visa = _("Visa") + Unknown = _("Unknown") class CardFundingType(Enum): - credit = _("Credit") - debit = _("Debit") - prepaid = _("Prepaid") - unknown = _("Unknown") + credit = _("Credit") + debit = _("Debit") + prepaid = _("Prepaid") + unknown = _("Unknown") class CardTokenizationMethod(Enum): - apple_pay = _("Apple Pay") - android_pay = _("Android Pay") + apple_pay = _("Apple Pay") + android_pay = _("Android Pay") class ChargeStatus(Enum): - succeeded = _("Succeeded") - pending = _("Pending") - failed = _("Failed") + succeeded = _("Succeeded") + pending = _("Pending") + failed = _("Failed") class ConfirmationMethod(Enum): - automatic = _("Automatic") - manual = _("Manual") + automatic = _("Automatic") + manual = _("Manual") class CouponDuration(Enum): - once = _("Once") - repeating = _("Multi-month") - forever = _("Forever") + once = _("Once") + repeating = _("Multi-month") + forever = _("Forever") class DisputeReason(Enum): - duplicate = _("Duplicate") - fraudulent = _("Fraudulent") - subscription_canceled = _("Subscription canceled") - product_unacceptable = _("Product unacceptable") - product_not_received = _("Product not received") - unrecognized = _("Unrecognized") - credit_not_processed = _("Credit not processed") - general = _("General") - incorrect_account_details = _("Incorrect account details") - insufficient_funds = _("Insufficient funds") - bank_cannot_process = _("Bank cannot process") - debit_not_authorized = _("Debit not authorized") - customer_initiated = _("Customer-initiated") + duplicate = _("Duplicate") + fraudulent = _("Fraudulent") + subscription_canceled = _("Subscription canceled") + product_unacceptable = _("Product unacceptable") + product_not_received = _("Product not received") + unrecognized = _("Unrecognized") + credit_not_processed = _("Credit not processed") + general = _("General") + incorrect_account_details = _("Incorrect account details") + insufficient_funds = _("Insufficient funds") + bank_cannot_process = _("Bank cannot process") + debit_not_authorized = _("Debit not authorized") + customer_initiated = _("Customer-initiated") class DisputeStatus(Enum): - warning_needs_response = _("Warning needs response") - warning_under_review = _("Warning under review") - warning_closed = _("Warning closed") - needs_response = _("Needs response") - under_review = _("Under review") - charge_refunded = _("Charge refunded") - won = _("Won") - lost = _("Lost") + warning_needs_response = _("Warning needs response") + warning_under_review = _("Warning under review") + warning_closed = _("Warning closed") + needs_response = _("Needs response") + under_review = _("Under review") + charge_refunded = _("Charge refunded") + won = _("Won") + lost = _("Lost") class FileUploadPurpose(Enum): - dispute_evidence = _("Dispute evidence") - identity_document = _("Identity document") - tax_document_user_upload = _("Tax document user upload") + dispute_evidence = _("Dispute evidence") + identity_document = _("Identity document") + tax_document_user_upload = _("Tax document user upload") class FileUploadType(Enum): - pdf = _("PDF") - jpg = _("JPG") - png = _("PNG") - csv = _("CSV") - xls = _("XLS") - xlsx = _("XLSX") - docx = _("DOCX") + pdf = _("PDF") + jpg = _("JPG") + png = _("PNG") + csv = _("CSV") + xls = _("XLS") + xlsx = _("XLSX") + docx = _("DOCX") class InvoiceBilling(Enum): - charge_automatically = _("Charge automatically") - send_invoice = _("Send invoice") + charge_automatically = _("Charge automatically") + send_invoice = _("Send invoice") class IntentUsage(Enum): - on_session = _("On session") - off_session = _("Off session") + on_session = _("On session") + off_session = _("Off session") class IntentStatus(Enum): - """ - Status of Intents which apply both to PaymentIntents - and SetupIntents. - """ - - requires_payment_method = _( - "Intent created and requires a Payment Method to be attached." - ) - requires_confirmation = _("Intent is ready to be confirmed.") - requires_action = _("Payment Method require additional action, such as 3D secure.") - processing = _("Required actions have been handled.") - canceled = _( - "Cancellation invalidates the intent for future confirmation and cannot be undone." - ) + """ + Status of Intents which apply both to PaymentIntents + and SetupIntents. + """ + + requires_payment_method = _( + "Intent created and requires a Payment Method to be attached." + ) + requires_confirmation = _("Intent is ready to be confirmed.") + requires_action = _("Payment Method require additional action, such as 3D secure.") + processing = _("Required actions have been handled.") + canceled = _( + "Cancellation invalidates the intent for future confirmation and " + "cannot be undone." + ) class PaymentIntentStatus(IntentStatus): - succeeded = _("The funds are in your account.") - requires_capture = _("Capture the funds on the cards which have been put on holds.") + succeeded = _("The funds are in your account.") + requires_capture = _("Capture the funds on the cards which have been put on holds.") class SetupIntentStatus(IntentStatus): - succeeded = _( - "Setup was successful and the payment method is optimized for future payments." - ) + succeeded = _( + "Setup was successful and the payment method is optimized for future payments." + ) class PayoutFailureCode(Enum): - """ - Payout failure error codes. + """ + Payout failure error codes. - https://stripe.com/docs/api#payout_failures - """ + https://stripe.com/docs/api#payout_failures + """ - account_closed = _("Bank account has been closed.") - account_frozen = _("Bank account has been frozen.") - bank_account_restricted = _("Bank account has restrictions on payouts allowed.") - bank_ownership_changed = _("Destination bank account has changed ownership.") - could_not_process = _("Bank could not process payout.") - debit_not_authorized = _("Debit transactions not approved on the bank account.") - insufficient_funds = _("Stripe account has insufficient funds.") - invalid_account_number = _("Invalid account number") - invalid_currency = _("Bank account does not support currency.") - no_account = _("Bank account could not be located.") - unsupported_card = _("Card no longer supported.") + account_closed = _("Bank account has been closed.") + account_frozen = _("Bank account has been frozen.") + bank_account_restricted = _("Bank account has restrictions on payouts allowed.") + bank_ownership_changed = _("Destination bank account has changed ownership.") + could_not_process = _("Bank could not process payout.") + debit_not_authorized = _("Debit transactions not approved on the bank account.") + insufficient_funds = _("Stripe account has insufficient funds.") + invalid_account_number = _("Invalid account number") + invalid_currency = _("Bank account does not support currency.") + no_account = _("Bank account could not be located.") + unsupported_card = _("Card no longer supported.") class PayoutMethod(Enum): - standard = _("Standard") - instant = _("Instant") + standard = _("Standard") + instant = _("Instant") class PayoutStatus(Enum): - paid = _("Paid") - pending = _("Pending") - in_transit = _("In transit") - canceled = _("Canceled") - failed = _("Failed") + paid = _("Paid") + pending = _("Pending") + in_transit = _("In transit") + canceled = _("Canceled") + failed = _("Failed") class PayoutType(Enum): - bank_account = _("Bank account") - card = _("Card") + bank_account = _("Bank account") + card = _("Card") class PlanAggregateUsage(Enum): - last_during_period = _("Last during period") - last_ever = _("Last ever") - max = _("Max") - sum = _("Sum") + last_during_period = _("Last during period") + last_ever = _("Last ever") + max = _("Max") + sum = _("Sum") class PlanBillingScheme(Enum): - per_unit = _("Per unit") - tiered = _("Tiered") + per_unit = _("Per unit") + tiered = _("Tiered") class PlanInterval(Enum): - day = _("Day") - week = _("Week") - month = _("Month") - year = _("Year") + day = _("Day") + week = _("Week") + month = _("Month") + year = _("Year") class PlanUsageType(Enum): - metered = _("Metered") - licensed = _("Licensed") + metered = _("Metered") + licensed = _("Licensed") class PlanTiersMode(Enum): - graduated = _("Graduated") - volume = _("Volume-based") + graduated = _("Graduated") + volume = _("Volume-based") class ProductType(Enum): - good = _("Good") - service = _("Service") + good = _("Good") + service = _("Service") class ScheduledQueryRunStatus(Enum): - canceled = _("Canceled") - failed = _("Failed") - timed_out = _("Timed out") + canceled = _("Canceled") + failed = _("Failed") + timed_out = _("Timed out") class SourceFlow(Enum): - redirect = _("Redirect") - receiver = _("Receiver") - code_verification = _("Code verification") - none = _("None") + redirect = _("Redirect") + receiver = _("Receiver") + code_verification = _("Code verification") + none = _("None") class SourceStatus(Enum): - canceled = _("Canceled") - chargeable = _("Chargeable") - consumed = _("Consumed") - failed = _("Failed") - pending = _("Pending") + canceled = _("Canceled") + chargeable = _("Chargeable") + consumed = _("Consumed") + failed = _("Failed") + pending = _("Pending") class SourceType(Enum): - ach_credit_transfer = _("ACH Credit Transfer") - ach_debit = _("ACH Debit") - alipay = _("Alipay") - bancontact = _("Bancontact") - bitcoin = _("Bitcoin") - card = _("Card") - card_present = _("Card present") - eps = _("EPS") - giropay = _("Giropay") - ideal = _("iDEAL") - p24 = _("P24") - paper_check = _("Paper check") - sepa_debit = _("SEPA Direct Debit") - sepa_credit_transfer = _("SEPA credit transfer") - sofort = _("SOFORT") - three_d_secure = _("3D Secure") + ach_credit_transfer = _("ACH Credit Transfer") + ach_debit = _("ACH Debit") + alipay = _("Alipay") + bancontact = _("Bancontact") + bitcoin = _("Bitcoin") + card = _("Card") + card_present = _("Card present") + eps = _("EPS") + giropay = _("Giropay") + ideal = _("iDEAL") + p24 = _("P24") + paper_check = _("Paper check") + sepa_debit = _("SEPA Direct Debit") + sepa_credit_transfer = _("SEPA credit transfer") + sofort = _("SOFORT") + three_d_secure = _("3D Secure") class LegacySourceType(Enum): - card = _("Card") - bank_account = _("Bank account") - bitcoin_receiver = _("Bitcoin receiver") - alipay_account = _("Alipay account") + card = _("Card") + bank_account = _("Bank account") + bitcoin_receiver = _("Bitcoin receiver") + alipay_account = _("Alipay account") class RefundFailureReason(Enum): - lost_or_stolen_card = _("Lost or stolen card") - expired_or_canceled_card = _("Expired or canceled card") - unknown = _("Unknown") + lost_or_stolen_card = _("Lost or stolen card") + expired_or_canceled_card = _("Expired or canceled card") + unknown = _("Unknown") class RefundReason(Enum): - duplicate = _("Duplicate charge") - fraudulent = _("Fraudulent") - requested_by_customer = _("Requested by customer") + duplicate = _("Duplicate charge") + fraudulent = _("Fraudulent") + requested_by_customer = _("Requested by customer") class RefundStatus(Enum): - pending = _("Pending") - succeeded = _("Succeeded") - failed = _("Failed") - canceled = _("Canceled") + pending = _("Pending") + succeeded = _("Succeeded") + failed = _("Failed") + canceled = _("Canceled") class SourceUsage(Enum): - reusable = _("Reusable") - single_use = _("Single-use") + reusable = _("Reusable") + single_use = _("Single-use") class SourceCodeVerificationStatus(Enum): - pending = _("Pending") - succeeded = _("Succeeded") - failed = _("Failed") + pending = _("Pending") + succeeded = _("Succeeded") + failed = _("Failed") class SourceRedirectFailureReason(Enum): - user_abort = _("User-aborted") - declined = _("Declined") - processing_error = _("Processing error") + user_abort = _("User-aborted") + declined = _("Declined") + processing_error = _("Processing error") class SourceRedirectStatus(Enum): - pending = _("Pending") - succeeded = _("Succeeded") - not_required = _("Not required") - failed = _("Failed") + pending = _("Pending") + succeeded = _("Succeeded") + not_required = _("Not required") + failed = _("Failed") class SubmitTypeStatus(Enum): - auto = _("Auto") - book = _("Book") - donate = _("donate") - pay = _("pay") + auto = _("Auto") + book = _("Book") + donate = _("donate") + pay = _("pay") class SubscriptionStatus(Enum): - trialing = _("Trialing") - active = _("Active") - past_due = _("Past due") - canceled = _("Canceled") - unpaid = _("Unpaid") + trialing = _("Trialing") + active = _("Active") + past_due = _("Past due") + canceled = _("Canceled") + unpaid = _("Unpaid") class DjstripePaymentMethodType(Enum): - """ - A djstripe-specific enum for the DjStripePaymentMethod model. - """ + """ + A djstripe-specific enum for the DjStripePaymentMethod model. + """ - card = _("Card") - bank_account = _("Bank account") - source = _("Source") + card = _("Card") + bank_account = _("Bank account") + source = _("Source") # Alias (Deprecated, remove in 2.2.0) diff --git a/djstripe/event_handlers.py b/djstripe/event_handlers.py index b9510c0f44..32bf2bd3e8 100644 --- a/djstripe/event_handlers.py +++ b/djstripe/event_handlers.py @@ -4,12 +4,13 @@ Stripe docs for Events: https://stripe.com/docs/api/events Stripe docs for Webhooks: https://stripe.com/docs/webhooks -TODO: Implement webhook event handlers for all the models that need to respond to webhook events. +TODO: Implement webhook event handlers for all the models that need to + respond to webhook events. NOTE: - Event data is not guaranteed to be in the correct API version format. - See #116. When writing a webhook handler, make sure to first - re-retrieve the object you wish to process. + Event data is not guaranteed to be in the correct API version format. + See #116. When writing a webhook handler, make sure to first + re-retrieve the object you wish to process. """ import logging @@ -23,134 +24,146 @@ @webhooks.handler("customer") def customer_webhook_handler(event): - """Handle updates to customer objects. + """Handle updates to customer objects. - First determines the crud_type and then handles the event if a customer exists locally. - As customers are tied to local users, djstripe will not create customers that - do not already exist locally. + First determines the crud_type and then handles the event if a customer + exists locally. + As customers are tied to local users, djstripe will not create customers that + do not already exist locally. - Docs and an example customer webhook response: https://stripe.com/docs/api#customer_object - """ - if event.customer: - # As customers are tied to local users, djstripe will not create - # customers that do not already exist locally. - _handle_crud_like_event( - target_cls=models.Customer, event=event, crud_exact=True, crud_valid=True - ) + Docs and an example customer webhook response: + https://stripe.com/docs/api#customer_object + """ + if event.customer: + # As customers are tied to local users, djstripe will not create + # customers that do not already exist locally. + _handle_crud_like_event( + target_cls=models.Customer, event=event, crud_exact=True, crud_valid=True + ) @webhooks.handler("customer.discount") def customer_discount_webhook_handler(event): - """Handle updates to customer discount objects. - - Docs: https://stripe.com/docs/api#discounts - - Because there is no concept of a "Discount" model in dj-stripe (due to the - lack of a stripe id on them), this is a little different to the other - handlers. - """ - - crud_type = CrudType.determine(event=event) - discount_data = event.data.get("object", {}) - coupon_data = discount_data.get("coupon", {}) - customer = event.customer - - if crud_type.created or crud_type.updated: - coupon, _ = _handle_crud_like_event( - target_cls=models.Coupon, event=event, data=coupon_data, id=coupon_data.get("id") - ) - coupon_start = discount_data.get("start") - coupon_end = discount_data.get("end") - else: - coupon = None - coupon_start = None - coupon_end = None - - customer.coupon = coupon - customer.coupon_start = convert_tstamp(coupon_start) - customer.coupon_end = convert_tstamp(coupon_end) - customer.save() + """Handle updates to customer discount objects. + + Docs: https://stripe.com/docs/api#discounts + + Because there is no concept of a "Discount" model in dj-stripe (due to the + lack of a stripe id on them), this is a little different to the other + handlers. + """ + + crud_type = CrudType.determine(event=event) + discount_data = event.data.get("object", {}) + coupon_data = discount_data.get("coupon", {}) + customer = event.customer + + if crud_type.created or crud_type.updated: + coupon, _ = _handle_crud_like_event( + target_cls=models.Coupon, + event=event, + data=coupon_data, + id=coupon_data.get("id"), + ) + coupon_start = discount_data.get("start") + coupon_end = discount_data.get("end") + else: + coupon = None + coupon_start = None + coupon_end = None + + customer.coupon = coupon + customer.coupon_start = convert_tstamp(coupon_start) + customer.coupon_end = convert_tstamp(coupon_end) + customer.save() @webhooks.handler("customer.source") def customer_source_webhook_handler(event): - """Handle updates to customer payment-source objects. - - Docs: https://stripe.com/docs/api#customer_object-sources. - """ - customer_data = event.data.get("object", {}) - source_type = customer_data.get("object", {}) - - # TODO: handle other types of sources (https://stripe.com/docs/api#customer_object-sources) - if source_type == SourceType.card: - if event.verb.endswith("deleted") and customer_data: - # On customer.source.deleted, we do not delete the object, we merely unlink it. - # customer = Customer.objects.get(id=customer_data["id"]) - # NOTE: for now, customer.sources still points to Card - # Also, https://github.com/dj-stripe/dj-stripe/issues/576 - models.Card.objects.filter(id=customer_data.get("id", "")).delete() - models.DjstripePaymentMethod.objects.filter(id=customer_data.get("id", "")).delete() - else: - _handle_crud_like_event(target_cls=models.Card, event=event) + """Handle updates to customer payment-source objects. + + Docs: https://stripe.com/docs/api#customer_object-sources. + """ + customer_data = event.data.get("object", {}) + source_type = customer_data.get("object", {}) + + # TODO: handle other types of sources + # (https://stripe.com/docs/api#customer_object-sources) + if source_type == SourceType.card: + if event.verb.endswith("deleted") and customer_data: + # On customer.source.deleted, we do not delete the object, + # we merely unlink it. + # customer = Customer.objects.get(id=customer_data["id"]) + # NOTE: for now, customer.sources still points to Card + # Also, https://github.com/dj-stripe/dj-stripe/issues/576 + models.Card.objects.filter(id=customer_data.get("id", "")).delete() + models.DjstripePaymentMethod.objects.filter( + id=customer_data.get("id", "") + ).delete() + else: + _handle_crud_like_event(target_cls=models.Card, event=event) @webhooks.handler("customer.subscription") def customer_subscription_webhook_handler(event): - """Handle updates to customer subscription objects. + """Handle updates to customer subscription objects. - Docs an example subscription webhook response: https://stripe.com/docs/api#subscription_object - """ - _handle_crud_like_event(target_cls=models.Subscription, event=event) + Docs an example subscription webhook response: + https://stripe.com/docs/api#subscription_object + """ + _handle_crud_like_event(target_cls=models.Subscription, event=event) @webhooks.handler( - "transfer", - "charge", - "coupon", - "invoice", - "invoiceitem", - "paymentintent", - "paymentmethod", - "plan", - "product", - "setupintent", - "source", + "transfer", + "charge", + "coupon", + "invoice", + "invoiceitem", + "paymentintent", + "paymentmethod", + "plan", + "product", + "setupintent", + "source", ) def other_object_webhook_handler(event): - """Handle updates to transfer, charge, invoice, invoiceitem, plan, product and source objects. - - Docs for: - - charge: https://stripe.com/docs/api#charges - - coupon: https://stripe.com/docs/api#coupons - - invoice: https://stripe.com/docs/api#invoices - - invoiceitem: https://stripe.com/docs/api#invoiceitems - - plan: https://stripe.com/docs/api#plans - - product: https://stripe.com/docs/api#products - - source: https://stripe.com/docs/api#sources - - payment_method: https://stripe.com/docs/api/payment_methods - - payment_intent: https://stripe.com/docs/api/payment_intents - """ - - if event.parts[:2] == ["charge", "dispute"]: - # Do not attempt to handle charge.dispute.* events. - # We do not have a Dispute model yet. - target_cls = models.Dispute - else: - target_cls = { - "charge": models.Charge, - "coupon": models.Coupon, - "invoice": models.Invoice, - "invoiceitem": models.InvoiceItem, - "paymentintent": models.PaymentIntent, - "paymentmethod": models.PaymentMethod, - "plan": models.Plan, - "product": models.Product, - "transfer": models.Transfer, - "setupintent": models.SetupIntent, - "source": models.Source, - }.get(event.category) - - _handle_crud_like_event(target_cls=target_cls, event=event) + """ + Handle updates to transfer, charge, invoice, invoiceitem, plan, product + and source objects. + + Docs for: + - charge: https://stripe.com/docs/api#charges + - coupon: https://stripe.com/docs/api#coupons + - invoice: https://stripe.com/docs/api#invoices + - invoiceitem: https://stripe.com/docs/api#invoiceitems + - plan: https://stripe.com/docs/api#plans + - product: https://stripe.com/docs/api#products + - source: https://stripe.com/docs/api#sources + - payment_method: https://stripe.com/docs/api/payment_methods + - payment_intent: https://stripe.com/docs/api/payment_intents + """ + + if event.parts[:2] == ["charge", "dispute"]: + # Do not attempt to handle charge.dispute.* events. + # We do not have a Dispute model yet. + target_cls = models.Dispute + else: + target_cls = { + "charge": models.Charge, + "coupon": models.Coupon, + "invoice": models.Invoice, + "invoiceitem": models.InvoiceItem, + "paymentintent": models.PaymentIntent, + "paymentmethod": models.PaymentMethod, + "plan": models.Plan, + "product": models.Product, + "transfer": models.Transfer, + "setupintent": models.SetupIntent, + "source": models.Source, + }.get(event.category) + + _handle_crud_like_event(target_cls=target_cls, event=event) # @@ -159,123 +172,127 @@ def other_object_webhook_handler(event): class CrudType(object): - """Helper object to determine CRUD-like event state.""" - - created = False - updated = False - deleted = False - - def __init__(self, **kwargs): - """Set attributes.""" - for k, v in kwargs.items(): - setattr(self, k, v) - - @property - def valid(self): - """Return True if this is a CRUD-like event.""" - return self.created or self.updated or self.deleted - - @classmethod - def determine(cls, event, verb=None, exact=False): - """ - Determine if the event verb is a crud_type (without the 'R') event. - - :param verb: The event verb to examine. - :type verb: string (``str``/`unicode``) - :param exact: If True, match crud_type to event verb string exactly. - :param type: ``bool`` - :returns: The CrudType state object. - :rtype: ``CrudType`` - """ - verb = verb or event.verb - - def check(crud_type_event): - if exact: - return verb == crud_type_event - else: - return verb.endswith(crud_type_event) - - created = updated = deleted = False - - if check("updated"): - updated = True - elif check("created"): - created = True - elif check("deleted"): - deleted = True - - return cls(created=created, updated=updated, deleted=deleted) + """Helper object to determine CRUD-like event state.""" + + created = False + updated = False + deleted = False + + def __init__(self, **kwargs): + """Set attributes.""" + for k, v in kwargs.items(): + setattr(self, k, v) + + @property + def valid(self): + """Return True if this is a CRUD-like event.""" + return self.created or self.updated or self.deleted + + @classmethod + def determine(cls, event, verb=None, exact=False): + """ + Determine if the event verb is a crud_type (without the 'R') event. + + :param verb: The event verb to examine. + :type verb: string (``str``/`unicode``) + :param exact: If True, match crud_type to event verb string exactly. + :param type: ``bool`` + :returns: The CrudType state object. + :rtype: ``CrudType`` + """ + verb = verb or event.verb + + def check(crud_type_event): + if exact: + return verb == crud_type_event + else: + return verb.endswith(crud_type_event) + + created = updated = deleted = False + + if check("updated"): + updated = True + elif check("created"): + created = True + elif check("deleted"): + deleted = True + + return cls(created=created, updated=updated, deleted=deleted) def _handle_crud_like_event( - target_cls, - event, - data=None, - verb=None, - id=None, - customer=None, - crud_type=None, - crud_exact=False, - crud_valid=False, + target_cls, + event, + data=None, + verb=None, + id=None, + customer=None, + crud_type=None, + crud_exact=False, + crud_valid=False, ): - """ - Helper to process crud_type-like events for objects. - - Non-deletes (creates, updates and "anything else" events) are treated as - update_or_create events - The object will be retrieved locally, then it is - synchronised with the Stripe API for parity. - - Deletes only occur for delete events and cause the object to be deleted - from the local database, if it existed. If it doesn't exist then it is - ignored (but the event processing still succeeds). - - :param target_cls: The djstripe model being handled. - :type: ``djstripe.models.StripeModel`` - :param data: The event object data (defaults to ``event.data``). - :param verb: The event verb (defaults to ``event.verb``). - :param id: The object Stripe ID (defaults to ``object.id``). - :param customer: The customer object (defaults to ``event.customer``). - :param crud_type: The CrudType object (determined by default). - :param crud_exact: If True, match verb against CRUD type exactly. - :param crud_valid: If True, CRUD type must match valid type. - :returns: The object (if any) and the event CrudType. - :rtype: ``tuple(obj, CrudType)`` - """ - data = data or event.data - id = id or data.get("object", {}).get("id", None) - - if not id: - # We require an object when applying CRUD-like events, so if there's - # no ID the event is ignored/dropped. This happens in events such as - # invoice.upcoming, which refer to a future (non-existant) invoice. - logger.debug("Ignoring %r Stripe event without object ID: %r", event.type, event) - return - - verb = verb or event.verb - customer = customer or event.customer - crud_type = crud_type or CrudType.determine(event=event, verb=verb, exact=crud_exact) - obj = None - - if crud_valid and not crud_type.valid: - logger.debug( - "Ignoring %r Stripe event without valid CRUD type: %r", event.type, event - ) - return - - if crud_type.deleted: - qs = target_cls.objects.filter(id=id) - if target_cls is models.Customer and qs.exists(): - qs.get().purge() - else: - obj = target_cls.objects.filter(id=id).delete() - else: - # Any other event type (creates, updates, etc.) - This can apply to - # verbs that aren't strictly CRUD but Stripe do intend an update. Such - # as invoice.payment_failed. - kwargs = {"id": id} - if hasattr(target_cls, "customer"): - kwargs["customer"] = customer - data = target_cls(**kwargs).api_retrieve() - obj = target_cls.sync_from_stripe_data(data) - - return obj, crud_type + """ + Helper to process crud_type-like events for objects. + + Non-deletes (creates, updates and "anything else" events) are treated as + update_or_create events - The object will be retrieved locally, then it is + synchronised with the Stripe API for parity. + + Deletes only occur for delete events and cause the object to be deleted + from the local database, if it existed. If it doesn't exist then it is + ignored (but the event processing still succeeds). + + :param target_cls: The djstripe model being handled. + :type: ``djstripe.models.StripeModel`` + :param data: The event object data (defaults to ``event.data``). + :param verb: The event verb (defaults to ``event.verb``). + :param id: The object Stripe ID (defaults to ``object.id``). + :param customer: The customer object (defaults to ``event.customer``). + :param crud_type: The CrudType object (determined by default). + :param crud_exact: If True, match verb against CRUD type exactly. + :param crud_valid: If True, CRUD type must match valid type. + :returns: The object (if any) and the event CrudType. + :rtype: ``tuple(obj, CrudType)`` + """ + data = data or event.data + id = id or data.get("object", {}).get("id", None) + + if not id: + # We require an object when applying CRUD-like events, so if there's + # no ID the event is ignored/dropped. This happens in events such as + # invoice.upcoming, which refer to a future (non-existant) invoice. + logger.debug( + "Ignoring %r Stripe event without object ID: %r", event.type, event + ) + return + + verb = verb or event.verb + customer = customer or event.customer + crud_type = crud_type or CrudType.determine( + event=event, verb=verb, exact=crud_exact + ) + obj = None + + if crud_valid and not crud_type.valid: + logger.debug( + "Ignoring %r Stripe event without valid CRUD type: %r", event.type, event + ) + return + + if crud_type.deleted: + qs = target_cls.objects.filter(id=id) + if target_cls is models.Customer and qs.exists(): + qs.get().purge() + else: + obj = target_cls.objects.filter(id=id).delete() + else: + # Any other event type (creates, updates, etc.) - This can apply to + # verbs that aren't strictly CRUD but Stripe do intend an update. Such + # as invoice.payment_failed. + kwargs = {"id": id} + if hasattr(target_cls, "customer"): + kwargs["customer"] = customer + data = target_cls(**kwargs).api_retrieve() + obj = target_cls.sync_from_stripe_data(data) + + return obj, crud_type diff --git a/djstripe/exceptions.py b/djstripe/exceptions.py index 602e727811..3940b40134 100644 --- a/djstripe/exceptions.py +++ b/djstripe/exceptions.py @@ -4,12 +4,15 @@ class MultipleSubscriptionException(Exception): - """Raised when a Customer has multiple Subscriptions and only one is expected.""" + """Raised when a Customer has multiple Subscriptions and only one is expected.""" - pass + pass class StripeObjectManipulationException(Exception): - """Raised when an attempt to manipulate a non-standalone stripe object is made not through its parent object.""" + """ + Raised when an attempt to manipulate a non-standalone stripe object is made + not through its parent object. + """ - pass + pass diff --git a/djstripe/fields.py b/djstripe/fields.py index 0aa0608b42..3955228415 100644 --- a/djstripe/fields.py +++ b/djstripe/fields.py @@ -10,113 +10,113 @@ from .utils import convert_tstamp if USE_NATIVE_JSONFIELD: - from django.contrib.postgres.fields import JSONField as BaseJSONField + from django.contrib.postgres.fields import JSONField as BaseJSONField else: - from jsonfield import JSONField as BaseJSONField + from jsonfield import JSONField as BaseJSONField class PaymentMethodForeignKey(models.ForeignKey): - def __init__(self, **kwargs): - kwargs.setdefault("to", "DjstripePaymentMethod") - super().__init__(**kwargs) + def __init__(self, **kwargs): + kwargs.setdefault("to", "DjstripePaymentMethod") + super().__init__(**kwargs) class StripePercentField(models.DecimalField): - """A field used to define a percent according to djstripe logic.""" + """A field used to define a percent according to djstripe logic.""" - def __init__(self, *args, **kwargs): - """Assign default args to this field.""" - defaults = { - "decimal_places": 2, - "max_digits": 5, - "validators": [MinValueValidator(1), MaxValueValidator(100)], - } - defaults.update(kwargs) - super().__init__(*args, **defaults) + def __init__(self, *args, **kwargs): + """Assign default args to this field.""" + defaults = { + "decimal_places": 2, + "max_digits": 5, + "validators": [MinValueValidator(1), MaxValueValidator(100)], + } + defaults.update(kwargs) + super().__init__(*args, **defaults) class StripeCurrencyCodeField(models.CharField): - """ - A field used to store a three-letter currency code (eg. usd, eur, ...) - """ + """ + A field used to store a three-letter currency code (eg. usd, eur, ...) + """ - def __init__(self, *args, **kwargs): - defaults = {"max_length": 3, "help_text": "Three-letter ISO currency code"} - defaults.update(kwargs) - super().__init__(*args, **defaults) + def __init__(self, *args, **kwargs): + defaults = {"max_length": 3, "help_text": "Three-letter ISO currency code"} + defaults.update(kwargs) + super().__init__(*args, **defaults) class StripeQuantumCurrencyAmountField(models.IntegerField): - pass + pass class StripeDecimalCurrencyAmountField(models.DecimalField): - """ - A field used to define currency according to djstripe logic. + """ + A field used to define currency according to djstripe logic. - Stripe is always in cents. djstripe stores everything in dollars. - """ + Stripe is always in cents. djstripe stores everything in dollars. + """ - def __init__(self, *args, **kwargs): - """Assign default args to this field.""" - defaults = {"decimal_places": 2, "max_digits": 8} - defaults.update(kwargs) - super().__init__(*args, **defaults) + def __init__(self, *args, **kwargs): + """Assign default args to this field.""" + defaults = {"decimal_places": 2, "max_digits": 8} + defaults.update(kwargs) + super().__init__(*args, **defaults) - def stripe_to_db(self, data): - """Convert the raw value to decimal representation.""" - val = data.get(self.name) + def stripe_to_db(self, data): + """Convert the raw value to decimal representation.""" + val = data.get(self.name) - # Note: 0 is a possible return value, which is 'falseish' - if val is not None: - return val / decimal.Decimal("100") + # Note: 0 is a possible return value, which is 'falseish' + if val is not None: + return val / decimal.Decimal("100") class StripeEnumField(models.CharField): - def __init__(self, enum, *args, **kwargs): - self.enum = enum - choices = enum.choices - defaults = {"choices": choices, "max_length": max(len(k) for k, v in choices)} - defaults.update(kwargs) - super().__init__(*args, **defaults) + def __init__(self, enum, *args, **kwargs): + self.enum = enum + choices = enum.choices + defaults = {"choices": choices, "max_length": max(len(k) for k, v in choices)} + defaults.update(kwargs) + super().__init__(*args, **defaults) - def deconstruct(self): - name, path, args, kwargs = super().deconstruct() - kwargs["enum"] = self.enum - del kwargs["choices"] - return name, path, args, kwargs + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + kwargs["enum"] = self.enum + del kwargs["choices"] + return name, path, args, kwargs class StripeIdField(models.CharField): - """A field with enough space to hold any stripe ID.""" + """A field with enough space to hold any stripe ID.""" - def __init__(self, *args, **kwargs): - """ - Assign default args to this field. + def __init__(self, *args, **kwargs): + """ + Assign default args to this field. - As per: https://stripe.com/docs/upgrades - You can safely assume object IDs we generate will never exceed 255 - characters, but you should be able to handle IDs of up to that - length. - """ - defaults = {"max_length": 255, "blank": False, "null": False} - defaults.update(kwargs) - super().__init__(*args, **defaults) + As per: https://stripe.com/docs/upgrades + You can safely assume object IDs we generate will never exceed 255 + characters, but you should be able to handle IDs of up to that + length. + """ + defaults = {"max_length": 255, "blank": False, "null": False} + defaults.update(kwargs) + super().__init__(*args, **defaults) class StripeDateTimeField(models.DateTimeField): - """A field used to define a DateTimeField value according to djstripe logic.""" + """A field used to define a DateTimeField value according to djstripe logic.""" - def stripe_to_db(self, data): - """Convert the raw timestamp value to a DateTime representation.""" - val = data.get(self.name) + def stripe_to_db(self, data): + """Convert the raw timestamp value to a DateTime representation.""" + val = data.get(self.name) - # Note: 0 is a possible return value, which is 'falseish' - if val is not None: - return convert_tstamp(val) + # Note: 0 is a possible return value, which is 'falseish' + if val is not None: + return convert_tstamp(val) class JSONField(BaseJSONField): - """A field used to define a JSONField value according to djstripe logic.""" + """A field used to define a JSONField value according to djstripe logic.""" - pass + pass diff --git a/djstripe/management/commands/djstripe_clear_expired_idempotency_keys.py b/djstripe/management/commands/djstripe_clear_expired_idempotency_keys.py index 27343c7c9f..f7f048795a 100644 --- a/djstripe/management/commands/djstripe_clear_expired_idempotency_keys.py +++ b/djstripe/management/commands/djstripe_clear_expired_idempotency_keys.py @@ -4,7 +4,7 @@ class Command(BaseCommand): - help = "Deleted expired Stripe idempotency keys." + help = "Deleted expired Stripe idempotency keys." - def handle(self, *args, **options): - clear_expired_idempotency_keys() + def handle(self, *args, **options): + clear_expired_idempotency_keys() diff --git a/djstripe/management/commands/djstripe_init_customers.py b/djstripe/management/commands/djstripe_init_customers.py index f9ae4cb3a8..36cbb5e1e6 100644 --- a/djstripe/management/commands/djstripe_init_customers.py +++ b/djstripe/management/commands/djstripe_init_customers.py @@ -8,13 +8,17 @@ class Command(BaseCommand): - """Create customer objects for existing subscribers that don't have one.""" + """Create customer objects for existing subscribers that don't have one.""" - help = "Create customer objects for existing subscribers that don't have one" + help = "Create customer objects for existing subscribers that don't have one" - def handle(self, *args, **options): - """Create Customer objects for Subscribers without Customer objects associated.""" - for subscriber in get_subscriber_model().objects.filter(djstripe_customers=None): - # use get_or_create in case of race conditions on large subscriber bases - Customer.get_or_create(subscriber=subscriber) - print("Created subscriber for {0}".format(subscriber.email)) + def handle(self, *args, **options): + """ + Create Customer objects for Subscribers without Customer objects associated. + """ + for subscriber in get_subscriber_model().objects.filter( + djstripe_customers=None + ): + # use get_or_create in case of race conditions on large subscriber bases + Customer.get_or_create(subscriber=subscriber) + print("Created subscriber for {0}".format(subscriber.email)) diff --git a/djstripe/management/commands/djstripe_sync_customers.py b/djstripe/management/commands/djstripe_sync_customers.py index 3f76704e30..ba93a1781b 100644 --- a/djstripe/management/commands/djstripe_sync_customers.py +++ b/djstripe/management/commands/djstripe_sync_customers.py @@ -8,21 +8,21 @@ class Command(BaseCommand): - """Sync subscriber data with stripe.""" + """Sync subscriber data with stripe.""" - help = "Sync subscriber data with stripe." + help = "Sync subscriber data with stripe." - def handle(self, *args, **options): - """Call sync_subscriber on Subscribers without customers associated to them.""" - qs = get_subscriber_model().objects.filter(djstripe_customers__isnull=True) - count = 0 - total = qs.count() - for subscriber in qs: - count += 1 - perc = int(round(100 * (float(count) / float(total)))) - print( - "[{0}/{1} {2}%] Syncing {3} [{4}]".format( - count, total, perc, subscriber.email, subscriber.pk - ) - ) - sync_subscriber(subscriber) + def handle(self, *args, **options): + """Call sync_subscriber on Subscribers without customers associated to them.""" + qs = get_subscriber_model().objects.filter(djstripe_customers__isnull=True) + count = 0 + total = qs.count() + for subscriber in qs: + count += 1 + perc = int(round(100 * (float(count) / float(total)))) + print( + "[{0}/{1} {2}%] Syncing {3} [{4}]".format( + count, total, perc, subscriber.email, subscriber.pk + ) + ) + sync_subscriber(subscriber) diff --git a/djstripe/management/commands/djstripe_sync_models.py b/djstripe/management/commands/djstripe_sync_models.py index 73684960a8..850b42d5f9 100644 --- a/djstripe/management/commands/djstripe_sync_models.py +++ b/djstripe/management/commands/djstripe_sync_models.py @@ -7,83 +7,94 @@ class Command(BaseCommand): - """Sync models from stripe.""" - - help = "Sync models from stripe." - - def add_arguments(self, parser): - parser.add_argument( - "args", - metavar="ModelName", - nargs="*", - help="restricts sync to these model names (default is to sync all supported models)", - ) - - def handle(self, *args, **options): - app_label = "djstripe" - app_config = apps.get_app_config(app_label) - model_list = [] # type: List[models.StripeModel] - - if args: - for model_label in args: - try: - model = app_config.get_model(model_label) - except LookupError: - raise CommandError("Unknown model: {}.{}".format(app_label, model_label)) - - model_list.append(model) - else: - model_list = app_config.get_models() - - for model in model_list: - self.sync_model(model) - - def sync_model(self, model): - model_name = model.__name__ - - if not issubclass(model, models.StripeModel): - print("Skipping {} (not a StripeModel)".format(model_name)) - return - - if model.stripe_class is None: - print("Skipping {} (no stripe_class)".format(model_name)) - return - - if not hasattr(model.stripe_class, "list"): - print("Skipping {} (no stripe_class.list)".format(model_name)) - return - - print("Syncing {}:".format(model_name)) - - try: - count = 0 - - if model is models.Account: - # special case, since own account isn't returned by Account.api_list - stripe_obj = models.Account.stripe_class.retrieve( - api_key=settings.STRIPE_SECRET_KEY - ) - count += 1 - djstripe_obj = model.sync_from_stripe_data(stripe_obj) - print( - " id={id}, pk={pk} ({djstripe_obj})".format( - id=djstripe_obj.id, pk=djstripe_obj.pk, djstripe_obj=djstripe_obj - ) - ) - - for stripe_obj in model.api_list(): - count += 1 - djstripe_obj = model.sync_from_stripe_data(stripe_obj) - print( - " id={id}, pk={pk} ({djstripe_obj})".format( - id=djstripe_obj.id, pk=djstripe_obj.pk, djstripe_obj=djstripe_obj - ) - ) - - if count == 0: - print(" (no results)") - else: - print(" Synced {count} {model_name}".format(count=count, model_name=model_name)) - - except Exception as e: - print(e) + """Sync models from stripe.""" + + help = "Sync models from stripe." + + def add_arguments(self, parser): + parser.add_argument( + "args", + metavar="ModelName", + nargs="*", + help="restricts sync to these model names (default is to sync all " + "supported models)", + ) + + def handle(self, *args, **options): + app_label = "djstripe" + app_config = apps.get_app_config(app_label) + model_list = [] # type: List[models.StripeModel] + + if args: + for model_label in args: + try: + model = app_config.get_model(model_label) + except LookupError: + raise CommandError( + "Unknown model: {}.{}".format(app_label, model_label) + ) + + model_list.append(model) + else: + model_list = app_config.get_models() + + for model in model_list: + self.sync_model(model) + + def sync_model(self, model): + model_name = model.__name__ + + if not issubclass(model, models.StripeModel): + print("Skipping {} (not a StripeModel)".format(model_name)) + return + + if model.stripe_class is None: + print("Skipping {} (no stripe_class)".format(model_name)) + return + + if not hasattr(model.stripe_class, "list"): + print("Skipping {} (no stripe_class.list)".format(model_name)) + return + + print("Syncing {}:".format(model_name)) + + try: + count = 0 + + if model is models.Account: + # special case, since own account isn't returned by Account.api_list + stripe_obj = models.Account.stripe_class.retrieve( + api_key=settings.STRIPE_SECRET_KEY + ) + count += 1 + djstripe_obj = model.sync_from_stripe_data(stripe_obj) + print( + " id={id}, pk={pk} ({djstripe_obj})".format( + id=djstripe_obj.id, + pk=djstripe_obj.pk, + djstripe_obj=djstripe_obj, + ) + ) + + for stripe_obj in model.api_list(): + count += 1 + djstripe_obj = model.sync_from_stripe_data(stripe_obj) + print( + " id={id}, pk={pk} ({djstripe_obj})".format( + id=djstripe_obj.id, + pk=djstripe_obj.pk, + djstripe_obj=djstripe_obj, + ) + ) + + if count == 0: + print(" (no results)") + else: + print( + " Synced {count} {model_name}".format( + count=count, model_name=model_name + ) + ) + + except Exception as e: + print(e) diff --git a/djstripe/management/commands/djstripe_sync_plans_from_stripe.py b/djstripe/management/commands/djstripe_sync_plans_from_stripe.py index 3b10acaa75..92d314b25e 100644 --- a/djstripe/management/commands/djstripe_sync_plans_from_stripe.py +++ b/djstripe/management/commands/djstripe_sync_plans_from_stripe.py @@ -7,12 +7,12 @@ class Command(BaseCommand): - """Sync plans from stripe.""" + """Sync plans from stripe.""" - help = "Sync plans from stripe." + help = "Sync plans from stripe." - def handle(self, *args, **options): - """Call sync_from_stripe_data for each plan returned by api_list.""" - for plan_data in Plan.api_list(): - plan = Plan.sync_from_stripe_data(plan_data) - print("Synchronized plan {0}".format(plan.id)) + def handle(self, *args, **options): + """Call sync_from_stripe_data for each plan returned by api_list.""" + for plan_data in Plan.api_list(): + plan = Plan.sync_from_stripe_data(plan_data) + print("Synchronized plan {0}".format(plan.id)) diff --git a/djstripe/managers.py b/djstripe/managers.py index 18312ca252..5c0f4a8d3f 100644 --- a/djstripe/managers.py +++ b/djstripe/managers.py @@ -7,84 +7,96 @@ class StripeModelManager(models.Manager): - """Manager used in StripeModel.""" + """Manager used in StripeModel.""" - pass + pass class SubscriptionManager(models.Manager): - """Manager used in models.Subscription.""" - - def started_during(self, year, month): - """Return Subscriptions not in trial status between a certain time range.""" - return self.exclude(status="trialing").filter(start__year=year, start__month=month) - - def active(self): - """Return active Subscriptions.""" - return self.filter(status="active") - - def canceled(self): - """Return canceled Subscriptions.""" - return self.filter(status="canceled") - - def canceled_during(self, year, month): - """Return Subscriptions canceled during a certain time range.""" - return self.canceled().filter(canceled_at__year=year, canceled_at__month=month) - - def started_plan_summary_for(self, year, month): - """Return started_during Subscriptions with plan counts annotated.""" - return ( - self.started_during(year, month) - .values("plan") - .order_by() - .annotate(count=models.Count("plan")) - ) - - def active_plan_summary(self): - """Return active Subscriptions with plan counts annotated.""" - return self.active().values("plan").order_by().annotate(count=models.Count("plan")) - - def canceled_plan_summary_for(self, year, month): - """Return Subscriptions canceled within a time range with plan counts annotated.""" - return ( - self.canceled_during(year, month) - .values("plan") - .order_by() - .annotate(count=models.Count("plan")) - ) - - def churn(self): - """Return number of canceled Subscriptions divided by active Subscriptions.""" - canceled = self.canceled().count() - active = self.active().count() - return decimal.Decimal(str(canceled)) / decimal.Decimal(str(active)) + """Manager used in models.Subscription.""" + + def started_during(self, year, month): + """Return Subscriptions not in trial status between a certain time range.""" + return self.exclude(status="trialing").filter( + start__year=year, start__month=month + ) + + def active(self): + """Return active Subscriptions.""" + return self.filter(status="active") + + def canceled(self): + """Return canceled Subscriptions.""" + return self.filter(status="canceled") + + def canceled_during(self, year, month): + """Return Subscriptions canceled during a certain time range.""" + return self.canceled().filter(canceled_at__year=year, canceled_at__month=month) + + def started_plan_summary_for(self, year, month): + """Return started_during Subscriptions with plan counts annotated.""" + return ( + self.started_during(year, month) + .values("plan") + .order_by() + .annotate(count=models.Count("plan")) + ) + + def active_plan_summary(self): + """Return active Subscriptions with plan counts annotated.""" + return ( + self.active().values("plan").order_by().annotate(count=models.Count("plan")) + ) + + def canceled_plan_summary_for(self, year, month): + """ + Return Subscriptions canceled within a time range with plan counts annotated. + """ + return ( + self.canceled_during(year, month) + .values("plan") + .order_by() + .annotate(count=models.Count("plan")) + ) + + def churn(self): + """Return number of canceled Subscriptions divided by active Subscriptions.""" + canceled = self.canceled().count() + active = self.active().count() + return decimal.Decimal(str(canceled)) / decimal.Decimal(str(active)) class TransferManager(models.Manager): - """Manager used by models.Transfer.""" + """Manager used by models.Transfer.""" - def during(self, year, month): - """Return Transfers between a certain time range.""" - return self.filter(created__year=year, created__month=month) + def during(self, year, month): + """Return Transfers between a certain time range.""" + return self.filter(created__year=year, created__month=month) - def paid_totals_for(self, year, month): - """Return paid Transfers during a certain year, month with total amounts annotated.""" - return self.during(year, month).aggregate(total_amount=models.Sum("amount")) + def paid_totals_for(self, year, month): + """ + Return paid Transfers during a certain year, month with total amounts annotated. + """ + return self.during(year, month).aggregate(total_amount=models.Sum("amount")) class ChargeManager(models.Manager): - """Manager used by models.Charge.""" - - def during(self, year, month): - """Return Charges between a certain time range based on `created`.""" - return self.filter(created__year=year, created__month=month) - - def paid_totals_for(self, year, month): - """Return paid Charges during a certain year, month with total amount, fee and refunded annotated.""" - return ( - self.during(year, month) - .filter(paid=True) - .aggregate( - total_amount=models.Sum("amount"), total_refunded=models.Sum("amount_refunded") - ) - ) + """Manager used by models.Charge.""" + + def during(self, year, month): + """Return Charges between a certain time range based on `created`.""" + return self.filter(created__year=year, created__month=month) + + def paid_totals_for(self, year, month): + """ + Return paid Charges during a certain year, month with total amount, + fee and refunded annotated. + """ + return ( + self.during(year, month) + .filter(paid=True) + .aggregate( + total_amount=models.Sum("amount"), + total_refunded=models.Sum("amount_refunded"), + ) + ) diff --git a/djstripe/middleware.py b/djstripe/middleware.py index 99549bc132..9e6cdacd4c 100644 --- a/djstripe/middleware.py +++ b/djstripe/middleware.py @@ -15,7 +15,7 @@ from .utils import subscriber_has_active_subscription DJSTRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS = getattr( - settings, "DJSTRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS", () + settings, "DJSTRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS", () ) @@ -25,73 +25,75 @@ class SubscriptionPaymentMiddleware(MiddlewareMixin): - """ - Used to redirect users from subcription-locked request destinations. - - Rules: - - * "(app_name)" means everything from this app is exempt - * "[namespace]" means everything with this name is exempt - * "namespace:name" means this namespaced URL is exempt - * "name" means this URL is exempt - * The entire djstripe namespace is exempt - * If settings.DEBUG is True, then django-debug-toolbar is exempt - * A 'fn:' prefix means the rest of the URL is fnmatch'd. - - Example:: - - DJSTRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS = ( - "[blogs]", # Anything in the blogs namespace - "products:detail", # A ProductDetail view you want shown to non-payers - "home", # Site homepage - "fn:/accounts*", # anything in the accounts/ URL path - ) - """ - - def process_request(self, request): - """Check the subscriber's subscription status. - - Returns early if request does not outlined in this middleware's docstring. - """ - if self.is_matching_rule(request): - return - - return self.check_subscription(request) - - def is_matching_rule(self, request): - """Check according to the rules defined in the class docstring.""" - # First, if in DEBUG mode and with django-debug-toolbar, we skip - # this entire process. - if settings.DEBUG and request.path.startswith("/__debug__"): - return True - - # Second we check against matches - match = resolve(request.path, getattr(request, "urlconf", settings.ROOT_URLCONF)) - if "({0})".format(match.app_name) in EXEMPT: - return True - - if "[{0}]".format(match.namespace) in EXEMPT: - return True - - if "{0}:{1}".format(match.namespace, match.url_name) in EXEMPT: - return True - - if match.url_name in EXEMPT: - return True - - # Third, we check wildcards: - for exempt in [x for x in EXEMPT if x.startswith("fn:")]: - exempt = exempt.replace("fn:", "") - if fnmatch.fnmatch(request.path, exempt): - return True - - return False - - def check_subscription(self, request): - """Redirect to the subscribe page if the user lacks an active subscription.""" - subscriber = subscriber_request_callback(request) - - if not subscriber_has_active_subscription(subscriber): - if not SUBSCRIPTION_REDIRECT: - raise ImproperlyConfigured("DJSTRIPE_SUBSCRIPTION_REDIRECT is not set.") - return redirect(SUBSCRIPTION_REDIRECT) + """ + Used to redirect users from subcription-locked request destinations. + + Rules: + + * "(app_name)" means everything from this app is exempt + * "[namespace]" means everything with this name is exempt + * "namespace:name" means this namespaced URL is exempt + * "name" means this URL is exempt + * The entire djstripe namespace is exempt + * If settings.DEBUG is True, then django-debug-toolbar is exempt + * A 'fn:' prefix means the rest of the URL is fnmatch'd. + + Example:: + + DJSTRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS = ( + "[blogs]", # Anything in the blogs namespace + "products:detail", # A ProductDetail view you want shown to non-payers + "home", # Site homepage + "fn:/accounts*", # anything in the accounts/ URL path + ) + """ + + def process_request(self, request): + """Check the subscriber's subscription status. + + Returns early if request does not outlined in this middleware's docstring. + """ + if self.is_matching_rule(request): + return + + return self.check_subscription(request) + + def is_matching_rule(self, request): + """Check according to the rules defined in the class docstring.""" + # First, if in DEBUG mode and with django-debug-toolbar, we skip + # this entire process. + if settings.DEBUG and request.path.startswith("/__debug__"): + return True + + # Second we check against matches + match = resolve( + request.path, getattr(request, "urlconf", settings.ROOT_URLCONF) + ) + if "({0})".format(match.app_name) in EXEMPT: + return True + + if "[{0}]".format(match.namespace) in EXEMPT: + return True + + if "{0}:{1}".format(match.namespace, match.url_name) in EXEMPT: + return True + + if match.url_name in EXEMPT: + return True + + # Third, we check wildcards: + for exempt in [x for x in EXEMPT if x.startswith("fn:")]: + exempt = exempt.replace("fn:", "") + if fnmatch.fnmatch(request.path, exempt): + return True + + return False + + def check_subscription(self, request): + """Redirect to the subscribe page if the user lacks an active subscription.""" + subscriber = subscriber_request_callback(request) + + if not subscriber_has_active_subscription(subscriber): + if not SUBSCRIPTION_REDIRECT: + raise ImproperlyConfigured("DJSTRIPE_SUBSCRIPTION_REDIRECT is not set.") + return redirect(SUBSCRIPTION_REDIRECT) diff --git a/djstripe/migrations/0001_initial.py b/djstripe/migrations/0001_initial.py index dc33a386f7..28dfccbc16 100644 --- a/djstripe/migrations/0001_initial.py +++ b/djstripe/migrations/0001_initial.py @@ -13,7 +13,7 @@ from djstripe.models.webhooks import _get_version DJSTRIPE_SUBSCRIBER_MODEL = getattr( - settings, "DJSTRIPE_SUBSCRIBER_MODEL", settings.AUTH_USER_MODEL + settings, "DJSTRIPE_SUBSCRIBER_MODEL", settings.AUTH_USER_MODEL ) # Needed here for external apps that have added the DJSTRIPE_SUBSCRIBER_MODEL @@ -21,2696 +21,2941 @@ # ValueError: Related model 'DJSTRIPE_SUBSCRIBER_MODEL' cannot be resolved # Context: https://github.com/dj-stripe/dj-stripe/issues/707 DJSTRIPE_SUBSCRIBER_MODEL_MIGRATION_DEPENDENCY = getattr( - settings, "DJSTRIPE_SUBSCRIBER_MODEL_MIGRATION_DEPENDENCY", "__first__" + settings, "DJSTRIPE_SUBSCRIBER_MODEL_MIGRATION_DEPENDENCY", "__first__" ) DJSTRIPE_SUBSCRIBER_MODEL_DEPENDENCY = migrations.swappable_dependency( - DJSTRIPE_SUBSCRIBER_MODEL + DJSTRIPE_SUBSCRIBER_MODEL ) if DJSTRIPE_SUBSCRIBER_MODEL != settings.AUTH_USER_MODEL: - DJSTRIPE_SUBSCRIBER_MODEL_DEPENDENCY = migrations.migration.SwappableTuple( - ( - DJSTRIPE_SUBSCRIBER_MODEL.split(".", 1)[0], - DJSTRIPE_SUBSCRIBER_MODEL_MIGRATION_DEPENDENCY, - ), - DJSTRIPE_SUBSCRIBER_MODEL, - ) + DJSTRIPE_SUBSCRIBER_MODEL_DEPENDENCY = migrations.migration.SwappableTuple( + ( + DJSTRIPE_SUBSCRIBER_MODEL.split(".", 1)[0], + DJSTRIPE_SUBSCRIBER_MODEL_MIGRATION_DEPENDENCY, + ), + DJSTRIPE_SUBSCRIBER_MODEL, + ) class Migration(migrations.Migration): - initial = True + initial = True - dependencies = [DJSTRIPE_SUBSCRIBER_MODEL_DEPENDENCY] + dependencies = [DJSTRIPE_SUBSCRIBER_MODEL_DEPENDENCY] - operations = [ - migrations.CreateModel( - name="Account", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "business_name", - models.CharField( - help_text="The publicly visible name of the business", max_length=255 - ), - ), - ( - "business_primary_color", - models.CharField( - help_text="A CSS hex color value representing the primary branding color for this account", - max_length=7, - null=True, - blank=True, - ), - ), - ( - "business_url", - models.CharField( - help_text="The publicly visible website of the business", - max_length=200, - null=True, - ), - ), - ( - "charges_enabled", - models.BooleanField(help_text="Whether the account can create live charges"), - ), - ("country", models.CharField(help_text="The country of the account", max_length=2)), - ( - "debit_negative_balances", - models.NullBooleanField( - default=False, - help_text="A Boolean indicating if Stripe should try to reclaim negative balances from an attached bank account.", - ), - ), - ( - "decline_charge_on", - djstripe.fields.JSONField( - help_text="Account-level settings to automatically decline certain types of charges regardless of the decision of the card issuer", - null=True, - blank=True, - ), - ), - ( - "default_currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="The currency this account has chosen to use as the default", - max_length=3, - ), - ), - ( - "details_submitted", - models.BooleanField( - help_text="Whether account details have been submitted. Standard accounts cannot receive payouts before this is true." - ), - ), - ( - "display_name", - models.CharField( - help_text="The display name for this account. This is used on the Stripe Dashboard to differentiate between accounts.", - max_length=255, - ), - ), - ( - "email", - models.CharField(help_text="The primary user’s email address.", max_length=255), - ), - ( - "legal_entity", - djstripe.fields.JSONField( - help_text="Information about the legal entity itself, including about the associated account representative", - null=True, - blank=True, - ), - ), - ( - "payout_schedule", - djstripe.fields.JSONField( - help_text="Details on when funds from charges are available, and when they are paid out to an external account.", - null=True, - blank=True, - ), - ), - ( - "payout_statement_descriptor", - models.CharField( - default="", - help_text="The text that appears on the bank account statement for payouts.", - max_length=255, - null=True, - blank=True, - ), - ), - ( - "payouts_enabled", - models.BooleanField(help_text="Whether Stripe can send payouts to this account"), - ), - ( - "product_description", - models.CharField( - help_text="Internal-only description of the product sold or service provided by the business. It’s used by Stripe for risk and underwriting purposes.", - max_length=255, - null=True, - blank=True, - ), - ), - ( - "statement_descriptor", - models.CharField( - default="", - help_text="The default text that appears on credit card statements when a charge is made directly on the account", - max_length=255, - blank=True, - ), - ), - ( - "support_email", - models.CharField( - help_text="A publicly shareable support email address for the business", - max_length=255, - ), - ), - ( - "support_phone", - models.CharField( - help_text="A publicly shareable support phone number for the business", - max_length=255, - ), - ), - ( - "support_url", - models.CharField( - help_text="A publicly shareable URL that provides support for this account", - max_length=200, - ), - ), - ( - "timezone", - models.CharField( - help_text="The timezone used in the Stripe Dashboard for this account.", - max_length=50, - ), - ), - ( - "type", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.AccountType, - help_text="The Stripe account type.", - max_length=8, - ), - ), - ( - "tos_acceptance", - djstripe.fields.JSONField( - help_text="Details on the acceptance of the Stripe Services Agreement", - null=True, - blank=True, - ), - ), - ( - "verification", - djstripe.fields.JSONField( - help_text="Information on the verification state of the account, including what information is needed and by when", - null=True, - blank=True, - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="BankAccount", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "account_holder_name", - models.CharField( - help_text="The name of the person or business that owns the bank account.", - max_length=5000, - null=True, - blank=True, - ), - ), - ( - "account_holder_type", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.BankAccountHolderType, - help_text="The type of entity that holds the account.", - max_length=10, - ), - ), - ( - "bank_name", - models.CharField( - help_text="Name of the bank associated with the routing number (e.g., `WELLS FARGO`).", - max_length=255, - ), - ), - ( - "country", - models.CharField( - help_text="Two-letter ISO code representing the country the bank account is located in.", - max_length=2, - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "default_for_currency", - models.NullBooleanField( - help_text="Whether this external account is the default account for its currency." - ), - ), - ( - "fingerprint", - models.CharField( - help_text="Uniquely identifies this particular bank account. You can use this attribute to check whether two bank accounts are the same.", - max_length=16, - ), - ), - ("last4", models.CharField(max_length=4)), - ( - "routing_number", - models.CharField( - help_text="The routing transit number for the bank account.", max_length=255 - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.BankAccountStatus, max_length=19 - ), - ), - ( - "account", - models.ForeignKey( - help_text="The account the charge was made on behalf of. Null here indicates that this value was never set.", - on_delete=django.db.models.deletion.PROTECT, - related_name="bank_account", - to="djstripe.Account", - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Card", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ("address_city", models.TextField(help_text="Billing address city.", null=True)), - ( - "address_country", - models.TextField(help_text="Billing address country.", null=True), - ), - ( - "address_line1", - models.TextField(help_text="Billing address (Line 1).", null=True), - ), - ( - "address_line1_check", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.CardCheckResult, - help_text="If `address_line1` was provided, results of the check.", - max_length=11, - null=True, - ), - ), - ( - "address_line2", - models.TextField(help_text="Billing address (Line 2).", null=True), - ), - ("address_state", models.TextField(help_text="Billing address state.", null=True)), - ("address_zip", models.TextField(help_text="Billing address zip code.", null=True)), - ( - "address_zip_check", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.CardCheckResult, - help_text="If `address_zip` was provided, results of the check.", - max_length=11, - null=True, - ), - ), - ( - "brand", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.CardBrand, help_text="Card brand.", max_length=16 - ), - ), - ( - "country", - models.CharField( - help_text="Two-letter ISO code representing the country of the card.", - max_length=2, - null=True, - ), - ), - ( - "cvc_check", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.CardCheckResult, - help_text="If a CVC was provided, results of the check.", - max_length=11, - null=True, - ), - ), - ( - "dynamic_last4", - models.CharField( - help_text="(For tokenized numbers only.) The last four digits of the device account number.", - max_length=4, - null=True, - ), - ), - ("exp_month", models.IntegerField(help_text="Card expiration month.")), - ("exp_year", models.IntegerField(help_text="Card expiration year.")), - ( - "fingerprint", - models.TextField( - help_text="Uniquely identifies this particular card number.", - null=True, - blank=True, - ), - ), - ( - "funding", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.CardFundingType, help_text="Card funding type.", max_length=7 - ), - ), - ( - "last4", - models.CharField(help_text="Last four digits of Card number.", max_length=4), - ), - ("name", models.TextField(help_text="Cardholder name.", null=True)), - ( - "tokenization_method", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.CardTokenizationMethod, - help_text="If the card number is tokenized, this is the method that was used.", - max_length=11, - null=True, - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Charge", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, help_text="Amount charged.", max_digits=8 - ), - ), - ( - "amount_refunded", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="Amount refunded (can be less than the amount attribute on the charge if a partial refund was issued).", - max_digits=8, - ), - ), - ( - "captured", - models.BooleanField( - default=False, - help_text="If the charge was created without capturing, this boolean represents whether or not it is still uncaptured or has since been captured.", - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="The currency in which the charge was made.", max_length=3 - ), - ), - ( - "failure_code", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.ApiErrorCode, - help_text="Error code explaining reason for charge failure if available.", - max_length=20, - null=True, - ), - ), - ( - "failure_message", - models.TextField( - help_text="Message to user further explaining reason for charge failure if available.", - null=True, - ), - ), - ( - "fraud_details", - djstripe.fields.JSONField( - help_text="Hash with information on fraud assessments for the charge." - ), - ), - ( - "outcome", - djstripe.fields.JSONField( - help_text="Details about whether or not the payment was accepted, and why." - ), - ), - ( - "paid", - models.BooleanField( - default=False, - help_text="True if the charge succeeded, or was successfully authorized for later capture, False otherwise.", - ), - ), - ( - "receipt_email", - models.CharField( - help_text="The email address that the receipt for this charge was sent to.", - max_length=800, - null=True, - ), - ), - ( - "receipt_number", - models.CharField( - help_text="The transaction number that appears on email receipts sent for this charge.", - max_length=9, - null=True, - ), - ), - ( - "refunded", - models.BooleanField( - default=False, - help_text="Whether or not the charge has been fully refunded. If the charge is only partially refunded, this attribute will still be false.", - ), - ), - ( - "shipping", - djstripe.fields.JSONField( - help_text="Shipping information for the charge", null=True - ), - ), - ( - "statement_descriptor", - models.CharField( - help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", - max_length=22, - null=True, - blank=True, - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.ChargeStatus, - help_text="The status of the payment.", - max_length=9, - ), - ), - ( - "transfer_group", - models.CharField( - blank=True, - help_text="A string that identifies this transaction as part of a group.", - max_length=255, - null=True, - ), - ), - ( - "fee", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, max_digits=8, null=True, blank=True - ), - ), - ("fee_details", djstripe.fields.JSONField(null=True, blank=True)), - ( - "source_type", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.LegacySourceType, - help_text="The payment source type. If the payment source is supported by dj-stripe, a corresponding model is attached to this Charge via a foreign key matching this field.", - max_length=16, - null=True, - ), - ), - ( - "source_stripe_id", - djstripe.fields.StripeIdField( - help_text="The payment source id.", max_length=255, null=True - ), - ), - ( - "fraudulent", - models.BooleanField( - default=False, help_text="Whether or not this charge was marked as fraudulent." - ), - ), - ( - "receipt_sent", - models.BooleanField( - default=False, help_text="Whether or not a receipt was sent for this charge." - ), - ), - ( - "account", - models.ForeignKey( - help_text="The account the charge was made on behalf of. Null here indicates that this value was never set.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="charges", - to="djstripe.Account", - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Coupon", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ("stripe_id", djstripe.fields.StripeIdField(max_length=500)), - ( - "amount_off", - djstripe.fields.StripeDecimalCurrencyAmountField( - blank=True, - decimal_places=2, - help_text="Amount that will be taken off the subtotal of any invoices for this customer.", - max_digits=8, - null=True, - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - blank=True, help_text="Three-letter ISO currency code", max_length=3, null=True - ), - ), - ( - "duration", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.CouponDuration, - help_text="Describes how long a customer who applies this coupon will get the discount.", - max_length=9, - ), - ), - ( - "duration_in_months", - models.PositiveIntegerField( - blank=True, - help_text="If `duration` is `repeating`, the number of months the coupon applies.", - null=True, - ), - ), - ( - "max_redemptions", - models.PositiveIntegerField( - blank=True, - help_text="Maximum number of times this coupon can be redeemed, in total, before it is no longer valid.", - null=True, - ), - ), - ( - "percent_off", - models.PositiveIntegerField( - blank=True, - null=True, - validators=[ - django.core.validators.MinValueValidator(1), - django.core.validators.MaxValueValidator(100), - ], - ), - ), - ( - "redeem_by", - djstripe.fields.StripeDateTimeField( - blank=True, - help_text="Date after which the coupon can no longer be redeemed. Max 5 years in the future.", - null=True, - ), - ), - ( - "times_redeemed", - models.PositiveIntegerField( - default=0, - editable=False, - help_text="Number of times this coupon has been applied to a customer.", - ), - ), - ], - ), - migrations.CreateModel( - name="Customer", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "account_balance", - models.IntegerField( - help_text="Current balance, if any, being stored on the customer's account. If negative, the customer has credit to apply to the next invoice. If positive, the customer has an amount owed that will be added to the next invoice. The balance does not refer to any unpaid invoices; it solely takes into account amounts that have yet to be successfully applied to any invoice. This balance is only taken into account for recurring billing purposes (i.e., subscriptions, invoices, invoice items)." - ), - ), - ( - "business_vat_id", - models.CharField( - help_text="The customer's VAT identification number.", - max_length=20, - null=True, - blank=True, - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="The currency the customer can be charged in for recurring billing purposes", - max_length=3, - null=True, - ), - ), - ( - "delinquent", - models.BooleanField( - help_text="Whether or not the latest charge for the customer's latest invoice has failed." - ), - ), - ( - "coupon_start", - djstripe.fields.StripeDateTimeField( - editable=False, - help_text="If a coupon is present, the date at which it was applied.", - null=True, - blank=True, - ), - ), - ( - "coupon_end", - djstripe.fields.StripeDateTimeField( - editable=False, - help_text="If a coupon is present and has a limited duration, the date that the discount will end.", - null=True, - blank=True, - ), - ), - ("email", models.TextField(null=True)), - ( - "shipping", - djstripe.fields.JSONField( - help_text="Shipping information associated with the customer.", - null=True, - blank=True, - ), - ), - ("date_purged", models.DateTimeField(editable=False, null=True)), - ( - "coupon", - models.ForeignKey( - null=True, - blank=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.Coupon", - ), - ), - ], - ), - migrations.CreateModel( - name="Dispute", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeQuantumCurrencyAmountField( - help_text="Disputed amount. Usually the amount of the charge, but can differ (usually because of currency fluctuation or because only part of the order is disputed)." - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "evidence", - djstripe.fields.JSONField(help_text="Evidence provided to respond to a dispute."), - ), - ( - "evidence_details", - djstripe.fields.JSONField(help_text="Information about the evidence submission."), - ), - ( - "is_charge_refundable", - models.BooleanField( - help_text="If true, it is still possible to refund the disputed payment. Once the payment has been fully refunded, no further funds will be withdrawn from your Stripe account as a result of this dispute." - ), - ), - ( - "reason", - djstripe.fields.StripeEnumField(enum=djstripe.enums.DisputeReason, max_length=25), - ), - ( - "status", - djstripe.fields.StripeEnumField(enum=djstripe.enums.DisputeStatus, max_length=22), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Event", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "api_version", - models.CharField( - blank=True, - help_text="the API version at which the event data was rendered. Blank for old entries only, all new entries will have this value", - max_length=15, - ), - ), - ( - "data", - djstripe.fields.JSONField( - help_text="data received at webhook. data should be considered to be garbage until validity check is run and valid flag is set" - ), - ), - ( - "request_id", - models.CharField( - blank=True, - help_text="Information about the request that triggered this event, for traceability purposes. If empty string then this is an old entry without that data. If Null then this is not an old entry, but a Stripe 'automated' event with no associated request.", - max_length=50, - null=True, - ), - ), - ("idempotency_key", models.TextField(blank=True, null=True)), - ( - "type", - models.CharField(help_text="Stripe's event description code", max_length=250), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="FileUpload", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "filename", - models.CharField( - help_text="A filename for the file, suitable for saving to a filesystem.", - max_length=255, - ), - ), - ( - "purpose", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.FileUploadPurpose, - help_text="The purpose of the uploaded file.", - max_length=24, - ), - ), - ( - "size", - models.IntegerField(help_text="The size in bytes of the file upload object."), - ), - ( - "type", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.FileUploadType, - help_text="The type of the file returned.", - max_length=4, - ), - ), - ( - "url", - models.CharField( - help_text="A read-only URL where the uploaded file can be accessed.", - max_length=200, - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="IdempotencyKey", - fields=[ - ( - "uuid", - models.UUIDField( - default=uuid.uuid4, editable=False, primary_key=True, serialize=False - ), - ), - ("action", models.CharField(max_length=100)), - ( - "livemode", - models.BooleanField(help_text="Whether the key was used in live or test mode."), - ), - ("created", models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name="Invoice", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount_due", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="Final amount due at this time for this invoice. If the invoice's total is smaller than the minimum charge amount, for example, or if there is account credit that can be applied to the invoice, the amount_due may be 0. If there is a positive starting_balance for the invoice (the customer owes money), the amount_due will also take that into account. The charge that gets generated for the invoice will be for the amount specified in amount_due.", - max_digits=8, - ), - ), - ( - "amount_paid", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="The amount, in cents, that was paid.", - max_digits=8, - null=True, - ), - ), - ( - "amount_remaining", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="The amount remaining, in cents, that is due.", - max_digits=8, - null=True, - ), - ), - ( - "application_fee", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="The fee in cents that will be applied to the invoice and transferred to the application owner's Stripe account when the invoice is paid.", - max_digits=8, - null=True, - ), - ), - ( - "attempt_count", - models.IntegerField( - help_text="Number of payment attempts made for this invoice, from the perspective of the payment retry schedule. Any payment attempt counts as the first attempt, and subsequently only automatic retries increment the attempt count. In other words, manual payment attempts after the first attempt do not affect the retry schedule." - ), - ), - ( - "attempted", - models.BooleanField( - default=False, - help_text="Whether or not an attempt has been made to pay the invoice. An invoice is not attempted until 1 hour after the ``invoice.created`` webhook, for example, so you might not want to display that invoice as unpaid to your users.", - ), - ), - ( - "billing", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.InvoiceBilling, - help_text="When charging automatically, Stripe will attempt to pay this invoice using the default source attached to the customer. When sending an invoice, Stripe will email this invoice to the customer with payment instructions.", - max_length=20, - null=True, - ), - ), - ( - "closed", - models.BooleanField( - default=False, - help_text="Whether or not the invoice is still trying to collect payment. An invoice is closed if it's either paid or it has been marked closed. A closed invoice will no longer attempt to collect payment.", - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ("date", djstripe.fields.StripeDateTimeField(help_text="The date on the invoice.")), - ( - "due_date", - djstripe.fields.StripeDateTimeField( - help_text="The date on which payment for this invoice is due. This value will be null for invoices where billing=charge_automatically.", - null=True, - ), - ), - ( - "ending_balance", - models.IntegerField( - help_text="Ending customer balance after attempting to pay invoice. If the invoice has not been attempted yet, this will be null.", - null=True, - ), - ), - ( - "forgiven", - models.BooleanField( - default=False, - help_text="Whether or not the invoice has been forgiven. Forgiving an invoice instructs us to update the subscription status as if the invoice were successfully paid. Once an invoice has been forgiven, it cannot be unforgiven or reopened.", - ), - ), - ( - "hosted_invoice_url", - models.CharField( - help_text="The URL for the hosted invoice page, which allows customers to view and pay an invoice. If the invoice has not been frozen yet, this will be null.", - max_length=799, - null=True, - blank=True, - ), - ), - ( - "invoice_pdf", - models.CharField( - help_text="The link to download the PDF for the invoice. If the invoice has not been frozen yet, this will be null.", - max_length=799, - null=True, - blank=True, - ), - ), - ( - "next_payment_attempt", - djstripe.fields.StripeDateTimeField( - help_text="The time at which payment will next be attempted.", null=True - ), - ), - ( - "number", - models.CharField( - help_text="A unique, identifying string that appears on emails sent to the customer for this invoice. This starts with the customer’s unique invoice_prefix if it is specified.", - max_length=64, - null=True, - blank=True, - ), - ), - ( - "paid", - models.BooleanField( - default=False, help_text="The time at which payment will next be attempted." - ), - ), - ( - "period_end", - djstripe.fields.StripeDateTimeField( - help_text="End of the usage period during which invoice items were added to this invoice." - ), - ), - ( - "period_start", - djstripe.fields.StripeDateTimeField( - help_text="Start of the usage period during which invoice items were added to this invoice." - ), - ), - ( - "receipt_number", - models.CharField( - help_text="This is the transaction number that appears on email receipts sent for this invoice.", - max_length=64, - null=True, - ), - ), - ( - "starting_balance", - models.IntegerField( - help_text="Starting customer balance before attempting to pay invoice. If the invoice has not been attempted yet, this will be the current customer balance." - ), - ), - ( - "statement_descriptor", - models.CharField( - help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", - max_length=22, - null=True, - blank=True, - ), - ), - ( - "subscription_proration_date", - djstripe.fields.StripeDateTimeField( - help_text="Only set for upcoming invoices that preview prorations. The time used to calculate prorations.", - null=True, - blank=True, - ), - ), - ( - "subtotal", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="Only set for upcoming invoices that preview prorations. The time used to calculate prorations.", - max_digits=8, - ), - ), - ( - "tax", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="The amount of tax included in the total, calculated from ``tax_percent`` and the subtotal. If no ``tax_percent`` is defined, this value will be null.", - max_digits=8, - null=True, - blank=True, - ), - ), - ( - "tax_percent", - djstripe.fields.StripePercentField( - decimal_places=2, - help_text="This percentage of the subtotal has been added to the total amount of the invoice, including invoice line items and discounts. This field is inherited from the subscription's ``tax_percent`` field, but can be changed before the invoice is paid. This field defaults to null.", - max_digits=5, - null=True, - validators=[ - django.core.validators.MinValueValidator(1.0), - django.core.validators.MaxValueValidator(100.0), - ], - ), - ), - ( - "total", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, max_digits=8, verbose_name="Total after discount." - ), - ), - ( - "webhooks_delivered_at", - djstripe.fields.StripeDateTimeField( - help_text="The time at which webhooks for this invoice were successfully delivered (if the invoice had no webhooks to deliver, this will match `date`). Invoice payment is delayed until webhooks are delivered, or until all webhook delivery attempts have been exhausted.", - null=True, - ), - ), - ], - options={"ordering": ["-date"]}, - ), - migrations.CreateModel( - name="InvoiceItem", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, help_text="Amount invoiced.", max_digits=8 - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "date", - djstripe.fields.StripeDateTimeField(help_text="The date on the invoiceitem."), - ), - ( - "discountable", - models.BooleanField( - default=False, - help_text="If True, discounts will apply to this invoice item. Always False for prorations.", - ), - ), - ("period", djstripe.fields.JSONField()), - ( - "period_end", - djstripe.fields.StripeDateTimeField( - help_text="Might be the date when this invoiceitem's invoice was sent." - ), - ), - ( - "period_start", - djstripe.fields.StripeDateTimeField( - help_text="Might be the date when this invoiceitem was added to the invoice" - ), - ), - ( - "proration", - models.BooleanField( - default=False, - help_text="Whether or not the invoice item was created automatically as a proration adjustment when the customer switched plans.", - ), - ), - ( - "quantity", - models.IntegerField( - help_text="If the invoice item is a proration, the quantity of the subscription for which the proration was computed.", - null=True, - blank=True, - ), - ), - ( - "customer", - models.ForeignKey( - help_text="The customer associated with this invoiceitem.", - on_delete=django.db.models.deletion.CASCADE, - related_name="invoiceitems", - to="djstripe.Customer", - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="PaymentMethod", - fields=[ - ("id", models.CharField(max_length=255, primary_key=True, serialize=False)), - ("type", models.CharField(db_index=True, max_length=12)), - ], - ), - migrations.CreateModel( - name="Payout", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="Amount to be transferred to your bank account or debit card.", - max_digits=8, - ), - ), - ( - "arrival_date", - djstripe.fields.StripeDateTimeField( - help_text="Date the payout is expected to arrive in the bank. This factors in delays like weekends or bank holidays." - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "failure_code", - djstripe.fields.StripeEnumField( - blank=True, - enum=djstripe.enums.PayoutFailureCode, - help_text="Error code explaining reason for transfer failure if available. See https://stripe.com/docs/api/python#transfer_failures.", - max_length=23, - null=True, - ), - ), - ( - "failure_message", - models.TextField( - blank=True, - help_text="Message to user further explaining reason for payout failure if available.", - null=True, - ), - ), - ( - "method", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.PayoutMethod, - help_text="The method used to send this payout. `instant` is only supported for payouts to debit cards.", - max_length=8, - ), - ), - ( - "statement_descriptor", - models.CharField( - blank=True, - help_text="Extra information about a payout to be displayed on the user's bank statement.", - max_length=255, - null=True, - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.PayoutStatus, - help_text="Current status of the payout. A payout will be `pending` until it is submitted to the bank, at which point it becomes `in_transit`. It will then change to paid if the transaction goes through. If it does not go through successfully, its status will change to `failed` or `canceled`.", - max_length=10, - ), - ), - ( - "type", - djstripe.fields.StripeEnumField(enum=djstripe.enums.PayoutType, max_length=12), - ), - ( - "destination", - models.ForeignKey( - help_text="ID of the bank account or card the payout was sent to.", - null=True, - on_delete=django.db.models.deletion.PROTECT, - to="djstripe.BankAccount", - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Plan", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "aggregate_usage", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.PlanAggregateUsage, - help_text="Specifies a usage aggregation strategy for plans of usage_type=metered. Allowed values are `sum` for summing up all usage during a period, `last_during_period` for picking the last usage record reported within a period, `last_ever` for picking the last usage record ever (across period bounds) or max which picks the usage record with the maximum reported usage during a period. Defaults to `sum`.", - max_length=18, - null=True, - blank=True, - ), - ), - ( - "amount", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="Amount to be charged on the interval specified.", - max_digits=8, - ), - ), - ( - "billing_scheme", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.PlanBillingScheme, - help_text="Describes how to compute the price per period. Either `per_unit` or `tiered`. `per_unit` indicates that the fixed amount (specified in amount) will be charged per unit in quantity (for plans with `usage_type=licensed`), or per unit of total usage (for plans with `usage_type=metered`). `tiered` indicates that the unit pricing will be computed using a tiering strategy as defined using the tiers and tiers_mode attributes.", - max_length=8, - null=True, - blank=True, - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "interval", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.PlanInterval, - help_text="The frequency with which a subscription should be billed.", - max_length=5, - ), - ), - ( - "interval_count", - models.IntegerField( - help_text="The number of intervals (specified in the interval property) between each subscription billing.", - null=True, - ), - ), - ( - "nickname", - models.CharField( - help_text="A brief description of the plan, hidden from customers.", - max_length=5000, - null=True, - blank=True, - ), - ), - ( - "tiers", - djstripe.fields.JSONField( - help_text="Each element represents a pricing tier. This parameter requires `billing_scheme` to be set to `tiered`.", - null=True, - blank=True, - ), - ), - ( - "tiers_mode", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.PlanTiersMode, - help_text="Defines if the tiering price should be `graduated` or `volume` based. In `volume`-based tiering, the maximum quantity within a period determines the per unit price, in `graduated` tiering pricing can successively change as the quantity grows.", - max_length=9, - null=True, - blank=True, - ), - ), - ( - "transform_usage", - djstripe.fields.JSONField( - help_text="Apply a transformation to the reported usage or set quantity before computing the billed price. Cannot be combined with `tiers`.", - null=True, - blank=True, - ), - ), - ( - "trial_period_days", - models.IntegerField( - help_text="Number of trial period days granted when subscribing a customer to this plan. Null if the plan has no trial period.", - null=True, - ), - ), - ( - "usage_type", - djstripe.fields.StripeEnumField( - default="licensed", - enum=djstripe.enums.PlanUsageType, - help_text="Configures how the quantity per period should be determined, can be either `metered` or `licensed`. `licensed` will automatically bill the `quantity` set for a plan when adding it to a subscription, `metered` will aggregate the total usage based on usage records. Defaults to `licensed`.", - max_length=8, - ), - ), - ( - "name", - models.TextField( - help_text="Name of the plan, to be displayed on invoices and in the web interface.", - null=True, - blank=True, - ), - ), - ( - "statement_descriptor", - models.CharField( - help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", - max_length=22, - null=True, - blank=True, - ), - ), - ], - options={"ordering": ["amount"]}, - ), - migrations.CreateModel( - name="Product", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "name", - models.CharField( - help_text="The product's name, meant to be displayable to the customer. Applicable to both `service` and `good` types.", - max_length=5000, - ), - ), - ( - "type", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.ProductType, - help_text="The type of the product. The product is either of type `good`, which is eligible for use with Orders and SKUs, or `service`, which is eligible for use with Subscriptions and Plans.", - max_length=7, - ), - ), - ( - "active", - models.NullBooleanField( - help_text="Whether the product is currently available for purchase. Only applicable to products of `type=good`." - ), - ), - ( - "attributes", - djstripe.fields.JSONField( - help_text='A list of up to 5 attributes that each SKU can provide values for (e.g., `["color", "size"]`). Only applicable to products of `type=good`.', - null=True, - blank=True, - ), - ), - ( - "caption", - models.CharField( - help_text="A short one-line description of the product, meant to be displayableto the customer. Only applicable to products of `type=good`.", - max_length=5000, - null=True, - blank=True, - ), - ), - ( - "deactivate_on", - djstripe.fields.JSONField( - blank=True, - help_text="An array of connect application identifiers that cannot purchase this product. Only applicable to products of `type=good`.", - ), - ), - ( - "images", - djstripe.fields.JSONField( - blank=True, - help_text="A list of up to 8 URLs of images for this product, meant to be displayable to the customer. Only applicable to products of `type=good`.", - ), - ), - ( - "package_dimensions", - djstripe.fields.JSONField( - help_text="The dimensions of this product for shipping purposes. A SKU associated with this product can override this value by having its own `package_dimensions`. Only applicable to products of `type=good`.", - null=True, - blank=True, - ), - ), - ( - "shippable", - models.NullBooleanField( - help_text="Whether this product is a shipped good. Only applicable to products of `type=good`." - ), - ), - ( - "url", - models.CharField( - help_text="A URL of a publicly-accessible webpage for this product. Only applicable to products of `type=good`.", - max_length=799, - null=True, - blank=True, - ), - ), - ( - "statement_descriptor", - models.CharField( - help_text="Extra information about a product which will appear on your customer's credit card statement. In the case that multiple products are billed at once, the first statement descriptor will be used. Only available on products of type=`service`.", - max_length=22, - null=True, - blank=True, - ), - ), - ("unit_label", models.CharField(max_length=12, null=True)), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Refund", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeQuantumCurrencyAmountField(help_text="Amount, in cents."), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "failure_reason", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.RefundFailureReason, - help_text="If the refund failed, the reason for refund failure if known.", - max_length=24, - null=True, - blank=True, - ), - ), - ( - "reason", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.RefundReason, - help_text="Reason for the refund.", - max_length=21, - null=True, - ), - ), - ( - "receipt_number", - models.CharField( - help_text="The transaction number that appears on email receipts sent for this charge.", - max_length=9, - null=True, - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.RefundFailureReason, - help_text="Status of the refund.", - max_length=24, - ), - ), - ( - "charge", - models.ForeignKey( - help_text="The charge that was refunded", - on_delete=django.db.models.deletion.CASCADE, - related_name="refunds", - to="djstripe.Charge", - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Source", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeDecimalCurrencyAmountField( - blank=True, - decimal_places=2, - help_text="Amount associated with the source. This is the amount for which the source will be chargeable once ready. Required for `single_use` sources.", - max_digits=8, - null=True, - ), - ), - ( - "client_secret", - models.CharField( - help_text="The client secret of the source. Used for client-side retrieval using a publishable key.", - max_length=255, - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - blank=True, help_text="Three-letter ISO currency code", max_length=3, null=True - ), - ), - ( - "flow", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.SourceFlow, - help_text="The authentication flow of the source.", - max_length=17, - ), - ), - ( - "owner", - djstripe.fields.JSONField( - help_text="Information about the owner of the payment instrument that may be used or required by particular source types." - ), - ), - ( - "statement_descriptor", - models.CharField( - blank=True, - help_text="Extra information about a source. This will appear on your customer's statement every time you charge the source.", - max_length=255, - null=True, - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.SourceStatus, - help_text="The status of the source. Only `chargeable` sources can be used to create a charge.", - max_length=10, - ), - ), - ( - "type", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.SourceType, help_text="The type of the source.", max_length=19 - ), - ), - ( - "usage", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.SourceUsage, - help_text="Whether this source should be reusable or not. Some source types may or may not be reusable by construction, while other may leave the option at creation.", - max_length=10, - ), - ), - ( - "code_verification", - djstripe.fields.JSONField( - blank=True, - help_text="Information related to the code verification flow. Present if the source is authenticated by a verification code (`flow` is `code_verification`).", - null=True, - ), - ), - ( - "receiver", - djstripe.fields.JSONField( - blank=True, - help_text="Information related to the receiver flow. Present if the source is a receiver (`flow` is `receiver`).", - null=True, - ), - ), - ( - "redirect", - djstripe.fields.JSONField( - blank=True, - help_text="Information related to the redirect flow. Present if the source is authenticated by a redirect (`flow` is `redirect`).", - null=True, - ), - ), - ( - "source_data", - djstripe.fields.JSONField(help_text="The data corresponding to the source type."), - ), - ( - "customer", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="sources", - to="djstripe.Customer", - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Subscription", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "application_fee_percent", - djstripe.fields.StripePercentField( - blank=True, - decimal_places=2, - help_text="A positive decimal that represents the fee percentage of the subscription invoice amount that will be transferred to the application owner's Stripe account each billing period.", - max_digits=5, - null=True, - validators=[ - django.core.validators.MinValueValidator(1.0), - django.core.validators.MaxValueValidator(100.0), - ], - ), - ), - ( - "billing", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.InvoiceBilling, - help_text="Either `charge_automatically`, or `send_invoice`. When charging automatically, Stripe will attempt to pay this subscription at the end of the cycle using the default source attached to the customer. When sending an invoice, Stripe will email your customer an invoice with payment instructions.", - max_length=20, - ), - ), - ( - "billing_cycle_anchor", - djstripe.fields.StripeDateTimeField( - help_text="Determines the date of the first full invoice, and, for plans with `month` or `year` intervals, the day of the month for subsequent invoices.", - null=True, - blank=True, - ), - ), - ( - "cancel_at_period_end", - models.BooleanField( - default=False, - help_text="If the subscription has been canceled with the ``at_period_end`` flag set to true, ``cancel_at_period_end`` on the subscription will be true. You can use this attribute to determine whether a subscription that has a status of active is scheduled to be canceled at the end of the current period.", - ), - ), - ( - "canceled_at", - djstripe.fields.StripeDateTimeField( - blank=True, - help_text="If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with ``cancel_at_period_end``, canceled_at will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state.", - null=True, - ), - ), - ( - "current_period_end", - djstripe.fields.StripeDateTimeField( - help_text="End of the current period for which the subscription has been invoiced. At the end of this period, a new invoice will be created." - ), - ), - ( - "current_period_start", - djstripe.fields.StripeDateTimeField( - help_text="Start of the current period for which the subscription has been invoiced." - ), - ), - ( - "days_until_due", - models.IntegerField( - help_text="Number of days a customer has to pay invoices generated by this subscription. This value will be `null` for subscriptions where `billing=charge_automatically`.", - null=True, - blank=True, - ), - ), - ( - "ended_at", - djstripe.fields.StripeDateTimeField( - blank=True, - help_text="If the subscription has ended (either because it was canceled or because the customer was switched to a subscription to a new plan), the date the subscription ended.", - null=True, - ), - ), - ( - "quantity", - models.IntegerField(help_text="The quantity applied to this subscription."), - ), - ( - "start", - djstripe.fields.StripeDateTimeField(help_text="Date the subscription started."), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.SubscriptionStatus, - help_text="The status of this subscription.", - max_length=8, - ), - ), - ( - "tax_percent", - djstripe.fields.StripePercentField( - blank=True, - decimal_places=2, - help_text="A positive decimal (with at most two decimal places) between 1 and 100. This represents the percentage of the subscription invoice subtotal that will be calculated and added as tax to the final amount each billing period.", - max_digits=5, - null=True, - validators=[ - django.core.validators.MinValueValidator(1.0), - django.core.validators.MaxValueValidator(100.0), - ], - ), - ), - ( - "trial_end", - djstripe.fields.StripeDateTimeField( - blank=True, - help_text="If the subscription has a trial, the end of that trial.", - null=True, - ), - ), - ( - "trial_start", - djstripe.fields.StripeDateTimeField( - blank=True, - help_text="If the subscription has a trial, the beginning of that trial.", - null=True, - ), - ), - ( - "customer", - models.ForeignKey( - help_text="The customer associated with this subscription.", - on_delete=django.db.models.deletion.CASCADE, - related_name="subscriptions", - to="djstripe.Customer", - ), - ), - ( - "plan", - models.ForeignKey( - help_text="The plan associated with this subscription.", - on_delete=django.db.models.deletion.CASCADE, - related_name="subscriptions", - to="djstripe.Plan", - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="Transfer", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("stripe_id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - help_text="The datetime this object was created in stripe.", null=True, blank=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, help_text="The amount transferred", max_digits=8 - ), - ), - ( - "amount_reversed", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, - help_text="The amount reversed (can be less than the amount attribute on the transfer if a partial reversal was issued).", - max_digits=8, - null=True, - blank=True, - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "destination", - djstripe.fields.StripeIdField( - help_text="ID of the bank account, card, or Stripe account the transfer was sent to.", - max_length=255, - ), - ), - ( - "destination_payment", - djstripe.fields.StripeIdField( - help_text="If the destination is a Stripe account, this will be the ID of the payment that the destination account received for the transfer.", - max_length=255, - null=True, - blank=True, - ), - ), - ( - "reversed", - models.BooleanField( - default=False, - help_text="Whether or not the transfer has been fully reversed. If the transfer is only partially reversed, this attribute will still be false.", - ), - ), - ( - "source_transaction", - djstripe.fields.StripeIdField( - help_text="ID of the charge (or other transaction) that was used to fund the transfer. If null, the transfer was funded from the available balance.", - max_length=255, - null=True, - ), - ), - ( - "source_type", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.LegacySourceType, - help_text="The source balance from which this transfer came.", - max_length=16, - ), - ), - ( - "transfer_group", - models.CharField( - blank=True, - help_text="A string that identifies this transaction as part of a group.", - max_length=255, - null=True, - ), - ), - ( - "date", - djstripe.fields.StripeDateTimeField( - help_text="Date the transfer is scheduled to arrive in the bank. This doesn't factor in delays like weekends or bank holidays." - ), - ), - ( - "destination_type", - models.CharField( - blank=True, - help_text="The type of the transfer destination.", - max_length=14, - null=True, - ), - ), - ( - "failure_code", - djstripe.fields.StripeEnumField( - blank=True, - enum=djstripe.enums.PayoutFailureCode, - help_text="Error code explaining reason for transfer failure if available. See https://stripe.com/docs/api/python#transfer_failures.", - max_length=23, - null=True, - ), - ), - ( - "failure_message", - models.TextField( - blank=True, - help_text="Message to user further explaining reason for transfer failure if available.", - null=True, - ), - ), - ( - "statement_descriptor", - models.CharField( - help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", - max_length=22, - null=True, - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - blank=True, - enum=djstripe.enums.PayoutStatus, - help_text="The current status of the transfer. A transfer will be pending until it is submitted to the bank, at which point it becomes in_transit. It will then change to paid if the transaction goes through. If it does not go through successfully, its status will change to failed or canceled.", - max_length=10, - null=True, - ), - ), - ( - "fee", - djstripe.fields.StripeDecimalCurrencyAmountField( - decimal_places=2, max_digits=8, null=True, blank=True - ), - ), - ("fee_details", djstripe.fields.JSONField(null=True, blank=True)), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="WebhookEventTrigger", - fields=[ - ("id", models.BigAutoField(primary_key=True, serialize=False)), - ( - "remote_ip", - models.GenericIPAddressField(help_text="IP address of the request client."), - ), - ("headers", djstripe.fields.JSONField()), - ("body", models.TextField(blank=True)), - ( - "valid", - models.BooleanField( - default=False, help_text="Whether or not the webhook event has passed validation" - ), - ), - ( - "processed", - models.BooleanField( - default=False, - help_text="Whether or not the webhook event has been successfully processed", - ), - ), - ("exception", models.CharField(blank=True, max_length=128)), - ( - "traceback", - models.TextField( - blank=True, help_text="Traceback if an exception was thrown during processing" - ), - ), - ( - "djstripe_version", - models.CharField( - default=_get_version, - help_text="The version of dj-stripe when the webhook was received", - max_length=32, - ), - ), - ("created", models.DateTimeField(auto_now_add=True)), - ("updated", models.DateTimeField(auto_now=True)), - ( - "event", - models.ForeignKey( - blank=True, - help_text="Event object contained in the (valid) Webhook", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.Event", - ), - ), - ], - ), - migrations.CreateModel( - name="UpcomingInvoice", - fields=[ - ( - "invoice_ptr", - models.OneToOneField( - auto_created=True, - on_delete=django.db.models.deletion.CASCADE, - parent_link=True, - primary_key=True, - serialize=False, - to="djstripe.Invoice", - ), - ) - ], - options={"abstract": False}, - bases=("djstripe.invoice",), - ), - migrations.AddField( - model_name="plan", - name="product", - field=models.ForeignKey( - help_text="The product whose pricing this plan determines.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.Product", - ), - ), - migrations.AddField( - model_name="invoiceitem", - name="invoice", - field=models.ForeignKey( - help_text="The invoice to which this invoiceitem is attached.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="invoiceitems", - to="djstripe.Invoice", - ), - ), - migrations.AddField( - model_name="invoiceitem", - name="plan", - field=models.ForeignKey( - help_text="If the invoice item is a proration, the plan of the subscription for which the proration was computed.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="invoiceitems", - to="djstripe.Plan", - ), - ), - migrations.AddField( - model_name="invoiceitem", - name="subscription", - field=models.ForeignKey( - help_text="The subscription that this invoice item has been created for, if any.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="invoiceitems", - to="djstripe.Subscription", - ), - ), - migrations.AddField( - model_name="invoice", - name="charge", - field=models.OneToOneField( - help_text="The latest charge generated for this invoice, if any.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="latest_invoice", - to="djstripe.Charge", - ), - ), - migrations.AddField( - model_name="invoice", - name="customer", - field=models.ForeignKey( - help_text="The customer associated with this invoice.", - on_delete=django.db.models.deletion.CASCADE, - related_name="invoices", - to="djstripe.Customer", - ), - ), - migrations.AddField( - model_name="invoice", - name="subscription", - field=models.ForeignKey( - help_text="The subscription that this invoice was prepared for, if any.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="invoices", - to="djstripe.Subscription", - ), - ), - migrations.AlterUniqueTogether( - name="idempotencykey", unique_together={("action", "livemode")} - ), - migrations.AddField( - model_name="customer", - name="default_source", - field=djstripe.fields.PaymentMethodForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="customers", - to="djstripe.PaymentMethod", - ), - ), - migrations.AddField( - model_name="customer", - name="subscriber", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="djstripe_customers", - to=DJSTRIPE_SUBSCRIBER_MODEL, - ), - ), - migrations.AlterUniqueTogether( - name="coupon", unique_together={("stripe_id", "livemode")} - ), - migrations.AddField( - model_name="charge", - name="customer", - field=models.ForeignKey( - help_text="The customer associated with this charge.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="charges", - to="djstripe.Customer", - ), - ), - migrations.AddField( - model_name="charge", - name="dispute", - field=models.ForeignKey( - help_text="Details about the dispute if the charge has been disputed.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="charges", - to="djstripe.Dispute", - ), - ), - migrations.AddField( - model_name="charge", - name="invoice", - field=models.ForeignKey( - help_text="The invoice this charge is for if one exists.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="charges", - to="djstripe.Invoice", - ), - ), - migrations.AddField( - model_name="charge", - name="source", - field=djstripe.fields.PaymentMethodForeignKey( - help_text="The source used for this charge.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="charges", - to="djstripe.PaymentMethod", - ), - ), - migrations.AddField( - model_name="charge", - name="transfer", - field=models.ForeignKey( - help_text="The transfer to the destination account (only applicable if the charge was created using the destination parameter).", - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="djstripe.Transfer", - ), - ), - migrations.AddField( - model_name="card", - name="customer", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="legacy_cards", - to="djstripe.Customer", - ), - ), - migrations.AddField( - model_name="bankaccount", - name="customer", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="bank_account", - to="djstripe.Customer", - ), - ), - migrations.AddField( - model_name="account", - name="business_logo", - field=models.ForeignKey( - help_text="An icon for the account. Must be square and at least 128px x 128px.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="icon_account", - to="djstripe.FileUpload", - ), - ), - migrations.AlterUniqueTogether( - name="customer", unique_together={("subscriber", "livemode")} - ), - ] + operations = [ + migrations.CreateModel( + name="Account", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "business_name", + models.CharField( + help_text="The publicly visible name of the business", + max_length=255, + ), + ), + ( + "business_primary_color", + models.CharField( + help_text="A CSS hex color value representing the primary branding color for this account", + max_length=7, + null=True, + blank=True, + ), + ), + ( + "business_url", + models.CharField( + help_text="The publicly visible website of the business", + max_length=200, + null=True, + ), + ), + ( + "charges_enabled", + models.BooleanField( + help_text="Whether the account can create live charges" + ), + ), + ( + "country", + models.CharField( + help_text="The country of the account", max_length=2 + ), + ), + ( + "debit_negative_balances", + models.NullBooleanField( + default=False, + help_text="A Boolean indicating if Stripe should try to reclaim negative balances from an attached bank account.", + ), + ), + ( + "decline_charge_on", + djstripe.fields.JSONField( + help_text="Account-level settings to automatically decline certain types of charges regardless of the decision of the card issuer", + null=True, + blank=True, + ), + ), + ( + "default_currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="The currency this account has chosen to use as the default", + max_length=3, + ), + ), + ( + "details_submitted", + models.BooleanField( + help_text="Whether account details have been submitted. Standard accounts cannot receive payouts before this is true." + ), + ), + ( + "display_name", + models.CharField( + help_text="The display name for this account. This is used on the Stripe Dashboard to differentiate between accounts.", + max_length=255, + ), + ), + ( + "email", + models.CharField( + help_text="The primary user’s email address.", max_length=255 + ), + ), + ( + "legal_entity", + djstripe.fields.JSONField( + help_text="Information about the legal entity itself, including about the associated account representative", + null=True, + blank=True, + ), + ), + ( + "payout_schedule", + djstripe.fields.JSONField( + help_text="Details on when funds from charges are available, and when they are paid out to an external account.", + null=True, + blank=True, + ), + ), + ( + "payout_statement_descriptor", + models.CharField( + default="", + help_text="The text that appears on the bank account statement for payouts.", + max_length=255, + null=True, + blank=True, + ), + ), + ( + "payouts_enabled", + models.BooleanField( + help_text="Whether Stripe can send payouts to this account" + ), + ), + ( + "product_description", + models.CharField( + help_text="Internal-only description of the product sold or service provided by the business. It’s used by Stripe for risk and underwriting purposes.", + max_length=255, + null=True, + blank=True, + ), + ), + ( + "statement_descriptor", + models.CharField( + default="", + help_text="The default text that appears on credit card statements when a charge is made directly on the account", + max_length=255, + blank=True, + ), + ), + ( + "support_email", + models.CharField( + help_text="A publicly shareable support email address for the business", + max_length=255, + ), + ), + ( + "support_phone", + models.CharField( + help_text="A publicly shareable support phone number for the business", + max_length=255, + ), + ), + ( + "support_url", + models.CharField( + help_text="A publicly shareable URL that provides support for this account", + max_length=200, + ), + ), + ( + "timezone", + models.CharField( + help_text="The timezone used in the Stripe Dashboard for this account.", + max_length=50, + ), + ), + ( + "type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.AccountType, + help_text="The Stripe account type.", + max_length=8, + ), + ), + ( + "tos_acceptance", + djstripe.fields.JSONField( + help_text="Details on the acceptance of the Stripe Services Agreement", + null=True, + blank=True, + ), + ), + ( + "verification", + djstripe.fields.JSONField( + help_text="Information on the verification state of the account, including what information is needed and by when", + null=True, + blank=True, + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="BankAccount", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "account_holder_name", + models.CharField( + help_text="The name of the person or business that owns the bank account.", + max_length=5000, + null=True, + blank=True, + ), + ), + ( + "account_holder_type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.BankAccountHolderType, + help_text="The type of entity that holds the account.", + max_length=10, + ), + ), + ( + "bank_name", + models.CharField( + help_text="Name of the bank associated with the routing number (e.g., `WELLS FARGO`).", + max_length=255, + ), + ), + ( + "country", + models.CharField( + help_text="Two-letter ISO code representing the country the bank account is located in.", + max_length=2, + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "default_for_currency", + models.NullBooleanField( + help_text="Whether this external account is the default account for its currency." + ), + ), + ( + "fingerprint", + models.CharField( + help_text="Uniquely identifies this particular bank account. You can use this attribute to check whether two bank accounts are the same.", + max_length=16, + ), + ), + ("last4", models.CharField(max_length=4)), + ( + "routing_number", + models.CharField( + help_text="The routing transit number for the bank account.", + max_length=255, + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.BankAccountStatus, max_length=19 + ), + ), + ( + "account", + models.ForeignKey( + help_text="The account the charge was made on behalf of. Null here indicates that this value was never set.", + on_delete=django.db.models.deletion.PROTECT, + related_name="bank_account", + to="djstripe.Account", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Card", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "address_city", + models.TextField(help_text="Billing address city.", null=True), + ), + ( + "address_country", + models.TextField(help_text="Billing address country.", null=True), + ), + ( + "address_line1", + models.TextField(help_text="Billing address (Line 1).", null=True), + ), + ( + "address_line1_check", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.CardCheckResult, + help_text="If `address_line1` was provided, results of the check.", + max_length=11, + null=True, + ), + ), + ( + "address_line2", + models.TextField(help_text="Billing address (Line 2).", null=True), + ), + ( + "address_state", + models.TextField(help_text="Billing address state.", null=True), + ), + ( + "address_zip", + models.TextField(help_text="Billing address zip code.", null=True), + ), + ( + "address_zip_check", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.CardCheckResult, + help_text="If `address_zip` was provided, results of the check.", + max_length=11, + null=True, + ), + ), + ( + "brand", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.CardBrand, + help_text="Card brand.", + max_length=16, + ), + ), + ( + "country", + models.CharField( + help_text="Two-letter ISO code representing the country of the card.", + max_length=2, + null=True, + ), + ), + ( + "cvc_check", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.CardCheckResult, + help_text="If a CVC was provided, results of the check.", + max_length=11, + null=True, + ), + ), + ( + "dynamic_last4", + models.CharField( + help_text="(For tokenized numbers only.) The last four digits of the device account number.", + max_length=4, + null=True, + ), + ), + ("exp_month", models.IntegerField(help_text="Card expiration month.")), + ("exp_year", models.IntegerField(help_text="Card expiration year.")), + ( + "fingerprint", + models.TextField( + help_text="Uniquely identifies this particular card number.", + null=True, + blank=True, + ), + ), + ( + "funding", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.CardFundingType, + help_text="Card funding type.", + max_length=7, + ), + ), + ( + "last4", + models.CharField( + help_text="Last four digits of Card number.", max_length=4 + ), + ), + ("name", models.TextField(help_text="Cardholder name.", null=True)), + ( + "tokenization_method", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.CardTokenizationMethod, + help_text="If the card number is tokenized, this is the method that was used.", + max_length=11, + null=True, + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Charge", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, help_text="Amount charged.", max_digits=8 + ), + ), + ( + "amount_refunded", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="Amount refunded (can be less than the amount attribute on the charge if a partial refund was issued).", + max_digits=8, + ), + ), + ( + "captured", + models.BooleanField( + default=False, + help_text="If the charge was created without capturing, this boolean represents whether or not it is still uncaptured or has since been captured.", + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="The currency in which the charge was made.", + max_length=3, + ), + ), + ( + "failure_code", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.ApiErrorCode, + help_text="Error code explaining reason for charge failure if available.", + max_length=20, + null=True, + ), + ), + ( + "failure_message", + models.TextField( + help_text="Message to user further explaining reason for charge failure if available.", + null=True, + ), + ), + ( + "fraud_details", + djstripe.fields.JSONField( + help_text="Hash with information on fraud assessments for the charge." + ), + ), + ( + "outcome", + djstripe.fields.JSONField( + help_text="Details about whether or not the payment was accepted, and why." + ), + ), + ( + "paid", + models.BooleanField( + default=False, + help_text="True if the charge succeeded, or was successfully authorized for later capture, False otherwise.", + ), + ), + ( + "receipt_email", + models.CharField( + help_text="The email address that the receipt for this charge was sent to.", + max_length=800, + null=True, + ), + ), + ( + "receipt_number", + models.CharField( + help_text="The transaction number that appears on email receipts sent for this charge.", + max_length=9, + null=True, + ), + ), + ( + "refunded", + models.BooleanField( + default=False, + help_text="Whether or not the charge has been fully refunded. If the charge is only partially refunded, this attribute will still be false.", + ), + ), + ( + "shipping", + djstripe.fields.JSONField( + help_text="Shipping information for the charge", null=True + ), + ), + ( + "statement_descriptor", + models.CharField( + help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", + max_length=22, + null=True, + blank=True, + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.ChargeStatus, + help_text="The status of the payment.", + max_length=9, + ), + ), + ( + "transfer_group", + models.CharField( + blank=True, + help_text="A string that identifies this transaction as part of a group.", + max_length=255, + null=True, + ), + ), + ( + "fee", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, max_digits=8, null=True, blank=True + ), + ), + ("fee_details", djstripe.fields.JSONField(null=True, blank=True)), + ( + "source_type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.LegacySourceType, + help_text="The payment source type. If the payment source is supported by dj-stripe, a corresponding model is attached to this Charge via a foreign key matching this field.", + max_length=16, + null=True, + ), + ), + ( + "source_stripe_id", + djstripe.fields.StripeIdField( + help_text="The payment source id.", max_length=255, null=True + ), + ), + ( + "fraudulent", + models.BooleanField( + default=False, + help_text="Whether or not this charge was marked as fraudulent.", + ), + ), + ( + "receipt_sent", + models.BooleanField( + default=False, + help_text="Whether or not a receipt was sent for this charge.", + ), + ), + ( + "account", + models.ForeignKey( + help_text="The account the charge was made on behalf of. Null here indicates that this value was never set.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="charges", + to="djstripe.Account", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Coupon", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ("stripe_id", djstripe.fields.StripeIdField(max_length=500)), + ( + "amount_off", + djstripe.fields.StripeDecimalCurrencyAmountField( + blank=True, + decimal_places=2, + help_text="Amount that will be taken off the subtotal of any invoices for this customer.", + max_digits=8, + null=True, + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + blank=True, + help_text="Three-letter ISO currency code", + max_length=3, + null=True, + ), + ), + ( + "duration", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.CouponDuration, + help_text="Describes how long a customer who applies this coupon will get the discount.", + max_length=9, + ), + ), + ( + "duration_in_months", + models.PositiveIntegerField( + blank=True, + help_text="If `duration` is `repeating`, the number of months the coupon applies.", + null=True, + ), + ), + ( + "max_redemptions", + models.PositiveIntegerField( + blank=True, + help_text="Maximum number of times this coupon can be redeemed, in total, before it is no longer valid.", + null=True, + ), + ), + ( + "percent_off", + models.PositiveIntegerField( + blank=True, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(100), + ], + ), + ), + ( + "redeem_by", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="Date after which the coupon can no longer be redeemed. Max 5 years in the future.", + null=True, + ), + ), + ( + "times_redeemed", + models.PositiveIntegerField( + default=0, + editable=False, + help_text="Number of times this coupon has been applied to a customer.", + ), + ), + ], + ), + migrations.CreateModel( + name="Customer", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "account_balance", + models.IntegerField( + help_text="Current balance, if any, being stored on the customer's account. If negative, the customer has credit to apply to the next invoice. If positive, the customer has an amount owed that will be added to the next invoice. The balance does not refer to any unpaid invoices; it solely takes into account amounts that have yet to be successfully applied to any invoice. This balance is only taken into account for recurring billing purposes (i.e., subscriptions, invoices, invoice items)." + ), + ), + ( + "business_vat_id", + models.CharField( + help_text="The customer's VAT identification number.", + max_length=20, + null=True, + blank=True, + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="The currency the customer can be charged in for recurring billing purposes", + max_length=3, + null=True, + ), + ), + ( + "delinquent", + models.BooleanField( + help_text="Whether or not the latest charge for the customer's latest invoice has failed." + ), + ), + ( + "coupon_start", + djstripe.fields.StripeDateTimeField( + editable=False, + help_text="If a coupon is present, the date at which it was applied.", + null=True, + blank=True, + ), + ), + ( + "coupon_end", + djstripe.fields.StripeDateTimeField( + editable=False, + help_text="If a coupon is present and has a limited duration, the date that the discount will end.", + null=True, + blank=True, + ), + ), + ("email", models.TextField(null=True)), + ( + "shipping", + djstripe.fields.JSONField( + help_text="Shipping information associated with the customer.", + null=True, + blank=True, + ), + ), + ("date_purged", models.DateTimeField(editable=False, null=True)), + ( + "coupon", + models.ForeignKey( + null=True, + blank=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.Coupon", + ), + ), + ], + ), + migrations.CreateModel( + name="Dispute", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Disputed amount. Usually the amount of the charge, but can differ (usually because of currency fluctuation or because only part of the order is disputed)." + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "evidence", + djstripe.fields.JSONField( + help_text="Evidence provided to respond to a dispute." + ), + ), + ( + "evidence_details", + djstripe.fields.JSONField( + help_text="Information about the evidence submission." + ), + ), + ( + "is_charge_refundable", + models.BooleanField( + help_text="If true, it is still possible to refund the disputed payment. Once the payment has been fully refunded, no further funds will be withdrawn from your Stripe account as a result of this dispute." + ), + ), + ( + "reason", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.DisputeReason, max_length=25 + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.DisputeStatus, max_length=22 + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Event", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "api_version", + models.CharField( + blank=True, + help_text="the API version at which the event data was rendered. Blank for old entries only, all new entries will have this value", + max_length=15, + ), + ), + ( + "data", + djstripe.fields.JSONField( + help_text="data received at webhook. data should be considered to be garbage until validity check is run and valid flag is set" + ), + ), + ( + "request_id", + models.CharField( + blank=True, + help_text="Information about the request that triggered this event, for traceability purposes. If empty string then this is an old entry without that data. If Null then this is not an old entry, but a Stripe 'automated' event with no associated request.", + max_length=50, + null=True, + ), + ), + ("idempotency_key", models.TextField(blank=True, null=True)), + ( + "type", + models.CharField( + help_text="Stripe's event description code", max_length=250 + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="FileUpload", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "filename", + models.CharField( + help_text="A filename for the file, suitable for saving to a filesystem.", + max_length=255, + ), + ), + ( + "purpose", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.FileUploadPurpose, + help_text="The purpose of the uploaded file.", + max_length=24, + ), + ), + ( + "size", + models.IntegerField( + help_text="The size in bytes of the file upload object." + ), + ), + ( + "type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.FileUploadType, + help_text="The type of the file returned.", + max_length=4, + ), + ), + ( + "url", + models.CharField( + help_text="A read-only URL where the uploaded file can be accessed.", + max_length=200, + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="IdempotencyKey", + fields=[ + ( + "uuid", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("action", models.CharField(max_length=100)), + ( + "livemode", + models.BooleanField( + help_text="Whether the key was used in live or test mode." + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="Invoice", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount_due", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="Final amount due at this time for this invoice. If the invoice's total is smaller than the minimum charge amount, for example, or if there is account credit that can be applied to the invoice, the amount_due may be 0. If there is a positive starting_balance for the invoice (the customer owes money), the amount_due will also take that into account. The charge that gets generated for the invoice will be for the amount specified in amount_due.", + max_digits=8, + ), + ), + ( + "amount_paid", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="The amount, in cents, that was paid.", + max_digits=8, + null=True, + ), + ), + ( + "amount_remaining", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="The amount remaining, in cents, that is due.", + max_digits=8, + null=True, + ), + ), + ( + "application_fee", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="The fee in cents that will be applied to the invoice and transferred to the application owner's Stripe account when the invoice is paid.", + max_digits=8, + null=True, + ), + ), + ( + "attempt_count", + models.IntegerField( + help_text="Number of payment attempts made for this invoice, from the perspective of the payment retry schedule. Any payment attempt counts as the first attempt, and subsequently only automatic retries increment the attempt count. In other words, manual payment attempts after the first attempt do not affect the retry schedule." + ), + ), + ( + "attempted", + models.BooleanField( + default=False, + help_text="Whether or not an attempt has been made to pay the invoice. An invoice is not attempted until 1 hour after the ``invoice.created`` webhook, for example, so you might not want to display that invoice as unpaid to your users.", + ), + ), + ( + "billing", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.InvoiceBilling, + help_text="When charging automatically, Stripe will attempt to pay this invoice using the default source attached to the customer. When sending an invoice, Stripe will email this invoice to the customer with payment instructions.", + max_length=20, + null=True, + ), + ), + ( + "closed", + models.BooleanField( + default=False, + help_text="Whether or not the invoice is still trying to collect payment. An invoice is closed if it's either paid or it has been marked closed. A closed invoice will no longer attempt to collect payment.", + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "date", + djstripe.fields.StripeDateTimeField( + help_text="The date on the invoice." + ), + ), + ( + "due_date", + djstripe.fields.StripeDateTimeField( + help_text="The date on which payment for this invoice is due. This value will be null for invoices where billing=charge_automatically.", + null=True, + ), + ), + ( + "ending_balance", + models.IntegerField( + help_text="Ending customer balance after attempting to pay invoice. If the invoice has not been attempted yet, this will be null.", + null=True, + ), + ), + ( + "forgiven", + models.BooleanField( + default=False, + help_text="Whether or not the invoice has been forgiven. Forgiving an invoice instructs us to update the subscription status as if the invoice were successfully paid. Once an invoice has been forgiven, it cannot be unforgiven or reopened.", + ), + ), + ( + "hosted_invoice_url", + models.CharField( + help_text="The URL for the hosted invoice page, which allows customers to view and pay an invoice. If the invoice has not been frozen yet, this will be null.", + max_length=799, + null=True, + blank=True, + ), + ), + ( + "invoice_pdf", + models.CharField( + help_text="The link to download the PDF for the invoice. If the invoice has not been frozen yet, this will be null.", + max_length=799, + null=True, + blank=True, + ), + ), + ( + "next_payment_attempt", + djstripe.fields.StripeDateTimeField( + help_text="The time at which payment will next be attempted.", + null=True, + ), + ), + ( + "number", + models.CharField( + help_text="A unique, identifying string that appears on emails sent to the customer for this invoice. This starts with the customer’s unique invoice_prefix if it is specified.", + max_length=64, + null=True, + blank=True, + ), + ), + ( + "paid", + models.BooleanField( + default=False, + help_text="The time at which payment will next be attempted.", + ), + ), + ( + "period_end", + djstripe.fields.StripeDateTimeField( + help_text="End of the usage period during which invoice items were added to this invoice." + ), + ), + ( + "period_start", + djstripe.fields.StripeDateTimeField( + help_text="Start of the usage period during which invoice items were added to this invoice." + ), + ), + ( + "receipt_number", + models.CharField( + help_text="This is the transaction number that appears on email receipts sent for this invoice.", + max_length=64, + null=True, + ), + ), + ( + "starting_balance", + models.IntegerField( + help_text="Starting customer balance before attempting to pay invoice. If the invoice has not been attempted yet, this will be the current customer balance." + ), + ), + ( + "statement_descriptor", + models.CharField( + help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", + max_length=22, + null=True, + blank=True, + ), + ), + ( + "subscription_proration_date", + djstripe.fields.StripeDateTimeField( + help_text="Only set for upcoming invoices that preview prorations. The time used to calculate prorations.", + null=True, + blank=True, + ), + ), + ( + "subtotal", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="Only set for upcoming invoices that preview prorations. The time used to calculate prorations.", + max_digits=8, + ), + ), + ( + "tax", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="The amount of tax included in the total, calculated from ``tax_percent`` and the subtotal. If no ``tax_percent`` is defined, this value will be null.", + max_digits=8, + null=True, + blank=True, + ), + ), + ( + "tax_percent", + djstripe.fields.StripePercentField( + decimal_places=2, + help_text="This percentage of the subtotal has been added to the total amount of the invoice, including invoice line items and discounts. This field is inherited from the subscription's ``tax_percent`` field, but can be changed before the invoice is paid. This field defaults to null.", + max_digits=5, + null=True, + validators=[ + django.core.validators.MinValueValidator(1.0), + django.core.validators.MaxValueValidator(100.0), + ], + ), + ), + ( + "total", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + max_digits=8, + verbose_name="Total after discount.", + ), + ), + ( + "webhooks_delivered_at", + djstripe.fields.StripeDateTimeField( + help_text="The time at which webhooks for this invoice were successfully delivered (if the invoice had no webhooks to deliver, this will match `date`). Invoice payment is delayed until webhooks are delivered, or until all webhook delivery attempts have been exhausted.", + null=True, + ), + ), + ], + options={"ordering": ["-date"]}, + ), + migrations.CreateModel( + name="InvoiceItem", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, help_text="Amount invoiced.", max_digits=8 + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "date", + djstripe.fields.StripeDateTimeField( + help_text="The date on the invoiceitem." + ), + ), + ( + "discountable", + models.BooleanField( + default=False, + help_text="If True, discounts will apply to this invoice item. Always False for prorations.", + ), + ), + ("period", djstripe.fields.JSONField()), + ( + "period_end", + djstripe.fields.StripeDateTimeField( + help_text="Might be the date when this invoiceitem's invoice was sent." + ), + ), + ( + "period_start", + djstripe.fields.StripeDateTimeField( + help_text="Might be the date when this invoiceitem was added to the invoice" + ), + ), + ( + "proration", + models.BooleanField( + default=False, + help_text="Whether or not the invoice item was created automatically as a proration adjustment when the customer switched plans.", + ), + ), + ( + "quantity", + models.IntegerField( + help_text="If the invoice item is a proration, the quantity of the subscription for which the proration was computed.", + null=True, + blank=True, + ), + ), + ( + "customer", + models.ForeignKey( + help_text="The customer associated with this invoiceitem.", + on_delete=django.db.models.deletion.CASCADE, + related_name="invoiceitems", + to="djstripe.Customer", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="PaymentMethod", + fields=[ + ( + "id", + models.CharField(max_length=255, primary_key=True, serialize=False), + ), + ("type", models.CharField(db_index=True, max_length=12)), + ], + ), + migrations.CreateModel( + name="Payout", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="Amount to be transferred to your bank account or debit card.", + max_digits=8, + ), + ), + ( + "arrival_date", + djstripe.fields.StripeDateTimeField( + help_text="Date the payout is expected to arrive in the bank. This factors in delays like weekends or bank holidays." + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "failure_code", + djstripe.fields.StripeEnumField( + blank=True, + enum=djstripe.enums.PayoutFailureCode, + help_text="Error code explaining reason for transfer failure if available. See https://stripe.com/docs/api/python#transfer_failures.", + max_length=23, + null=True, + ), + ), + ( + "failure_message", + models.TextField( + blank=True, + help_text="Message to user further explaining reason for payout failure if available.", + null=True, + ), + ), + ( + "method", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.PayoutMethod, + help_text="The method used to send this payout. `instant` is only supported for payouts to debit cards.", + max_length=8, + ), + ), + ( + "statement_descriptor", + models.CharField( + blank=True, + help_text="Extra information about a payout to be displayed on the user's bank statement.", + max_length=255, + null=True, + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.PayoutStatus, + help_text="Current status of the payout. A payout will be `pending` until it is submitted to the bank, at which point it becomes `in_transit`. It will then change to paid if the transaction goes through. If it does not go through successfully, its status will change to `failed` or `canceled`.", + max_length=10, + ), + ), + ( + "type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.PayoutType, max_length=12 + ), + ), + ( + "destination", + models.ForeignKey( + help_text="ID of the bank account or card the payout was sent to.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="djstripe.BankAccount", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Plan", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "aggregate_usage", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.PlanAggregateUsage, + help_text="Specifies a usage aggregation strategy for plans of usage_type=metered. Allowed values are `sum` for summing up all usage during a period, `last_during_period` for picking the last usage record reported within a period, `last_ever` for picking the last usage record ever (across period bounds) or max which picks the usage record with the maximum reported usage during a period. Defaults to `sum`.", + max_length=18, + null=True, + blank=True, + ), + ), + ( + "amount", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="Amount to be charged on the interval specified.", + max_digits=8, + ), + ), + ( + "billing_scheme", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.PlanBillingScheme, + help_text="Describes how to compute the price per period. Either `per_unit` or `tiered`. `per_unit` indicates that the fixed amount (specified in amount) will be charged per unit in quantity (for plans with `usage_type=licensed`), or per unit of total usage (for plans with `usage_type=metered`). `tiered` indicates that the unit pricing will be computed using a tiering strategy as defined using the tiers and tiers_mode attributes.", + max_length=8, + null=True, + blank=True, + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "interval", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.PlanInterval, + help_text="The frequency with which a subscription should be billed.", + max_length=5, + ), + ), + ( + "interval_count", + models.IntegerField( + help_text="The number of intervals (specified in the interval property) between each subscription billing.", + null=True, + ), + ), + ( + "nickname", + models.CharField( + help_text="A brief description of the plan, hidden from customers.", + max_length=5000, + null=True, + blank=True, + ), + ), + ( + "tiers", + djstripe.fields.JSONField( + help_text="Each element represents a pricing tier. This parameter requires `billing_scheme` to be set to `tiered`.", + null=True, + blank=True, + ), + ), + ( + "tiers_mode", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.PlanTiersMode, + help_text="Defines if the tiering price should be `graduated` or `volume` based. In `volume`-based tiering, the maximum quantity within a period determines the per unit price, in `graduated` tiering pricing can successively change as the quantity grows.", + max_length=9, + null=True, + blank=True, + ), + ), + ( + "transform_usage", + djstripe.fields.JSONField( + help_text="Apply a transformation to the reported usage or set quantity before computing the billed price. Cannot be combined with `tiers`.", + null=True, + blank=True, + ), + ), + ( + "trial_period_days", + models.IntegerField( + help_text="Number of trial period days granted when subscribing a customer to this plan. Null if the plan has no trial period.", + null=True, + ), + ), + ( + "usage_type", + djstripe.fields.StripeEnumField( + default="licensed", + enum=djstripe.enums.PlanUsageType, + help_text="Configures how the quantity per period should be determined, can be either `metered` or `licensed`. `licensed` will automatically bill the `quantity` set for a plan when adding it to a subscription, `metered` will aggregate the total usage based on usage records. Defaults to `licensed`.", + max_length=8, + ), + ), + ( + "name", + models.TextField( + help_text="Name of the plan, to be displayed on invoices and in the web interface.", + null=True, + blank=True, + ), + ), + ( + "statement_descriptor", + models.CharField( + help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", + max_length=22, + null=True, + blank=True, + ), + ), + ], + options={"ordering": ["amount"]}, + ), + migrations.CreateModel( + name="Product", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "name", + models.CharField( + help_text="The product's name, meant to be displayable to the customer. Applicable to both `service` and `good` types.", + max_length=5000, + ), + ), + ( + "type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.ProductType, + help_text="The type of the product. The product is either of type `good`, which is eligible for use with Orders and SKUs, or `service`, which is eligible for use with Subscriptions and Plans.", + max_length=7, + ), + ), + ( + "active", + models.NullBooleanField( + help_text="Whether the product is currently available for purchase. Only applicable to products of `type=good`." + ), + ), + ( + "attributes", + djstripe.fields.JSONField( + help_text='A list of up to 5 attributes that each SKU can provide values for (e.g., `["color", "size"]`). Only applicable to products of `type=good`.', + null=True, + blank=True, + ), + ), + ( + "caption", + models.CharField( + help_text="A short one-line description of the product, meant to be displayableto the customer. Only applicable to products of `type=good`.", + max_length=5000, + null=True, + blank=True, + ), + ), + ( + "deactivate_on", + djstripe.fields.JSONField( + blank=True, + help_text="An array of connect application identifiers that cannot purchase this product. Only applicable to products of `type=good`.", + ), + ), + ( + "images", + djstripe.fields.JSONField( + blank=True, + help_text="A list of up to 8 URLs of images for this product, meant to be displayable to the customer. Only applicable to products of `type=good`.", + ), + ), + ( + "package_dimensions", + djstripe.fields.JSONField( + help_text="The dimensions of this product for shipping purposes. A SKU associated with this product can override this value by having its own `package_dimensions`. Only applicable to products of `type=good`.", + null=True, + blank=True, + ), + ), + ( + "shippable", + models.NullBooleanField( + help_text="Whether this product is a shipped good. Only applicable to products of `type=good`." + ), + ), + ( + "url", + models.CharField( + help_text="A URL of a publicly-accessible webpage for this product. Only applicable to products of `type=good`.", + max_length=799, + null=True, + blank=True, + ), + ), + ( + "statement_descriptor", + models.CharField( + help_text="Extra information about a product which will appear on your customer's credit card statement. In the case that multiple products are billed at once, the first statement descriptor will be used. Only available on products of type=`service`.", + max_length=22, + null=True, + blank=True, + ), + ), + ("unit_label", models.CharField(max_length=12, null=True)), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Refund", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Amount, in cents." + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "failure_reason", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.RefundFailureReason, + help_text="If the refund failed, the reason for refund failure if known.", + max_length=24, + null=True, + blank=True, + ), + ), + ( + "reason", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.RefundReason, + help_text="Reason for the refund.", + max_length=21, + null=True, + ), + ), + ( + "receipt_number", + models.CharField( + help_text="The transaction number that appears on email receipts sent for this charge.", + max_length=9, + null=True, + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.RefundFailureReason, + help_text="Status of the refund.", + max_length=24, + ), + ), + ( + "charge", + models.ForeignKey( + help_text="The charge that was refunded", + on_delete=django.db.models.deletion.CASCADE, + related_name="refunds", + to="djstripe.Charge", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Source", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeDecimalCurrencyAmountField( + blank=True, + decimal_places=2, + help_text="Amount associated with the source. This is the amount for which the source will be chargeable once ready. Required for `single_use` sources.", + max_digits=8, + null=True, + ), + ), + ( + "client_secret", + models.CharField( + help_text="The client secret of the source. Used for client-side retrieval using a publishable key.", + max_length=255, + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + blank=True, + help_text="Three-letter ISO currency code", + max_length=3, + null=True, + ), + ), + ( + "flow", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.SourceFlow, + help_text="The authentication flow of the source.", + max_length=17, + ), + ), + ( + "owner", + djstripe.fields.JSONField( + help_text="Information about the owner of the payment instrument that may be used or required by particular source types." + ), + ), + ( + "statement_descriptor", + models.CharField( + blank=True, + help_text="Extra information about a source. This will appear on your customer's statement every time you charge the source.", + max_length=255, + null=True, + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.SourceStatus, + help_text="The status of the source. Only `chargeable` sources can be used to create a charge.", + max_length=10, + ), + ), + ( + "type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.SourceType, + help_text="The type of the source.", + max_length=19, + ), + ), + ( + "usage", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.SourceUsage, + help_text="Whether this source should be reusable or not. Some source types may or may not be reusable by construction, while other may leave the option at creation.", + max_length=10, + ), + ), + ( + "code_verification", + djstripe.fields.JSONField( + blank=True, + help_text="Information related to the code verification flow. Present if the source is authenticated by a verification code (`flow` is `code_verification`).", + null=True, + ), + ), + ( + "receiver", + djstripe.fields.JSONField( + blank=True, + help_text="Information related to the receiver flow. Present if the source is a receiver (`flow` is `receiver`).", + null=True, + ), + ), + ( + "redirect", + djstripe.fields.JSONField( + blank=True, + help_text="Information related to the redirect flow. Present if the source is authenticated by a redirect (`flow` is `redirect`).", + null=True, + ), + ), + ( + "source_data", + djstripe.fields.JSONField( + help_text="The data corresponding to the source type." + ), + ), + ( + "customer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sources", + to="djstripe.Customer", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Subscription", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "application_fee_percent", + djstripe.fields.StripePercentField( + blank=True, + decimal_places=2, + help_text="A positive decimal that represents the fee percentage of the subscription invoice amount that will be transferred to the application owner's Stripe account each billing period.", + max_digits=5, + null=True, + validators=[ + django.core.validators.MinValueValidator(1.0), + django.core.validators.MaxValueValidator(100.0), + ], + ), + ), + ( + "billing", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.InvoiceBilling, + help_text="Either `charge_automatically`, or `send_invoice`. When charging automatically, Stripe will attempt to pay this subscription at the end of the cycle using the default source attached to the customer. When sending an invoice, Stripe will email your customer an invoice with payment instructions.", + max_length=20, + ), + ), + ( + "billing_cycle_anchor", + djstripe.fields.StripeDateTimeField( + help_text="Determines the date of the first full invoice, and, for plans with `month` or `year` intervals, the day of the month for subsequent invoices.", + null=True, + blank=True, + ), + ), + ( + "cancel_at_period_end", + models.BooleanField( + default=False, + help_text="If the subscription has been canceled with the ``at_period_end`` flag set to true, ``cancel_at_period_end`` on the subscription will be true. You can use this attribute to determine whether a subscription that has a status of active is scheduled to be canceled at the end of the current period.", + ), + ), + ( + "canceled_at", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="If the subscription has been canceled, the date of that cancellation. If the subscription was canceled with ``cancel_at_period_end``, canceled_at will still reflect the date of the initial cancellation request, not the end of the subscription period when the subscription is automatically moved to a canceled state.", + null=True, + ), + ), + ( + "current_period_end", + djstripe.fields.StripeDateTimeField( + help_text="End of the current period for which the subscription has been invoiced. At the end of this period, a new invoice will be created." + ), + ), + ( + "current_period_start", + djstripe.fields.StripeDateTimeField( + help_text="Start of the current period for which the subscription has been invoiced." + ), + ), + ( + "days_until_due", + models.IntegerField( + help_text="Number of days a customer has to pay invoices generated by this subscription. This value will be `null` for subscriptions where `billing=charge_automatically`.", + null=True, + blank=True, + ), + ), + ( + "ended_at", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="If the subscription has ended (either because it was canceled or because the customer was switched to a subscription to a new plan), the date the subscription ended.", + null=True, + ), + ), + ( + "quantity", + models.IntegerField( + help_text="The quantity applied to this subscription." + ), + ), + ( + "start", + djstripe.fields.StripeDateTimeField( + help_text="Date the subscription started." + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.SubscriptionStatus, + help_text="The status of this subscription.", + max_length=8, + ), + ), + ( + "tax_percent", + djstripe.fields.StripePercentField( + blank=True, + decimal_places=2, + help_text="A positive decimal (with at most two decimal places) between 1 and 100. This represents the percentage of the subscription invoice subtotal that will be calculated and added as tax to the final amount each billing period.", + max_digits=5, + null=True, + validators=[ + django.core.validators.MinValueValidator(1.0), + django.core.validators.MaxValueValidator(100.0), + ], + ), + ), + ( + "trial_end", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="If the subscription has a trial, the end of that trial.", + null=True, + ), + ), + ( + "trial_start", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="If the subscription has a trial, the beginning of that trial.", + null=True, + ), + ), + ( + "customer", + models.ForeignKey( + help_text="The customer associated with this subscription.", + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to="djstripe.Customer", + ), + ), + ( + "plan", + models.ForeignKey( + help_text="The plan associated with this subscription.", + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to="djstripe.Plan", + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="Transfer", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "stripe_id", + djstripe.fields.StripeIdField(max_length=255, unique=True), + ), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + help_text="The datetime this object was created in stripe.", + null=True, + blank=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="The amount transferred", + max_digits=8, + ), + ), + ( + "amount_reversed", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, + help_text="The amount reversed (can be less than the amount attribute on the transfer if a partial reversal was issued).", + max_digits=8, + null=True, + blank=True, + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "destination", + djstripe.fields.StripeIdField( + help_text="ID of the bank account, card, or Stripe account the transfer was sent to.", + max_length=255, + ), + ), + ( + "destination_payment", + djstripe.fields.StripeIdField( + help_text="If the destination is a Stripe account, this will be the ID of the payment that the destination account received for the transfer.", + max_length=255, + null=True, + blank=True, + ), + ), + ( + "reversed", + models.BooleanField( + default=False, + help_text="Whether or not the transfer has been fully reversed. If the transfer is only partially reversed, this attribute will still be false.", + ), + ), + ( + "source_transaction", + djstripe.fields.StripeIdField( + help_text="ID of the charge (or other transaction) that was used to fund the transfer. If null, the transfer was funded from the available balance.", + max_length=255, + null=True, + ), + ), + ( + "source_type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.LegacySourceType, + help_text="The source balance from which this transfer came.", + max_length=16, + ), + ), + ( + "transfer_group", + models.CharField( + blank=True, + help_text="A string that identifies this transaction as part of a group.", + max_length=255, + null=True, + ), + ), + ( + "date", + djstripe.fields.StripeDateTimeField( + help_text="Date the transfer is scheduled to arrive in the bank. This doesn't factor in delays like weekends or bank holidays." + ), + ), + ( + "destination_type", + models.CharField( + blank=True, + help_text="The type of the transfer destination.", + max_length=14, + null=True, + ), + ), + ( + "failure_code", + djstripe.fields.StripeEnumField( + blank=True, + enum=djstripe.enums.PayoutFailureCode, + help_text="Error code explaining reason for transfer failure if available. See https://stripe.com/docs/api/python#transfer_failures.", + max_length=23, + null=True, + ), + ), + ( + "failure_message", + models.TextField( + blank=True, + help_text="Message to user further explaining reason for transfer failure if available.", + null=True, + ), + ), + ( + "statement_descriptor", + models.CharField( + help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", + max_length=22, + null=True, + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + blank=True, + enum=djstripe.enums.PayoutStatus, + help_text="The current status of the transfer. A transfer will be pending until it is submitted to the bank, at which point it becomes in_transit. It will then change to paid if the transaction goes through. If it does not go through successfully, its status will change to failed or canceled.", + max_length=10, + null=True, + ), + ), + ( + "fee", + djstripe.fields.StripeDecimalCurrencyAmountField( + decimal_places=2, max_digits=8, null=True, blank=True + ), + ), + ("fee_details", djstripe.fields.JSONField(null=True, blank=True)), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="WebhookEventTrigger", + fields=[ + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ( + "remote_ip", + models.GenericIPAddressField( + help_text="IP address of the request client." + ), + ), + ("headers", djstripe.fields.JSONField()), + ("body", models.TextField(blank=True)), + ( + "valid", + models.BooleanField( + default=False, + help_text="Whether or not the webhook event has passed validation", + ), + ), + ( + "processed", + models.BooleanField( + default=False, + help_text="Whether or not the webhook event has been successfully processed", + ), + ), + ("exception", models.CharField(blank=True, max_length=128)), + ( + "traceback", + models.TextField( + blank=True, + help_text="Traceback if an exception was thrown during processing", + ), + ), + ( + "djstripe_version", + models.CharField( + default=_get_version, + help_text="The version of dj-stripe when the webhook was received", + max_length=32, + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ( + "event", + models.ForeignKey( + blank=True, + help_text="Event object contained in the (valid) Webhook", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.Event", + ), + ), + ], + ), + migrations.CreateModel( + name="UpcomingInvoice", + fields=[ + ( + "invoice_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="djstripe.Invoice", + ), + ) + ], + options={"abstract": False}, + bases=("djstripe.invoice",), + ), + migrations.AddField( + model_name="plan", + name="product", + field=models.ForeignKey( + help_text="The product whose pricing this plan determines.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.Product", + ), + ), + migrations.AddField( + model_name="invoiceitem", + name="invoice", + field=models.ForeignKey( + help_text="The invoice to which this invoiceitem is attached.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="invoiceitems", + to="djstripe.Invoice", + ), + ), + migrations.AddField( + model_name="invoiceitem", + name="plan", + field=models.ForeignKey( + help_text="If the invoice item is a proration, the plan of the subscription for which the proration was computed.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invoiceitems", + to="djstripe.Plan", + ), + ), + migrations.AddField( + model_name="invoiceitem", + name="subscription", + field=models.ForeignKey( + help_text="The subscription that this invoice item has been created for, if any.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invoiceitems", + to="djstripe.Subscription", + ), + ), + migrations.AddField( + model_name="invoice", + name="charge", + field=models.OneToOneField( + help_text="The latest charge generated for this invoice, if any.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="latest_invoice", + to="djstripe.Charge", + ), + ), + migrations.AddField( + model_name="invoice", + name="customer", + field=models.ForeignKey( + help_text="The customer associated with this invoice.", + on_delete=django.db.models.deletion.CASCADE, + related_name="invoices", + to="djstripe.Customer", + ), + ), + migrations.AddField( + model_name="invoice", + name="subscription", + field=models.ForeignKey( + help_text="The subscription that this invoice was prepared for, if any.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="invoices", + to="djstripe.Subscription", + ), + ), + migrations.AlterUniqueTogether( + name="idempotencykey", unique_together={("action", "livemode")} + ), + migrations.AddField( + model_name="customer", + name="default_source", + field=djstripe.fields.PaymentMethodForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="customers", + to="djstripe.PaymentMethod", + ), + ), + migrations.AddField( + model_name="customer", + name="subscriber", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="djstripe_customers", + to=DJSTRIPE_SUBSCRIBER_MODEL, + ), + ), + migrations.AlterUniqueTogether( + name="coupon", unique_together={("stripe_id", "livemode")} + ), + migrations.AddField( + model_name="charge", + name="customer", + field=models.ForeignKey( + help_text="The customer associated with this charge.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="charges", + to="djstripe.Customer", + ), + ), + migrations.AddField( + model_name="charge", + name="dispute", + field=models.ForeignKey( + help_text="Details about the dispute if the charge has been disputed.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="charges", + to="djstripe.Dispute", + ), + ), + migrations.AddField( + model_name="charge", + name="invoice", + field=models.ForeignKey( + help_text="The invoice this charge is for if one exists.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="charges", + to="djstripe.Invoice", + ), + ), + migrations.AddField( + model_name="charge", + name="source", + field=djstripe.fields.PaymentMethodForeignKey( + help_text="The source used for this charge.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="charges", + to="djstripe.PaymentMethod", + ), + ), + migrations.AddField( + model_name="charge", + name="transfer", + field=models.ForeignKey( + help_text="The transfer to the destination account (only applicable if the charge was created using the destination parameter).", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="djstripe.Transfer", + ), + ), + migrations.AddField( + model_name="card", + name="customer", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="legacy_cards", + to="djstripe.Customer", + ), + ), + migrations.AddField( + model_name="bankaccount", + name="customer", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="bank_account", + to="djstripe.Customer", + ), + ), + migrations.AddField( + model_name="account", + name="business_logo", + field=models.ForeignKey( + help_text="An icon for the account. Must be square and at least 128px x 128px.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="icon_account", + to="djstripe.FileUpload", + ), + ), + migrations.AlterUniqueTogether( + name="customer", unique_together={("subscriber", "livemode")} + ), + ] diff --git a/djstripe/migrations/0002_auto_20180627_1121.py b/djstripe/migrations/0002_auto_20180627_1121.py index 15ed1f5a26..7244936a26 100644 --- a/djstripe/migrations/0002_auto_20180627_1121.py +++ b/djstripe/migrations/0002_auto_20180627_1121.py @@ -7,54 +7,54 @@ class Migration(migrations.Migration): - dependencies = [("djstripe", "0001_initial")] + dependencies = [("djstripe", "0001_initial")] - operations = [ - migrations.AlterField( - model_name="account", - name="business_name", - field=models.CharField( - help_text="The publicly visible name of the business", - max_length=255, - null=True, - blank=True, - ), - ), - migrations.AlterField( - model_name="account", - name="support_url", - field=models.CharField( - help_text="A publicly shareable URL that provides support for this account", - max_length=200, - null=True, - blank=True, - ), - ), - migrations.AlterField( - model_name="charge", - name="receipt_number", - field=models.CharField( - help_text="The transaction number that appears on email receipts sent for this charge.", - max_length=14, - null=True, - ), - ), - migrations.AlterField( - model_name="product", - name="deactivate_on", - field=djstripe.fields.JSONField( - help_text="An array of connect application identifiers that cannot purchase this product. Only applicable to products of `type=good`.", - null=True, - blank=True, - ), - ), - migrations.AlterField( - model_name="product", - name="images", - field=djstripe.fields.JSONField( - help_text="A list of up to 8 URLs of images for this product, meant to be displayable to the customer. Only applicable to products of `type=good`.", - null=True, - blank=True, - ), - ), - ] + operations = [ + migrations.AlterField( + model_name="account", + name="business_name", + field=models.CharField( + help_text="The publicly visible name of the business", + max_length=255, + null=True, + blank=True, + ), + ), + migrations.AlterField( + model_name="account", + name="support_url", + field=models.CharField( + help_text="A publicly shareable URL that provides support for this account", + max_length=200, + null=True, + blank=True, + ), + ), + migrations.AlterField( + model_name="charge", + name="receipt_number", + field=models.CharField( + help_text="The transaction number that appears on email receipts sent for this charge.", + max_length=14, + null=True, + ), + ), + migrations.AlterField( + model_name="product", + name="deactivate_on", + field=djstripe.fields.JSONField( + help_text="An array of connect application identifiers that cannot purchase this product. Only applicable to products of `type=good`.", + null=True, + blank=True, + ), + ), + migrations.AlterField( + model_name="product", + name="images", + field=djstripe.fields.JSONField( + help_text="A list of up to 8 URLs of images for this product, meant to be displayable to the customer. Only applicable to products of `type=good`.", + null=True, + blank=True, + ), + ), + ] diff --git a/djstripe/migrations/0003_auto_20181117_2328_squashed_0004_auto_20190227_2114.py b/djstripe/migrations/0003_auto_20181117_2328_squashed_0004_auto_20190227_2114.py index ba1287e048..966682adf9 100644 --- a/djstripe/migrations/0003_auto_20181117_2328_squashed_0004_auto_20190227_2114.py +++ b/djstripe/migrations/0003_auto_20181117_2328_squashed_0004_auto_20190227_2114.py @@ -9,1372 +9,1497 @@ def postgres_migration_prep(apps, schema_editor): - """ - Set null text fields to empty string to workaround incompatibility with migration 0003 on postgres - See https://github.com/dj-stripe/dj-stripe/issues/850 - """ + """ + Set null text fields to empty string to workaround incompatibility with migration 0003 on postgres + See https://github.com/dj-stripe/dj-stripe/issues/850 + """ - Account = apps.get_model("djstripe", "Account") - BankAccount = apps.get_model("djstripe", "BankAccount") - Card = apps.get_model("djstripe", "Card") - Charge = apps.get_model("djstripe", "Charge") - Customer = apps.get_model("djstripe", "Customer") - Event = apps.get_model("djstripe", "Event") - Invoice = apps.get_model("djstripe", "Invoice") - Payout = apps.get_model("djstripe", "Payout") - Plan = apps.get_model("djstripe", "Plan") - Product = apps.get_model("djstripe", "Product") - Refund = apps.get_model("djstripe", "Refund") - Source = apps.get_model("djstripe", "Source") - Transfer = apps.get_model("djstripe", "Transfer") + Account = apps.get_model("djstripe", "Account") + BankAccount = apps.get_model("djstripe", "BankAccount") + Card = apps.get_model("djstripe", "Card") + Charge = apps.get_model("djstripe", "Charge") + Customer = apps.get_model("djstripe", "Customer") + Event = apps.get_model("djstripe", "Event") + Invoice = apps.get_model("djstripe", "Invoice") + Payout = apps.get_model("djstripe", "Payout") + Plan = apps.get_model("djstripe", "Plan") + Product = apps.get_model("djstripe", "Product") + Refund = apps.get_model("djstripe", "Refund") + Source = apps.get_model("djstripe", "Source") + Transfer = apps.get_model("djstripe", "Transfer") - model_fields = [ - ( - Account, - ( - "business_name", - "business_primary_color", - "business_url", - "payout_statement_descriptor", - "product_description", - "support_url", - ), - ), - (BankAccount, ("account_holder_name",)), - ( - Card, - ( - "address_city", - "address_country", - "address_line1", - "address_line1_check", - "address_line2", - "address_state", - "address_zip", - "address_zip_check", - "country", - "cvc_check", - "dynamic_last4", - "fingerprint", - "name", - "tokenization_method", - ), - ), - ( - Charge, - ( - "failure_code", - "failure_message", - "receipt_email", - "receipt_number", - "statement_descriptor", - "transfer_group", - ), - ), - (Customer, ("business_vat_id", "currency", "email")), - (Event, ("idempotency_key", "request_id")), - (Invoice, ("hosted_invoice_url", "invoice_pdf", "number", "statement_descriptor")), - (Payout, ("failure_code", "failure_message", "statement_descriptor")), - (Plan, ("aggregate_usage", "billing_scheme", "nickname")), - (Product, ("caption", "statement_descriptor", "unit_label")), - (Refund, ("failure_reason", "reason", "receipt_number")), - (Source, ("currency", "statement_descriptor")), - (Transfer, ("transfer_group",)), - ] + model_fields = [ + ( + Account, + ( + "business_name", + "business_primary_color", + "business_url", + "payout_statement_descriptor", + "product_description", + "support_url", + ), + ), + (BankAccount, ("account_holder_name",)), + ( + Card, + ( + "address_city", + "address_country", + "address_line1", + "address_line1_check", + "address_line2", + "address_state", + "address_zip", + "address_zip_check", + "country", + "cvc_check", + "dynamic_last4", + "fingerprint", + "name", + "tokenization_method", + ), + ), + ( + Charge, + ( + "failure_code", + "failure_message", + "receipt_email", + "receipt_number", + "statement_descriptor", + "transfer_group", + ), + ), + (Customer, ("business_vat_id", "currency", "email")), + (Event, ("idempotency_key", "request_id")), + ( + Invoice, + ("hosted_invoice_url", "invoice_pdf", "number", "statement_descriptor"), + ), + (Payout, ("failure_code", "failure_message", "statement_descriptor")), + (Plan, ("aggregate_usage", "billing_scheme", "nickname")), + (Product, ("caption", "statement_descriptor", "unit_label")), + (Refund, ("failure_reason", "reason", "receipt_number")), + (Source, ("currency", "statement_descriptor")), + (Transfer, ("transfer_group",)), + ] - for model, fields in model_fields: - for field in fields: - filter_param = {"{}__isnull".format(field): True} - update_param = {field: ""} - model.objects.filter(**filter_param).update(**update_param) + for model, fields in model_fields: + for field in fields: + filter_param = {"{}__isnull".format(field): True} + update_param = {field: ""} + model.objects.filter(**filter_param).update(**update_param) class Migration(migrations.Migration): - def __init__(self, *args, **kwargs): - # Hack to support sqlite for quick testing. Without this migrations fail with: - # "django.db.utils.NotSupportedError: Renaming the 'djstripe_paymentmethod' table while in a transaction - # is not supported on SQLite because it would break referential integrity. - # Try adding `atomic = False` to the Migration class." - from django.db import connection + def __init__(self, *args, **kwargs): + # Hack to support sqlite for quick testing. Without this migrations fail with: + # "django.db.utils.NotSupportedError: Renaming the 'djstripe_paymentmethod' table while in a transaction + # is not supported on SQLite because it would break referential integrity. + # Try adding `atomic = False` to the Migration class." + from django.db import connection - # use getattr because I think connection.vendor isn't documented - if getattr(connection, "vendor", None) == "sqlite": - self.atomic = False + # use getattr because I think connection.vendor isn't documented + if getattr(connection, "vendor", None) == "sqlite": + self.atomic = False - super().__init__(*args, **kwargs) + super().__init__(*args, **kwargs) - dependencies = [("djstripe", "0002_auto_20180627_1121")] + dependencies = [("djstripe", "0002_auto_20180627_1121")] - operations = [ - migrations.RunPython(postgres_migration_prep, migrations.RunPython.noop), - migrations.AlterField( - model_name="account", - name="display_name", - field=models.CharField( - blank=True, - default="", - help_text="The display name for this account. This is used on the Stripe Dashboard to differentiate between accounts.", - max_length=255, - ), - ), - migrations.AlterField( - model_name="account", - name="support_email", - field=models.CharField( - blank=True, - default="", - help_text="A publicly shareable support email address for the business", - max_length=255, - ), - ), - migrations.AlterField( - model_name="account", - name="support_phone", - field=models.CharField( - blank=True, - default="", - help_text="A publicly shareable support phone number for the business", - max_length=255, - ), - ), - migrations.AlterField( - model_name="card", - name="customer", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="legacy_cards", - to="djstripe.Customer", - ), - ), - migrations.AddField( - model_name="coupon", - name="name", - field=models.TextField( - blank=True, - default="", - help_text="Name of the coupon displayed to customers on for instance invoices or receipts.", - max_length=5000, - ), - ), - migrations.AlterField( - model_name="source", - name="type", - field=djstripe.fields.StripeEnumField( - enum=djstripe.enums.SourceType, help_text="The type of the source.", max_length=20 - ), - ), - migrations.AddField( - model_name="plan", - name="active", - field=models.BooleanField( - default=True, - help_text="Whether the plan is currently available for new subscriptions.", - ), - preserve_default=False, - ), - migrations.RemoveField(model_name="charge", name="fraudulent"), - migrations.RemoveField(model_name="charge", name="receipt_sent"), - migrations.RemoveField(model_name="charge", name="source_stripe_id"), - migrations.RemoveField(model_name="charge", name="source_type"), - migrations.RemoveField(model_name="charge", name="fee"), - migrations.RemoveField(model_name="charge", name="fee_details"), - migrations.RemoveField(model_name="transfer", name="date"), - migrations.RemoveField(model_name="transfer", name="destination_type"), - migrations.RemoveField(model_name="transfer", name="failure_code"), - migrations.RemoveField(model_name="transfer", name="failure_message"), - migrations.RemoveField(model_name="transfer", name="fee"), - migrations.RemoveField(model_name="transfer", name="fee_details"), - migrations.RemoveField(model_name="transfer", name="statement_descriptor"), - migrations.RemoveField(model_name="transfer", name="status"), - migrations.AlterModelOptions(name="account", options={"get_latest_by": "created"}), - migrations.AlterModelOptions( - name="bankaccount", options={"get_latest_by": "created"} - ), - migrations.AlterModelOptions(name="card", options={"get_latest_by": "created"}), - migrations.AlterModelOptions(name="charge", options={"get_latest_by": "created"}), - migrations.AlterModelOptions(name="dispute", options={"get_latest_by": "created"}), - migrations.AlterModelOptions(name="event", options={"get_latest_by": "created"}), - migrations.AlterModelOptions(name="fileupload", options={"get_latest_by": "created"}), - migrations.AlterModelOptions( - name="invoiceitem", options={"get_latest_by": "created"} - ), - migrations.AlterModelOptions(name="payout", options={"get_latest_by": "created"}), - migrations.AlterModelOptions(name="product", options={"get_latest_by": "created"}), - migrations.AlterModelOptions(name="refund", options={"get_latest_by": "created"}), - migrations.AlterModelOptions(name="source", options={"get_latest_by": "created"}), - migrations.AlterModelOptions( - name="subscription", options={"get_latest_by": "created"} - ), - migrations.AlterModelOptions(name="transfer", options={"get_latest_by": "created"}), - migrations.AlterModelOptions( - name="upcominginvoice", options={"get_latest_by": "created"} - ), - migrations.CreateModel( - name="CountrySpec", - fields=[ - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ("id", models.CharField(max_length=2, primary_key=True, serialize=False)), - ( - "default_currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="The default currency for this country. This applies to both payment methods and bank accounts.", - max_length=3, - ), - ), - ( - "supported_bank_account_currencies", - djstripe.fields.JSONField( - help_text="Currencies that can be accepted in the specific country (for transfers)." - ), - ), - ( - "supported_payment_currencies", - djstripe.fields.JSONField( - help_text="Currencies that can be accepted in the specified country (for payments)." - ), - ), - ( - "supported_payment_methods", - djstripe.fields.JSONField( - help_text="Payment methods available in the specified country." - ), - ), - ( - "supported_transfer_countries", - djstripe.fields.JSONField( - help_text="Countries that can accept transfers from the specified country." - ), - ), - ( - "verification_fields", - djstripe.fields.JSONField( - help_text="Lists the types of verification data needed to keep an account open." - ), - ), - ], - options={"abstract": False}, - ), - migrations.CreateModel( - name="BalanceTransaction", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeQuantumCurrencyAmountField( - help_text="Gross amount of the transaction, in cents." - ), - ), - ( - "available_on", - djstripe.fields.StripeDateTimeField( - help_text="The date the transaction's net funds will become available in the Stripe balance." - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ("exchange_rate", models.DecimalField(null=True, decimal_places=6, max_digits=8)), - ( - "fee", - djstripe.fields.StripeQuantumCurrencyAmountField( - help_text="Fee (in cents) paid for this transaction." - ), - ), - ("fee_details", djstripe.fields.JSONField()), - ( - "net", - djstripe.fields.StripeQuantumCurrencyAmountField( - help_text="Net amount of the transaction, in cents." - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.BalanceTransactionStatus, max_length=9 - ), - ), - ( - "type", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.BalanceTransactionType, max_length=22 - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.CreateModel( - name="ScheduledQueryRun", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "data_load_time", - djstripe.fields.StripeDateTimeField( - help_text="When the query was run, Sigma contained a snapshot of your Stripe data at this time." - ), - ), - ( - "error", - djstripe.fields.JSONField( - blank=True, - help_text="If the query run was not succeesful, contains information about the failure.", - null=True, - ), - ), - ( - "result_available_until", - djstripe.fields.StripeDateTimeField( - help_text="Time at which the result expires and is no longer available for download." - ), - ), - ("sql", models.TextField(help_text="SQL for the query.", max_length=5000)), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.ScheduledQueryRunStatus, - help_text="The query's execution status.", - max_length=9, - ), - ), - ("title", models.TextField(help_text="Title of the query.", max_length=5000)), - ( - "file", - models.ForeignKey( - blank=True, - help_text="The file object representing the results of the query.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.FileUpload", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.CreateModel( - name="SubscriptionItem", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "quantity", - models.PositiveIntegerField( - help_text="The quantity of the plan to which the customer should be subscribed." - ), - ), - ( - "plan", - models.ForeignKey( - help_text="The plan the customer is subscribed to.", - on_delete=django.db.models.deletion.CASCADE, - related_name="subscription_items", - to="djstripe.Plan", - ), - ), - ( - "subscription", - models.ForeignKey( - help_text="The subscription this subscription item belongs to.", - on_delete=django.db.models.deletion.CASCADE, - related_name="items", - to="djstripe.Subscription", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.CreateModel( - name="TransferReversal", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeQuantumCurrencyAmountField(help_text="Amount, in cents."), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "balance_transaction", - models.ForeignKey( - blank=True, - help_text="Balance transaction that describes the impact on your account balance.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="transfer_reversals", - to="djstripe.BalanceTransaction", - ), - ), - ( - "transfer", - models.ForeignKey( - help_text="The transfer that was reversed.", - on_delete=django.db.models.deletion.CASCADE, - related_name="reversals", - to="djstripe.Transfer", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.CreateModel( - name="UsageRecord", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "quantity", - models.PositiveIntegerField( - help_text="The quantity of the plan to which the customer should be subscribed." - ), - ), - ( - "subscription_item", - models.ForeignKey( - help_text="The subscription item this usage record contains data for.", - on_delete=django.db.models.deletion.CASCADE, - related_name="usage_records", - to="djstripe.SubscriptionItem", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.CreateModel( - name="ApplicationFee", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeQuantumCurrencyAmountField(help_text="Amount earned."), - ), - ( - "amount_refunded", - djstripe.fields.StripeQuantumCurrencyAmountField( - help_text="Amount refunded (can be less than the amount attribute on the fee if a partial refund was issued)" - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "refunded", - models.BooleanField( - help_text="Whether the fee has been fully refunded. If the fee is only partially refunded, this attribute will still be false." - ), - ), - ( - "balance_transaction", - models.ForeignKey( - help_text="Balance transaction that describes the impact on your account balance.", - on_delete=django.db.models.deletion.CASCADE, - to="djstripe.BalanceTransaction", - ), - ), - ( - "charge", - models.ForeignKey( - help_text="The charge that the application fee was taken from.", - on_delete=django.db.models.deletion.CASCADE, - to="djstripe.Charge", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.CreateModel( - name="ApplicationFeeRefund", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeQuantumCurrencyAmountField(help_text="Amount refunded."), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "balance_transaction", - models.ForeignKey( - help_text="Balance transaction that describes the impact on your account balance.", - on_delete=django.db.models.deletion.CASCADE, - to="djstripe.BalanceTransaction", - ), - ), - ( - "fee", - models.ForeignKey( - help_text="The application fee that was refunded", - on_delete=django.db.models.deletion.CASCADE, - related_name="refunds", - to="djstripe.ApplicationFee", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.AddField( - model_name="charge", - name="balance_transaction", - field=models.ForeignKey( - help_text="The balance transaction that describes the impact of this charge on your account balance (not including refunds or disputes).", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.BalanceTransaction", - ), - ), - migrations.AddField( - model_name="payout", - name="balance_transaction", - field=models.ForeignKey( - help_text="Balance transaction that describes the impact on your account balance.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.BalanceTransaction", - ), - ), - migrations.AddField( - model_name="payout", - name="failure_balance_transaction", - field=models.ForeignKey( - help_text="If the payout failed or was canceled, this will be the balance transaction that reversed the initial balance transaction, and puts the funds from the failed payout back in your balance.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.BalanceTransaction", - related_name="failure_payouts", - ), - ), - migrations.AddField( - model_name="refund", - name="balance_transaction", - field=models.ForeignKey( - help_text="Balance transaction that describes the impact on your account balance.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.BalanceTransaction", - ), - ), - migrations.AddField( - model_name="refund", - name="failure_balance_transaction", - field=models.ForeignKey( - help_text="If the refund failed, this balance transaction describes the adjustment made on your account balance that reverses the initial balance transaction.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.BalanceTransaction", - related_name="failure_refunds", - ), - ), - migrations.AddField( - model_name="transfer", - name="balance_transaction", - field=models.ForeignKey( - blank=True, - help_text="Balance transaction that describes the impact on your account balance.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.BalanceTransaction", - ), - ), - migrations.RenameField(model_name="account", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="bankaccount", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="card", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="charge", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="customer", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="dispute", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="event", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="fileupload", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="invoice", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="invoiceitem", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="payout", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="plan", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="product", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="refund", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="source", old_name="stripe_id", new_name="id"), - migrations.RenameField( - model_name="subscription", old_name="stripe_id", new_name="id" - ), - migrations.RenameField(model_name="transfer", old_name="stripe_id", new_name="id"), - migrations.RenameField(model_name="coupon", old_name="stripe_id", new_name="id"), - migrations.AlterField( - model_name="coupon", - name="percent_off", - field=djstripe.fields.StripePercentField( - blank=True, - decimal_places=2, - help_text="Percent that will be taken off the subtotal of any invoices for this customer for the duration of the coupon. For example, a coupon with percent_off of 50 will make a $100 invoice $50 instead.", - max_digits=5, - null=True, - validators=[ - django.core.validators.MinValueValidator(1), - django.core.validators.MaxValueValidator(100), - ], - ), - ), - # Update all text-type fields to non-null CharField blank=True default="" - migrations.AlterField( - model_name="account", - name="business_name", - field=models.CharField( - blank=True, - default="", - help_text="The publicly visible name of the business", - max_length=255, - ), - ), - migrations.AlterField( - model_name="account", - name="business_primary_color", - field=models.CharField( - blank=True, - default="", - help_text="A CSS hex color value representing the primary branding color for this account", - max_length=7, - ), - ), - migrations.AlterField( - model_name="account", - name="business_url", - field=models.CharField( - blank=True, - default="", - help_text="The publicly visible website of the business", - max_length=200, - ), - ), - migrations.AlterField( - model_name="account", - name="payout_statement_descriptor", - field=models.CharField( - blank=True, - default="", - help_text="The text that appears on the bank account statement for payouts.", - max_length=255, - ), - ), - migrations.AlterField( - model_name="account", - name="product_description", - field=models.CharField( - blank=True, - default="", - help_text="Internal-only description of the product sold or service provided by the business. It’s used by Stripe for risk and underwriting purposes.", - max_length=255, - ), - ), - migrations.AlterField( - model_name="account", - name="support_url", - field=models.CharField( - blank=True, - default="", - help_text="A publicly shareable URL that provides support for this account", - max_length=200, - ), - ), - migrations.AlterField( - model_name="bankaccount", - name="account_holder_name", - field=models.TextField( - blank=True, - default="", - help_text="The name of the person or business that owns the bank account.", - max_length=5000, - ), - ), - migrations.AlterField( - model_name="card", - name="address_city", - field=models.TextField( - blank=True, - default="", - help_text="City/District/Suburb/Town/Village.", - max_length=5000, - ), - ), - migrations.AlterField( - model_name="card", - name="address_country", - field=models.TextField( - blank=True, default="", help_text="Billing address country.", max_length=5000 - ), - ), - migrations.AlterField( - model_name="card", - name="address_line1", - field=models.TextField( - blank=True, - default="", - help_text="Street address/PO Box/Company name.", - max_length=5000, - ), - ), - migrations.AlterField( - model_name="card", - name="address_line1_check", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.CardCheckResult, - help_text="If `address_line1` was provided, results of the check.", - max_length=11, - ), - ), - migrations.AlterField( - model_name="card", - name="address_line2", - field=models.TextField( - blank=True, default="", help_text="Apartment/Suite/Unit/Building.", max_length=5000 - ), - ), - migrations.AlterField( - model_name="card", - name="address_state", - field=models.TextField( - blank=True, default="", help_text="State/County/Province/Region.", max_length=5000 - ), - ), - migrations.AlterField( - model_name="card", - name="address_zip", - field=models.TextField( - blank=True, default="", help_text="ZIP or postal code.", max_length=5000 - ), - ), - migrations.AlterField( - model_name="card", - name="address_zip_check", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.CardCheckResult, - help_text="If `address_zip` was provided, results of the check.", - max_length=11, - ), - ), - migrations.AlterField( - model_name="card", - name="country", - field=models.CharField( - blank=True, - default="", - help_text="Two-letter ISO code representing the country of the card.", - max_length=2, - ), - ), - migrations.AlterField( - model_name="card", - name="cvc_check", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.CardCheckResult, - help_text="If a CVC was provided, results of the check.", - max_length=11, - ), - ), - migrations.AlterField( - model_name="card", - name="dynamic_last4", - field=models.CharField( - blank=True, - default="", - help_text="(For tokenized numbers only.) The last four digits of the device account number.", - max_length=4, - ), - ), - migrations.AlterField( - model_name="card", - name="fingerprint", - field=models.CharField( - blank=True, - default="", - help_text="Uniquely identifies this particular card number.", - max_length=16, - ), - ), - migrations.AlterField( - model_name="card", - name="name", - field=models.TextField( - blank=True, default="", help_text="Cardholder name.", max_length=5000 - ), - ), - migrations.AlterField( - model_name="card", - name="tokenization_method", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.CardTokenizationMethod, - help_text="If the card number is tokenized, this is the method that was used.", - max_length=11, - ), - ), - migrations.AlterField( - model_name="charge", - name="failure_code", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.ApiErrorCode, - help_text="Error code explaining reason for charge failure if available.", - max_length=42, - ), - ), - migrations.AlterField( - model_name="charge", - name="failure_message", - field=models.TextField( - blank=True, - default="", - help_text="Message to user further explaining reason for charge failure if available.", - max_length=5000, - ), - ), - migrations.AlterField( - model_name="charge", - name="receipt_email", - field=models.TextField( - blank=True, - default="", - help_text="The email address that the receipt for this charge was sent to.", - max_length=800, - ), - ), - migrations.AlterField( - model_name="charge", - name="receipt_number", - field=models.CharField( - blank=True, - default="", - help_text="The transaction number that appears on email receipts sent for this charge.", - max_length=14, - ), - ), - migrations.AlterField( - model_name="charge", - name="statement_descriptor", - field=models.CharField( - blank=True, - default="", - help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", - max_length=22, - ), - ), - migrations.AlterField( - model_name="charge", - name="transfer_group", - field=models.CharField( - blank=True, - default="", - help_text="A string that identifies this transaction as part of a group.", - max_length=255, - ), - ), - migrations.AlterField( - model_name="customer", - name="business_vat_id", - field=models.CharField( - blank=True, - default="", - help_text="The customer's VAT identification number.", - max_length=20, - ), - ), - migrations.AlterField( - model_name="customer", - name="currency", - field=djstripe.fields.StripeCurrencyCodeField( - default="", - help_text="The currency the customer can be charged in for recurring billing purposes", - max_length=3, - ), - ), - migrations.AlterField( - model_name="customer", - name="email", - field=models.TextField(blank=True, default="", max_length=5000), - ), - migrations.AlterField( - model_name="event", - name="idempotency_key", - field=models.TextField(blank=True, default=""), - ), - migrations.AlterField( - model_name="event", - name="request_id", - field=models.CharField( - blank=True, - default="", - help_text="Information about the request that triggered this event, for traceability purposes. If empty string then this is an old entry without that data. If Null then this is not an old entry, but a Stripe 'automated' event with no associated request.", - max_length=50, - ), - ), - migrations.AlterField( - model_name="invoice", - name="hosted_invoice_url", - field=models.TextField( - blank=True, - default="", - help_text="The URL for the hosted invoice page, which allows customers to view and pay an invoice. If the invoice has not been frozen yet, this will be null.", - max_length=799, - ), - ), - migrations.AlterField( - model_name="invoice", - name="invoice_pdf", - field=models.TextField( - blank=True, - default="", - help_text="The link to download the PDF for the invoice. If the invoice has not been frozen yet, this will be null.", - max_length=799, - ), - ), - migrations.AlterField( - model_name="invoice", - name="number", - field=models.CharField( - blank=True, - default="", - help_text="A unique, identifying string that appears on emails sent to the customer for this invoice. This starts with the customer’s unique invoice_prefix if it is specified.", - max_length=64, - ), - ), - migrations.AlterField( - model_name="invoice", - name="statement_descriptor", - field=models.CharField( - blank=True, - default="", - help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", - max_length=22, - ), - ), - migrations.AlterField( - model_name="payout", - name="failure_code", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.PayoutFailureCode, - help_text="Error code explaining reason for transfer failure if available. See https://stripe.com/docs/api/python#transfer_failures.", - max_length=23, - ), - ), - migrations.AlterField( - model_name="payout", - name="failure_message", - field=models.TextField( - blank=True, - default="", - help_text="Message to user further explaining reason for payout failure if available.", - ), - ), - migrations.AlterField( - model_name="payout", - name="statement_descriptor", - field=models.CharField( - blank=True, - default="", - help_text="Extra information about a payout to be displayed on the user's bank statement.", - max_length=255, - ), - ), - migrations.AlterField( - model_name="plan", - name="aggregate_usage", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.PlanAggregateUsage, - help_text="Specifies a usage aggregation strategy for plans of usage_type=metered. Allowed values are `sum` for summing up all usage during a period, `last_during_period` for picking the last usage record reported within a period, `last_ever` for picking the last usage record ever (across period bounds) or max which picks the usage record with the maximum reported usage during a period. Defaults to `sum`.", - max_length=18, - ), - ), - migrations.AlterField( - model_name="plan", - name="billing_scheme", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.PlanBillingScheme, - help_text="Describes how to compute the price per period. Either `per_unit` or `tiered`. `per_unit` indicates that the fixed amount (specified in amount) will be charged per unit in quantity (for plans with `usage_type=licensed`), or per unit of total usage (for plans with `usage_type=metered`). `tiered` indicates that the unit pricing will be computed using a tiering strategy as defined using the tiers and tiers_mode attributes.", - max_length=8, - ), - ), - migrations.AlterField( - model_name="plan", - name="nickname", - field=models.TextField( - blank=True, - default="", - help_text="A brief description of the plan, hidden from customers.", - max_length=5000, - ), - ), - migrations.AlterField( - model_name="product", - name="caption", - field=models.TextField( - blank=True, - default="", - help_text="A short one-line description of the product, meant to be displayableto the customer. Only applicable to products of `type=good`.", - max_length=5000, - ), - ), - migrations.AlterField( - model_name="product", - name="statement_descriptor", - field=models.CharField( - blank=True, - default="", - help_text="Extra information about a product which will appear on your customer's credit card statement. In the case that multiple products are billed at once, the first statement descriptor will be used. Only available on products of type=`service`.", - max_length=22, - ), - ), - migrations.AlterField( - model_name="product", - name="unit_label", - field=models.CharField(blank=True, default="", max_length=12), - ), - migrations.AlterField( - model_name="refund", - name="failure_reason", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.RefundFailureReason, - help_text="If the refund failed, the reason for refund failure if known.", - max_length=24, - ), - ), - migrations.AlterField( - model_name="refund", - name="reason", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.RefundReason, - help_text="Reason for the refund.", - max_length=21, - ), - ), - migrations.AlterField( - model_name="refund", - name="receipt_number", - field=models.CharField( - blank=True, - default="", - help_text="The transaction number that appears on email receipts sent for this charge.", - max_length=9, - ), - ), - migrations.AlterField( - model_name="source", - name="currency", - field=djstripe.fields.StripeCurrencyCodeField( - blank=True, default="", help_text="Three-letter ISO currency code", max_length=3 - ), - ), - migrations.AlterField( - model_name="source", - name="statement_descriptor", - field=models.CharField( - blank=True, - default="", - help_text="Extra information about a source. This will appear on your customer's statement every time you charge the source.", - max_length=255, - ), - ), - migrations.AlterField( - model_name="transfer", - name="transfer_group", - field=models.CharField( - blank=True, - default="", - help_text="A string that identifies this transaction as part of a group.", - max_length=255, - ), - ), - migrations.AlterField( - model_name="product", - name="name", - field=models.TextField( - help_text="The product's name, meant to be displayable to the customer. Applicable to both `service` and `good` types.", - max_length=5000, - ), - ), - migrations.AlterField( - model_name="subscription", - name="plan", - field=models.ForeignKey( - blank=True, - help_text="The plan associated with this subscription. This value will be `null` for multi-plan subscriptions", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="subscriptions", - to="djstripe.Plan", - ), - ), - migrations.AlterField( - model_name="subscription", - name="quantity", - field=models.IntegerField( - blank=True, - help_text="The quantity applied to this subscription. This value will be `null` for multi-plan subscriptions", - null=True, - ), - ), - migrations.AlterField( - model_name="plan", - name="amount", - field=djstripe.fields.StripeDecimalCurrencyAmountField( - blank=True, - decimal_places=2, - help_text="Amount to be charged on the interval specified.", - max_digits=8, - null=True, - ), - ), - migrations.AlterField( - model_name="invoice", - name="closed", - field=models.NullBooleanField( - default=False, - help_text="Whether or not the invoice is still trying to collect payment. An invoice is closed if it's either paid or it has been marked closed. A closed invoice will no longer attempt to collect payment.", - ), - ), - migrations.AlterField( - model_name="invoice", - name="forgiven", - field=models.NullBooleanField( - default=False, - help_text="Whether or not the invoice has been forgiven. Forgiving an invoice instructs us to update the subscription status as if the invoice were successfully paid. Once an invoice has been forgiven, it cannot be unforgiven or reopened.", - ), - ), - migrations.RenameModel(old_name="PaymentMethod", new_name="DjstripePaymentMethod"), - ] + operations = [ + migrations.RunPython(postgres_migration_prep, migrations.RunPython.noop), + migrations.AlterField( + model_name="account", + name="display_name", + field=models.CharField( + blank=True, + default="", + help_text="The display name for this account. This is used on the Stripe Dashboard to differentiate between accounts.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="account", + name="support_email", + field=models.CharField( + blank=True, + default="", + help_text="A publicly shareable support email address for the business", + max_length=255, + ), + ), + migrations.AlterField( + model_name="account", + name="support_phone", + field=models.CharField( + blank=True, + default="", + help_text="A publicly shareable support phone number for the business", + max_length=255, + ), + ), + migrations.AlterField( + model_name="card", + name="customer", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="legacy_cards", + to="djstripe.Customer", + ), + ), + migrations.AddField( + model_name="coupon", + name="name", + field=models.TextField( + blank=True, + default="", + help_text="Name of the coupon displayed to customers on for instance invoices or receipts.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="source", + name="type", + field=djstripe.fields.StripeEnumField( + enum=djstripe.enums.SourceType, + help_text="The type of the source.", + max_length=20, + ), + ), + migrations.AddField( + model_name="plan", + name="active", + field=models.BooleanField( + default=True, + help_text="Whether the plan is currently available for new subscriptions.", + ), + preserve_default=False, + ), + migrations.RemoveField(model_name="charge", name="fraudulent"), + migrations.RemoveField(model_name="charge", name="receipt_sent"), + migrations.RemoveField(model_name="charge", name="source_stripe_id"), + migrations.RemoveField(model_name="charge", name="source_type"), + migrations.RemoveField(model_name="charge", name="fee"), + migrations.RemoveField(model_name="charge", name="fee_details"), + migrations.RemoveField(model_name="transfer", name="date"), + migrations.RemoveField(model_name="transfer", name="destination_type"), + migrations.RemoveField(model_name="transfer", name="failure_code"), + migrations.RemoveField(model_name="transfer", name="failure_message"), + migrations.RemoveField(model_name="transfer", name="fee"), + migrations.RemoveField(model_name="transfer", name="fee_details"), + migrations.RemoveField(model_name="transfer", name="statement_descriptor"), + migrations.RemoveField(model_name="transfer", name="status"), + migrations.AlterModelOptions( + name="account", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="bankaccount", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions(name="card", options={"get_latest_by": "created"}), + migrations.AlterModelOptions( + name="charge", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="dispute", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="event", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="fileupload", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="invoiceitem", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="payout", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="product", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="refund", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="source", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="subscription", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="transfer", options={"get_latest_by": "created"} + ), + migrations.AlterModelOptions( + name="upcominginvoice", options={"get_latest_by": "created"} + ), + migrations.CreateModel( + name="CountrySpec", + fields=[ + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "id", + models.CharField(max_length=2, primary_key=True, serialize=False), + ), + ( + "default_currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="The default currency for this country. This applies to both payment methods and bank accounts.", + max_length=3, + ), + ), + ( + "supported_bank_account_currencies", + djstripe.fields.JSONField( + help_text="Currencies that can be accepted in the specific country (for transfers)." + ), + ), + ( + "supported_payment_currencies", + djstripe.fields.JSONField( + help_text="Currencies that can be accepted in the specified country (for payments)." + ), + ), + ( + "supported_payment_methods", + djstripe.fields.JSONField( + help_text="Payment methods available in the specified country." + ), + ), + ( + "supported_transfer_countries", + djstripe.fields.JSONField( + help_text="Countries that can accept transfers from the specified country." + ), + ), + ( + "verification_fields", + djstripe.fields.JSONField( + help_text="Lists the types of verification data needed to keep an account open." + ), + ), + ], + options={"abstract": False}, + ), + migrations.CreateModel( + name="BalanceTransaction", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Gross amount of the transaction, in cents." + ), + ), + ( + "available_on", + djstripe.fields.StripeDateTimeField( + help_text="The date the transaction's net funds will become available in the Stripe balance." + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "exchange_rate", + models.DecimalField(null=True, decimal_places=6, max_digits=8), + ), + ( + "fee", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Fee (in cents) paid for this transaction." + ), + ), + ("fee_details", djstripe.fields.JSONField()), + ( + "net", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Net amount of the transaction, in cents." + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.BalanceTransactionStatus, max_length=9 + ), + ), + ( + "type", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.BalanceTransactionType, max_length=22 + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.CreateModel( + name="ScheduledQueryRun", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "data_load_time", + djstripe.fields.StripeDateTimeField( + help_text="When the query was run, Sigma contained a snapshot of your Stripe data at this time." + ), + ), + ( + "error", + djstripe.fields.JSONField( + blank=True, + help_text="If the query run was not succeesful, contains information about the failure.", + null=True, + ), + ), + ( + "result_available_until", + djstripe.fields.StripeDateTimeField( + help_text="Time at which the result expires and is no longer available for download." + ), + ), + ( + "sql", + models.TextField(help_text="SQL for the query.", max_length=5000), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.ScheduledQueryRunStatus, + help_text="The query's execution status.", + max_length=9, + ), + ), + ( + "title", + models.TextField(help_text="Title of the query.", max_length=5000), + ), + ( + "file", + models.ForeignKey( + blank=True, + help_text="The file object representing the results of the query.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.FileUpload", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.CreateModel( + name="SubscriptionItem", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "quantity", + models.PositiveIntegerField( + help_text="The quantity of the plan to which the customer should be subscribed." + ), + ), + ( + "plan", + models.ForeignKey( + help_text="The plan the customer is subscribed to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="subscription_items", + to="djstripe.Plan", + ), + ), + ( + "subscription", + models.ForeignKey( + help_text="The subscription this subscription item belongs to.", + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="djstripe.Subscription", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.CreateModel( + name="TransferReversal", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Amount, in cents." + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "balance_transaction", + models.ForeignKey( + blank=True, + help_text="Balance transaction that describes the impact on your account balance.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="transfer_reversals", + to="djstripe.BalanceTransaction", + ), + ), + ( + "transfer", + models.ForeignKey( + help_text="The transfer that was reversed.", + on_delete=django.db.models.deletion.CASCADE, + related_name="reversals", + to="djstripe.Transfer", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.CreateModel( + name="UsageRecord", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "quantity", + models.PositiveIntegerField( + help_text="The quantity of the plan to which the customer should be subscribed." + ), + ), + ( + "subscription_item", + models.ForeignKey( + help_text="The subscription item this usage record contains data for.", + on_delete=django.db.models.deletion.CASCADE, + related_name="usage_records", + to="djstripe.SubscriptionItem", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.CreateModel( + name="ApplicationFee", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Amount earned." + ), + ), + ( + "amount_refunded", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Amount refunded (can be less than the amount attribute on the fee if a partial refund was issued)" + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "refunded", + models.BooleanField( + help_text="Whether the fee has been fully refunded. If the fee is only partially refunded, this attribute will still be false." + ), + ), + ( + "balance_transaction", + models.ForeignKey( + help_text="Balance transaction that describes the impact on your account balance.", + on_delete=django.db.models.deletion.CASCADE, + to="djstripe.BalanceTransaction", + ), + ), + ( + "charge", + models.ForeignKey( + help_text="The charge that the application fee was taken from.", + on_delete=django.db.models.deletion.CASCADE, + to="djstripe.Charge", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.CreateModel( + name="ApplicationFeeRefund", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Amount refunded." + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "balance_transaction", + models.ForeignKey( + help_text="Balance transaction that describes the impact on your account balance.", + on_delete=django.db.models.deletion.CASCADE, + to="djstripe.BalanceTransaction", + ), + ), + ( + "fee", + models.ForeignKey( + help_text="The application fee that was refunded", + on_delete=django.db.models.deletion.CASCADE, + related_name="refunds", + to="djstripe.ApplicationFee", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.AddField( + model_name="charge", + name="balance_transaction", + field=models.ForeignKey( + help_text="The balance transaction that describes the impact of this charge on your account balance (not including refunds or disputes).", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.BalanceTransaction", + ), + ), + migrations.AddField( + model_name="payout", + name="balance_transaction", + field=models.ForeignKey( + help_text="Balance transaction that describes the impact on your account balance.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.BalanceTransaction", + ), + ), + migrations.AddField( + model_name="payout", + name="failure_balance_transaction", + field=models.ForeignKey( + help_text="If the payout failed or was canceled, this will be the balance transaction that reversed the initial balance transaction, and puts the funds from the failed payout back in your balance.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.BalanceTransaction", + related_name="failure_payouts", + ), + ), + migrations.AddField( + model_name="refund", + name="balance_transaction", + field=models.ForeignKey( + help_text="Balance transaction that describes the impact on your account balance.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.BalanceTransaction", + ), + ), + migrations.AddField( + model_name="refund", + name="failure_balance_transaction", + field=models.ForeignKey( + help_text="If the refund failed, this balance transaction describes the adjustment made on your account balance that reverses the initial balance transaction.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.BalanceTransaction", + related_name="failure_refunds", + ), + ), + migrations.AddField( + model_name="transfer", + name="balance_transaction", + field=models.ForeignKey( + blank=True, + help_text="Balance transaction that describes the impact on your account balance.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.BalanceTransaction", + ), + ), + migrations.RenameField( + model_name="account", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="bankaccount", old_name="stripe_id", new_name="id" + ), + migrations.RenameField(model_name="card", old_name="stripe_id", new_name="id"), + migrations.RenameField( + model_name="charge", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="customer", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="dispute", old_name="stripe_id", new_name="id" + ), + migrations.RenameField(model_name="event", old_name="stripe_id", new_name="id"), + migrations.RenameField( + model_name="fileupload", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="invoice", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="invoiceitem", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="payout", old_name="stripe_id", new_name="id" + ), + migrations.RenameField(model_name="plan", old_name="stripe_id", new_name="id"), + migrations.RenameField( + model_name="product", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="refund", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="source", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="subscription", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="transfer", old_name="stripe_id", new_name="id" + ), + migrations.RenameField( + model_name="coupon", old_name="stripe_id", new_name="id" + ), + migrations.AlterField( + model_name="coupon", + name="percent_off", + field=djstripe.fields.StripePercentField( + blank=True, + decimal_places=2, + help_text="Percent that will be taken off the subtotal of any invoices for this customer for the duration of the coupon. For example, a coupon with percent_off of 50 will make a $100 invoice $50 instead.", + max_digits=5, + null=True, + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(100), + ], + ), + ), + # Update all text-type fields to non-null CharField blank=True default="" + migrations.AlterField( + model_name="account", + name="business_name", + field=models.CharField( + blank=True, + default="", + help_text="The publicly visible name of the business", + max_length=255, + ), + ), + migrations.AlterField( + model_name="account", + name="business_primary_color", + field=models.CharField( + blank=True, + default="", + help_text="A CSS hex color value representing the primary branding color for this account", + max_length=7, + ), + ), + migrations.AlterField( + model_name="account", + name="business_url", + field=models.CharField( + blank=True, + default="", + help_text="The publicly visible website of the business", + max_length=200, + ), + ), + migrations.AlterField( + model_name="account", + name="payout_statement_descriptor", + field=models.CharField( + blank=True, + default="", + help_text="The text that appears on the bank account statement for payouts.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="account", + name="product_description", + field=models.CharField( + blank=True, + default="", + help_text="Internal-only description of the product sold or service provided by the business. It’s used by Stripe for risk and underwriting purposes.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="account", + name="support_url", + field=models.CharField( + blank=True, + default="", + help_text="A publicly shareable URL that provides support for this account", + max_length=200, + ), + ), + migrations.AlterField( + model_name="bankaccount", + name="account_holder_name", + field=models.TextField( + blank=True, + default="", + help_text="The name of the person or business that owns the bank account.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="card", + name="address_city", + field=models.TextField( + blank=True, + default="", + help_text="City/District/Suburb/Town/Village.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="card", + name="address_country", + field=models.TextField( + blank=True, + default="", + help_text="Billing address country.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="card", + name="address_line1", + field=models.TextField( + blank=True, + default="", + help_text="Street address/PO Box/Company name.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="card", + name="address_line1_check", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.CardCheckResult, + help_text="If `address_line1` was provided, results of the check.", + max_length=11, + ), + ), + migrations.AlterField( + model_name="card", + name="address_line2", + field=models.TextField( + blank=True, + default="", + help_text="Apartment/Suite/Unit/Building.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="card", + name="address_state", + field=models.TextField( + blank=True, + default="", + help_text="State/County/Province/Region.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="card", + name="address_zip", + field=models.TextField( + blank=True, default="", help_text="ZIP or postal code.", max_length=5000 + ), + ), + migrations.AlterField( + model_name="card", + name="address_zip_check", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.CardCheckResult, + help_text="If `address_zip` was provided, results of the check.", + max_length=11, + ), + ), + migrations.AlterField( + model_name="card", + name="country", + field=models.CharField( + blank=True, + default="", + help_text="Two-letter ISO code representing the country of the card.", + max_length=2, + ), + ), + migrations.AlterField( + model_name="card", + name="cvc_check", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.CardCheckResult, + help_text="If a CVC was provided, results of the check.", + max_length=11, + ), + ), + migrations.AlterField( + model_name="card", + name="dynamic_last4", + field=models.CharField( + blank=True, + default="", + help_text="(For tokenized numbers only.) The last four digits of the device account number.", + max_length=4, + ), + ), + migrations.AlterField( + model_name="card", + name="fingerprint", + field=models.CharField( + blank=True, + default="", + help_text="Uniquely identifies this particular card number.", + max_length=16, + ), + ), + migrations.AlterField( + model_name="card", + name="name", + field=models.TextField( + blank=True, default="", help_text="Cardholder name.", max_length=5000 + ), + ), + migrations.AlterField( + model_name="card", + name="tokenization_method", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.CardTokenizationMethod, + help_text="If the card number is tokenized, this is the method that was used.", + max_length=11, + ), + ), + migrations.AlterField( + model_name="charge", + name="failure_code", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.ApiErrorCode, + help_text="Error code explaining reason for charge failure if available.", + max_length=42, + ), + ), + migrations.AlterField( + model_name="charge", + name="failure_message", + field=models.TextField( + blank=True, + default="", + help_text="Message to user further explaining reason for charge failure if available.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="charge", + name="receipt_email", + field=models.TextField( + blank=True, + default="", + help_text="The email address that the receipt for this charge was sent to.", + max_length=800, + ), + ), + migrations.AlterField( + model_name="charge", + name="receipt_number", + field=models.CharField( + blank=True, + default="", + help_text="The transaction number that appears on email receipts sent for this charge.", + max_length=14, + ), + ), + migrations.AlterField( + model_name="charge", + name="statement_descriptor", + field=models.CharField( + blank=True, + default="", + help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", + max_length=22, + ), + ), + migrations.AlterField( + model_name="charge", + name="transfer_group", + field=models.CharField( + blank=True, + default="", + help_text="A string that identifies this transaction as part of a group.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="customer", + name="business_vat_id", + field=models.CharField( + blank=True, + default="", + help_text="The customer's VAT identification number.", + max_length=20, + ), + ), + migrations.AlterField( + model_name="customer", + name="currency", + field=djstripe.fields.StripeCurrencyCodeField( + default="", + help_text="The currency the customer can be charged in for recurring billing purposes", + max_length=3, + ), + ), + migrations.AlterField( + model_name="customer", + name="email", + field=models.TextField(blank=True, default="", max_length=5000), + ), + migrations.AlterField( + model_name="event", + name="idempotency_key", + field=models.TextField(blank=True, default=""), + ), + migrations.AlterField( + model_name="event", + name="request_id", + field=models.CharField( + blank=True, + default="", + help_text="Information about the request that triggered this event, for traceability purposes. If empty string then this is an old entry without that data. If Null then this is not an old entry, but a Stripe 'automated' event with no associated request.", + max_length=50, + ), + ), + migrations.AlterField( + model_name="invoice", + name="hosted_invoice_url", + field=models.TextField( + blank=True, + default="", + help_text="The URL for the hosted invoice page, which allows customers to view and pay an invoice. If the invoice has not been frozen yet, this will be null.", + max_length=799, + ), + ), + migrations.AlterField( + model_name="invoice", + name="invoice_pdf", + field=models.TextField( + blank=True, + default="", + help_text="The link to download the PDF for the invoice. If the invoice has not been frozen yet, this will be null.", + max_length=799, + ), + ), + migrations.AlterField( + model_name="invoice", + name="number", + field=models.CharField( + blank=True, + default="", + help_text="A unique, identifying string that appears on emails sent to the customer for this invoice. This starts with the customer’s unique invoice_prefix if it is specified.", + max_length=64, + ), + ), + migrations.AlterField( + model_name="invoice", + name="statement_descriptor", + field=models.CharField( + blank=True, + default="", + help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement description may not include <>\"' characters, and will appear on your customer's statement in capital letters. Non-ASCII characters are automatically stripped. While most banks display this information consistently, some may display it incorrectly or not at all.", + max_length=22, + ), + ), + migrations.AlterField( + model_name="payout", + name="failure_code", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.PayoutFailureCode, + help_text="Error code explaining reason for transfer failure if available. See https://stripe.com/docs/api/python#transfer_failures.", + max_length=23, + ), + ), + migrations.AlterField( + model_name="payout", + name="failure_message", + field=models.TextField( + blank=True, + default="", + help_text="Message to user further explaining reason for payout failure if available.", + ), + ), + migrations.AlterField( + model_name="payout", + name="statement_descriptor", + field=models.CharField( + blank=True, + default="", + help_text="Extra information about a payout to be displayed on the user's bank statement.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="plan", + name="aggregate_usage", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.PlanAggregateUsage, + help_text="Specifies a usage aggregation strategy for plans of usage_type=metered. Allowed values are `sum` for summing up all usage during a period, `last_during_period` for picking the last usage record reported within a period, `last_ever` for picking the last usage record ever (across period bounds) or max which picks the usage record with the maximum reported usage during a period. Defaults to `sum`.", + max_length=18, + ), + ), + migrations.AlterField( + model_name="plan", + name="billing_scheme", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.PlanBillingScheme, + help_text="Describes how to compute the price per period. Either `per_unit` or `tiered`. `per_unit` indicates that the fixed amount (specified in amount) will be charged per unit in quantity (for plans with `usage_type=licensed`), or per unit of total usage (for plans with `usage_type=metered`). `tiered` indicates that the unit pricing will be computed using a tiering strategy as defined using the tiers and tiers_mode attributes.", + max_length=8, + ), + ), + migrations.AlterField( + model_name="plan", + name="nickname", + field=models.TextField( + blank=True, + default="", + help_text="A brief description of the plan, hidden from customers.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="product", + name="caption", + field=models.TextField( + blank=True, + default="", + help_text="A short one-line description of the product, meant to be displayableto the customer. Only applicable to products of `type=good`.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="product", + name="statement_descriptor", + field=models.CharField( + blank=True, + default="", + help_text="Extra information about a product which will appear on your customer's credit card statement. In the case that multiple products are billed at once, the first statement descriptor will be used. Only available on products of type=`service`.", + max_length=22, + ), + ), + migrations.AlterField( + model_name="product", + name="unit_label", + field=models.CharField(blank=True, default="", max_length=12), + ), + migrations.AlterField( + model_name="refund", + name="failure_reason", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.RefundFailureReason, + help_text="If the refund failed, the reason for refund failure if known.", + max_length=24, + ), + ), + migrations.AlterField( + model_name="refund", + name="reason", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.RefundReason, + help_text="Reason for the refund.", + max_length=21, + ), + ), + migrations.AlterField( + model_name="refund", + name="receipt_number", + field=models.CharField( + blank=True, + default="", + help_text="The transaction number that appears on email receipts sent for this charge.", + max_length=9, + ), + ), + migrations.AlterField( + model_name="source", + name="currency", + field=djstripe.fields.StripeCurrencyCodeField( + blank=True, + default="", + help_text="Three-letter ISO currency code", + max_length=3, + ), + ), + migrations.AlterField( + model_name="source", + name="statement_descriptor", + field=models.CharField( + blank=True, + default="", + help_text="Extra information about a source. This will appear on your customer's statement every time you charge the source.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="transfer", + name="transfer_group", + field=models.CharField( + blank=True, + default="", + help_text="A string that identifies this transaction as part of a group.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="product", + name="name", + field=models.TextField( + help_text="The product's name, meant to be displayable to the customer. Applicable to both `service` and `good` types.", + max_length=5000, + ), + ), + migrations.AlterField( + model_name="subscription", + name="plan", + field=models.ForeignKey( + blank=True, + help_text="The plan associated with this subscription. This value will be `null` for multi-plan subscriptions", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to="djstripe.Plan", + ), + ), + migrations.AlterField( + model_name="subscription", + name="quantity", + field=models.IntegerField( + blank=True, + help_text="The quantity applied to this subscription. This value will be `null` for multi-plan subscriptions", + null=True, + ), + ), + migrations.AlterField( + model_name="plan", + name="amount", + field=djstripe.fields.StripeDecimalCurrencyAmountField( + blank=True, + decimal_places=2, + help_text="Amount to be charged on the interval specified.", + max_digits=8, + null=True, + ), + ), + migrations.AlterField( + model_name="invoice", + name="closed", + field=models.NullBooleanField( + default=False, + help_text="Whether or not the invoice is still trying to collect payment. An invoice is closed if it's either paid or it has been marked closed. A closed invoice will no longer attempt to collect payment.", + ), + ), + migrations.AlterField( + model_name="invoice", + name="forgiven", + field=models.NullBooleanField( + default=False, + help_text="Whether or not the invoice has been forgiven. Forgiving an invoice instructs us to update the subscription status as if the invoice were successfully paid. Once an invoice has been forgiven, it cannot be unforgiven or reopened.", + ), + ), + migrations.RenameModel( + old_name="PaymentMethod", new_name="DjstripePaymentMethod" + ), + ] diff --git a/djstripe/migrations/0004_auto_20190612_0850.py b/djstripe/migrations/0004_auto_20190612_0850.py index 38079e5a57..68d2d7391c 100644 --- a/djstripe/migrations/0004_auto_20190612_0850.py +++ b/djstripe/migrations/0004_auto_20190612_0850.py @@ -9,103 +9,109 @@ class Migration(migrations.Migration): - dependencies = [ - ("djstripe", "0003_auto_20181117_2328_squashed_0004_auto_20190227_2114") - ] + dependencies = [ + ("djstripe", "0003_auto_20181117_2328_squashed_0004_auto_20190227_2114") + ] - operations = [ - migrations.AlterField( - model_name="subscriptionitem", - name="quantity", - field=models.PositiveIntegerField( - blank=True, - help_text="The quantity of the plan to which the customer should be subscribed.", - null=True, - ), - ), - migrations.AddField( - model_name="invoice", - name="auto_advance", - field=models.NullBooleanField( - help_text="Controls whether Stripe will perform automatic collection of the invoice. When false, the invoice’s state will not automatically advance without an explicit action." - ), - ), - migrations.RenameField( - model_name="account", old_name="business_logo", new_name="branding_icon" - ), - migrations.AddField( - model_name="account", - name="branding_logo", - field=models.ForeignKey( - help_text="A logo for the account that will be used in Checkout instead of the icon and without the account’s name next to it if provided. Must be at least 128px x 128px.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="logo_account", - to="djstripe.FileUpload", - ), - ), - migrations.AddField( - model_name="account", - name="business_profile", - field=djstripe.fields.JSONField( - blank=True, help_text="Optional information related to the business.", null=True - ), - ), - migrations.AddField( - model_name="account", - name="business_type", - field=djstripe.fields.StripeEnumField( - blank=True, - default="", - enum=djstripe.enums.BusinessType, - help_text="The business type.", - max_length=10, - ), - ), - migrations.AddField( - model_name="account", - name="company", - field=djstripe.fields.JSONField( - blank=True, - help_text="Information about the company or business. This field is null unless business_type is set to company.", - null=True, - ), - ), - migrations.AddField( - model_name="account", - name="individual", - field=djstripe.fields.JSONField( - blank=True, - help_text="Information about the person represented by the account. This field is null unless business_type is set to individual.", - null=True, - ), - ), - migrations.AddField( - model_name="account", - name="requirements", - field=djstripe.fields.JSONField( - blank=True, - help_text="Information about the requirements for the account, including what information needs to be collected, and by when.", - null=True, - ), - ), - migrations.AddField( - model_name="account", - name="settings", - field=djstripe.fields.JSONField( - blank=True, - help_text="Account options for customizing how the account functions within Stripe.", - null=True, - ), - ), - migrations.AlterModelOptions(name="invoice", options={"ordering": ["-created"]}), - migrations.RenameField( - model_name="invoice", old_name="application_fee", new_name="application_fee_amount" - ), - migrations.RemoveField(model_name="invoice", name="date"), - migrations.AddField( - model_name="invoice", - name="status_transitions", - field=djstripe.fields.JSONField(blank=True, null=True), - ), - ] + operations = [ + migrations.AlterField( + model_name="subscriptionitem", + name="quantity", + field=models.PositiveIntegerField( + blank=True, + help_text="The quantity of the plan to which the customer should be subscribed.", + null=True, + ), + ), + migrations.AddField( + model_name="invoice", + name="auto_advance", + field=models.NullBooleanField( + help_text="Controls whether Stripe will perform automatic collection of the invoice. When false, the invoice’s state will not automatically advance without an explicit action." + ), + ), + migrations.RenameField( + model_name="account", old_name="business_logo", new_name="branding_icon" + ), + migrations.AddField( + model_name="account", + name="branding_logo", + field=models.ForeignKey( + help_text="A logo for the account that will be used in Checkout instead of the icon and without the account’s name next to it if provided. Must be at least 128px x 128px.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="logo_account", + to="djstripe.FileUpload", + ), + ), + migrations.AddField( + model_name="account", + name="business_profile", + field=djstripe.fields.JSONField( + blank=True, + help_text="Optional information related to the business.", + null=True, + ), + ), + migrations.AddField( + model_name="account", + name="business_type", + field=djstripe.fields.StripeEnumField( + blank=True, + default="", + enum=djstripe.enums.BusinessType, + help_text="The business type.", + max_length=10, + ), + ), + migrations.AddField( + model_name="account", + name="company", + field=djstripe.fields.JSONField( + blank=True, + help_text="Information about the company or business. This field is null unless business_type is set to company.", + null=True, + ), + ), + migrations.AddField( + model_name="account", + name="individual", + field=djstripe.fields.JSONField( + blank=True, + help_text="Information about the person represented by the account. This field is null unless business_type is set to individual.", + null=True, + ), + ), + migrations.AddField( + model_name="account", + name="requirements", + field=djstripe.fields.JSONField( + blank=True, + help_text="Information about the requirements for the account, including what information needs to be collected, and by when.", + null=True, + ), + ), + migrations.AddField( + model_name="account", + name="settings", + field=djstripe.fields.JSONField( + blank=True, + help_text="Account options for customizing how the account functions within Stripe.", + null=True, + ), + ), + migrations.AlterModelOptions( + name="invoice", options={"ordering": ["-created"]} + ), + migrations.RenameField( + model_name="invoice", + old_name="application_fee", + new_name="application_fee_amount", + ), + migrations.RemoveField(model_name="invoice", name="date"), + migrations.AddField( + model_name="invoice", + name="status_transitions", + field=djstripe.fields.JSONField(blank=True, null=True), + ), + ] diff --git a/djstripe/migrations/0005_auto_20190710_1023.py b/djstripe/migrations/0005_auto_20190710_1023.py index 46833640c2..73283dedb6 100644 --- a/djstripe/migrations/0005_auto_20190710_1023.py +++ b/djstripe/migrations/0005_auto_20190710_1023.py @@ -5,10 +5,10 @@ class Migration(migrations.Migration): - dependencies = [("djstripe", "0004_auto_20190612_0850")] + dependencies = [("djstripe", "0004_auto_20190612_0850")] - operations = [ - migrations.RenameField( - model_name="customer", old_name="account_balance", new_name="balance" - ) - ] + operations = [ + migrations.RenameField( + model_name="customer", old_name="account_balance", new_name="balance" + ) + ] diff --git a/djstripe/migrations/0006_auto_20190729_1329.py b/djstripe/migrations/0006_auto_20190729_1329.py index 815662a605..42aa4d7da0 100644 --- a/djstripe/migrations/0006_auto_20190729_1329.py +++ b/djstripe/migrations/0006_auto_20190729_1329.py @@ -8,631 +8,655 @@ def fix_djstripepaymentmethod_index_name_forwards(apps, schema_editor): - # Altering the index is required because while we changed the name of old PaymentMethod model to - # DjStripePaymentMethod, the migrations didn't update the names of index. - # In the current migration, we create a new PaymentMethod model, hence before creating it, its - # better to rename the old index. - if not schema_editor.connection.vendor.startswith("postgres"): - return - schema_editor.execute( - "ALTER INDEX djstripe_paymentmethod_id_0b9251df_like rename TO djstripe_paymentmethod_legacy_id_0b9251df_like" - ) + # Altering the index is required because while we changed the name of old PaymentMethod model to + # DjStripePaymentMethod, the migrations didn't update the names of index. + # In the current migration, we create a new PaymentMethod model, hence before creating it, its + # better to rename the old index. + if not schema_editor.connection.vendor.startswith("postgres"): + return + schema_editor.execute( + "ALTER INDEX djstripe_paymentmethod_id_0b9251df_like rename TO djstripe_paymentmethod_legacy_id_0b9251df_like" + ) def fix_djstripepaymentmethod_index_name_backwards(apps, schema_editor): - if not schema_editor.connection.vendor.startswith("postgres"): - return - schema_editor.execute( - "ALTER INDEX djstripe_paymentmethod_legacy_id_0b9251df_like rename TO djstripe_paymentmethod_id_0b9251df_like" - ) + if not schema_editor.connection.vendor.startswith("postgres"): + return + schema_editor.execute( + "ALTER INDEX djstripe_paymentmethod_legacy_id_0b9251df_like rename TO djstripe_paymentmethod_id_0b9251df_like" + ) class Migration(migrations.Migration): - dependencies = [("djstripe", "0005_auto_20190710_1023")] + dependencies = [("djstripe", "0005_auto_20190710_1023")] - operations = [ - # Altering the index is required because while we chnage the name of old PaymentMethod model to - # DjStripePaymentMethod, the migrations didn't update the names of index. - # In the current migration, we create a new PaymentMethod model, hence before creating it, its - # better to rename the old index. - migrations.RunPython( - fix_djstripepaymentmethod_index_name_forwards, - fix_djstripepaymentmethod_index_name_backwards, - ), - migrations.CreateModel( - name="PaymentIntent", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "amount", - djstripe.fields.StripeQuantumCurrencyAmountField( - help_text="Amount intended to be collected by this PaymentIntent." - ), - ), - ( - "amount_capturable", - djstripe.fields.StripeQuantumCurrencyAmountField( - help_text="Amount that can be captured from this PaymentIntent." - ), - ), - ( - "amount_received", - djstripe.fields.StripeQuantumCurrencyAmountField( - help_text="Amount that was collected by this PaymentIntent." - ), - ), - ( - "canceled_at", - models.DateTimeField( - default=None, - help_text="Populated when status is canceled, this is the time at which the PaymentIntent was canceled. Measured in seconds since the Unix epoch.", - null=True, - ), - ), - ( - "cancellation_reason", - models.CharField( - help_text="User-given reason for cancellation of this PaymentIntent, one of duplicate, fraudulent, requested_by_customer, or failed_invoice.", - max_length=255, - null=True, - ), - ), - ( - "capture_method", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.CaptureMethod, - help_text="Capture method of this PaymentIntent, one of automatic or manual.", - max_length=9, - ), - ), - ( - "client_secret", - models.CharField( - help_text="The client secret of this PaymentIntent. Used for client-side retrieval using a publishable key.", - max_length=255, - ), - ), - ( - "confirmation_method", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.ConfirmationMethod, - help_text="Confirmation method of this PaymentIntent, one of manual or automatic.", - max_length=9, - ), - ), - ( - "currency", - djstripe.fields.StripeCurrencyCodeField( - help_text="Three-letter ISO currency code", max_length=3 - ), - ), - ( - "description", - models.TextField( - default="", - help_text="An arbitrary string attached to the object. Often useful for displaying to users.", - ), - ), - ( - "last_payment_error", - djstripe.fields.JSONField( - help_text="The payment error encountered in the previous PaymentIntent confirmation." - ), - ), - ( - "next_action", - djstripe.fields.JSONField( - help_text="If present, this property tells you what actions you need to take in order for your customer to fulfill a payment using the provided source." - ), - ), - ( - "payment_method_types", - djstripe.fields.JSONField( - help_text="The list of payment method types (e.g. card) that this PaymentIntent is allowed to use." - ), - ), - ( - "receipt_email", - models.CharField( - help_text="Email address that the receipt for the resulting payment will be sent to.", - max_length=255, - ), - ), - ( - "setup_future_usage", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.IntentUsage, - help_text="Indicates that you intend to make future payments with thisPaymentIntent’s payment method.If present, the payment method used with this PaymentIntent canbe attached to a Customer, even after the transaction completes.Use `on_session` if you intend to only reuse the payment methodwhen your customer is present in your checkout flow. Use `off_session`if your customer may or may not be in your checkout flow.Stripe uses `setup_future_usage` to dynamically optimize your payment flow andcomply with regional legislation and network rules. For example,if your customer is impacted by SCA, using `off_session` willensure that they are authenticated while processing this PaymentIntent.You will then be able to make later off-session payments for this customer.", - max_length=11, - ), - ), - ( - "shipping", - djstripe.fields.JSONField( - blank=True, help_text="Shipping information for this PaymentIntent.", null=True - ), - ), - ( - "statement_descriptor", - models.CharField( - blank=True, - help_text="Extra information about a PaymentIntent. This will appear on your customer’s statement when this PaymentIntent succeeds in creating a charge.", - max_length=255, - null=True, - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.PaymentIntentStatus, - help_text="Status of this PaymentIntent, one of requires_payment_method, requires_confirmation, requires_action, processing, requires_capture, canceled, or succeeded. You can read more about PaymentIntent statuses here.", - max_length=16, - ), - ), - ( - "transfer_data", - djstripe.fields.JSONField( - blank=True, - help_text="The data with which to automatically create a Transfer when the payment is finalized. See the PaymentIntents Connect usage guide for details.", - null=True, - ), - ), - ( - "transfer_group", - models.CharField( - help_text="A string that identifies the resulting payment as part of a group. See the PaymentIntents Connect usage guide for details.", - max_length=255, - ), - ), - ( - "customer", - models.ForeignKey( - help_text="Customer this PaymentIntent is for if one exists.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="djstripe.Customer", - ), - ), - ( - "on_behalf_of", - models.ForeignKey( - help_text="The account (if any) for which the funds of the PaymentIntent are intended.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="djstripe.Account", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.CreateModel( - name="PaymentMethod", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "billing_details", - djstripe.fields.JSONField( - help_text="Billing information associated with the PaymentMethod that may be used or required by particular types of payment methods." - ), - ), - ( - "card", - djstripe.fields.JSONField( - help_text="If this is a card PaymentMethod, this hash contains details about the card." - ), - ), - ( - "card_present", - djstripe.fields.JSONField( - help_text="If this is an card_present PaymentMethod, this hash contains details about the Card Present payment method." - ), - ), - ( - "type", - models.CharField( - blank=True, - help_text="The type of the PaymentMethod. An additional hash is included on the PaymentMethodwith a name matching this value. It contains additional information specific to thePaymentMethod type.", - max_length=255, - null=True, - ), - ), - ( - "customer", - models.ForeignKey( - blank=True, - help_text="Customer to which this PaymentMethod is saved.This will not be set when the PaymentMethod has not been saved to a Customer.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="payment_methods", - to="djstripe.Customer", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.AlterField( - model_name="payout", - name="destination", - field=models.ForeignKey( - help_text="Bank account or card the payout was sent to.", - null=True, - on_delete=django.db.models.deletion.PROTECT, - to="djstripe.BankAccount", - ), - ), - migrations.CreateModel( - name="SetupIntent", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "application", - models.CharField( - blank=True, - help_text="ID of the Connect application that created the SetupIntent.", - max_length=255, - null=True, - ), - ), - ( - "cancellation_reason", - models.CharField( - help_text="Reason for cancellation of this SetupIntent, one of abandoned, requested_by_customer, or duplicate", - max_length=255, - null=True, - ), - ), - ( - "client_secret", - models.CharField( - blank=True, - help_text="The client secret of this SetupIntent. Used for client-side retrieval using a publishable key.", - max_length=255, - null=True, - ), - ), - ( - "last_setup_error", - djstripe.fields.JSONField( - blank=True, - help_text="The error encountered in the previous SetupIntent confirmation.", - null=True, - ), - ), - ( - "next_action", - djstripe.fields.JSONField( - blank=True, - help_text="If present, this property tells you what actions you need to take inorder for your customer to continue payment setup.", - null=True, - ), - ), - ( - "payment_method_types", - djstripe.fields.JSONField( - help_text="The list of payment method types (e.g. card) that this PaymentIntent is allowed to use." - ), - ), - ( - "status", - djstripe.fields.StripeEnumField( - enum=djstripe.enums.SetupIntentStatus, - help_text="Status of this SetupIntent, one of requires_payment_method, requires_confirmation,requires_action, processing, canceled, or succeeded.", - max_length=9, - ), - ), - ( - "usage", - djstripe.fields.StripeEnumField( - default="off_session", - enum=djstripe.enums.IntentUsage, - help_text="Indicates how the payment method is intended to be used in the future.", - max_length=11, - ), - ), - ( - "customer", - models.ForeignKey( - help_text="Customer this SetupIntent belongs to, if one exists.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.Customer", - ), - ), - ( - "on_behalf_of", - models.ForeignKey( - help_text="The account (if any) for which the setup is intended.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.Account", - ), - ), - ( - "payment_method", - models.ForeignKey( - help_text="Payment method used in this PaymentIntent.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.PaymentMethod", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.CreateModel( - name="Session", - fields=[ - ( - "djstripe_id", - models.BigAutoField(primary_key=True, serialize=False, verbose_name="ID"), - ), - ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), - ( - "livemode", - models.NullBooleanField( - default=None, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", - ), - ), - ( - "created", - djstripe.fields.StripeDateTimeField( - blank=True, help_text="The datetime this object was created in stripe.", null=True - ), - ), - ( - "metadata", - djstripe.fields.JSONField( - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", - null=True, - ), - ), - ( - "description", - models.TextField(blank=True, help_text="A description of this object.", null=True), - ), - ("djstripe_created", models.DateTimeField(auto_now_add=True)), - ("djstripe_updated", models.DateTimeField(auto_now=True)), - ( - "biling_address_collection", - models.CharField( - blank=True, - help_text="The value (auto or required) for whether Checkoutcollected the customer’s billing address.", - max_length=255, - null=True, - ), - ), - ( - "cancel_url", - models.CharField( - blank=True, - help_text="The URL the customer will be directed to if theydecide to cancel payment and return to your website.", - max_length=255, - null=True, - ), - ), - ( - "client_reference_id", - models.CharField( - blank=True, - help_text="A unique string to reference the Checkout Session.This can be a customer ID, a cart ID, or similar, andcan be used to reconcile the session with your internal systems.", - max_length=255, - null=True, - ), - ), - ( - "customer_email", - models.CharField( - blank=True, - help_text="If provided, this value will be used when the Customer object is created.", - max_length=255, - null=True, - ), - ), - ( - "display_items", - djstripe.fields.JSONField( - blank=True, - help_text="The line items, plans, or SKUs purchased by the customer.", - null=True, - ), - ), - ( - "locale", - models.CharField( - blank=True, - help_text="The IETF language tag of the locale Checkout is displayed in.If blank or auto, the browser’s locale is used.", - max_length=255, - null=True, - ), - ), - ( - "payment_method_types", - djstripe.fields.JSONField( - help_text="The list of payment method types (e.g. card) that this Checkout Session is allowed to accept." - ), - ), - ( - "submit_type", - djstripe.fields.StripeEnumField( - blank=True, - enum=djstripe.enums.SubmitTypeStatus, - help_text="Describes the type of transaction being performed by Checkoutin order to customize relevant text on the page, such as the submit button.", - max_length=6, - null=True, - ), - ), - ( - "success_url", - models.CharField( - blank=True, - help_text="The URL the customer will be directed to after the payment or subscriptioncreation is successful.", - max_length=255, - null=True, - ), - ), - ( - "customer", - models.ForeignKey( - help_text="Customer this Checkout is for if one exists.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.Customer", - ), - ), - ( - "payment_intent", - models.ForeignKey( - help_text="PaymentIntent created if SKUs or line items were provided.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.PaymentIntent", - ), - ), - ( - "subscription", - models.ForeignKey( - help_text="Subscription created if one or more plans were provided.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.Subscription", - ), - ), - ], - options={"get_latest_by": "created", "abstract": False}, - ), - migrations.AddField( - model_name="paymentintent", - name="payment_method", - field=models.ForeignKey( - help_text="Payment method used in this PaymentIntent.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="djstripe.PaymentMethod", - ), - ), - migrations.AddField( - model_name="charge", - name="payment_intent", - field=models.ForeignKey( - help_text="PaymentIntent associated with this charge, if one exists.", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="charges", - to="djstripe.PaymentIntent", - ), - ), - migrations.AddField( - model_name="invoice", - name="payment_intent", - field=models.OneToOneField( - help_text="The PaymentIntent associated with this invoice. The PaymentIntent is generated when the invoice is finalized, and can then be used to pay the invoice.Note that voiding an invoice will cancel the PaymentIntent", - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="djstripe.PaymentIntent", - ), - ), - migrations.AddField( - model_name="subscription", - name="pending_setup_intent", - field=models.ForeignKey( - blank=True, - help_text="We can use this SetupIntent to collect user authentication when creating a subscription without immediate payment or updating a subscription’s payment method, allowing you to optimize for off-session payments.", - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="setup_intents", - to="djstripe.SetupIntent", - ), - ), - ] + operations = [ + # Altering the index is required because while we chnage the name of old PaymentMethod model to + # DjStripePaymentMethod, the migrations didn't update the names of index. + # In the current migration, we create a new PaymentMethod model, hence before creating it, its + # better to rename the old index. + migrations.RunPython( + fix_djstripepaymentmethod_index_name_forwards, + fix_djstripepaymentmethod_index_name_backwards, + ), + migrations.CreateModel( + name="PaymentIntent", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "amount", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Amount intended to be collected by this PaymentIntent." + ), + ), + ( + "amount_capturable", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Amount that can be captured from this PaymentIntent." + ), + ), + ( + "amount_received", + djstripe.fields.StripeQuantumCurrencyAmountField( + help_text="Amount that was collected by this PaymentIntent." + ), + ), + ( + "canceled_at", + models.DateTimeField( + default=None, + help_text="Populated when status is canceled, this is the time at which the PaymentIntent was canceled. Measured in seconds since the Unix epoch.", + null=True, + ), + ), + ( + "cancellation_reason", + models.CharField( + help_text="User-given reason for cancellation of this PaymentIntent, one of duplicate, fraudulent, requested_by_customer, or failed_invoice.", + max_length=255, + null=True, + ), + ), + ( + "capture_method", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.CaptureMethod, + help_text="Capture method of this PaymentIntent, one of automatic or manual.", + max_length=9, + ), + ), + ( + "client_secret", + models.CharField( + help_text="The client secret of this PaymentIntent. Used for client-side retrieval using a publishable key.", + max_length=255, + ), + ), + ( + "confirmation_method", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.ConfirmationMethod, + help_text="Confirmation method of this PaymentIntent, one of manual or automatic.", + max_length=9, + ), + ), + ( + "currency", + djstripe.fields.StripeCurrencyCodeField( + help_text="Three-letter ISO currency code", max_length=3 + ), + ), + ( + "description", + models.TextField( + default="", + help_text="An arbitrary string attached to the object. Often useful for displaying to users.", + ), + ), + ( + "last_payment_error", + djstripe.fields.JSONField( + help_text="The payment error encountered in the previous PaymentIntent confirmation." + ), + ), + ( + "next_action", + djstripe.fields.JSONField( + help_text="If present, this property tells you what actions you need to take in order for your customer to fulfill a payment using the provided source." + ), + ), + ( + "payment_method_types", + djstripe.fields.JSONField( + help_text="The list of payment method types (e.g. card) that this PaymentIntent is allowed to use." + ), + ), + ( + "receipt_email", + models.CharField( + help_text="Email address that the receipt for the resulting payment will be sent to.", + max_length=255, + ), + ), + ( + "setup_future_usage", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.IntentUsage, + help_text="Indicates that you intend to make future payments with this PaymentIntent’s payment method. If present, the payment method used with this PaymentIntent can be attached to a Customer, even after the transaction completes. Use `on_session` if you intend to only reuse the payment method when your customer is present in your checkout flow. Use `off_session` if your customer may or may not be in your checkout flow. Stripe uses `setup_future_usage` to dynamically optimize your payment flow and comply with regional legislation and network rules. For example, if your customer is impacted by SCA, using `off_session` will ensure that they are authenticated while processing this PaymentIntent. You will then be able to make later off-session payments for this customer.", + max_length=11, + ), + ), + ( + "shipping", + djstripe.fields.JSONField( + blank=True, + help_text="Shipping information for this PaymentIntent.", + null=True, + ), + ), + ( + "statement_descriptor", + models.CharField( + blank=True, + help_text="Extra information about a PaymentIntent. This will appear on your customer’s statement when this PaymentIntent succeeds in creating a charge.", + max_length=255, + null=True, + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.PaymentIntentStatus, + help_text="Status of this PaymentIntent, one of requires_payment_method, requires_confirmation, requires_action, processing, requires_capture, canceled, or succeeded. You can read more about PaymentIntent statuses here.", + max_length=16, + ), + ), + ( + "transfer_data", + djstripe.fields.JSONField( + blank=True, + help_text="The data with which to automatically create a Transfer when the payment is finalized. See the PaymentIntents Connect usage guide for details.", + null=True, + ), + ), + ( + "transfer_group", + models.CharField( + help_text="A string that identifies the resulting payment as part of a group. See the PaymentIntents Connect usage guide for details.", + max_length=255, + ), + ), + ( + "customer", + models.ForeignKey( + help_text="Customer this PaymentIntent is for if one exists.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="djstripe.Customer", + ), + ), + ( + "on_behalf_of", + models.ForeignKey( + help_text="The account (if any) for which the funds of the PaymentIntent are intended.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="djstripe.Account", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.CreateModel( + name="PaymentMethod", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "billing_details", + djstripe.fields.JSONField( + help_text="Billing information associated with the PaymentMethod that may be used or required by particular types of payment methods." + ), + ), + ( + "card", + djstripe.fields.JSONField( + help_text="If this is a card PaymentMethod, this hash contains details about the card." + ), + ), + ( + "card_present", + djstripe.fields.JSONField( + help_text="If this is an card_present PaymentMethod, this hash contains details about the Card Present payment method." + ), + ), + ( + "type", + models.CharField( + blank=True, + help_text="The type of the PaymentMethod. An additional hash is included on the PaymentMethod with a name matching this value. It contains additional information specific to the PaymentMethod type.", + max_length=255, + null=True, + ), + ), + ( + "customer", + models.ForeignKey( + blank=True, + help_text="Customer to which this PaymentMethod is saved.This will not be set when the PaymentMethod has not been saved to a Customer.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="payment_methods", + to="djstripe.Customer", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.AlterField( + model_name="payout", + name="destination", + field=models.ForeignKey( + help_text="Bank account or card the payout was sent to.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="djstripe.BankAccount", + ), + ), + migrations.CreateModel( + name="SetupIntent", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "application", + models.CharField( + blank=True, + help_text="ID of the Connect application that created the SetupIntent.", + max_length=255, + null=True, + ), + ), + ( + "cancellation_reason", + models.CharField( + help_text="Reason for cancellation of this SetupIntent, one of abandoned, requested_by_customer, or duplicate", + max_length=255, + null=True, + ), + ), + ( + "client_secret", + models.CharField( + blank=True, + help_text="The client secret of this SetupIntent. Used for client-side retrieval using a publishable key.", + max_length=255, + null=True, + ), + ), + ( + "last_setup_error", + djstripe.fields.JSONField( + blank=True, + help_text="The error encountered in the previous SetupIntent confirmation.", + null=True, + ), + ), + ( + "next_action", + djstripe.fields.JSONField( + blank=True, + help_text="If present, this property tells you what actions you need to take inorder for your customer to continue payment setup.", + null=True, + ), + ), + ( + "payment_method_types", + djstripe.fields.JSONField( + help_text="The list of payment method types (e.g. card) that this PaymentIntent is allowed to use." + ), + ), + ( + "status", + djstripe.fields.StripeEnumField( + enum=djstripe.enums.SetupIntentStatus, + help_text="Status of this SetupIntent, one of requires_payment_method, requires_confirmation, requires_action, processing, canceled, or succeeded.", + max_length=9, + ), + ), + ( + "usage", + djstripe.fields.StripeEnumField( + default="off_session", + enum=djstripe.enums.IntentUsage, + help_text="Indicates how the payment method is intended to be used in the future.", + max_length=11, + ), + ), + ( + "customer", + models.ForeignKey( + help_text="Customer this SetupIntent belongs to, if one exists.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.Customer", + ), + ), + ( + "on_behalf_of", + models.ForeignKey( + help_text="The account (if any) for which the setup is intended.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.Account", + ), + ), + ( + "payment_method", + models.ForeignKey( + help_text="Payment method used in this PaymentIntent.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.PaymentMethod", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.CreateModel( + name="Session", + fields=[ + ( + "djstripe_id", + models.BigAutoField( + primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("id", djstripe.fields.StripeIdField(max_length=255, unique=True)), + ( + "livemode", + models.NullBooleanField( + default=None, + help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, this field indicates whether this record comes from Stripe test mode or live mode operation.", + ), + ), + ( + "created", + djstripe.fields.StripeDateTimeField( + blank=True, + help_text="The datetime this object was created in stripe.", + null=True, + ), + ), + ( + "metadata", + djstripe.fields.JSONField( + blank=True, + help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional information about an object in a structured format.", + null=True, + ), + ), + ( + "description", + models.TextField( + blank=True, help_text="A description of this object.", null=True + ), + ), + ("djstripe_created", models.DateTimeField(auto_now_add=True)), + ("djstripe_updated", models.DateTimeField(auto_now=True)), + ( + "biling_address_collection", + models.CharField( + blank=True, + help_text="The value (auto or required) for whether Checkoutcollected the customer’s billing address.", + max_length=255, + null=True, + ), + ), + ( + "cancel_url", + models.CharField( + blank=True, + help_text="The URL the customer will be directed to if theydecide to cancel payment and return to your website.", + max_length=255, + null=True, + ), + ), + ( + "client_reference_id", + models.CharField( + blank=True, + help_text="A unique string to reference the Checkout Session.This can be a customer ID, a cart ID, or similar, andcan be used to reconcile the session with your internal systems.", + max_length=255, + null=True, + ), + ), + ( + "customer_email", + models.CharField( + blank=True, + help_text="If provided, this value will be used when the Customer object is created.", + max_length=255, + null=True, + ), + ), + ( + "display_items", + djstripe.fields.JSONField( + blank=True, + help_text="The line items, plans, or SKUs purchased by the customer.", + null=True, + ), + ), + ( + "locale", + models.CharField( + blank=True, + help_text="The IETF language tag of the locale Checkout is displayed in.If blank or auto, the browser’s locale is used.", + max_length=255, + null=True, + ), + ), + ( + "payment_method_types", + djstripe.fields.JSONField( + help_text="The list of payment method types (e.g. card) that this Checkout Session is allowed to accept." + ), + ), + ( + "submit_type", + djstripe.fields.StripeEnumField( + blank=True, + enum=djstripe.enums.SubmitTypeStatus, + help_text="Describes the type of transaction being performed by Checkoutin order to customize relevant text on the page, such as the submit button.", + max_length=6, + null=True, + ), + ), + ( + "success_url", + models.CharField( + blank=True, + help_text="The URL the customer will be directed to after the payment or subscriptioncreation is successful.", + max_length=255, + null=True, + ), + ), + ( + "customer", + models.ForeignKey( + help_text="Customer this Checkout is for if one exists.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.Customer", + ), + ), + ( + "payment_intent", + models.ForeignKey( + help_text="PaymentIntent created if SKUs or line items were provided.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.PaymentIntent", + ), + ), + ( + "subscription", + models.ForeignKey( + help_text="Subscription created if one or more plans were provided.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.Subscription", + ), + ), + ], + options={"get_latest_by": "created", "abstract": False}, + ), + migrations.AddField( + model_name="paymentintent", + name="payment_method", + field=models.ForeignKey( + help_text="Payment method used in this PaymentIntent.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="djstripe.PaymentMethod", + ), + ), + migrations.AddField( + model_name="charge", + name="payment_intent", + field=models.ForeignKey( + help_text="PaymentIntent associated with this charge, if one exists.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="charges", + to="djstripe.PaymentIntent", + ), + ), + migrations.AddField( + model_name="invoice", + name="payment_intent", + field=models.OneToOneField( + help_text="The PaymentIntent associated with this invoice. The PaymentIntent is generated when the invoice is finalized, and can then be used to pay the invoice.Note that voiding an invoice will cancel the PaymentIntent", + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="djstripe.PaymentIntent", + ), + ), + migrations.AddField( + model_name="subscription", + name="pending_setup_intent", + field=models.ForeignKey( + blank=True, + help_text="We can use this SetupIntent to collect user authentication when creating a subscription without immediate payment or updating a subscription’s payment method, allowing you to optimize for off-session payments.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="setup_intents", + to="djstripe.SetupIntent", + ), + ), + ] diff --git a/djstripe/mixins.py b/djstripe/mixins.py index ca9b1e1bf7..e956089540 100644 --- a/djstripe/mixins.py +++ b/djstripe/mixins.py @@ -7,29 +7,29 @@ class PaymentsContextMixin: - """Adds plan context to a view.""" + """Adds plan context to a view.""" - def get_context_data(self, **kwargs): - """Inject STRIPE_PUBLIC_KEY and plans into context_data.""" - context = super().get_context_data(**kwargs) - context.update( - { - "STRIPE_PUBLIC_KEY": djstripe_settings.STRIPE_PUBLIC_KEY, - "plans": Plan.objects.all(), - } - ) - return context + def get_context_data(self, **kwargs): + """Inject STRIPE_PUBLIC_KEY and plans into context_data.""" + context = super().get_context_data(**kwargs) + context.update( + { + "STRIPE_PUBLIC_KEY": djstripe_settings.STRIPE_PUBLIC_KEY, + "plans": Plan.objects.all(), + } + ) + return context class SubscriptionMixin(PaymentsContextMixin): - """Adds customer subscription context to a view.""" + """Adds customer subscription context to a view.""" - def get_context_data(self, *args, **kwargs): - """Inject is_plans_plural and customer into context_data.""" - context = super().get_context_data(**kwargs) - context["is_plans_plural"] = Plan.objects.count() > 1 - context["customer"], _created = Customer.get_or_create( - subscriber=djstripe_settings.subscriber_request_callback(self.request) - ) - context["subscription"] = context["customer"].subscription - return context + def get_context_data(self, *args, **kwargs): + """Inject is_plans_plural and customer into context_data.""" + context = super().get_context_data(**kwargs) + context["is_plans_plural"] = Plan.objects.count() > 1 + context["customer"], _created = Customer.get_or_create( + subscriber=djstripe_settings.subscriber_request_callback(self.request) + ) + context["subscription"] = context["customer"].subscription + return context diff --git a/djstripe/models/__init__.py b/djstripe/models/__init__.py index 29491cedb7..2f35893ef0 100644 --- a/djstripe/models/__init__.py +++ b/djstripe/models/__init__.py @@ -1,56 +1,80 @@ from .base import IdempotencyKey, StripeModel from .billing import ( - Coupon, Invoice, InvoiceItem, Plan, Subscription, - SubscriptionItem, UpcomingInvoice, UsageRecord + Coupon, + Invoice, + InvoiceItem, + Plan, + Subscription, + SubscriptionItem, + UpcomingInvoice, + UsageRecord, ) from .checkout import Session from .connect import ( - Account, ApplicationFee, ApplicationFeeRefund, CountrySpec, Transfer, TransferReversal + Account, + ApplicationFee, + ApplicationFeeRefund, + CountrySpec, + Transfer, + TransferReversal, ) from .core import ( - BalanceTransaction, Charge, Customer, Dispute, Event, - FileUpload, PaymentIntent, Payout, Product, Refund, SetupIntent + BalanceTransaction, + Charge, + Customer, + Dispute, + Event, + FileUpload, + PaymentIntent, + Payout, + Product, + Refund, + SetupIntent, ) from .payment_methods import ( - BankAccount, Card, DjstripePaymentMethod, PaymentMethod, Source + BankAccount, + Card, + DjstripePaymentMethod, + PaymentMethod, + Source, ) from .sigma import ScheduledQueryRun from .webhooks import WebhookEventTrigger __all__ = [ - "Account", - "ApplicationFee", - "ApplicationFeeRefund", - "BalanceTransaction", - "BankAccount", - "Card", - "Charge", - "CountrySpec", - "Coupon", - "Customer", - "Dispute", - "DjstripePaymentMethod", - "Event", - "FileUpload", - "IdempotencyKey", - "Invoice", - "InvoiceItem", - "PaymentIntent", - "PaymentMethod", - "Payout", - "Plan", - "Product", - "Refund", - "SetupIntent", - "Session", - "ScheduledQueryRun", - "Source", - "StripeModel", - "Subscription", - "SubscriptionItem", - "Transfer", - "TransferReversal", - "UpcomingInvoice", - "UsageRecord", - "WebhookEventTrigger", + "Account", + "ApplicationFee", + "ApplicationFeeRefund", + "BalanceTransaction", + "BankAccount", + "Card", + "Charge", + "CountrySpec", + "Coupon", + "Customer", + "Dispute", + "DjstripePaymentMethod", + "Event", + "FileUpload", + "IdempotencyKey", + "Invoice", + "InvoiceItem", + "PaymentIntent", + "PaymentMethod", + "Payout", + "Plan", + "Product", + "Refund", + "SetupIntent", + "Session", + "ScheduledQueryRun", + "Source", + "StripeModel", + "Subscription", + "SubscriptionItem", + "Transfer", + "TransferReversal", + "UpcomingInvoice", + "UsageRecord", + "WebhookEventTrigger", ] diff --git a/djstripe/models/base.py b/djstripe/models/base.py index 1b211ee347..2f7e483245 100644 --- a/djstripe/models/base.py +++ b/djstripe/models/base.py @@ -14,609 +14,640 @@ class StripeModel(models.Model): - # This must be defined in descendants of this model/mixin - # e.g. Event, Charge, Customer, etc. - stripe_class = None - expand_fields = [] - stripe_dashboard_item_name = "" - - objects = models.Manager() - stripe_objects = StripeModelManager() - - djstripe_id = models.BigAutoField(verbose_name="ID", serialize=False, primary_key=True) - - id = StripeIdField(unique=True) - livemode = models.NullBooleanField( - default=None, - null=True, - blank=True, - help_text="Null here indicates that the livemode status is unknown or was previously unrecorded. Otherwise, " - "this field indicates whether this record comes from Stripe test mode or live mode operation.", - ) - created = StripeDateTimeField( - null=True, blank=True, help_text="The datetime this object was created in stripe." - ) - metadata = JSONField( - null=True, - blank=True, - help_text="A set of key/value pairs that you can attach to an object. It can be useful for storing additional " - "information about an object in a structured format.", - ) - description = models.TextField( - null=True, blank=True, help_text="A description of this object." - ) - - djstripe_created = models.DateTimeField(auto_now_add=True, editable=False) - djstripe_updated = models.DateTimeField(auto_now=True, editable=False) - - class Meta: - abstract = True - get_latest_by = "created" - - def _get_base_stripe_dashboard_url(self): - return "https://dashboard.stripe.com/{}".format("test/" if not self.livemode else "") - - def get_stripe_dashboard_url(self): - """Get the stripe dashboard url for this object.""" - if not self.stripe_dashboard_item_name or not self.id: - return "" - else: - return "{base_url}{item}/{id}".format( - base_url=self._get_base_stripe_dashboard_url(), - item=self.stripe_dashboard_item_name, - id=self.id, - ) - - @property - def default_api_key(self): - return djstripe_settings.get_default_api_key(self.livemode) - - def api_retrieve(self, api_key=None): - """ - Call the stripe API's retrieve operation for this model. - - :param api_key: The api key to use for this request. Defaults to settings.STRIPE_SECRET_KEY. - :type api_key: string - """ - api_key = api_key or self.default_api_key - - return self.stripe_class.retrieve( - id=self.id, api_key=api_key, expand=self.expand_fields - ) - - @classmethod - def api_list(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs): - """ - Call the stripe API's list operation for this model. - - :param api_key: The api key to use for this request. Defaults to djstripe_settings.STRIPE_SECRET_KEY. - :type api_key: string - - See Stripe documentation for accepted kwargs for each object. - - :returns: an iterator over all items in the query - """ - - return cls.stripe_class.list(api_key=api_key, **kwargs).auto_paging_iter() - - @classmethod - def _api_create(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs): - """ - Call the stripe API's create operation for this model. - - :param api_key: The api key to use for this request. Defaults to djstripe_settings.STRIPE_SECRET_KEY. - :type api_key: string - """ - - return cls.stripe_class.create(api_key=api_key, **kwargs) - - def _api_delete(self, api_key=None, **kwargs): - """ - Call the stripe API's delete operation for this model - - :param api_key: The api key to use for this request. Defaults to djstripe_settings.STRIPE_SECRET_KEY. - :type api_key: string - """ - api_key = api_key or self.default_api_key - - return self.api_retrieve(api_key=api_key).delete(**kwargs) - - def str_parts(self): - """ - Extend this to add information to the string representation of the object - - :rtype: list of str - """ - return ["id={id}".format(id=self.id)] - - @classmethod - def _manipulate_stripe_object_hook(cls, data): - """ - Gets called by this object's stripe object conversion method just before conversion. - Use this to populate custom fields in a StripeModel from stripe data. - """ - return data - - @classmethod - def _stripe_object_to_record(cls, data, current_ids=None, pending_relations=None): - """ - This takes an object, as it is formatted in Stripe's current API for our object - type. In return, it provides a dict. The dict can be used to create a record or - to update a record - - This function takes care of mapping from one field name to another, converting - from cents to dollars, converting timestamps, and eliminating unused fields - (so that an objects.create() call would not fail). - - :param data: the object, as sent by Stripe. Parsed from JSON, into a dict - :type data: dict - :param current_ids: stripe ids of objects that are currently being processed - :type current_ids: set - :param pending_relations: list of tuples of relations to be attached post-save - :type pending_relations: list - :return: All the members from the input, translated, mutated, etc - :rtype: dict - """ - - manipulated_data = cls._manipulate_stripe_object_hook(data) - - if "object" not in data: - raise ValueError("Stripe data has no `object` value. Aborting. %r" % (data)) - - if not cls.is_valid_object(data): - raise ValueError( - "Trying to fit a %r into %r. Aborting." % (data["object"], cls.__name__) - ) - - result = {} - if current_ids is None: - current_ids = set() - - # Iterate over all the fields that we know are related to Stripe, let each field work its own magic - ignore_fields = ["date_purged", "subscriber"] # XXX: Customer hack - for field in cls._meta.fields: - if field.name.startswith("djstripe_") or field.name in ignore_fields: - continue - if isinstance(field, models.ForeignKey): - field_data, skip = cls._stripe_object_field_to_foreign_key( - field=field, - manipulated_data=manipulated_data, - current_ids=current_ids, - pending_relations=pending_relations, - ) - if skip: - continue - else: - if hasattr(field, "stripe_to_db"): - field_data = field.stripe_to_db(manipulated_data) - else: - field_data = manipulated_data.get(field.name) - - if isinstance(field, (models.CharField, models.TextField)) and field_data is None: - field_data = "" - - result[field.name] = field_data - - return result - - @classmethod - def _id_from_data(cls, data): - """ - Extract stripe id from stripe field data - :param data: - :return: - """ - - if isinstance(data, str): - # data like "sub_6lsC8pt7IcFpjA" - id_ = data - elif data: - # data like {"id": sub_6lsC8pt7IcFpjA", ...} - id_ = data.get("id") - else: - id_ = None - - return id_ - - @classmethod - def _stripe_object_field_to_foreign_key( - cls, field, manipulated_data, current_ids=None, pending_relations=None - ): - """ - This converts a stripe API field to the dj stripe object it references, - so that foreign keys can be connected up automatically. - - :param field: - :type field: models.ForeignKey - :param manipulated_data: - :type manipulated_data: dict - :param current_ids: stripe ids of objects that are currently being processed - :type current_ids: set - :param pending_relations: list of tuples of relations to be attached post-save - :type pending_relations: list - :return: - """ - field_data = None - field_name = field.name - raw_field_data = manipulated_data.get(field_name) - refetch = False - skip = False - - if issubclass(field.related_model, StripeModel): - id_ = cls._id_from_data(raw_field_data) - - if not raw_field_data: - skip = True - elif id_ == raw_field_data: - # A field like {"subscription": "sub_6lsC8pt7IcFpjA", ...} - refetch = True - else: - # A field like {"subscription": {"id": sub_6lsC8pt7IcFpjA", ...}} - pass - - if id_ in current_ids: - # this object is currently being fetched, don't try to fetch again, to avoid recursion - # instead, record the relation that should be be created once "object_id" object exists - if pending_relations is not None: - object_id = manipulated_data["id"] - pending_relations.append((object_id, field, id_)) - skip = True - - if not skip: - field_data, _ = field.related_model._get_or_create_from_stripe_object( - manipulated_data, - field_name, - refetch=refetch, - current_ids=current_ids, - pending_relations=pending_relations, - ) - else: - # eg PaymentMethod, handled in hooks - skip = True - - return field_data, skip - - @classmethod - def is_valid_object(cls, data): - """ - Returns whether the data is a valid object for the class - """ - return data["object"] == cls.stripe_class.OBJECT_NAME - - def _attach_objects_hook(self, cls, data): - """ - Gets called by this object's create and sync methods just before save. - Use this to populate fields before the model is saved. - - :param cls: The target class for the instantiated object. - :param data: The data dictionary received from the Stripe API. - :type data: dict - """ - - pass - - def _attach_objects_post_save_hook(self, cls, data, pending_relations=None): - """ - Gets called by this object's create and sync methods just after save. - Use this to populate fields after the model is saved. - - :param cls: The target class for the instantiated object. - :param data: The data dictionary received from the Stripe API. - :type data: dict - """ - - unprocessed_pending_relations = [] - if pending_relations is not None: - for post_save_relation in pending_relations: - object_id, field, id_ = post_save_relation - - if self.id == id_: - # the target instance now exists - target = field.model.objects.get(id=object_id) - setattr(target, field.name, self) - target.save() - - # reload so that indirect relations back to this object - eg self.charge.invoice = self are set - # TODO - reverse the field reference here to avoid hitting the DB? - self.refresh_from_db() - else: - unprocessed_pending_relations.append(post_save_relation) - - if len(pending_relations) != len(unprocessed_pending_relations): - # replace in place so passed in list is updated in calling method - pending_relations[:] = unprocessed_pending_relations - - @classmethod - def _create_from_stripe_object( - cls, data, current_ids=None, pending_relations=None, save=True - ): - """ - Instantiates a model instance using the provided data object received - from Stripe, and saves it to the database if specified. - - :param data: The data dictionary received from the Stripe API. - :type data: dict - :param current_ids: stripe ids of objects that are currently being processed - :type current_ids: set - :param pending_relations: list of tuples of relations to be attached post-save - :type pending_relations: list - :param save: If True, the object is saved after instantiation. - :type save: bool - :returns: The instantiated object. - """ - - instance = cls( - **cls._stripe_object_to_record( - data, current_ids=current_ids, pending_relations=pending_relations - ) - ) - instance._attach_objects_hook(cls, data) - - if save: - instance.save(force_insert=True) - - instance._attach_objects_post_save_hook( - cls, data, pending_relations=pending_relations - ) - - return instance - - @classmethod - def _get_or_create_from_stripe_object( - cls, - data, - field_name="id", - refetch=True, - current_ids=None, - pending_relations=None, - save=True, - ): - """ - - :param data: - :param field_name: - :param refetch: - :param current_ids: stripe ids of objects that are currently being processed - :type current_ids: set - :param pending_relations: list of tuples of relations to be attached post-save - :type pending_relations: list - :param save: - :return: - """ - field = data.get(field_name) - is_nested_data = field_name != "id" - should_expand = False - - if pending_relations is None: - pending_relations = [] - - id_ = cls._id_from_data(field) - - if not field: - # An empty field - We need to return nothing here because there is - # no way of knowing what needs to be fetched! - logger.warning( - "empty field %s.%s = %r - this is a bug, please report it to dj-stripe! data = %r", - cls.__name__, - field_name, - field, - data, - ) - return None, False - elif id_ == field: - # A field like {"subscription": "sub_6lsC8pt7IcFpjA", ...} - # We'll have to expand if the field is not "id" (= is nested) - should_expand = is_nested_data - else: - # A field like {"subscription": {"id": sub_6lsC8pt7IcFpjA", ...}} - data = field - - try: - return cls.stripe_objects.get(id=id_), False - except cls.DoesNotExist: - if is_nested_data and refetch: - # This is what `data` usually looks like: - # {"id": "cus_XXXX", "default_source": "card_XXXX"} - # Leaving the default field_name ("id") will get_or_create the customer. - # If field_name="default_source", we get_or_create the card instead. - cls_instance = cls(id=id_) - data = cls_instance.api_retrieve() - should_expand = False - - # The next thing to happen will be the "create from stripe object" call. - # At this point, if we don't have data to start with (field is a str), - # *and* we didn't refetch by id, then `should_expand` is True and we - # don't have the data to actually create the object. - # If this happens when syncing Stripe data, it's a djstripe bug. Report it! - assert not should_expand, "No data to create {} from {}".format( - cls.__name__, field_name - ) - - try: - # We wrap the `_create_from_stripe_object` in a transaction to avoid TransactionManagementError - # on subsequent queries in case of the IntegrityError catch below. See PR #903 - with transaction.atomic(): - return ( - cls._create_from_stripe_object( - data, current_ids=current_ids, pending_relations=pending_relations, save=save - ), - True, - ) - except IntegrityError: - # Handle the race condition that something else created the object after the `get` - # and before `_create_from_stripe_object`. - # This is common during webhook handling, since Stripe sends multiple webhook events simultaneously, - # each of which will cause recursive syncs. See issue #429 - return cls.stripe_objects.get(id=id_), False - - @classmethod - def _stripe_object_to_customer(cls, target_cls, data): - """ - Search the given manager for the Customer matching this object's ``customer`` field. - :param target_cls: The target class - :type target_cls: Customer - :param data: stripe object - :type data: dict - """ - - if "customer" in data and data["customer"]: - return target_cls._get_or_create_from_stripe_object(data, "customer")[0] - - @classmethod - def _stripe_object_to_invoice_items(cls, target_cls, data, invoice): - """ - Retrieves InvoiceItems for an invoice. - - If the invoice item doesn't exist already then it is created. - - If the invoice is an upcoming invoice that doesn't persist to the - database (i.e. ephemeral) then the invoice items are also not saved. - - :param target_cls: The target class to instantiate per invoice item. - :type target_cls: ``InvoiceItem`` - :param data: The data dictionary received from the Stripe API. - :type data: dict - :param invoice: The invoice object that should hold the invoice items. - :type invoice: ``djstripe.models.Invoice`` - """ - - lines = data.get("lines") - if not lines: - return [] - - invoiceitems = [] - for line in lines.get("data", []): - if invoice.id: - save = True - line.setdefault("invoice", invoice.id) - - if line.get("type") == "subscription": - # Lines for subscriptions need to be keyed based on invoice and - # subscription, because their id is *just* the subscription - # when received from Stripe. This means that future updates to - # a subscription will change previously saved invoices - Doing - # the composite key avoids this. - if not line["id"].startswith(invoice.id): - line["id"] = "{invoice_id}-{subscription_id}".format( - invoice_id=invoice.id, subscription_id=line["id"] - ) - else: - # Don't save invoice items for ephemeral invoices - save = False - - line.setdefault("customer", invoice.customer.id) - line.setdefault("date", int(dateformat.format(invoice.created, "U"))) - - item, _ = target_cls._get_or_create_from_stripe_object( - line, refetch=False, save=save - ) - invoiceitems.append(item) - - return invoiceitems - - @classmethod - def _stripe_object_to_subscription_items(cls, target_cls, data, subscription): - """ - Retrieves SubscriptionItems for a subscription. - - If the subscription item doesn't exist already then it is created. - - :param target_cls: The target class to instantiate per invoice item. - :type target_cls: ``SubscriptionItem`` - :param data: The data dictionary received from the Stripe API. - :type data: dict - :param invoice: The invoice object that should hold the invoice items. - :type invoice: ``djstripe.models.Subscription`` - """ - - items = data.get("items") - if not items: - return [] - - subscriptionitems = [] - for item_data in items.get("data", []): - item, _ = target_cls._get_or_create_from_stripe_object(item_data, refetch=False) - subscriptionitems.append(item) - - return subscriptionitems - - @classmethod - def _stripe_object_to_refunds(cls, target_cls, data, charge): - """ - Retrieves Refunds for a charge - :param target_cls: The target class to instantiate per invoice item. - :type target_cls: ``Refund`` - :param data: The data dictionary received from the Stripe API. - :type data: dict - :param charge: The charge object that refunds are for. - :type invoice: ``djstripe.models.Refund`` - :return: - """ - - refunds = data.get("refunds") - if not refunds: - return [] - - refund_objs = [] - for refund_data in refunds.get("data", []): - item, _ = target_cls._get_or_create_from_stripe_object(refund_data, refetch=False) - refund_objs.append(item) - - return refund_objs - - def _sync(self, record_data): - for attr, value in record_data.items(): - setattr(self, attr, value) - - @classmethod - def sync_from_stripe_data(cls, data): - """ - Syncs this object from the stripe data provided. - - Foreign keys will also be retrieved and synced recursively. - - :param data: stripe object - :type data: dict - """ - current_ids = set() - data_id = data.get("id") - - if data_id: - # stop nested objects from trying to retrieve this object before initial sync is complete - current_ids.add(data_id) - - instance, created = cls._get_or_create_from_stripe_object( - data, current_ids=current_ids - ) - - if not created: - instance._sync(cls._stripe_object_to_record(data)) - instance._attach_objects_hook(cls, data) - instance.save() - instance._attach_objects_post_save_hook(cls, data) - - return instance - - def __str__(self): - return smart_text("<{list}>".format(list=", ".join(self.str_parts()))) + # This must be defined in descendants of this model/mixin + # e.g. Event, Charge, Customer, etc. + stripe_class = None + expand_fields = [] + stripe_dashboard_item_name = "" + + objects = models.Manager() + stripe_objects = StripeModelManager() + + djstripe_id = models.BigAutoField( + verbose_name="ID", serialize=False, primary_key=True + ) + + id = StripeIdField(unique=True) + livemode = models.NullBooleanField( + default=None, + null=True, + blank=True, + help_text="Null here indicates that the livemode status is unknown or was " + "previously unrecorded. Otherwise, this field indicates whether this record " + "comes from Stripe test mode or live mode operation.", + ) + created = StripeDateTimeField( + null=True, + blank=True, + help_text="The datetime this object was created in stripe.", + ) + metadata = JSONField( + null=True, + blank=True, + help_text="A set of key/value pairs that you can attach to an object. " + "It can be useful for storing additional information about an object in " + "a structured format.", + ) + description = models.TextField( + null=True, blank=True, help_text="A description of this object." + ) + + djstripe_created = models.DateTimeField(auto_now_add=True, editable=False) + djstripe_updated = models.DateTimeField(auto_now=True, editable=False) + + class Meta: + abstract = True + get_latest_by = "created" + + def _get_base_stripe_dashboard_url(self): + return "https://dashboard.stripe.com/{}".format( + "test/" if not self.livemode else "" + ) + + def get_stripe_dashboard_url(self): + """Get the stripe dashboard url for this object.""" + if not self.stripe_dashboard_item_name or not self.id: + return "" + else: + return "{base_url}{item}/{id}".format( + base_url=self._get_base_stripe_dashboard_url(), + item=self.stripe_dashboard_item_name, + id=self.id, + ) + + @property + def default_api_key(self): + return djstripe_settings.get_default_api_key(self.livemode) + + def api_retrieve(self, api_key=None): + """ + Call the stripe API's retrieve operation for this model. + + :param api_key: The api key to use for this request. \ + Defaults to settings.STRIPE_SECRET_KEY. + :type api_key: string + """ + api_key = api_key or self.default_api_key + + return self.stripe_class.retrieve( + id=self.id, api_key=api_key, expand=self.expand_fields + ) + + @classmethod + def api_list(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs): + """ + Call the stripe API's list operation for this model. + + :param api_key: The api key to use for this request. \ + Defaults to djstripe_settings.STRIPE_SECRET_KEY. + :type api_key: string + + See Stripe documentation for accepted kwargs for each object. + + :returns: an iterator over all items in the query + """ + + return cls.stripe_class.list(api_key=api_key, **kwargs).auto_paging_iter() + + @classmethod + def _api_create(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs): + """ + Call the stripe API's create operation for this model. + + :param api_key: The api key to use for this request. \ + Defaults to djstripe_settings.STRIPE_SECRET_KEY. + :type api_key: string + """ + + return cls.stripe_class.create(api_key=api_key, **kwargs) + + def _api_delete(self, api_key=None, **kwargs): + """ + Call the stripe API's delete operation for this model + + :param api_key: The api key to use for this request. \ + Defaults to djstripe_settings.STRIPE_SECRET_KEY. + :type api_key: string + """ + api_key = api_key or self.default_api_key + + return self.api_retrieve(api_key=api_key).delete(**kwargs) + + def str_parts(self): + """ + Extend this to add information to the string representation of the object + + :rtype: list of str + """ + return ["id={id}".format(id=self.id)] + + @classmethod + def _manipulate_stripe_object_hook(cls, data): + """ + Gets called by this object's stripe object conversion method just before + conversion. + Use this to populate custom fields in a StripeModel from stripe data. + """ + return data + + @classmethod + def _stripe_object_to_record(cls, data, current_ids=None, pending_relations=None): + """ + This takes an object, as it is formatted in Stripe's current API for our object + type. In return, it provides a dict. The dict can be used to create a record or + to update a record + + This function takes care of mapping from one field name to another, converting + from cents to dollars, converting timestamps, and eliminating unused fields + (so that an objects.create() call would not fail). + + :param data: the object, as sent by Stripe. Parsed from JSON, into a dict + :type data: dict + :param current_ids: stripe ids of objects that are currently being processed + :type current_ids: set + :param pending_relations: list of tuples of relations to be attached post-save + :type pending_relations: list + :return: All the members from the input, translated, mutated, etc + :rtype: dict + """ + + manipulated_data = cls._manipulate_stripe_object_hook(data) + + if "object" not in data: + raise ValueError("Stripe data has no `object` value. Aborting. %r" % (data)) + + if not cls.is_valid_object(data): + raise ValueError( + "Trying to fit a %r into %r. Aborting." % (data["object"], cls.__name__) + ) + + result = {} + if current_ids is None: + current_ids = set() + + # Iterate over all the fields that we know are related to Stripe, + # let each field work its own magic + ignore_fields = ["date_purged", "subscriber"] # XXX: Customer hack + for field in cls._meta.fields: + if field.name.startswith("djstripe_") or field.name in ignore_fields: + continue + if isinstance(field, models.ForeignKey): + field_data, skip = cls._stripe_object_field_to_foreign_key( + field=field, + manipulated_data=manipulated_data, + current_ids=current_ids, + pending_relations=pending_relations, + ) + if skip: + continue + else: + if hasattr(field, "stripe_to_db"): + field_data = field.stripe_to_db(manipulated_data) + else: + field_data = manipulated_data.get(field.name) + + if ( + isinstance(field, (models.CharField, models.TextField)) + and field_data is None + ): + field_data = "" + + result[field.name] = field_data + + return result + + @classmethod + def _id_from_data(cls, data): + """ + Extract stripe id from stripe field data + :param data: + :return: + """ + + if isinstance(data, str): + # data like "sub_6lsC8pt7IcFpjA" + id_ = data + elif data: + # data like {"id": sub_6lsC8pt7IcFpjA", ...} + id_ = data.get("id") + else: + id_ = None + + return id_ + + @classmethod + def _stripe_object_field_to_foreign_key( + cls, field, manipulated_data, current_ids=None, pending_relations=None + ): + """ + This converts a stripe API field to the dj stripe object it references, + so that foreign keys can be connected up automatically. + + :param field: + :type field: models.ForeignKey + :param manipulated_data: + :type manipulated_data: dict + :param current_ids: stripe ids of objects that are currently being processed + :type current_ids: set + :param pending_relations: list of tuples of relations to be attached post-save + :type pending_relations: list + :return: + """ + field_data = None + field_name = field.name + raw_field_data = manipulated_data.get(field_name) + refetch = False + skip = False + + if issubclass(field.related_model, StripeModel): + id_ = cls._id_from_data(raw_field_data) + + if not raw_field_data: + skip = True + elif id_ == raw_field_data: + # A field like {"subscription": "sub_6lsC8pt7IcFpjA", ...} + refetch = True + else: + # A field like {"subscription": {"id": sub_6lsC8pt7IcFpjA", ...}} + pass + + if id_ in current_ids: + # this object is currently being fetched, don't try to fetch again, + # to avoid recursion instead, record the relation that should be + # created once "object_id" object exists + if pending_relations is not None: + object_id = manipulated_data["id"] + pending_relations.append((object_id, field, id_)) + skip = True + + if not skip: + field_data, _ = field.related_model._get_or_create_from_stripe_object( + manipulated_data, + field_name, + refetch=refetch, + current_ids=current_ids, + pending_relations=pending_relations, + ) + else: + # eg PaymentMethod, handled in hooks + skip = True + + return field_data, skip + + @classmethod + def is_valid_object(cls, data): + """ + Returns whether the data is a valid object for the class + """ + return data["object"] == cls.stripe_class.OBJECT_NAME + + def _attach_objects_hook(self, cls, data): + """ + Gets called by this object's create and sync methods just before save. + Use this to populate fields before the model is saved. + + :param cls: The target class for the instantiated object. + :param data: The data dictionary received from the Stripe API. + :type data: dict + """ + + pass + + def _attach_objects_post_save_hook(self, cls, data, pending_relations=None): + """ + Gets called by this object's create and sync methods just after save. + Use this to populate fields after the model is saved. + + :param cls: The target class for the instantiated object. + :param data: The data dictionary received from the Stripe API. + :type data: dict + """ + + unprocessed_pending_relations = [] + if pending_relations is not None: + for post_save_relation in pending_relations: + object_id, field, id_ = post_save_relation + + if self.id == id_: + # the target instance now exists + target = field.model.objects.get(id=object_id) + setattr(target, field.name, self) + target.save() + + # reload so that indirect relations back to this object + # eg self.charge.invoice = self are set + # TODO - reverse the field reference here to avoid hitting the DB? + self.refresh_from_db() + else: + unprocessed_pending_relations.append(post_save_relation) + + if len(pending_relations) != len(unprocessed_pending_relations): + # replace in place so passed in list is updated in calling method + pending_relations[:] = unprocessed_pending_relations + + @classmethod + def _create_from_stripe_object( + cls, data, current_ids=None, pending_relations=None, save=True + ): + """ + Instantiates a model instance using the provided data object received + from Stripe, and saves it to the database if specified. + + :param data: The data dictionary received from the Stripe API. + :type data: dict + :param current_ids: stripe ids of objects that are currently being processed + :type current_ids: set + :param pending_relations: list of tuples of relations to be attached post-save + :type pending_relations: list + :param save: If True, the object is saved after instantiation. + :type save: bool + :returns: The instantiated object. + """ + + instance = cls( + **cls._stripe_object_to_record( + data, current_ids=current_ids, pending_relations=pending_relations + ) + ) + instance._attach_objects_hook(cls, data) + + if save: + instance.save(force_insert=True) + + instance._attach_objects_post_save_hook( + cls, data, pending_relations=pending_relations + ) + + return instance + + @classmethod + def _get_or_create_from_stripe_object( + cls, + data, + field_name="id", + refetch=True, + current_ids=None, + pending_relations=None, + save=True, + ): + """ + + :param data: + :param field_name: + :param refetch: + :param current_ids: stripe ids of objects that are currently being processed + :type current_ids: set + :param pending_relations: list of tuples of relations to be attached post-save + :type pending_relations: list + :param save: + :return: + """ + field = data.get(field_name) + is_nested_data = field_name != "id" + should_expand = False + + if pending_relations is None: + pending_relations = [] + + id_ = cls._id_from_data(field) + + if not field: + # An empty field - We need to return nothing here because there is + # no way of knowing what needs to be fetched! + logger.warning( + "empty field %s.%s = %r - this is a bug, " + "please report it to dj-stripe! data = %r", + cls.__name__, + field_name, + field, + data, + ) + return None, False + elif id_ == field: + # A field like {"subscription": "sub_6lsC8pt7IcFpjA", ...} + # We'll have to expand if the field is not "id" (= is nested) + should_expand = is_nested_data + else: + # A field like {"subscription": {"id": sub_6lsC8pt7IcFpjA", ...}} + data = field + + try: + return cls.stripe_objects.get(id=id_), False + except cls.DoesNotExist: + if is_nested_data and refetch: + # This is what `data` usually looks like: + # {"id": "cus_XXXX", "default_source": "card_XXXX"} + # Leaving the default field_name ("id") will get_or_create the customer. + # If field_name="default_source", we get_or_create the card instead. + cls_instance = cls(id=id_) + data = cls_instance.api_retrieve() + should_expand = False + + # The next thing to happen will be the "create from stripe object" call. + # At this point, if we don't have data to start with (field is a str), + # *and* we didn't refetch by id, then `should_expand` is True and we + # don't have the data to actually create the object. + # If this happens when syncing Stripe data, it's a djstripe bug. Report it! + assert not should_expand, "No data to create {} from {}".format( + cls.__name__, field_name + ) + + try: + # We wrap the `_create_from_stripe_object` in a transaction to + # avoid TransactionManagementError on subsequent queries in case + # of the IntegrityError catch below. See PR #903 + with transaction.atomic(): + return ( + cls._create_from_stripe_object( + data, + current_ids=current_ids, + pending_relations=pending_relations, + save=save, + ), + True, + ) + except IntegrityError: + # Handle the race condition that something else created the object + # after the `get` and before `_create_from_stripe_object`. + # This is common during webhook handling, since Stripe sends + # multiple webhook events simultaneously, + # each of which will cause recursive syncs. See issue #429 + return cls.stripe_objects.get(id=id_), False + + @classmethod + def _stripe_object_to_customer(cls, target_cls, data): + """ + Search the given manager for the Customer matching this object's + ``customer`` field. + :param target_cls: The target class + :type target_cls: Customer + :param data: stripe object + :type data: dict + """ + + if "customer" in data and data["customer"]: + return target_cls._get_or_create_from_stripe_object(data, "customer")[0] + + @classmethod + def _stripe_object_to_invoice_items(cls, target_cls, data, invoice): + """ + Retrieves InvoiceItems for an invoice. + + If the invoice item doesn't exist already then it is created. + + If the invoice is an upcoming invoice that doesn't persist to the + database (i.e. ephemeral) then the invoice items are also not saved. + + :param target_cls: The target class to instantiate per invoice item. + :type target_cls: ``InvoiceItem`` + :param data: The data dictionary received from the Stripe API. + :type data: dict + :param invoice: The invoice object that should hold the invoice items. + :type invoice: ``djstripe.models.Invoice`` + """ + + lines = data.get("lines") + if not lines: + return [] + + invoiceitems = [] + for line in lines.get("data", []): + if invoice.id: + save = True + line.setdefault("invoice", invoice.id) + + if line.get("type") == "subscription": + # Lines for subscriptions need to be keyed based on invoice and + # subscription, because their id is *just* the subscription + # when received from Stripe. This means that future updates to + # a subscription will change previously saved invoices - Doing + # the composite key avoids this. + if not line["id"].startswith(invoice.id): + line["id"] = "{invoice_id}-{subscription_id}".format( + invoice_id=invoice.id, subscription_id=line["id"] + ) + else: + # Don't save invoice items for ephemeral invoices + save = False + + line.setdefault("customer", invoice.customer.id) + line.setdefault("date", int(dateformat.format(invoice.created, "U"))) + + item, _ = target_cls._get_or_create_from_stripe_object( + line, refetch=False, save=save + ) + invoiceitems.append(item) + + return invoiceitems + + @classmethod + def _stripe_object_to_subscription_items(cls, target_cls, data, subscription): + """ + Retrieves SubscriptionItems for a subscription. + + If the subscription item doesn't exist already then it is created. + + :param target_cls: The target class to instantiate per invoice item. + :type target_cls: ``SubscriptionItem`` + :param data: The data dictionary received from the Stripe API. + :type data: dict + :param invoice: The invoice object that should hold the invoice items. + :type invoice: ``djstripe.models.Subscription`` + """ + + items = data.get("items") + if not items: + return [] + + subscriptionitems = [] + for item_data in items.get("data", []): + item, _ = target_cls._get_or_create_from_stripe_object( + item_data, refetch=False + ) + subscriptionitems.append(item) + + return subscriptionitems + + @classmethod + def _stripe_object_to_refunds(cls, target_cls, data, charge): + """ + Retrieves Refunds for a charge + :param target_cls: The target class to instantiate per invoice item. + :type target_cls: ``Refund`` + :param data: The data dictionary received from the Stripe API. + :type data: dict + :param charge: The charge object that refunds are for. + :type invoice: ``djstripe.models.Refund`` + :return: + """ + + refunds = data.get("refunds") + if not refunds: + return [] + + refund_objs = [] + for refund_data in refunds.get("data", []): + item, _ = target_cls._get_or_create_from_stripe_object( + refund_data, refetch=False + ) + refund_objs.append(item) + + return refund_objs + + def _sync(self, record_data): + for attr, value in record_data.items(): + setattr(self, attr, value) + + @classmethod + def sync_from_stripe_data(cls, data): + """ + Syncs this object from the stripe data provided. + + Foreign keys will also be retrieved and synced recursively. + + :param data: stripe object + :type data: dict + """ + current_ids = set() + data_id = data.get("id") + + if data_id: + # stop nested objects from trying to retrieve this object before + # initial sync is complete + current_ids.add(data_id) + + instance, created = cls._get_or_create_from_stripe_object( + data, current_ids=current_ids + ) + + if not created: + instance._sync(cls._stripe_object_to_record(data)) + instance._attach_objects_hook(cls, data) + instance.save() + instance._attach_objects_post_save_hook(cls, data) + + return instance + + def __str__(self): + return smart_text("<{list}>".format(list=", ".join(self.str_parts()))) class IdempotencyKey(models.Model): - uuid = models.UUIDField( - max_length=36, primary_key=True, editable=False, default=uuid.uuid4 - ) - action = models.CharField(max_length=100) - livemode = models.BooleanField( - help_text="Whether the key was used in live or test mode." - ) - created = models.DateTimeField(auto_now_add=True) - - class Meta: - unique_together = ("action", "livemode") - - def __str__(self): - return str(self.uuid) - - @property - def is_expired(self): - return timezone.now() > self.created + timedelta(hours=24) + uuid = models.UUIDField( + max_length=36, primary_key=True, editable=False, default=uuid.uuid4 + ) + action = models.CharField(max_length=100) + livemode = models.BooleanField( + help_text="Whether the key was used in live or test mode." + ) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("action", "livemode") + + def __str__(self): + return str(self.uuid) + + @property + def is_expired(self): + return timezone.now() > self.created + timedelta(hours=24) diff --git a/djstripe/models/billing.py b/djstripe/models/billing.py index 37be881a81..7094ab853b 100644 --- a/djstripe/models/billing.py +++ b/djstripe/models/billing.py @@ -11,8 +11,13 @@ from .. import enums from .. import settings as djstripe_settings from ..fields import ( - JSONField, StripeCurrencyCodeField, StripeDateTimeField, - StripeDecimalCurrencyAmountField, StripeEnumField, StripeIdField, StripePercentField + JSONField, + StripeCurrencyCodeField, + StripeDateTimeField, + StripeDecimalCurrencyAmountField, + StripeEnumField, + StripeIdField, + StripePercentField, ) from ..managers import SubscriptionManager from ..utils import QuerySetMock, get_friendly_currency_amount @@ -20,1263 +25,1351 @@ class Coupon(StripeModel): - id = StripeIdField(max_length=500) - amount_off = StripeDecimalCurrencyAmountField( - null=True, - blank=True, - help_text="Amount that will be taken off the subtotal of any invoices for this customer.", - ) - currency = StripeCurrencyCodeField(null=True, blank=True) - duration = StripeEnumField( - enum=enums.CouponDuration, - help_text=( - "Describes how long a customer who applies this coupon will get the discount." - ), - ) - duration_in_months = models.PositiveIntegerField( - null=True, - blank=True, - help_text="If `duration` is `repeating`, the number of months the coupon applies.", - ) - max_redemptions = models.PositiveIntegerField( - null=True, - blank=True, - help_text="Maximum number of times this coupon can be redeemed, in total, before it is no longer valid.", - ) - name = models.TextField( - max_length=5000, - default="", - blank=True, - help_text=( - "Name of the coupon displayed to customers on for instance invoices or receipts." - ), - ) - percent_off = StripePercentField( - null=True, - blank=True, - help_text=( - "Percent that will be taken off the subtotal of any invoices for 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 = StripeDateTimeField( - null=True, - blank=True, - help_text="Date after which the coupon can no longer be redeemed. Max 5 years in the future.", - ) - times_redeemed = models.PositiveIntegerField( - editable=False, - default=0, - help_text="Number of times this coupon has been applied to a customer.", - ) - # valid = models.BooleanField(editable=False) - - # XXX - DURATION_FOREVER = "forever" - DURATION_ONCE = "once" - DURATION_REPEATING = "repeating" - - class Meta: - unique_together = ("id", "livemode") - - stripe_class = stripe.Coupon - stripe_dashboard_item_name = "coupons" - - def __str__(self): - if self.name: - return self.name - return self.human_readable - - @property - def human_readable_amount(self): - if self.percent_off: - amount = "{percent_off}%".format(percent_off=self.percent_off) - else: - amount = get_friendly_currency_amount(self.amount_off or 0, self.currency) - return "{amount} off".format(amount=amount) - - @property - def human_readable(self): - if self.duration == self.DURATION_REPEATING: - if self.duration_in_months == 1: - duration = "for {duration_in_months} month" - else: - duration = "for {duration_in_months} months" - duration = duration.format(duration_in_months=self.duration_in_months) - else: - duration = self.duration - return "{amount} {duration}".format( - amount=self.human_readable_amount, duration=duration - ) + id = StripeIdField(max_length=500) + amount_off = StripeDecimalCurrencyAmountField( + null=True, + blank=True, + help_text="Amount that will be taken off the subtotal of any invoices " + "for this customer.", + ) + currency = StripeCurrencyCodeField(null=True, blank=True) + duration = StripeEnumField( + enum=enums.CouponDuration, + help_text=( + "Describes how long a customer who applies this coupon " + "will get the discount." + ), + ) + duration_in_months = models.PositiveIntegerField( + null=True, + blank=True, + help_text="If `duration` is `repeating`, the number of months " + "the coupon applies.", + ) + max_redemptions = models.PositiveIntegerField( + null=True, + blank=True, + help_text="Maximum number of times this coupon can be redeemed, in total, " + "before it is no longer valid.", + ) + name = models.TextField( + max_length=5000, + default="", + blank=True, + help_text=( + "Name of the coupon displayed to customers on for instance invoices " + "or receipts." + ), + ) + percent_off = StripePercentField( + null=True, + blank=True, + help_text=( + "Percent that will be taken off the subtotal of any invoices for " + "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 = StripeDateTimeField( + null=True, + blank=True, + help_text="Date after which the coupon can no longer be redeemed. " + "Max 5 years in the future.", + ) + times_redeemed = models.PositiveIntegerField( + editable=False, + default=0, + help_text="Number of times this coupon has been applied to a customer.", + ) + # valid = models.BooleanField(editable=False) + + # XXX + DURATION_FOREVER = "forever" + DURATION_ONCE = "once" + DURATION_REPEATING = "repeating" + + class Meta: + unique_together = ("id", "livemode") + + stripe_class = stripe.Coupon + stripe_dashboard_item_name = "coupons" + + def __str__(self): + if self.name: + return self.name + return self.human_readable + + @property + def human_readable_amount(self): + if self.percent_off: + amount = "{percent_off}%".format(percent_off=self.percent_off) + else: + amount = get_friendly_currency_amount(self.amount_off or 0, self.currency) + return "{amount} off".format(amount=amount) + + @property + def human_readable(self): + if self.duration == self.DURATION_REPEATING: + if self.duration_in_months == 1: + duration = "for {duration_in_months} month" + else: + duration = "for {duration_in_months} months" + duration = duration.format(duration_in_months=self.duration_in_months) + else: + duration = self.duration + return "{amount} {duration}".format( + amount=self.human_readable_amount, duration=duration + ) class Invoice(StripeModel): - """ - Invoices are statements of what a customer owes for a particular billing - period, including subscriptions, invoice items, and any automatic proration - adjustments if necessary. - - Once an invoice is created, payment is automatically attempted. Note that - the payment, while automatic, does not happen exactly at the time of invoice - creation. If you have configured webhooks, the invoice will wait until one - hour after the last webhook is successfully sent (or the last webhook times - out after failing). - - Any customer credit on the account is applied before determining how much is - due for that invoice (the amount that will be actually charged). - If the amount due for the invoice is less than 50 cents (the minimum for a - charge), we add the amount to the customer's running account balance to be - added to the next invoice. If this amount is negative, it will act as a - credit to offset the next invoice. Note that the customer account balance - does not include unpaid invoices; it only includes balances that need to be - taken into account when calculating the amount due for the next invoice. - - Stripe documentation: https://stripe.com/docs/api/python#invoices - """ - - stripe_class = stripe.Invoice - stripe_dashboard_item_name = "invoices" - - amount_due = StripeDecimalCurrencyAmountField( - help_text="Final amount due at this time for this invoice. If the invoice's total is smaller than the minimum " - "charge amount, for example, or if there is account credit that can be applied to the invoice, the amount_due " - "may be 0. If there is a positive starting_balance for the invoice (the customer owes money), the amount_due " - "will also take that into account. The charge that gets generated for the invoice will be for the amount " - "specified in amount_due." - ) - amount_paid = StripeDecimalCurrencyAmountField( - null=True, # XXX: This is not nullable, but it's a new field - help_text="The amount, in cents, that was paid.", - ) - amount_remaining = StripeDecimalCurrencyAmountField( - null=True, # XXX: This is not nullable, but it's a new field - help_text="The amount remaining, in cents, that is due.", - ) - auto_advance = models.NullBooleanField( - help_text="Controls whether Stripe will perform automatic collection of the invoice. " - "When false, the invoice’s state will not automatically advance without an explicit action." - ) - application_fee_amount = StripeDecimalCurrencyAmountField( - null=True, - help_text="The fee in cents that will be applied to the invoice and transferred to the application owner's " - "Stripe account when the invoice is paid.", - ) - attempt_count = models.IntegerField( - help_text="Number of payment attempts made for this invoice, from the perspective of the payment retry " - "schedule. Any payment attempt counts as the first attempt, and subsequently only automatic retries " - "increment the attempt count. In other words, manual payment attempts after the first attempt do not affect " - "the retry schedule." - ) - attempted = models.BooleanField( - default=False, - help_text="Whether or not an attempt has been made to pay the invoice. An invoice is not attempted until 1 " - "hour after the ``invoice.created`` webhook, for example, so you might not want to display that invoice as " - "unpaid to your users.", - ) - billing = StripeEnumField( - enum=enums.InvoiceBilling, - null=True, - help_text=( - "When charging automatically, Stripe will attempt to pay this invoice " - "using the default source attached to the customer. " - "When sending an invoice, Stripe will email this invoice to the customer " - "with payment instructions." - ), - ) - charge = models.OneToOneField( - "Charge", - on_delete=models.CASCADE, - null=True, - related_name="latest_invoice", - help_text="The latest charge generated for this invoice, if any.", - ) - # deprecated, will be removed in 2.2 - closed = models.NullBooleanField( - default=False, - help_text="Whether or not the invoice is still trying to collect payment. An invoice is closed if it's either " - "paid or it has been marked closed. A closed invoice will no longer attempt to collect payment.", - ) - currency = StripeCurrencyCodeField() - customer = models.ForeignKey( - "Customer", - on_delete=models.CASCADE, - related_name="invoices", - help_text="The customer associated with this invoice.", - ) - # TODO: discount - due_date = StripeDateTimeField( - null=True, - help_text=( - "The date on which payment for this invoice is due. " - "This value will be null for invoices where billing=charge_automatically." - ), - ) - ending_balance = models.IntegerField( - null=True, - help_text="Ending customer balance after attempting to pay invoice. If the invoice has not been attempted " - "yet, this will be null.", - ) - # deprecated, will be removed in 2.2 - forgiven = models.NullBooleanField( - default=False, - help_text="Whether or not the invoice has been forgiven. Forgiving an invoice instructs us to update the " - "subscription status as if the invoice were successfully paid. Once an invoice has been forgiven, it cannot " - "be unforgiven or reopened.", - ) - hosted_invoice_url = models.TextField( - max_length=799, - default="", - blank=True, - help_text=( - "The URL for the hosted invoice page, which allows customers to view and pay an invoice. " - "If the invoice has not been frozen yet, this will be null." - ), - ) - invoice_pdf = models.TextField( - max_length=799, - default="", - blank=True, - help_text=( - "The link to download the PDF for the invoice. " - "If the invoice has not been frozen yet, this will be null." - ), - ) - next_payment_attempt = StripeDateTimeField( - null=True, help_text="The time at which payment will next be attempted." - ) - number = models.CharField( - max_length=64, - default="", - blank=True, - help_text=( - "A unique, identifying string that appears on emails sent to the customer for this invoice. " - "This starts with the customer’s unique invoice_prefix if it is specified." - ), - ) - paid = models.BooleanField( - default=False, help_text="The time at which payment will next be attempted." - ) - payment_intent = models.OneToOneField( - "PaymentIntent", - on_delete=models.CASCADE, - null=True, - help_text=( - "The PaymentIntent associated with this invoice. The PaymentIntent is generated " - "when the invoice is finalized, and can then be used to pay the invoice." - "Note that voiding an invoice will cancel the PaymentIntent" - ), - ) - period_end = StripeDateTimeField( - help_text="End of the usage period during which invoice items were added to this invoice." - ) - period_start = StripeDateTimeField( - help_text="Start of the usage period during which invoice items were added to this invoice." - ) - receipt_number = models.CharField( - max_length=64, - null=True, - help_text=( - "This is the transaction number that appears on email receipts sent for this invoice." - ), - ) - starting_balance = models.IntegerField( - help_text="Starting customer balance before attempting to pay invoice. If the invoice has not been attempted " - "yet, this will be the current customer balance." - ) - statement_descriptor = models.CharField( - max_length=22, - default="", - blank=True, - help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement " - "description may not include <>\"' characters, and will appear on your customer's statement in capital " - "letters. Non-ASCII characters are automatically stripped. While most banks display this information " - "consistently, some may display it incorrectly or not at all.", - ) - status_transitions = JSONField(null=True, blank=True) - subscription = models.ForeignKey( - "Subscription", - null=True, - related_name="invoices", - on_delete=models.SET_NULL, - help_text="The subscription that this invoice was prepared for, if any.", - ) - subscription_proration_date = StripeDateTimeField( - null=True, - blank=True, - help_text="Only set for upcoming invoices that preview prorations. The time used to calculate prorations.", - ) - subtotal = StripeDecimalCurrencyAmountField( - help_text="Only set for upcoming invoices that preview prorations. The time used to calculate prorations." - ) - tax = StripeDecimalCurrencyAmountField( - null=True, - blank=True, - help_text="The amount of tax included in the total, calculated from ``tax_percent`` and the subtotal. If no " - "``tax_percent`` is defined, this value will be null.", - ) - tax_percent = StripePercentField( - null=True, - help_text="This percentage of the subtotal has been added to the total amount of the invoice, including " - "invoice line items and discounts. This field is inherited from the subscription's ``tax_percent`` field, " - "but can be changed before the invoice is paid. This field defaults to null.", - ) - total = StripeDecimalCurrencyAmountField("Total after discount.") - webhooks_delivered_at = StripeDateTimeField( - null=True, - help_text=( - "The time at which webhooks for this invoice were successfully delivered " - "(if the invoice had no webhooks to deliver, this will match `date`). " - "Invoice payment is delayed until webhooks are delivered, or until all " - "webhook delivery attempts have been exhausted." - ), - ) - - class Meta(object): - ordering = ["-created"] - - def __str__(self): - return "Invoice #{number}".format( - number=self.number or self.receipt_number or self.id - ) - - @classmethod - def _manipulate_stripe_object_hook(cls, data): - data = super()._manipulate_stripe_object_hook(data) - # Invoice.closed and .forgiven deprecated in API 2018-11-08 - see https://stripe.com/docs/upgrades#2018-11-08 - - if "closed" not in data: - # TODO - drop this in 2.2, use auto_advance instead - # https://stripe.com/docs/billing/invoices/migrating-new-invoice-states#autoadvance - if "auto_advance" in data: - data["closed"] = not data["auto_advance"] - else: - data["closed"] = False - - if "forgiven" not in data: - # TODO - drop this in 2.2, use status == "uncollectible" instead - if "status" in data: - data["forgiven"] = data["status"] == "uncollectible" - else: - data["forgiven"] = False - - return data - - @classmethod - def upcoming( - cls, - api_key=djstripe_settings.STRIPE_SECRET_KEY, - customer=None, - coupon=None, - subscription=None, - subscription_plan=None, - subscription_prorate=None, - subscription_proration_date=None, - subscription_quantity=None, - subscription_trial_end=None, - **kwargs - ): - """ - Gets the upcoming preview invoice (singular) for a customer. - - At any time, you can preview the upcoming - invoice for a customer. This will show you all the charges that are - pending, including subscription renewal charges, invoice item charges, - etc. It will also show you any discount that is applicable to the - customer. (Source: https://stripe.com/docs/api#upcoming_invoice) - - .. important:: Note that when you are viewing an upcoming invoice, you are simply viewing a preview. - - :param customer: The identifier of the customer whose upcoming invoice \ - you'd like to retrieve. - :type customer: Customer or string (customer ID) - :param coupon: The code of the coupon to apply. - :type coupon: str - :param subscription: The identifier of the subscription to retrieve an \ - invoice for. - :type subscription: Subscription or string (subscription ID) - :param subscription_plan: If set, the invoice returned will preview \ - updating the subscription given to this plan, or creating a new \ - subscription to this plan if no subscription is given. - :type subscription_plan: Plan or string (plan ID) - :param subscription_prorate: If previewing an update to a subscription, \ - this decides whether the preview will show the result of applying \ - prorations or not. - :type subscription_prorate: bool - :param subscription_proration_date: If previewing an update to a \ - subscription, and doing proration, subscription_proration_date forces \ - the proration to be calculated as though the update was done at the \ - specified time. - :type subscription_proration_date: datetime - :param subscription_quantity: If provided, the invoice returned will \ - preview updating or creating a subscription with that quantity. - :type subscription_quantity: int - :param subscription_trial_end: If provided, the invoice returned will \ - preview updating or creating a subscription with that trial end. - :type subscription_trial_end: datetime - :returns: The upcoming preview invoice. - :rtype: UpcomingInvoice - """ - - # Convert Customer to id - if customer is not None and isinstance(customer, StripeModel): - customer = customer.id - - # Convert Subscription to id - if subscription is not None and isinstance(subscription, StripeModel): - subscription = subscription.id - - # Convert Plan to id - if subscription_plan is not None and isinstance(subscription_plan, StripeModel): - subscription_plan = subscription_plan.id - - try: - upcoming_stripe_invoice = cls.stripe_class.upcoming( - api_key=api_key, - customer=customer, - coupon=coupon, - subscription=subscription, - subscription_plan=subscription_plan, - subscription_prorate=subscription_prorate, - subscription_proration_date=subscription_proration_date, - subscription_quantity=subscription_quantity, - subscription_trial_end=subscription_trial_end, - **kwargs - ) - except InvalidRequestError as exc: - if str(exc) != "Nothing to invoice for customer": - raise - return - - # Workaround for "id" being missing (upcoming invoices don't persist). - upcoming_stripe_invoice["id"] = "upcoming" - - return UpcomingInvoice._create_from_stripe_object(upcoming_stripe_invoice, save=False) - - def retry(self): - """ Retry payment on this invoice if it isn't paid, closed, or forgiven.""" - - if not self.paid and not self.forgiven and not self.closed: - stripe_invoice = self.api_retrieve() - updated_stripe_invoice = ( - stripe_invoice.pay() - ) # pay() throws an exception if the charge is not successful. - type(self).sync_from_stripe_data(updated_stripe_invoice) - return True - return False - - STATUS_PAID = "Paid" - STATUS_FORGIVEN = "Forgiven" - STATUS_CLOSED = "Closed" - STATUS_OPEN = "Open" - - @property - def status(self): - """ Attempts to label this invoice with a status. Note that an invoice can be more than one of the choices. - We just set a priority on which status appears. - """ - - if self.paid: - return self.STATUS_PAID - if self.forgiven: - return self.STATUS_FORGIVEN - if self.closed: - return self.STATUS_CLOSED - return self.STATUS_OPEN - - # deprecated, will be removed in 2.2 - @property - def application_fee(self): - warnings.warn( - "Invoice.application_fee has been renamed to .application_fee_amount. " - "This alias will be removed in djstripe 2.2", - DeprecationWarning, - ) - return self.application_fee_amount - - # deprecated, will be removed in 2.2 - @property - def date(self): - warnings.warn( - "Invoice.date has been removed, use .created instead. This alias will be removed in djstripe 2.2", - DeprecationWarning, - ) - return self.created - - def get_stripe_dashboard_url(self): - return self.customer.get_stripe_dashboard_url() - - def _attach_objects_post_save_hook(self, cls, data, pending_relations=None): - super()._attach_objects_post_save_hook(cls, data, pending_relations=pending_relations) - - # InvoiceItems need a saved invoice because they're associated via a - # RelatedManager, so this must be done as part of the post save hook. - cls._stripe_object_to_invoice_items(target_cls=InvoiceItem, data=data, invoice=self) - - @property - def plan(self): - """ Gets the associated plan for this invoice. - - In order to provide a consistent view of invoices, the plan object - should be taken from the first invoice item that has one, rather than - using the plan associated with the subscription. - - Subscriptions (and their associated plan) are updated by the customer - and represent what is current, but invoice items are immutable within - the invoice and stay static/unchanged. - - In other words, a plan retrieved from an invoice item will represent - the plan as it was at the time an invoice was issued. The plan - retrieved from the subscription will be the currently active plan. - - :returns: The associated plan for the invoice. - :rtype: ``djstripe.Plan`` - """ - - for invoiceitem in self.invoiceitems.all(): - if invoiceitem.plan: - return invoiceitem.plan - - if self.subscription: - return self.subscription.plan + """ + Invoices are statements of what a customer owes for a particular billing + period, including subscriptions, invoice items, and any automatic proration + adjustments if necessary. + + Once an invoice is created, payment is automatically attempted. Note that + the payment, while automatic, does not happen exactly at the time of invoice + creation. If you have configured webhooks, the invoice will wait until one + hour after the last webhook is successfully sent (or the last webhook times + out after failing). + + Any customer credit on the account is applied before determining how much is + due for that invoice (the amount that will be actually charged). + If the amount due for the invoice is less than 50 cents (the minimum for a + charge), we add the amount to the customer's running account balance to be + added to the next invoice. If this amount is negative, it will act as a + credit to offset the next invoice. Note that the customer account balance + does not include unpaid invoices; it only includes balances that need to be + taken into account when calculating the amount due for the next invoice. + + Stripe documentation: https://stripe.com/docs/api/python#invoices + """ + + stripe_class = stripe.Invoice + stripe_dashboard_item_name = "invoices" + + amount_due = StripeDecimalCurrencyAmountField( + help_text="Final amount due at this time for this invoice. " + "If the invoice's total is smaller than the minimum charge amount, " + "for example, or if there is account credit that can be applied to the " + "invoice, the amount_due may be 0. If there is a positive starting_balance " + "for the invoice (the customer owes money), the amount_due will also take that " + "into account. The charge that gets generated for the invoice will be for " + "the amount specified in amount_due." + ) + amount_paid = StripeDecimalCurrencyAmountField( + null=True, # XXX: This is not nullable, but it's a new field + help_text="The amount, in cents, that was paid.", + ) + amount_remaining = StripeDecimalCurrencyAmountField( + null=True, # XXX: This is not nullable, but it's a new field + help_text="The amount remaining, in cents, that is due.", + ) + auto_advance = models.NullBooleanField( + help_text="Controls whether Stripe will perform automatic collection of the " + "invoice. When false, the invoice’s state will not automatically " + "advance without an explicit action." + ) + application_fee_amount = StripeDecimalCurrencyAmountField( + null=True, + help_text="The fee in cents that will be applied to the invoice and " + "transferred to the application owner's " + "Stripe account when the invoice is paid.", + ) + attempt_count = models.IntegerField( + help_text="Number of payment attempts made for this invoice, " + "from the perspective of the payment retry schedule. " + "Any payment attempt counts as the first attempt, and subsequently " + "only automatic retries increment the attempt count. " + "In other words, manual payment attempts after the first attempt do not affect " + "the retry schedule." + ) + attempted = models.BooleanField( + default=False, + help_text="Whether or not an attempt has been made to pay the invoice. " + "An invoice is not attempted until 1 hour after the ``invoice.created`` " + "webhook, for example, so you might not want to display that invoice as " + "unpaid to your users.", + ) + billing = StripeEnumField( + enum=enums.InvoiceBilling, + null=True, + help_text=( + "When charging automatically, Stripe will attempt to pay this invoice " + "using the default source attached to the customer. " + "When sending an invoice, Stripe will email this invoice to the customer " + "with payment instructions." + ), + ) + charge = models.OneToOneField( + "Charge", + on_delete=models.CASCADE, + null=True, + related_name="latest_invoice", + help_text="The latest charge generated for this invoice, if any.", + ) + # deprecated, will be removed in 2.2 + closed = models.NullBooleanField( + default=False, + help_text="Whether or not the invoice is still trying to collect payment." + " An invoice is closed if it's either paid or it has been marked closed. " + "A closed invoice will no longer attempt to collect payment.", + ) + currency = StripeCurrencyCodeField() + customer = models.ForeignKey( + "Customer", + on_delete=models.CASCADE, + related_name="invoices", + help_text="The customer associated with this invoice.", + ) + # TODO: discount + due_date = StripeDateTimeField( + null=True, + help_text=( + "The date on which payment for this invoice is due. " + "This value will be null for invoices where billing=charge_automatically." + ), + ) + ending_balance = models.IntegerField( + null=True, + help_text="Ending customer balance after attempting to pay invoice. " + "If the invoice has not been attempted yet, this will be null.", + ) + # deprecated, will be removed in 2.2 + forgiven = models.NullBooleanField( + default=False, + help_text="Whether or not the invoice has been forgiven. " + "Forgiving an invoice instructs us to update the subscription status as " + "if the invoice were successfully paid. Once an invoice has been forgiven, " + "it cannot be unforgiven or reopened.", + ) + hosted_invoice_url = models.TextField( + max_length=799, + default="", + blank=True, + help_text="The URL for the hosted invoice page, which allows customers to view " + "and pay an invoice. If the invoice has not been frozen yet, " + "this will be null.", + ) + invoice_pdf = models.TextField( + max_length=799, + default="", + blank=True, + help_text=( + "The link to download the PDF for the invoice. " + "If the invoice has not been frozen yet, this will be null." + ), + ) + next_payment_attempt = StripeDateTimeField( + null=True, help_text="The time at which payment will next be attempted." + ) + number = models.CharField( + max_length=64, + default="", + blank=True, + help_text=( + "A unique, identifying string that appears on emails sent to the customer " + "for this invoice. " + "This starts with the customer’s unique invoice_prefix if it is specified." + ), + ) + paid = models.BooleanField( + default=False, help_text="The time at which payment will next be attempted." + ) + payment_intent = models.OneToOneField( + "PaymentIntent", + on_delete=models.CASCADE, + null=True, + help_text=( + "The PaymentIntent associated with this invoice. " + "The PaymentIntent is generated when the invoice is finalized, " + "and can then be used to pay the invoice." + "Note that voiding an invoice will cancel the PaymentIntent" + ), + ) + period_end = StripeDateTimeField( + help_text="End of the usage period during which invoice items were " + "added to this invoice." + ) + period_start = StripeDateTimeField( + help_text="Start of the usage period during which invoice items were " + "added to this invoice." + ) + receipt_number = models.CharField( + max_length=64, + null=True, + help_text=( + "This is the transaction number that appears on email receipts " + "sent for this invoice." + ), + ) + starting_balance = models.IntegerField( + help_text="Starting customer balance before attempting to pay invoice. " + "If the invoice has not been attempted " + "yet, this will be the current customer balance." + ) + statement_descriptor = models.CharField( + max_length=22, + default="", + blank=True, + help_text="An arbitrary string to be displayed on your customer's " + "credit card statement. The statement description may not include <>\"' " + "characters, and will appear on your customer's statement in capital letters. " + "Non-ASCII characters are automatically stripped. " + "While most banks display this information consistently, " + "some may display it incorrectly or not at all.", + ) + status_transitions = JSONField(null=True, blank=True) + subscription = models.ForeignKey( + "Subscription", + null=True, + related_name="invoices", + on_delete=models.SET_NULL, + help_text="The subscription that this invoice was prepared for, if any.", + ) + subscription_proration_date = StripeDateTimeField( + null=True, + blank=True, + help_text="Only set for upcoming invoices that preview prorations. " + "The time used to calculate prorations.", + ) + subtotal = StripeDecimalCurrencyAmountField( + help_text="Only set for upcoming invoices that preview prorations. " + "The time used to calculate prorations." + ) + tax = StripeDecimalCurrencyAmountField( + null=True, + blank=True, + help_text="The amount of tax included in the total, calculated from " + "``tax_percent`` and the subtotal. If no " + "``tax_percent`` is defined, this value will be null.", + ) + tax_percent = StripePercentField( + null=True, + help_text="This percentage of the subtotal has been added to the total amount " + "of the invoice, including invoice line items and discounts. " + "This field is inherited from the subscription's ``tax_percent`` field, " + "but can be changed before the invoice is paid. This field defaults to null.", + ) + total = StripeDecimalCurrencyAmountField("Total after discount.") + webhooks_delivered_at = StripeDateTimeField( + null=True, + help_text=( + "The time at which webhooks for this invoice were successfully delivered " + "(if the invoice had no webhooks to deliver, this will match `date`). " + "Invoice payment is delayed until webhooks are delivered, or until all " + "webhook delivery attempts have been exhausted." + ), + ) + + class Meta(object): + ordering = ["-created"] + + def __str__(self): + return "Invoice #{number}".format( + number=self.number or self.receipt_number or self.id + ) + + @classmethod + def _manipulate_stripe_object_hook(cls, data): + data = super()._manipulate_stripe_object_hook(data) + # Invoice.closed and .forgiven deprecated in API 2018-11-08 - + # see https://stripe.com/docs/upgrades#2018-11-08 + + if "closed" not in data: + # TODO - drop this in 2.2, use auto_advance instead + # https://stripe.com/docs/billing/invoices/migrating-new-invoice-states#autoadvance + if "auto_advance" in data: + data["closed"] = not data["auto_advance"] + else: + data["closed"] = False + + if "forgiven" not in data: + # TODO - drop this in 2.2, use status == "uncollectible" instead + if "status" in data: + data["forgiven"] = data["status"] == "uncollectible" + else: + data["forgiven"] = False + + return data + + @classmethod + def upcoming( + cls, + api_key=djstripe_settings.STRIPE_SECRET_KEY, + customer=None, + coupon=None, + subscription=None, + subscription_plan=None, + subscription_prorate=None, + subscription_proration_date=None, + subscription_quantity=None, + subscription_trial_end=None, + **kwargs + ): + """ + Gets the upcoming preview invoice (singular) for a customer. + + At any time, you can preview the upcoming + invoice for a customer. This will show you all the charges that are + pending, including subscription renewal charges, invoice item charges, + etc. It will also show you any discount that is applicable to the + customer. (Source: https://stripe.com/docs/api#upcoming_invoice) + + .. important:: Note that when you are viewing an upcoming invoice, + you are simply viewing a preview. + + :param customer: The identifier of the customer whose upcoming invoice \ + you'd like to retrieve. + :type customer: Customer or string (customer ID) + :param coupon: The code of the coupon to apply. + :type coupon: str + :param subscription: The identifier of the subscription to retrieve an \ + invoice for. + :type subscription: Subscription or string (subscription ID) + :param subscription_plan: If set, the invoice returned will preview \ + updating the subscription given to this plan, or creating a new \ + subscription to this plan if no subscription is given. + :type subscription_plan: Plan or string (plan ID) + :param subscription_prorate: If previewing an update to a subscription, \ + this decides whether the preview will show the result of applying \ + prorations or not. + :type subscription_prorate: bool + :param subscription_proration_date: If previewing an update to a \ + subscription, and doing proration, subscription_proration_date forces \ + the proration to be calculated as though the update was done at the \ + specified time. + :type subscription_proration_date: datetime + :param subscription_quantity: If provided, the invoice returned will \ + preview updating or creating a subscription with that quantity. + :type subscription_quantity: int + :param subscription_trial_end: If provided, the invoice returned will \ + preview updating or creating a subscription with that trial end. + :type subscription_trial_end: datetime + :returns: The upcoming preview invoice. + :rtype: UpcomingInvoice + """ + + # Convert Customer to id + if customer is not None and isinstance(customer, StripeModel): + customer = customer.id + + # Convert Subscription to id + if subscription is not None and isinstance(subscription, StripeModel): + subscription = subscription.id + + # Convert Plan to id + if subscription_plan is not None and isinstance(subscription_plan, StripeModel): + subscription_plan = subscription_plan.id + + try: + upcoming_stripe_invoice = cls.stripe_class.upcoming( + api_key=api_key, + customer=customer, + coupon=coupon, + subscription=subscription, + subscription_plan=subscription_plan, + subscription_prorate=subscription_prorate, + subscription_proration_date=subscription_proration_date, + subscription_quantity=subscription_quantity, + subscription_trial_end=subscription_trial_end, + **kwargs + ) + except InvalidRequestError as exc: + if str(exc) != "Nothing to invoice for customer": + raise + return + + # Workaround for "id" being missing (upcoming invoices don't persist). + upcoming_stripe_invoice["id"] = "upcoming" + + return UpcomingInvoice._create_from_stripe_object( + upcoming_stripe_invoice, save=False + ) + + def retry(self): + """ Retry payment on this invoice if it isn't paid, closed, or forgiven.""" + + if not self.paid and not self.forgiven and not self.closed: + stripe_invoice = self.api_retrieve() + updated_stripe_invoice = ( + stripe_invoice.pay() + ) # pay() throws an exception if the charge is not successful. + type(self).sync_from_stripe_data(updated_stripe_invoice) + return True + return False + + STATUS_PAID = "Paid" + STATUS_FORGIVEN = "Forgiven" + STATUS_CLOSED = "Closed" + STATUS_OPEN = "Open" + + @property + def status(self): + """ + Attempts to label this invoice with a status. + Note that an invoice can be more than one of the choices. + We just set a priority on which status appears. + """ + + if self.paid: + return self.STATUS_PAID + if self.forgiven: + return self.STATUS_FORGIVEN + if self.closed: + return self.STATUS_CLOSED + return self.STATUS_OPEN + + # deprecated, will be removed in 2.2 + @property + def application_fee(self): + warnings.warn( + "Invoice.application_fee has been renamed to .application_fee_amount. " + "This alias will be removed in djstripe 2.2", + DeprecationWarning, + ) + return self.application_fee_amount + + # deprecated, will be removed in 2.2 + @property + def date(self): + warnings.warn( + "Invoice.date has been removed, use .created instead." + "This alias will be removed in djstripe 2.2", + DeprecationWarning, + ) + return self.created + + def get_stripe_dashboard_url(self): + return self.customer.get_stripe_dashboard_url() + + def _attach_objects_post_save_hook(self, cls, data, pending_relations=None): + super()._attach_objects_post_save_hook( + cls, data, pending_relations=pending_relations + ) + + # InvoiceItems need a saved invoice because they're associated via a + # RelatedManager, so this must be done as part of the post save hook. + cls._stripe_object_to_invoice_items( + target_cls=InvoiceItem, data=data, invoice=self + ) + + @property + def plan(self): + """ Gets the associated plan for this invoice. + + In order to provide a consistent view of invoices, the plan object + should be taken from the first invoice item that has one, rather than + using the plan associated with the subscription. + + Subscriptions (and their associated plan) are updated by the customer + and represent what is current, but invoice items are immutable within + the invoice and stay static/unchanged. + + In other words, a plan retrieved from an invoice item will represent + the plan as it was at the time an invoice was issued. The plan + retrieved from the subscription will be the currently active plan. + + :returns: The associated plan for the invoice. + :rtype: ``djstripe.Plan`` + """ + + for invoiceitem in self.invoiceitems.all(): + if invoiceitem.plan: + return invoiceitem.plan + + if self.subscription: + return self.subscription.plan class UpcomingInvoice(Invoice): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._invoiceitems = [] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._invoiceitems = [] - def get_stripe_dashboard_url(self): - return "" + def get_stripe_dashboard_url(self): + return "" - def _attach_objects_hook(self, cls, data): - super()._attach_objects_hook(cls, data) - self._invoiceitems = cls._stripe_object_to_invoice_items( - target_cls=InvoiceItem, data=data, invoice=self - ) + def _attach_objects_hook(self, cls, data): + super()._attach_objects_hook(cls, data) + self._invoiceitems = cls._stripe_object_to_invoice_items( + target_cls=InvoiceItem, data=data, invoice=self + ) - @property - def invoiceitems(self): - """ - Gets the invoice items associated with this upcoming invoice. + @property + def invoiceitems(self): + """ + Gets the invoice items associated with this upcoming invoice. - This differs from normal (non-upcoming) invoices, in that upcoming - invoices are in-memory and do not persist to the database. Therefore, - all of the data comes from the Stripe API itself. + This differs from normal (non-upcoming) invoices, in that upcoming + invoices are in-memory and do not persist to the database. Therefore, + all of the data comes from the Stripe API itself. - Instead of returning a normal queryset for the invoiceitems, this will - return a mock of a queryset, but with the data fetched from Stripe - It - will act like a normal queryset, but mutation will silently fail. - """ + Instead of returning a normal queryset for the invoiceitems, this will + return a mock of a queryset, but with the data fetched from Stripe - It + will act like a normal queryset, but mutation will silently fail. + """ - return QuerySetMock.from_iterable(InvoiceItem, self._invoiceitems) + return QuerySetMock.from_iterable(InvoiceItem, self._invoiceitems) - @property - def id(self): - return None + @property + def id(self): + return None - @id.setter - def id(self, value): - return # noop + @id.setter + def id(self, value): + return # noop - def save(self, *args, **kwargs): - return # noop + def save(self, *args, **kwargs): + return # noop class InvoiceItem(StripeModel): - """ - Sometimes you want to add a charge or credit to a customer but only actually - charge the customer's card at the end of a regular billing cycle. - This is useful for combining several charges to minimize per-transaction fees - or having Stripe tabulate your usage-based billing totals. - - Stripe documentation: https://stripe.com/docs/api/python#invoiceitems - """ - - stripe_class = stripe.InvoiceItem - - amount = StripeDecimalCurrencyAmountField(help_text="Amount invoiced.") - currency = StripeCurrencyCodeField() - customer = models.ForeignKey( - "Customer", - on_delete=models.CASCADE, - related_name="invoiceitems", - help_text="The customer associated with this invoiceitem.", - ) - date = StripeDateTimeField(help_text="The date on the invoiceitem.") - discountable = models.BooleanField( - default=False, - help_text="If True, discounts will apply to this invoice item. Always False for prorations.", - ) - invoice = models.ForeignKey( - "Invoice", - on_delete=models.CASCADE, - null=True, - related_name="invoiceitems", - help_text="The invoice to which this invoiceitem is attached.", - ) - period = JSONField() - period_end = StripeDateTimeField( - help_text="Might be the date when this invoiceitem's invoice was sent." - ) - period_start = StripeDateTimeField( - help_text="Might be the date when this invoiceitem was added to the invoice" - ) - plan = models.ForeignKey( - "Plan", - null=True, - related_name="invoiceitems", - on_delete=models.SET_NULL, - help_text="If the invoice item is a proration, the plan of the subscription for which the proration was " - "computed.", - ) - proration = models.BooleanField( - default=False, - help_text="Whether or not the invoice item was created automatically as a proration adjustment when the " - "customer switched plans.", - ) - quantity = models.IntegerField( - null=True, - blank=True, - help_text="If the invoice item is a proration, the quantity of the subscription for which the proration " - "was computed.", - ) - subscription = models.ForeignKey( - "Subscription", - null=True, - related_name="invoiceitems", - on_delete=models.SET_NULL, - help_text="The subscription that this invoice item has been created for, if any.", - ) - # XXX: subscription_item - - @classmethod - def _manipulate_stripe_object_hook(cls, data): - data["period_start"] = data["period"]["start"] - data["period_end"] = data["period"]["end"] - - return data - - @classmethod - def sync_from_stripe_data(cls, data): - invoice_data = data.get("invoice") - - if invoice_data: - # sync the Invoice first if it doesn't yet exist in our DB to avoid recursive Charge/Invoice loop - invoice_id = cls._id_from_data(invoice_data) - if not Invoice.objects.filter(id=invoice_id).exists(): - if invoice_id == invoice_data: - # we only have the id, fetch the full data - invoice_data = Invoice(id=invoice_id).api_retrieve() - Invoice.sync_from_stripe_data(data=invoice_data) - - return super().sync_from_stripe_data(data) - - def __str__(self): - if self.plan and self.plan.product: - return self.plan.product.name or str(self.plan) - return super().__str__() - - @classmethod - def is_valid_object(cls, data): - return data["object"] in ("invoiceitem", "line_item") - - def get_stripe_dashboard_url(self): - return self.invoice.get_stripe_dashboard_url() - - def str_parts(self): - return [ - "amount={amount}".format(amount=self.amount), - "date={date}".format(date=self.date), - ] + super().str_parts() + """ + Sometimes you want to add a charge or credit to a customer but only actually + charge the customer's card at the end of a regular billing cycle. + This is useful for combining several charges to minimize per-transaction fees + or having Stripe tabulate your usage-based billing totals. + + Stripe documentation: https://stripe.com/docs/api/python#invoiceitems + """ + + stripe_class = stripe.InvoiceItem + + amount = StripeDecimalCurrencyAmountField(help_text="Amount invoiced.") + currency = StripeCurrencyCodeField() + customer = models.ForeignKey( + "Customer", + on_delete=models.CASCADE, + related_name="invoiceitems", + help_text="The customer associated with this invoiceitem.", + ) + date = StripeDateTimeField(help_text="The date on the invoiceitem.") + discountable = models.BooleanField( + default=False, + help_text="If True, discounts will apply to this invoice item. " + "Always False for prorations.", + ) + invoice = models.ForeignKey( + "Invoice", + on_delete=models.CASCADE, + null=True, + related_name="invoiceitems", + help_text="The invoice to which this invoiceitem is attached.", + ) + period = JSONField() + period_end = StripeDateTimeField( + help_text="Might be the date when this invoiceitem's invoice was sent." + ) + period_start = StripeDateTimeField( + help_text="Might be the date when this invoiceitem was added to the invoice" + ) + plan = models.ForeignKey( + "Plan", + null=True, + related_name="invoiceitems", + on_delete=models.SET_NULL, + help_text="If the invoice item is a proration, the plan of the subscription " + "for which the proration was computed.", + ) + proration = models.BooleanField( + default=False, + help_text="Whether or not the invoice item was created automatically as a " + "proration adjustment when the customer switched plans.", + ) + quantity = models.IntegerField( + null=True, + blank=True, + help_text="If the invoice item is a proration, the quantity of the " + "subscription for which the proration was computed.", + ) + subscription = models.ForeignKey( + "Subscription", + null=True, + related_name="invoiceitems", + on_delete=models.SET_NULL, + help_text="The subscription that this invoice item has been created for, " + "if any.", + ) + # XXX: subscription_item + + @classmethod + def _manipulate_stripe_object_hook(cls, data): + data["period_start"] = data["period"]["start"] + data["period_end"] = data["period"]["end"] + + return data + + @classmethod + def sync_from_stripe_data(cls, data): + invoice_data = data.get("invoice") + + if invoice_data: + # sync the Invoice first if it doesn't yet exist in our DB + # to avoid recursive Charge/Invoice loop + invoice_id = cls._id_from_data(invoice_data) + if not Invoice.objects.filter(id=invoice_id).exists(): + if invoice_id == invoice_data: + # we only have the id, fetch the full data + invoice_data = Invoice(id=invoice_id).api_retrieve() + Invoice.sync_from_stripe_data(data=invoice_data) + + return super().sync_from_stripe_data(data) + + def __str__(self): + if self.plan and self.plan.product: + return self.plan.product.name or str(self.plan) + return super().__str__() + + @classmethod + def is_valid_object(cls, data): + return data["object"] in ("invoiceitem", "line_item") + + def get_stripe_dashboard_url(self): + return self.invoice.get_stripe_dashboard_url() + + def str_parts(self): + return [ + "amount={amount}".format(amount=self.amount), + "date={date}".format(date=self.date), + ] + super().str_parts() class Plan(StripeModel): - """ - A subscription plan contains the pricing information for different - products and feature levels on your site. - - Stripe documentation: https://stripe.com/docs/api/python#plans) - """ - - stripe_class = stripe.Plan - stripe_dashboard_item_name = "plans" - - active = models.BooleanField( - help_text="Whether the plan is currently available for new subscriptions." - ) - aggregate_usage = StripeEnumField( - enum=enums.PlanAggregateUsage, - default="", - blank=True, - help_text=( - "Specifies a usage aggregation strategy for plans of usage_type=metered. " - "Allowed values are `sum` for summing up all usage during a period, " - "`last_during_period` for picking the last usage record reported within a " - "period, `last_ever` for picking the last usage record ever (across period " - "bounds) or max which picks the usage record with the maximum reported " - "usage during a period. Defaults to `sum`." - ), - ) - amount = StripeDecimalCurrencyAmountField( - null=True, blank=True, help_text="Amount to be charged on the interval specified." - ) - billing_scheme = StripeEnumField( - enum=enums.PlanBillingScheme, - default="", - blank=True, - help_text=( - "Describes how to compute the price per period. Either `per_unit` or `tiered`. " - "`per_unit` indicates that the fixed amount (specified in amount) will be charged " - "per unit in quantity (for plans with `usage_type=licensed`), or per unit of total " - "usage (for plans with `usage_type=metered`). " - "`tiered` indicates that the unit pricing will be computed using a tiering strategy " - "as defined using the tiers and tiers_mode attributes." - ), - ) - currency = StripeCurrencyCodeField() - interval = StripeEnumField( - enum=enums.PlanInterval, - help_text="The frequency with which a subscription should be billed.", - ) - interval_count = models.IntegerField( - null=True, - help_text=( - "The number of intervals (specified in the interval property) between each subscription billing." - ), - ) - nickname = models.TextField( - max_length=5000, - default="", - blank=True, - help_text="A brief description of the plan, hidden from customers.", - ) - product = models.ForeignKey( - "Product", - on_delete=models.SET_NULL, - null=True, - help_text=("The product whose pricing this plan determines."), - ) - tiers = JSONField( - null=True, - blank=True, - help_text=( - "Each element represents a pricing tier. " - "This parameter requires `billing_scheme` to be set to `tiered`." - ), - ) - tiers_mode = StripeEnumField( - enum=enums.PlanTiersMode, - null=True, - blank=True, - help_text=( - "Defines if the tiering price should be `graduated` or `volume` based. " - "In `volume`-based tiering, the maximum quantity within a period " - "determines the per unit price, in `graduated` tiering pricing can " - "successively change as the quantity grows." - ), - ) - transform_usage = JSONField( - null=True, - blank=True, - help_text=( - "Apply a transformation to the reported usage or set quantity " - "before computing the billed price. Cannot be combined with `tiers`." - ), - ) - trial_period_days = models.IntegerField( - null=True, - help_text=( - "Number of trial period days granted when subscribing a customer to this plan. " - "Null if the plan has no trial period." - ), - ) - usage_type = StripeEnumField( - enum=enums.PlanUsageType, - default=enums.PlanUsageType.licensed, - help_text=( - "Configures how the quantity per period should be determined, can be either " - "`metered` or `licensed`. `licensed` will automatically bill the `quantity` " - "set for a plan when adding it to a subscription, `metered` will aggregate " - "the total usage based on usage records. Defaults to `licensed`." - ), - ) - - # Legacy fields (pre 2017-08-15) - name = models.TextField( - null=True, - blank=True, - help_text="Name of the plan, to be displayed on invoices and in the web interface.", - ) - statement_descriptor = models.CharField( - max_length=22, - null=True, - blank=True, - help_text=( - "An arbitrary string to be displayed on your customer's credit card statement. The statement " - "description may not include <>\"' characters, and will appear on your customer's statement in capital " - "letters. Non-ASCII characters are automatically stripped. While most banks display this information " - "consistently, some may display it incorrectly or not at all." - ), - ) - - class Meta(object): - ordering = ["amount"] - - @classmethod - def get_or_create(cls, **kwargs): - """ Get or create a Plan.""" - - try: - return Plan.objects.get(id=kwargs["id"]), False - except Plan.DoesNotExist: - return cls.create(**kwargs), True - - @classmethod - def create(cls, **kwargs): - # A few minor things are changed in the api-version of the create call - api_kwargs = dict(kwargs) - api_kwargs["amount"] = int(api_kwargs["amount"] * 100) - - if isinstance(api_kwargs.get("product"), StripeModel): - api_kwargs["product"] = api_kwargs["product"].id - - stripe_plan = cls._api_create(**api_kwargs) - plan = cls.sync_from_stripe_data(stripe_plan) - - return plan - - def __str__(self): - return self.name or self.nickname or self.id - - @property - def amount_in_cents(self): - return int(self.amount * 100) - - @property - def human_readable_price(self): - amount = get_friendly_currency_amount(self.amount, self.currency) - interval_count = self.interval_count - - if interval_count == 1: - interval = { - "day": _("day"), - "week": _("week"), - "month": _("month"), - "year": _("year"), - }[self.interval] - template = _("{amount}/{interval}") - else: - interval = { - "day": _("days"), - "week": _("weeks"), - "month": _("months"), - "year": _("years"), - }[self.interval] - template = _("{amount} every {interval_count} {interval}") - - return format_lazy( - template, amount=amount, interval=interval, interval_count=interval_count - ) - - # TODO: Move this type of update to the model's save() method so it happens automatically - # Also, block other fields from being saved. - def update_name(self): - """ - Update the name of the Plan in Stripe and in the db. - - Assumes the object being called has the name attribute already - reset, but has not been saved. - - Stripe does not allow for update of any other Plan attributes besides name. - """ - - p = self.api_retrieve() - p.name = self.name - p.save() - - self.save() + """ + A subscription plan contains the pricing information for different + products and feature levels on your site. + + Stripe documentation: https://stripe.com/docs/api/python#plans) + """ + + stripe_class = stripe.Plan + stripe_dashboard_item_name = "plans" + + active = models.BooleanField( + help_text="Whether the plan is currently available for new subscriptions." + ) + aggregate_usage = StripeEnumField( + enum=enums.PlanAggregateUsage, + default="", + blank=True, + help_text=( + "Specifies a usage aggregation strategy for plans of usage_type=metered. " + "Allowed values are `sum` for summing up all usage during a period, " + "`last_during_period` for picking the last usage record reported within a " + "period, `last_ever` for picking the last usage record ever (across period " + "bounds) or max which picks the usage record with the maximum reported " + "usage during a period. Defaults to `sum`." + ), + ) + amount = StripeDecimalCurrencyAmountField( + null=True, + blank=True, + help_text="Amount to be charged on the interval specified.", + ) + billing_scheme = StripeEnumField( + enum=enums.PlanBillingScheme, + default="", + blank=True, + help_text=( + "Describes how to compute the price per period. " + "Either `per_unit` or `tiered`. " + "`per_unit` indicates that the fixed amount (specified in amount) " + "will be charged per unit in quantity " + "(for plans with `usage_type=licensed`), or per unit of total " + "usage (for plans with `usage_type=metered`). " + "`tiered` indicates that the unit pricing will be computed using " + "a tiering strategy as defined using the tiers and tiers_mode attributes." + ), + ) + currency = StripeCurrencyCodeField() + interval = StripeEnumField( + enum=enums.PlanInterval, + help_text="The frequency with which a subscription should be billed.", + ) + interval_count = models.IntegerField( + null=True, + help_text=( + "The number of intervals (specified in the interval property) " + "between each subscription billing." + ), + ) + nickname = models.TextField( + max_length=5000, + default="", + blank=True, + help_text="A brief description of the plan, hidden from customers.", + ) + product = models.ForeignKey( + "Product", + on_delete=models.SET_NULL, + null=True, + help_text="The product whose pricing this plan determines.", + ) + tiers = JSONField( + null=True, + blank=True, + help_text=( + "Each element represents a pricing tier. " + "This parameter requires `billing_scheme` to be set to `tiered`." + ), + ) + tiers_mode = StripeEnumField( + enum=enums.PlanTiersMode, + null=True, + blank=True, + help_text=( + "Defines if the tiering price should be `graduated` or `volume` based. " + "In `volume`-based tiering, the maximum quantity within a period " + "determines the per unit price, in `graduated` tiering pricing can " + "successively change as the quantity grows." + ), + ) + transform_usage = JSONField( + null=True, + blank=True, + help_text=( + "Apply a transformation to the reported usage or set quantity " + "before computing the billed price. Cannot be combined with `tiers`." + ), + ) + trial_period_days = models.IntegerField( + null=True, + help_text=( + "Number of trial period days granted when subscribing a customer " + "to this plan. Null if the plan has no trial period." + ), + ) + usage_type = StripeEnumField( + enum=enums.PlanUsageType, + default=enums.PlanUsageType.licensed, + help_text=( + "Configures how the quantity per period should be determined, " + "can be either `metered` or `licensed`. `licensed` will automatically " + "bill the `quantity` set for a plan when adding it to a subscription, " + "`metered` will aggregate the total usage based on usage records. " + "Defaults to `licensed`." + ), + ) + + # Legacy fields (pre 2017-08-15) + name = models.TextField( + null=True, + blank=True, + help_text="Name of the plan, to be displayed on invoices and in " + "the web interface.", + ) + statement_descriptor = models.CharField( + max_length=22, + null=True, + blank=True, + help_text="An arbitrary string to be displayed on your customer's credit card " + "statement. The statement description may not include <>\"' characters, " + "and will appear on your customer's statement in capital letters. " + "Non-ASCII characters are automatically stripped. " + "While most banks display this information consistently, some may display it " + "incorrectly or not at all.", + ) + + class Meta(object): + ordering = ["amount"] + + @classmethod + def get_or_create(cls, **kwargs): + """ Get or create a Plan.""" + + try: + return Plan.objects.get(id=kwargs["id"]), False + except Plan.DoesNotExist: + return cls.create(**kwargs), True + + @classmethod + def create(cls, **kwargs): + # A few minor things are changed in the api-version of the create call + api_kwargs = dict(kwargs) + api_kwargs["amount"] = int(api_kwargs["amount"] * 100) + + if isinstance(api_kwargs.get("product"), StripeModel): + api_kwargs["product"] = api_kwargs["product"].id + + stripe_plan = cls._api_create(**api_kwargs) + plan = cls.sync_from_stripe_data(stripe_plan) + + return plan + + def __str__(self): + return self.name or self.nickname or self.id + + @property + def amount_in_cents(self): + return int(self.amount * 100) + + @property + def human_readable_price(self): + amount = get_friendly_currency_amount(self.amount, self.currency) + interval_count = self.interval_count + + if interval_count == 1: + interval = { + "day": _("day"), + "week": _("week"), + "month": _("month"), + "year": _("year"), + }[self.interval] + template = _("{amount}/{interval}") + else: + interval = { + "day": _("days"), + "week": _("weeks"), + "month": _("months"), + "year": _("years"), + }[self.interval] + template = _("{amount} every {interval_count} {interval}") + + return format_lazy( + template, amount=amount, interval=interval, interval_count=interval_count + ) + + # TODO: Move this type of update to the model's save() method + # so it happens automatically + # Also, block other fields from being saved. + def update_name(self): + """ + Update the name of the Plan in Stripe and in the db. + + Assumes the object being called has the name attribute already + reset, but has not been saved. + + Stripe does not allow for update of any other Plan attributes besides name. + """ + + p = self.api_retrieve() + p.name = self.name + p.save() + + self.save() class Subscription(StripeModel): - """ - Subscriptions allow you to charge a customer's card on a recurring basis. - A subscription ties a customer to a particular plan you've created. - - A subscription still in its trial period is ``trialing`` and moves to ``active`` - when the trial period is over. - When payment to renew the subscription fails, the subscription becomes ``past_due``. - After Stripe has exhausted all payment retry attempts, the subscription ends up - with a status of either ``canceled`` or ``unpaid`` depending on your retry settings. - Note that when a subscription has a status of ``unpaid``, no subsequent invoices - will be attempted (invoices will be created, but then immediately automatically closed. - Additionally, updating customer card details will not lead to Stripe retrying the - latest invoice.). - After receiving updated card details from a customer, you may choose to reopen and - pay their closed invoices. - - Stripe documentation: https://stripe.com/docs/api/python#subscriptions - """ - - stripe_class = stripe.Subscription - stripe_dashboard_item_name = "subscriptions" - - application_fee_percent = StripePercentField( - null=True, - blank=True, - help_text="A positive decimal that represents the fee percentage of the subscription invoice amount that " - "will be transferred to the application owner's Stripe account each billing period.", - ) - billing = StripeEnumField( - enum=enums.InvoiceBilling, - help_text=( - "Either `charge_automatically`, or `send_invoice`. When charging automatically, " - "Stripe will attempt to pay this subscription at the end of the cycle using the " - "default source attached to the customer. When sending an invoice, Stripe will " - "email your customer an invoice with payment instructions." - ), - ) - billing_cycle_anchor = StripeDateTimeField( - null=True, - blank=True, - help_text=( - "Determines the date of the first full invoice, and, for plans with `month` or " - "`year` intervals, the day of the month for subsequent invoices." - ), - ) - cancel_at_period_end = models.BooleanField( - default=False, - help_text="If the subscription has been canceled with the ``at_period_end`` flag set to true, " - "``cancel_at_period_end`` on the subscription will be true. You can use this attribute to determine whether " - "a subscription that has a status of active is scheduled to be canceled at the end of the current period.", - ) - canceled_at = StripeDateTimeField( - null=True, - blank=True, - help_text="If the subscription has been canceled, the date of that cancellation. If the subscription was " - "canceled with ``cancel_at_period_end``, canceled_at will still reflect the date of the initial cancellation " - "request, not the end of the subscription period when the subscription is automatically moved to a canceled " - "state.", - ) - current_period_end = StripeDateTimeField( - help_text="End of the current period for which the subscription has been invoiced. At the end of this period, " - "a new invoice will be created." - ) - current_period_start = StripeDateTimeField( - help_text="Start of the current period for which the subscription has been invoiced." - ) - customer = models.ForeignKey( - "Customer", - on_delete=models.CASCADE, - related_name="subscriptions", - help_text="The customer associated with this subscription.", - ) - days_until_due = models.IntegerField( - null=True, - blank=True, - help_text=( - "Number of days a customer has to pay invoices generated by this subscription. " - "This value will be `null` for subscriptions where `billing=charge_automatically`." - ), - ) - # TODO: discount - ended_at = StripeDateTimeField( - null=True, - blank=True, - help_text=( - "If the subscription has ended (either because it was canceled or because the customer was switched " - "to a subscription to a new plan), the date the subscription ended." - ), - ) - pending_setup_intent = models.ForeignKey( - "SetupIntent", - null=True, - blank=True, - on_delete=models.CASCADE, - related_name="setup_intents", - help_text="We can use this SetupIntent to collect user authentication when creating a subscription " - "without immediate payment or updating a subscription’s payment method, allowing you to " - "optimize for off-session payments.", - ) - plan = models.ForeignKey( - "Plan", - null=True, - blank=True, - on_delete=models.CASCADE, - related_name="subscriptions", - help_text="The plan associated with this subscription. This value will be `null` for multi-plan subscriptions", - ) - quantity = models.IntegerField( - null=True, - blank=True, - help_text="The quantity applied to this subscription. This value will be `null` for multi-plan subscriptions", - ) - start = StripeDateTimeField(help_text="Date the subscription started.") - status = StripeEnumField( - enum=enums.SubscriptionStatus, help_text="The status of this subscription." - ) - tax_percent = StripePercentField( - null=True, - blank=True, - help_text="A positive decimal (with at most two decimal places) between 1 and 100. This represents the " - "percentage of the subscription invoice subtotal that will be calculated and added as tax to the final " - "amount each billing period.", - ) - trial_end = StripeDateTimeField( - null=True, - blank=True, - help_text="If the subscription has a trial, the end of that trial.", - ) - trial_start = StripeDateTimeField( - null=True, - blank=True, - help_text="If the subscription has a trial, the beginning of that trial.", - ) - - objects = SubscriptionManager() - - def __str__(self): - return "{customer} on {plan}".format(customer=str(self.customer), plan=str(self.plan)) - - def update( - self, - plan=None, - application_fee_percent=None, - billing_cycle_anchor=None, - coupon=None, - prorate=djstripe_settings.PRORATION_POLICY, - proration_date=None, - metadata=None, - quantity=None, - tax_percent=None, - trial_end=None, - ): - """ - See `Customer.subscribe() <#djstripe.models.Customer.subscribe>`__ - - :param plan: The plan to which to subscribe the customer. - :type plan: Plan or string (plan ID) - :param application_fee_percent: - :type application_fee_percent: - :param billing_cycle_anchor: - :type billing_cycle_anchor: - :param coupon: - :type coupon: - :param prorate: Whether or not to prorate when switching plans. Default is True. - :type prorate: boolean - :param proration_date: - If set, the proration will be calculated as though the subscription was updated at the - given time. This can be used to apply exactly the same proration that was previewed - with upcoming invoice endpoint. It can also be used to implement custom proration - logic, such as prorating by day instead of by second, by providing the time that you - wish to use for proration calculations. - :type proration_date: datetime - :param metadata: - :type metadata: - :param quantity: - :type quantity: - :param tax_percent: - :type tax_percent: - :param trial_end: - :type trial_end: - - .. note:: The default value for ``prorate`` is the DJSTRIPE_PRORATION_POLICY setting. - - .. important:: Updating a subscription by changing the plan or quantity creates a new ``Subscription`` in \ - Stripe (and dj-stripe). - """ - - # Convert Plan to id - if plan is not None and isinstance(plan, StripeModel): - plan = plan.id - - kwargs = deepcopy(locals()) - del kwargs["self"] - - stripe_subscription = self.api_retrieve() - - for kwarg, value in kwargs.items(): - if value is not None: - setattr(stripe_subscription, kwarg, value) - - return Subscription.sync_from_stripe_data(stripe_subscription.save()) - - def extend(self, delta): - """ - Extends this subscription by the provided delta. - - :param delta: The timedelta by which to extend this subscription. - :type delta: timedelta - """ - - if delta.total_seconds() < 0: - raise ValueError("delta must be a positive timedelta.") - - if self.trial_end is not None and self.trial_end > timezone.now(): - period_end = self.trial_end - else: - period_end = self.current_period_end - - period_end += delta - - return self.update(prorate=False, trial_end=period_end) - - def cancel(self, at_period_end=djstripe_settings.CANCELLATION_AT_PERIOD_END): - """ - Cancels this subscription. If you set the at_period_end parameter to true, the subscription will remain active - until the end of the period, at which point it will be canceled and not renewed. By default, the subscription - is terminated immediately. In either case, the customer will not be charged again for the subscription. Note, - however, that any pending invoice items that you've created will still be charged for at the end of the period - unless manually deleted. If you've set the subscription to cancel at period end, any pending prorations will - also be left in place and collected at the end of the period, but if the subscription is set to cancel - immediately, pending prorations will be removed. - - By default, all unpaid invoices for the customer will be closed upon subscription cancellation. We do this in - order to prevent unexpected payment retries once the customer has canceled a subscription. However, you can - reopen the invoices manually after subscription cancellation to have us proceed with automatic retries, or you - could even re-attempt payment yourself on all unpaid invoices before allowing the customer to cancel the - subscription at all. - - :param at_period_end: A flag that if set to true will delay the cancellation of the subscription until the end - of the current period. Default is False. - :type at_period_end: boolean - - .. important:: If a subscription is cancelled during a trial period, the ``at_period_end`` flag will be \ - overridden to False so that the trial ends immediately and the customer's card isn't charged. - """ - - # If plan has trial days and customer cancels before - # trial period ends, then end subscription now, - # i.e. at_period_end=False - if self.trial_end and self.trial_end > timezone.now(): - at_period_end = False - - if at_period_end: - stripe_subscription = self.api_retrieve() - stripe_subscription.cancel_at_period_end = True - stripe_subscription.save() - else: - try: - stripe_subscription = self._api_delete() - except InvalidRequestError as exc: - if "No such subscription:" in str(exc): - # cancel() works by deleting the subscription. The object still - # exists in Stripe however, and can still be retrieved. - # If the subscription was already canceled (status=canceled), - # that api_retrieve() call will fail with "No such subscription". - # However, this may also happen if the subscription legitimately - # does not exist, in which case the following line will re-raise. - stripe_subscription = self.api_retrieve() - else: - raise - - return Subscription.sync_from_stripe_data(stripe_subscription) - - def reactivate(self): - """ - Reactivates this subscription. - - If a customer's subscription is canceled with ``at_period_end`` set to True and it has not yet reached the end - of the billing period, it can be reactivated. Subscriptions canceled immediately cannot be reactivated. - (Source: https://stripe.com/docs/subscriptions/canceling-pausing) - - .. warning:: Reactivating a fully canceled Subscription will fail silently. Be sure to check the returned \ - Subscription's status. - """ - stripe_subscription = self.api_retrieve() - stripe_subscription.plan = self.plan.id - stripe_subscription.cancel_at_period_end = False - - return Subscription.sync_from_stripe_data(stripe_subscription.save()) - - def is_period_current(self): - """ Returns True if this subscription's period is current, false otherwise.""" - - return self.current_period_end > timezone.now() or ( - self.trial_end and self.trial_end > timezone.now() - ) - - def is_status_current(self): - """ Returns True if this subscription's status is current (active or trialing), false otherwise.""" - - return self.status in ["trialing", "active"] - - def is_status_temporarily_current(self): - """ - A status is temporarily current when the subscription is canceled with the ``at_period_end`` flag. - The subscription is still active, but is technically canceled and we're just waiting for it to run out. - - You could use this method to give customers limited service after they've canceled. For example, a video - on demand service could only allow customers to download their libraries and do nothing else when their - subscription is temporarily current. - """ - - return ( - self.canceled_at and self.start < self.canceled_at and self.cancel_at_period_end - ) - - def is_valid(self): - """ Returns True if this subscription's status and period are current, false otherwise.""" - - if not self.is_status_current(): - return False - - if not self.is_period_current(): - return False - - return True - - def _attach_objects_post_save_hook(self, cls, data, pending_relations=None): - super()._attach_objects_post_save_hook(cls, data, pending_relations=pending_relations) - - cls._stripe_object_to_subscription_items( - target_cls=SubscriptionItem, data=data, subscription=self - ) + """ + Subscriptions allow you to charge a customer's card on a recurring basis. + A subscription ties a customer to a particular plan you've created. + + A subscription still in its trial period is ``trialing`` and moves to ``active`` + when the trial period is over. + When payment to renew the subscription fails, the subscription becomes ``past_due``. + After Stripe has exhausted all payment retry attempts, the subscription ends up + with a status of either ``canceled`` or ``unpaid`` depending on your retry settings. + Note that when a subscription has a status of ``unpaid``, no subsequent invoices + will be attempted (invoices will be created, but then immediately + automatically closed. + Additionally, updating customer card details will not lead to Stripe retrying the + latest invoice.). + After receiving updated card details from a customer, you may choose to reopen and + pay their closed invoices. + + Stripe documentation: https://stripe.com/docs/api/python#subscriptions + """ + + stripe_class = stripe.Subscription + stripe_dashboard_item_name = "subscriptions" + + application_fee_percent = StripePercentField( + null=True, + blank=True, + help_text="A positive decimal that represents the fee percentage of the " + "subscription invoice amount that will be transferred to the application " + "owner's Stripe account each billing period.", + ) + billing = StripeEnumField( + enum=enums.InvoiceBilling, + help_text="Either `charge_automatically`, or `send_invoice`. When charging " + "automatically, Stripe will attempt to pay this subscription at the end of the " + "cycle using the default source attached to the customer. " + "When sending an invoice, Stripe will email your customer an invoice with " + "payment instructions.", + ) + billing_cycle_anchor = StripeDateTimeField( + null=True, + blank=True, + help_text=( + "Determines the date of the first full invoice, and, for plans " + "with `month` or `year` intervals, the day of the month for subsequent " + "invoices." + ), + ) + cancel_at_period_end = models.BooleanField( + default=False, + help_text="If the subscription has been canceled with the ``at_period_end`` " + "flag set to true, ``cancel_at_period_end`` on the subscription will be true. " + "You can use this attribute to determine whether a subscription that has a " + "status of active is scheduled to be canceled at the end of the " + "current period.", + ) + canceled_at = StripeDateTimeField( + null=True, + blank=True, + help_text="If the subscription has been canceled, the date of that " + "cancellation. If the subscription was canceled with ``cancel_at_period_end``, " + "canceled_at will still reflect the date of the initial cancellation request, " + "not the end of the subscription period when the subscription is automatically " + "moved to a canceled state.", + ) + current_period_end = StripeDateTimeField( + help_text="End of the current period for which the subscription has been " + "invoiced. At the end of this period, a new invoice will be created." + ) + current_period_start = StripeDateTimeField( + help_text="Start of the current period for which the subscription has " + "been invoiced." + ) + customer = models.ForeignKey( + "Customer", + on_delete=models.CASCADE, + related_name="subscriptions", + help_text="The customer associated with this subscription.", + ) + days_until_due = models.IntegerField( + null=True, + blank=True, + help_text="Number of days a customer has to pay invoices generated by this " + "subscription. This value will be `null` for subscriptions where " + "`billing=charge_automatically`.", + ) + # TODO: discount + ended_at = StripeDateTimeField( + null=True, + blank=True, + help_text="If the subscription has ended (either because it was canceled or " + "because the customer was switched to a subscription to a new plan), " + "the date the subscription ended.", + ) + pending_setup_intent = models.ForeignKey( + "SetupIntent", + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="setup_intents", + help_text="We can use this SetupIntent to collect user authentication " + "when creating a subscription without immediate payment or updating a " + "subscription’s payment method, allowing you to " + "optimize for off-session payments.", + ) + plan = models.ForeignKey( + "Plan", + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="subscriptions", + help_text="The plan associated with this subscription. This value will be " + "`null` for multi-plan subscriptions", + ) + quantity = models.IntegerField( + null=True, + blank=True, + help_text="The quantity applied to this subscription. This value will be " + "`null` for multi-plan subscriptions", + ) + start = StripeDateTimeField(help_text="Date the subscription started.") + status = StripeEnumField( + enum=enums.SubscriptionStatus, help_text="The status of this subscription." + ) + tax_percent = StripePercentField( + null=True, + blank=True, + help_text="A positive decimal (with at most two decimal places) " + "between 1 and 100. This represents the percentage of the subscription " + "invoice subtotal that will be calculated and added as tax to the final " + "amount each billing period.", + ) + trial_end = StripeDateTimeField( + null=True, + blank=True, + help_text="If the subscription has a trial, the end of that trial.", + ) + trial_start = StripeDateTimeField( + null=True, + blank=True, + help_text="If the subscription has a trial, the beginning of that trial.", + ) + + objects = SubscriptionManager() + + def __str__(self): + return "{customer} on {plan}".format( + customer=str(self.customer), plan=str(self.plan) + ) + + def update( + self, + plan=None, + application_fee_percent=None, + billing_cycle_anchor=None, + coupon=None, + prorate=djstripe_settings.PRORATION_POLICY, + proration_date=None, + metadata=None, + quantity=None, + tax_percent=None, + trial_end=None, + ): + """ + See `Customer.subscribe() <#djstripe.models.Customer.subscribe>`__ + + :param plan: The plan to which to subscribe the customer. + :type plan: Plan or string (plan ID) + :param application_fee_percent: + :type application_fee_percent: + :param billing_cycle_anchor: + :type billing_cycle_anchor: + :param coupon: + :type coupon: + :param prorate: Whether or not to prorate when switching plans. Default is True. + :type prorate: boolean + :param proration_date: + If set, the proration will be calculated as though the subscription was + updated at the given time. This can be used to apply exactly the same + proration that was previewed with upcoming invoice endpoint. + It can also be used to implement custom proration logic, such as prorating + by day instead of by second, by providing the time that you + wish to use for proration calculations. + :type proration_date: datetime + :param metadata: + :type metadata: + :param quantity: + :type quantity: + :param tax_percent: + :type tax_percent: + :param trial_end: + :type trial_end: + + .. note:: The default value for ``prorate`` is the DJSTRIPE_PRORATION_POLICY \ + setting. + + .. important:: Updating a subscription by changing the plan or quantity \ + creates a new ``Subscription`` in \ + Stripe (and dj-stripe). + """ + + # Convert Plan to id + if plan is not None and isinstance(plan, StripeModel): + plan = plan.id + + kwargs = deepcopy(locals()) + del kwargs["self"] + + stripe_subscription = self.api_retrieve() + + for kwarg, value in kwargs.items(): + if value is not None: + setattr(stripe_subscription, kwarg, value) + + return Subscription.sync_from_stripe_data(stripe_subscription.save()) + + def extend(self, delta): + """ + Extends this subscription by the provided delta. + + :param delta: The timedelta by which to extend this subscription. + :type delta: timedelta + """ + + if delta.total_seconds() < 0: + raise ValueError("delta must be a positive timedelta.") + + if self.trial_end is not None and self.trial_end > timezone.now(): + period_end = self.trial_end + else: + period_end = self.current_period_end + + period_end += delta + + return self.update(prorate=False, trial_end=period_end) + + def cancel(self, at_period_end=djstripe_settings.CANCELLATION_AT_PERIOD_END): + """ + Cancels this subscription. If you set the at_period_end parameter to true, + the subscription will remain active until the end of the period, at which point + it will be canceled and not renewed. By default, the subscriptionis terminated + immediately. In either case, the customer will not be charged again for + the subscription. Note, however, that any pending invoice items that you've + created will still be charged for at the end of the period unless manually + deleted. If you've set the subscription to cancel at period end, + any pending prorations will also be left in place and collected at the end of + the period, but if the subscription is set to cancel immediately, + pending prorations will be removed. + + By default, all unpaid invoices for the customer will be closed upon + subscription cancellation. We do this in order to prevent unexpected payment + retries once the customer has canceled a subscription. However, you can + reopen the invoices manually after subscription cancellation to have us proceed + with automatic retries, or you could even re-attempt payment yourself on all + unpaid invoices before allowing the customer to cancel the + subscription at all. + + :param at_period_end: A flag that if set to true will delay the cancellation \ + of the subscription until the end of the current period. Default is False. + :type at_period_end: boolean + + .. important:: If a subscription is cancelled during a trial period, \ + the ``at_period_end`` flag will be overridden to False so that the trial ends \ + immediately and the customer's card isn't charged. + """ + + # If plan has trial days and customer cancels before + # trial period ends, then end subscription now, + # i.e. at_period_end=False + if self.trial_end and self.trial_end > timezone.now(): + at_period_end = False + + if at_period_end: + stripe_subscription = self.api_retrieve() + stripe_subscription.cancel_at_period_end = True + stripe_subscription.save() + else: + try: + stripe_subscription = self._api_delete() + except InvalidRequestError as exc: + if "No such subscription:" in str(exc): + # cancel() works by deleting the subscription. The object still + # exists in Stripe however, and can still be retrieved. + # If the subscription was already canceled (status=canceled), + # that api_retrieve() call will fail with "No such subscription". + # However, this may also happen if the subscription legitimately + # does not exist, in which case the following line will re-raise. + stripe_subscription = self.api_retrieve() + else: + raise + + return Subscription.sync_from_stripe_data(stripe_subscription) + + def reactivate(self): + """ + Reactivates this subscription. + + If a customer's subscription is canceled with ``at_period_end`` set to True and + it has not yet reached the end of the billing period, it can be reactivated. + Subscriptions canceled immediately cannot be reactivated. + (Source: https://stripe.com/docs/subscriptions/canceling-pausing) + + .. warning:: Reactivating a fully canceled Subscription will fail silently. \ + Be sure to check the returned Subscription's status. + """ + stripe_subscription = self.api_retrieve() + stripe_subscription.plan = self.plan.id + stripe_subscription.cancel_at_period_end = False + + return Subscription.sync_from_stripe_data(stripe_subscription.save()) + + def is_period_current(self): + """ + Returns True if this subscription's period is current, false otherwise. + """ + + return self.current_period_end > timezone.now() or ( + self.trial_end and self.trial_end > timezone.now() + ) + + def is_status_current(self): + """ + Returns True if this subscription's status is current (active or trialing), + false otherwise. + """ + + return self.status in ["trialing", "active"] + + def is_status_temporarily_current(self): + """ + A status is temporarily current when the subscription is canceled with the + ``at_period_end`` flag. + The subscription is still active, but is technically canceled and we're just + waiting for it to run out. + + You could use this method to give customers limited service after they've + canceled. For example, a video on demand service could only allow customers + to download their libraries and do nothing else when their + subscription is temporarily current. + """ + + return ( + self.canceled_at + and self.start < self.canceled_at + and self.cancel_at_period_end + ) + + def is_valid(self): + """ + Returns True if this subscription's status and period are current, + false otherwise. + """ + + if not self.is_status_current(): + return False + + if not self.is_period_current(): + return False + + return True + + def _attach_objects_post_save_hook(self, cls, data, pending_relations=None): + super()._attach_objects_post_save_hook( + cls, data, pending_relations=pending_relations + ) + + cls._stripe_object_to_subscription_items( + target_cls=SubscriptionItem, data=data, subscription=self + ) class SubscriptionItem(StripeModel): - """ - Subscription items allow you to create customer subscriptions - with more than one plan, making it easy to represent complex billing relationships. - - Stripe documentation: https://stripe.com/docs/api#subscription_items - """ - - stripe_class = stripe.SubscriptionItem - - plan = models.ForeignKey( - "Plan", - on_delete=models.CASCADE, - related_name="subscription_items", - help_text="The plan the customer is subscribed to.", - ) - quantity = models.PositiveIntegerField( - null=True, - blank=True, - help_text=("The quantity of the plan to which the customer should be subscribed."), - ) - subscription = models.ForeignKey( - "Subscription", - on_delete=models.CASCADE, - related_name="items", - help_text="The subscription this subscription item belongs to.", - ) + """ + Subscription items allow you to create customer subscriptions + with more than one plan, making it easy to represent complex billing relationships. + + Stripe documentation: https://stripe.com/docs/api#subscription_items + """ + + stripe_class = stripe.SubscriptionItem + + plan = models.ForeignKey( + "Plan", + on_delete=models.CASCADE, + related_name="subscription_items", + help_text="The plan the customer is subscribed to.", + ) + quantity = models.PositiveIntegerField( + null=True, + blank=True, + help_text=( + "The quantity of the plan to which the customer should be subscribed." + ), + ) + subscription = models.ForeignKey( + "Subscription", + on_delete=models.CASCADE, + related_name="items", + help_text="The subscription this subscription item belongs to.", + ) class UsageRecord(StripeModel): - """ - Usage records allow you to continually report usage and metrics to - Stripe for metered billing of plans. - - Stripe documentation: https://stripe.com/docs/api#usage_records - """ - - quantity = models.PositiveIntegerField( - help_text=("The quantity of the plan to which the customer should be subscribed.") - ) - subscription_item = models.ForeignKey( - "SubscriptionItem", - on_delete=models.CASCADE, - related_name="usage_records", - help_text="The subscription item this usage record contains data for.", - ) + """ + Usage records allow you to continually report usage and metrics to + Stripe for metered billing of plans. + + Stripe documentation: https://stripe.com/docs/api#usage_records + """ + + quantity = models.PositiveIntegerField( + help_text=( + "The quantity of the plan to which the customer should be subscribed." + ) + ) + subscription_item = models.ForeignKey( + "SubscriptionItem", + on_delete=models.CASCADE, + related_name="usage_records", + help_text="The subscription item this usage record contains data for.", + ) diff --git a/djstripe/models/checkout.py b/djstripe/models/checkout.py index dd42d769e3..22e463af54 100644 --- a/djstripe/models/checkout.py +++ b/djstripe/models/checkout.py @@ -7,103 +7,99 @@ class Session(StripeModel): - """ - A Checkout Session represents your customer's session as they pay - for one-time purchases or subscriptions through Checkout. - """ + """ + A Checkout Session represents your customer's session as they pay + for one-time purchases or subscriptions through Checkout. + """ - stripe_class = stripe.PaymentIntent - stripe_dashboard_item_name = "sessions" + stripe_class = stripe.PaymentIntent + stripe_dashboard_item_name = "sessions" - biling_address_collection = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "The value (auto or required) for whether Checkout" - "collected the customer’s billing address." - ), - ) - cancel_url = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "The URL the customer will be directed to if they" - "decide to cancel payment and return to your website." - ), - ) - client_reference_id = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "A unique string to reference the Checkout Session." - "This can be a customer ID, a cart ID, or similar, and" - "can be used to reconcile the session with your internal systems." - ), - ) - customer = models.ForeignKey( - "Customer", - null=True, - on_delete=models.SET_NULL, - help_text=("Customer this Checkout is for if one exists."), - ) - customer_email = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "If provided, this value will be used when the Customer object is created." - ), - ) - display_items = JSONField( - null=True, - blank=True, - help_text=("The line items, plans, or SKUs purchased by the customer."), - ) - locale = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "The IETF language tag of the locale Checkout is displayed in." - "If blank or auto, the browser’s locale is used." - ), - ) - payment_intent = models.ForeignKey( - "PaymentIntent", - null=True, - on_delete=models.SET_NULL, - help_text=("PaymentIntent created if SKUs or line items were provided."), - ) - payment_method_types = JSONField( - help_text=( - "The list of payment method types (e.g. card) that this Checkout Session is allowed to " - "accept." - ) - ) - submit_type = StripeEnumField( - enum=enums.SubmitTypeStatus, - null=True, - blank=True, - help_text=( - "Describes the type of transaction being performed by Checkout" - "in order to customize relevant text on the page, such as the submit button." - ), - ) - subscription = models.ForeignKey( - "Subscription", - null=True, - on_delete=models.SET_NULL, - help_text=("Subscription created if one or more plans were provided."), - ) - success_url = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "The URL the customer will be directed to after the payment or subscription" - "creation is successful." - ), - ) + biling_address_collection = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "The value (auto or required) for whether Checkout" + "collected the customer’s billing address." + ), + ) + cancel_url = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "The URL the customer will be directed to if they" + "decide to cancel payment and return to your website." + ), + ) + client_reference_id = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "A unique string to reference the Checkout Session." + "This can be a customer ID, a cart ID, or similar, and" + "can be used to reconcile the session with your internal systems." + ), + ) + customer = models.ForeignKey( + "Customer", + null=True, + on_delete=models.SET_NULL, + help_text=("Customer this Checkout is for if one exists."), + ) + customer_email = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "If provided, this value will be used when the Customer object is created." + ), + ) + display_items = JSONField( + null=True, + blank=True, + help_text=("The line items, plans, or SKUs purchased by the customer."), + ) + locale = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "The IETF language tag of the locale Checkout is displayed in." + "If blank or auto, the browser’s locale is used." + ), + ) + payment_intent = models.ForeignKey( + "PaymentIntent", + null=True, + on_delete=models.SET_NULL, + help_text=("PaymentIntent created if SKUs or line items were provided."), + ) + payment_method_types = JSONField( + help_text="The list of payment method types (e.g. card) that this " + "Checkout Session is allowed to accept." + ) + submit_type = StripeEnumField( + enum=enums.SubmitTypeStatus, + null=True, + blank=True, + help_text="Describes the type of transaction being performed by Checkout" + "in order to customize relevant text on the page, such as the submit button.", + ) + subscription = models.ForeignKey( + "Subscription", + null=True, + on_delete=models.SET_NULL, + help_text=("Subscription created if one or more plans were provided."), + ) + success_url = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "The URL the customer will be directed to after the payment or subscription" + "creation is successful." + ), + ) diff --git a/djstripe/models/connect.py b/djstripe/models/connect.py index 3fefc3bdf5..804b7580e2 100644 --- a/djstripe/models/connect.py +++ b/djstripe/models/connect.py @@ -6,526 +6,554 @@ from .. import enums from .. import settings as djstripe_settings from ..fields import ( - JSONField, StripeCurrencyCodeField, StripeDecimalCurrencyAmountField, - StripeEnumField, StripeIdField, StripeQuantumCurrencyAmountField + JSONField, + StripeCurrencyCodeField, + StripeDecimalCurrencyAmountField, + StripeEnumField, + StripeIdField, + StripeQuantumCurrencyAmountField, ) from ..managers import TransferManager from .base import StripeModel class Account(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#account - """ - - stripe_class = stripe.Account - # Special handling of the icon and logo fields, they moved to settings.branding in Stripe 2019-02-19 but we - # want them as ForeignKeys - branding_icon = models.ForeignKey( - "FileUpload", - on_delete=models.SET_NULL, - null=True, - related_name="icon_account", - help_text="An icon for the account. Must be square and at least 128px x 128px.", - ) - branding_logo = models.ForeignKey( - "FileUpload", - on_delete=models.SET_NULL, - null=True, - related_name="logo_account", - help_text="A logo for the account that will be used in Checkout instead of the icon " - "and without the account’s name next to it if provided. Must be at least 128px x 128px.", - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - business_name = models.CharField( - max_length=255, - default="", - blank=True, - help_text="The publicly visible name of the business", - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - business_primary_color = models.CharField( - max_length=7, - default="", - blank=True, - help_text=( - "A CSS hex color value representing the primary branding color for this account" - ), - ) - business_profile = JSONField( - null=True, blank=True, help_text=("Optional information related to the business.") - ) - business_type = StripeEnumField( - enum=enums.BusinessType, default="", blank=True, help_text="The business type." - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - business_url = models.CharField( - max_length=200, - default="", - blank=True, - help_text=("The publicly visible website of the business"), - ) - charges_enabled = models.BooleanField( - help_text="Whether the account can create live charges" - ) - country = models.CharField(max_length=2, help_text="The country of the account") - company = JSONField( - null=True, - blank=True, - help_text=( - "Information about the company or business. " - "This field is null unless business_type is set to company." - ), - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - debit_negative_balances = models.NullBooleanField( - null=True, - blank=True, - default=False, - help_text=( - "A Boolean indicating if Stripe should try to reclaim negative " - "balances from an attached bank account." - ), - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - decline_charge_on = JSONField( - null=True, - blank=True, - help_text=( - "Account-level settings to automatically decline certain types " - "of charges regardless of the decision of the card issuer" - ), - ) - default_currency = StripeCurrencyCodeField( - help_text="The currency this account has chosen to use as the default" - ) - details_submitted = models.BooleanField( - help_text=( - "Whether account details have been submitted. " - "Standard accounts cannot receive payouts before this is true." - ) - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - display_name = models.CharField( - max_length=255, - default="", - blank=True, - help_text=( - "The display name for this account. " - "This is used on the Stripe Dashboard to differentiate between accounts." - ), - ) - email = models.CharField(max_length=255, help_text="The primary user’s email address.") - # TODO external_accounts = ... - individual = JSONField( - null=True, - blank=True, - help_text=( - "Information about the person represented by the account. " - "This field is null unless business_type is set to individual." - ), - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - legal_entity = JSONField( - null=True, - blank=True, - help_text=( - "Information about the legal entity itself, including about the associated account representative" - ), - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - payout_schedule = JSONField( - null=True, - blank=True, - help_text=( - "Details on when funds from charges are available, and when they are paid out to an external account." - ), - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - payout_statement_descriptor = models.CharField( - max_length=255, - default="", - blank=True, - help_text="The text that appears on the bank account statement for payouts.", - ) - payouts_enabled = models.BooleanField( - help_text="Whether Stripe can send payouts to this account" - ) - product_description = models.CharField( - max_length=255, - default="", - blank=True, - help_text=( - "Internal-only description of the product sold or service provided by the business. " - "It’s used by Stripe for risk and underwriting purposes." - ), - ) - requirements = JSONField( - null=True, - blank=True, - help_text=( - "Information about the requirements for the account, " - "including what information needs to be collected, and by when." - ), - ) - settings = JSONField( - null=True, - blank=True, - help_text=( - "Account options for customizing how the account functions within Stripe." - ), - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - statement_descriptor = models.CharField( - max_length=255, - default="", - blank=True, - help_text=( - "The default text that appears on credit card statements when a charge is made directly on the account" - ), - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - support_email = models.CharField( - max_length=255, - default="", - blank=True, - help_text="A publicly shareable support email address for the business", - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - support_phone = models.CharField( - max_length=255, - default="", - blank=True, - help_text="A publicly shareable support phone number for the business", - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - support_url = models.CharField( - max_length=200, - default="", - blank=True, - help_text="A publicly shareable URL that provides support for this account", - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - timezone = models.CharField( - max_length=50, help_text="The timezone used in the Stripe Dashboard for this account." - ) - type = StripeEnumField(enum=enums.AccountType, help_text="The Stripe account type.") - tos_acceptance = JSONField( - null=True, - blank=True, - help_text="Details on the acceptance of the Stripe Services Agreement", - ) - # deprecated, will be removed in 2.2. see https://stripe.com/docs/upgrades#2019-02-19 - verification = JSONField( - null=True, - blank=True, - help_text=( - "Information on the verification state of the account, " - "including what information is needed and by when" - ), - ) - - @classmethod - def get_connected_account_from_token(cls, access_token): - account_data = cls.stripe_class.retrieve(api_key=access_token) - - return cls._get_or_create_from_stripe_object(account_data)[0] - - @classmethod - def get_default_account(cls): - account_data = cls.stripe_class.retrieve(api_key=djstripe_settings.STRIPE_SECRET_KEY) - - return cls._get_or_create_from_stripe_object(account_data)[0] - - def __str__(self): - return self.display_name or self.business_name - - # deprecated, remove in 2.2 - @property - def business_logo(self): - warnings.warn( - "Account.business_logo has been renamed to branding_icon, this alias will be removed in dj-stripe 2.2", - DeprecationWarning, - ) - return self.branding_icon - - @classmethod # noqa: C901 - def _manipulate_stripe_object_hook(cls, data): - data = super()._manipulate_stripe_object_hook(data) - - def empty_string_to_none(v): - """ - stripe.StripeObject.__setitem__ doesn't allow = "" - """ - if v == "": - return None - else: - return v - - # icon (formerly called business_logo) logo (formerly called business_logo_large) - # moved to settings.branding in Stripe 2019-02-19 but we'll keep them to provide the ForeignKey - for old, new in [("branding_icon", "icon"), ("branding_logo", "logo")]: - try: - data[old] = data["settings"]["branding"][new] - except KeyError: - pass - - # copy data back from new location to deprecated fields to be removed in 2.2 - # see https://stripe.com/docs/upgrades#2019-02-19 - try: - new = data["requirements"]["current_deadline"] - if new: - data["verification"] = data["verification"] or {} - data["verification"]["due_by"] = new - except KeyError: - pass - - for old, new in [ - ("payout_schedule", "schedule"), - ("payout_statement_descriptor", "statement_descriptor"), - ("debit_negative_balances", "debit_negative_balances"), - ]: - try: - data[old] = empty_string_to_none(data["settings"]["payouts"][new]) - except KeyError: - pass - - for old, new in [("statement_descriptor", "statement_descriptor")]: - try: - data[old] = empty_string_to_none(data["settings"]["payments"][new]) - except KeyError: - pass - - for old, new in [("decline_charge_on", "decline_on")]: - try: - data[old] = data["settings"]["card_payments"][new] - except KeyError: - pass - - for old, new in [("business_primary_color", "primary_color")]: - try: - data[old] = empty_string_to_none(data["settings"]["branding"][new]) - except KeyError: - pass - - for old, new in [("display_name", "display_name"), ("timezone", "timezone")]: - try: - data[old] = empty_string_to_none(data["settings"]["dashboard"][new]) - except KeyError: - pass - - for old, new in [ - ("business_name", "name"), - ("business_url", "url"), - ("product_description", "product_description"), - ("support_address", "support_address"), - ("support_email", "support_email"), - ("support_phone", "support_phone"), - ("support_url", "support_url"), - ]: - try: - data[old] = empty_string_to_none(data["business_profile"][new]) - except KeyError: - pass - # end of deprecated fields to be removed in 2.2 - - return data + """ + Stripe documentation: https://stripe.com/docs/api#account + """ + + stripe_class = stripe.Account + # Special handling of the icon and logo fields, they moved to settings.branding + # in Stripe 2019-02-19 but we want them as ForeignKeys + branding_icon = models.ForeignKey( + "FileUpload", + on_delete=models.SET_NULL, + null=True, + related_name="icon_account", + help_text="An icon for the account. Must be square and at least 128px x 128px.", + ) + branding_logo = models.ForeignKey( + "FileUpload", + on_delete=models.SET_NULL, + null=True, + related_name="logo_account", + help_text="A logo for the account that will be used in Checkout instead of " + "the icon and without the account’s name next to it if provided. " + "Must be at least 128px x 128px.", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + business_name = models.CharField( + max_length=255, + default="", + blank=True, + help_text="The publicly visible name of the business", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + business_primary_color = models.CharField( + max_length=7, + default="", + blank=True, + help_text="A CSS hex color value representing the primary branding color for " + "this account", + ) + business_profile = JSONField( + null=True, blank=True, help_text="Optional information related to the business." + ) + business_type = StripeEnumField( + enum=enums.BusinessType, default="", blank=True, help_text="The business type." + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + business_url = models.CharField( + max_length=200, + default="", + blank=True, + help_text="The publicly visible website of the business", + ) + charges_enabled = models.BooleanField( + help_text="Whether the account can create live charges" + ) + country = models.CharField(max_length=2, help_text="The country of the account") + company = JSONField( + null=True, + blank=True, + help_text=( + "Information about the company or business. " + "This field is null unless business_type is set to company." + ), + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + debit_negative_balances = models.NullBooleanField( + null=True, + blank=True, + default=False, + help_text=( + "A Boolean indicating if Stripe should try to reclaim negative " + "balances from an attached bank account." + ), + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + decline_charge_on = JSONField( + null=True, + blank=True, + help_text=( + "Account-level settings to automatically decline certain types " + "of charges regardless of the decision of the card issuer" + ), + ) + default_currency = StripeCurrencyCodeField( + help_text="The currency this account has chosen to use as the default" + ) + details_submitted = models.BooleanField( + help_text=( + "Whether account details have been submitted. " + "Standard accounts cannot receive payouts before this is true." + ) + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + display_name = models.CharField( + max_length=255, + default="", + blank=True, + help_text=( + "The display name for this account. " + "This is used on the Stripe Dashboard to differentiate between accounts." + ), + ) + email = models.CharField( + max_length=255, help_text="The primary user’s email address." + ) + # TODO external_accounts = ... + individual = JSONField( + null=True, + blank=True, + help_text=( + "Information about the person represented by the account. " + "This field is null unless business_type is set to individual." + ), + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + legal_entity = JSONField( + null=True, + blank=True, + help_text="Information about the legal entity itself, including about the " + "associated account representative", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + payout_schedule = JSONField( + null=True, + blank=True, + help_text="Details on when funds from charges are available, and when they are " + "paid out to an external account.", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + payout_statement_descriptor = models.CharField( + max_length=255, + default="", + blank=True, + help_text="The text that appears on the bank account statement for payouts.", + ) + payouts_enabled = models.BooleanField( + help_text="Whether Stripe can send payouts to this account" + ) + product_description = models.CharField( + max_length=255, + default="", + blank=True, + help_text="Internal-only description of the product sold or service provided " + "by the business. It’s used by Stripe for risk and underwriting purposes.", + ) + requirements = JSONField( + null=True, + blank=True, + help_text="Information about the requirements for the account, " + "including what information needs to be collected, and by when.", + ) + settings = JSONField( + null=True, + blank=True, + help_text=( + "Account options for customizing how the account functions within Stripe." + ), + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + statement_descriptor = models.CharField( + max_length=255, + default="", + blank=True, + help_text="The default text that appears on credit card statements when " + "a charge is made directly on the account", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + support_email = models.CharField( + max_length=255, + default="", + blank=True, + help_text="A publicly shareable support email address for the business", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + support_phone = models.CharField( + max_length=255, + default="", + blank=True, + help_text="A publicly shareable support phone number for the business", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + support_url = models.CharField( + max_length=200, + default="", + blank=True, + help_text="A publicly shareable URL that provides support for this account", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + timezone = models.CharField( + max_length=50, + help_text="The timezone used in the Stripe Dashboard for this account.", + ) + type = StripeEnumField(enum=enums.AccountType, help_text="The Stripe account type.") + tos_acceptance = JSONField( + null=True, + blank=True, + help_text="Details on the acceptance of the Stripe Services Agreement", + ) + # deprecated, will be removed in 2.2. + # see https://stripe.com/docs/upgrades#2019-02-19 + verification = JSONField( + null=True, + blank=True, + help_text=( + "Information on the verification state of the account, " + "including what information is needed and by when" + ), + ) + + @classmethod + def get_connected_account_from_token(cls, access_token): + account_data = cls.stripe_class.retrieve(api_key=access_token) + + return cls._get_or_create_from_stripe_object(account_data)[0] + + @classmethod + def get_default_account(cls): + account_data = cls.stripe_class.retrieve( + api_key=djstripe_settings.STRIPE_SECRET_KEY + ) + + return cls._get_or_create_from_stripe_object(account_data)[0] + + def __str__(self): + return self.display_name or self.business_name + + # deprecated, remove in 2.2 + @property + def business_logo(self): + warnings.warn( + "Account.business_logo has been renamed to branding_icon, " + "this alias will be removed in dj-stripe 2.2", + DeprecationWarning, + ) + return self.branding_icon + + @classmethod # noqa: C901 + def _manipulate_stripe_object_hook(cls, data): + data = super()._manipulate_stripe_object_hook(data) + + def empty_string_to_none(v): + """ + stripe.StripeObject.__setitem__ doesn't allow = "" + """ + if v == "": + return None + else: + return v + + # icon (formerly called business_logo) + # logo (formerly called business_logo_large) + # moved to settings.branding in Stripe 2019-02-19 + # but we'll keep them to provide the ForeignKey + for old, new in [("branding_icon", "icon"), ("branding_logo", "logo")]: + try: + data[old] = data["settings"]["branding"][new] + except KeyError: + pass + + # copy data back from new location to deprecated fields to be removed in 2.2 + # see https://stripe.com/docs/upgrades#2019-02-19 + try: + new = data["requirements"]["current_deadline"] + if new: + data["verification"] = data["verification"] or {} + data["verification"]["due_by"] = new + except KeyError: + pass + + for old, new in [ + ("payout_schedule", "schedule"), + ("payout_statement_descriptor", "statement_descriptor"), + ("debit_negative_balances", "debit_negative_balances"), + ]: + try: + data[old] = empty_string_to_none(data["settings"]["payouts"][new]) + except KeyError: + pass + + for old, new in [("statement_descriptor", "statement_descriptor")]: + try: + data[old] = empty_string_to_none(data["settings"]["payments"][new]) + except KeyError: + pass + + for old, new in [("decline_charge_on", "decline_on")]: + try: + data[old] = data["settings"]["card_payments"][new] + except KeyError: + pass + + for old, new in [("business_primary_color", "primary_color")]: + try: + data[old] = empty_string_to_none(data["settings"]["branding"][new]) + except KeyError: + pass + + for old, new in [("display_name", "display_name"), ("timezone", "timezone")]: + try: + data[old] = empty_string_to_none(data["settings"]["dashboard"][new]) + except KeyError: + pass + + for old, new in [ + ("business_name", "name"), + ("business_url", "url"), + ("product_description", "product_description"), + ("support_address", "support_address"), + ("support_email", "support_email"), + ("support_phone", "support_phone"), + ("support_url", "support_url"), + ]: + try: + data[old] = empty_string_to_none(data["business_profile"][new]) + except KeyError: + pass + # end of deprecated fields to be removed in 2.2 + + return data class ApplicationFee(StripeModel): - """ - When you collect a transaction fee on top of a charge made for your - user (using Connect), an ApplicationFee is created in your account. - - Stripe documentation: https://stripe.com/docs/api#application_fees - """ - - stripe_class = stripe.ApplicationFee - - amount = StripeQuantumCurrencyAmountField(help_text="Amount earned.") - amount_refunded = StripeQuantumCurrencyAmountField( - help_text="Amount refunded (can be less than the amount attribute " - "on the fee if a partial refund was issued)" - ) - # TODO application = ... - balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.CASCADE, - help_text="Balance transaction that describes the impact on your account balance.", - ) - charge = models.ForeignKey( - "Charge", - on_delete=models.CASCADE, - help_text="The charge that the application fee was taken from.", - ) - currency = StripeCurrencyCodeField() - # TODO originating_transaction = ... (refs. both Charge and Transfer) - refunded = models.BooleanField( - help_text=( - "Whether the fee has been fully refunded. If the fee is only " - "partially refunded, this attribute will still be false." - ) - ) + """ + When you collect a transaction fee on top of a charge made for your + user (using Connect), an ApplicationFee is created in your account. + + Stripe documentation: https://stripe.com/docs/api#application_fees + """ + + stripe_class = stripe.ApplicationFee + + amount = StripeQuantumCurrencyAmountField(help_text="Amount earned.") + amount_refunded = StripeQuantumCurrencyAmountField( + help_text="Amount refunded (can be less than the amount attribute " + "on the fee if a partial refund was issued)" + ) + # TODO application = ... + balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.CASCADE, + help_text="Balance transaction that describes the impact on your account" + " balance.", + ) + charge = models.ForeignKey( + "Charge", + on_delete=models.CASCADE, + help_text="The charge that the application fee was taken from.", + ) + currency = StripeCurrencyCodeField() + # TODO originating_transaction = ... (refs. both Charge and Transfer) + refunded = models.BooleanField( + help_text=( + "Whether the fee has been fully refunded. If the fee is only " + "partially refunded, this attribute will still be false." + ) + ) class ApplicationFeeRefund(StripeModel): - """ - ApplicationFeeRefund objects allow you to refund an ApplicationFee that - has previously been created but not yet refunded. - Funds will be refunded to the Stripe account from which the fee was - originally collected. - - Stripe documentation: https://stripe.com/docs/api#fee_refunds - """ - - description = None - - amount = StripeQuantumCurrencyAmountField(help_text="Amount refunded.") - balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.CASCADE, - help_text="Balance transaction that describes the impact on your account balance.", - ) - currency = StripeCurrencyCodeField() - fee = models.ForeignKey( - "ApplicationFee", - on_delete=models.CASCADE, - related_name="refunds", - help_text="The application fee that was refunded", - ) + """ + ApplicationFeeRefund objects allow you to refund an ApplicationFee that + has previously been created but not yet refunded. + Funds will be refunded to the Stripe account from which the fee was + originally collected. + + Stripe documentation: https://stripe.com/docs/api#fee_refunds + """ + + description = None + + amount = StripeQuantumCurrencyAmountField(help_text="Amount refunded.") + balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.CASCADE, + help_text="Balance transaction that describes the impact on your account " + "balance.", + ) + currency = StripeCurrencyCodeField() + fee = models.ForeignKey( + "ApplicationFee", + on_delete=models.CASCADE, + related_name="refunds", + help_text="The application fee that was refunded", + ) class CountrySpec(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#country_specs - """ - - stripe_class = stripe.CountrySpec - - id = models.CharField(max_length=2, primary_key=True, serialize=True) - - default_currency = StripeCurrencyCodeField( - help_text=( - "The default currency for this country. " - "This applies to both payment methods and bank accounts." - ) - ) - supported_bank_account_currencies = JSONField( - help_text="Currencies that can be accepted in the specific country (for transfers)." - ) - supported_payment_currencies = JSONField( - help_text="Currencies that can be accepted in the specified country (for payments)." - ) - supported_payment_methods = JSONField( - help_text="Payment methods available in the specified country." - ) - supported_transfer_countries = JSONField( - help_text="Countries that can accept transfers from the specified country." - ) - verification_fields = JSONField( - help_text="Lists the types of verification data needed to keep an account open." - ) - - # Get rid of core common fields - djstripe_id = None - created = None - description = None - livemode = True - metadata = None - - class Meta: - pass + """ + Stripe documentation: https://stripe.com/docs/api#country_specs + """ + + stripe_class = stripe.CountrySpec + + id = models.CharField(max_length=2, primary_key=True, serialize=True) + + default_currency = StripeCurrencyCodeField( + help_text=( + "The default currency for this country. " + "This applies to both payment methods and bank accounts." + ) + ) + supported_bank_account_currencies = JSONField( + help_text="Currencies that can be accepted in the specific country" + " (for transfers)." + ) + supported_payment_currencies = JSONField( + help_text="Currencies that can be accepted in the specified country" + " (for payments)." + ) + supported_payment_methods = JSONField( + help_text="Payment methods available in the specified country." + ) + supported_transfer_countries = JSONField( + help_text="Countries that can accept transfers from the specified country." + ) + verification_fields = JSONField( + help_text="Lists the types of verification data needed to keep an account open." + ) + + # Get rid of core common fields + djstripe_id = None + created = None + description = None + livemode = True + metadata = None + + class Meta: + pass class Transfer(StripeModel): - """ - When Stripe sends you money or you initiate a transfer to a bank account, - debit card, or connected Stripe account, a transfer object will be created. - - Stripe documentation: https://stripe.com/docs/api/python#transfers - """ - - stripe_class = stripe.Transfer - expand_fields = ["balance_transaction"] - stripe_dashboard_item_name = "transfers" - - objects = TransferManager() - - amount = StripeDecimalCurrencyAmountField(help_text="The amount transferred") - amount_reversed = StripeDecimalCurrencyAmountField( - null=True, - blank=True, - help_text="The amount reversed (can be less than the amount attribute on the transfer if a partial " - "reversal was issued).", - ) - balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Balance transaction that describes the impact on your account balance.", - ) - currency = StripeCurrencyCodeField() - # TODO: Link destination to Card, Account, or Bank Account Models - destination = StripeIdField( - help_text="ID of the bank account, card, or Stripe account the transfer was sent to." - ) - destination_payment = StripeIdField( - null=True, - blank=True, - help_text="If the destination is a Stripe account, this will be the ID of the payment that the destination " - "account received for the transfer.", - ) - reversed = models.BooleanField( - default=False, - help_text="Whether or not the transfer has been fully reversed. If the transfer is only partially " - "reversed, this attribute will still be false.", - ) - source_transaction = StripeIdField( - null=True, - help_text="ID of the charge (or other transaction) that was used to fund the transfer. " - "If null, the transfer was funded from the available balance.", - ) - source_type = StripeEnumField( - enum=enums.LegacySourceType, - help_text=("The source balance from which this transfer came."), - ) - transfer_group = models.CharField( - max_length=255, - default="", - blank=True, - help_text="A string that identifies this transaction as part of a group.", - ) - - @property - def fee(self): - if self.balance_transaction: - return self.balance_transaction.fee - - def str_parts(self): - return ["amount={amount}".format(amount=self.amount)] + super().str_parts() + """ + When Stripe sends you money or you initiate a transfer to a bank account, + debit card, or connected Stripe account, a transfer object will be created. + + Stripe documentation: https://stripe.com/docs/api/python#transfers + """ + + stripe_class = stripe.Transfer + expand_fields = ["balance_transaction"] + stripe_dashboard_item_name = "transfers" + + objects = TransferManager() + + amount = StripeDecimalCurrencyAmountField(help_text="The amount transferred") + amount_reversed = StripeDecimalCurrencyAmountField( + null=True, + blank=True, + help_text="The amount reversed (can be less than the amount attribute on the" + " transfer if a partial reversal was issued).", + ) + balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Balance transaction that describes the impact on your account" + " balance.", + ) + currency = StripeCurrencyCodeField() + # TODO: Link destination to Card, Account, or Bank Account Models + destination = StripeIdField( + help_text="ID of the bank account, card, or Stripe account the transfer was " + "sent to." + ) + destination_payment = StripeIdField( + null=True, + blank=True, + help_text="If the destination is a Stripe account, this will be the ID of the " + "payment that the destination account received for the transfer.", + ) + reversed = models.BooleanField( + default=False, + help_text="Whether or not the transfer has been fully reversed. " + "If the transfer is only partially reversed, this attribute will still " + "be false.", + ) + source_transaction = StripeIdField( + null=True, + help_text="ID of the charge (or other transaction) that was used to fund " + "the transfer. If null, the transfer was funded from the available balance.", + ) + source_type = StripeEnumField( + enum=enums.LegacySourceType, + help_text="The source balance from which this transfer came.", + ) + transfer_group = models.CharField( + max_length=255, + default="", + blank=True, + help_text="A string that identifies this transaction as part of a group.", + ) + + @property + def fee(self): + if self.balance_transaction: + return self.balance_transaction.fee + + def str_parts(self): + return ["amount={amount}".format(amount=self.amount)] + super().str_parts() class TransferReversal(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#transfer_reversals - """ - - stripe_class = stripe.Transfer - - amount = StripeQuantumCurrencyAmountField(help_text="Amount, in cents.") - balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="transfer_reversals", - help_text="Balance transaction that describes the impact on your account balance.", - ) - currency = StripeCurrencyCodeField() - transfer = models.ForeignKey( - "Transfer", - on_delete=models.CASCADE, - help_text="The transfer that was reversed.", - related_name="reversals", - ) + """ + Stripe documentation: https://stripe.com/docs/api#transfer_reversals + """ + + stripe_class = stripe.Transfer + + amount = StripeQuantumCurrencyAmountField(help_text="Amount, in cents.") + balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="transfer_reversals", + help_text="Balance transaction that describes the impact on your account " + "balance.", + ) + currency = StripeCurrencyCodeField() + transfer = models.ForeignKey( + "Transfer", + on_delete=models.CASCADE, + help_text="The transfer that was reversed.", + related_name="reversals", + ) diff --git a/djstripe/models/core.py b/djstripe/models/core.py index e18b4cbf4f..eb33a8fed8 100644 --- a/djstripe/models/core.py +++ b/djstripe/models/core.py @@ -12,8 +12,13 @@ from .. import webhooks from ..exceptions import MultipleSubscriptionException from ..fields import ( - JSONField, PaymentMethodForeignKey, StripeCurrencyCodeField, StripeDateTimeField, - StripeDecimalCurrencyAmountField, StripeEnumField, StripeQuantumCurrencyAmountField + JSONField, + PaymentMethodForeignKey, + StripeCurrencyCodeField, + StripeDateTimeField, + StripeDecimalCurrencyAmountField, + StripeEnumField, + StripeQuantumCurrencyAmountField, ) from ..managers import ChargeManager from ..signals import WEBHOOK_SIGNALS @@ -29,1740 +34,1839 @@ class BalanceTransaction(StripeModel): - """ - A single transaction that updates the Stripe balance. - - Stripe documentation: https://stripe.com/docs/api#balance_transaction_object - """ - - stripe_class = stripe.BalanceTransaction - - amount = StripeQuantumCurrencyAmountField( - help_text="Gross amount of the transaction, in cents." - ) - available_on = StripeDateTimeField( - help_text=( - "The date the transaction's net funds will become available in the Stripe balance." - ) - ) - currency = StripeCurrencyCodeField() - exchange_rate = models.DecimalField(null=True, decimal_places=6, max_digits=8) - fee = StripeQuantumCurrencyAmountField( - help_text="Fee (in cents) paid for this transaction." - ) - fee_details = JSONField() - net = StripeQuantumCurrencyAmountField( - help_text="Net amount of the transaction, in cents." - ) - # TODO: source (Reverse lookup only? or generic foreign key?) - status = StripeEnumField(enum=enums.BalanceTransactionStatus) - type = StripeEnumField(enum=enums.BalanceTransactionType) + """ + A single transaction that updates the Stripe balance. + + Stripe documentation: https://stripe.com/docs/api#balance_transaction_object + """ + + stripe_class = stripe.BalanceTransaction + + amount = StripeQuantumCurrencyAmountField( + help_text="Gross amount of the transaction, in cents." + ) + available_on = StripeDateTimeField( + help_text=( + "The date the transaction's net funds " + "will become available in the Stripe balance." + ) + ) + currency = StripeCurrencyCodeField() + exchange_rate = models.DecimalField(null=True, decimal_places=6, max_digits=8) + fee = StripeQuantumCurrencyAmountField( + help_text="Fee (in cents) paid for this transaction." + ) + fee_details = JSONField() + net = StripeQuantumCurrencyAmountField( + help_text="Net amount of the transaction, in cents." + ) + # TODO: source (Reverse lookup only? or generic foreign key?) + status = StripeEnumField(enum=enums.BalanceTransactionStatus) + type = StripeEnumField(enum=enums.BalanceTransactionType) class Charge(StripeModel): - """ - To charge a credit or a debit card, you create a charge object. You can - retrieve and refund individual charges as well as list all charges. Charges - are identified by a unique random ID. - - Stripe documentation: https://stripe.com/docs/api/python#charges - """ - - stripe_class = stripe.Charge - expand_fields = ["balance_transaction"] - stripe_dashboard_item_name = "payments" - - amount = StripeDecimalCurrencyAmountField(help_text="Amount charged.") - amount_refunded = StripeDecimalCurrencyAmountField( - help_text="Amount refunded (can be less than the amount attribute on the charge " - "if a partial refund was issued)." - ) - # TODO: application, application_fee - balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.SET_NULL, - null=True, - help_text=( - "The balance transaction that describes the impact of this charge " - "on your account balance (not including refunds or disputes)." - ), - ) - captured = models.BooleanField( - default=False, - help_text="If the charge was created without capturing, this boolean represents whether or not it is still " - "uncaptured or has since been captured.", - ) - currency = StripeCurrencyCodeField( - help_text="The currency in which the charge was made." - ) - customer = models.ForeignKey( - "Customer", - on_delete=models.CASCADE, - null=True, - related_name="charges", - help_text="The customer associated with this charge.", - ) - # XXX: destination - account = models.ForeignKey( - "Account", - on_delete=models.CASCADE, - null=True, - related_name="charges", - help_text="The account the charge was made on behalf of. Null here indicates that this value was never set.", - ) - dispute = models.ForeignKey( - "Dispute", - on_delete=models.SET_NULL, - null=True, - related_name="charges", - help_text="Details about the dispute if the charge has been disputed.", - ) - failure_code = StripeEnumField( - enum=enums.ApiErrorCode, - default="", - blank=True, - help_text="Error code explaining reason for charge failure if available.", - ) - failure_message = models.TextField( - max_length=5000, - default="", - blank=True, - help_text="Message to user further explaining reason for charge failure if available.", - ) - fraud_details = JSONField( - help_text="Hash with information on fraud assessments for the charge." - ) - invoice = models.ForeignKey( - "Invoice", - on_delete=models.CASCADE, - null=True, - related_name="charges", - help_text="The invoice this charge is for if one exists.", - ) - # TODO: on_behalf_of, order - outcome = JSONField( - help_text="Details about whether or not the payment was accepted, and why." - ) - paid = models.BooleanField( - default=False, - help_text="True if the charge succeeded, or was successfully authorized for later capture, False otherwise.", - ) - payment_intent = models.ForeignKey( - "PaymentIntent", - null=True, - on_delete=models.SET_NULL, - related_name="charges", - help_text=("PaymentIntent associated with this charge, if one exists."), - ) - receipt_email = models.TextField( - max_length=800, # yup, 800. - default="", - blank=True, - help_text="The email address that the receipt for this charge was sent to.", - ) - receipt_number = models.CharField( - max_length=14, - default="", - blank=True, - help_text="The transaction number that appears on email receipts sent for this charge.", - ) - refunded = models.BooleanField( - default=False, - help_text="Whether or not the charge has been fully refunded. If the charge is only partially refunded, " - "this attribute will still be false.", - ) - # TODO: review - shipping = JSONField(null=True, help_text="Shipping information for the charge") - source = PaymentMethodForeignKey( - on_delete=models.SET_NULL, - null=True, - related_name="charges", - help_text="The source used for this charge.", - ) - # TODO: source_transfer - statement_descriptor = models.CharField( - max_length=22, - default="", - blank=True, - help_text="An arbitrary string to be displayed on your customer's credit card statement. The statement " - "description may not include <>\"' characters, and will appear on your customer's statement in capital " - "letters. Non-ASCII characters are automatically stripped. While most banks display this information " - "consistently, some may display it incorrectly or not at all.", - ) - status = StripeEnumField( - enum=enums.ChargeStatus, help_text="The status of the payment." - ) - transfer = models.ForeignKey( - "Transfer", - null=True, - on_delete=models.CASCADE, - help_text="The transfer to the destination account (only applicable if the charge was created using the " - "destination parameter).", - ) - transfer_group = models.CharField( - max_length=255, - default="", - blank=True, - help_text="A string that identifies this transaction as part of a group.", - ) - - objects = ChargeManager() - - def __str__(self): - amount = self.human_readable_amount - status = self.human_readable_status - if not status: - return amount - return "{amount} ({status})".format(amount=amount, status=status) - - @property - def disputed(self): - return self.dispute is not None - - @property - def fee(self): - if self.balance_transaction: - return self.balance_transaction.fee - - @property - def human_readable_amount(self): - return get_friendly_currency_amount(self.amount, self.currency) - - @property - def human_readable_status(self): - if not self.captured: - return "Uncaptured" - elif self.disputed: - return "Disputed" - elif self.refunded: - return "Refunded" - elif self.amount_refunded: - return "Partially refunded" - elif self.status == enums.ChargeStatus.failed: - return "Failed" - - return "" - - @property - def fraudulent(self): - return self.fraud_details and list(self.fraud_details.values())[0] == "fraudulent" - - def _attach_objects_hook(self, cls, data): - from .payment_methods import DjstripePaymentMethod - - # Set the account on this object. - destination_account = cls._stripe_object_destination_to_account( - target_cls=Account, data=data - ) - if destination_account: - self.account = destination_account - else: - self.account = Account.get_default_account() - - # Source doesn't always appear to be present, so handle the case - # where it is missing. - source_data = data.get("source") - if not source_data: - return - - source_type = source_data.get("object") - if not source_type: - return - - self.source, _ = DjstripePaymentMethod._get_or_create_source( - data=source_data, source_type=source_type - ) - - def _calculate_refund_amount(self, amount=None): - """ - :rtype: int - :return: amount that can be refunded, in CENTS - """ - eligible_to_refund = self.amount - (self.amount_refunded or 0) - if amount: - amount_to_refund = min(eligible_to_refund, amount) - else: - amount_to_refund = eligible_to_refund - return int(amount_to_refund * 100) - - def refund(self, amount=None, reason=None): - """ - Initiate a refund. If amount is not provided, then this will be a full refund. - - :param amount: A positive decimal amount representing how much of this charge - to refund. Can only refund up to the unrefunded amount remaining of the charge. - :trye amount: Decimal - :param reason: String indicating the reason for the refund. If set, possible values - are ``duplicate``, ``fraudulent``, and ``requested_by_customer``. Specifying - ``fraudulent`` as the reason when you believe the charge to be fraudulent will - help Stripe improve their fraud detection algorithms. - - :return: Stripe charge object - :rtype: dict - """ - charge_obj = self.api_retrieve().refund( - amount=self._calculate_refund_amount(amount=amount), reason=reason - ) - return self.__class__.sync_from_stripe_data(charge_obj) - - def capture(self): - """ - Capture the payment of an existing, uncaptured, charge. - This is the second half of the two-step payment flow, where first you - created a charge with the capture option set to False. - - See https://stripe.com/docs/api#capture_charge - """ - - captured_charge = self.api_retrieve().capture() - return self.__class__.sync_from_stripe_data(captured_charge) - - @classmethod - def _stripe_object_destination_to_account(cls, target_cls, data): - """ - Search the given manager for the Account matching this Charge object's ``destination`` field. - - :param target_cls: The target class - :type target_cls: Account - :param data: stripe object - :type data: dict - """ - - if "destination" in data and data["destination"]: - return target_cls._get_or_create_from_stripe_object(data, "destination")[0] - - def _attach_objects_post_save_hook(self, cls, data, pending_relations=None): - super()._attach_objects_post_save_hook(cls, data, pending_relations=pending_relations) - - cls._stripe_object_to_refunds(target_cls=Refund, data=data, charge=self) + """ + To charge a credit or a debit card, you create a charge object. You can + retrieve and refund individual charges as well as list all charges. Charges + are identified by a unique random ID. + + Stripe documentation: https://stripe.com/docs/api/python#charges + """ + + stripe_class = stripe.Charge + expand_fields = ["balance_transaction"] + stripe_dashboard_item_name = "payments" + + amount = StripeDecimalCurrencyAmountField(help_text="Amount charged.") + amount_refunded = StripeDecimalCurrencyAmountField( + help_text=( + "Amount refunded (can be less than the amount attribute on the charge " + "if a partial refund was issued)." + ) + ) + # TODO: application, application_fee + balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.SET_NULL, + null=True, + help_text=( + "The balance transaction that describes the impact of this charge " + "on your account balance (not including refunds or disputes)." + ), + ) + captured = models.BooleanField( + default=False, + help_text="If the charge was created without capturing, this boolean " + "represents whether or not it is still uncaptured or has since been captured.", + ) + currency = StripeCurrencyCodeField( + help_text="The currency in which the charge was made." + ) + customer = models.ForeignKey( + "Customer", + on_delete=models.CASCADE, + null=True, + related_name="charges", + help_text="The customer associated with this charge.", + ) + # XXX: destination + account = models.ForeignKey( + "Account", + on_delete=models.CASCADE, + null=True, + related_name="charges", + help_text="The account the charge was made on behalf of. " + "Null here indicates that this value was never set.", + ) + dispute = models.ForeignKey( + "Dispute", + on_delete=models.SET_NULL, + null=True, + related_name="charges", + help_text="Details about the dispute if the charge has been disputed.", + ) + failure_code = StripeEnumField( + enum=enums.ApiErrorCode, + default="", + blank=True, + help_text="Error code explaining reason for charge failure if available.", + ) + failure_message = models.TextField( + max_length=5000, + default="", + blank=True, + help_text="Message to user further explaining reason " + "for charge failure if available.", + ) + fraud_details = JSONField( + help_text="Hash with information on fraud assessments for the charge." + ) + invoice = models.ForeignKey( + "Invoice", + on_delete=models.CASCADE, + null=True, + related_name="charges", + help_text="The invoice this charge is for if one exists.", + ) + # TODO: on_behalf_of, order + outcome = JSONField( + help_text="Details about whether or not the payment was accepted, and why." + ) + paid = models.BooleanField( + default=False, + help_text="True if the charge succeeded, " + "or was successfully authorized for later capture, False otherwise.", + ) + payment_intent = models.ForeignKey( + "PaymentIntent", + null=True, + on_delete=models.SET_NULL, + related_name="charges", + help_text="PaymentIntent associated with this charge, if one exists.", + ) + receipt_email = models.TextField( + max_length=800, # yup, 800. + default="", + blank=True, + help_text="The email address that the receipt for this charge was sent to.", + ) + receipt_number = models.CharField( + max_length=14, + default="", + blank=True, + help_text="The transaction number that appears " + "on email receipts sent for this charge.", + ) + refunded = models.BooleanField( + default=False, + help_text="Whether or not the charge has been fully refunded. " + "If the charge is only partially refunded, " + "this attribute will still be false.", + ) + # TODO: review + shipping = JSONField(null=True, help_text="Shipping information for the charge") + source = PaymentMethodForeignKey( + on_delete=models.SET_NULL, + null=True, + related_name="charges", + help_text="The source used for this charge.", + ) + # TODO: source_transfer + statement_descriptor = models.CharField( + max_length=22, + default="", + blank=True, + help_text="An arbitrary string to be displayed on your customer's " + "credit card statement. The statement description may not include <>\"' " + "characters, and will appear on your customer's statement in capital letters. " + "Non-ASCII characters are automatically stripped. " + "While most banks display this information consistently, " + "some may display it incorrectly or not at all.", + ) + status = StripeEnumField( + enum=enums.ChargeStatus, help_text="The status of the payment." + ) + transfer = models.ForeignKey( + "Transfer", + null=True, + on_delete=models.CASCADE, + help_text="The transfer to the destination account " + "(only applicable if the charge was created using the destination parameter).", + ) + transfer_group = models.CharField( + max_length=255, + default="", + blank=True, + help_text="A string that identifies this transaction as part of a group.", + ) + + objects = ChargeManager() + + def __str__(self): + amount = self.human_readable_amount + status = self.human_readable_status + if not status: + return amount + return "{amount} ({status})".format(amount=amount, status=status) + + @property + def disputed(self): + return self.dispute is not None + + @property + def fee(self): + if self.balance_transaction: + return self.balance_transaction.fee + + @property + def human_readable_amount(self): + return get_friendly_currency_amount(self.amount, self.currency) + + @property + def human_readable_status(self): + if not self.captured: + return "Uncaptured" + elif self.disputed: + return "Disputed" + elif self.refunded: + return "Refunded" + elif self.amount_refunded: + return "Partially refunded" + elif self.status == enums.ChargeStatus.failed: + return "Failed" + + return "" + + @property + def fraudulent(self): + return ( + self.fraud_details and list(self.fraud_details.values())[0] == "fraudulent" + ) + + def _attach_objects_hook(self, cls, data): + from .payment_methods import DjstripePaymentMethod + + # Set the account on this object. + destination_account = cls._stripe_object_destination_to_account( + target_cls=Account, data=data + ) + if destination_account: + self.account = destination_account + else: + self.account = Account.get_default_account() + + # Source doesn't always appear to be present, so handle the case + # where it is missing. + source_data = data.get("source") + if not source_data: + return + + source_type = source_data.get("object") + if not source_type: + return + + self.source, _ = DjstripePaymentMethod._get_or_create_source( + data=source_data, source_type=source_type + ) + + def _calculate_refund_amount(self, amount=None): + """ + :rtype: int + :return: amount that can be refunded, in CENTS + """ + eligible_to_refund = self.amount - (self.amount_refunded or 0) + if amount: + amount_to_refund = min(eligible_to_refund, amount) + else: + amount_to_refund = eligible_to_refund + return int(amount_to_refund * 100) + + def refund(self, amount=None, reason=None): + """ + Initiate a refund. If amount is not provided, then this will be a full refund. + + :param amount: A positive decimal amount representing how much of this charge + to refund. + Can only refund up to the unrefunded amount remaining of the charge. + :trye amount: Decimal + :param reason: String indicating the reason for the refund. + If set, possible values are ``duplicate``, ``fraudulent``, + and ``requested_by_customer``. Specifying ``fraudulent`` as the reason + when you believe the charge to be fraudulent will + help Stripe improve their fraud detection algorithms. + + :return: Stripe charge object + :rtype: dict + """ + charge_obj = self.api_retrieve().refund( + amount=self._calculate_refund_amount(amount=amount), reason=reason + ) + return self.__class__.sync_from_stripe_data(charge_obj) + + def capture(self): + """ + Capture the payment of an existing, uncaptured, charge. + This is the second half of the two-step payment flow, where first you + created a charge with the capture option set to False. + + See https://stripe.com/docs/api#capture_charge + """ + + captured_charge = self.api_retrieve().capture() + return self.__class__.sync_from_stripe_data(captured_charge) + + @classmethod + def _stripe_object_destination_to_account(cls, target_cls, data): + """ + Search the given manager for the Account matching this Charge object's + ``destination`` field. + + :param target_cls: The target class + :type target_cls: Account + :param data: stripe object + :type data: dict + """ + + if "destination" in data and data["destination"]: + return target_cls._get_or_create_from_stripe_object(data, "destination")[0] + + def _attach_objects_post_save_hook(self, cls, data, pending_relations=None): + super()._attach_objects_post_save_hook( + cls, data, pending_relations=pending_relations + ) + + cls._stripe_object_to_refunds(target_cls=Refund, data=data, charge=self) class Customer(StripeModel): - """ - Customer objects allow you to perform recurring charges and track multiple - charges that are associated with the same customer. - - Stripe documentation: https://stripe.com/docs/api/python#customers - """ - - stripe_class = stripe.Customer - expand_fields = ["default_source"] - stripe_dashboard_item_name = "customers" - - balance = models.IntegerField( - help_text=( - "Current balance, if any, being stored on the customer's account. " - "If negative, the customer has credit to apply to the next invoice. " - "If positive, the customer has an amount owed that will be added to the " - "next invoice. The balance does not refer to any unpaid invoices; it " - "solely takes into account amounts that have yet to be successfully " - "applied to any invoice. This balance is only taken into account for " - "recurring billing purposes (i.e., subscriptions, invoices, invoice items)." - ) - ) - business_vat_id = models.CharField( - max_length=20, - default="", - blank=True, - help_text="The customer's VAT identification number.", - ) - currency = StripeCurrencyCodeField( - default="", - help_text="The currency the customer can be charged in for recurring billing purposes", - ) - default_source = PaymentMethodForeignKey( - on_delete=models.SET_NULL, null=True, related_name="customers" - ) - delinquent = models.BooleanField( - help_text="Whether or not the latest charge for the customer's latest invoice has failed." - ) - # - coupon = models.ForeignKey("Coupon", null=True, blank=True, on_delete=models.SET_NULL) - coupon_start = StripeDateTimeField( - null=True, - blank=True, - editable=False, - help_text="If a coupon is present, the date at which it was applied.", - ) - coupon_end = StripeDateTimeField( - null=True, - blank=True, - editable=False, - help_text="If a coupon is present and has a limited duration, the date that the discount will end.", - ) - # - email = models.TextField(max_length=5000, default="", blank=True) - shipping = JSONField( - null=True, blank=True, help_text="Shipping information associated with the customer." - ) - - # dj-stripe fields - subscriber = models.ForeignKey( - djstripe_settings.get_subscriber_model_string(), - null=True, - on_delete=models.SET_NULL, - related_name="djstripe_customers", - ) - date_purged = models.DateTimeField(null=True, editable=False) - - class Meta: - unique_together = ("subscriber", "livemode") - - def __str__(self): - if not self.subscriber: - return "{id} (deleted)".format(id=self.id) - elif self.subscriber.email: - return self.subscriber.email - else: - return self.id - - @classmethod - def _manipulate_stripe_object_hook(cls, data): - discount = data.get("discount") - if discount: - data["coupon_start"] = discount["start"] - data["coupon_end"] = discount["end"] - - return data - - @classmethod - def get_or_create(cls, subscriber, livemode=djstripe_settings.STRIPE_LIVE_MODE): - """ - Get or create a dj-stripe customer. - - :param subscriber: The subscriber model instance for which to get or create a customer. - :type subscriber: User - - :param livemode: Whether to get the subscriber in live or test mode. - :type livemode: bool - """ - - try: - return Customer.objects.get(subscriber=subscriber, livemode=livemode), False - except Customer.DoesNotExist: - action = "create:{}".format(subscriber.pk) - idempotency_key = djstripe_settings.get_idempotency_key("customer", action, livemode) - return cls.create(subscriber, idempotency_key=idempotency_key), True - - @classmethod - def create(cls, subscriber, idempotency_key=None): - metadata = {} - subscriber_key = djstripe_settings.SUBSCRIBER_CUSTOMER_KEY - if subscriber_key not in ("", None): - metadata[subscriber_key] = subscriber.pk - - stripe_customer = cls._api_create( - email=subscriber.email, idempotency_key=idempotency_key, metadata=metadata - ) - customer, created = Customer.objects.get_or_create( - id=stripe_customer["id"], - defaults={ - "subscriber": subscriber, - "livemode": stripe_customer["livemode"], - "balance": stripe_customer.get("balance", 0), - "delinquent": stripe_customer.get("delinquent", False), - }, - ) - - return customer - - @property - def credits(self): - """ - The customer is considered to have credits if their balance is below 0. - """ - return abs(min(self.balance, 0)) - - @property - def customer_payment_methods(self): - """ - An iterable of all of the customer's payment methods (sources, then legacy cards) - """ - for source in self.sources.iterator(): - yield source - - for card in self.legacy_cards.iterator(): - yield card - - @property - def pending_charges(self): - """ - The customer is considered to have pending charges if their balance is above 0. - """ - return max(self.balance, 0) - - # deprecated, will be removed in 2.2 - @property - def account_balance(self): - warnings.warn( - "Customer.date has been removed, use .balance instead. This alias will be removed in djstripe 2.2", - DeprecationWarning, - ) - return self.balance - - def subscribe( - self, - plan, - charge_immediately=True, - application_fee_percent=None, - coupon=None, - quantity=None, - metadata=None, - tax_percent=None, - billing_cycle_anchor=None, - trial_end=None, - trial_from_plan=None, - trial_period_days=None, - ): - """ - Subscribes this customer to a plan. - - :param plan: The plan to which to subscribe the customer. - :type plan: Plan or string (plan ID) - :param application_fee_percent: This represents the percentage of the subscription invoice subtotal - that will be transferred to the application owner's Stripe account. - The request must be made with an OAuth key in order to set an - application fee percentage. - :type application_fee_percent: Decimal. Precision is 2; anything more will be ignored. A positive - decimal between 1 and 100. - :param coupon: The code of the coupon to apply to this subscription. A coupon applied to a subscription - will only affect invoices created for that particular subscription. - :type coupon: string - :param quantity: The quantity applied to this subscription. Default is 1. - :type quantity: integer - :param metadata: A set of key/value pairs useful for storing additional information. - :type metadata: dict - :param tax_percent: This represents the percentage of the subscription invoice subtotal that will - be calculated and added as tax to the final amount each billing period. - :type tax_percent: Decimal. Precision is 2; anything more will be ignored. A positive decimal - between 1 and 100. - :param billing_cycle_anchor: A future timestamp to anchor the subscription’s billing cycle. - This is used to determine the date of the first full invoice, and, - for plans with month or year intervals, the day of the month for - subsequent invoices. - :type billing_cycle_anchor: datetime - :param trial_end: The end datetime of the trial period the customer will get before being charged for - the first time. If set, this will override the default trial period of the plan the - customer is being subscribed to. The special value ``now`` can be provided to end - the customer's trial immediately. - :type trial_end: datetime - :param charge_immediately: Whether or not to charge for the subscription upon creation. If False, an - invoice will be created at the end of this period. - :type charge_immediately: boolean - :param trial_from_plan: Indicates if a plan’s trial_period_days should be applied to the subscription. - Setting trial_end per subscription is preferred, and this defaults to false. - Setting this flag to true together with trial_end is not allowed. - :type trial_from_plan: boolean - :param trial_period_days: Integer representing the number of trial period days before the customer is - charged for the first time. This will always overwrite any trials that might - apply via a subscribed plan. - :type trial_period_days: integer - - .. Notes: - .. ``charge_immediately`` is only available on ``Customer.subscribe()`` - .. if you're using ``Customer.subscribe()`` instead of ``Customer.subscribe()``, ``plan`` \ - can only be a string - """ - from .billing import Subscription - - # Convert Plan to id - if isinstance(plan, StripeModel): - plan = plan.id - - stripe_subscription = Subscription._api_create( - plan=plan, - customer=self.id, - application_fee_percent=application_fee_percent, - coupon=coupon, - quantity=quantity, - metadata=metadata, - billing_cycle_anchor=billing_cycle_anchor, - tax_percent=tax_percent, - trial_end=trial_end, - trial_from_plan=trial_from_plan, - trial_period_days=trial_period_days, - ) - - if charge_immediately: - self.send_invoice() - - return Subscription.sync_from_stripe_data(stripe_subscription) - - def charge( - self, - amount, - currency=None, - application_fee=None, - capture=None, - description=None, - destination=None, - metadata=None, - shipping=None, - source=None, - statement_descriptor=None, - idempotency_key=None, - ): - """ - Creates a charge for this customer. - - Parameters not implemented: - - * **receipt_email** - Since this is a charge on a customer, the customer's email address is used. - - - :param amount: The amount to charge. - :type amount: Decimal. Precision is 2; anything more will be ignored. - :param currency: 3-letter ISO code for currency - :type currency: string - :param application_fee: A fee that will be applied to the charge and transfered to the platform owner's - account. - :type application_fee: Decimal. Precision is 2; anything more will be ignored. - :param capture: Whether or not to immediately capture the charge. When false, the charge issues an - authorization (or pre-authorization), and will need to be captured later. Uncaptured - charges expire in 7 days. Default is True - :type capture: bool - :param description: An arbitrary string. - :type description: string - :param destination: An account to make the charge on behalf of. - :type destination: Account - :param metadata: A set of key/value pairs useful for storing additional information. - :type metadata: dict - :param shipping: Shipping information for the charge. - :type shipping: dict - :param source: The source to use for this charge. Must be a source attributed to this customer. If None, - the customer's default source is used. Can be either the id of the source or the source object - itself. - :type source: string, Source - :param statement_descriptor: An arbitrary string to be displayed on the customer's credit card statement. - :type statement_descriptor: string - """ - - if not isinstance(amount, decimal.Decimal): - raise ValueError("You must supply a decimal value representing dollars.") - - # TODO: better default detection (should charge in customer default) - currency = currency or "usd" - - # Convert Source to id - if source and isinstance(source, StripeModel): - source = source.id - - stripe_charge = Charge._api_create( - amount=int(amount * 100), # Convert dollars into cents - currency=currency, - application_fee=int(application_fee * 100) - if application_fee - else None, # Convert dollars into cents - capture=capture, - description=description, - destination=destination, - metadata=metadata, - shipping=shipping, - customer=self.id, - source=source, - statement_descriptor=statement_descriptor, - idempotency_key=idempotency_key, - ) - - return Charge.sync_from_stripe_data(stripe_charge) - - def add_invoice_item( - self, - amount, - currency, - description=None, - discountable=None, - invoice=None, - metadata=None, - subscription=None, - ): - """ - Adds an arbitrary charge or credit to the customer's upcoming invoice. - Different than creating a charge. Charges are separate bills that get - processed immediately. Invoice items are appended to the customer's next - invoice. This is extremely useful when adding surcharges to subscriptions. - - :param amount: The amount to charge. - :type amount: Decimal. Precision is 2; anything more will be ignored. - :param currency: 3-letter ISO code for currency - :type currency: string - :param description: An arbitrary string. - :type description: string - :param discountable: Controls whether discounts apply to this invoice item. Defaults to False for - prorations or negative invoice items, and True for all other invoice items. - :type discountable: boolean - :param invoice: An existing invoice to add this invoice item to. When left blank, the invoice - item will be added to the next upcoming scheduled invoice. Use this when adding - invoice items in response to an ``invoice.created`` webhook. You cannot add an invoice - item to an invoice that has already been paid, attempted or closed. - :type invoice: Invoice or string (invoice ID) - :param metadata: A set of key/value pairs useful for storing additional information. - :type metadata: dict - :param subscription: A subscription to add this invoice item to. When left blank, the invoice - item will be be added to the next upcoming scheduled invoice. When set, - scheduled invoices for subscriptions other than the specified subscription - will ignore the invoice item. Use this when you want to express that an - invoice item has been accrued within the context of a particular subscription. - :type subscription: Subscription or string (subscription ID) - - .. Notes: - .. if you're using ``Customer.add_invoice_item()`` instead of ``Customer.add_invoice_item()``, \ - ``invoice`` and ``subscriptions`` can only be strings - """ - from .billing import InvoiceItem - - if not isinstance(amount, decimal.Decimal): - raise ValueError("You must supply a decimal value representing dollars.") - - # Convert Invoice to id - if invoice is not None and isinstance(invoice, StripeModel): - invoice = invoice.id - - # Convert Subscription to id - if subscription is not None and isinstance(subscription, StripeModel): - subscription = subscription.id - - stripe_invoiceitem = InvoiceItem._api_create( - amount=int(amount * 100), # Convert dollars into cents - currency=currency, - customer=self.id, - description=description, - discountable=discountable, - invoice=invoice, - metadata=metadata, - subscription=subscription, - ) - - return InvoiceItem.sync_from_stripe_data(stripe_invoiceitem) - - def add_card(self, source, set_default=True): - """ - Adds a card to this customer's account. - - :param source: Either a token, like the ones returned by our Stripe.js, or a dictionary containing a - user's credit card details. Stripe will automatically validate the card. - :type source: string, dict - :param set_default: Whether or not to set the source as the customer's default source - :type set_default: boolean - - """ - from .payment_methods import DjstripePaymentMethod - - stripe_customer = self.api_retrieve() - new_stripe_payment_method = stripe_customer.sources.create(source=source) - - if set_default: - stripe_customer.default_source = new_stripe_payment_method["id"] - stripe_customer.save() - - new_payment_method = DjstripePaymentMethod.from_stripe_object( - new_stripe_payment_method - ) - - # Change the default source - if set_default: - self.default_source = new_payment_method - self.save() - - return new_payment_method.resolve() - - # TODO - support setting default payment method (as per set_default param to add_card), see - # see https://stripe.com/docs/api/payment_methods/attach - def add_payment_method(self, payment_method_id): - """ - Adds an already existing payment method to this customer's account - - :param payment_method_id: ID of the PaymentMethod to be attached to the customer. - :return: - """ - from .payment_methods import PaymentMethod - - stripe_customer = self.api_retrieve() - PaymentMethod.attach(payment_method_id, stripe_customer) - - def purge(self): - try: - self._api_delete() - except InvalidRequestError as exc: - if "No such customer:" in str(exc): - # The exception was thrown because the stripe customer was already - # deleted on the stripe side, ignore the exception - pass - else: - # The exception was raised for another reason, re-raise it - raise - - if self.subscriber: - # Delete the idempotency key used by Customer.create() - # So re-creating a customer for this subscriber before the key expires - # doesn't return the older Customer data - idempotency_key_action = "customer:create:{}".format(self.subscriber.pk) - IdempotencyKey.objects.filter(action=idempotency_key_action).delete() - - self.subscriber = None - - # Remove sources - self.default_source = None - for source in self.legacy_cards.all(): - source.remove() - - for source in self.sources.all(): - source.detach() - - self.date_purged = timezone.now() - self.save() - - # TODO: Override Queryset.delete() with a custom manager, since this doesn't get called in bulk deletes - # (or cascades, but that's another matter) - def delete(self, using=None, keep_parents=False): - """ - Overriding the delete method to keep the customer in the records. - All identifying information is removed via the purge() method. - - The only way to delete a customer is to use SQL. - """ - - self.purge() - - def _get_valid_subscriptions(self): - """ Get a list of this customer's valid subscriptions.""" - - return [ - subscription for subscription in self.subscriptions.all() if subscription.is_valid() - ] - - def has_active_subscription(self, plan=None): - """ - Checks to see if this customer has an active subscription to the given plan. - - :param plan: The plan for which to check for an active subscription. If plan is None and - there exists only one active subscription, this method will check if that subscription - is valid. Calling this method with no plan and multiple valid subscriptions for this customer will - throw an exception. - :type plan: Plan or string (plan ID) - - :returns: True if there exists an active subscription, False otherwise. - :throws: TypeError if ``plan`` is None and more than one active subscription exists for this customer. - """ - - if plan is None: - valid_subscriptions = self._get_valid_subscriptions() - - if len(valid_subscriptions) == 0: - return False - elif len(valid_subscriptions) == 1: - return True - else: - raise TypeError( - "plan cannot be None if more than one valid subscription exists for this customer." - ) - - else: - # Convert Plan to id - if isinstance(plan, StripeModel): - plan = plan.id - - return any( - [ - subscription.is_valid() - for subscription in self.subscriptions.filter(plan__id=plan) - ] - ) - - def has_any_active_subscription(self): - """ - Checks to see if this customer has an active subscription to any plan. - - :returns: True if there exists an active subscription, False otherwise. - """ - - return len(self._get_valid_subscriptions()) != 0 - - @property - def active_subscriptions(self): - """ - Returns active subscriptions (subscriptions with an active status that end in the future). - """ - return self.subscriptions.filter( - status=enums.SubscriptionStatus.active, current_period_end__gt=timezone.now() - ) - - @property - def valid_subscriptions(self): - """ - Returns this customer's valid subscriptions (subscriptions that aren't cancelled. - """ - return self.subscriptions.exclude(status=enums.SubscriptionStatus.canceled) - - @property - def subscription(self): - """ - Shortcut to get this customer's subscription. - - :returns: None if the customer has no subscriptions, the subscription if - the customer has a subscription. - :raises MultipleSubscriptionException: Raised if the customer has multiple subscriptions. - In this case, use ``Customer.subscriptions`` instead. - """ - - subscriptions = self.valid_subscriptions - - if subscriptions.count() > 1: - raise MultipleSubscriptionException( - "This customer has multiple subscriptions. Use Customer.subscriptions " - "to access them." - ) - else: - return subscriptions.first() - - def can_charge(self): - """Determines if this customer is able to be charged.""" - - return self.has_valid_source() and self.date_purged is None - - def send_invoice(self): - """ - Pay and send the customer's latest invoice. - - :returns: True if an invoice was able to be created and paid, False otherwise - (typically if there was nothing to invoice). - """ - from .billing import Invoice - - try: - invoice = Invoice._api_create(customer=self.id) - invoice.pay() - return True - except InvalidRequestError: # TODO: Check this for a more specific error message. - return False # There was nothing to invoice - - def retry_unpaid_invoices(self): - """ Attempt to retry collecting payment on the customer's unpaid invoices.""" - - self._sync_invoices() - for invoice in self.invoices.filter(paid=False, closed=False): - try: - invoice.retry() # Always retry unpaid invoices - except InvalidRequestError as exc: - if str(exc) != "Invoice is already paid": - raise - - def has_valid_source(self): - """ Check whether the customer has a valid payment source.""" - return self.default_source is not None - - def add_coupon(self, coupon, idempotency_key=None): - """ - Add a coupon to a Customer. - - The coupon can be a Coupon object, or a valid Stripe Coupon ID. - """ - if isinstance(coupon, StripeModel): - coupon = coupon.id - - stripe_customer = self.api_retrieve() - stripe_customer["coupon"] = coupon - stripe_customer.save(idempotency_key=idempotency_key) - return self.__class__.sync_from_stripe_data(stripe_customer) - - def upcoming_invoice(self, **kwargs): - """ Gets the upcoming preview invoice (singular) for this customer. - - See `Invoice.upcoming() <#djstripe.Invoice.upcoming>`__. - - The ``customer`` argument to the ``upcoming()`` call is automatically set by this method. - """ - from .billing import Invoice - - kwargs["customer"] = self - return Invoice.upcoming(**kwargs) - - def _attach_objects_post_save_hook( - self, cls, data, pending_relations=None - ): # noqa (function complexity) - from .billing import Coupon - from .payment_methods import DjstripePaymentMethod - - super()._attach_objects_post_save_hook(cls, data, pending_relations=pending_relations) - - save = False - - customer_sources = data.get("sources") - if customer_sources: - # Have to create sources before we handle the default_source - # We save all of them in the `sources` dict, so that we can find them - # by id when we look at the default_source (we need the source type). - sources = {} - for source in customer_sources["data"]: - obj, _ = DjstripePaymentMethod._get_or_create_source(source, source["object"]) - sources[source["id"]] = obj - - default_source = data.get("default_source") - if default_source: - if isinstance(default_source, str): - default_source_id = default_source - else: - default_source_id = default_source["id"] - source = sources[default_source_id] - - save = self.default_source != source - self.default_source = source - - discount = data.get("discount") - if discount: - coupon, _created = Coupon._get_or_create_from_stripe_object(discount, "coupon") - if coupon and coupon != self.coupon: - self.coupon = coupon - save = True - elif self.coupon: - self.coupon = None - save = True - - if save: - self.save() - - def _attach_objects_hook(self, cls, data): - # When we save a customer to Stripe, we add a reference to its Django PK - # in the `django_account` key. If we find that, we re-attach that PK. - subscriber_key = djstripe_settings.SUBSCRIBER_CUSTOMER_KEY - if subscriber_key in ("", None): - # Disabled. Nothing else to do. - return - - subscriber_id = data.get("metadata", {}).get(subscriber_key) - if subscriber_id: - cls = djstripe_settings.get_subscriber_model() - try: - # We have to perform a get(), instead of just attaching the PK - # blindly as the object may have been deleted or not exist. - # Attempting to save that would cause an IntegrityError. - self.subscriber = cls.objects.get(pk=subscriber_id) - except (cls.DoesNotExist, ValueError): - logger.warning( - "Could not find subscriber %r matching customer %r", subscriber_id, self.id - ) - self.subscriber = None - - # SYNC methods should be dropped in favor of the master sync infrastructure proposed - def _sync_invoices(self, **kwargs): - from .billing import Invoice - - for stripe_invoice in Invoice.api_list(customer=self.id, **kwargs): - Invoice.sync_from_stripe_data(stripe_invoice) - - def _sync_charges(self, **kwargs): - for stripe_charge in Charge.api_list(customer=self.id, **kwargs): - Charge.sync_from_stripe_data(stripe_charge) - - def _sync_cards(self, **kwargs): - from .payment_methods import Card - - for stripe_card in Card.api_list(customer=self, **kwargs): - Card.sync_from_stripe_data(stripe_card) - - def _sync_subscriptions(self, **kwargs): - from .billing import Subscription - - for stripe_subscription in Subscription.api_list( - customer=self.id, status="all", **kwargs - ): - Subscription.sync_from_stripe_data(stripe_subscription) + """ + Customer objects allow you to perform recurring charges and track multiple + charges that are associated with the same customer. + + Stripe documentation: https://stripe.com/docs/api/python#customers + """ + + stripe_class = stripe.Customer + expand_fields = ["default_source"] + stripe_dashboard_item_name = "customers" + + balance = models.IntegerField( + help_text=( + "Current balance, if any, being stored on the customer's account. " + "If negative, the customer has credit to apply to the next invoice. " + "If positive, the customer has an amount owed that will be added to the " + "next invoice. The balance does not refer to any unpaid invoices; it " + "solely takes into account amounts that have yet to be successfully " + "applied to any invoice. This balance is only taken into account for " + "recurring billing purposes (i.e., subscriptions, invoices, invoice items)." + ) + ) + business_vat_id = models.CharField( + max_length=20, + default="", + blank=True, + help_text="The customer's VAT identification number.", + ) + currency = StripeCurrencyCodeField( + default="", + help_text="The currency the customer can be charged in for " + "recurring billing purposes", + ) + default_source = PaymentMethodForeignKey( + on_delete=models.SET_NULL, null=True, related_name="customers" + ) + delinquent = models.BooleanField( + help_text="Whether or not the latest charge for the customer's " + "latest invoice has failed." + ) + # + coupon = models.ForeignKey( + "Coupon", null=True, blank=True, on_delete=models.SET_NULL + ) + coupon_start = StripeDateTimeField( + null=True, + blank=True, + editable=False, + help_text="If a coupon is present, the date at which it was applied.", + ) + coupon_end = StripeDateTimeField( + null=True, + blank=True, + editable=False, + help_text="If a coupon is present and has a limited duration, " + "the date that the discount will end.", + ) + # + email = models.TextField(max_length=5000, default="", blank=True) + shipping = JSONField( + null=True, + blank=True, + help_text="Shipping information associated with the customer.", + ) + + # dj-stripe fields + subscriber = models.ForeignKey( + djstripe_settings.get_subscriber_model_string(), + null=True, + on_delete=models.SET_NULL, + related_name="djstripe_customers", + ) + date_purged = models.DateTimeField(null=True, editable=False) + + class Meta: + unique_together = ("subscriber", "livemode") + + def __str__(self): + if not self.subscriber: + return "{id} (deleted)".format(id=self.id) + elif self.subscriber.email: + return self.subscriber.email + else: + return self.id + + @classmethod + def _manipulate_stripe_object_hook(cls, data): + discount = data.get("discount") + if discount: + data["coupon_start"] = discount["start"] + data["coupon_end"] = discount["end"] + + return data + + @classmethod + def get_or_create(cls, subscriber, livemode=djstripe_settings.STRIPE_LIVE_MODE): + """ + Get or create a dj-stripe customer. + + :param subscriber: The subscriber model instance for which to get or + create a customer. + :type subscriber: User + + :param livemode: Whether to get the subscriber in live or test mode. + :type livemode: bool + """ + + try: + return Customer.objects.get(subscriber=subscriber, livemode=livemode), False + except Customer.DoesNotExist: + action = "create:{}".format(subscriber.pk) + idempotency_key = djstripe_settings.get_idempotency_key( + "customer", action, livemode + ) + return cls.create(subscriber, idempotency_key=idempotency_key), True + + @classmethod + def create(cls, subscriber, idempotency_key=None): + metadata = {} + subscriber_key = djstripe_settings.SUBSCRIBER_CUSTOMER_KEY + if subscriber_key not in ("", None): + metadata[subscriber_key] = subscriber.pk + + stripe_customer = cls._api_create( + email=subscriber.email, idempotency_key=idempotency_key, metadata=metadata + ) + customer, created = Customer.objects.get_or_create( + id=stripe_customer["id"], + defaults={ + "subscriber": subscriber, + "livemode": stripe_customer["livemode"], + "balance": stripe_customer.get("balance", 0), + "delinquent": stripe_customer.get("delinquent", False), + }, + ) + + return customer + + @property + def credits(self): + """ + The customer is considered to have credits if their balance is below 0. + """ + return abs(min(self.balance, 0)) + + @property + def customer_payment_methods(self): + """ + An iterable of all of the customer's payment methods + (sources, then legacy cards) + """ + for source in self.sources.iterator(): + yield source + + for card in self.legacy_cards.iterator(): + yield card + + @property + def pending_charges(self): + """ + The customer is considered to have pending charges if their balance is above 0. + """ + return max(self.balance, 0) + + # deprecated, will be removed in 2.2 + @property + def account_balance(self): + warnings.warn( + "Customer.date has been removed, use .balance instead. " + "This alias will be removed in djstripe 2.2", + DeprecationWarning, + ) + return self.balance + + def subscribe( + self, + plan, + charge_immediately=True, + application_fee_percent=None, + coupon=None, + quantity=None, + metadata=None, + tax_percent=None, + billing_cycle_anchor=None, + trial_end=None, + trial_from_plan=None, + trial_period_days=None, + ): + """ + Subscribes this customer to a plan. + + :param plan: The plan to which to subscribe the customer. + :type plan: Plan or string (plan ID) + :param application_fee_percent: This represents the percentage of the + subscription invoice subtotal + that will be transferred to the application owner's Stripe account. + The request must be made with an OAuth key in order to set an + application fee percentage. + :type application_fee_percent: Decimal. Precision is 2; anything more + will be ignored. A positive decimal between 1 and 100. + :param coupon: The code of the coupon to apply to this subscription. + A coupon applied to a subscription + will only affect invoices created for that particular subscription. + :type coupon: string + :param quantity: The quantity applied to this subscription. Default is 1. + :type quantity: integer + :param metadata: A set of key/value pairs useful for storing + additional information. + :type metadata: dict + :param tax_percent: This represents the percentage of the subscription invoice + subtotal that will be calculated and added as tax to the + final amount each billing period. + :type tax_percent: Decimal. Precision is 2; anything more will be ignored. + A positive decimal between 1 and 100. + :param billing_cycle_anchor: A future timestamp to anchor the + subscription’s billing cycle. + This is used to determine the date of the first full invoice, and, + for plans with month or year intervals, the day of the month for + subsequent invoices. + :type billing_cycle_anchor: datetime + :param trial_end: The end datetime of the trial period the customer will get + before being charged for the first time. If set, this will override + the default trial period of the plan the customer is being subscribed to. + The special value ``now`` can be provided to end the customer's + trial immediately. + :type trial_end: datetime + :param charge_immediately: Whether or not to charge for + the subscription upon creation. + If False, an invoice will be created at the end of this period. + :type charge_immediately: boolean + :param trial_from_plan: Indicates if a plan’s trial_period_days should + be applied to the subscription. + Setting trial_end per subscription is preferred, and this defaults to false. + Setting this flag to true together with trial_end is not allowed. + :type trial_from_plan: boolean + :param trial_period_days: Integer representing the number of trial period days + before the customer is charged for the first time. + This will always overwrite any trials that might apply + via a subscribed plan. + :type trial_period_days: integer + + .. Notes: + .. ``charge_immediately`` is only available on ``Customer.subscribe()`` + .. if you're using ``Customer.subscribe()`` + .. instead of ``Customer.subscribe()``, ``plan`` can only be a string + """ + from .billing import Subscription + + # Convert Plan to id + if isinstance(plan, StripeModel): + plan = plan.id + + stripe_subscription = Subscription._api_create( + plan=plan, + customer=self.id, + application_fee_percent=application_fee_percent, + coupon=coupon, + quantity=quantity, + metadata=metadata, + billing_cycle_anchor=billing_cycle_anchor, + tax_percent=tax_percent, + trial_end=trial_end, + trial_from_plan=trial_from_plan, + trial_period_days=trial_period_days, + ) + + if charge_immediately: + self.send_invoice() + + return Subscription.sync_from_stripe_data(stripe_subscription) + + def charge( + self, + amount, + currency=None, + application_fee=None, + capture=None, + description=None, + destination=None, + metadata=None, + shipping=None, + source=None, + statement_descriptor=None, + idempotency_key=None, + ): + """ + Creates a charge for this customer. + + Parameters not implemented: + + * **receipt_email** - Since this is a charge on a customer, + the customer's email address is used. + + + :param amount: The amount to charge. + :type amount: Decimal. Precision is 2; anything more will be ignored. + :param currency: 3-letter ISO code for currency + :type currency: string + :param application_fee: A fee that will be applied to the charge and transfered + to the platform owner's account. + :type application_fee: Decimal. Precision is 2; anything more will be ignored. + :param capture: Whether or not to immediately capture the charge. + When false, the charge issues an authorization (or pre-authorization), + and will need to be captured later. Uncaptured charges expire in 7 days. + Default is True + :type capture: bool + :param description: An arbitrary string. + :type description: string + :param destination: An account to make the charge on behalf of. + :type destination: Account + :param metadata: A set of key/value pairs useful for storing + additional information. + :type metadata: dict + :param shipping: Shipping information for the charge. + :type shipping: dict + :param source: The source to use for this charge. + Must be a source attributed to this customer. If None, the customer's + default source is used. Can be either the id of the source or + the source object itself. + :type source: string, Source + :param statement_descriptor: An arbitrary string to be displayed on the + customer's credit card statement. + :type statement_descriptor: string + """ + + if not isinstance(amount, decimal.Decimal): + raise ValueError("You must supply a decimal value representing dollars.") + + # TODO: better default detection (should charge in customer default) + currency = currency or "usd" + + # Convert Source to id + if source and isinstance(source, StripeModel): + source = source.id + + stripe_charge = Charge._api_create( + amount=int(amount * 100), # Convert dollars into cents + currency=currency, + application_fee=int(application_fee * 100) + if application_fee + else None, # Convert dollars into cents + capture=capture, + description=description, + destination=destination, + metadata=metadata, + shipping=shipping, + customer=self.id, + source=source, + statement_descriptor=statement_descriptor, + idempotency_key=idempotency_key, + ) + + return Charge.sync_from_stripe_data(stripe_charge) + + def add_invoice_item( + self, + amount, + currency, + description=None, + discountable=None, + invoice=None, + metadata=None, + subscription=None, + ): + """ + Adds an arbitrary charge or credit to the customer's upcoming invoice. + Different than creating a charge. Charges are separate bills that get + processed immediately. Invoice items are appended to the customer's next + invoice. This is extremely useful when adding surcharges to subscriptions. + + :param amount: The amount to charge. + :type amount: Decimal. Precision is 2; anything more will be ignored. + :param currency: 3-letter ISO code for currency + :type currency: string + :param description: An arbitrary string. + :type description: string + :param discountable: Controls whether discounts apply to this invoice item. + Defaults to False for prorations or negative invoice items, + and True for all other invoice items. + :type discountable: boolean + :param invoice: An existing invoice to add this invoice item to. + When left blank, the invoice item will be added to the next upcoming + scheduled invoice. + Use this when adding invoice items in response to an + ``invoice.created`` webhook. You cannot add an invoice + item to an invoice that has already been paid, attempted or closed. + :type invoice: Invoice or string (invoice ID) + :param metadata: A set of key/value pairs useful for storing + additional information. + :type metadata: dict + :param subscription: A subscription to add this invoice item to. + When left blank, the invoice item will be be added to the next upcoming + scheduled invoice. When set, scheduled invoices for subscriptions other + than the specified subscription will ignore the invoice item. + Use this when you want to express that an invoice item has been accrued + within the context of a particular subscription. + :type subscription: Subscription or string (subscription ID) + + .. Notes: + .. if you're using ``Customer.add_invoice_item()`` instead of + .. ``Customer.add_invoice_item()``, ``invoice`` and ``subscriptions`` + .. can only be strings + """ + from .billing import InvoiceItem + + if not isinstance(amount, decimal.Decimal): + raise ValueError("You must supply a decimal value representing dollars.") + + # Convert Invoice to id + if invoice is not None and isinstance(invoice, StripeModel): + invoice = invoice.id + + # Convert Subscription to id + if subscription is not None and isinstance(subscription, StripeModel): + subscription = subscription.id + + stripe_invoiceitem = InvoiceItem._api_create( + amount=int(amount * 100), # Convert dollars into cents + currency=currency, + customer=self.id, + description=description, + discountable=discountable, + invoice=invoice, + metadata=metadata, + subscription=subscription, + ) + + return InvoiceItem.sync_from_stripe_data(stripe_invoiceitem) + + def add_card(self, source, set_default=True): + """ + Adds a card to this customer's account. + + :param source: Either a token, like the ones returned by our Stripe.js, or a + dictionary containing a user's credit card details. + Stripe will automatically validate the card. + :type source: string, dict + :param set_default: Whether or not to set the source as the customer's + default source + :type set_default: boolean + + """ + from .payment_methods import DjstripePaymentMethod + + stripe_customer = self.api_retrieve() + new_stripe_payment_method = stripe_customer.sources.create(source=source) + + if set_default: + stripe_customer.default_source = new_stripe_payment_method["id"] + stripe_customer.save() + + new_payment_method = DjstripePaymentMethod.from_stripe_object( + new_stripe_payment_method + ) + + # Change the default source + if set_default: + self.default_source = new_payment_method + self.save() + + return new_payment_method.resolve() + + # TODO - support setting default payment method + # (as per set_default param to add_card), see + # see https://stripe.com/docs/api/payment_methods/attach + def add_payment_method(self, payment_method_id): + """ + Adds an already existing payment method to this customer's account + + :param payment_method_id: ID of the PaymentMethod to be attached to the customer + :return: + """ + from .payment_methods import PaymentMethod + + stripe_customer = self.api_retrieve() + PaymentMethod.attach(payment_method_id, stripe_customer) + + def purge(self): + try: + self._api_delete() + except InvalidRequestError as exc: + if "No such customer:" in str(exc): + # The exception was thrown because the stripe customer was already + # deleted on the stripe side, ignore the exception + pass + else: + # The exception was raised for another reason, re-raise it + raise + + if self.subscriber: + # Delete the idempotency key used by Customer.create() + # So re-creating a customer for this subscriber before the key expires + # doesn't return the older Customer data + idempotency_key_action = "customer:create:{}".format(self.subscriber.pk) + IdempotencyKey.objects.filter(action=idempotency_key_action).delete() + + self.subscriber = None + + # Remove sources + self.default_source = None + for source in self.legacy_cards.all(): + source.remove() + + for source in self.sources.all(): + source.detach() + + self.date_purged = timezone.now() + self.save() + + # TODO: Override Queryset.delete() with a custom manager, + # since this doesn't get called in bulk deletes + # (or cascades, but that's another matter) + def delete(self, using=None, keep_parents=False): + """ + Overriding the delete method to keep the customer in the records. + All identifying information is removed via the purge() method. + + The only way to delete a customer is to use SQL. + """ + + self.purge() + + def _get_valid_subscriptions(self): + """ Get a list of this customer's valid subscriptions.""" + + return [ + subscription + for subscription in self.subscriptions.all() + if subscription.is_valid() + ] + + def has_active_subscription(self, plan=None): + """ + Checks to see if this customer has an active subscription to the given plan. + + :param plan: The plan for which to check for an active subscription. + If plan is None and there exists only one active subscription, + this method will check if that subscription is valid. + Calling this method with no plan and multiple valid subscriptions + for this customer will throw an exception. + :type plan: Plan or string (plan ID) + + :returns: True if there exists an active subscription, False otherwise. + :throws: TypeError if ``plan`` is None and more than one active subscription + exists for this customer. + """ + + if plan is None: + valid_subscriptions = self._get_valid_subscriptions() + + if len(valid_subscriptions) == 0: + return False + elif len(valid_subscriptions) == 1: + return True + else: + raise TypeError( + "plan cannot be None if more than one valid subscription " + "exists for this customer." + ) + + else: + # Convert Plan to id + if isinstance(plan, StripeModel): + plan = plan.id + + return any( + [ + subscription.is_valid() + for subscription in self.subscriptions.filter(plan__id=plan) + ] + ) + + def has_any_active_subscription(self): + """ + Checks to see if this customer has an active subscription to any plan. + + :returns: True if there exists an active subscription, False otherwise. + """ + + return len(self._get_valid_subscriptions()) != 0 + + @property + def active_subscriptions(self): + """ + Returns active subscriptions + (subscriptions with an active status that end in the future). + """ + return self.subscriptions.filter( + status=enums.SubscriptionStatus.active, + current_period_end__gt=timezone.now(), + ) + + @property + def valid_subscriptions(self): + """ + Returns this customer's valid subscriptions + (subscriptions that aren't cancelled). + """ + return self.subscriptions.exclude(status=enums.SubscriptionStatus.canceled) + + @property + def subscription(self): + """ + Shortcut to get this customer's subscription. + + :returns: None if the customer has no subscriptions, the subscription if + the customer has a subscription. + :raises MultipleSubscriptionException: Raised if the customer has multiple + subscriptions. + In this case, use ``Customer.subscriptions`` instead. + """ + + subscriptions = self.valid_subscriptions + + if subscriptions.count() > 1: + raise MultipleSubscriptionException( + "This customer has multiple subscriptions. Use Customer.subscriptions " + "to access them." + ) + else: + return subscriptions.first() + + def can_charge(self): + """Determines if this customer is able to be charged.""" + + return self.has_valid_source() and self.date_purged is None + + def send_invoice(self): + """ + Pay and send the customer's latest invoice. + + :returns: True if an invoice was able to be created and paid, False otherwise + (typically if there was nothing to invoice). + """ + from .billing import Invoice + + try: + invoice = Invoice._api_create(customer=self.id) + invoice.pay() + return True + except InvalidRequestError: # TODO: Check this for a more + # specific error message. + return False # There was nothing to invoice + + def retry_unpaid_invoices(self): + """ Attempt to retry collecting payment on the customer's unpaid invoices.""" + + self._sync_invoices() + for invoice in self.invoices.filter(paid=False, closed=False): + try: + invoice.retry() # Always retry unpaid invoices + except InvalidRequestError as exc: + if str(exc) != "Invoice is already paid": + raise + + def has_valid_source(self): + """ Check whether the customer has a valid payment source.""" + return self.default_source is not None + + def add_coupon(self, coupon, idempotency_key=None): + """ + Add a coupon to a Customer. + + The coupon can be a Coupon object, or a valid Stripe Coupon ID. + """ + if isinstance(coupon, StripeModel): + coupon = coupon.id + + stripe_customer = self.api_retrieve() + stripe_customer["coupon"] = coupon + stripe_customer.save(idempotency_key=idempotency_key) + return self.__class__.sync_from_stripe_data(stripe_customer) + + def upcoming_invoice(self, **kwargs): + """ Gets the upcoming preview invoice (singular) for this customer. + + See `Invoice.upcoming() <#djstripe.Invoice.upcoming>`__. + + The ``customer`` argument to the ``upcoming()`` call is automatically set + by this method. + """ + from .billing import Invoice + + kwargs["customer"] = self + return Invoice.upcoming(**kwargs) + + def _attach_objects_post_save_hook( + self, cls, data, pending_relations=None + ): # noqa (function complexity) + from .billing import Coupon + from .payment_methods import DjstripePaymentMethod + + super()._attach_objects_post_save_hook( + cls, data, pending_relations=pending_relations + ) + + save = False + + customer_sources = data.get("sources") + if customer_sources: + # Have to create sources before we handle the default_source + # We save all of them in the `sources` dict, so that we can find them + # by id when we look at the default_source (we need the source type). + sources = {} + for source in customer_sources["data"]: + obj, _ = DjstripePaymentMethod._get_or_create_source( + source, source["object"] + ) + sources[source["id"]] = obj + + default_source = data.get("default_source") + if default_source: + if isinstance(default_source, str): + default_source_id = default_source + else: + default_source_id = default_source["id"] + source = sources[default_source_id] + + save = self.default_source != source + self.default_source = source + + discount = data.get("discount") + if discount: + coupon, _created = Coupon._get_or_create_from_stripe_object( + discount, "coupon" + ) + if coupon and coupon != self.coupon: + self.coupon = coupon + save = True + elif self.coupon: + self.coupon = None + save = True + + if save: + self.save() + + def _attach_objects_hook(self, cls, data): + # When we save a customer to Stripe, we add a reference to its Django PK + # in the `django_account` key. If we find that, we re-attach that PK. + subscriber_key = djstripe_settings.SUBSCRIBER_CUSTOMER_KEY + if subscriber_key in ("", None): + # Disabled. Nothing else to do. + return + + subscriber_id = data.get("metadata", {}).get(subscriber_key) + if subscriber_id: + cls = djstripe_settings.get_subscriber_model() + try: + # We have to perform a get(), instead of just attaching the PK + # blindly as the object may have been deleted or not exist. + # Attempting to save that would cause an IntegrityError. + self.subscriber = cls.objects.get(pk=subscriber_id) + except (cls.DoesNotExist, ValueError): + logger.warning( + "Could not find subscriber %r matching customer %r", + subscriber_id, + self.id, + ) + self.subscriber = None + + # SYNC methods should be dropped in favor of the master sync infrastructure proposed + def _sync_invoices(self, **kwargs): + from .billing import Invoice + + for stripe_invoice in Invoice.api_list(customer=self.id, **kwargs): + Invoice.sync_from_stripe_data(stripe_invoice) + + def _sync_charges(self, **kwargs): + for stripe_charge in Charge.api_list(customer=self.id, **kwargs): + Charge.sync_from_stripe_data(stripe_charge) + + def _sync_cards(self, **kwargs): + from .payment_methods import Card + + for stripe_card in Card.api_list(customer=self, **kwargs): + Card.sync_from_stripe_data(stripe_card) + + def _sync_subscriptions(self, **kwargs): + from .billing import Subscription + + for stripe_subscription in Subscription.api_list( + customer=self.id, status="all", **kwargs + ): + Subscription.sync_from_stripe_data(stripe_subscription) class Dispute(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#disputes - """ - - stripe_class = stripe.Dispute - stripe_dashboard_item_name = "disputes" - - amount = StripeQuantumCurrencyAmountField( - help_text=( - "Disputed amount. Usually the amount of the charge, but can differ " - "(usually because of currency fluctuation or because only part of the order is disputed)." - ) - ) - currency = StripeCurrencyCodeField() - evidence = JSONField(help_text="Evidence provided to respond to a dispute.") - evidence_details = JSONField(help_text="Information about the evidence submission.") - is_charge_refundable = models.BooleanField( - help_text=( - "If true, it is still possible to refund the disputed payment. " - "Once the payment has been fully refunded, no further funds will " - "be withdrawn from your Stripe account as a result of this dispute." - ) - ) - reason = StripeEnumField(enum=enums.DisputeReason) - status = StripeEnumField(enum=enums.DisputeStatus) + """ + Stripe documentation: https://stripe.com/docs/api#disputes + """ + + stripe_class = stripe.Dispute + stripe_dashboard_item_name = "disputes" + + amount = StripeQuantumCurrencyAmountField( + help_text=( + "Disputed amount. Usually the amount of the charge, but can differ " + "(usually because of currency fluctuation or because only part of " + "the order is disputed)." + ) + ) + currency = StripeCurrencyCodeField() + evidence = JSONField(help_text="Evidence provided to respond to a dispute.") + evidence_details = JSONField(help_text="Information about the evidence submission.") + is_charge_refundable = models.BooleanField( + help_text=( + "If true, it is still possible to refund the disputed payment. " + "Once the payment has been fully refunded, no further funds will " + "be withdrawn from your Stripe account as a result of this dispute." + ) + ) + reason = StripeEnumField(enum=enums.DisputeReason) + status = StripeEnumField(enum=enums.DisputeStatus) class Event(StripeModel): - """ - Events are Stripe's way of letting you know when something interesting - happens in your account. - When an interesting event occurs, a new Event object is created and POSTed - to the configured webhook URL if the Event type matches. - - Stripe documentation: https://stripe.com/docs/api/events - """ - - stripe_class = stripe.Event - stripe_dashboard_item_name = "events" - - api_version = models.CharField( - max_length=15, - blank=True, - help_text="the API version at which the event data was " - "rendered. Blank for old entries only, all new entries will have this value", - ) - data = JSONField( - help_text="data received at webhook. data should be considered to be garbage until validity check is run " - "and valid flag is set" - ) - request_id = models.CharField( - max_length=50, - help_text="Information about the request that triggered this event, for traceability purposes. If empty " - "string then this is an old entry without that data. If Null then this is not an old entry, but a Stripe " - "'automated' event with no associated request.", - default="", - blank=True, - ) - idempotency_key = models.TextField(default="", blank=True) - type = models.CharField(max_length=250, help_text="Stripe's event description code") - - def str_parts(self): - return ["type={type}".format(type=self.type)] + super().str_parts() - - def _attach_objects_hook(self, cls, data): - if self.api_version is None: - # as of api version 2017-02-14, the account.application.deauthorized - # event sends None as api_version. - # If we receive that, store an empty string instead. - # Remove this hack if this gets fixed upstream. - self.api_version = "" - - request_obj = data.get("request", None) - if isinstance(request_obj, dict): - # Format as of 2017-05-25 - self.request_id = request_obj.get("request") or "" - self.idempotency_key = request_obj.get("idempotency_key") or "" - else: - # Format before 2017-05-25 - self.request_id = request_obj or "" - - @classmethod - def process(cls, data): - qs = cls.objects.filter(id=data["id"]) - if qs.exists(): - return qs.first() - - # Rollback any DB operations in the case of failure so - # we will retry creating and processing the event the - # next time the webhook fires. - with transaction.atomic(): - ret = cls._create_from_stripe_object(data) - ret.invoke_webhook_handlers() - return ret - - def invoke_webhook_handlers(self): - """ - Invokes any webhook handlers that have been registered for this event - based on event type or event sub-type. - - See event handlers registered in the ``djstripe.event_handlers`` module - (or handlers registered in djstripe plugins or contrib packages). - """ - - webhooks.call_handlers(event=self) - - signal = WEBHOOK_SIGNALS.get(self.type) - if signal: - return signal.send(sender=Event, event=self) - - @cached_property - def parts(self): - """ Gets the event category/verb as a list of parts. """ - return str(self.type).split(".") - - @cached_property - def category(self): - """ Gets the event category string (e.g. 'customer'). """ - return self.parts[0] - - @cached_property - def verb(self): - """ Gets the event past-tense verb string (e.g. 'updated'). """ - return ".".join(self.parts[1:]) - - @property - def customer(self): - data = self.data["object"] - if data["object"] == "customer": - field = "id" - else: - field = "customer" - - if data.get(field): - return Customer._get_or_create_from_stripe_object(data, field)[0] + """ + Events are Stripe's way of letting you know when something interesting + happens in your account. + When an interesting event occurs, a new Event object is created and POSTed + to the configured webhook URL if the Event type matches. + + Stripe documentation: https://stripe.com/docs/api/events + """ + + stripe_class = stripe.Event + stripe_dashboard_item_name = "events" + + api_version = models.CharField( + max_length=15, + blank=True, + help_text="the API version at which the event data was " + "rendered. Blank for old entries only, all new entries will have this value", + ) + data = JSONField( + help_text="data received at webhook. data should be considered to be garbage " + "until validity check is run and valid flag is set" + ) + request_id = models.CharField( + max_length=50, + help_text="Information about the request that triggered this event, " + "for traceability purposes. If empty string then this is an old entry " + "without that data. If Null then this is not an old entry, but a Stripe " + "'automated' event with no associated request.", + default="", + blank=True, + ) + idempotency_key = models.TextField(default="", blank=True) + type = models.CharField(max_length=250, help_text="Stripe's event description code") + + def str_parts(self): + return ["type={type}".format(type=self.type)] + super().str_parts() + + def _attach_objects_hook(self, cls, data): + if self.api_version is None: + # as of api version 2017-02-14, the account.application.deauthorized + # event sends None as api_version. + # If we receive that, store an empty string instead. + # Remove this hack if this gets fixed upstream. + self.api_version = "" + + request_obj = data.get("request", None) + if isinstance(request_obj, dict): + # Format as of 2017-05-25 + self.request_id = request_obj.get("request") or "" + self.idempotency_key = request_obj.get("idempotency_key") or "" + else: + # Format before 2017-05-25 + self.request_id = request_obj or "" + + @classmethod + def process(cls, data): + qs = cls.objects.filter(id=data["id"]) + if qs.exists(): + return qs.first() + + # Rollback any DB operations in the case of failure so + # we will retry creating and processing the event the + # next time the webhook fires. + with transaction.atomic(): + ret = cls._create_from_stripe_object(data) + ret.invoke_webhook_handlers() + return ret + + def invoke_webhook_handlers(self): + """ + Invokes any webhook handlers that have been registered for this event + based on event type or event sub-type. + + See event handlers registered in the ``djstripe.event_handlers`` module + (or handlers registered in djstripe plugins or contrib packages). + """ + + webhooks.call_handlers(event=self) + + signal = WEBHOOK_SIGNALS.get(self.type) + if signal: + return signal.send(sender=Event, event=self) + + @cached_property + def parts(self): + """ Gets the event category/verb as a list of parts. """ + return str(self.type).split(".") + + @cached_property + def category(self): + """ Gets the event category string (e.g. 'customer'). """ + return self.parts[0] + + @cached_property + def verb(self): + """ Gets the event past-tense verb string (e.g. 'updated'). """ + return ".".join(self.parts[1:]) + + @property + def customer(self): + data = self.data["object"] + if data["object"] == "customer": + field = "id" + else: + field = "customer" + + if data.get(field): + return Customer._get_or_create_from_stripe_object(data, field)[0] class FileUpload(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#file_uploads - """ - - stripe_class = stripe.FileUpload - - filename = models.CharField( - max_length=255, - help_text="A filename for the file, suitable for saving to a filesystem.", - ) - purpose = StripeEnumField( - enum=enums.FileUploadPurpose, help_text="The purpose of the uploaded file." - ) - size = models.IntegerField(help_text="The size in bytes of the file upload object.") - type = StripeEnumField( - enum=enums.FileUploadType, help_text="The type of the file returned." - ) - url = models.CharField( - max_length=200, help_text="A read-only URL where the uploaded file can be accessed." - ) - - @classmethod - def is_valid_object(cls, data): - return data["object"] in ("file", "file_upload") + """ + Stripe documentation: https://stripe.com/docs/api#file_uploads + """ + + stripe_class = stripe.FileUpload + + filename = models.CharField( + max_length=255, + help_text="A filename for the file, suitable for saving to a filesystem.", + ) + purpose = StripeEnumField( + enum=enums.FileUploadPurpose, help_text="The purpose of the uploaded file." + ) + size = models.IntegerField(help_text="The size in bytes of the file upload object.") + type = StripeEnumField( + enum=enums.FileUploadType, help_text="The type of the file returned." + ) + url = models.CharField( + max_length=200, + help_text="A read-only URL where the uploaded file can be accessed.", + ) + + @classmethod + def is_valid_object(cls, data): + return data["object"] in ("file", "file_upload") # Alias for compatability -# TODO - rename the model and switch this alias the other way around to match stripe python +# TODO - rename the model and switch this alias the other way around +# to match stripe python File = FileUpload class PaymentIntent(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#payment_intents - """ - - stripe_class = stripe.PaymentIntent - stripe_dashboard_item_name = "payment intents" - - amount = StripeQuantumCurrencyAmountField( - help_text=("Amount intended to be collected by this PaymentIntent.") - ) - amount_capturable = StripeQuantumCurrencyAmountField( - help_text=("Amount that can be captured from this PaymentIntent.") - ) - amount_received = StripeQuantumCurrencyAmountField( - help_text=("Amount that was collected by this PaymentIntent.") - ) - # application - # application_fee_amount - canceled_at = models.DateTimeField( - null=True, - default=None, - help_text=( - "Populated when status is canceled, this is the time at which the PaymentIntent was " - "canceled. Measured in seconds since the Unix epoch." - ), - ) - cancellation_reason = models.CharField( - max_length=255, - null=True, - help_text=( - "User-given reason for cancellation of this PaymentIntent, one of duplicate, " - "fraudulent, requested_by_customer, or failed_invoice." - ), - ) - capture_method = StripeEnumField( - enum=enums.CaptureMethod, - help_text=("Capture method of this PaymentIntent, one of automatic or manual."), - ) - client_secret = models.CharField( - max_length=255, - help_text=( - "The client secret of this PaymentIntent. Used for client-side retrieval using a " - "publishable key." - ), - ) - confirmation_method = StripeEnumField( - enum=enums.ConfirmationMethod, - help_text=("Confirmation method of this PaymentIntent, one of manual or automatic."), - ) - currency = StripeCurrencyCodeField() - customer = models.ForeignKey( - "Customer", - null=True, - on_delete=models.CASCADE, - help_text=("Customer this PaymentIntent is for if one exists."), - ) - description = models.TextField( - default="", - help_text=( - "An arbitrary string attached to the object. Often useful for displaying to users." - ), - ) - last_payment_error = JSONField( - help_text=( - "The payment error encountered in the previous PaymentIntent confirmation." - ) - ) - next_action = JSONField( - help_text=( - "If present, this property tells you what actions you need to take in order for your " - "customer to fulfill a payment using the provided source." - ) - ) - on_behalf_of = models.ForeignKey( - "Account", - on_delete=models.CASCADE, - null=True, - help_text="The account (if any) for which the funds of the PaymentIntent are intended.", - ) - payment_method = models.ForeignKey( - "PaymentMethod", - on_delete=models.SET_NULL, - null=True, - help_text=("Payment method used in this PaymentIntent."), - ) - payment_method_types = JSONField( - help_text=( - "The list of payment method types (e.g. card) that this PaymentIntent is allowed to " - "use." - ) - ) - receipt_email = models.CharField( - max_length=255, - help_text=( - "Email address that the receipt for the resulting payment will be sent to." - ), - ) - # TODO: Add `review` field after we add Review model. - setup_future_usage = StripeEnumField( - enum=enums.IntentUsage, - help_text=( - "Indicates that you intend to make future payments with this" - "PaymentIntent’s payment method." - "If present, the payment method used with this PaymentIntent can" - "be attached to a Customer, even after the transaction completes." - "Use `on_session` if you intend to only reuse the payment method" - "when your customer is present in your checkout flow. Use `off_session`" - "if your customer may or may not be in your checkout flow." - "Stripe uses `setup_future_usage` to dynamically optimize your payment flow and" - "comply with regional legislation and network rules. For example," - "if your customer is impacted by SCA, using `off_session` will" - "ensure that they are authenticated while processing this PaymentIntent." - "You will then be able to make later off-session payments for this customer." - ), - ) - shipping = JSONField( - null=True, blank=True, help_text=("Shipping information for this PaymentIntent.") - ) - statement_descriptor = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "Extra information about a PaymentIntent. This will appear on your customer’s " - "statement when this PaymentIntent succeeds in creating a charge." - ), - ) - status = StripeEnumField( - enum=enums.PaymentIntentStatus, - help_text=( - "Status of this PaymentIntent, one of requires_payment_method, requires_confirmation, " - "requires_action, processing, requires_capture, canceled, or succeeded. " - "You can read more about PaymentIntent statuses here." - ), - ) - transfer_data = JSONField( - null=True, - blank=True, - help_text=( - "The data with which to automatically create a Transfer when the payment is finalized. " - "See the PaymentIntents Connect usage guide for details." - ), - ) - transfer_group = models.CharField( - max_length=255, - help_text=( - "A string that identifies the resulting payment as part of a group. See the " - "PaymentIntents Connect usage guide for details." - ), - ) - - def update(self, api_key=None, **kwargs): - """ - Call the stripe API's modify operation for this model - - :param api_key: The api key to use for this request. Defaults to djstripe_settings.STRIPE_SECRET_KEY. - :type api_key: string - """ - api_key = api_key or self.default_api_key - - return self.api_retrieve(api_key=api_key).modify(**kwargs) - - def _api_cancel(self, api_key=None, **kwargs): - """ - Call the stripe API's cancel operation for this model - - :param api_key: The api key to use for this request. Defaults to djstripe_settings.STRIPE_SECRET_KEY. - :type api_key: string - """ - api_key = api_key or self.default_api_key - - return self.api_retrieve(api_key=api_key).cancel(**kwargs) - - def _api_confirm(self, api_key=None, **kwargs): - """ - Call the stripe API's confirm operation for this model. - - Confirm that your customer intends to pay with current or - provided payment method. Upon confirmation, the PaymentIntent - will attempt to initiate a payment. - - :param api_key: The api key to use for this request. Defaults to djstripe_settings.STRIPE_SECRET_KEY. - :type api_key: string - """ - api_key = api_key or self.default_api_key - - return self.api_retrieve(api_key=api_key).confirm(**kwargs) + """ + Stripe documentation: https://stripe.com/docs/api#payment_intents + """ + + stripe_class = stripe.PaymentIntent + stripe_dashboard_item_name = "payment intents" + + amount = StripeQuantumCurrencyAmountField( + help_text="Amount intended to be collected by this PaymentIntent." + ) + amount_capturable = StripeQuantumCurrencyAmountField( + help_text="Amount that can be captured from this PaymentIntent." + ) + amount_received = StripeQuantumCurrencyAmountField( + help_text="Amount that was collected by this PaymentIntent." + ) + # application + # application_fee_amount + canceled_at = models.DateTimeField( + null=True, + default=None, + help_text=( + "Populated when status is canceled, this is the time at which the " + "PaymentIntent was canceled. Measured in seconds since the Unix epoch." + ), + ) + cancellation_reason = models.CharField( + max_length=255, + null=True, + help_text=( + "User-given reason for cancellation of this PaymentIntent, " + "one of duplicate, fraudulent, requested_by_customer, or failed_invoice." + ), + ) + capture_method = StripeEnumField( + enum=enums.CaptureMethod, + help_text="Capture method of this PaymentIntent, one of automatic or manual.", + ) + client_secret = models.CharField( + max_length=255, + help_text=( + "The client secret of this PaymentIntent. " + "Used for client-side retrieval using a publishable key." + ), + ) + confirmation_method = StripeEnumField( + enum=enums.ConfirmationMethod, + help_text=( + "Confirmation method of this PaymentIntent, one of manual or automatic." + ), + ) + currency = StripeCurrencyCodeField() + customer = models.ForeignKey( + "Customer", + null=True, + on_delete=models.CASCADE, + help_text="Customer this PaymentIntent is for if one exists.", + ) + description = models.TextField( + default="", + help_text=( + "An arbitrary string attached to the object. " + "Often useful for displaying to users." + ), + ) + last_payment_error = JSONField( + help_text=( + "The payment error encountered in the previous PaymentIntent confirmation." + ) + ) + next_action = JSONField( + help_text=( + "If present, this property tells you what actions you need to take " + "in order for your customer to fulfill a payment using the provided source." + ) + ) + on_behalf_of = models.ForeignKey( + "Account", + on_delete=models.CASCADE, + null=True, + help_text="The account (if any) for which the funds of the " + "PaymentIntent are intended.", + ) + payment_method = models.ForeignKey( + "PaymentMethod", + on_delete=models.SET_NULL, + null=True, + help_text="Payment method used in this PaymentIntent.", + ) + payment_method_types = JSONField( + help_text=( + "The list of payment method types (e.g. card) that this " + "PaymentIntent is allowed to use." + ) + ) + receipt_email = models.CharField( + max_length=255, + help_text=( + "Email address that the receipt for the resulting payment will be sent to." + ), + ) + # TODO: Add `review` field after we add Review model. + setup_future_usage = StripeEnumField( + enum=enums.IntentUsage, + help_text=( + "Indicates that you intend to make future payments with this " + "PaymentIntent’s payment method. " + "If present, the payment method used with this PaymentIntent can " + "be attached to a Customer, even after the transaction completes. " + "Use `on_session` if you intend to only reuse the payment method " + "when your customer is present in your checkout flow. Use `off_session` " + "if your customer may or may not be in your checkout flow. " + "Stripe uses `setup_future_usage` to dynamically optimize " + "your payment flow and comply with regional legislation and network rules. " + "For example, if your customer is impacted by SCA, using `off_session` " + "will ensure that they are authenticated while processing this " + "PaymentIntent. You will then be able to make later off-session payments " + "for this customer." + ), + ) + shipping = JSONField( + null=True, blank=True, help_text="Shipping information for this PaymentIntent." + ) + statement_descriptor = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "Extra information about a PaymentIntent. " + "This will appear on your customer’s statement when this " + "PaymentIntent succeeds in creating a charge." + ), + ) + status = StripeEnumField( + enum=enums.PaymentIntentStatus, + help_text=( + "Status of this PaymentIntent, one of requires_payment_method, " + "requires_confirmation, requires_action, processing, requires_capture, " + "canceled, or succeeded. " + "You can read more about PaymentIntent statuses here." + ), + ) + transfer_data = JSONField( + null=True, + blank=True, + help_text=( + "The data with which to automatically create a Transfer when the payment " + "is finalized. " + "See the PaymentIntents Connect usage guide for details." + ), + ) + transfer_group = models.CharField( + max_length=255, + help_text=( + "A string that identifies the resulting payment as part of a group. " + "See the PaymentIntents Connect usage guide for details." + ), + ) + + def update(self, api_key=None, **kwargs): + """ + Call the stripe API's modify operation for this model + + :param api_key: The api key to use for this request. + Defaults to djstripe_settings.STRIPE_SECRET_KEY. + :type api_key: string + """ + api_key = api_key or self.default_api_key + + return self.api_retrieve(api_key=api_key).modify(**kwargs) + + def _api_cancel(self, api_key=None, **kwargs): + """ + Call the stripe API's cancel operation for this model + + :param api_key: The api key to use for this request. + Defaults to djstripe_settings.STRIPE_SECRET_KEY. + :type api_key: string + """ + api_key = api_key or self.default_api_key + + return self.api_retrieve(api_key=api_key).cancel(**kwargs) + + def _api_confirm(self, api_key=None, **kwargs): + """ + Call the stripe API's confirm operation for this model. + + Confirm that your customer intends to pay with current or + provided payment method. Upon confirmation, the PaymentIntent + will attempt to initiate a payment. + + :param api_key: The api key to use for this request. + Defaults to djstripe_settings.STRIPE_SECRET_KEY. + :type api_key: string + """ + api_key = api_key or self.default_api_key + + return self.api_retrieve(api_key=api_key).confirm(**kwargs) class SetupIntent(StripeModel): - """ - A SetupIntent guides you through the process of setting up a customer's payment credentials - for future payments. For example, you could use a SetupIntent to set up your customer's - card without immediately collecting a payment. Later, you can use PaymentIntents - to drive the payment flow. - - NOTE: You should not maintain long-lived, unconfirmed SetupIntents. - For security purposes, SetupIntents older than 24 hours may no longer be valid. - - Stripe documentation: https://stripe.com/docs/api#setup_intents - """ - - stripe_class = stripe.SetupIntent - stripe_dashboard_item_name = "setup intents" - - application = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=("ID of the Connect application that created the SetupIntent."), - ) - cancellation_reason = models.CharField( - max_length=255, - null=True, - help_text=( - "Reason for cancellation of this SetupIntent, one of abandoned, requested_by_customer, or duplicate" - ), - ) - client_secret = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "The client secret of this SetupIntent. Used for client-side retrieval using a publishable key." - ), - ) - customer = models.ForeignKey( - "Customer", - null=True, - on_delete=models.SET_NULL, - help_text=("Customer this SetupIntent belongs to, if one exists."), - ) - last_setup_error = JSONField( - null=True, - blank=True, - help_text=("The error encountered in the previous SetupIntent confirmation."), - ) - next_action = JSONField( - null=True, - blank=True, - help_text=( - "If present, this property tells you what actions you need to take in" - "order for your customer to continue payment setup." - ), - ) - on_behalf_of = models.ForeignKey( - "Account", - on_delete=models.SET_NULL, - null=True, - help_text="The account (if any) for which the setup is intended.", - ) - payment_method = models.ForeignKey( - "PaymentMethod", - on_delete=models.SET_NULL, - null=True, - help_text=("Payment method used in this PaymentIntent."), - ) - payment_method_types = JSONField( - help_text=( - "The list of payment method types (e.g. card) that this PaymentIntent is allowed to " - "use." - ) - ) - status = StripeEnumField( - enum=enums.SetupIntentStatus, - help_text=( - "Status of this SetupIntent, one of requires_payment_method, requires_confirmation," - "requires_action, processing, canceled, or succeeded." - ), - ) - usage = StripeEnumField( - enum=enums.IntentUsage, - default=enums.IntentUsage.off_session, - help_text=("Indicates how the payment method is intended to be used in the future."), - ) + """ + A SetupIntent guides you through the process of setting up a customer's + payment credentials for future payments. For example, you could use a SetupIntent + to set up your customer's card without immediately collecting a payment. + Later, you can use PaymentIntents to drive the payment flow. + + NOTE: You should not maintain long-lived, unconfirmed SetupIntents. + For security purposes, SetupIntents older than 24 hours may no longer be valid. + + Stripe documentation: https://stripe.com/docs/api#setup_intents + """ + + stripe_class = stripe.SetupIntent + stripe_dashboard_item_name = "setup intents" + + application = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="ID of the Connect application that created the SetupIntent.", + ) + cancellation_reason = models.CharField( + max_length=255, + null=True, + help_text=( + "Reason for cancellation of this SetupIntent, one of abandoned, " + "requested_by_customer, or duplicate" + ), + ) + client_secret = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=( + "The client secret of this SetupIntent. " + "Used for client-side retrieval using a publishable key." + ), + ) + customer = models.ForeignKey( + "Customer", + null=True, + on_delete=models.SET_NULL, + help_text="Customer this SetupIntent belongs to, if one exists.", + ) + last_setup_error = JSONField( + null=True, + blank=True, + help_text="The error encountered in the previous SetupIntent confirmation.", + ) + next_action = JSONField( + null=True, + blank=True, + help_text=( + "If present, this property tells you what actions you need to take in" + "order for your customer to continue payment setup." + ), + ) + on_behalf_of = models.ForeignKey( + "Account", + on_delete=models.SET_NULL, + null=True, + help_text="The account (if any) for which the setup is intended.", + ) + payment_method = models.ForeignKey( + "PaymentMethod", + on_delete=models.SET_NULL, + null=True, + help_text="Payment method used in this PaymentIntent.", + ) + payment_method_types = JSONField( + help_text=( + "The list of payment method types (e.g. card) that this PaymentIntent is " + "allowed to use." + ) + ) + status = StripeEnumField( + enum=enums.SetupIntentStatus, + help_text=( + "Status of this SetupIntent, one of requires_payment_method, " + "requires_confirmation, requires_action, processing, " + "canceled, or succeeded." + ), + ) + usage = StripeEnumField( + enum=enums.IntentUsage, + default=enums.IntentUsage.off_session, + help_text=( + "Indicates how the payment method is intended to be used in the future." + ), + ) class Payout(StripeModel): - """ - A Payout object is created when you receive funds from Stripe, or when you initiate - a payout to either a bank account or debit card of a connected Stripe account. - - Stripe documentation: https://stripe.com/docs/api#payouts - """ - - stripe_class = stripe.Payout - stripe_dashboard_item_name = "payouts" - - amount = StripeDecimalCurrencyAmountField( - help_text="Amount to be transferred to your bank account or debit card." - ) - arrival_date = StripeDateTimeField( - help_text=( - "Date the payout is expected to arrive in the bank. " - "This factors in delays like weekends or bank holidays." - ) - ) - balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.SET_NULL, - null=True, - help_text="Balance transaction that describes the impact on your account balance.", - ) - currency = StripeCurrencyCodeField() - destination = models.ForeignKey( - "BankAccount", - on_delete=models.PROTECT, - null=True, - help_text="Bank account or card the payout was sent to.", - ) - failure_balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.SET_NULL, - related_name="failure_payouts", - null=True, - help_text=( - "If the payout failed or was canceled, this will be the balance " - "transaction that reversed the initial balance transaction, and " - "puts the funds from the failed payout back in your balance." - ), - ) - failure_code = StripeEnumField( - enum=enums.PayoutFailureCode, - default="", - blank=True, - help_text="Error code explaining reason for transfer failure if available. " - "See https://stripe.com/docs/api/python#transfer_failures.", - ) - failure_message = models.TextField( - default="", - blank=True, - help_text="Message to user further explaining reason for payout failure if available.", - ) - method = StripeEnumField( - max_length=8, - enum=enums.PayoutMethod, - help_text=( - "The method used to send this payout. " - "`instant` is only supported for payouts to debit cards." - ), - ) - # TODO: source_type - statement_descriptor = models.CharField( - max_length=255, - default="", - blank=True, - help_text="Extra information about a payout to be displayed on the user's bank statement.", - ) - status = StripeEnumField( - enum=enums.PayoutStatus, - help_text=( - "Current status of the payout. " - "A payout will be `pending` until it is submitted to the bank, at which point it " - "becomes `in_transit`. It will then change to paid if the transaction goes through. " - "If it does not go through successfully, its status will change to `failed` or `canceled`." - ), - ) - type = StripeEnumField(enum=enums.PayoutType) + """ + A Payout object is created when you receive funds from Stripe, or when you initiate + a payout to either a bank account or debit card of a connected Stripe account. + + Stripe documentation: https://stripe.com/docs/api#payouts + """ + + stripe_class = stripe.Payout + stripe_dashboard_item_name = "payouts" + + amount = StripeDecimalCurrencyAmountField( + help_text="Amount to be transferred to your bank account or debit card." + ) + arrival_date = StripeDateTimeField( + help_text=( + "Date the payout is expected to arrive in the bank. " + "This factors in delays like weekends or bank holidays." + ) + ) + balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.SET_NULL, + null=True, + help_text="Balance transaction that describes the impact on your " + "account balance.", + ) + currency = StripeCurrencyCodeField() + destination = models.ForeignKey( + "BankAccount", + on_delete=models.PROTECT, + null=True, + help_text="Bank account or card the payout was sent to.", + ) + failure_balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.SET_NULL, + related_name="failure_payouts", + null=True, + help_text=( + "If the payout failed or was canceled, this will be the balance " + "transaction that reversed the initial balance transaction, and " + "puts the funds from the failed payout back in your balance." + ), + ) + failure_code = StripeEnumField( + enum=enums.PayoutFailureCode, + default="", + blank=True, + help_text="Error code explaining reason for transfer failure if available. " + "See https://stripe.com/docs/api/python#transfer_failures.", + ) + failure_message = models.TextField( + default="", + blank=True, + help_text="Message to user further explaining reason for " + "payout failure if available.", + ) + method = StripeEnumField( + max_length=8, + enum=enums.PayoutMethod, + help_text=( + "The method used to send this payout. " + "`instant` is only supported for payouts to debit cards." + ), + ) + # TODO: source_type + statement_descriptor = models.CharField( + max_length=255, + default="", + blank=True, + help_text="Extra information about a payout to be displayed " + "on the user's bank statement.", + ) + status = StripeEnumField( + enum=enums.PayoutStatus, + help_text=( + "Current status of the payout. " + "A payout will be `pending` until it is submitted to the bank, " + "at which point it becomes `in_transit`. " + "It will then change to paid if the transaction goes through. " + "If it does not go through successfully, " + "its status will change to `failed` or `canceled`." + ), + ) + type = StripeEnumField(enum=enums.PayoutType) class Product(StripeModel): - """ - Stripe documentation: - - https://stripe.com/docs/api#products - - https://stripe.com/docs/api#service_products - """ - - stripe_class = stripe.Product - stripe_dashboard_item_name = "products" - - # Fields applicable to both `good` and `service` - name = models.TextField( - max_length=5000, - help_text=( - "The product's name, meant to be displayable to the customer. " - "Applicable to both `service` and `good` types." - ), - ) - type = StripeEnumField( - enum=enums.ProductType, - help_text=( - "The type of the product. The product is either of type `good`, which is " - "eligible for use with Orders and SKUs, or `service`, which is eligible " - "for use with Subscriptions and Plans." - ), - ) - - # Fields applicable to `good` only - active = models.NullBooleanField( - help_text=( - "Whether the product is currently available for purchase. " - "Only applicable to products of `type=good`." - ) - ) - attributes = JSONField( - null=True, - blank=True, - help_text=( - "A list of up to 5 attributes that each SKU can provide values for " - '(e.g., `["color", "size"]`). Only applicable to products of `type=good`.' - ), - ) - caption = models.TextField( - default="", - blank=True, - max_length=5000, - help_text=( - "A short one-line description of the product, meant to be displayable" - "to the customer. Only applicable to products of `type=good`." - ), - ) - deactivate_on = JSONField( - null=True, - blank=True, - help_text=( - "An array of connect application identifiers that cannot purchase " - "this product. Only applicable to products of `type=good`." - ), - ) - images = JSONField( - null=True, - blank=True, - help_text=( - "A list of up to 8 URLs of images for this product, meant to be " - "displayable to the customer. Only applicable to products of `type=good`." - ), - ) - package_dimensions = JSONField( - null=True, - blank=True, - help_text=( - "The dimensions of this product for shipping purposes. " - "A SKU associated with this product can override this value by having its " - "own `package_dimensions`. Only applicable to products of `type=good`." - ), - ) - shippable = models.NullBooleanField( - null=True, - blank=True, - help_text=( - "Whether this product is a shipped good. " - "Only applicable to products of `type=good`." - ), - ) - url = models.CharField( - max_length=799, - null=True, - blank=True, - help_text=( - "A URL of a publicly-accessible webpage for this product. " - "Only applicable to products of `type=good`." - ), - ) - - # Fields available to `service` only - statement_descriptor = models.CharField( - max_length=22, - default="", - blank=True, - help_text=( - "Extra information about a product which will appear on your customer's " - "credit card statement. In the case that multiple products are billed at " - "once, the first statement descriptor will be used. " - "Only available on products of type=`service`." - ), - ) - unit_label = models.CharField(max_length=12, default="", blank=True) - - def __str__(self): - return self.name + """ + Stripe documentation: + - https://stripe.com/docs/api#products + - https://stripe.com/docs/api#service_products + """ + + stripe_class = stripe.Product + stripe_dashboard_item_name = "products" + + # Fields applicable to both `good` and `service` + name = models.TextField( + max_length=5000, + help_text=( + "The product's name, meant to be displayable to the customer. " + "Applicable to both `service` and `good` types." + ), + ) + type = StripeEnumField( + enum=enums.ProductType, + help_text=( + "The type of the product. The product is either of type `good`, which is " + "eligible for use with Orders and SKUs, or `service`, which is eligible " + "for use with Subscriptions and Plans." + ), + ) + + # Fields applicable to `good` only + active = models.NullBooleanField( + help_text=( + "Whether the product is currently available for purchase. " + "Only applicable to products of `type=good`." + ) + ) + attributes = JSONField( + null=True, + blank=True, + help_text=( + "A list of up to 5 attributes that each SKU can provide values for " + '(e.g., `["color", "size"]`). Only applicable to products of `type=good`.' + ), + ) + caption = models.TextField( + default="", + blank=True, + max_length=5000, + help_text=( + "A short one-line description of the product, meant to be displayable" + "to the customer. Only applicable to products of `type=good`." + ), + ) + deactivate_on = JSONField( + null=True, + blank=True, + help_text=( + "An array of connect application identifiers that cannot purchase " + "this product. Only applicable to products of `type=good`." + ), + ) + images = JSONField( + null=True, + blank=True, + help_text=( + "A list of up to 8 URLs of images for this product, meant to be " + "displayable to the customer. Only applicable to products of `type=good`." + ), + ) + package_dimensions = JSONField( + null=True, + blank=True, + help_text=( + "The dimensions of this product for shipping purposes. " + "A SKU associated with this product can override this value by having its " + "own `package_dimensions`. Only applicable to products of `type=good`." + ), + ) + shippable = models.NullBooleanField( + null=True, + blank=True, + help_text=( + "Whether this product is a shipped good. " + "Only applicable to products of `type=good`." + ), + ) + url = models.CharField( + max_length=799, + null=True, + blank=True, + help_text=( + "A URL of a publicly-accessible webpage for this product. " + "Only applicable to products of `type=good`." + ), + ) + + # Fields available to `service` only + statement_descriptor = models.CharField( + max_length=22, + default="", + blank=True, + help_text=( + "Extra information about a product which will appear on your customer's " + "credit card statement. In the case that multiple products are billed at " + "once, the first statement descriptor will be used. " + "Only available on products of type=`service`." + ), + ) + unit_label = models.CharField(max_length=12, default="", blank=True) + + def __str__(self): + return self.name class Refund(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#refund_object - """ - - stripe_class = stripe.Refund - - amount = StripeQuantumCurrencyAmountField(help_text="Amount, in cents.") - balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.SET_NULL, - null=True, - help_text="Balance transaction that describes the impact on your account balance.", - ) - charge = models.ForeignKey( - "Charge", - on_delete=models.CASCADE, - related_name="refunds", - help_text="The charge that was refunded", - ) - currency = StripeCurrencyCodeField() - failure_balance_transaction = models.ForeignKey( - "BalanceTransaction", - on_delete=models.SET_NULL, - related_name="failure_refunds", - null=True, - help_text=( - "If the refund failed, this balance transaction describes the adjustment " - "made on your account balance that reverses the initial balance transaction." - ), - ) - failure_reason = StripeEnumField( - enum=enums.RefundFailureReason, - default="", - blank=True, - help_text="If the refund failed, the reason for refund failure if known.", - ) - reason = StripeEnumField( - enum=enums.RefundReason, blank=True, default="", help_text="Reason for the refund." - ) - receipt_number = models.CharField( - max_length=9, - default="", - blank=True, - help_text=( - "The transaction number that appears on email receipts sent for this charge." - ), - ) - status = StripeEnumField( - enum=enums.RefundFailureReason, help_text="Status of the refund." - ) - - def get_stripe_dashboard_url(self): - return self.charge.get_stripe_dashboard_url() + """ + Stripe documentation: https://stripe.com/docs/api#refund_object + """ + + stripe_class = stripe.Refund + + amount = StripeQuantumCurrencyAmountField(help_text="Amount, in cents.") + balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.SET_NULL, + null=True, + help_text="Balance transaction that describes the impact on your account " + "balance.", + ) + charge = models.ForeignKey( + "Charge", + on_delete=models.CASCADE, + related_name="refunds", + help_text="The charge that was refunded", + ) + currency = StripeCurrencyCodeField() + failure_balance_transaction = models.ForeignKey( + "BalanceTransaction", + on_delete=models.SET_NULL, + related_name="failure_refunds", + null=True, + help_text="If the refund failed, this balance transaction describes the " + "adjustment made on your account balance that reverses the initial " + "balance transaction.", + ) + failure_reason = StripeEnumField( + enum=enums.RefundFailureReason, + default="", + blank=True, + help_text="If the refund failed, the reason for refund failure if known.", + ) + reason = StripeEnumField( + enum=enums.RefundReason, + blank=True, + default="", + help_text="Reason for the refund.", + ) + receipt_number = models.CharField( + max_length=9, + default="", + blank=True, + help_text="The transaction number that appears on email receipts sent " + "for this charge.", + ) + status = StripeEnumField( + enum=enums.RefundFailureReason, help_text="Status of the refund." + ) + + def get_stripe_dashboard_url(self): + return self.charge.get_stripe_dashboard_url() diff --git a/djstripe/models/payment_methods.py b/djstripe/models/payment_methods.py index 2b38b70a27..b7b7960af9 100644 --- a/djstripe/models/payment_methods.py +++ b/djstripe/models/payment_methods.py @@ -6,509 +6,524 @@ from .. import settings as djstripe_settings from ..exceptions import StripeObjectManipulationException from ..fields import ( - JSONField, StripeCurrencyCodeField, StripeDecimalCurrencyAmountField, StripeEnumField + JSONField, + StripeCurrencyCodeField, + StripeDecimalCurrencyAmountField, + StripeEnumField, ) from .base import StripeModel, logger from .core import Customer class DjstripePaymentMethod(models.Model): - """ - An internal model that abstracts the legacy Card and BankAccount - objects with Source objects. - - Contains two fields: `id` and `type`: - - `id` is the id of the Stripe object. - - `type` can be `card`, `bank_account` or `source`. - """ - - id = models.CharField(max_length=255, primary_key=True) - type = models.CharField(max_length=12, db_index=True) - - @classmethod - def from_stripe_object(cls, data): - source_type = data["object"] - model = cls._model_for_type(source_type) - - with transaction.atomic(): - model.sync_from_stripe_data(data) - instance, _ = cls.objects.get_or_create( - id=data["id"], defaults={"type": source_type} - ) - - return instance - - @classmethod - def _get_or_create_source(cls, data, source_type): - try: - model = cls._model_for_type(source_type) - model._get_or_create_from_stripe_object(data) - except ValueError as e: - # This may happen if we have source types we don't know about. - # Let's not make dj-stripe entirely unusable if that happens. - logger.warning("Could not sync source of type %r: %s", source_type, e) - - return cls.objects.get_or_create(id=data["id"], defaults={"type": source_type}) - - @classmethod - def _model_for_type(cls, type): - if type == "card": - return Card - elif type == "source": - return Source - elif type == "bank_account": - return BankAccount - - raise ValueError("Unknown source type: {}".format(type)) - - @property - def object_model(self): - return self._model_for_type(self.type) - - def resolve(self): - return self.object_model.objects.get(id=self.id) + """ + An internal model that abstracts the legacy Card and BankAccount + objects with Source objects. + + Contains two fields: `id` and `type`: + - `id` is the id of the Stripe object. + - `type` can be `card`, `bank_account` or `source`. + """ + + id = models.CharField(max_length=255, primary_key=True) + type = models.CharField(max_length=12, db_index=True) + + @classmethod + def from_stripe_object(cls, data): + source_type = data["object"] + model = cls._model_for_type(source_type) + + with transaction.atomic(): + model.sync_from_stripe_data(data) + instance, _ = cls.objects.get_or_create( + id=data["id"], defaults={"type": source_type} + ) + + return instance + + @classmethod + def _get_or_create_source(cls, data, source_type): + try: + model = cls._model_for_type(source_type) + model._get_or_create_from_stripe_object(data) + except ValueError as e: + # This may happen if we have source types we don't know about. + # Let's not make dj-stripe entirely unusable if that happens. + logger.warning("Could not sync source of type %r: %s", source_type, e) + + return cls.objects.get_or_create(id=data["id"], defaults={"type": source_type}) + + @classmethod + def _model_for_type(cls, type): + if type == "card": + return Card + elif type == "source": + return Source + elif type == "bank_account": + return BankAccount + + raise ValueError("Unknown source type: {}".format(type)) + + @property + def object_model(self): + return self._model_for_type(self.type) + + def resolve(self): + return self.object_model.objects.get(id=self.id) class BankAccount(StripeModel): - account = models.ForeignKey( - "Account", - on_delete=models.PROTECT, - related_name="bank_account", - help_text="The account the charge was made on behalf of. Null here indicates that this value was never set.", - ) - account_holder_name = models.TextField( - max_length=5000, - default="", - blank=True, - help_text="The name of the person or business that owns the bank account.", - ) - account_holder_type = StripeEnumField( - enum=enums.BankAccountHolderType, - help_text="The type of entity that holds the account.", - ) - bank_name = models.CharField( - max_length=255, - help_text="Name of the bank associated with the routing number (e.g., `WELLS FARGO`).", - ) - country = models.CharField( - max_length=2, - help_text="Two-letter ISO code representing the country the bank account is located in.", - ) - currency = StripeCurrencyCodeField() - customer = models.ForeignKey( - "Customer", on_delete=models.SET_NULL, null=True, related_name="bank_account" - ) - default_for_currency = models.NullBooleanField( - help_text="Whether this external account is the default account for its currency." - ) - fingerprint = models.CharField( - max_length=16, - help_text=( - "Uniquely identifies this particular bank account. " - "You can use this attribute to check whether two bank accounts are the same." - ), - ) - last4 = models.CharField(max_length=4) - routing_number = models.CharField( - max_length=255, help_text="The routing transit number for the bank account." - ) - status = StripeEnumField(enum=enums.BankAccountStatus) + account = models.ForeignKey( + "Account", + on_delete=models.PROTECT, + related_name="bank_account", + help_text="The account the charge was made on behalf of. Null here indicates " + "that this value was never set.", + ) + account_holder_name = models.TextField( + max_length=5000, + default="", + blank=True, + help_text="The name of the person or business that owns the bank account.", + ) + account_holder_type = StripeEnumField( + enum=enums.BankAccountHolderType, + help_text="The type of entity that holds the account.", + ) + bank_name = models.CharField( + max_length=255, + help_text="Name of the bank associated with the routing number " + "(e.g., `WELLS FARGO`).", + ) + country = models.CharField( + max_length=2, + help_text="Two-letter ISO code representing the country the bank account " + "is located in.", + ) + currency = StripeCurrencyCodeField() + customer = models.ForeignKey( + "Customer", on_delete=models.SET_NULL, null=True, related_name="bank_account" + ) + default_for_currency = models.NullBooleanField( + help_text="Whether this external account is the default account for " + "its currency." + ) + fingerprint = models.CharField( + max_length=16, + help_text=( + "Uniquely identifies this particular bank account. " + "You can use this attribute to check whether two bank accounts are " + "the same." + ), + ) + last4 = models.CharField(max_length=4) + routing_number = models.CharField( + max_length=255, help_text="The routing transit number for the bank account." + ) + status = StripeEnumField(enum=enums.BankAccountStatus) class Card(StripeModel): - """ - You can store multiple cards on a customer in order to charge the customer later. - - This is a legacy model which only applies to the "v2" Stripe API (eg. Checkout.js). - You should strive to use the Stripe "v3" API (eg. Stripe Elements). - Also see: https://stripe.com/docs/stripe-js/elements/migrating - When using Elements, you will not be using Card objects. Instead, you will use - Source objects. - A Source object of type "card" is equivalent to a Card object. However, Card - objects cannot be converted into Source objects by Stripe at this time. - - Stripe documentation: https://stripe.com/docs/api/python#cards - """ - - stripe_class = stripe.Card - - address_city = models.TextField( - max_length=5000, - blank=True, - default="", - help_text="City/District/Suburb/Town/Village.", - ) - address_country = models.TextField( - max_length=5000, blank=True, default="", help_text="Billing address country." - ) - address_line1 = models.TextField( - max_length=5000, - blank=True, - default="", - help_text="Street address/PO Box/Company name.", - ) - address_line1_check = StripeEnumField( - enum=enums.CardCheckResult, - blank=True, - default="", - help_text="If `address_line1` was provided, results of the check.", - ) - address_line2 = models.TextField( - max_length=5000, blank=True, default="", help_text="Apartment/Suite/Unit/Building." - ) - address_state = models.TextField( - max_length=5000, blank=True, default="", help_text="State/County/Province/Region." - ) - address_zip = models.TextField( - max_length=5000, blank=True, default="", help_text="ZIP or postal code." - ) - address_zip_check = StripeEnumField( - enum=enums.CardCheckResult, - blank=True, - default="", - help_text="If `address_zip` was provided, results of the check.", - ) - brand = StripeEnumField(enum=enums.CardBrand, help_text="Card brand.") - country = models.CharField( - max_length=2, - default="", - blank=True, - help_text="Two-letter ISO code representing the country of the card.", - ) - customer = models.ForeignKey( - "Customer", on_delete=models.SET_NULL, null=True, related_name="legacy_cards" - ) - cvc_check = StripeEnumField( - enum=enums.CardCheckResult, - default="", - blank=True, - help_text="If a CVC was provided, results of the check.", - ) - dynamic_last4 = models.CharField( - max_length=4, - default="", - blank=True, - help_text="(For tokenized numbers only.) The last four digits of the device account number.", - ) - exp_month = models.IntegerField(help_text="Card expiration month.") - exp_year = models.IntegerField(help_text="Card expiration year.") - fingerprint = models.CharField( - default="", - blank=True, - max_length=16, - help_text="Uniquely identifies this particular card number.", - ) - funding = StripeEnumField(enum=enums.CardFundingType, help_text="Card funding type.") - last4 = models.CharField(max_length=4, help_text="Last four digits of Card number.") - name = models.TextField( - max_length=5000, default="", blank=True, help_text="Cardholder name." - ) - tokenization_method = StripeEnumField( - enum=enums.CardTokenizationMethod, - default="", - blank=True, - help_text="If the card number is tokenized, this is the method that was used.", - ) - - @staticmethod - def _get_customer_from_kwargs(**kwargs): - if "customer" not in kwargs or not isinstance(kwargs["customer"], Customer): - raise StripeObjectManipulationException( - "Cards must be manipulated through a Customer. " - "Pass a Customer object into this call." - ) - - customer = kwargs["customer"] - del kwargs["customer"] - - return customer, kwargs - - @classmethod - def _api_create(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs): - # OVERRIDING the parent version of this function - # Cards must be manipulated through a customer or account. - # TODO: When managed accounts are supported, this method needs to - # check if either a customer or account is supplied to determine - # the correct object to use. - - customer, clean_kwargs = cls._get_customer_from_kwargs(**kwargs) - - return customer.api_retrieve().sources.create(api_key=api_key, **clean_kwargs) - - @classmethod - def api_list(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs): - # OVERRIDING the parent version of this function - # Cards must be manipulated through a customer or account. - # TODO: When managed accounts are supported, this method needs to - # check if either a customer or account is supplied to determine - # the correct object to use. - - customer, clean_kwargs = cls._get_customer_from_kwargs(**kwargs) - - return ( - customer.api_retrieve(api_key=api_key) - .sources.list(object="card", **clean_kwargs) - .auto_paging_iter() - ) - - def get_stripe_dashboard_url(self): - return self.customer.get_stripe_dashboard_url() - - def remove(self): - """ - Removes a card from this customer's account. - """ - - # First, wipe default source on all customers that use this card. - Customer.objects.filter(default_source=self.id).update(default_source=None) - - try: - self._api_delete() - except InvalidRequestError as exc: - if "No such source:" in str(exc) or "No such customer:" in str(exc): - # The exception was thrown because the stripe customer or card was already - # deleted on the stripe side, ignore the exception - pass - else: - # The exception was raised for another reason, re-raise it - raise - - self.delete() - - def api_retrieve(self, api_key=None): - # OVERRIDING the parent version of this function - # Cards must be manipulated through a customer or account. - # TODO: When managed accounts are supported, this method needs to check if - # either a customer or account is supplied to determine the correct object to use. - api_key = api_key or self.default_api_key - customer = self.customer.api_retrieve(api_key=api_key) - - # If the customer is deleted, the sources attribute will be absent. - # eg. {"id": "cus_XXXXXXXX", "deleted": True} - if "sources" not in customer: - # We fake a native stripe InvalidRequestError so that it's caught like an invalid ID error. - raise InvalidRequestError("No such source: %s" % (self.id), "id") - - return customer.sources.retrieve(self.id, expand=self.expand_fields) - - def str_parts(self): - return [ - "brand={brand}".format(brand=self.brand), - "last4={last4}".format(last4=self.last4), - "exp_month={exp_month}".format(exp_month=self.exp_month), - "exp_year={exp_year}".format(exp_year=self.exp_year), - ] + super().str_parts() - - @classmethod - def create_token( - cls, - number, - exp_month, - exp_year, - cvc, - api_key=djstripe_settings.STRIPE_SECRET_KEY, - **kwargs - ): - """ - Creates a single use token that wraps the details of a credit card. This token can be used in - place of a credit card dictionary with any API method. These tokens can only be used once: by - creating a new charge object, or attaching them to a customer. - (Source: https://stripe.com/docs/api/python#create_card_token) - - :param exp_month: The card's expiration month. - :type exp_month: Two digit int - :param exp_year: The card's expiration year. - :type exp_year: Two or Four digit int - :param number: The card number - :type number: string without any separators (no spaces) - :param cvc: Card security code. - :type cvc: string - """ - - card = {"number": number, "exp_month": exp_month, "exp_year": exp_year, "cvc": cvc} - card.update(kwargs) - - return stripe.Token.create(api_key=api_key, card=card) + """ + You can store multiple cards on a customer in order to charge the customer later. + + This is a legacy model which only applies to the "v2" Stripe API (eg. Checkout.js). + You should strive to use the Stripe "v3" API (eg. Stripe Elements). + Also see: https://stripe.com/docs/stripe-js/elements/migrating + When using Elements, you will not be using Card objects. Instead, you will use + Source objects. + A Source object of type "card" is equivalent to a Card object. However, Card + objects cannot be converted into Source objects by Stripe at this time. + + Stripe documentation: https://stripe.com/docs/api/python#cards + """ + + stripe_class = stripe.Card + + address_city = models.TextField( + max_length=5000, + blank=True, + default="", + help_text="City/District/Suburb/Town/Village.", + ) + address_country = models.TextField( + max_length=5000, blank=True, default="", help_text="Billing address country." + ) + address_line1 = models.TextField( + max_length=5000, + blank=True, + default="", + help_text="Street address/PO Box/Company name.", + ) + address_line1_check = StripeEnumField( + enum=enums.CardCheckResult, + blank=True, + default="", + help_text="If `address_line1` was provided, results of the check.", + ) + address_line2 = models.TextField( + max_length=5000, + blank=True, + default="", + help_text="Apartment/Suite/Unit/Building.", + ) + address_state = models.TextField( + max_length=5000, + blank=True, + default="", + help_text="State/County/Province/Region.", + ) + address_zip = models.TextField( + max_length=5000, blank=True, default="", help_text="ZIP or postal code." + ) + address_zip_check = StripeEnumField( + enum=enums.CardCheckResult, + blank=True, + default="", + help_text="If `address_zip` was provided, results of the check.", + ) + brand = StripeEnumField(enum=enums.CardBrand, help_text="Card brand.") + country = models.CharField( + max_length=2, + default="", + blank=True, + help_text="Two-letter ISO code representing the country of the card.", + ) + customer = models.ForeignKey( + "Customer", on_delete=models.SET_NULL, null=True, related_name="legacy_cards" + ) + cvc_check = StripeEnumField( + enum=enums.CardCheckResult, + default="", + blank=True, + help_text="If a CVC was provided, results of the check.", + ) + dynamic_last4 = models.CharField( + max_length=4, + default="", + blank=True, + help_text="(For tokenized numbers only.) The last four digits of the device " + "account number.", + ) + exp_month = models.IntegerField(help_text="Card expiration month.") + exp_year = models.IntegerField(help_text="Card expiration year.") + fingerprint = models.CharField( + default="", + blank=True, + max_length=16, + help_text="Uniquely identifies this particular card number.", + ) + funding = StripeEnumField( + enum=enums.CardFundingType, help_text="Card funding type." + ) + last4 = models.CharField(max_length=4, help_text="Last four digits of Card number.") + name = models.TextField( + max_length=5000, default="", blank=True, help_text="Cardholder name." + ) + tokenization_method = StripeEnumField( + enum=enums.CardTokenizationMethod, + default="", + blank=True, + help_text="If the card number is tokenized, this is the method that was used.", + ) + + @staticmethod + def _get_customer_from_kwargs(**kwargs): + if "customer" not in kwargs or not isinstance(kwargs["customer"], Customer): + raise StripeObjectManipulationException( + "Cards must be manipulated through a Customer. " + "Pass a Customer object into this call." + ) + + customer = kwargs["customer"] + del kwargs["customer"] + + return customer, kwargs + + @classmethod + def _api_create(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs): + # OVERRIDING the parent version of this function + # Cards must be manipulated through a customer or account. + # TODO: When managed accounts are supported, this method needs to + # check if either a customer or account is supplied to determine + # the correct object to use. + + customer, clean_kwargs = cls._get_customer_from_kwargs(**kwargs) + + return customer.api_retrieve().sources.create(api_key=api_key, **clean_kwargs) + + @classmethod + def api_list(cls, api_key=djstripe_settings.STRIPE_SECRET_KEY, **kwargs): + # OVERRIDING the parent version of this function + # Cards must be manipulated through a customer or account. + # TODO: When managed accounts are supported, this method needs to + # check if either a customer or account is supplied to determine + # the correct object to use. + + customer, clean_kwargs = cls._get_customer_from_kwargs(**kwargs) + + return ( + customer.api_retrieve(api_key=api_key) + .sources.list(object="card", **clean_kwargs) + .auto_paging_iter() + ) + + def get_stripe_dashboard_url(self): + return self.customer.get_stripe_dashboard_url() + + def remove(self): + """ + Removes a card from this customer's account. + """ + + # First, wipe default source on all customers that use this card. + Customer.objects.filter(default_source=self.id).update(default_source=None) + + try: + self._api_delete() + except InvalidRequestError as exc: + if "No such source:" in str(exc) or "No such customer:" in str(exc): + # The exception was thrown because the stripe customer or card + # was already deleted on the stripe side, ignore the exception + pass + else: + # The exception was raised for another reason, re-raise it + raise + + self.delete() + + def api_retrieve(self, api_key=None): + # OVERRIDING the parent version of this function + # Cards must be manipulated through a customer or account. + # TODO: When managed accounts are supported, this method needs to check if + # either a customer or account is supplied to determine the + # correct object to use. + api_key = api_key or self.default_api_key + customer = self.customer.api_retrieve(api_key=api_key) + + # If the customer is deleted, the sources attribute will be absent. + # eg. {"id": "cus_XXXXXXXX", "deleted": True} + if "sources" not in customer: + # We fake a native stripe InvalidRequestError so that it's caught + # like an invalid ID error. + raise InvalidRequestError("No such source: %s" % (self.id), "id") + + return customer.sources.retrieve(self.id, expand=self.expand_fields) + + def str_parts(self): + return [ + "brand={brand}".format(brand=self.brand), + "last4={last4}".format(last4=self.last4), + "exp_month={exp_month}".format(exp_month=self.exp_month), + "exp_year={exp_year}".format(exp_year=self.exp_year), + ] + super().str_parts() + + @classmethod + def create_token( + cls, + number, + exp_month, + exp_year, + cvc, + api_key=djstripe_settings.STRIPE_SECRET_KEY, + **kwargs + ): + """ + Creates a single use token that wraps the details of a credit card. + This token can be used in place of a credit card dictionary with any API method. + These tokens can only be used once: by creating a new charge object, + or attaching them to a customer. + (Source: https://stripe.com/docs/api/python#create_card_token) + + :param exp_month: The card's expiration month. + :type exp_month: Two digit int + :param exp_year: The card's expiration year. + :type exp_year: Two or Four digit int + :param number: The card number + :type number: string without any separators (no spaces) + :param cvc: Card security code. + :type cvc: string + """ + + card = { + "number": number, + "exp_month": exp_month, + "exp_year": exp_year, + "cvc": cvc, + } + card.update(kwargs) + + return stripe.Token.create(api_key=api_key, card=card) class Source(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#sources - """ - - amount = StripeDecimalCurrencyAmountField( - null=True, - blank=True, - help_text=( - "Amount associated with the source. " - "This is the amount for which the source will be chargeable once ready. " - "Required for `single_use` sources." - ), - ) - client_secret = models.CharField( - max_length=255, - help_text=( - "The client secret of the source. " - "Used for client-side retrieval using a publishable key." - ), - ) - currency = StripeCurrencyCodeField(default="", blank=True) - flow = StripeEnumField( - enum=enums.SourceFlow, help_text="The authentication flow of the source." - ) - owner = JSONField( - help_text=( - "Information about the owner of the payment instrument that may be " - "used or required by particular source types." - ) - ) - statement_descriptor = models.CharField( - max_length=255, - default="", - blank=True, - help_text=( - "Extra information about a source. " - "This will appear on your customer's statement every time you charge the source." - ), - ) - status = StripeEnumField( - enum=enums.SourceStatus, - help_text=( - "The status of the source. Only `chargeable` sources can be used to create a charge." - ), - ) - type = StripeEnumField(enum=enums.SourceType, help_text="The type of the source.") - usage = StripeEnumField( - enum=enums.SourceUsage, - help_text=( - "Whether this source should be reusable or not. " - "Some source types may or may not be reusable by construction, " - "while other may leave the option at creation." - ), - ) - - # Flows - code_verification = JSONField( - null=True, - blank=True, - help_text=( - "Information related to the code verification flow. " - "Present if the source is authenticated by a verification code (`flow` is `code_verification`)." - ), - ) - receiver = JSONField( - null=True, - blank=True, - help_text=( - "Information related to the receiver flow. " - "Present if the source is a receiver (`flow` is `receiver`)." - ), - ) - redirect = JSONField( - null=True, - blank=True, - help_text=( - "Information related to the redirect flow. " - "Present if the source is authenticated by a redirect (`flow` is `redirect`)." - ), - ) - - source_data = JSONField(help_text=("The data corresponding to the source type.")) - - customer = models.ForeignKey( - "Customer", on_delete=models.SET_NULL, null=True, blank=True, related_name="sources" - ) - - stripe_class = stripe.Source - stripe_dashboard_item_name = "sources" - - @classmethod - def _manipulate_stripe_object_hook(cls, data): - # The source_data dict is an alias of all the source types - data["source_data"] = data[data["type"]] - return data - - def _attach_objects_hook(self, cls, data): - customer = cls._stripe_object_to_customer(target_cls=Customer, data=data) - if customer: - self.customer = customer - else: - self.customer = None - - def detach(self): - """ - Detach the source from its customer. - """ - - # First, wipe default source on all customers that use this. - Customer.objects.filter(default_source=self.id).update(default_source=None) - - try: - # TODO - we could use the return value of sync_from_stripe_data - # or call its internals - self._sync/_attach_objects_hook etc here - # to update `self` at this point? - self.sync_from_stripe_data(self.api_retrieve().detach()) - return True - except (InvalidRequestError, NotImplementedError): - # The source was already detached. Resyncing. - # NotImplementedError is an artifact of stripe-python<2.0 - # https://github.com/stripe/stripe-python/issues/376 - self.sync_from_stripe_data(self.api_retrieve()) - return False + """ + Stripe documentation: https://stripe.com/docs/api#sources + """ + + amount = StripeDecimalCurrencyAmountField( + null=True, + blank=True, + help_text=( + "Amount associated with the source. " + "This is the amount for which the source will be chargeable once ready. " + "Required for `single_use` sources." + ), + ) + client_secret = models.CharField( + max_length=255, + help_text=( + "The client secret of the source. " + "Used for client-side retrieval using a publishable key." + ), + ) + currency = StripeCurrencyCodeField(default="", blank=True) + flow = StripeEnumField( + enum=enums.SourceFlow, help_text="The authentication flow of the source." + ) + owner = JSONField( + help_text=( + "Information about the owner of the payment instrument that may be " + "used or required by particular source types." + ) + ) + statement_descriptor = models.CharField( + max_length=255, + default="", + blank=True, + help_text="Extra information about a source. This will appear on your " + "customer's statement every time you charge the source.", + ) + status = StripeEnumField( + enum=enums.SourceStatus, + help_text="The status of the source. Only `chargeable` sources can be used " + "to create a charge.", + ) + type = StripeEnumField(enum=enums.SourceType, help_text="The type of the source.") + usage = StripeEnumField( + enum=enums.SourceUsage, + help_text="Whether this source should be reusable or not. " + "Some source types may or may not be reusable by construction, " + "while other may leave the option at creation.", + ) + + # Flows + code_verification = JSONField( + null=True, + blank=True, + help_text="Information related to the code verification flow. " + "Present if the source is authenticated by a verification code " + "(`flow` is `code_verification`).", + ) + receiver = JSONField( + null=True, + blank=True, + help_text="Information related to the receiver flow. " + "Present if the source is a receiver (`flow` is `receiver`).", + ) + redirect = JSONField( + null=True, + blank=True, + help_text="Information related to the redirect flow. " + "Present if the source is authenticated by a redirect (`flow` is `redirect`).", + ) + + source_data = JSONField(help_text="The data corresponding to the source type.") + + customer = models.ForeignKey( + "Customer", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sources", + ) + + stripe_class = stripe.Source + stripe_dashboard_item_name = "sources" + + @classmethod + def _manipulate_stripe_object_hook(cls, data): + # The source_data dict is an alias of all the source types + data["source_data"] = data[data["type"]] + return data + + def _attach_objects_hook(self, cls, data): + customer = cls._stripe_object_to_customer(target_cls=Customer, data=data) + if customer: + self.customer = customer + else: + self.customer = None + + def detach(self): + """ + Detach the source from its customer. + """ + + # First, wipe default source on all customers that use this. + Customer.objects.filter(default_source=self.id).update(default_source=None) + + try: + # TODO - we could use the return value of sync_from_stripe_data + # or call its internals - self._sync/_attach_objects_hook etc here + # to update `self` at this point? + self.sync_from_stripe_data(self.api_retrieve().detach()) + return True + except (InvalidRequestError, NotImplementedError): + # The source was already detached. Resyncing. + # NotImplementedError is an artifact of stripe-python<2.0 + # https://github.com/stripe/stripe-python/issues/376 + self.sync_from_stripe_data(self.api_retrieve()) + return False class PaymentMethod(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#payment_methods - """ - - billing_details = JSONField( - help_text=( - "Billing information associated with the PaymentMethod that may be used or " - "required by particular types of payment methods." - ) - ) - card = JSONField( - help_text=( - "If this is a card PaymentMethod, this hash contains details about the card." - ) - ) - card_present = JSONField( - help_text=( - "If this is an card_present PaymentMethod, this hash contains details about " - "the Card Present payment method." - ) - ) - customer = models.ForeignKey( - "Customer", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="payment_methods", - help_text=( - "Customer to which this PaymentMethod is saved." - "This will not be set when the PaymentMethod has not been saved to a Customer." - ), - ) - type = models.CharField( - max_length=255, - null=True, - blank=True, - help_text=( - "The type of the PaymentMethod. An additional hash is included on the PaymentMethod" - "with a name matching this value. It contains additional information specific to the" - "PaymentMethod type." - ), - ) - - stripe_class = stripe.PaymentMethod - stripe_dashboard_item_name = "payment methods" - - @classmethod - def attach( - cls, payment_method_id, stripe_customer, api_key=djstripe_settings.STRIPE_SECRET_KEY - ): - stripe_payment_method = stripe.PaymentMethod.attach( - payment_method_id, customer=stripe_customer["id"], api_key=api_key - ) - return cls.sync_from_stripe_data(stripe_payment_method) + """ + Stripe documentation: https://stripe.com/docs/api#payment_methods + """ + + billing_details = JSONField( + help_text=( + "Billing information associated with the PaymentMethod that may be used or " + "required by particular types of payment methods." + ) + ) + card = JSONField( + help_text="If this is a card PaymentMethod, this hash contains details " + "about the card." + ) + card_present = JSONField( + help_text="If this is an card_present PaymentMethod, this hash contains " + "details about the Card Present payment method." + ) + customer = models.ForeignKey( + "Customer", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="payment_methods", + help_text="Customer to which this PaymentMethod is saved." + "This will not be set when the PaymentMethod has not been saved to a Customer.", + ) + type = models.CharField( + max_length=255, + null=True, + blank=True, + help_text="The type of the PaymentMethod. An additional hash is included " + "on the PaymentMethod with a name matching this value. It contains additional " + "information specific to the PaymentMethod type.", + ) + + stripe_class = stripe.PaymentMethod + stripe_dashboard_item_name = "payment methods" + + @classmethod + def attach( + cls, + payment_method_id, + stripe_customer, + api_key=djstripe_settings.STRIPE_SECRET_KEY, + ): + stripe_payment_method = stripe.PaymentMethod.attach( + payment_method_id, customer=stripe_customer["id"], api_key=api_key + ) + return cls.sync_from_stripe_data(stripe_payment_method) diff --git a/djstripe/models/sigma.py b/djstripe/models/sigma.py index 57dd662453..aa6bc12a51 100644 --- a/djstripe/models/sigma.py +++ b/djstripe/models/sigma.py @@ -7,32 +7,35 @@ class ScheduledQueryRun(StripeModel): - """ - Stripe documentation: https://stripe.com/docs/api#scheduled_queries - """ + """ + Stripe documentation: https://stripe.com/docs/api#scheduled_queries + """ - stripe_class = stripe.sigma.ScheduledQueryRun + stripe_class = stripe.sigma.ScheduledQueryRun - data_load_time = StripeDateTimeField( - help_text="When the query was run, Sigma contained a snapshot of your Stripe data at this time." - ) - error = JSONField( - null=True, - blank=True, - help_text="If the query run was not succeesful, contains information about the failure.", - ) - file = models.ForeignKey( - "FileUpload", - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="The file object representing the results of the query.", - ) - result_available_until = StripeDateTimeField( - help_text="Time at which the result expires and is no longer available for download." - ) - sql = models.TextField(max_length=5000, help_text="SQL for the query.") - status = StripeEnumField( - enum=enums.ScheduledQueryRunStatus, help_text="The query's execution status." - ) - title = models.TextField(max_length=5000, help_text="Title of the query.") + data_load_time = StripeDateTimeField( + help_text="When the query was run, Sigma contained a snapshot of your " + "Stripe data at this time." + ) + error = JSONField( + null=True, + blank=True, + help_text="If the query run was not succeesful, contains information " + "about the failure.", + ) + file = models.ForeignKey( + "FileUpload", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="The file object representing the results of the query.", + ) + result_available_until = StripeDateTimeField( + help_text="Time at which the result expires and is no longer available " + "for download." + ) + sql = models.TextField(max_length=5000, help_text="SQL for the query.") + status = StripeEnumField( + enum=enums.ScheduledQueryRunStatus, help_text="The query's execution status." + ) + title = models.TextField(max_length=5000, help_text="Title of the query.") diff --git a/djstripe/models/webhooks.py b/djstripe/models/webhooks.py index 252df38551..069806c4b2 100644 --- a/djstripe/models/webhooks.py +++ b/djstripe/models/webhooks.py @@ -16,169 +16,177 @@ def _get_version(): - from .. import __version__ + from .. import __version__ - return __version__ + return __version__ class WebhookEventTrigger(models.Model): - """ - An instance of a request that reached the server endpoint for Stripe webhooks. - - Webhook Events are initially **UNTRUSTED**, as it is possible for any web entity to - post any data to our webhook url. Data posted may be valid Stripe information, garbage, or even malicious. - The 'valid' flag in this model monitors this. - """ - - id = models.BigAutoField(primary_key=True) - remote_ip = models.GenericIPAddressField(help_text="IP address of the request client.") - headers = JSONField() - body = models.TextField(blank=True) - valid = models.BooleanField( - default=False, help_text="Whether or not the webhook event has passed validation" - ) - processed = models.BooleanField( - default=False, - help_text="Whether or not the webhook event has been successfully processed", - ) - exception = models.CharField(max_length=128, blank=True) - traceback = models.TextField( - blank=True, help_text="Traceback if an exception was thrown during processing" - ) - event = models.ForeignKey( - "Event", - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text="Event object contained in the (valid) Webhook", - ) - djstripe_version = models.CharField( - max_length=32, - default=_get_version, # Needs to be a callable, otherwise it's a db default. - help_text="The version of dj-stripe when the webhook was received", - ) - created = models.DateTimeField(auto_now_add=True) - updated = models.DateTimeField(auto_now=True) - - @classmethod - def from_request(cls, request): - """ - Create, validate and process a WebhookEventTrigger given a Django - request object. - - The process is three-fold: - 1. Create a WebhookEventTrigger object from a Django request. - 2. Validate the WebhookEventTrigger as a Stripe event using the API. - 3. If valid, process it into an Event object (and child resource). - """ - - headers = fix_django_headers(request.META) - assert headers - try: - body = request.body.decode(request.encoding or "utf-8") - except Exception: - body = "(error decoding body)" - - ip = request.META.get("REMOTE_ADDR") - if not ip: - warnings.warn( - "Could not determine remote IP (missing REMOTE_ADDR). " - "This is likely an issue with your wsgi/server setup." - ) - ip = "0.0.0.0" - obj = cls.objects.create(headers=headers, body=body, remote_ip=ip) - - try: - obj.valid = obj.validate() - if obj.valid: - if djstripe_settings.WEBHOOK_EVENT_CALLBACK: - # If WEBHOOK_EVENT_CALLBACK, pass it for processing - djstripe_settings.WEBHOOK_EVENT_CALLBACK(obj) - else: - # Process the item (do not save it, it'll get saved below) - obj.process(save=False) - except Exception as e: - max_length = WebhookEventTrigger._meta.get_field("exception").max_length - obj.exception = str(e)[:max_length] - obj.traceback = format_exc() - - # Send the exception as the webhook_processing_error signal - webhook_processing_error.send( - sender=WebhookEventTrigger, exception=e, data=getattr(e, "http_body", "") - ) - - # re-raise the exception so Django sees it - raise e - finally: - obj.save() - - return obj - - @cached_property - def json_body(self): - try: - return json.loads(self.body) - except ValueError: - return {} - - @property - def is_test_event(self): - event_id = self.json_body.get("id") - return event_id and event_id.endswith("_00000000000000") - - def validate(self, api_key=None): - """ - The original contents of the Event message must be confirmed by - refetching it and comparing the fetched data with the original data. - - This function makes an API call to Stripe to redownload the Event data - and returns whether or not it matches the WebhookEventTrigger data. - """ - - local_data = self.json_body - if "id" not in local_data or "livemode" not in local_data: - return False - - if self.is_test_event: - logger.info("Test webhook received: {}".format(local_data)) - return False - - if djstripe_settings.WEBHOOK_VALIDATION is None: - # validation disabled - return True - elif ( - djstripe_settings.WEBHOOK_VALIDATION == "verify_signature" - and djstripe_settings.WEBHOOK_SECRET - ): - try: - stripe.WebhookSignature.verify_header( - self.body, - self.headers.get("stripe-signature"), - djstripe_settings.WEBHOOK_SECRET, - djstripe_settings.WEBHOOK_TOLERANCE, - ) - except stripe.error.SignatureVerificationError: - return False - else: - return True - - livemode = local_data["livemode"] - api_key = api_key or djstripe_settings.get_default_api_key(livemode) - - # Retrieve the event using the api_version specified in itself - with stripe_temporary_api_version(local_data["api_version"], validate=False): - remote_data = Event.stripe_class.retrieve(id=local_data["id"], api_key=api_key) - - return local_data["data"] == remote_data["data"] - - def process(self, save=True): - # Reset traceback and exception in case of reprocessing - self.exception = "" - self.traceback = "" - - self.event = Event.process(self.json_body) - self.processed = True - if save: - self.save() - - return self.event + """ + An instance of a request that reached the server endpoint for Stripe webhooks. + + Webhook Events are initially **UNTRUSTED**, as it is possible for any web entity to + post any data to our webhook url. Data posted may be valid Stripe information, + garbage, or even malicious. + The 'valid' flag in this model monitors this. + """ + + id = models.BigAutoField(primary_key=True) + remote_ip = models.GenericIPAddressField( + help_text="IP address of the request client." + ) + headers = JSONField() + body = models.TextField(blank=True) + valid = models.BooleanField( + default=False, + help_text="Whether or not the webhook event has passed validation", + ) + processed = models.BooleanField( + default=False, + help_text="Whether or not the webhook event has been successfully processed", + ) + exception = models.CharField(max_length=128, blank=True) + traceback = models.TextField( + blank=True, help_text="Traceback if an exception was thrown during processing" + ) + event = models.ForeignKey( + "Event", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Event object contained in the (valid) Webhook", + ) + djstripe_version = models.CharField( + max_length=32, + default=_get_version, # Needs to be a callable, otherwise it's a db default. + help_text="The version of dj-stripe when the webhook was received", + ) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + @classmethod + def from_request(cls, request): + """ + Create, validate and process a WebhookEventTrigger given a Django + request object. + + The process is three-fold: + 1. Create a WebhookEventTrigger object from a Django request. + 2. Validate the WebhookEventTrigger as a Stripe event using the API. + 3. If valid, process it into an Event object (and child resource). + """ + + headers = fix_django_headers(request.META) + assert headers + try: + body = request.body.decode(request.encoding or "utf-8") + except Exception: + body = "(error decoding body)" + + ip = request.META.get("REMOTE_ADDR") + if not ip: + warnings.warn( + "Could not determine remote IP (missing REMOTE_ADDR). " + "This is likely an issue with your wsgi/server setup." + ) + ip = "0.0.0.0" + obj = cls.objects.create(headers=headers, body=body, remote_ip=ip) + + try: + obj.valid = obj.validate() + if obj.valid: + if djstripe_settings.WEBHOOK_EVENT_CALLBACK: + # If WEBHOOK_EVENT_CALLBACK, pass it for processing + djstripe_settings.WEBHOOK_EVENT_CALLBACK(obj) + else: + # Process the item (do not save it, it'll get saved below) + obj.process(save=False) + except Exception as e: + max_length = WebhookEventTrigger._meta.get_field("exception").max_length + obj.exception = str(e)[:max_length] + obj.traceback = format_exc() + + # Send the exception as the webhook_processing_error signal + webhook_processing_error.send( + sender=WebhookEventTrigger, + exception=e, + data=getattr(e, "http_body", ""), + ) + + # re-raise the exception so Django sees it + raise e + finally: + obj.save() + + return obj + + @cached_property + def json_body(self): + try: + return json.loads(self.body) + except ValueError: + return {} + + @property + def is_test_event(self): + event_id = self.json_body.get("id") + return event_id and event_id.endswith("_00000000000000") + + def validate(self, api_key=None): + """ + The original contents of the Event message must be confirmed by + refetching it and comparing the fetched data with the original data. + + This function makes an API call to Stripe to redownload the Event data + and returns whether or not it matches the WebhookEventTrigger data. + """ + + local_data = self.json_body + if "id" not in local_data or "livemode" not in local_data: + return False + + if self.is_test_event: + logger.info("Test webhook received: {}".format(local_data)) + return False + + if djstripe_settings.WEBHOOK_VALIDATION is None: + # validation disabled + return True + elif ( + djstripe_settings.WEBHOOK_VALIDATION == "verify_signature" + and djstripe_settings.WEBHOOK_SECRET + ): + try: + stripe.WebhookSignature.verify_header( + self.body, + self.headers.get("stripe-signature"), + djstripe_settings.WEBHOOK_SECRET, + djstripe_settings.WEBHOOK_TOLERANCE, + ) + except stripe.error.SignatureVerificationError: + return False + else: + return True + + livemode = local_data["livemode"] + api_key = api_key or djstripe_settings.get_default_api_key(livemode) + + # Retrieve the event using the api_version specified in itself + with stripe_temporary_api_version(local_data["api_version"], validate=False): + remote_data = Event.stripe_class.retrieve( + id=local_data["id"], api_key=api_key + ) + + return local_data["data"] == remote_data["data"] + + def process(self, save=True): + # Reset traceback and exception in case of reprocessing + self.exception = "" + self.traceback = "" + + self.event = Event.process(self.json_body) + self.processed = True + if save: + self.save() + + return self.event diff --git a/djstripe/settings.py b/djstripe/settings.py index 7699b45b5d..6cd043e528 100644 --- a/djstripe/settings.py +++ b/djstripe/settings.py @@ -13,54 +13,54 @@ def get_callback_function(setting_name, default=None): - """ - Resolve a callback function based on a setting name. + """ + Resolve a callback function based on a setting name. - If the setting value isn't set, default is returned. If the setting value - is already a callable function, that value is used - If the setting value - is a string, an attempt is made to import it. Anything else will result in - a failed import causing ImportError to be raised. + If the setting value isn't set, default is returned. If the setting value + is already a callable function, that value is used - If the setting value + is a string, an attempt is made to import it. Anything else will result in + a failed import causing ImportError to be raised. - :param setting_name: The name of the setting to resolve a callback from. - :type setting_name: string (``str``/``unicode``) - :param default: The default to return if setting isn't populated. - :type default: ``bool`` - :returns: The resolved callback function (if any). - :type: ``callable`` - """ - func = getattr(settings, setting_name, None) - if not func: - return default + :param setting_name: The name of the setting to resolve a callback from. + :type setting_name: string (``str``/``unicode``) + :param default: The default to return if setting isn't populated. + :type default: ``bool`` + :returns: The resolved callback function (if any). + :type: ``callable`` + """ + func = getattr(settings, setting_name, None) + if not func: + return default - if callable(func): - return func + if callable(func): + return func - if isinstance(func, str): - func = import_string(func) + if isinstance(func, str): + func = import_string(func) - if not callable(func): - raise ImproperlyConfigured("{name} must be callable.".format(name=setting_name)) + if not callable(func): + raise ImproperlyConfigured("{name} must be callable.".format(name=setting_name)) - return func + return func subscriber_request_callback = get_callback_function( - "DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK", default=(lambda request: request.user) + "DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK", default=(lambda request: request.user) ) def _get_idempotency_key(object_type, action, livemode): - from .models import IdempotencyKey + from .models import IdempotencyKey - action = "{}:{}".format(object_type, action) - idempotency_key, _created = IdempotencyKey.objects.get_or_create( - action=action, livemode=livemode - ) - return str(idempotency_key.uuid) + action = "{}:{}".format(object_type, action) + idempotency_key, _created = IdempotencyKey.objects.get_or_create( + action=action, livemode=livemode + ) + return str(idempotency_key.uuid) get_idempotency_key = get_callback_function( - "DJSTRIPE_IDEMPOTENCY_KEY_CALLBACK", _get_idempotency_key + "DJSTRIPE_IDEMPOTENCY_KEY_CALLBACK", _get_idempotency_key ) USE_NATIVE_JSONFIELD = getattr(settings, "DJSTRIPE_USE_NATIVE_JSONFIELD", False) @@ -71,10 +71,10 @@ def _get_idempotency_key(object_type, action, livemode): DJSTRIPE_WEBHOOK_URL = getattr(settings, "DJSTRIPE_WEBHOOK_URL", r"^webhook/$") WEBHOOK_TOLERANCE = getattr( - settings, "DJSTRIPE_WEBHOOK_TOLERANCE", stripe.Webhook.DEFAULT_TOLERANCE + settings, "DJSTRIPE_WEBHOOK_TOLERANCE", stripe.Webhook.DEFAULT_TOLERANCE ) WEBHOOK_VALIDATION = getattr( - settings, "DJSTRIPE_WEBHOOK_VALIDATION", "verify_signature" + settings, "DJSTRIPE_WEBHOOK_VALIDATION", "verify_signature" ) WEBHOOK_SECRET = getattr(settings, "DJSTRIPE_WEBHOOK_SECRET", "") @@ -84,7 +84,7 @@ def _get_idempotency_key(object_type, action, livemode): WEBHOOK_EVENT_CALLBACK = get_callback_function("DJSTRIPE_WEBHOOK_EVENT_CALLBACK") SUBSCRIBER_CUSTOMER_KEY = getattr( - settings, "DJSTRIPE_SUBSCRIBER_CUSTOMER_KEY", "djstripe_subscriber" + settings, "DJSTRIPE_SUBSCRIBER_CUSTOMER_KEY", "djstripe_subscriber" ) TEST_API_KEY = getattr(settings, "STRIPE_TEST_SECRET_KEY", "") @@ -95,132 +95,134 @@ def _get_idempotency_key(object_type, action, livemode): # Default secret key if hasattr(settings, "STRIPE_SECRET_KEY"): - STRIPE_SECRET_KEY = settings.STRIPE_SECRET_KEY + STRIPE_SECRET_KEY = settings.STRIPE_SECRET_KEY else: - STRIPE_SECRET_KEY = LIVE_API_KEY if STRIPE_LIVE_MODE else TEST_API_KEY + STRIPE_SECRET_KEY = LIVE_API_KEY if STRIPE_LIVE_MODE else TEST_API_KEY # Default public key if hasattr(settings, "STRIPE_PUBLIC_KEY"): - STRIPE_PUBLIC_KEY = settings.STRIPE_PUBLIC_KEY + STRIPE_PUBLIC_KEY = settings.STRIPE_PUBLIC_KEY elif STRIPE_LIVE_MODE: - STRIPE_PUBLIC_KEY = getattr(settings, "STRIPE_LIVE_PUBLIC_KEY", "") + STRIPE_PUBLIC_KEY = getattr(settings, "STRIPE_LIVE_PUBLIC_KEY", "") else: - STRIPE_PUBLIC_KEY = getattr(settings, "STRIPE_TEST_PUBLIC_KEY", "") + STRIPE_PUBLIC_KEY = getattr(settings, "STRIPE_TEST_PUBLIC_KEY", "") # Set STRIPE_API_HOST if you want to use a different Stripe API server # Example: https://github.com/stripe/stripe-mock if hasattr(settings, "STRIPE_API_HOST"): - stripe.api_base = settings.STRIPE_API_HOST + stripe.api_base = settings.STRIPE_API_HOST def get_default_api_key(livemode): - """ - Returns the default API key for a value of `livemode`. - """ - if livemode is None: - # Livemode is unknown. Use the default secret key. - return STRIPE_SECRET_KEY - elif livemode: - # Livemode is true, use the live secret key - return LIVE_API_KEY or STRIPE_SECRET_KEY - else: - # Livemode is false, use the test secret key - return TEST_API_KEY or STRIPE_SECRET_KEY + """ + Returns the default API key for a value of `livemode`. + """ + if livemode is None: + # Livemode is unknown. Use the default secret key. + return STRIPE_SECRET_KEY + elif livemode: + # Livemode is true, use the live secret key + return LIVE_API_KEY or STRIPE_SECRET_KEY + else: + # Livemode is false, use the test secret key + return TEST_API_KEY or STRIPE_SECRET_KEY SUBSCRIPTION_REDIRECT = getattr(settings, "DJSTRIPE_SUBSCRIPTION_REDIRECT", "") ZERO_DECIMAL_CURRENCIES = set( - [ - "bif", - "clp", - "djf", - "gnf", - "jpy", - "kmf", - "krw", - "mga", - "pyg", - "rwf", - "vnd", - "vuv", - "xaf", - "xof", - "xpf", - ] + [ + "bif", + "clp", + "djf", + "gnf", + "jpy", + "kmf", + "krw", + "mga", + "pyg", + "rwf", + "vnd", + "vuv", + "xaf", + "xof", + "xpf", + ] ) def get_subscriber_model_string(): - """Get the configured subscriber model as a module path string.""" - return getattr(settings, "DJSTRIPE_SUBSCRIBER_MODEL", settings.AUTH_USER_MODEL) + """Get the configured subscriber model as a module path string.""" + return getattr(settings, "DJSTRIPE_SUBSCRIBER_MODEL", settings.AUTH_USER_MODEL) def get_subscriber_model(): - """ - Attempt to pull settings.DJSTRIPE_SUBSCRIBER_MODEL. - - Users have the option of specifying a custom subscriber model via the - DJSTRIPE_SUBSCRIBER_MODEL setting. - - This methods falls back to AUTH_USER_MODEL if DJSTRIPE_SUBSCRIBER_MODEL is not set. - - Returns the subscriber model that is active in this project. - """ - model_name = get_subscriber_model_string() - - # Attempt a Django 1.7 app lookup - try: - subscriber_model = django_apps.get_model(model_name) - except ValueError: - raise ImproperlyConfigured( - "DJSTRIPE_SUBSCRIBER_MODEL must be of the form 'app_label.model_name'." - ) - except LookupError: - raise ImproperlyConfigured( - "DJSTRIPE_SUBSCRIBER_MODEL refers to model '{model}' " - "that has not been installed.".format(model=model_name) - ) - - if ( - "email" not in [field_.name for field_ in subscriber_model._meta.get_fields()] - ) and not hasattr(subscriber_model, "email"): - raise ImproperlyConfigured("DJSTRIPE_SUBSCRIBER_MODEL must have an email attribute.") - - if model_name != settings.AUTH_USER_MODEL: - # Custom user model detected. Make sure the callback is configured. - func = get_callback_function("DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK") - if not func: - raise ImproperlyConfigured( - "DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK must be implemented " - "if a DJSTRIPE_SUBSCRIBER_MODEL is defined." - ) - - return subscriber_model + """ + Attempt to pull settings.DJSTRIPE_SUBSCRIBER_MODEL. + + Users have the option of specifying a custom subscriber model via the + DJSTRIPE_SUBSCRIBER_MODEL setting. + + This methods falls back to AUTH_USER_MODEL if DJSTRIPE_SUBSCRIBER_MODEL is not set. + + Returns the subscriber model that is active in this project. + """ + model_name = get_subscriber_model_string() + + # Attempt a Django 1.7 app lookup + try: + subscriber_model = django_apps.get_model(model_name) + except ValueError: + raise ImproperlyConfigured( + "DJSTRIPE_SUBSCRIBER_MODEL must be of the form 'app_label.model_name'." + ) + except LookupError: + raise ImproperlyConfigured( + "DJSTRIPE_SUBSCRIBER_MODEL refers to model '{model}' " + "that has not been installed.".format(model=model_name) + ) + + if ( + "email" not in [field_.name for field_ in subscriber_model._meta.get_fields()] + ) and not hasattr(subscriber_model, "email"): + raise ImproperlyConfigured( + "DJSTRIPE_SUBSCRIBER_MODEL must have an email attribute." + ) + + if model_name != settings.AUTH_USER_MODEL: + # Custom user model detected. Make sure the callback is configured. + func = get_callback_function("DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK") + if not func: + raise ImproperlyConfigured( + "DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK must be implemented " + "if a DJSTRIPE_SUBSCRIBER_MODEL is defined." + ) + + return subscriber_model def get_stripe_api_version(): - """Get the desired API version to use for Stripe requests.""" - version = getattr(settings, "STRIPE_API_VERSION", stripe.api_version) - return version or DEFAULT_STRIPE_API_VERSION + """Get the desired API version to use for Stripe requests.""" + version = getattr(settings, "STRIPE_API_VERSION", stripe.api_version) + return version or DEFAULT_STRIPE_API_VERSION def set_stripe_api_version(version=None, validate=True): - """ - Set the desired API version to use for Stripe requests. - - :param version: The version to set for the Stripe API. - :type version: ``str`` - :param validate: If True validate the value for the specified version). - :type validate: ``bool`` - """ - version = version or get_stripe_api_version() - - if validate: - valid = validate_stripe_api_version(version) - if not valid: - raise ValueError("Bad stripe API version: {}".format(version)) - - stripe.api_version = version + """ + Set the desired API version to use for Stripe requests. + + :param version: The version to set for the Stripe API. + :type version: ``str`` + :param validate: If True validate the value for the specified version). + :type validate: ``bool`` + """ + version = version or get_stripe_api_version() + + if validate: + valid = validate_stripe_api_version(version) + if not valid: + raise ValueError("Bad stripe API version: {}".format(version)) + + stripe.api_version = version diff --git a/djstripe/signals.py b/djstripe/signals.py index 6f524e2917..3799f2b16e 100644 --- a/djstripe/signals.py +++ b/djstripe/signals.py @@ -13,149 +13,149 @@ # A signal for each Event type. See https://stripe.com/docs/api/events/types WEBHOOK_SIGNALS = dict( - [ - (hook, Signal(providing_args=["event"])) - for hook in [ - "account.updated", - "account.application.authorized", - "account.application.deauthorized", - "account.external_account.created", - "account.external_account.deleted", - "account.external_account.updated", - "application_fee.created", - "application_fee.refunded", - "application_fee.refund.updated", - "balance.available", - "charge.captured", - "charge.expired", - "charge.failed", - "charge.pending", - "charge.refunded", - "charge.succeeded", - "charge.updated", - "charge.dispute.closed", - "charge.dispute.created", - "charge.dispute.funds_reinstated", - "charge.dispute.funds_withdrawn", - "charge.dispute.updated", - "charge.refund.updated", - "checkout.session.completed", - "coupon.created", - "coupon.deleted", - "coupon.updated", - "customer.created", - "customer.deleted", - "customer.updated", - "customer.discount.created", - "customer.discount.deleted", - "customer.discount.updated", - "customer.source.created", - "customer.source.deleted", - "customer.source.expiring", - "customer.source.updated", - "customer.subscription.created", - "customer.subscription.deleted", - "customer.subscription.trial_will_end", - "customer.subscription.updated", - "file.created", - "invoice.created", - "invoice.deleted", - "invoice.finalized", - "invoice.marked_uncollectible", - "invoice.payment_failed", - "invoice.payment_succeeded", - "invoice.sent", - "invoice.upcoming", - "invoice.updated", - "invoice.voided", - "invoiceitem.created", - "invoiceitem.deleted", - "invoiceitem.updated", - "issuing_authorization.created", - "issuing_authorization.request", - "issuing_authorization.updated", - "issuing_card.created", - "issuing_card.updated", - "issuing_cardholder.created", - "issuing_cardholder.updated", - "issuing_dispute.created", - "issuing_dispute.updated", - "issuing_settlement.created", - "issuing_settlement.updated", - "issuing_transaction.created", - "issuing_transaction.updated", - "order.created", - "order.payment_failed", - "order.payment_succeeded", - "order.updated", - "order_return.created", - "payment_intent.amount_capturable_updated", - "payment_intent.created", - "payment_intent.payment_failed", - "payment_intent.succeeded", - "payment_method.attached", - "payment_method.card_automatically_updated", - "payment_method.detached", - "payment_method.updated", - "payout.canceled", - "payout.created", - "payout.failed", - "payout.paid", - "payout.updated", - "plan.created", - "plan.deleted", - "plan.updated", - "product.created", - "product.deleted", - "product.updated", - "recipient.created", - "recipient.deleted", - "recipient.updated", - "reporting.report_run.failed", - "reporting.report_run.succeeded", - "reporting.report_type.updated", - "review.closed", - "review.opened", - "setup_intent.created", - "setup_intent.setup_failed", - "setup_intent.succeeded", - "sigma.scheduled_query_run.created", - "sku.created", - "sku.deleted", - "sku.updated", - "source.canceled", - "source.chargeable", - "source.failed", - "source.mandate_notification", - "source.refund_attributes_required", - "source.transaction.created", - "source.transaction.updated", - "topup.canceled", - "topup.created", - "topup.failed", - "topup.reversed", - "topup.succeeded", - "transfer.created", - "transfer.reversed", - "transfer.updated", - # deprecated (no longer in events_types list) - TODO can be deleted? - "checkout_beta.session_succeeded", - "issuer_fraud_record.created", - "payment_intent.requires_capture", - "subscription_schedule.canceled", - "subscription_schedule.completed", - "subscription_schedule.created", - "subscription_schedule.released", - "subscription_schedule.updated", - # special case? - TODO can be deleted? - "ping", - ] - ] + [ + (hook, Signal(providing_args=["event"])) + for hook in [ + "account.updated", + "account.application.authorized", + "account.application.deauthorized", + "account.external_account.created", + "account.external_account.deleted", + "account.external_account.updated", + "application_fee.created", + "application_fee.refunded", + "application_fee.refund.updated", + "balance.available", + "charge.captured", + "charge.expired", + "charge.failed", + "charge.pending", + "charge.refunded", + "charge.succeeded", + "charge.updated", + "charge.dispute.closed", + "charge.dispute.created", + "charge.dispute.funds_reinstated", + "charge.dispute.funds_withdrawn", + "charge.dispute.updated", + "charge.refund.updated", + "checkout.session.completed", + "coupon.created", + "coupon.deleted", + "coupon.updated", + "customer.created", + "customer.deleted", + "customer.updated", + "customer.discount.created", + "customer.discount.deleted", + "customer.discount.updated", + "customer.source.created", + "customer.source.deleted", + "customer.source.expiring", + "customer.source.updated", + "customer.subscription.created", + "customer.subscription.deleted", + "customer.subscription.trial_will_end", + "customer.subscription.updated", + "file.created", + "invoice.created", + "invoice.deleted", + "invoice.finalized", + "invoice.marked_uncollectible", + "invoice.payment_failed", + "invoice.payment_succeeded", + "invoice.sent", + "invoice.upcoming", + "invoice.updated", + "invoice.voided", + "invoiceitem.created", + "invoiceitem.deleted", + "invoiceitem.updated", + "issuing_authorization.created", + "issuing_authorization.request", + "issuing_authorization.updated", + "issuing_card.created", + "issuing_card.updated", + "issuing_cardholder.created", + "issuing_cardholder.updated", + "issuing_dispute.created", + "issuing_dispute.updated", + "issuing_settlement.created", + "issuing_settlement.updated", + "issuing_transaction.created", + "issuing_transaction.updated", + "order.created", + "order.payment_failed", + "order.payment_succeeded", + "order.updated", + "order_return.created", + "payment_intent.amount_capturable_updated", + "payment_intent.created", + "payment_intent.payment_failed", + "payment_intent.succeeded", + "payment_method.attached", + "payment_method.card_automatically_updated", + "payment_method.detached", + "payment_method.updated", + "payout.canceled", + "payout.created", + "payout.failed", + "payout.paid", + "payout.updated", + "plan.created", + "plan.deleted", + "plan.updated", + "product.created", + "product.deleted", + "product.updated", + "recipient.created", + "recipient.deleted", + "recipient.updated", + "reporting.report_run.failed", + "reporting.report_run.succeeded", + "reporting.report_type.updated", + "review.closed", + "review.opened", + "setup_intent.created", + "setup_intent.setup_failed", + "setup_intent.succeeded", + "sigma.scheduled_query_run.created", + "sku.created", + "sku.deleted", + "sku.updated", + "source.canceled", + "source.chargeable", + "source.failed", + "source.mandate_notification", + "source.refund_attributes_required", + "source.transaction.created", + "source.transaction.updated", + "topup.canceled", + "topup.created", + "topup.failed", + "topup.reversed", + "topup.succeeded", + "transfer.created", + "transfer.reversed", + "transfer.updated", + # deprecated (no longer in events_types list) - TODO can be deleted? + "checkout_beta.session_succeeded", + "issuer_fraud_record.created", + "payment_intent.requires_capture", + "subscription_schedule.canceled", + "subscription_schedule.completed", + "subscription_schedule.created", + "subscription_schedule.released", + "subscription_schedule.updated", + # special case? - TODO can be deleted? + "ping", + ] + ] ) @receiver(pre_delete, sender=djstripe_settings.get_subscriber_model_string()) def on_delete_subscriber_purge_customer(instance=None, **kwargs): - """ Purge associated customers when the subscriber is deleted. """ - for customer in instance.djstripe_customers.all(): - customer.purge() + """ Purge associated customers when the subscriber is deleted. """ + for customer in instance.djstripe_customers.all(): + customer.purge() diff --git a/djstripe/sync.py b/djstripe/sync.py index 8207734d22..720839fc34 100644 --- a/djstripe/sync.py +++ b/djstripe/sync.py @@ -7,14 +7,14 @@ def sync_subscriber(subscriber): - """Sync a Customer with Stripe api data.""" - customer, _created = Customer.get_or_create(subscriber=subscriber) - try: - customer.sync_from_stripe_data(customer.api_retrieve()) - customer._sync_subscriptions() - customer._sync_invoices() - customer._sync_cards() - customer._sync_charges() - except InvalidRequestError as e: - print("ERROR: " + str(e)) - return customer + """Sync a Customer with Stripe api data.""" + customer, _created = Customer.get_or_create(subscriber=subscriber) + try: + customer.sync_from_stripe_data(customer.api_retrieve()) + customer._sync_subscriptions() + customer._sync_invoices() + customer._sync_cards() + customer._sync_charges() + except InvalidRequestError as e: + print("ERROR: " + str(e)) + return customer diff --git a/djstripe/templates/djstripe/admin/change_form.html b/djstripe/templates/djstripe/admin/change_form.html index 2139b62092..54ec110408 100644 --- a/djstripe/templates/djstripe/admin/change_form.html +++ b/djstripe/templates/djstripe/admin/change_form.html @@ -2,10 +2,10 @@ {% load i18n %} {% block object-tools-items %} - {{ block.super }} - {% with original.get_stripe_dashboard_url as stripe_url %} - {% if stripe_url %} -
  • {% trans "View on Stripe Dashboard" %}
  • - {% endif %} - {% endwith %} + {{ block.super }} + {% with original.get_stripe_dashboard_url as stripe_url %} + {% if stripe_url %} +
  • {% trans "View on Stripe Dashboard" %}
  • + {% endif %} + {% endwith %} {% endblock %} diff --git a/djstripe/urls.py b/djstripe/urls.py index 8f3573c289..3c71387877 100644 --- a/djstripe/urls.py +++ b/djstripe/urls.py @@ -3,17 +3,17 @@ Wire this into the root URLConf this way:: - url(r"^stripe/", include("djstripe.urls", namespace="djstripe")), - # url can be changed - # Call to 'djstripe.urls' and 'namespace' must stay as is + url(r"^stripe/", include("djstripe.urls", namespace="djstripe")), + # url can be changed + # Call to 'djstripe.urls' and 'namespace' must stay as is Call it from reverse():: - reverse("djstripe:subscribe") + reverse("djstripe:subscribe") Call from url tag:: - {% url "djstripe:subscribe" %} + {% url "djstripe:subscribe" %} """ from django.conf.urls import url @@ -23,8 +23,10 @@ app_name = "djstripe" urlpatterns = [ - # Webhook - url( - app_settings.DJSTRIPE_WEBHOOK_URL, views.ProcessWebhookView.as_view(), name="webhook" - ) + # Webhook + url( + app_settings.DJSTRIPE_WEBHOOK_URL, + views.ProcessWebhookView.as_view(), + name="webhook", + ) ] diff --git a/djstripe/utils.py b/djstripe/utils.py index d15cc1c0bd..e5f7e5940b 100644 --- a/djstripe/utils.py +++ b/djstripe/utils.py @@ -12,111 +12,112 @@ from django.utils import timezone ANONYMOUS_USER_ERROR_MSG = ( - "dj-stripe's payment checking mechanisms require the user " - "be authenticated before use. Please use django.contrib.auth's " - "login_required decorator or a LoginRequiredMixin. " - "Please read the warning at " - "http://dj-stripe.readthedocs.org/en/latest/usage.html#ongoing-subscriptions." + "dj-stripe's payment checking mechanisms require the user " + "be authenticated before use. Please use django.contrib.auth's " + "login_required decorator or a LoginRequiredMixin. " + "Please read the warning at " + "http://dj-stripe.readthedocs.org/en/latest/usage.html#ongoing-subscriptions." ) def fix_django_headers(meta): - """ - Fix this nonsensical API: - https://docs.djangoproject.com/en/1.11/ref/request-response/ - https://code.djangoproject.com/ticket/20147 - """ - ret = {} - for k, v in meta.items(): - if k.startswith("HTTP_"): - k = k[len("HTTP_") :] - elif k not in ("CONTENT_LENGTH", "CONTENT_TYPE"): - # Skip CGI garbage - continue + """ + Fix this nonsensical API: + https://docs.djangoproject.com/en/1.11/ref/request-response/ + https://code.djangoproject.com/ticket/20147 + """ + ret = {} + for k, v in meta.items(): + if k.startswith("HTTP_"): + k = k[len("HTTP_") :] + elif k not in ("CONTENT_LENGTH", "CONTENT_TYPE"): + # Skip CGI garbage + continue - ret[k.lower().replace("_", "-")] = v + ret[k.lower().replace("_", "-")] = v - return ret + return ret def subscriber_has_active_subscription(subscriber, plan=None): - """ - Helper function to check if a subscriber has an active subscription. + """ + Helper function to check if a subscriber has an active subscription. - Throws improperlyConfigured if the subscriber is an instance of AUTH_USER_MODEL - and get_user_model().is_anonymous == True. + Throws improperlyConfigured if the subscriber is an instance of AUTH_USER_MODEL + and get_user_model().is_anonymous == True. - Activate subscription rules (or): - * customer has active subscription + Activate subscription rules (or): + * customer has active subscription - If the subscriber is an instance of AUTH_USER_MODEL, active subscription rules (or): - * customer has active subscription - * user.is_superuser - * user.is_staff + If the subscriber is an instance of AUTH_USER_MODEL, active subscription rules (or): + * customer has active subscription + * user.is_superuser + * user.is_staff - :param subscriber: The subscriber for which to check for an active subscription. - :type subscriber: dj-stripe subscriber - :param plan: The plan for which to check for an active subscription. If plan is None and - there exists only one subscription, this method will check if that subscription - is active. Calling this method with no plan and multiple subscriptions will throw - an exception. - :type plan: Plan or string (plan ID) + :param subscriber: The subscriber for which to check for an active subscription. + :type subscriber: dj-stripe subscriber + :param plan: The plan for which to check for an active subscription. + If plan is None and there exists only one subscription, this method will + check if that subscription is active. Calling this method with no plan and + multiple subscriptions will throw an exception. + :type plan: Plan or string (plan ID) - """ - if isinstance(subscriber, AnonymousUser): - raise ImproperlyConfigured(ANONYMOUS_USER_ERROR_MSG) + """ + if isinstance(subscriber, AnonymousUser): + raise ImproperlyConfigured(ANONYMOUS_USER_ERROR_MSG) - if isinstance(subscriber, get_user_model()): - if subscriber.is_superuser or subscriber.is_staff: - return True - from .models import Customer + if isinstance(subscriber, get_user_model()): + if subscriber.is_superuser or subscriber.is_staff: + return True + from .models import Customer - customer, created = Customer.get_or_create(subscriber) - if created or not customer.has_active_subscription(plan): - return False - return True + customer, created = Customer.get_or_create(subscriber) + if created or not customer.has_active_subscription(plan): + return False + return True def get_supported_currency_choices(api_key): - """ - Pull a stripe account's supported currencies and returns a choices tuple of those supported currencies. + """ + Pull a stripe account's supported currencies and returns a choices tuple of those + supported currencies. - :param api_key: The api key associated with the account from which to pull data. - :type api_key: str - """ - import stripe + :param api_key: The api key associated with the account from which to pull data. + :type api_key: str + """ + import stripe - stripe.api_key = api_key + stripe.api_key = api_key - account = stripe.Account.retrieve() - supported_payment_currencies = stripe.CountrySpec.retrieve(account["country"])[ - "supported_payment_currencies" - ] + account = stripe.Account.retrieve() + supported_payment_currencies = stripe.CountrySpec.retrieve(account["country"])[ + "supported_payment_currencies" + ] - return [(currency, currency.upper()) for currency in supported_payment_currencies] + return [(currency, currency.upper()) for currency in supported_payment_currencies] def clear_expired_idempotency_keys(): - from .models import IdempotencyKey + from .models import IdempotencyKey - threshold = timezone.now() - datetime.timedelta(hours=24) - IdempotencyKey.objects.filter(created__lt=threshold).delete() + threshold = timezone.now() - datetime.timedelta(hours=24) + IdempotencyKey.objects.filter(created__lt=threshold).delete() def convert_tstamp(response): - """ - Convert a Stripe API timestamp response (unix epoch) to a native datetime. + """ + Convert a Stripe API timestamp response (unix epoch) to a native datetime. - :rtype: datetime - """ - if response is None: - # Allow passing None to convert_tstamp() - return response + :rtype: datetime + """ + if response is None: + # Allow passing None to convert_tstamp() + return response - # Overrides the set timezone to UTC - I think... - tz = timezone.utc if settings.USE_TZ else None + # Overrides the set timezone to UTC - I think... + tz = timezone.utc if settings.USE_TZ else None - return datetime.datetime.fromtimestamp(response, tz) + return datetime.datetime.fromtimestamp(response, tz) # TODO: Finish this. @@ -124,31 +125,31 @@ def convert_tstamp(response): def get_friendly_currency_amount(amount, currency): - currency = currency.upper() - sigil = CURRENCY_SIGILS.get(currency, "") - return "{sigil}{amount:.2f} {currency}".format( - sigil=sigil, amount=amount, currency=currency - ) + currency = currency.upper() + sigil = CURRENCY_SIGILS.get(currency, "") + return "{sigil}{amount:.2f} {currency}".format( + sigil=sigil, amount=amount, currency=currency + ) class QuerySetMock(QuerySet): - """ - A mocked QuerySet class that does not handle updates. - Used by UpcomingInvoice.invoiceitems. - """ - - @classmethod - def from_iterable(cls, model, iterable): - instance = cls(model) - instance._result_cache = list(iterable) - instance._prefetch_done = True - return instance - - def _clone(self): - return self.__class__.from_iterable(self.model, self._result_cache) - - def update(self): - return 0 - - def delete(self): - return 0 + """ + A mocked QuerySet class that does not handle updates. + Used by UpcomingInvoice.invoiceitems. + """ + + @classmethod + def from_iterable(cls, model, iterable): + instance = cls(model) + instance._result_cache = list(iterable) + instance._prefetch_done = True + return instance + + def _clone(self): + return self.__class__.from_iterable(self.model, self._result_cache) + + def update(self): + return 0 + + def delete(self): + return 0 diff --git a/djstripe/views.py b/djstripe/views.py index faea0618fd..757677e927 100644 --- a/djstripe/views.py +++ b/djstripe/views.py @@ -15,31 +15,31 @@ @method_decorator(csrf_exempt, name="dispatch") class ProcessWebhookView(View): - """ - A Stripe Webhook handler view. + """ + A Stripe Webhook handler view. - This will create a WebhookEventTrigger instance, verify it, - then attempt to process it. + This will create a WebhookEventTrigger instance, verify it, + then attempt to process it. - If the webhook cannot be verified, returns HTTP 400. + If the webhook cannot be verified, returns HTTP 400. - If an exception happens during processing, returns HTTP 500. - """ + If an exception happens during processing, returns HTTP 500. + """ - def post(self, request): - if "HTTP_STRIPE_SIGNATURE" not in request.META: - # Do not even attempt to process/store the event if there is - # no signature in the headers so we avoid overfilling the db. - return HttpResponseBadRequest() + def post(self, request): + if "HTTP_STRIPE_SIGNATURE" not in request.META: + # Do not even attempt to process/store the event if there is + # no signature in the headers so we avoid overfilling the db. + return HttpResponseBadRequest() - trigger = WebhookEventTrigger.from_request(request) + trigger = WebhookEventTrigger.from_request(request) - if trigger.is_test_event: - # Since we don't do signature verification, we have to skip trigger.valid - return HttpResponse("Test webhook successfully received!") + if trigger.is_test_event: + # Since we don't do signature verification, we have to skip trigger.valid + return HttpResponse("Test webhook successfully received!") - if not trigger.valid: - # Webhook Event did not validate, return 400 - return HttpResponseBadRequest() + if not trigger.valid: + # Webhook Event did not validate, return 400 + return HttpResponseBadRequest() - return HttpResponse(str(trigger.id)) + return HttpResponse(str(trigger.id)) diff --git a/djstripe/webhooks.py b/djstripe/webhooks.py index 7f32d41cf7..63e346ad96 100644 --- a/djstripe/webhooks.py +++ b/djstripe/webhooks.py @@ -9,7 +9,7 @@ Each registration entry is a list of processors Each processor in these lists is a function to be called The function signature is: - + There is also a "global registry" which is just a list of processors (as defined above) @@ -31,68 +31,68 @@ def handler(*event_types): - """ - Decorator that registers a function as a webhook handler. + """ + Decorator that registers a function as a webhook handler. - Functions can be registered for event types (e.g. 'customer') or - fully qualified event sub-types (e.g. 'customer.subscription.deleted'). + Functions can be registered for event types (e.g. 'customer') or + fully qualified event sub-types (e.g. 'customer.subscription.deleted'). - If an event type is specified, the handler will receive callbacks for - ALL webhook events of that type. For example, if 'customer' is specified, - the handler will receive events for 'customer.subscription.created', - 'customer.subscription.updated', etc. + If an event type is specified, the handler will receive callbacks for + ALL webhook events of that type. For example, if 'customer' is specified, + the handler will receive events for 'customer.subscription.created', + 'customer.subscription.updated', etc. - :param event_types: The event type(s) that should be handled. - :type event_types: str. - """ + :param event_types: The event type(s) that should be handled. + :type event_types: str. + """ - def decorator(func): - for event_type in event_types: - registrations[event_type].append(func) - return func + def decorator(func): + for event_type in event_types: + registrations[event_type].append(func) + return func - return decorator + return decorator def handler_all(func=None): - """ - Decorator that registers a function as a webhook handler for ALL webhook events. + """ + Decorator that registers a function as a webhook handler for ALL webhook events. - Handles all webhooks regardless of event type or sub-type. - """ - if not func: - return functools.partial(handler_all) + Handles all webhooks regardless of event type or sub-type. + """ + if not func: + return functools.partial(handler_all) - registrations_global.append(func) + registrations_global.append(func) - return func + return func def call_handlers(event): - """ - Invoke all handlers for the provided event type/sub-type. + """ + Invoke all handlers for the provided event type/sub-type. - The handlers are invoked in the following order: + The handlers are invoked in the following order: - 1. Global handlers - 2. Event type handlers - 3. Event sub-type handlers + 1. Global handlers + 2. Event type handlers + 3. Event sub-type handlers - Handlers within each group are invoked in order of registration. + Handlers within each group are invoked in order of registration. - :param event: The event model object. - :type event: ``djstripe.models.Event`` - """ - chain = [registrations_global] + :param event: The event model object. + :type event: ``djstripe.models.Event`` + """ + chain = [registrations_global] - # Build up a list of handlers with each qualified part of the event - # category and verb. For example, "customer.subscription.created" creates: - # 1. "customer" - # 2. "customer.subscription" - # 3. "customer.subscription.created" - for index, _ in enumerate(event.parts): - qualified_event_type = ".".join(event.parts[: (index + 1)]) - chain.append(registrations[qualified_event_type]) + # Build up a list of handlers with each qualified part of the event + # category and verb. For example, "customer.subscription.created" creates: + # 1. "customer" + # 2. "customer.subscription" + # 3. "customer.subscription.created" + for index, _ in enumerate(event.parts): + qualified_event_type = ".".join(event.parts[: (index + 1)]) + chain.append(registrations[qualified_event_type]) - for handler_func in itertools.chain(*chain): - handler_func(event=event) + for handler_func in itertools.chain(*chain): + handler_func(event=event) diff --git a/docs/conf.py b/docs/conf.py index da3c5f6f9d..fd5b854394 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,18 +26,18 @@ settings.configure( - INSTALLED_APPS=[ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.sites", - "jsonfield", - "djstripe", - ], - SITE_ID=1, - STRIPE_PUBLIC_KEY=os.environ.get("STRIPE_PUBLIC_KEY", ""), - STRIPE_SECRET_KEY=os.environ.get("STRIPE_SECRET_KEY", ""), + INSTALLED_APPS=[ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "jsonfield", + "djstripe", + ], + SITE_ID=1, + STRIPE_PUBLIC_KEY=os.environ.get("STRIPE_PUBLIC_KEY", ""), + STRIPE_SECRET_KEY=os.environ.get("STRIPE_SECRET_KEY", ""), ) @@ -120,17 +120,18 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -# on_rtd is whether we are on readthedocs.org, this line of code grabbed from docs.readthedocs.org +# on_rtd is whether we are on readthedocs.org, +# this line of code grabbed from docs.readthedocs.org # https://github.com/snide/sphinx_rtd_theme on_rtd = os.environ.get("READTHEDOCS", None) == "True" if not on_rtd: # only import and set the theme if we're building docs locally - import sphinx_rtd_theme + import sphinx_rtd_theme - html_theme = "sphinx_rtd_theme" - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + html_theme = "sphinx_rtd_theme" + html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] else: - html_theme = "default" + html_theme = "default" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -221,13 +222,13 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ( - "index", - "dj-stripe.tex", - u"dj-stripe Documentation", - u"Alexander Kavanaugh", - "manual", - ) + ( + "index", + "dj-stripe.tex", + u"dj-stripe Documentation", + u"Alexander Kavanaugh", + "manual", + ) ] # The name of an image file (relative to this directory) to place at the top of @@ -256,7 +257,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ("index", "dj-stripe", u"dj-stripe Documentation", [u"Alexander Kavanaugh"], 1) + ("index", "dj-stripe", u"dj-stripe Documentation", [u"Alexander Kavanaugh"], 1) ] # If true, show URL addresses after external links. @@ -269,15 +270,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ( - "index", - "dj-stripe", - u"dj-stripe Documentation", - u"Alexander Kavanaugh", - "dj-stripe", - "Django + Stripe Made Easy", - "Miscellaneous", - ) + ( + "index", + "dj-stripe", + u"dj-stripe Documentation", + u"Alexander Kavanaugh", + "dj-stripe", + "Django + Stripe Made Easy", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. diff --git a/docs/usage/examples/manually_syncing_with_stripe.py b/docs/usage/examples/manually_syncing_with_stripe.py index 820fb60c3b..1583097e18 100644 --- a/docs/usage/examples/manually_syncing_with_stripe.py +++ b/docs/usage/examples/manually_syncing_with_stripe.py @@ -1,17 +1,17 @@ def example(): - import djstripe.models - import djstripe.settings - import stripe + import djstripe.models + import djstripe.settings + import stripe - # stripe API return value is a dict-like object - stripe_data = stripe.Product.create( - api_key=djstripe.settings.STRIPE_SECRET_KEY, - name="Monthly membership base fee", - type="service", - ) + # stripe API return value is a dict-like object + stripe_data = stripe.Product.create( + api_key=djstripe.settings.STRIPE_SECRET_KEY, + name="Monthly membership base fee", + type="service", + ) - # sync_from_stripe_data to save it to the database, - # and recursively update any referenced objects - djstripe_obj = djstripe.models.Product.sync_from_stripe_data(stripe_data) + # sync_from_stripe_data to save it to the database, + # and recursively update any referenced objects + djstripe_obj = djstripe.models.Product.sync_from_stripe_data(stripe_data) - return djstripe_obj + return djstripe_obj diff --git a/docs/usage/examples/test_docs_examples.py b/docs/usage/examples/test_docs_examples.py index 480176ae39..1a8fc7187b 100644 --- a/docs/usage/examples/test_docs_examples.py +++ b/docs/usage/examples/test_docs_examples.py @@ -7,24 +7,24 @@ class CheckExamplesTest(TestCase): - """ - Sanity check example code - """ - - @patch("stripe.Product.create", autospec=True) - def test_manually_sync_data_with_stripe(self, product_create_mock): - example_product_data = { - "id": "example_product", - "object": "product", - "active": True, - "name": "Monthly membership base fee", - "type": "service", - } - - product_create_mock.return_value = example_product_data - - manually_syncing_with_stripe.example() - - self.assertEqual( - djstripe.models.Product.objects.last().id, example_product_data["id"] - ) + """ + Sanity check example code + """ + + @patch("stripe.Product.create", autospec=True) + def test_manually_sync_data_with_stripe(self, product_create_mock): + example_product_data = { + "id": "example_product", + "object": "product", + "active": True, + "name": "Monthly membership base fee", + "type": "service", + } + + product_create_mock.return_value = example_product_data + + manually_syncing_with_stripe.example() + + self.assertEqual( + djstripe.models.Product.objects.last().id, example_product_data["id"] + ) diff --git a/docs/usage/manually_syncing_with_stripe.rst b/docs/usage/manually_syncing_with_stripe.rst index 3300e74e01..b526ecb959 100644 --- a/docs/usage/manually_syncing_with_stripe.rst +++ b/docs/usage/manually_syncing_with_stripe.rst @@ -12,14 +12,14 @@ e.g. to populate an empty database from an existing Stripe account. .. code-block:: - ./manage.py djstripe_sync_models + ./manage.py djstripe_sync_models With no arguments this will sync all supported models, or a list of models to sync can be provided. .. code-block:: - ./manage.py djstripe_sync_models Invoice Subscription + ./manage.py djstripe_sync_models Invoice Subscription Note that this may be redundant since we recursively sync related objects. diff --git a/manage.py b/manage.py index 6b9b804ab3..e9e7fdde0f 100755 --- a/manage.py +++ b/manage.py @@ -3,13 +3,13 @@ import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) diff --git a/setup.cfg b/setup.cfg index e7d68f59d7..4bf975eeb6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,33 +9,33 @@ download_url = https://github.com/dj-stripe/dj-stripe/tarball/master long_description_content_type = text/x-rst keywords = django, stripe, payments classifiers = - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Topic :: Office/Business :: Financial - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Framework :: Django - Framework :: Django :: 2.1 - Framework :: Django :: 2.2 + Development Status :: 5 - Production/Stable + Environment :: Web Environment + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Topic :: Office/Business :: Financial + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Framework :: Django + Framework :: Django :: 2.1 + Framework :: Django :: 2.2 [options] packages = find: include_package_data = True zip_safe = False install_requires = - Django >= 2.1 - jsonfield >= 2.0.2 - stripe >= 2.3.0 + Django >= 2.1 + jsonfield >= 2.0.2 + stripe >= 2.3.0 [options.packages.find] exclude = - docs - tests - tests.* + docs + tests + tests.* [bdist_wheel] universal = 1 diff --git a/setup.py b/setup.py index 9d0460fec7..01a552e8f3 100755 --- a/setup.py +++ b/setup.py @@ -5,8 +5,8 @@ from setuptools import setup if sys.argv[-1] == "publish": - os.system("python setup.py bdist_wheel upload --sign") - sys.exit() + os.system("python setup.py bdist_wheel upload --sign") + sys.exit() readme = open("README.rst").read() diff --git a/tests/__init__.py b/tests/__init__.py index afc11d22e4..6e84cf5ad9 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -38,270 +38,272 @@ class AssertStripeFksMixin: - def assert_fks(self, obj, expected_blank_fks, processed_stripe_ids=None): - """ - Recursively walk through fks on obj, asserting they're not-none - :param obj: - :param expected_blank_fks: fields that are expected to be None - :param processed_stripe_ids: set of objects ids already processed - :return: - """ + def assert_fks(self, obj, expected_blank_fks, processed_stripe_ids=None): + """ + Recursively walk through fks on obj, asserting they're not-none + :param obj: + :param expected_blank_fks: fields that are expected to be None + :param processed_stripe_ids: set of objects ids already processed + :return: + """ - if processed_stripe_ids is None: - processed_stripe_ids = set() + if processed_stripe_ids is None: + processed_stripe_ids = set() - processed_stripe_ids.add(obj.id) + processed_stripe_ids.add(obj.id) - for field in obj._meta.fields: - if isinstance(field, models.ForeignKey): - field_str = str(field) - field_value = getattr(obj, field.name) + for field in obj._meta.fields: + if isinstance(field, models.ForeignKey): + field_str = str(field) + field_value = getattr(obj, field.name) - if field_str in expected_blank_fks: - self.assertIsNone(field_value, field) - else: - self.assertIsNotNone(field_value, field) + if field_str in expected_blank_fks: + self.assertIsNone(field_value, field) + else: + self.assertIsNotNone(field_value, field) - if field_value.id not in processed_stripe_ids: - # recurse into the object if it's not already been checked - self.assert_fks(field_value, expected_blank_fks, processed_stripe_ids) + if field_value.id not in processed_stripe_ids: + # recurse into the object if it's not already been checked + self.assert_fks( + field_value, expected_blank_fks, processed_stripe_ids + ) - logger.warning("checked {}".format(field_str)) + logger.warning("checked {}".format(field_str)) def load_fixture(filename): - with FIXTURE_DIR_PATH.joinpath(filename).open("r") as f: - return json.load(f) + with FIXTURE_DIR_PATH.joinpath(filename).open("r") as f: + return json.load(f) def datetime_to_unix(datetime_): - return int(dateformat.format(datetime_, "U")) + return int(dateformat.format(datetime_, "U")) class StripeList(dict): - object = "list" - has_more = False - url = "/v1/fakes" + object = "list" + has_more = False + url = "/v1/fakes" - def __init__(self, data): - self.data = data + def __init__(self, data): + self.data = data - def __getitem__(self, key): - return self.getattr(key) + def __getitem__(self, key): + return self.getattr(key) - def auto_paging_iter(self): - return self.data + def auto_paging_iter(self): + return self.data - @property - def total_count(self): - return len(self.data) + @property + def total_count(self): + return len(self.data) def default_account(): - from djstripe.models import Account + from djstripe.models import Account - return Account.objects.create( - charges_enabled=True, details_submitted=True, payouts_enabled=True - ) + return Account.objects.create( + charges_enabled=True, details_submitted=True, payouts_enabled=True + ) FAKE_BALANCE_TRANSACTION = load_fixture( - "balance_transaction_txn_fake_ch_fakefakefakefakefake0001.json" + "balance_transaction_txn_fake_ch_fakefakefakefakefake0001.json" ) FAKE_BALANCE_TRANSACTION_II = { - "id": "txn_16g5h62eZvKYlo2CQ2AHA89s", - "object": "balance_transaction", - "amount": 65400, - "available_on": 1441670400, - "created": 1441079064, - "currency": "usd", - "description": None, - "fee": 1927, - "fee_details": [ - { - "amount": 1927, - "currency": "usd", - "type": "stripe_fee", - "description": "Stripe processing fees", - "application": None, - } - ], - "net": 63473, - "source": "ch_16g5h62eZvKYlo2CMRXkSqa0", - "sourced_transfers": { - "object": "list", - "total_count": 0, - "has_more": False, - "url": "/v1/transfers?source_transaction=ch_16g5h62eZvKYlo2CMRXkSqa0", - "data": [], - }, - "status": "pending", - "type": "charge", + "id": "txn_16g5h62eZvKYlo2CQ2AHA89s", + "object": "balance_transaction", + "amount": 65400, + "available_on": 1441670400, + "created": 1441079064, + "currency": "usd", + "description": None, + "fee": 1927, + "fee_details": [ + { + "amount": 1927, + "currency": "usd", + "type": "stripe_fee", + "description": "Stripe processing fees", + "application": None, + } + ], + "net": 63473, + "source": "ch_16g5h62eZvKYlo2CMRXkSqa0", + "sourced_transfers": { + "object": "list", + "total_count": 0, + "has_more": False, + "url": "/v1/transfers?source_transaction=ch_16g5h62eZvKYlo2CMRXkSqa0", + "data": [], + }, + "status": "pending", + "type": "charge", } FAKE_BALANCE_TRANSACTION_III = { - "id": "txn_16g5h62eZvKYlo2CQ2AHA89s", - "object": "balance_transaction", - "amount": 2000, - "available_on": 1441670400, - "created": 1441079064, - "currency": "usd", - "description": None, - "fee": 1927, - "fee_details": [ - { - "amount": 1927, - "currency": "usd", - "type": "stripe_fee", - "description": "Stripe processing fees", - "application": None, - } - ], - "net": 73, - "source": "ch_16g5h62eZvKYlo2CMRXkSqa0", - "sourced_transfers": { - "object": "list", - "total_count": 0, - "has_more": False, - "url": "/v1/transfers?source_transaction=ch_16g5h62eZvKYlo2CMRXkSqa0", - "data": [], - }, - "status": "pending", - "type": "charge", + "id": "txn_16g5h62eZvKYlo2CQ2AHA89s", + "object": "balance_transaction", + "amount": 2000, + "available_on": 1441670400, + "created": 1441079064, + "currency": "usd", + "description": None, + "fee": 1927, + "fee_details": [ + { + "amount": 1927, + "currency": "usd", + "type": "stripe_fee", + "description": "Stripe processing fees", + "application": None, + } + ], + "net": 73, + "source": "ch_16g5h62eZvKYlo2CMRXkSqa0", + "sourced_transfers": { + "object": "list", + "total_count": 0, + "has_more": False, + "url": "/v1/transfers?source_transaction=ch_16g5h62eZvKYlo2CMRXkSqa0", + "data": [], + }, + "status": "pending", + "type": "charge", } FAKE_BALANCE_TRANSACTION_IV = { - "id": "txn_16g5h62eZvKYlo2CQ2AHA89s", - "object": "balance_transaction", - "amount": 19010, - "available_on": 1441670400, - "created": 1441079064, - "currency": "usd", - "description": None, - "fee": 1927, - "fee_details": [ - { - "amount": 1927, - "currency": "usd", - "type": "stripe_fee", - "description": "Stripe processing fees", - "application": None, - } - ], - "net": 17083, - "source": "ch_16g5h62eZvKYlo2CMRXkSqa0", - "sourced_transfers": { - "object": "list", - "total_count": 0, - "has_more": False, - "url": "/v1/transfers?source_transaction=ch_16g5h62eZvKYlo2CMRXkSqa0", - "data": [], - }, - "status": "pending", - "type": "charge", + "id": "txn_16g5h62eZvKYlo2CQ2AHA89s", + "object": "balance_transaction", + "amount": 19010, + "available_on": 1441670400, + "created": 1441079064, + "currency": "usd", + "description": None, + "fee": 1927, + "fee_details": [ + { + "amount": 1927, + "currency": "usd", + "type": "stripe_fee", + "description": "Stripe processing fees", + "application": None, + } + ], + "net": 17083, + "source": "ch_16g5h62eZvKYlo2CMRXkSqa0", + "sourced_transfers": { + "object": "list", + "total_count": 0, + "has_more": False, + "url": "/v1/transfers?source_transaction=ch_16g5h62eZvKYlo2CMRXkSqa0", + "data": [], + }, + "status": "pending", + "type": "charge", } FAKE_BANK_ACCOUNT = { - "id": "ba_16hTzo2eZvKYlo2CeSjfb0tS", - "object": "bank_account", - "account_holder_name": None, - "account_holder_type": None, - "bank_name": "STRIPE TEST BANK", - "country": "US", - "currency": "usd", - "fingerprint": "1JWtPxqbdX5Gamtc", - "last4": "6789", - "routing_number": "110000000", - "status": "new", + "id": "ba_16hTzo2eZvKYlo2CeSjfb0tS", + "object": "bank_account", + "account_holder_name": None, + "account_holder_type": None, + "bank_name": "STRIPE TEST BANK", + "country": "US", + "currency": "usd", + "fingerprint": "1JWtPxqbdX5Gamtc", + "last4": "6789", + "routing_number": "110000000", + "status": "new", } FAKE_BANK_ACCOUNT_II = { - "id": "ba_17O4Tz2eZvKYlo2CMYsxroV5", - "object": "bank_account", - "account_holder_name": None, - "account_holder_type": None, - "bank_name": None, - "country": "US", - "currency": "usd", - "fingerprint": "1JWtPxqbdX5Gamtc", - "last4": "6789", - "routing_number": "110000000", - "status": "new", + "id": "ba_17O4Tz2eZvKYlo2CMYsxroV5", + "object": "bank_account", + "account_holder_name": None, + "account_holder_type": None, + "bank_name": None, + "country": "US", + "currency": "usd", + "fingerprint": "1JWtPxqbdX5Gamtc", + "last4": "6789", + "routing_number": "110000000", + "status": "new", } class CardDict(dict): - def delete(self): - return self + def delete(self): + return self FAKE_CARD = CardDict(load_fixture("card_card_fakefakefakefakefake0001.json")) FAKE_CARD_II = CardDict(load_fixture("card_card_fakefakefakefakefake0002.json")) FAKE_CARD_III = CardDict( - { - "id": "card_17PLiR2eZvKYlo2CRwTCUAdZ", - "object": "card", - "address_city": None, - "address_country": None, - "address_line1": None, - "address_line1_check": None, - "address_line2": None, - "address_state": None, - "address_zip": None, - "address_zip_check": None, - "brand": "American Express", - "country": "US", - "customer": None, - "cvc_check": "unchecked", - "dynamic_last4": None, - "exp_month": 7, - "exp_year": 2019, - "fingerprint": "Xt5EWLLDS7FJjR1c", - "funding": "credit", - "last4": "1005", - "metadata": {"djstripe_test_fake_id": "card_fakefakefakefakefake0003"}, - "name": None, - "tokenization_method": None, - } + { + "id": "card_17PLiR2eZvKYlo2CRwTCUAdZ", + "object": "card", + "address_city": None, + "address_country": None, + "address_line1": None, + "address_line1_check": None, + "address_line2": None, + "address_state": None, + "address_zip": None, + "address_zip_check": None, + "brand": "American Express", + "country": "US", + "customer": None, + "cvc_check": "unchecked", + "dynamic_last4": None, + "exp_month": 7, + "exp_year": 2019, + "fingerprint": "Xt5EWLLDS7FJjR1c", + "funding": "credit", + "last4": "1005", + "metadata": {"djstripe_test_fake_id": "card_fakefakefakefakefake0003"}, + "name": None, + "tokenization_method": None, + } ) FAKE_CARD_IV = CardDict( - { - "id": "card_186Qdm2eZvKYlo2CInjNRrRE", - "object": "card", - "address_city": None, - "address_country": None, - "address_line1": None, - "address_line1_check": None, - "address_line2": None, - "address_state": None, - "address_zip": None, - "address_zip_check": None, - "brand": "Visa", - "country": "US", - "customer": None, - "cvc_check": "unchecked", - "dynamic_last4": None, - "exp_month": 6, - "exp_year": 2018, - "funding": "credit", - "last4": "4242", - "metadata": {"djstripe_test_fake_id": "card_fakefakefakefakefake0004"}, - "name": None, - "tokenization_method": None, - } + { + "id": "card_186Qdm2eZvKYlo2CInjNRrRE", + "object": "card", + "address_city": None, + "address_country": None, + "address_line1": None, + "address_line1_check": None, + "address_line2": None, + "address_state": None, + "address_zip": None, + "address_zip_check": None, + "brand": "Visa", + "country": "US", + "customer": None, + "cvc_check": "unchecked", + "dynamic_last4": None, + "exp_month": 6, + "exp_year": 2018, + "funding": "credit", + "last4": "4242", + "metadata": {"djstripe_test_fake_id": "card_fakefakefakefakefake0004"}, + "name": None, + "tokenization_method": None, + } ) FAKE_CARD_V = CardDict(load_fixture("card_card_fakefakefakefakefake0005.json")) class SourceDict(dict): - def detach(self): - self.pop("customer") - self.update({"status": "consumed"}) - return self + def detach(self): + self.pop("customer") + self.update({"status": "consumed"}) + return self # Attached, chargeable source @@ -309,54 +311,54 @@ def detach(self): # Detached, consumed source FAKE_SOURCE_II = SourceDict( - { - "id": "src_1DuuGjkE6hxDGaasasjdlajl", - "object": "source", - "amount": None, - "card": { - "address_line1_check": None, - "address_zip_check": "pass", - "brand": "Visa", - "country": "US", - "cvc_check": "pass", - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2029, - "fingerprint": "TmOrYzPdAoO6YFNB", - "funding": "credit", - "last4": "4242", - "name": None, - "three_d_secure": "optional", - "tokenization_method": None, - }, - "client_secret": "src_client_secret_ENg5dyB1KTXCAEJGJQWEf67X", - "created": 1548046215, - "currency": None, - "flow": "none", - "livemode": False, - "metadata": {"djstripe_test_fake_id": "src_fakefakefakefakefake0002"}, - "owner": { - "address": { - "city": None, - "country": None, - "line1": None, - "line2": None, - "postal_code": "90210", - "state": None, - }, - "email": None, - "name": None, - "phone": None, - "verified_address": None, - "verified_email": None, - "verified_name": None, - "verified_phone": None, - }, - "statement_descriptor": None, - "status": "consumed", - "type": "card", - "usage": "reusable", - } + { + "id": "src_1DuuGjkE6hxDGaasasjdlajl", + "object": "source", + "amount": None, + "card": { + "address_line1_check": None, + "address_zip_check": "pass", + "brand": "Visa", + "country": "US", + "cvc_check": "pass", + "dynamic_last4": None, + "exp_month": 10, + "exp_year": 2029, + "fingerprint": "TmOrYzPdAoO6YFNB", + "funding": "credit", + "last4": "4242", + "name": None, + "three_d_secure": "optional", + "tokenization_method": None, + }, + "client_secret": "src_client_secret_ENg5dyB1KTXCAEJGJQWEf67X", + "created": 1548046215, + "currency": None, + "flow": "none", + "livemode": False, + "metadata": {"djstripe_test_fake_id": "src_fakefakefakefakefake0002"}, + "owner": { + "address": { + "city": None, + "country": None, + "line1": None, + "line2": None, + "postal_code": "90210", + "state": None, + }, + "email": None, + "name": None, + "phone": None, + "verified_address": None, + "verified_email": None, + "verified_name": None, + "verified_phone": None, + }, + "statement_descriptor": None, + "status": "consumed", + "type": "card", + "usage": "reusable", + } ) @@ -365,178 +367,178 @@ def detach(self): class ChargeDict(dict): - def refund(self, amount=None, reason=None): - self.update({"refunded": True, "amount_refunded": amount}) - return self + def refund(self, amount=None, reason=None): + self.update({"refunded": True, "amount_refunded": amount}) + return self - def capture(self): - self.update({"captured": True}) - return self + def capture(self): + self.update({"captured": True}) + return self FAKE_CHARGE = ChargeDict(load_fixture("charge_ch_fakefakefakefakefake0001.json")) FAKE_CHARGE_II = ChargeDict( - { - "id": "ch_16ag432eZvKYlo2CGDe6lvVs", - "object": "charge", - "amount": 3000, - "amount_refunded": 0, - "application_fee": None, - "balance_transaction": FAKE_BALANCE_TRANSACTION["id"], - "captured": False, - "created": 1439788903, - "currency": "usd", - "customer": "cus_4UbFSo9tl62jqj", - "description": None, - "destination": None, - "dispute": None, - "failure_code": "expired_card", - "failure_message": "Your card has expired.", - "fraud_details": {}, - "invoice": "in_16af5A2eZvKYlo2CJjANLL81", - "livemode": False, - "metadata": {}, - "order": None, - "outcome": { - "network_status": "declined_by_network", - "reason": "expired_card", - "risk_level": "normal", - "seller_message": "The bank returned the decline code `expired_card`.", - "type": "issuer_declined", - }, - "paid": False, - "receipt_email": None, - "receipt_number": None, - "refunded": False, - "refunds": { - "object": "list", - "total_count": 0, - "has_more": False, - "url": "/v1/charges/ch_16ag432eZvKYlo2CGDe6lvVs/refunds", - "data": [], - }, - "shipping": None, - "source": deepcopy(FAKE_CARD_II), - "source_transfer": None, - "statement_descriptor": None, - "status": "failed", - } + { + "id": "ch_16ag432eZvKYlo2CGDe6lvVs", + "object": "charge", + "amount": 3000, + "amount_refunded": 0, + "application_fee": None, + "balance_transaction": FAKE_BALANCE_TRANSACTION["id"], + "captured": False, + "created": 1439788903, + "currency": "usd", + "customer": "cus_4UbFSo9tl62jqj", + "description": None, + "destination": None, + "dispute": None, + "failure_code": "expired_card", + "failure_message": "Your card has expired.", + "fraud_details": {}, + "invoice": "in_16af5A2eZvKYlo2CJjANLL81", + "livemode": False, + "metadata": {}, + "order": None, + "outcome": { + "network_status": "declined_by_network", + "reason": "expired_card", + "risk_level": "normal", + "seller_message": "The bank returned the decline code `expired_card`.", + "type": "issuer_declined", + }, + "paid": False, + "receipt_email": None, + "receipt_number": None, + "refunded": False, + "refunds": { + "object": "list", + "total_count": 0, + "has_more": False, + "url": "/v1/charges/ch_16ag432eZvKYlo2CGDe6lvVs/refunds", + "data": [], + }, + "shipping": None, + "source": deepcopy(FAKE_CARD_II), + "source_transfer": None, + "statement_descriptor": None, + "status": "failed", + } ) FAKE_CHARGE_REFUNDED = deepcopy(FAKE_CHARGE) FAKE_CHARGE_REFUNDED = FAKE_CHARGE_REFUNDED.refund( - amount=FAKE_CHARGE_REFUNDED["amount"] + amount=FAKE_CHARGE_REFUNDED["amount"] ) FAKE_REFUND = { - "id": "re_1E0he8KatMEEd8456454S01Vc", - "object": "refund", - "amount": FAKE_CHARGE_REFUNDED["amount_refunded"], - "balance_transaction": "txn_1E0he8KaGRDEd998TDswMZuN", - "charge": FAKE_CHARGE_REFUNDED["id"], - "created": 1549425864, - "currency": "usd", - "metadata": {}, - "reason": None, - "receipt_number": None, - "source_transfer_reversal": None, - "status": "succeeded", - "transfer_reversal": None, + "id": "re_1E0he8KatMEEd8456454S01Vc", + "object": "refund", + "amount": FAKE_CHARGE_REFUNDED["amount_refunded"], + "balance_transaction": "txn_1E0he8KaGRDEd998TDswMZuN", + "charge": FAKE_CHARGE_REFUNDED["id"], + "created": 1549425864, + "currency": "usd", + "metadata": {}, + "reason": None, + "receipt_number": None, + "source_transfer_reversal": None, + "status": "succeeded", + "transfer_reversal": None, } # Balance transaction associated with the refund FAKE_BALANCE_TRANSACTION_REFUND = { - "id": "txn_1E0he8KaGRDEd998TDswMZuN", - "amount": -1 * FAKE_CHARGE_REFUNDED["amount_refunded"], - "available_on": 1549425864, - "created": 1549425864, - "currency": "usd", - "description": "REFUND FOR CHARGE (Payment for invoice G432DF1C-0028)", - "exchange_rate": None, - "fee": 0, - "fee_details": [], - "net": -1 * FAKE_CHARGE_REFUNDED["amount_refunded"], - "object": "balance_transaction", - "source": FAKE_REFUND["id"], - "status": "available", - "type": "refund", + "id": "txn_1E0he8KaGRDEd998TDswMZuN", + "amount": -1 * FAKE_CHARGE_REFUNDED["amount_refunded"], + "available_on": 1549425864, + "created": 1549425864, + "currency": "usd", + "description": "REFUND FOR CHARGE (Payment for invoice G432DF1C-0028)", + "exchange_rate": None, + "fee": 0, + "fee_details": [], + "net": -1 * FAKE_CHARGE_REFUNDED["amount_refunded"], + "object": "balance_transaction", + "source": FAKE_REFUND["id"], + "status": "available", + "type": "refund", } FAKE_CHARGE_REFUNDED["refunds"].update( - {"total_count": 1, "data": [deepcopy(FAKE_REFUND)]} + {"total_count": 1, "data": [deepcopy(FAKE_REFUND)]} ) FAKE_COUPON = { - "id": "fake-coupon-1", - "object": "coupon", - "amount_off": None, - "created": 1490157071, - "currency": None, - "duration": "once", - "duration_in_months": None, - "livemode": False, - "max_redemptions": None, - "metadata": {}, - "percent_off": 1, - "redeem_by": None, - "times_redeemed": 0, - "valid": True, + "id": "fake-coupon-1", + "object": "coupon", + "amount_off": None, + "created": 1490157071, + "currency": None, + "duration": "once", + "duration_in_months": None, + "livemode": False, + "max_redemptions": None, + "metadata": {}, + "percent_off": 1, + "redeem_by": None, + "times_redeemed": 0, + "valid": True, } FAKE_DISPUTE = { - "id": "dp_XXXXXXXXXXXXXXXXXXXXXXXX", - "object": "dispute", - "amount": 499, - "balance_transaction": FAKE_BALANCE_TRANSACTION_III["id"], - "balance_transactions": [deepcopy(FAKE_BALANCE_TRANSACTION_III)], - "charge": FAKE_CHARGE["id"], - "created": 1515012086, - "currency": "usd", - "evidence": { - "access_activity_log": None, - "billing_address": None, - "cancellation_policy": None, - "cancellation_policy_disclosure": None, - "cancellation_rebuttal": None, - "customer_communication": None, - "customer_email_address": "customer@example.com", - "customer_name": "customer@example.com", - "customer_purchase_ip": "127.0.0.1", - "customer_signature": None, - "duplicate_charge_documentation": None, - "duplicate_charge_explanation": None, - "duplicate_charge_id": None, - "product_description": None, - "receipt": "file_XXXXXXXXXXXXXXXXXXXXXXXX", - "refund_policy": None, - "refund_policy_disclosure": None, - "refund_refusal_explanation": None, - "service_date": None, - "service_documentation": None, - "shipping_address": None, - "shipping_carrier": None, - "shipping_date": None, - "shipping_documentation": None, - "shipping_tracking_number": None, - "uncategorized_file": None, - "uncategorized_text": None, - }, - "evidence_details": { - "due_by": 1516406399, - "has_evidence": False, - "past_due": False, - "submission_count": 0, - }, - "is_charge_refundable": False, - "livemode": True, - "metadata": {}, - "reason": "subscription_canceled", - "status": "needs_response", + "id": "dp_XXXXXXXXXXXXXXXXXXXXXXXX", + "object": "dispute", + "amount": 499, + "balance_transaction": FAKE_BALANCE_TRANSACTION_III["id"], + "balance_transactions": [deepcopy(FAKE_BALANCE_TRANSACTION_III)], + "charge": FAKE_CHARGE["id"], + "created": 1515012086, + "currency": "usd", + "evidence": { + "access_activity_log": None, + "billing_address": None, + "cancellation_policy": None, + "cancellation_policy_disclosure": None, + "cancellation_rebuttal": None, + "customer_communication": None, + "customer_email_address": "customer@example.com", + "customer_name": "customer@example.com", + "customer_purchase_ip": "127.0.0.1", + "customer_signature": None, + "duplicate_charge_documentation": None, + "duplicate_charge_explanation": None, + "duplicate_charge_id": None, + "product_description": None, + "receipt": "file_XXXXXXXXXXXXXXXXXXXXXXXX", + "refund_policy": None, + "refund_policy_disclosure": None, + "refund_refusal_explanation": None, + "service_date": None, + "service_documentation": None, + "shipping_address": None, + "shipping_carrier": None, + "shipping_date": None, + "shipping_documentation": None, + "shipping_tracking_number": None, + "uncategorized_file": None, + "uncategorized_text": None, + }, + "evidence_details": { + "due_by": 1516406399, + "has_evidence": False, + "past_due": False, + "submission_count": 0, + }, + "is_charge_refundable": False, + "livemode": True, + "metadata": {}, + "reason": "subscription_canceled", + "status": "needs_response", } @@ -546,85 +548,85 @@ def capture(self): FAKE_PLAN_II = load_fixture("plan_silver41294.json") for plan in (FAKE_PLAN, FAKE_PLAN_II): - # sanity check - assert plan["product"] == FAKE_PRODUCT["id"] + # sanity check + assert plan["product"] == FAKE_PRODUCT["id"] FAKE_TIER_PLAN = { - "id": "tier21323", - "object": "plan", - "active": True, - "amount": None, - "created": 1386247539, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": {}, - "nickname": "New plan name", - "product": FAKE_PRODUCT["id"], - "trial_period_days": None, - "usage_type": "licensed", - "tiers_mode": "graduated", - "tiers": [ - {"flat_amount": 4900, "unit_amount": 1000, "up_to": 5}, - {"flat_amount": None, "unit_amount": 900, "up_to": None}, - ], + "id": "tier21323", + "object": "plan", + "active": True, + "amount": None, + "created": 1386247539, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": False, + "metadata": {}, + "nickname": "New plan name", + "product": FAKE_PRODUCT["id"], + "trial_period_days": None, + "usage_type": "licensed", + "tiers_mode": "graduated", + "tiers": [ + {"flat_amount": 4900, "unit_amount": 1000, "up_to": 5}, + {"flat_amount": None, "unit_amount": 900, "up_to": None}, + ], } FAKE_PLAN_METERED = { - "id": "plan_fakemetered", - "object": "plan", - "active": True, - "aggregate_usage": "sum", - "amount": 200, - "billing_scheme": "per_unit", - "created": 1552632817, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": {}, - "nickname": "Sum Metered Plan", - "product": FAKE_PRODUCT["id"], - "tiers": None, - "tiers_mode": None, - "transform_usage": None, - "trial_period_days": None, - "usage_type": "metered", + "id": "plan_fakemetered", + "object": "plan", + "active": True, + "aggregate_usage": "sum", + "amount": 200, + "billing_scheme": "per_unit", + "created": 1552632817, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": False, + "metadata": {}, + "nickname": "Sum Metered Plan", + "product": FAKE_PRODUCT["id"], + "tiers": None, + "tiers_mode": None, + "transform_usage": None, + "trial_period_days": None, + "usage_type": "metered", } class SubscriptionDict(dict): - def __setattr__(self, name, value): - if type(value) == datetime: - value = datetime_to_unix(value) + def __setattr__(self, name, value): + if type(value) == datetime: + value = datetime_to_unix(value) - # Special case for plan - if name == "plan": - for plan in [FAKE_PLAN, FAKE_PLAN_II, FAKE_TIER_PLAN, FAKE_PLAN_METERED]: - if value == plan["id"]: - value = plan + # Special case for plan + if name == "plan": + for plan in [FAKE_PLAN, FAKE_PLAN_II, FAKE_TIER_PLAN, FAKE_PLAN_METERED]: + if value == plan["id"]: + value = plan - self[name] = value + self[name] = value - def delete(self, **kwargs): - if "at_period_end" in kwargs: - self["cancel_at_period_end"] = kwargs["at_period_end"] + def delete(self, **kwargs): + if "at_period_end" in kwargs: + self["cancel_at_period_end"] = kwargs["at_period_end"] - return self + return self - def save(self, idempotency_key=None): - return self + def save(self, idempotency_key=None): + return self FAKE_SUBSCRIPTION = SubscriptionDict( - load_fixture("subscription_sub_fakefakefakefakefake0001.json") + load_fixture("subscription_sub_fakefakefakefakefake0001.json") ) FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT = deepcopy(FAKE_SUBSCRIPTION) FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT.update( - {"current_period_end": 1441907581, "current_period_start": 1439229181} + {"current_period_end": 1441907581, "current_period_start": 1439229181} ) FAKE_SUBSCRIPTION_CANCELED = deepcopy(FAKE_SUBSCRIPTION) @@ -636,101 +638,101 @@ def save(self, idempotency_key=None): FAKE_SUBSCRIPTION_CANCELED_AT_PERIOD_END["cancel_at_period_end"] = True FAKE_SUBSCRIPTION_II = SubscriptionDict( - load_fixture("subscription_sub_fakefakefakefakefake0002.json") + load_fixture("subscription_sub_fakefakefakefakefake0002.json") ) FAKE_SUBSCRIPTION_III = SubscriptionDict( - load_fixture("subscription_sub_fakefakefakefakefake0003.json") + load_fixture("subscription_sub_fakefakefakefakefake0003.json") ) FAKE_SUBSCRIPTION_MULTI_PLAN = SubscriptionDict( - load_fixture("subscription_sub_fakefakefakefakefake0004.json") + load_fixture("subscription_sub_fakefakefakefakefake0004.json") ) FAKE_SUBSCRIPTION_METERED = SubscriptionDict( - { - "id": "sub_1rn1dp7WgjMtx9", - "object": "subscription", - "application_fee_percent": None, - "billing": "charge_automatically", - "cancel_at_period_end": False, - "canceled_at": None, - "current_period_end": 1441907581, - "current_period_start": 1439229181, - "customer": "cus_6lsBvm5rJ0zyHc", - "discount": None, - "ended_at": None, - "metadata": {"djstripe_test_fake_id": "sub_fakefakefakefakefake0005"}, - "items": { - "data": [ - { - "created": 1441907581, - "id": "si_UXYmKmJp6aWTw6", - "metadata": {}, - "object": "subscription_item", - "plan": deepcopy(FAKE_PLAN_METERED), - "subscription": "sub_1rn1dp7WgjMtx9", - } - ] - }, - "plan": deepcopy(FAKE_PLAN_METERED), - "quantity": 1, - "start": 1439229181, - "status": "active", - "tax_percent": None, - "trial_end": None, - "trial_start": None, - } + { + "id": "sub_1rn1dp7WgjMtx9", + "object": "subscription", + "application_fee_percent": None, + "billing": "charge_automatically", + "cancel_at_period_end": False, + "canceled_at": None, + "current_period_end": 1441907581, + "current_period_start": 1439229181, + "customer": "cus_6lsBvm5rJ0zyHc", + "discount": None, + "ended_at": None, + "metadata": {"djstripe_test_fake_id": "sub_fakefakefakefakefake0005"}, + "items": { + "data": [ + { + "created": 1441907581, + "id": "si_UXYmKmJp6aWTw6", + "metadata": {}, + "object": "subscription_item", + "plan": deepcopy(FAKE_PLAN_METERED), + "subscription": "sub_1rn1dp7WgjMtx9", + } + ] + }, + "plan": deepcopy(FAKE_PLAN_METERED), + "quantity": 1, + "start": 1439229181, + "status": "active", + "tax_percent": None, + "trial_end": None, + "trial_start": None, + } ) class Sources(object): - def __init__(self, card_fakes): - self.card_fakes = card_fakes + def __init__(self, card_fakes): + self.card_fakes = card_fakes - def create(self, source, api_key=None): - for fake_card in self.card_fakes: - if fake_card["id"] == source: - return fake_card + def create(self, source, api_key=None): + for fake_card in self.card_fakes: + if fake_card["id"] == source: + return fake_card - def retrieve(self, id, expand=None): # noqa - for fake_card in self.card_fakes: - if fake_card["id"] == id: - return fake_card + def retrieve(self, id, expand=None): # noqa + for fake_card in self.card_fakes: + if fake_card["id"] == id: + return fake_card - def list(self, **kwargs): - return StripeList(data=self.card_fakes) + def list(self, **kwargs): + return StripeList(data=self.card_fakes) class CustomerDict(dict): - def save(self, idempotency_key=None): - return self + def save(self, idempotency_key=None): + return self - def delete(self): - return self + def delete(self): + return self - @property - def sources(self): - return Sources(card_fakes=self["sources"]["data"]) + @property + def sources(self): + return Sources(card_fakes=self["sources"]["data"]) - def create_for_user(self, user): - from djstripe.models import Customer + def create_for_user(self, user): + from djstripe.models import Customer - stripe_customer = Customer.sync_from_stripe_data(self) - stripe_customer.subscriber = user - stripe_customer.save() - return stripe_customer + stripe_customer = Customer.sync_from_stripe_data(self) + stripe_customer.subscriber = user + stripe_customer.save() + return stripe_customer FAKE_CUSTOMER = CustomerDict(load_fixture("customer_cus_6lsBvm5rJ0zyHc.json")) if FAKE_CUSTOMER["default_source"]: - FAKE_CUSTOMER["default_source"] = CardDict(FAKE_CUSTOMER["default_source"]) + FAKE_CUSTOMER["default_source"] = CardDict(FAKE_CUSTOMER["default_source"]) for n, d in enumerate(FAKE_CUSTOMER["sources"].get("data", [])): - FAKE_CUSTOMER["sources"]["data"][n] = CardDict(d) + FAKE_CUSTOMER["sources"]["data"][n] = CardDict(d) FAKE_CUSTOMER_II = CustomerDict(load_fixture("customer_cus_4UbFSo9tl62jqj.json")) @@ -741,647 +743,651 @@ def create_for_user(self, user): FAKE_DISCOUNT_CUSTOMER = { - "object": "discount", - "coupon": deepcopy(FAKE_COUPON), - "customer": FAKE_CUSTOMER["id"], - "start": 1493206114, - "end": None, - "subscription": None, + "object": "discount", + "coupon": deepcopy(FAKE_COUPON), + "customer": FAKE_CUSTOMER["id"], + "start": 1493206114, + "end": None, + "subscription": None, } class InvoiceDict(dict): - def pay(self): - return self + def pay(self): + return self FAKE_INVOICE = InvoiceDict(load_fixture("invoice_in_fakefakefakefakefake0001.json")) FAKE_INVOICE_II = InvoiceDict( - { - "id": "in_16af5A2eZvKYlo2CJjANLL81", - "object": "invoice", - "amount_due": 3000, - "amount_paid": 0, - "amount_remaining": 3000, - "application_fee_amount": None, - "attempt_count": 1, - "attempted": True, - "auto_advance": True, - "billing": "charge_automatically", - "charge": FAKE_CHARGE_II["id"], - "currency": "usd", - "customer": "cus_4UbFSo9tl62jqj", - "created": 1439785128, - "description": None, - "discount": None, - "due_date": None, - "ending_balance": 0, - "lines": { - "data": [ - { - "id": FAKE_SUBSCRIPTION_III["id"], - "object": "line_item", - "amount": 2000, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": {}, - "period": {"start": 1442469907, "end": 1445061907}, - "plan": deepcopy(FAKE_PLAN), - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription", - } - ], - "total_count": 1, - "object": "list", - "url": "/v1/invoices/in_16af5A2eZvKYlo2CJjANLL81/lines", - }, - "livemode": False, - "metadata": {}, - "next_payment_attempt": 1440048103, - "number": "XXXXXXX-0002", - "paid": False, - "period_end": 1439784771, - "period_start": 1439698371, - "receipt_number": None, - "starting_balance": 0, - "statement_descriptor": None, - "subscription": FAKE_SUBSCRIPTION_III["id"], - "subtotal": 3000, - "tax": None, - "tax_percent": None, - "total": 3000, - "webhooks_delivered_at": 1439785139, - } + { + "id": "in_16af5A2eZvKYlo2CJjANLL81", + "object": "invoice", + "amount_due": 3000, + "amount_paid": 0, + "amount_remaining": 3000, + "application_fee_amount": None, + "attempt_count": 1, + "attempted": True, + "auto_advance": True, + "billing": "charge_automatically", + "charge": FAKE_CHARGE_II["id"], + "currency": "usd", + "customer": "cus_4UbFSo9tl62jqj", + "created": 1439785128, + "description": None, + "discount": None, + "due_date": None, + "ending_balance": 0, + "lines": { + "data": [ + { + "id": FAKE_SUBSCRIPTION_III["id"], + "object": "line_item", + "amount": 2000, + "currency": "usd", + "description": None, + "discountable": True, + "livemode": True, + "metadata": {}, + "period": {"start": 1442469907, "end": 1445061907}, + "plan": deepcopy(FAKE_PLAN), + "proration": False, + "quantity": 1, + "subscription": None, + "type": "subscription", + } + ], + "total_count": 1, + "object": "list", + "url": "/v1/invoices/in_16af5A2eZvKYlo2CJjANLL81/lines", + }, + "livemode": False, + "metadata": {}, + "next_payment_attempt": 1440048103, + "number": "XXXXXXX-0002", + "paid": False, + "period_end": 1439784771, + "period_start": 1439698371, + "receipt_number": None, + "starting_balance": 0, + "statement_descriptor": None, + "subscription": FAKE_SUBSCRIPTION_III["id"], + "subtotal": 3000, + "tax": None, + "tax_percent": None, + "total": 3000, + "webhooks_delivered_at": 1439785139, + } ) FAKE_INVOICE_III = InvoiceDict( - { - "id": "in_16Z9dP2eZvKYlo2CgFHgFx2Z", - "object": "invoice", - "amount_due": 0, - "amount_paid": 0, - "amount_remaining": 0, - "application_fee_amount": None, - "attempt_count": 0, - "attempted": True, - "auto_advance": True, - "billing": "charge_automatically", - "charge": None, - "created": 1439425915, - "currency": "usd", - "customer": "cus_6lsBvm5rJ0zyHc", - "description": None, - "discount": None, - "due_date": None, - "ending_balance": 20, - "lines": { - "data": [ - { - "id": FAKE_SUBSCRIPTION["id"], - "object": "line_item", - "amount": 2000, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": {}, - "period": {"start": 1442111228, "end": 1444703228}, - "plan": deepcopy(FAKE_PLAN), - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription", - } - ], - "total_count": 1, - "object": "list", - "url": "/v1/invoices/in_16Z9dP2eZvKYlo2CgFHgFx2Z/lines", - }, - "livemode": False, - "metadata": {}, - "next_payment_attempt": None, - "number": "XXXXXXX-0003", - "paid": False, - "period_end": 1439424571, - "period_start": 1436746171, - "receipt_number": None, - "starting_balance": 0, - "statement_descriptor": None, - "subscription": FAKE_SUBSCRIPTION["id"], - "subtotal": 20, - "tax": None, - "tax_percent": None, - "total": 20, - "webhooks_delivered_at": 1439426955, - } + { + "id": "in_16Z9dP2eZvKYlo2CgFHgFx2Z", + "object": "invoice", + "amount_due": 0, + "amount_paid": 0, + "amount_remaining": 0, + "application_fee_amount": None, + "attempt_count": 0, + "attempted": True, + "auto_advance": True, + "billing": "charge_automatically", + "charge": None, + "created": 1439425915, + "currency": "usd", + "customer": "cus_6lsBvm5rJ0zyHc", + "description": None, + "discount": None, + "due_date": None, + "ending_balance": 20, + "lines": { + "data": [ + { + "id": FAKE_SUBSCRIPTION["id"], + "object": "line_item", + "amount": 2000, + "currency": "usd", + "description": None, + "discountable": True, + "livemode": True, + "metadata": {}, + "period": {"start": 1442111228, "end": 1444703228}, + "plan": deepcopy(FAKE_PLAN), + "proration": False, + "quantity": 1, + "subscription": None, + "type": "subscription", + } + ], + "total_count": 1, + "object": "list", + "url": "/v1/invoices/in_16Z9dP2eZvKYlo2CgFHgFx2Z/lines", + }, + "livemode": False, + "metadata": {}, + "next_payment_attempt": None, + "number": "XXXXXXX-0003", + "paid": False, + "period_end": 1439424571, + "period_start": 1436746171, + "receipt_number": None, + "starting_balance": 0, + "statement_descriptor": None, + "subscription": FAKE_SUBSCRIPTION["id"], + "subtotal": 20, + "tax": None, + "tax_percent": None, + "total": 20, + "webhooks_delivered_at": 1439426955, + } ) FAKE_UPCOMING_INVOICE = InvoiceDict( - { - "id": "in", - "object": "invoice", - "amount_due": 2000, - "amount_paid": 0, - "amount_remaining": 2000, - "application_fee_amount": None, - "attempt_count": 1, - "attempted": False, - "billing": "charge_automatically", - "charge": None, - "created": 1439218864, - "currency": "usd", - "customer": FAKE_CUSTOMER["id"], - "description": None, - "discount": None, - "due_date": None, - "ending_balance": None, - "lines": { - "data": [ - { - "id": FAKE_SUBSCRIPTION["id"], - "object": "line_item", - "amount": 2000, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": {}, - "period": {"start": 1441907581, "end": 1444499581}, - "plan": deepcopy(FAKE_PLAN), - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription", - } - ], - "total_count": 1, - "object": "list", - "url": "/v1/invoices/in_fakefakefakefakefake0001/lines", - }, - "livemode": False, - "metadata": {}, - "next_payment_attempt": 1439218689, - "number": None, - "paid": False, - "period_end": 1439218689, - "period_start": 1439132289, - "receipt_number": None, - "starting_balance": 0, - "statement_descriptor": None, - "subscription": FAKE_SUBSCRIPTION["id"], - "subtotal": 2000, - "tax": None, - "tax_percent": None, - "total": 2000, - "webhooks_delivered_at": 1439218870, - } + { + "id": "in", + "object": "invoice", + "amount_due": 2000, + "amount_paid": 0, + "amount_remaining": 2000, + "application_fee_amount": None, + "attempt_count": 1, + "attempted": False, + "billing": "charge_automatically", + "charge": None, + "created": 1439218864, + "currency": "usd", + "customer": FAKE_CUSTOMER["id"], + "description": None, + "discount": None, + "due_date": None, + "ending_balance": None, + "lines": { + "data": [ + { + "id": FAKE_SUBSCRIPTION["id"], + "object": "line_item", + "amount": 2000, + "currency": "usd", + "description": None, + "discountable": True, + "livemode": True, + "metadata": {}, + "period": {"start": 1441907581, "end": 1444499581}, + "plan": deepcopy(FAKE_PLAN), + "proration": False, + "quantity": 1, + "subscription": None, + "type": "subscription", + } + ], + "total_count": 1, + "object": "list", + "url": "/v1/invoices/in_fakefakefakefakefake0001/lines", + }, + "livemode": False, + "metadata": {}, + "next_payment_attempt": 1439218689, + "number": None, + "paid": False, + "period_end": 1439218689, + "period_start": 1439132289, + "receipt_number": None, + "starting_balance": 0, + "statement_descriptor": None, + "subscription": FAKE_SUBSCRIPTION["id"], + "subtotal": 2000, + "tax": None, + "tax_percent": None, + "total": 2000, + "webhooks_delivered_at": 1439218870, + } ) FAKE_INVOICEITEM = { - "id": "ii_16XVTY2eZvKYlo2Cxz5n3RaS", - "object": "invoiceitem", - "amount": 2000, - "currency": "usd", - "customer": FAKE_CUSTOMER_II["id"], - "date": 1439033216, - "description": "One-time setup fee", - "discountable": True, - "invoice": FAKE_INVOICE_II["id"], - "livemode": False, - "metadata": {"key1": "value1", "key2": "value2"}, - "period": {"start": 1439033216, "end": 1439033216}, - "plan": None, - "proration": False, - "quantity": None, - "subscription": None, + "id": "ii_16XVTY2eZvKYlo2Cxz5n3RaS", + "object": "invoiceitem", + "amount": 2000, + "currency": "usd", + "customer": FAKE_CUSTOMER_II["id"], + "date": 1439033216, + "description": "One-time setup fee", + "discountable": True, + "invoice": FAKE_INVOICE_II["id"], + "livemode": False, + "metadata": {"key1": "value1", "key2": "value2"}, + "period": {"start": 1439033216, "end": 1439033216}, + "plan": None, + "proration": False, + "quantity": None, + "subscription": None, } FAKE_INVOICEITEM_II = { - "id": "ii_16XVTY2eZvKYlo2Cxz5n3RaS", - "object": "invoiceitem", - "amount": 2000, - "currency": "usd", - "customer": FAKE_CUSTOMER["id"], - "date": 1439033216, - "description": "One-time setup fee", - "discountable": True, - "invoice": FAKE_INVOICE["id"], - "livemode": False, - "metadata": {"key1": "value1", "key2": "value2"}, - "period": {"start": 1439033216, "end": 1439033216}, - "plan": None, - "proration": False, - "quantity": None, - "subscription": None, + "id": "ii_16XVTY2eZvKYlo2Cxz5n3RaS", + "object": "invoiceitem", + "amount": 2000, + "currency": "usd", + "customer": FAKE_CUSTOMER["id"], + "date": 1439033216, + "description": "One-time setup fee", + "discountable": True, + "invoice": FAKE_INVOICE["id"], + "livemode": False, + "metadata": {"key1": "value1", "key2": "value2"}, + "period": {"start": 1439033216, "end": 1439033216}, + "plan": None, + "proration": False, + "quantity": None, + "subscription": None, } FAKE_TRANSFER = { - "id": "tr_16Y9BK2eZvKYlo2CR0ySu1BA", - "object": "transfer", - "amount": 100, - "amount_reversed": 0, - "application_fee_amount": None, - "balance_transaction": deepcopy(FAKE_BALANCE_TRANSACTION_II), - "created": 1439185846, - "currency": "usd", - "description": "Test description - 1439185984", - "destination": "acct_16Y9B9Fso9hLaeLu", - "destination_payment": "py_16Y9BKFso9hLaeLueFmWAYUi", - "livemode": False, - "metadata": {}, - "recipient": None, - "reversals": { - "object": "list", - "total_count": 0, - "has_more": False, - "url": "/v1/transfers/tr_16Y9BK2eZvKYlo2CR0ySu1BA/reversals", - "data": [], - }, - "reversed": False, - "source_transaction": None, - "source_type": "bank_account", + "id": "tr_16Y9BK2eZvKYlo2CR0ySu1BA", + "object": "transfer", + "amount": 100, + "amount_reversed": 0, + "application_fee_amount": None, + "balance_transaction": deepcopy(FAKE_BALANCE_TRANSACTION_II), + "created": 1439185846, + "currency": "usd", + "description": "Test description - 1439185984", + "destination": "acct_16Y9B9Fso9hLaeLu", + "destination_payment": "py_16Y9BKFso9hLaeLueFmWAYUi", + "livemode": False, + "metadata": {}, + "recipient": None, + "reversals": { + "object": "list", + "total_count": 0, + "has_more": False, + "url": "/v1/transfers/tr_16Y9BK2eZvKYlo2CR0ySu1BA/reversals", + "data": [], + }, + "reversed": False, + "source_transaction": None, + "source_type": "bank_account", } FAKE_TRANSFER_II = { - "id": "tr_16hTzv2eZvKYlo2CWuyMmuvV", - "object": "transfer", - "amount": 2000, - "amount_reversed": 0, - "application_fee_amount": None, - "balance_transaction": deepcopy(FAKE_BALANCE_TRANSACTION_III), - "bank_account": deepcopy(FAKE_BANK_ACCOUNT), - "created": 1440420000, - "currency": "usd", - "description": None, - "destination": "ba_16hTzo2eZvKYlo2CeSjfb0tS", - "livemode": False, - "metadata": {"foo": "bar"}, - "recipient": "rp_16hTzu2eZvKYlo2C9A5mgxEj", - "reversals": { - "object": "list", - "total_count": 0, - "has_more": False, - "url": "/v1/transfers/tr_16hTzv2eZvKYlo2CWuyMmuvV/reversals", - "data": [], - }, - "reversed": False, - "source_transaction": None, - "source_type": "card", + "id": "tr_16hTzv2eZvKYlo2CWuyMmuvV", + "object": "transfer", + "amount": 2000, + "amount_reversed": 0, + "application_fee_amount": None, + "balance_transaction": deepcopy(FAKE_BALANCE_TRANSACTION_III), + "bank_account": deepcopy(FAKE_BANK_ACCOUNT), + "created": 1440420000, + "currency": "usd", + "description": None, + "destination": "ba_16hTzo2eZvKYlo2CeSjfb0tS", + "livemode": False, + "metadata": {"foo": "bar"}, + "recipient": "rp_16hTzu2eZvKYlo2C9A5mgxEj", + "reversals": { + "object": "list", + "total_count": 0, + "has_more": False, + "url": "/v1/transfers/tr_16hTzv2eZvKYlo2CWuyMmuvV/reversals", + "data": [], + }, + "reversed": False, + "source_transaction": None, + "source_type": "card", } FAKE_TRANSFER_III = { - "id": "tr_17O4U52eZvKYlo2CmyYbDAEy", - "object": "transfer", - "amount": 19010, - "amount_reversed": 0, - "application_fee_amount": None, - "balance_transaction": deepcopy(FAKE_BALANCE_TRANSACTION_IV), - "bank_account": deepcopy(FAKE_BANK_ACCOUNT_II), - "created": 1451560845, - "currency": "usd", - "date": 1451560845, - "description": "Transfer+for+test@example.com", - "destination": "ba_17O4Tz2eZvKYlo2CMYsxroV5", - "livemode": False, - "metadata": {"foo2": "bar2"}, - "recipient": "rp_17O4U42eZvKYlo2CLk4upfDE", - "reversals": { - "object": "list", - "total_count": 0, - "has_more": False, - "url": "/v1/transfers/tr_17O4U52eZvKYlo2CmyYbDAEy/reversals", - "data": [], - }, - "reversed": False, - "source_transaction": None, - "source_type": "card", + "id": "tr_17O4U52eZvKYlo2CmyYbDAEy", + "object": "transfer", + "amount": 19010, + "amount_reversed": 0, + "application_fee_amount": None, + "balance_transaction": deepcopy(FAKE_BALANCE_TRANSACTION_IV), + "bank_account": deepcopy(FAKE_BANK_ACCOUNT_II), + "created": 1451560845, + "currency": "usd", + "date": 1451560845, + "description": "Transfer+for+test@example.com", + "destination": "ba_17O4Tz2eZvKYlo2CMYsxroV5", + "livemode": False, + "metadata": {"foo2": "bar2"}, + "recipient": "rp_17O4U42eZvKYlo2CLk4upfDE", + "reversals": { + "object": "list", + "total_count": 0, + "has_more": False, + "url": "/v1/transfers/tr_17O4U52eZvKYlo2CmyYbDAEy/reversals", + "data": [], + }, + "reversed": False, + "source_transaction": None, + "source_type": "card", } FAKE_ACCOUNT = { - "id": "acct_1032D82eZvKYlo2C", - "object": "account", - "business_profile": { - "name": "dj-stripe", - "support_email": "djstripe@example.com", - "support_phone": None, - "support_url": "https://example.com/support/", - # TODO - change this since stripe validation actually doesn't allow example.com - "url": "https://example.com", - }, - "settings": { - "branding": { - "icon": "file_4hshrsKatMEEd6736724HYAXyj", - "logo": "file_1E3fssKatMEEd6736724HYAXyj", - "primary_color": "#092e20", - }, - "dashboard": {"display_name": "dj-stripe", "timezone": "Etc/UTC"}, - "payments": {"statement_descriptor": "DJSTRIPE"}, - }, - "charges_enabled": True, - "country": "US", - "default_currency": "usd", - "details_submitted": True, - "email": "djstripe@example.com", - "payouts_enabled": True, - "type": "standard", + "id": "acct_1032D82eZvKYlo2C", + "object": "account", + "business_profile": { + "name": "dj-stripe", + "support_email": "djstripe@example.com", + "support_phone": None, + "support_url": "https://example.com/support/", + # TODO - change this since stripe validation actually doesn't allow example.com + "url": "https://example.com", + }, + "settings": { + "branding": { + "icon": "file_4hshrsKatMEEd6736724HYAXyj", + "logo": "file_1E3fssKatMEEd6736724HYAXyj", + "primary_color": "#092e20", + }, + "dashboard": {"display_name": "dj-stripe", "timezone": "Etc/UTC"}, + "payments": {"statement_descriptor": "DJSTRIPE"}, + }, + "charges_enabled": True, + "country": "US", + "default_currency": "usd", + "details_submitted": True, + "email": "djstripe@example.com", + "payouts_enabled": True, + "type": "standard", } FAKE_FILEUPLOAD_LOGO = { - "created": 1550134074, - "filename": "logo_preview.png", - "id": "file_1E3fssKatMEEd6736724HYAXyj", - "links": { - "data": [ - { - "created": 1550134074, - "expired": False, - "expires_at": None, - "file": "file_1E3fssKatMEEd6736724HYAXyj", - "id": "link_1E3fssKatMEEd673672V0JSH", - "livemode": False, - "metadata": {}, - "object": "file_link", - "url": "https://files.stripe.com/links/fl_test_69vG4ISDx9Chjklasrf06BJeQo", - } - ], - "has_more": False, - "object": "list", - "url": "/v1/file_links?file=file_1E3fssKatMEEd6736724HYAXyj", - }, - "object": "file_upload", - "purpose": "business_logo", - "size": 6650, - "type": "png", - "url": "https://files.stripe.com/files/f_test_BTJFKcS7VDahgkjqw8EVNWlM", + "created": 1550134074, + "filename": "logo_preview.png", + "id": "file_1E3fssKatMEEd6736724HYAXyj", + "links": { + "data": [ + { + "created": 1550134074, + "expired": False, + "expires_at": None, + "file": "file_1E3fssKatMEEd6736724HYAXyj", + "id": "link_1E3fssKatMEEd673672V0JSH", + "livemode": False, + "metadata": {}, + "object": "file_link", + "url": ( + "https://files.stripe.com/links/fl_test_69vG4ISDx9Chjklasrf06BJeQo" + ), + } + ], + "has_more": False, + "object": "list", + "url": "/v1/file_links?file=file_1E3fssKatMEEd6736724HYAXyj", + }, + "object": "file_upload", + "purpose": "business_logo", + "size": 6650, + "type": "png", + "url": "https://files.stripe.com/files/f_test_BTJFKcS7VDahgkjqw8EVNWlM", } FAKE_FILEUPLOAD_ICON = { - "created": 1550134074, - "filename": "icon_preview.png", - "id": "file_4hshrsKatMEEd6736724HYAXyj", - "links": { - "data": [ - { - "created": 1550134074, - "expired": False, - "expires_at": None, - "file": "file_4hshrsKatMEEd6736724HYAXyj", - "id": "link_4jsdgsKatMEEd673672V0JSH", - "livemode": False, - "metadata": {}, - "object": "file_link", - "url": "https://files.stripe.com/links/fl_test_69vG4ISDx9Chjklasrf06BJeQo", - } - ], - "has_more": False, - "object": "list", - "url": "/v1/file_links?file=file_4hshrsKatMEEd6736724HYAXyj", - }, - "object": "file_upload", - # Note that purpose="business_logo" for both icon and logo fields - "purpose": "business_logo", - "size": 6650, - "type": "png", - "url": "https://files.stripe.com/files/f_test_BTJFKcS7VDahgkjqw8EVNWlM", + "created": 1550134074, + "filename": "icon_preview.png", + "id": "file_4hshrsKatMEEd6736724HYAXyj", + "links": { + "data": [ + { + "created": 1550134074, + "expired": False, + "expires_at": None, + "file": "file_4hshrsKatMEEd6736724HYAXyj", + "id": "link_4jsdgsKatMEEd673672V0JSH", + "livemode": False, + "metadata": {}, + "object": "file_link", + "url": ( + "https://files.stripe.com/links/fl_test_69vG4ISDx9Chjklasrf06BJeQo" + ), + } + ], + "has_more": False, + "object": "list", + "url": "/v1/file_links?file=file_4hshrsKatMEEd6736724HYAXyj", + }, + "object": "file_upload", + # Note that purpose="business_logo" for both icon and logo fields + "purpose": "business_logo", + "size": 6650, + "type": "png", + "url": "https://files.stripe.com/files/f_test_BTJFKcS7VDahgkjqw8EVNWlM", } FAKE_EVENT_ACCOUNT_APPLICATION_DEAUTHORIZED = { - "id": "evt_XXXXXXXXXXXXXXXXXXXXXXXX", - "type": "account.application.deauthorized", - "pending_webhooks": 0, - "livemode": False, - "request": None, - "api_version": None, - "created": 1493823371, - "object": "event", - "data": { - "object": { - "id": "ca_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - "object": "application", - "name": "Test Connect Application", - } - }, + "id": "evt_XXXXXXXXXXXXXXXXXXXXXXXX", + "type": "account.application.deauthorized", + "pending_webhooks": 0, + "livemode": False, + "request": None, + "api_version": None, + "created": 1493823371, + "object": "event", + "data": { + "object": { + "id": "ca_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "object": "application", + "name": "Test Connect Application", + } + }, } # 2017-05-25 api changed request from id to object with id and idempotency_key # issue #541 FAKE_EVENT_PLAN_REQUEST_IS_OBJECT = { - "id": "evt_1AcdbXXXXXXXXXXXXXXXXXXX", - "object": "event", - "api_version": "2017-06-05", - "created": 1499361420, - "data": {"object": FAKE_PLAN, "previous_attributes": {"name": "Plan anual test4"}}, - "livemode": False, - "pending_webhooks": 1, - "request": {"id": "req_AyamqQWoi5AMR2", "idempotency_key": None}, - "type": "plan.updated", + "id": "evt_1AcdbXXXXXXXXXXXXXXXXXXX", + "object": "event", + "api_version": "2017-06-05", + "created": 1499361420, + "data": {"object": FAKE_PLAN, "previous_attributes": {"name": "Plan anual test4"}}, + "livemode": False, + "pending_webhooks": 1, + "request": {"id": "req_AyamqQWoi5AMR2", "idempotency_key": None}, + "type": "plan.updated", } FAKE_EVENT_CHARGE_SUCCEEDED = { - "id": "evt_16YKQi2eZvKYlo2CT2oe5ff3", - "object": "event", - "api_version": "2016-03-07", - "created": 1439229084, - "data": {"object": deepcopy(FAKE_CHARGE)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_6lsB7hkicwhaDj", - "type": "charge.succeeded", + "id": "evt_16YKQi2eZvKYlo2CT2oe5ff3", + "object": "event", + "api_version": "2016-03-07", + "created": 1439229084, + "data": {"object": deepcopy(FAKE_CHARGE)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_6lsB7hkicwhaDj", + "type": "charge.succeeded", } FAKE_EVENT_TEST_CHARGE_SUCCEEDED = deepcopy(FAKE_EVENT_CHARGE_SUCCEEDED) FAKE_EVENT_TEST_CHARGE_SUCCEEDED["id"] = TEST_EVENT_ID FAKE_EVENT_CUSTOMER_CREATED = { - "id": "evt_38DHch3whaDvKYlo2CT2oe5ff3", - "object": "event", - "api_version": "2016-03-07", - "created": 1439229084, - "data": {"object": deepcopy(FAKE_CUSTOMER)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_6l38DHch3whaDj", - "type": "customer.created", + "id": "evt_38DHch3whaDvKYlo2CT2oe5ff3", + "object": "event", + "api_version": "2016-03-07", + "created": 1439229084, + "data": {"object": deepcopy(FAKE_CUSTOMER)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_6l38DHch3whaDj", + "type": "customer.created", } FAKE_EVENT_CUSTOMER_DELETED = deepcopy(FAKE_EVENT_CUSTOMER_CREATED) FAKE_EVENT_CUSTOMER_DELETED.update( - {"id": "evt_38DHch3whaDvKYlo2jksfsFFxy", "type": "customer.deleted"} + {"id": "evt_38DHch3whaDvKYlo2jksfsFFxy", "type": "customer.deleted"} ) FAKE_EVENT_CUSTOMER_DISCOUNT_CREATED = { - "id": "evt_test_customer.discount.created", - "object": "event", - "api_version": "2018-05-21", - "created": 1439229084, - "data": {"object": deepcopy(FAKE_DISCOUNT_CUSTOMER)}, - "livemode": False, - "pending_webhooks": 1, - "request": {"id": "req_6l38DHch3whaDj", "idempotency_key": None}, - "type": "customer.discount.created", + "id": "evt_test_customer.discount.created", + "object": "event", + "api_version": "2018-05-21", + "created": 1439229084, + "data": {"object": deepcopy(FAKE_DISCOUNT_CUSTOMER)}, + "livemode": False, + "pending_webhooks": 1, + "request": {"id": "req_6l38DHch3whaDj", "idempotency_key": None}, + "type": "customer.discount.created", } FAKE_EVENT_CUSTOMER_DISCOUNT_DELETED = { - "id": "AGBWvF5zBm4sMCsLLPZrw9XX", - "type": "customer.discount.deleted", - "api_version": "2017-02-14", - "created": 1439229084, - "object": "event", - "pending_webhooks": 0, - "request": "req_6l38DHch3whaDj", - "data": {"object": deepcopy(FAKE_DISCOUNT_CUSTOMER)}, + "id": "AGBWvF5zBm4sMCsLLPZrw9XX", + "type": "customer.discount.deleted", + "api_version": "2017-02-14", + "created": 1439229084, + "object": "event", + "pending_webhooks": 0, + "request": "req_6l38DHch3whaDj", + "data": {"object": deepcopy(FAKE_DISCOUNT_CUSTOMER)}, } FAKE_EVENT_CUSTOMER_SOURCE_CREATED = { - "id": "evt_DvKYlo38huDvKYlo2C7SXedrZk", - "object": "event", - "api_version": "2016-03-07", - "created": 1439229084, - "data": {"object": deepcopy(FAKE_CARD)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_o3whaDvh3whaDj", - "type": "customer.source.created", + "id": "evt_DvKYlo38huDvKYlo2C7SXedrZk", + "object": "event", + "api_version": "2016-03-07", + "created": 1439229084, + "data": {"object": deepcopy(FAKE_CARD)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_o3whaDvh3whaDj", + "type": "customer.source.created", } FAKE_EVENT_CUSTOMER_SOURCE_DELETED = deepcopy(FAKE_EVENT_CUSTOMER_SOURCE_CREATED) FAKE_EVENT_CUSTOMER_SOURCE_DELETED.update( - {"id": "evt_DvKYlo38huDvKYlo2C7SXedrYk", "type": "customer.source.deleted"} + {"id": "evt_DvKYlo38huDvKYlo2C7SXedrYk", "type": "customer.source.deleted"} ) FAKE_EVENT_CUSTOMER_SOURCE_DELETED_DUPE = deepcopy(FAKE_EVENT_CUSTOMER_SOURCE_DELETED) FAKE_EVENT_CUSTOMER_SOURCE_DELETED_DUPE.update({"id": "evt_DvKYlo38huDvKYlo2C7SXedzAk"}) FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED = { - "id": "evt_38DHch3wHD2eZvKYlCT2oe5ff3", - "object": "event", - "api_version": "2016-03-07", - "created": 1439229084, - "data": {"object": deepcopy(FAKE_SUBSCRIPTION)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_6l87IHch3diaDj", - "type": "customer.subscription.created", + "id": "evt_38DHch3wHD2eZvKYlCT2oe5ff3", + "object": "event", + "api_version": "2016-03-07", + "created": 1439229084, + "data": {"object": deepcopy(FAKE_SUBSCRIPTION)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_6l87IHch3diaDj", + "type": "customer.subscription.created", } FAKE_EVENT_CUSTOMER_SUBSCRIPTION_DELETED = deepcopy( - FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED + FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED ) FAKE_EVENT_CUSTOMER_SUBSCRIPTION_DELETED.update( - {"id": "evt_38DHch3wHD2eZvKYlCT2oeryaf", "type": "customer.subscription.deleted"} + {"id": "evt_38DHch3wHD2eZvKYlCT2oeryaf", "type": "customer.subscription.deleted"} ) FAKE_EVENT_DISPUTE_CREATED = { - "id": "evt_16YKQi2eZvKYlo2CT2oe5ff3", - "object": "event", - "api_version": "2017-08-15", - "created": 1439229084, - "data": {"object": deepcopy(FAKE_DISPUTE)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_6lsB7hkicwhaDj", - "type": "charge.dispute.created", + "id": "evt_16YKQi2eZvKYlo2CT2oe5ff3", + "object": "event", + "api_version": "2017-08-15", + "created": 1439229084, + "data": {"object": deepcopy(FAKE_DISPUTE)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_6lsB7hkicwhaDj", + "type": "charge.dispute.created", } FAKE_EVENT_INVOICE_CREATED = { - "id": "evt_187IHD2eZvKYlo2C6YKQi2eZ", - "object": "event", - "api_version": "2016-03-07", - "created": 1462338623, - "data": {"object": deepcopy(FAKE_INVOICE)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_8O4sB7hkDobVT", - "type": "invoice.created", + "id": "evt_187IHD2eZvKYlo2C6YKQi2eZ", + "object": "event", + "api_version": "2016-03-07", + "created": 1462338623, + "data": {"object": deepcopy(FAKE_INVOICE)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_8O4sB7hkDobVT", + "type": "invoice.created", } FAKE_EVENT_INVOICE_DELETED = deepcopy(FAKE_EVENT_INVOICE_CREATED) FAKE_EVENT_INVOICE_DELETED.update( - {"id": "evt_187IHD2eZvKYlo2Cjkjsr34H", "type": "invoice.deleted"} + {"id": "evt_187IHD2eZvKYlo2Cjkjsr34H", "type": "invoice.deleted"} ) FAKE_EVENT_INVOICE_UPCOMING = { - "id": "evt_187IHD2eZvKYlo2C6YKQi2bc", - "object": "event", - "api_version": "2017-02-14", - "created": 1501859641, - "data": {"object": deepcopy(FAKE_INVOICE)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_8O4sB7hkDobZA", - "type": "invoice.upcoming", + "id": "evt_187IHD2eZvKYlo2C6YKQi2bc", + "object": "event", + "api_version": "2017-02-14", + "created": 1501859641, + "data": {"object": deepcopy(FAKE_INVOICE)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_8O4sB7hkDobZA", + "type": "invoice.upcoming", } del FAKE_EVENT_INVOICE_UPCOMING["data"]["object"]["id"] FAKE_EVENT_INVOICEITEM_CREATED = { - "id": "evt_187IHD2eZvKYlo2C7SXedrZk", - "object": "event", - "api_version": "2016-03-07", - "created": 1462338623, - "data": {"object": deepcopy(FAKE_INVOICEITEM)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_8O4Qbs2EDobDVT", - "type": "invoiceitem.created", + "id": "evt_187IHD2eZvKYlo2C7SXedrZk", + "object": "event", + "api_version": "2016-03-07", + "created": 1462338623, + "data": {"object": deepcopy(FAKE_INVOICEITEM)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_8O4Qbs2EDobDVT", + "type": "invoiceitem.created", } FAKE_EVENT_INVOICEITEM_DELETED = deepcopy(FAKE_EVENT_INVOICEITEM_CREATED) FAKE_EVENT_INVOICEITEM_DELETED.update( - {"id": "evt_187IHD2eZvKYloJfdsnnfs34", "type": "invoiceitem.deleted"} + {"id": "evt_187IHD2eZvKYloJfdsnnfs34", "type": "invoiceitem.deleted"} ) FAKE_EVENT_PLAN_CREATED = { - "id": "evt_1877X72eZvKYlo2CLK6daFxu", - "object": "event", - "api_version": "2016-03-07", - "created": 1462297325, - "data": {"object": deepcopy(FAKE_PLAN)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_8NtJXPttxSvFyM", - "type": "plan.created", + "id": "evt_1877X72eZvKYlo2CLK6daFxu", + "object": "event", + "api_version": "2016-03-07", + "created": 1462297325, + "data": {"object": deepcopy(FAKE_PLAN)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_8NtJXPttxSvFyM", + "type": "plan.created", } FAKE_EVENT_PLAN_DELETED = deepcopy(FAKE_EVENT_PLAN_CREATED) FAKE_EVENT_PLAN_DELETED.update( - {"id": "evt_1877X72eZvKYl2jkds32jJFc", "type": "plan.deleted"} + {"id": "evt_1877X72eZvKYl2jkds32jJFc", "type": "plan.deleted"} ) FAKE_EVENT_TRANSFER_CREATED = { - "id": "evt_16igNU2eZvKYlo2CYyMkYvet", - "object": "event", - "api_version": "2016-03-07", - "created": 1441696732, - "data": {"object": deepcopy(FAKE_TRANSFER)}, - "livemode": False, - "pending_webhooks": 0, - "request": "req_6wZW9MskhYU15Y", - "type": "transfer.created", + "id": "evt_16igNU2eZvKYlo2CYyMkYvet", + "object": "event", + "api_version": "2016-03-07", + "created": 1441696732, + "data": {"object": deepcopy(FAKE_TRANSFER)}, + "livemode": False, + "pending_webhooks": 0, + "request": "req_6wZW9MskhYU15Y", + "type": "transfer.created", } FAKE_EVENT_TRANSFER_DELETED = deepcopy(FAKE_EVENT_TRANSFER_CREATED) FAKE_EVENT_TRANSFER_DELETED.update( - {"id": "evt_16igNU2eZvKjklfsdjk232Mf", "type": "transfer.deleted"} + {"id": "evt_16igNU2eZvKjklfsdjk232Mf", "type": "transfer.deleted"} ) FAKE_TOKEN = { - "id": "tok_16YDIe2eZvKYlo2CPvqprIJd", - "object": "token", - "card": deepcopy(FAKE_CARD), - "client_ip": None, - "created": 1439201676, - "livemode": False, - "type": "card", - "used": False, + "id": "tok_16YDIe2eZvKYlo2CPvqprIJd", + "object": "token", + "card": deepcopy(FAKE_CARD), + "client_ip": None, + "created": 1439201676, + "livemode": False, + "type": "card", + "used": False, } diff --git a/tests/apps/example/apps.py b/tests/apps/example/apps.py index 9e5d1058e1..cdbeed7bcc 100644 --- a/tests/apps/example/apps.py +++ b/tests/apps/example/apps.py @@ -2,4 +2,4 @@ class ExampleConfig(AppConfig): - name = "example" + name = "example" diff --git a/tests/apps/example/forms.py b/tests/apps/example/forms.py index ecca068e30..4a9b60bc6a 100644 --- a/tests/apps/example/forms.py +++ b/tests/apps/example/forms.py @@ -5,12 +5,12 @@ class PurchaseSubscriptionForm(forms.Form): - email = forms.EmailField() - plan = forms.ModelChoiceField(queryset=djstripe.models.Plan.objects.all()) - stripe_source = forms.CharField( - max_length="255", widget=forms.HiddenInput(), required=False - ) + email = forms.EmailField() + plan = forms.ModelChoiceField(queryset=djstripe.models.Plan.objects.all()) + stripe_source = forms.CharField( + max_length="255", widget=forms.HiddenInput(), required=False + ) class PaymentIntentForm(forms.Form): - pass + pass diff --git a/tests/apps/example/management/commands/regenerate_test_fixtures.py b/tests/apps/example/management/commands/regenerate_test_fixtures.py index 8472f8ad09..e755cdec1c 100644 --- a/tests/apps/example/management/commands/regenerate_test_fixtures.py +++ b/tests/apps/example/management/commands/regenerate_test_fixtures.py @@ -18,700 +18,737 @@ class Command(BaseCommand): - """ - This does the following: - - 1) Load existing fixtures from JSON files - 2) Attempts to read the corresponding objects from Stripe - 3) If found, for types Stripe doesn't allow us to choose ids for, - we build a map between the fake ids in the fixtures and real Stripe ids - 3) If not found, creates objects in Stripe from the fixtures - 4) Save objects back as fixtures, using fake ids if available - - The rationale for this is so that the fixtures can automatically be updated - with Stripe schema changes running this command. - - This should make keeping our tests and model schema compatible with Stripe - schema changes less pain-staking and simplify the process of upgrading - the targeted Stripe API version. - """ - - help = "Command to update test fixtures using a real Stripe account." - - fake_data_map = {} # type: Dict[Type[djstripe.models.StripeModel], List] - fake_id_map = {} # type: Dict[str, str] - - def add_arguments(self, parser): - parser.add_argument( - "--delete-stale", - action="store_true", - help="Delete any untouched fixtures in the directory", - ) - parser.add_argument( - "--update-sideeffect-fields", - action="store_true", - help="Don't preserve sideeffect fields such as 'created'", - ) - - def handle(self, *args, **options): - do_delete_stale_fixtures = options["delete_stale"] - do_preserve_sideeffect_fields = not options["update_sideeffect_fields"] - common_readonly_fields = ["object", "created", "updated", "livemode"] - common_sideeffect_fields = ["created"] - - # TODO - is it be possible to get a list of which fields are writable from the API? - # maybe using https://github.com/stripe/openapi ? (though that's only for current version) - - """ - Fields that we treat as read-only. Most of these will cause an error if sent to the Stripe API. - """ - model_extra_readonly_fields = { - djstripe.models.Account: ["id"], - djstripe.models.Customer: [ - "account_balance", - "currency", - "default_source", - "delinquent", - "invoice_prefix", - "subscriptions", - "sources", - ], - djstripe.models.Card: [ - "id", - "address_line1_check", - "address_zip_check", - "brand", - "country", - "customer", - "cvc_check", - "dynamic_last4", - "exp_month", - "exp_year", - "fingerprint", - "funding", - "last4", - "tokenization_method", - ], - djstripe.models.PaymentIntent: ["id"], - djstripe.models.PaymentMethod: ["id"], - djstripe.models.Source: [ - "id", - "amount", - "card", - "client_secret", - "currency", - "customer", - "flow", - "owner", - "statement_descriptor", - "status", - "type", - "usage", - ], - djstripe.models.Subscription: [ - "id", - # not actually read-only - "billing_cycle_anchor", - # seem that this is replacing "billing"? (but they can't both be set) - "collection_method", - "current_period_end", - "current_period_start", - "latest_invoice", - "start", - "start_date", - "status", - ], - } # type: Dict[Type[djstripe.models.StripeModel], List[str]] - - """ - Fields that we don't care about the value of, and that preserving - allows us to avoid churn in the fixtures - """ - model_sideeffect_fields = { - djstripe.models.BalanceTransaction: ["available_on"], - djstripe.models.Source: ["client_secret"], - djstripe.models.Charge: ["receipt_url"], - djstripe.models.Subscription: [ - "billing_cycle_anchor", - "current_period_start", - "current_period_end", - "start", - "start_date", - ], - djstripe.models.SubscriptionItem: [ - # we don't currently track separate fixtures for SubscriptionItems - "id" - ], - djstripe.models.Product: ["updated"], - djstripe.models.Invoice: [ - "date", - "finalized_at", - "hosted_invoice_url", - "invoice_pdf", - "webhooks_delivered_at", - "period_start", - "period_end", - # we don't currently track separate fixtures for SubscriptionItems - "subscription_item", - ], - } # type: Dict[Type[djstripe.models.StripeModel], List[str]] - - object_sideeffect_fields = { - model.stripe_class.OBJECT_NAME: set(v) - for model, v in model_sideeffect_fields.items() - } # type: Dict[str, Set[str]] - - self.fake_data_map = { - # djstripe.models.Account: [tests.FAKE_ACCOUNT], - djstripe.models.Customer: [ - tests.FAKE_CUSTOMER, - tests.FAKE_CUSTOMER_II, - tests.FAKE_CUSTOMER_III, - ], - djstripe.models.Card: [tests.FAKE_CARD, tests.FAKE_CARD_II, tests.FAKE_CARD_V], - djstripe.models.Source: [tests.FAKE_SOURCE], - djstripe.models.Product: [tests.FAKE_PRODUCT], - djstripe.models.Plan: [tests.FAKE_PLAN, tests.FAKE_PLAN_II], - djstripe.models.Subscription: [ - tests.FAKE_SUBSCRIPTION, - tests.FAKE_SUBSCRIPTION_II, - tests.FAKE_SUBSCRIPTION_III, - tests.FAKE_SUBSCRIPTION_MULTI_PLAN, - ], - djstripe.models.Invoice: [tests.FAKE_INVOICE], - djstripe.models.Charge: [tests.FAKE_CHARGE], - djstripe.models.PaymentIntent: [tests.FAKE_PAYMENT_INTENT_I], - djstripe.models.PaymentMethod: [tests.FAKE_PAYMENT_METHOD_I], - djstripe.models.BalanceTransaction: [tests.FAKE_BALANCE_TRANSACTION], - } - - self.init_fake_id_map() - - objs = [] - - # Regenerate each of the fixture objects via Stripe - # We re-fetch objects in a second pass if they were created during the first pass, - # to ensure nested objects are up to date (eg Customer.subscriptions), - for n in range(2): - any_created = False - self.stdout.write(f"Updating fixture objects, pass {n}") - - # reset the objects list since we don't want to keep those from the first pass - objs.clear() - - for model_class, old_objs in self.fake_data_map.items(): - readonly_fields = ( - common_readonly_fields + model_extra_readonly_fields.get(model_class, []) - ) - - for old_obj in old_objs: - created, obj = self.update_fixture_obj( - old_obj=deepcopy(old_obj), - model_class=model_class, - readonly_fields=readonly_fields, - do_preserve_sideeffect_fields=do_preserve_sideeffect_fields, - object_sideeffect_fields=object_sideeffect_fields, - common_sideeffect_fields=common_sideeffect_fields, - ) - - objs.append(obj) - any_created = created or any_created - - if not any_created: - # nothing created on this pass, no need to continue - break - else: - self.stderr.write( - "Warning, unexpected behaviour - some fixtures still being created in second pass?" - ) - - # Now the fake_id_map should be complete and the objs should be up to date, save all the fixtures - paths = set() - for obj in objs: - path = self.save_fixture(obj) - paths.add(path) - - if do_delete_stale_fixtures: - for path in tests.FIXTURE_DIR_PATH.glob("*.json"): - if path in paths: - continue - else: - self.stdout.write("deleting {}".format(path)) - path.unlink() - - def init_fake_id_map(self): - """ - Build a mapping between fake ids stored in Stripe metadata and those obj's actual ids - - We do this so we can have fixtures with stable ids for objects Stripe doesn't allow - us to specify an id for (eg Card). - - Fixtures and tests will use the fake ids, when we talk to stripe we use the real ids - :return: - """ - - for fake_customer in self.fake_data_map[djstripe.models.Customer]: - try: - # can only access Cards via the customer - customer = djstripe.models.Customer(id=fake_customer["id"]).api_retrieve() - except InvalidRequestError: - self.stdout.write( - f"Fake customer {fake_customer['id']} doesn't exist in Stripe yet" - ) - return - - # assume that test customers don't have more than 100 cards... - for card in customer.sources.list(limit=100): - self.update_fake_id_map(card) - - for payment_method in djstripe.models.PaymentMethod.api_list( - customer=customer.id, type="card" - ): - self.update_fake_id_map(payment_method) - - for subscription in customer["subscriptions"]["data"]: - self.update_fake_id_map(subscription) - - def update_fake_id_map(self, obj): - fake_id = self.get_fake_id(obj) - actual_id = obj["id"] - - if fake_id: - if fake_id in self.fake_id_map: - assert ( - self.fake_id_map[fake_id] == actual_id - ), f"Duplicate fake_id {fake_id} - reset your test Stripe data at https://dashboard.stripe.com/account/data" - - self.fake_id_map[fake_id] = actual_id - - return fake_id - else: - return actual_id - - def get_fake_id(self, obj): - """ - Get a stable fake id from a real Stripe object, we use this so that fixtures are stable - :param obj: - :return: - """ - fake_id = None - - if isinstance(obj, str): - real_id = obj - real_id_map = {v: k for k, v in self.fake_id_map.items()} - - fake_id = real_id_map.get(real_id) - elif "metadata" in obj: - # Note: not all objects have a metadata dict (eg Account, BalanceTransaction don't) - fake_id = obj.get("metadata", {}).get(FAKE_ID_METADATA_KEY) - elif obj.get("object") == "balance_transaction": - # assume for purposes of fixture generation that 1 balance_transaction per source charge (etc) - fake_source_id = self.get_fake_id(obj["source"]) - - fake_id = "txn_fake_{}".format(fake_source_id) - - return fake_id - - def fake_json_ids(self, json_str): - """ - Replace real ids with fakes ones in the JSON fixture - - Do this on the serialized JSON string since it's a simple string replace - :param json_str: - :return: - """ - for fake_id, actual_id in self.fake_id_map.items(): - json_str = json_str.replace(actual_id, fake_id) - - return json_str - - def unfake_json_ids(self, json_str): - """ - Replace fake ids with actual ones in the JSON fixture - - Do this on the serialized JSON string since it's a simple string replace - :param json_str: - :return: - """ - for fake_id, actual_id in self.fake_id_map.items(): - json_str = json_str.replace(fake_id, actual_id) - - # special-case: undo the replace for the djstripe_test_fake_id in metadata - json_str = json_str.replace( - f'"{FAKE_ID_METADATA_KEY}": "{actual_id}"', f'"{FAKE_ID_METADATA_KEY}": "{fake_id}"' - ) - - return json_str - - def update_fixture_obj( # noqa: C901 - self, - old_obj, - model_class, - readonly_fields, - do_preserve_sideeffect_fields, - object_sideeffect_fields, - common_sideeffect_fields, - ): - """ - Given a fixture object, update it via stripe - :param model_class: - :param old_obj: - :param readonly_fields: - :return: - """ - - # restore real ids from Stripe - old_obj = json.loads(self.unfake_json_ids(json.dumps(old_obj))) - - id_ = old_obj["id"] - - self.stdout.write(f"{model_class.__name__} {id_}", ending="") - - # For objects that we can't directly choose the ids of - # (and that will thus vary between stripe accounts) - # we fetch the id from a related object - if issubclass(model_class, djstripe.models.Account): - created, obj = self.get_or_create_stripe_account( - old_obj=old_obj, readonly_fields=readonly_fields - ) - elif issubclass(model_class, djstripe.models.Card): - created, obj = self.get_or_create_stripe_card( - old_obj=old_obj, readonly_fields=readonly_fields - ) - elif issubclass(model_class, djstripe.models.Source): - created, obj = self.get_or_create_stripe_source( - old_obj=old_obj, readonly_fields=readonly_fields - ) - elif issubclass(model_class, djstripe.models.Invoice): - created, obj = self.get_or_create_stripe_invoice( - old_obj=old_obj, writable_fields=["metadata"] - ) - elif issubclass(model_class, djstripe.models.Charge): - created, obj = self.get_or_create_stripe_charge( - old_obj=old_obj, writable_fields=["metadata"] - ) - elif issubclass(model_class, djstripe.models.PaymentIntent): - created, obj = self.get_or_create_stripe_payment_intent( - old_obj=old_obj, writable_fields=["metadata"] - ) - elif issubclass(model_class, djstripe.models.PaymentMethod): - created, obj = self.get_or_create_stripe_payment_method( - old_obj=old_obj, writable_fields=["metadata"] - ) - elif issubclass(model_class, djstripe.models.BalanceTransaction): - created, obj = self.get_or_create_stripe_balance_transaction(old_obj=old_obj) - else: - try: - # fetch from Stripe, using the active API version - # this allows us regenerate the fixtures from Stripe - # and hopefully, automatically get schema changes - obj = model_class(id=id_).api_retrieve() - created = False - - self.stdout.write(" found") - except InvalidRequestError: - self.stdout.write(" creating") - - create_obj = deepcopy(old_obj) - - # create in Stripe - for k in readonly_fields: - create_obj.pop(k, None) - - if issubclass(model_class, djstripe.models.Subscription): - create_obj = self.pre_process_subscription(create_obj=create_obj) - - obj = model_class._api_create(**create_obj) - created = True - - self.update_fake_id_map(obj) - - if do_preserve_sideeffect_fields: - obj = self.preserve_old_sideeffect_values( - old_obj=old_obj, - new_obj=obj, - object_sideeffect_fields=object_sideeffect_fields, - common_sideeffect_fields=common_sideeffect_fields, - ) - - return created, obj - - def get_or_create_stripe_account(self, old_obj, readonly_fields): - obj = djstripe.models.Account().api_retrieve() - - return True, obj - - def get_or_create_stripe_card(self, old_obj, readonly_fields): - customer = djstripe.models.Customer(id=old_obj["customer"]).api_retrieve() - id_ = old_obj["id"] - - try: - obj = customer.sources.retrieve(id_) - created = False - - self.stdout.write(" found") - except InvalidRequestError: - self.stdout.write(" creating") - - create_obj = deepcopy(old_obj) - - # create in Stripe - for k in readonly_fields: - create_obj.pop(k, None) - - obj = customer.sources.create(**{"source": "tok_visa"}) - - for k, v in create_obj.items(): - setattr(obj, k, v) - - obj.save() - created = True - - return created, obj - - def get_or_create_stripe_source(self, old_obj, readonly_fields): - customer = djstripe.models.Customer(id=old_obj["customer"]).api_retrieve() - id_ = old_obj["id"] - - try: - obj = customer.sources.retrieve(id_) - created = False - - self.stdout.write(" found") - except InvalidRequestError: - self.stdout.write(" creating") + """ + This does the following: + + 1) Load existing fixtures from JSON files + 2) Attempts to read the corresponding objects from Stripe + 3) If found, for types Stripe doesn't allow us to choose ids for, + we build a map between the fake ids in the fixtures and real Stripe ids + 3) If not found, creates objects in Stripe from the fixtures + 4) Save objects back as fixtures, using fake ids if available + + The rationale for this is so that the fixtures can automatically be updated + with Stripe schema changes running this command. + + This should make keeping our tests and model schema compatible with Stripe + schema changes less pain-staking and simplify the process of upgrading + the targeted Stripe API version. + """ + + help = "Command to update test fixtures using a real Stripe account." + + fake_data_map = {} # type: Dict[Type[djstripe.models.StripeModel], List] + fake_id_map = {} # type: Dict[str, str] + + def add_arguments(self, parser): + parser.add_argument( + "--delete-stale", + action="store_true", + help="Delete any untouched fixtures in the directory", + ) + parser.add_argument( + "--update-sideeffect-fields", + action="store_true", + help="Don't preserve sideeffect fields such as 'created'", + ) + + def handle(self, *args, **options): + do_delete_stale_fixtures = options["delete_stale"] + do_preserve_sideeffect_fields = not options["update_sideeffect_fields"] + common_readonly_fields = ["object", "created", "updated", "livemode"] + common_sideeffect_fields = ["created"] + + # TODO - is it be possible to get a list of which fields are writable from + # the API? maybe using https://github.com/stripe/openapi ? + # (though that's only for current version) + + """ + Fields that we treat as read-only. + Most of these will cause an error if sent to the Stripe API. + """ + model_extra_readonly_fields = { + djstripe.models.Account: ["id"], + djstripe.models.Customer: [ + "account_balance", + "currency", + "default_source", + "delinquent", + "invoice_prefix", + "subscriptions", + "sources", + ], + djstripe.models.Card: [ + "id", + "address_line1_check", + "address_zip_check", + "brand", + "country", + "customer", + "cvc_check", + "dynamic_last4", + "exp_month", + "exp_year", + "fingerprint", + "funding", + "last4", + "tokenization_method", + ], + djstripe.models.PaymentIntent: ["id"], + djstripe.models.PaymentMethod: ["id"], + djstripe.models.Source: [ + "id", + "amount", + "card", + "client_secret", + "currency", + "customer", + "flow", + "owner", + "statement_descriptor", + "status", + "type", + "usage", + ], + djstripe.models.Subscription: [ + "id", + # not actually read-only + "billing_cycle_anchor", + # seem that this is replacing "billing"? (but they can't both be set) + "collection_method", + "current_period_end", + "current_period_start", + "latest_invoice", + "start", + "start_date", + "status", + ], + } # type: Dict[Type[djstripe.models.StripeModel], List[str]] + + """ + Fields that we don't care about the value of, and that preserving + allows us to avoid churn in the fixtures + """ + model_sideeffect_fields = { + djstripe.models.BalanceTransaction: ["available_on"], + djstripe.models.Source: ["client_secret"], + djstripe.models.Charge: ["receipt_url"], + djstripe.models.Subscription: [ + "billing_cycle_anchor", + "current_period_start", + "current_period_end", + "start", + "start_date", + ], + djstripe.models.SubscriptionItem: [ + # we don't currently track separate fixtures for SubscriptionItems + "id" + ], + djstripe.models.Product: ["updated"], + djstripe.models.Invoice: [ + "date", + "finalized_at", + "hosted_invoice_url", + "invoice_pdf", + "webhooks_delivered_at", + "period_start", + "period_end", + # we don't currently track separate fixtures for SubscriptionItems + "subscription_item", + ], + } # type: Dict[Type[djstripe.models.StripeModel], List[str]] + + object_sideeffect_fields = { + model.stripe_class.OBJECT_NAME: set(v) + for model, v in model_sideeffect_fields.items() + } # type: Dict[str, Set[str]] + + self.fake_data_map = { + # djstripe.models.Account: [tests.FAKE_ACCOUNT], + djstripe.models.Customer: [ + tests.FAKE_CUSTOMER, + tests.FAKE_CUSTOMER_II, + tests.FAKE_CUSTOMER_III, + ], + djstripe.models.Card: [ + tests.FAKE_CARD, + tests.FAKE_CARD_II, + tests.FAKE_CARD_V, + ], + djstripe.models.Source: [tests.FAKE_SOURCE], + djstripe.models.Product: [tests.FAKE_PRODUCT], + djstripe.models.Plan: [tests.FAKE_PLAN, tests.FAKE_PLAN_II], + djstripe.models.Subscription: [ + tests.FAKE_SUBSCRIPTION, + tests.FAKE_SUBSCRIPTION_II, + tests.FAKE_SUBSCRIPTION_III, + tests.FAKE_SUBSCRIPTION_MULTI_PLAN, + ], + djstripe.models.Invoice: [tests.FAKE_INVOICE], + djstripe.models.Charge: [tests.FAKE_CHARGE], + djstripe.models.PaymentIntent: [tests.FAKE_PAYMENT_INTENT_I], + djstripe.models.PaymentMethod: [tests.FAKE_PAYMENT_METHOD_I], + djstripe.models.BalanceTransaction: [tests.FAKE_BALANCE_TRANSACTION], + } + + self.init_fake_id_map() + + objs = [] + + # Regenerate each of the fixture objects via Stripe + # We re-fetch objects in a second pass if they were created during + # the first pass, to ensure nested objects are up to date + # (eg Customer.subscriptions), + for n in range(2): + any_created = False + self.stdout.write(f"Updating fixture objects, pass {n}") + + # reset the objects list since we don't want to keep those from + # the first pass + objs.clear() + + for model_class, old_objs in self.fake_data_map.items(): + readonly_fields = ( + common_readonly_fields + + model_extra_readonly_fields.get(model_class, []) + ) + + for old_obj in old_objs: + created, obj = self.update_fixture_obj( + old_obj=deepcopy(old_obj), + model_class=model_class, + readonly_fields=readonly_fields, + do_preserve_sideeffect_fields=do_preserve_sideeffect_fields, + object_sideeffect_fields=object_sideeffect_fields, + common_sideeffect_fields=common_sideeffect_fields, + ) + + objs.append(obj) + any_created = created or any_created + + if not any_created: + # nothing created on this pass, no need to continue + break + else: + self.stderr.write( + "Warning, unexpected behaviour - some fixtures still being created " + "in second pass?" + ) + + # Now the fake_id_map should be complete and the objs should be up to date, + # save all the fixtures + paths = set() + for obj in objs: + path = self.save_fixture(obj) + paths.add(path) + + if do_delete_stale_fixtures: + for path in tests.FIXTURE_DIR_PATH.glob("*.json"): + if path in paths: + continue + else: + self.stdout.write("deleting {}".format(path)) + path.unlink() + + def init_fake_id_map(self): + """ + Build a mapping between fake ids stored in Stripe metadata and those obj's + actual ids + + We do this so we can have fixtures with stable ids for objects Stripe doesn't + allow us to specify an id for (eg Card). + + Fixtures and tests will use the fake ids, when we talk to stripe we use the + real ids + :return: + """ + + for fake_customer in self.fake_data_map[djstripe.models.Customer]: + try: + # can only access Cards via the customer + customer = djstripe.models.Customer( + id=fake_customer["id"] + ).api_retrieve() + except InvalidRequestError: + self.stdout.write( + f"Fake customer {fake_customer['id']} doesn't exist in Stripe yet" + ) + return + + # assume that test customers don't have more than 100 cards... + for card in customer.sources.list(limit=100): + self.update_fake_id_map(card) + + for payment_method in djstripe.models.PaymentMethod.api_list( + customer=customer.id, type="card" + ): + self.update_fake_id_map(payment_method) + + for subscription in customer["subscriptions"]["data"]: + self.update_fake_id_map(subscription) + + def update_fake_id_map(self, obj): + fake_id = self.get_fake_id(obj) + actual_id = obj["id"] + + if fake_id: + if fake_id in self.fake_id_map: + assert self.fake_id_map[fake_id] == actual_id, ( + f"Duplicate fake_id {fake_id} - reset your test Stripe data at " + f"https://dashboard.stripe.com/account/data" + ) + + self.fake_id_map[fake_id] = actual_id + + return fake_id + else: + return actual_id + + def get_fake_id(self, obj): + """ + Get a stable fake id from a real Stripe object, we use this so that fixtures + are stable + :param obj: + :return: + """ + fake_id = None + + if isinstance(obj, str): + real_id = obj + real_id_map = {v: k for k, v in self.fake_id_map.items()} + + fake_id = real_id_map.get(real_id) + elif "metadata" in obj: + # Note: not all objects have a metadata dict + # (eg Account, BalanceTransaction don't) + fake_id = obj.get("metadata", {}).get(FAKE_ID_METADATA_KEY) + elif obj.get("object") == "balance_transaction": + # assume for purposes of fixture generation that 1 balance_transaction per + # source charge (etc) + fake_source_id = self.get_fake_id(obj["source"]) + + fake_id = "txn_fake_{}".format(fake_source_id) + + return fake_id + + def fake_json_ids(self, json_str): + """ + Replace real ids with fakes ones in the JSON fixture + + Do this on the serialized JSON string since it's a simple string replace + :param json_str: + :return: + """ + for fake_id, actual_id in self.fake_id_map.items(): + json_str = json_str.replace(actual_id, fake_id) + + return json_str + + def unfake_json_ids(self, json_str): + """ + Replace fake ids with actual ones in the JSON fixture + + Do this on the serialized JSON string since it's a simple string replace + :param json_str: + :return: + """ + for fake_id, actual_id in self.fake_id_map.items(): + json_str = json_str.replace(fake_id, actual_id) + + # special-case: undo the replace for the djstripe_test_fake_id in metadata + json_str = json_str.replace( + f'"{FAKE_ID_METADATA_KEY}": "{actual_id}"', + f'"{FAKE_ID_METADATA_KEY}": "{fake_id}"', + ) + + return json_str + + def update_fixture_obj( # noqa: C901 + self, + old_obj, + model_class, + readonly_fields, + do_preserve_sideeffect_fields, + object_sideeffect_fields, + common_sideeffect_fields, + ): + """ + Given a fixture object, update it via stripe + :param model_class: + :param old_obj: + :param readonly_fields: + :return: + """ + + # restore real ids from Stripe + old_obj = json.loads(self.unfake_json_ids(json.dumps(old_obj))) + + id_ = old_obj["id"] + + self.stdout.write(f"{model_class.__name__} {id_}", ending="") + + # For objects that we can't directly choose the ids of + # (and that will thus vary between stripe accounts) + # we fetch the id from a related object + if issubclass(model_class, djstripe.models.Account): + created, obj = self.get_or_create_stripe_account( + old_obj=old_obj, readonly_fields=readonly_fields + ) + elif issubclass(model_class, djstripe.models.Card): + created, obj = self.get_or_create_stripe_card( + old_obj=old_obj, readonly_fields=readonly_fields + ) + elif issubclass(model_class, djstripe.models.Source): + created, obj = self.get_or_create_stripe_source( + old_obj=old_obj, readonly_fields=readonly_fields + ) + elif issubclass(model_class, djstripe.models.Invoice): + created, obj = self.get_or_create_stripe_invoice( + old_obj=old_obj, writable_fields=["metadata"] + ) + elif issubclass(model_class, djstripe.models.Charge): + created, obj = self.get_or_create_stripe_charge( + old_obj=old_obj, writable_fields=["metadata"] + ) + elif issubclass(model_class, djstripe.models.PaymentIntent): + created, obj = self.get_or_create_stripe_payment_intent( + old_obj=old_obj, writable_fields=["metadata"] + ) + elif issubclass(model_class, djstripe.models.PaymentMethod): + created, obj = self.get_or_create_stripe_payment_method( + old_obj=old_obj, writable_fields=["metadata"] + ) + elif issubclass(model_class, djstripe.models.BalanceTransaction): + created, obj = self.get_or_create_stripe_balance_transaction( + old_obj=old_obj + ) + else: + try: + # fetch from Stripe, using the active API version + # this allows us regenerate the fixtures from Stripe + # and hopefully, automatically get schema changes + obj = model_class(id=id_).api_retrieve() + created = False + + self.stdout.write(" found") + except InvalidRequestError: + self.stdout.write(" creating") + + create_obj = deepcopy(old_obj) + + # create in Stripe + for k in readonly_fields: + create_obj.pop(k, None) + + if issubclass(model_class, djstripe.models.Subscription): + create_obj = self.pre_process_subscription(create_obj=create_obj) + + obj = model_class._api_create(**create_obj) + created = True + + self.update_fake_id_map(obj) + + if do_preserve_sideeffect_fields: + obj = self.preserve_old_sideeffect_values( + old_obj=old_obj, + new_obj=obj, + object_sideeffect_fields=object_sideeffect_fields, + common_sideeffect_fields=common_sideeffect_fields, + ) + + return created, obj + + def get_or_create_stripe_account(self, old_obj, readonly_fields): + obj = djstripe.models.Account().api_retrieve() + + return True, obj + + def get_or_create_stripe_card(self, old_obj, readonly_fields): + customer = djstripe.models.Customer(id=old_obj["customer"]).api_retrieve() + id_ = old_obj["id"] + + try: + obj = customer.sources.retrieve(id_) + created = False + + self.stdout.write(" found") + except InvalidRequestError: + self.stdout.write(" creating") + + create_obj = deepcopy(old_obj) + + # create in Stripe + for k in readonly_fields: + create_obj.pop(k, None) + + obj = customer.sources.create(**{"source": "tok_visa"}) + + for k, v in create_obj.items(): + setattr(obj, k, v) + + obj.save() + created = True + + return created, obj + + def get_or_create_stripe_source(self, old_obj, readonly_fields): + customer = djstripe.models.Customer(id=old_obj["customer"]).api_retrieve() + id_ = old_obj["id"] + + try: + obj = customer.sources.retrieve(id_) + created = False + + self.stdout.write(" found") + except InvalidRequestError: + self.stdout.write(" creating") - create_obj = deepcopy(old_obj) + create_obj = deepcopy(old_obj) - # create in Stripe - for k in readonly_fields: - create_obj.pop(k, None) + # create in Stripe + for k in readonly_fields: + create_obj.pop(k, None) - source_obj = djstripe.models.Source._api_create( - **{"token": "tok_visa", "type": "card"} - ) + source_obj = djstripe.models.Source._api_create( + **{"token": "tok_visa", "type": "card"} + ) - obj = customer.sources.create(**{"source": source_obj.id}) + obj = customer.sources.create(**{"source": source_obj.id}) - for k, v in create_obj.items(): - setattr(obj, k, v) + for k, v in create_obj.items(): + setattr(obj, k, v) - obj.save() - created = True + obj.save() + created = True - return created, obj + return created, obj - def get_or_create_stripe_invoice(self, old_obj, writable_fields): - subscription = djstripe.models.Subscription(id=old_obj["subscription"]).api_retrieve() - id_ = subscription["latest_invoice"] + def get_or_create_stripe_invoice(self, old_obj, writable_fields): + subscription = djstripe.models.Subscription( + id=old_obj["subscription"] + ).api_retrieve() + id_ = subscription["latest_invoice"] - try: - obj = djstripe.models.Invoice(id=id_).api_retrieve() - created = False + try: + obj = djstripe.models.Invoice(id=id_).api_retrieve() + created = False - self.stdout.write(f" found {id_}") - except InvalidRequestError: - assert False, "Expected to find invoice via subscription" + self.stdout.write(f" found {id_}") + except InvalidRequestError: + assert False, "Expected to find invoice via subscription" - for k in writable_fields: - if isinstance(obj.get(k), dict): - # merge dicts (eg metadata) - obj[k].update(old_obj.get(k, {})) - else: - obj[k] = old_obj[k] + for k in writable_fields: + if isinstance(obj.get(k), dict): + # merge dicts (eg metadata) + obj[k].update(old_obj.get(k, {})) + else: + obj[k] = old_obj[k] - obj.save() + obj.save() - return created, obj + return created, obj - def get_or_create_stripe_charge(self, old_obj, writable_fields): - invoice = djstripe.models.Invoice(id=old_obj["invoice"]).api_retrieve() - id_ = invoice["charge"] + def get_or_create_stripe_charge(self, old_obj, writable_fields): + invoice = djstripe.models.Invoice(id=old_obj["invoice"]).api_retrieve() + id_ = invoice["charge"] - try: - obj = djstripe.models.Charge(id=id_).api_retrieve() - created = False + try: + obj = djstripe.models.Charge(id=id_).api_retrieve() + created = False - self.stdout.write(f" found {id_}") - except InvalidRequestError: - assert False, "Expected to find charge via invoice" + self.stdout.write(f" found {id_}") + except InvalidRequestError: + assert False, "Expected to find charge via invoice" - for k in writable_fields: - if isinstance(obj.get(k), dict): - # merge dicts (eg metadata) - obj[k].update(old_obj.get(k, {})) - else: - obj[k] = old_obj[k] + for k in writable_fields: + if isinstance(obj.get(k), dict): + # merge dicts (eg metadata) + obj[k].update(old_obj.get(k, {})) + else: + obj[k] = old_obj[k] - obj.save() + obj.save() - return created, obj + return created, obj - def get_or_create_stripe_payment_intent(self, old_obj, writable_fields): - invoice = djstripe.models.Invoice(id=old_obj["invoice"]).api_retrieve() - id_ = invoice["payment_intent"] + def get_or_create_stripe_payment_intent(self, old_obj, writable_fields): + invoice = djstripe.models.Invoice(id=old_obj["invoice"]).api_retrieve() + id_ = invoice["payment_intent"] - try: - obj = djstripe.models.PaymentIntent(id=id_).api_retrieve() - created = False + try: + obj = djstripe.models.PaymentIntent(id=id_).api_retrieve() + created = False - self.stdout.write(f" found {id_}") - except InvalidRequestError: - assert False, "Expected to find payment_intent via invoice" + self.stdout.write(f" found {id_}") + except InvalidRequestError: + assert False, "Expected to find payment_intent via invoice" - for k in writable_fields: - if isinstance(obj.get(k), dict): - # merge dicts (eg metadata) - obj[k].update(old_obj.get(k, {})) - else: - obj[k] = old_obj[k] + for k in writable_fields: + if isinstance(obj.get(k), dict): + # merge dicts (eg metadata) + obj[k].update(old_obj.get(k, {})) + else: + obj[k] = old_obj[k] - obj.save() + obj.save() - return created, obj + return created, obj - def get_or_create_stripe_payment_method(self, old_obj, writable_fields): - id_ = old_obj["id"] - customer_id = old_obj["customer"] - type_ = old_obj["type"] + def get_or_create_stripe_payment_method(self, old_obj, writable_fields): + id_ = old_obj["id"] + customer_id = old_obj["customer"] + type_ = old_obj["type"] - try: - obj = djstripe.models.PaymentMethod(id=id_).api_retrieve() - created = False + try: + obj = djstripe.models.PaymentMethod(id=id_).api_retrieve() + created = False - self.stdout.write(" found") - except InvalidRequestError: - self.stdout.write(" creating") + self.stdout.write(" found") + except InvalidRequestError: + self.stdout.write(" creating") - obj = djstripe.models.PaymentMethod()._api_create( - type=type_, card={"token": "tok_visa"} - ) + obj = djstripe.models.PaymentMethod()._api_create( + type=type_, card={"token": "tok_visa"} + ) - stripe.PaymentMethod.attach( - obj["id"], customer=customer_id, api_key=djstripe.settings.STRIPE_SECRET_KEY - ) - - for k in writable_fields: - if isinstance(obj.get(k), dict): - # merge dicts (eg metadata) - obj[k].update(old_obj.get(k, {})) - else: - obj[k] = old_obj[k] - - obj.save() - - created = True - - return created, obj - - def get_or_create_stripe_balance_transaction(self, old_obj): - source = old_obj["source"] - - if source.startswith("ch_"): - charge = djstripe.models.Charge(id=source).api_retrieve() - id_ = djstripe.models.StripeModel._id_from_data(charge["balance_transaction"]) - - try: - obj = djstripe.models.BalanceTransaction(id=id_).api_retrieve() - created = False - - self.stdout.write(f" found {id_}") - except InvalidRequestError: - assert False, "Expected to find balance transaction via source" - - return created, obj - - def save_fixture(self, obj): - type_name = obj["object"] - id_ = self.update_fake_id_map(obj) - - fixture_path = tests.FIXTURE_DIR_PATH.joinpath(f"{type_name}_{id_}.json") - - with fixture_path.open("w") as f: - json_str = self.fake_json_ids(json.dumps(obj, indent="\t")) - - f.write(json_str) - - return fixture_path - - def pre_process_subscription(self, create_obj): - # flatten plan/items on create - - items = create_obj.get("items", {}).get("data", []) - - if len(items): - # don't try and create with both plan and item (list of plans) - create_obj.pop("plan", None) - create_obj.pop("quantity", None) - - # TODO - move this to SubscriptionItem handling? - subscription_item_create_fields = { - "plan", - "billing_thresholds", - "metadata", - "quantity", - "tax_rates", - } - create_items = [] - - for item in items: - create_item = { - k: v for k, v in item.items() if k in subscription_item_create_fields - } - - create_item["plan"] = djstripe.models.StripeModel._id_from_data(create_item["plan"]) - create_items.append(create_item) - - create_obj["items"] = create_items - else: - # don't try and send empty items list - create_obj.pop("items", None) - create_obj["plan"] = djstripe.models.StripeModel._id_from_data(create_obj["plan"]) - - return create_obj - - def preserve_old_sideeffect_values( - self, old_obj, new_obj, object_sideeffect_fields, common_sideeffect_fields - ): - """ - Try to preserve values of side-effect fields from old_obj, to reduce churn in fixtures - """ - object_name = new_obj.get("object") - sideeffect_fields = object_sideeffect_fields.get(object_name, set()).union( - set(common_sideeffect_fields) - ) - - old_obj = old_obj or {} - - for f, old_val in old_obj.items(): - try: - new_val = new_obj[f] - except KeyError: - continue - - if isinstance(new_val, stripe.api_resources.ListObject): - # recursively process nested lists - for n, (old_val_item, new_val_item) in enumerate( - zip(old_val.get("data", []), new_val.data) - ): - new_val.data[n] = self.preserve_old_sideeffect_values( - old_obj=old_val_item, - new_obj=new_val_item, - object_sideeffect_fields=object_sideeffect_fields, - common_sideeffect_fields=common_sideeffect_fields, - ) - elif isinstance(new_val, stripe.stripe_object.StripeObject): - # recursively process nested objects - new_obj[f] = self.preserve_old_sideeffect_values( - old_obj=old_val, - new_obj=new_val, - object_sideeffect_fields=object_sideeffect_fields, - common_sideeffect_fields=common_sideeffect_fields, - ) - elif ( - f in sideeffect_fields and type(old_val) == type(new_val) and old_val != new_val - ): - # only preserve old values if the type is the same - new_obj[f] = old_val - - return new_obj + stripe.PaymentMethod.attach( + obj["id"], + customer=customer_id, + api_key=djstripe.settings.STRIPE_SECRET_KEY, + ) + + for k in writable_fields: + if isinstance(obj.get(k), dict): + # merge dicts (eg metadata) + obj[k].update(old_obj.get(k, {})) + else: + obj[k] = old_obj[k] + + obj.save() + + created = True + + return created, obj + + def get_or_create_stripe_balance_transaction(self, old_obj): + source = old_obj["source"] + + if source.startswith("ch_"): + charge = djstripe.models.Charge(id=source).api_retrieve() + id_ = djstripe.models.StripeModel._id_from_data( + charge["balance_transaction"] + ) + + try: + obj = djstripe.models.BalanceTransaction(id=id_).api_retrieve() + created = False + + self.stdout.write(f" found {id_}") + except InvalidRequestError: + assert False, "Expected to find balance transaction via source" + + return created, obj + + def save_fixture(self, obj): + type_name = obj["object"] + id_ = self.update_fake_id_map(obj) + + fixture_path = tests.FIXTURE_DIR_PATH.joinpath(f"{type_name}_{id_}.json") + + with fixture_path.open("w") as f: + json_str = self.fake_json_ids(json.dumps(obj, indent="\t")) + + f.write(json_str) + + return fixture_path + + def pre_process_subscription(self, create_obj): + # flatten plan/items on create + + items = create_obj.get("items", {}).get("data", []) + + if len(items): + # don't try and create with both plan and item (list of plans) + create_obj.pop("plan", None) + create_obj.pop("quantity", None) + + # TODO - move this to SubscriptionItem handling? + subscription_item_create_fields = { + "plan", + "billing_thresholds", + "metadata", + "quantity", + "tax_rates", + } + create_items = [] + + for item in items: + create_item = { + k: v + for k, v in item.items() + if k in subscription_item_create_fields + } + + create_item["plan"] = djstripe.models.StripeModel._id_from_data( + create_item["plan"] + ) + create_items.append(create_item) + + create_obj["items"] = create_items + else: + # don't try and send empty items list + create_obj.pop("items", None) + create_obj["plan"] = djstripe.models.StripeModel._id_from_data( + create_obj["plan"] + ) + + return create_obj + + def preserve_old_sideeffect_values( + self, old_obj, new_obj, object_sideeffect_fields, common_sideeffect_fields + ): + """ + Try to preserve values of side-effect fields from old_obj, + to reduce churn in fixtures + """ + object_name = new_obj.get("object") + sideeffect_fields = object_sideeffect_fields.get(object_name, set()).union( + set(common_sideeffect_fields) + ) + + old_obj = old_obj or {} + + for f, old_val in old_obj.items(): + try: + new_val = new_obj[f] + except KeyError: + continue + + if isinstance(new_val, stripe.api_resources.ListObject): + # recursively process nested lists + for n, (old_val_item, new_val_item) in enumerate( + zip(old_val.get("data", []), new_val.data) + ): + new_val.data[n] = self.preserve_old_sideeffect_values( + old_obj=old_val_item, + new_obj=new_val_item, + object_sideeffect_fields=object_sideeffect_fields, + common_sideeffect_fields=common_sideeffect_fields, + ) + elif isinstance(new_val, stripe.stripe_object.StripeObject): + # recursively process nested objects + new_obj[f] = self.preserve_old_sideeffect_values( + old_obj=old_val, + new_obj=new_val, + object_sideeffect_fields=object_sideeffect_fields, + common_sideeffect_fields=common_sideeffect_fields, + ) + elif ( + f in sideeffect_fields + and type(old_val) == type(new_val) + and old_val != new_val + ): + # only preserve old values if the type is the same + new_obj[f] = old_val + + return new_obj diff --git a/tests/apps/example/templates/payment_intent.html b/tests/apps/example/templates/payment_intent.html index f68412fc7e..1101cf1012 100644 --- a/tests/apps/example/templates/payment_intent.html +++ b/tests/apps/example/templates/payment_intent.html @@ -10,178 +10,178 @@ {{ block.super }} {% endblock %} {% block content %}
    -
    +
    -

    Example Payment Intent Manual Configuration

    +

    Example Payment Intent Manual Configuration

    -

    - -
    +
    + +
    - - - + + + -
    +
    diff --git a/tests/apps/example/templates/purchase_subscription.html b/tests/apps/example/templates/purchase_subscription.html index 10a029e016..5af3f5092a 100644 --- a/tests/apps/example/templates/purchase_subscription.html +++ b/tests/apps/example/templates/purchase_subscription.html @@ -14,37 +14,37 @@ {{ block.super }} {% endblock %} @@ -52,127 +52,127 @@ {% block content %}
    -
    +
    -

    Example purchase of a Subscription

    +

    Example purchase of a Subscription

    -
    - {% csrf_token %} - {{form}} + + {% csrf_token %} + {{form}} -
    - -
    - -
    +
    + +
    + +
    - - -
    + + +
    - -
    + + -
    +
    {% endblock %} diff --git a/tests/apps/example/templates/purchase_subscription_success.html b/tests/apps/example/templates/purchase_subscription_success.html index 0dab6e8366..0944376b7e 100644 --- a/tests/apps/example/templates/purchase_subscription_success.html +++ b/tests/apps/example/templates/purchase_subscription_success.html @@ -3,13 +3,15 @@ {% block content %}
    -
    - Subscription "{{ subscription }}" created -
    +
    + Subscription "{{ subscription }}" created +
    - +
    {% endblock %}} diff --git a/tests/apps/example/urls.py b/tests/apps/example/urls.py index fbb07d8c84..7645380df5 100644 --- a/tests/apps/example/urls.py +++ b/tests/apps/example/urls.py @@ -5,15 +5,15 @@ app_name = "djstripe_example" urlpatterns = [ - path( - "purchase-subscription", - views.PurchaseSubscriptionView.as_view(), - name="purchase_subscription", - ), - path( - "purchase-subscription-success/", - views.PurchaseSubscriptionSuccessView.as_view(), - name="purchase_subscription_success", - ), - re_path(r"payment-intent", views.create_payment_intent, name="payment_intent"), + path( + "purchase-subscription", + views.PurchaseSubscriptionView.as_view(), + name="purchase_subscription", + ), + path( + "purchase-subscription-success/", + views.PurchaseSubscriptionSuccessView.as_view(), + name="purchase_subscription_success", + ), + re_path(r"payment-intent", views.create_payment_intent, name="payment_intent"), ] diff --git a/tests/apps/example/views.py b/tests/apps/example/views.py index 4bf723f1af..c908867b40 100644 --- a/tests/apps/example/views.py +++ b/tests/apps/example/views.py @@ -20,130 +20,141 @@ class PurchaseSubscriptionView(FormView): - """ - Example view to demonstrate how to use dj-stripe to: + """ + Example view to demonstrate how to use dj-stripe to: - * create a Customer - * add a card to the Customer - * create a Subscription using that card + * create a Customer + * add a card to the Customer + * create a Subscription using that card - This does a non-logged in purchase for the user of the provided email - """ + This does a non-logged in purchase for the user of the provided email + """ - template_name = "purchase_subscription.html" + template_name = "purchase_subscription.html" - form_class = forms.PurchaseSubscriptionForm + form_class = forms.PurchaseSubscriptionForm - def get_context_data(self, **kwargs): - ctx = super().get_context_data(**kwargs) + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) - if djstripe.models.Plan.objects.count() == 0: - raise Exception( - "No Product Plans in the dj-stripe database - create some in your stripe account and " - "then run `./manage.py djstripe_sync_plans_from_stripe` (or use the dj-stripe webhooks)" - ) + if djstripe.models.Plan.objects.count() == 0: + raise Exception( + "No Product Plans in the dj-stripe database - create some in your " + "stripe account and then " + "run `./manage.py djstripe_sync_plans_from_stripe` " + "(or use the dj-stripe webhooks)" + ) - ctx["STRIPE_PUBLIC_KEY"] = djstripe.settings.STRIPE_PUBLIC_KEY + ctx["STRIPE_PUBLIC_KEY"] = djstripe.settings.STRIPE_PUBLIC_KEY - return ctx + return ctx - def form_valid(self, form): - stripe_source = form.cleaned_data["stripe_source"] - email = form.cleaned_data["email"] - plan = form.cleaned_data["plan"] + def form_valid(self, form): + stripe_source = form.cleaned_data["stripe_source"] + email = form.cleaned_data["email"] + plan = form.cleaned_data["plan"] - # Guest checkout with the provided email - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - user = User.objects.create(username=email, email=email) + # Guest checkout with the provided email + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + user = User.objects.create(username=email, email=email) - # Create the stripe Customer, by default subscriber Model is User, - # this can be overridden with settings.DJSTRIPE_SUBSCRIBER_MODEL - customer, created = djstripe.models.Customer.get_or_create(subscriber=user) + # Create the stripe Customer, by default subscriber Model is User, + # this can be overridden with settings.DJSTRIPE_SUBSCRIBER_MODEL + customer, created = djstripe.models.Customer.get_or_create(subscriber=user) - # Add the source as the customer's default card - customer.add_card(stripe_source) + # Add the source as the customer's default card + customer.add_card(stripe_source) - # Using the Stripe API, create a subscription for this customer, - # using the customer's default payment source - stripe_subscription = stripe.Subscription.create( - customer=customer.id, - items=[{"plan": plan.id}], - billing="charge_automatically", - # tax_percent=15, - api_key=djstripe.settings.STRIPE_SECRET_KEY, - ) + # Using the Stripe API, create a subscription for this customer, + # using the customer's default payment source + stripe_subscription = stripe.Subscription.create( + customer=customer.id, + items=[{"plan": plan.id}], + billing="charge_automatically", + # tax_percent=15, + api_key=djstripe.settings.STRIPE_SECRET_KEY, + ) - # Sync the Stripe API return data to the database, - # this way we don't need to wait for a webhook-triggered sync - subscription = djstripe.models.Subscription.sync_from_stripe_data(stripe_subscription) + # Sync the Stripe API return data to the database, + # this way we don't need to wait for a webhook-triggered sync + subscription = djstripe.models.Subscription.sync_from_stripe_data( + stripe_subscription + ) - self.request.subscription = subscription + self.request.subscription = subscription - return super().form_valid(form) + return super().form_valid(form) - def get_success_url(self): - return reverse( - "djstripe_example:purchase_subscription_success", - kwargs={"id": self.request.subscription.id}, - ) + def get_success_url(self): + return reverse( + "djstripe_example:purchase_subscription_success", + kwargs={"id": self.request.subscription.id}, + ) class PurchaseSubscriptionSuccessView(DetailView): - template_name = "purchase_subscription_success.html" + template_name = "purchase_subscription_success.html" - queryset = djstripe.models.Subscription.objects.all() - slug_field = "id" - slug_url_kwarg = "id" - context_object_name = "subscription" + queryset = djstripe.models.Subscription.objects.all() + slug_field = "id" + slug_url_kwarg = "id" + context_object_name = "subscription" def create_payment_intent(request): - if request.method == "POST": - intent = None - data = json.loads(request.body) - try: - if "payment_method_id" in data: - # Create the PaymentIntent - intent = stripe.PaymentIntent.create( - payment_method=data["payment_method_id"], - amount=1099, - currency="usd", - confirmation_method="manual", - confirm=True, - api_key=djstripe.settings.STRIPE_SECRET_KEY, - ) - elif "payment_intent_id" in data: - intent = stripe.PaymentIntent.confirm( - data["payment_intent_id"], api_key=djstripe.settings.STRIPE_SECRET_KEY - ) - except stripe.error.CardError as e: - # Display error on client - return_data = json.dumps({"error": e.user_message}), 200 - return HttpResponse( - return_data[0], content_type="application/json", status=return_data[1] - ) - - if intent.status == "requires_action" and intent.next_action.type == "use_stripe_sdk": - # Tell the client to handle the action - return_data = ( - json.dumps( - {"requires_action": True, "payment_intent_client_secret": intent.client_secret} - ), - 200, - ) - elif intent.status == "succeeded": - # The payment did not need any additional actions and completed! - # Handle post-payment fulfillment - return_data = json.dumps({"success": True}), 200 - else: - # Invalid status - return_data = json.dumps({"error": "Invalid PaymentIntent status"}), 500 - return HttpResponse( - return_data[0], content_type="application/json", status=return_data[1] - ) - - else: - ctx = {"STRIPE_PUBLIC_KEY": djstripe.settings.STRIPE_PUBLIC_KEY} - return TemplateResponse(request, "payment_intent.html", ctx) + if request.method == "POST": + intent = None + data = json.loads(request.body) + try: + if "payment_method_id" in data: + # Create the PaymentIntent + intent = stripe.PaymentIntent.create( + payment_method=data["payment_method_id"], + amount=1099, + currency="usd", + confirmation_method="manual", + confirm=True, + api_key=djstripe.settings.STRIPE_SECRET_KEY, + ) + elif "payment_intent_id" in data: + intent = stripe.PaymentIntent.confirm( + data["payment_intent_id"], + api_key=djstripe.settings.STRIPE_SECRET_KEY, + ) + except stripe.error.CardError as e: + # Display error on client + return_data = json.dumps({"error": e.user_message}), 200 + return HttpResponse( + return_data[0], content_type="application/json", status=return_data[1] + ) + + if ( + intent.status == "requires_action" + and intent.next_action.type == "use_stripe_sdk" + ): + # Tell the client to handle the action + return_data = ( + json.dumps( + { + "requires_action": True, + "payment_intent_client_secret": intent.client_secret, + } + ), + 200, + ) + elif intent.status == "succeeded": + # The payment did not need any additional actions and completed! + # Handle post-payment fulfillment + return_data = json.dumps({"success": True}), 200 + else: + # Invalid status + return_data = json.dumps({"error": "Invalid PaymentIntent status"}), 500 + return HttpResponse( + return_data[0], content_type="application/json", status=return_data[1] + ) + + else: + ctx = {"STRIPE_PUBLIC_KEY": djstripe.settings.STRIPE_PUBLIC_KEY} + return TemplateResponse(request, "payment_intent.html", ctx) diff --git a/tests/apps/testapp/models.py b/tests/apps/testapp/models.py index be928b1fc2..43d6413121 100644 --- a/tests/apps/testapp/models.py +++ b/tests/apps/testapp/models.py @@ -3,22 +3,22 @@ class Organization(Model): - """ Model used to test the new custom model setting.""" + """ Model used to test the new custom model setting.""" - email = EmailField() + email = EmailField() class StaticEmailOrganization(Model): - """ Model used to test the new custom model setting.""" + """ Model used to test the new custom model setting.""" - name = CharField(max_length=200, unique=True) + name = CharField(max_length=200, unique=True) - @property - def email(self): - return "static@example.com" + @property + def email(self): + return "static@example.com" class NoEmailOrganization(Model): - """ Model used to test the new custom model setting.""" + """ Model used to test the new custom model setting.""" - name = CharField(max_length=200, unique=True) + name = CharField(max_length=200, unique=True) diff --git a/tests/apps/testapp/urls.py b/tests/apps/testapp/urls.py index d10c65cb5e..6d87861f53 100644 --- a/tests/apps/testapp/urls.py +++ b/tests/apps/testapp/urls.py @@ -3,14 +3,14 @@ def empty_view(request): - return HttpResponse() + return HttpResponse() urlpatterns = [ - url(r"^$", empty_view, name="test_url_name"), - url(r"^djstripe/", include("djstripe.urls", namespace="djstripe")), - url( - r"^rest_djstripe/", - include("djstripe.contrib.rest_framework.urls", namespace="rest_djstripe"), - ), + url(r"^$", empty_view, name="test_url_name"), + url(r"^djstripe/", include("djstripe.urls", namespace="djstripe")), + url( + r"^rest_djstripe/", + include("djstripe.contrib.rest_framework.urls", namespace="rest_djstripe"), + ), ] diff --git a/tests/apps/testapp_content/urls.py b/tests/apps/testapp_content/urls.py index d6a6ac7060..10f975277b 100644 --- a/tests/apps/testapp_content/urls.py +++ b/tests/apps/testapp_content/urls.py @@ -7,7 +7,7 @@ def testview(request): - return HttpResponse() + return HttpResponse() urlpatterns = [url(r"^$", testview, name="test_url_content")] diff --git a/tests/apps/testapp_namespaced/urls.py b/tests/apps/testapp_namespaced/urls.py index 2e4fe9d19e..d981b2556b 100644 --- a/tests/apps/testapp_namespaced/urls.py +++ b/tests/apps/testapp_namespaced/urls.py @@ -3,7 +3,7 @@ def testview(request): - return HttpResponse() + return HttpResponse() app_name = "testapp_namespaced" diff --git a/tests/fixtures/balance_transaction_txn_fake_ch_fakefakefakefakefake0001.json b/tests/fixtures/balance_transaction_txn_fake_ch_fakefakefakefakefake0001.json index edc706930d..1e562ac502 100644 --- a/tests/fixtures/balance_transaction_txn_fake_ch_fakefakefakefakefake0001.json +++ b/tests/fixtures/balance_transaction_txn_fake_ch_fakefakefakefakefake0001.json @@ -1,24 +1,24 @@ { - "id": "txn_fake_ch_fakefakefakefakefake0001", - "object": "balance_transaction", - "amount": 2000, - "available_on": 1558569600, - "created": 1557995177, - "currency": "usd", - "description": "Payment for invoice 3D77FB04-0001", - "exchange_rate": null, - "fee": 88, - "fee_details": [ - { - "amount": 88, - "application": null, - "currency": "usd", - "description": "Stripe processing fees", - "type": "stripe_fee" - } - ], - "net": 1912, - "source": "ch_fakefakefakefakefake0001", - "status": "pending", - "type": "charge" -} \ No newline at end of file + "id": "txn_fake_ch_fakefakefakefakefake0001", + "object": "balance_transaction", + "amount": 2000, + "available_on": 1558569600, + "created": 1557995177, + "currency": "usd", + "description": "Payment for invoice 3D77FB04-0001", + "exchange_rate": null, + "fee": 88, + "fee_details": [ + { + "amount": 88, + "application": null, + "currency": "usd", + "description": "Stripe processing fees", + "type": "stripe_fee" + } + ], + "net": 1912, + "source": "ch_fakefakefakefakefake0001", + "status": "pending", + "type": "charge" +} diff --git a/tests/fixtures/card_card_fakefakefakefakefake0001.json b/tests/fixtures/card_card_fakefakefakefakefake0001.json index 6c3abc0e79..af8901fd93 100644 --- a/tests/fixtures/card_card_fakefakefakefakefake0001.json +++ b/tests/fixtures/card_card_fakefakefakefakefake0001.json @@ -1,27 +1,27 @@ { - "id": "card_fakefakefakefakefake0001", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_6lsBvm5rJ0zyHc", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": { - "djstripe_test_fake_id": "card_fakefakefakefakefake0001" - }, - "name": "alex-nesnes@hotmail.fr", - "tokenization_method": null -} \ No newline at end of file + "id": "card_fakefakefakefakefake0001", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6lsBvm5rJ0zyHc", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": { + "djstripe_test_fake_id": "card_fakefakefakefakefake0001" + }, + "name": "alex-nesnes@hotmail.fr", + "tokenization_method": null +} diff --git a/tests/fixtures/card_card_fakefakefakefakefake0002.json b/tests/fixtures/card_card_fakefakefakefakefake0002.json index 84f4b08a82..afffffa97a 100644 --- a/tests/fixtures/card_card_fakefakefakefakefake0002.json +++ b/tests/fixtures/card_card_fakefakefakefakefake0002.json @@ -1,27 +1,27 @@ { - "id": "card_fakefakefakefakefake0002", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_4UbFSo9tl62jqj", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": { - "djstripe_test_fake_id": "card_fakefakefakefakefake0002" - }, - "name": null, - "tokenization_method": null -} \ No newline at end of file + "id": "card_fakefakefakefakefake0002", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_4UbFSo9tl62jqj", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": { + "djstripe_test_fake_id": "card_fakefakefakefakefake0002" + }, + "name": null, + "tokenization_method": null +} diff --git a/tests/fixtures/card_card_fakefakefakefakefake0005.json b/tests/fixtures/card_card_fakefakefakefakefake0005.json index d50877d164..368429df96 100644 --- a/tests/fixtures/card_card_fakefakefakefakefake0005.json +++ b/tests/fixtures/card_card_fakefakefakefakefake0005.json @@ -1,27 +1,27 @@ { - "id": "card_fakefakefakefakefake0005", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_6lsBvm5rJ0zyHc", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": { - "djstripe_test_fake_id": "card_fakefakefakefakefake0005" - }, - "name": null, - "tokenization_method": null -} \ No newline at end of file + "id": "card_fakefakefakefakefake0005", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6lsBvm5rJ0zyHc", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": { + "djstripe_test_fake_id": "card_fakefakefakefakefake0005" + }, + "name": null, + "tokenization_method": null +} diff --git a/tests/fixtures/charge_ch_fakefakefakefakefake0001.json b/tests/fixtures/charge_ch_fakefakefakefakefake0001.json index 883a2aca61..4ceca71867 100644 --- a/tests/fixtures/charge_ch_fakefakefakefakefake0001.json +++ b/tests/fixtures/charge_ch_fakefakefakefakefake0001.json @@ -1,107 +1,107 @@ { - "id": "ch_fakefakefakefakefake0001", - "object": "charge", - "amount": 2000, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_fake_ch_fakefakefakefakefake0001", - "billing_details": { - "address": { - "city": null, - "country": null, - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": null, - "name": "alex-nesnes@hotmail.fr", - "phone": null - }, - "captured": true, - "created": 1557995177, - "currency": "usd", - "customer": "cus_6lsBvm5rJ0zyHc", - "description": "Payment for invoice 3D77FB04-0001", - "destination": null, - "dispute": null, - "failure_code": null, - "failure_message": null, - "fraud_details": {}, - "invoice": "in_fakefakefakefakefake0001", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "ch_fakefakefakefakefake0001" - }, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 42, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": "pi_fakefakefakefakefake0001", - "payment_method": "card_fakefakefakefakefake0001", - "payment_method_details": { - "card": { - "brand": "visa", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": null - }, - "country": "US", - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": null, - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/acct_1EaetYCOCguPTL2B/ch_fakefakefakefakefake0001/rcpt_F4oXnrfNz0ijmlgXiDK31ArpYEgLEko", - "refunded": false, - "refunds": {}, - "review": null, - "shipping": null, - "source": { - "id": "card_fakefakefakefakefake0001", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_6lsBvm5rJ0zyHc", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": {}, - "name": "alex-nesnes@hotmail.fr", - "tokenization_method": null - }, - "source_transfer": null, - "statement_descriptor": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null -} \ No newline at end of file + "id": "ch_fakefakefakefakefake0001", + "object": "charge", + "amount": 2000, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_fake_ch_fakefakefakefakefake0001", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": "alex-nesnes@hotmail.fr", + "phone": null + }, + "captured": true, + "created": 1557995177, + "currency": "usd", + "customer": "cus_6lsBvm5rJ0zyHc", + "description": "Payment for invoice 3D77FB04-0001", + "destination": null, + "dispute": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": "in_fakefakefakefakefake0001", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "ch_fakefakefakefakefake0001" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 42, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_fakefakefakefakefake0001", + "payment_method": "card_fakefakefakefakefake0001", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_1EaetYCOCguPTL2B/ch_fakefakefakefakefake0001/rcpt_F4oXnrfNz0ijmlgXiDK31ArpYEgLEko", + "refunded": false, + "refunds": {}, + "review": null, + "shipping": null, + "source": { + "id": "card_fakefakefakefakefake0001", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6lsBvm5rJ0zyHc", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": {}, + "name": "alex-nesnes@hotmail.fr", + "tokenization_method": null + }, + "source_transfer": null, + "statement_descriptor": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/customer_cus_4QWKsZuuTHcs7X.json b/tests/fixtures/customer_cus_4QWKsZuuTHcs7X.json index 3f7fca7b93..d1fe545d7e 100644 --- a/tests/fixtures/customer_cus_4QWKsZuuTHcs7X.json +++ b/tests/fixtures/customer_cus_4QWKsZuuTHcs7X.json @@ -1,126 +1,126 @@ { - "id": "cus_4QWKsZuuTHcs7X", - "object": "customer", - "account_balance": 0, - "address": null, - "balance": 0, - "created": 1557995167, - "currency": null, - "default_source": { - "id": "src_fakefakefakefakefake0001", - "object": "source", - "amount": null, - "card": { - "exp_month": 6, - "exp_year": 2020, - "last4": "4242", - "country": "US", - "brand": "Visa", - "funding": "credit", - "fingerprint": "88PuXw9tEmvYe69o", - "three_d_secure": "optional", - "name": null, - "address_line1_check": null, - "address_zip_check": null, - "cvc_check": null, - "tokenization_method": null, - "dynamic_last4": null - }, - "client_secret": "src_client_secret_F5psHouOOldEtvBHgyz6y3FC", - "created": 1558230761, - "currency": null, - "customer": "cus_4QWKsZuuTHcs7X", - "flow": "none", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "src_fakefakefakefakefake0001" - }, - "owner": { - "address": null, - "email": null, - "name": null, - "phone": null, - "verified_address": null, - "verified_email": null, - "verified_name": null, - "verified_phone": null - }, - "statement_descriptor": null, - "status": "chargeable", - "type": "card", - "usage": "reusable" - }, - "delinquent": false, - "description": "John Doe", - "discount": null, - "email": "john.doe@example.com", - "invoice_prefix": "413F249C", - "invoice_settings": { - "custom_fields": null, - "default_payment_method": null, - "footer": null - }, - "livemode": false, - "metadata": {}, - "name": null, - "phone": null, - "preferred_locales": [], - "shipping": null, - "sources": { - "object": "list", - "data": [ - { - "id": "src_fakefakefakefakefake0001", - "object": "source", - "amount": null, - "card": { - "exp_month": 6, - "exp_year": 2020, - "last4": "4242", - "country": "US", - "brand": "Visa", - "funding": "credit", - "fingerprint": "88PuXw9tEmvYe69o", - "three_d_secure": "optional", - "name": null, - "address_line1_check": null, - "address_zip_check": null, - "cvc_check": null, - "tokenization_method": null, - "dynamic_last4": null - }, - "client_secret": "src_client_secret_F5psHouOOldEtvBHgyz6y3FC", - "created": 1558230761, - "currency": null, - "customer": "cus_4QWKsZuuTHcs7X", - "flow": "none", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "src_fakefakefakefakefake0001" - }, - "owner": { - "address": null, - "email": null, - "name": null, - "phone": null, - "verified_address": null, - "verified_email": null, - "verified_name": null, - "verified_phone": null - }, - "statement_descriptor": null, - "status": "chargeable", - "type": "card", - "usage": "reusable" - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/customers/cus_4QWKsZuuTHcs7X/sources" - }, - "subscriptions": {}, - "tax_exempt": "none", - "tax_ids": {}, - "tax_info": null, - "tax_info_verification": null -} \ No newline at end of file + "id": "cus_4QWKsZuuTHcs7X", + "object": "customer", + "account_balance": 0, + "address": null, + "balance": 0, + "created": 1557995167, + "currency": null, + "default_source": { + "id": "src_fakefakefakefakefake0001", + "object": "source", + "amount": null, + "card": { + "exp_month": 6, + "exp_year": 2020, + "last4": "4242", + "country": "US", + "brand": "Visa", + "funding": "credit", + "fingerprint": "88PuXw9tEmvYe69o", + "three_d_secure": "optional", + "name": null, + "address_line1_check": null, + "address_zip_check": null, + "cvc_check": null, + "tokenization_method": null, + "dynamic_last4": null + }, + "client_secret": "src_client_secret_F5psHouOOldEtvBHgyz6y3FC", + "created": 1558230761, + "currency": null, + "customer": "cus_4QWKsZuuTHcs7X", + "flow": "none", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "src_fakefakefakefakefake0001" + }, + "owner": { + "address": null, + "email": null, + "name": null, + "phone": null, + "verified_address": null, + "verified_email": null, + "verified_name": null, + "verified_phone": null + }, + "statement_descriptor": null, + "status": "chargeable", + "type": "card", + "usage": "reusable" + }, + "delinquent": false, + "description": "John Doe", + "discount": null, + "email": "john.doe@example.com", + "invoice_prefix": "413F249C", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null + }, + "livemode": false, + "metadata": {}, + "name": null, + "phone": null, + "preferred_locales": [], + "shipping": null, + "sources": { + "object": "list", + "data": [ + { + "id": "src_fakefakefakefakefake0001", + "object": "source", + "amount": null, + "card": { + "exp_month": 6, + "exp_year": 2020, + "last4": "4242", + "country": "US", + "brand": "Visa", + "funding": "credit", + "fingerprint": "88PuXw9tEmvYe69o", + "three_d_secure": "optional", + "name": null, + "address_line1_check": null, + "address_zip_check": null, + "cvc_check": null, + "tokenization_method": null, + "dynamic_last4": null + }, + "client_secret": "src_client_secret_F5psHouOOldEtvBHgyz6y3FC", + "created": 1558230761, + "currency": null, + "customer": "cus_4QWKsZuuTHcs7X", + "flow": "none", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "src_fakefakefakefakefake0001" + }, + "owner": { + "address": null, + "email": null, + "name": null, + "phone": null, + "verified_address": null, + "verified_email": null, + "verified_name": null, + "verified_phone": null + }, + "statement_descriptor": null, + "status": "chargeable", + "type": "card", + "usage": "reusable" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/customers/cus_4QWKsZuuTHcs7X/sources" + }, + "subscriptions": {}, + "tax_exempt": "none", + "tax_ids": {}, + "tax_info": null, + "tax_info_verification": null +} diff --git a/tests/fixtures/customer_cus_4UbFSo9tl62jqj.json b/tests/fixtures/customer_cus_4UbFSo9tl62jqj.json index 43c6a6d0c0..1d93e26a64 100644 --- a/tests/fixtures/customer_cus_4UbFSo9tl62jqj.json +++ b/tests/fixtures/customer_cus_4UbFSo9tl62jqj.json @@ -1,302 +1,302 @@ { - "id": "cus_4UbFSo9tl62jqj", - "object": "customer", - "account_balance": 0, - "address": null, - "balance": 0, - "created": 1557995167, - "currency": "usd", - "default_source": { - "id": "card_fakefakefakefakefake0002", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_4UbFSo9tl62jqj", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": { - "djstripe_test_fake_id": "card_fakefakefakefakefake0002" - }, - "name": null, - "tokenization_method": null - }, - "delinquent": false, - "description": "John Snow", - "discount": null, - "email": "john.snow@thewall.com", - "invoice_prefix": "023474AA", - "invoice_settings": { - "custom_fields": null, - "default_payment_method": null, - "footer": null - }, - "livemode": false, - "metadata": {}, - "name": null, - "phone": null, - "preferred_locales": [], - "shipping": null, - "sources": { - "object": "list", - "data": [ - { - "id": "card_fakefakefakefakefake0002", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_4UbFSo9tl62jqj", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": { - "djstripe_test_fake_id": "card_fakefakefakefakefake0002" - }, - "name": null, - "tokenization_method": null - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/customers/cus_4UbFSo9tl62jqj/sources" - }, - "subscriptions": { - "object": "list", - "data": [ - { - "id": "sub_fakefakefakefakefake0004", - "object": "subscription", - "application_fee_percent": null, - "billing": "charge_automatically", - "billing_cycle_anchor": 1558230771, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "collection_method": "charge_automatically", - "created": 1558230771, - "current_period_end": 1560909171, - "current_period_start": 1558230771, - "customer": "cus_4UbFSo9tl62jqj", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_F5uk9HMrUwrmUJ", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230772, - "metadata": {}, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0004", - "tax_rates": [] - }, - { - "id": "si_F5uk81B1xGi3Vr", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230772, - "metadata": {}, - "plan": { - "id": "silver41294", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 4000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": 12, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0004", - "tax_rates": [] - } - ], - "has_more": false, - "total_count": 2, - "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0004" - }, - "latest_invoice": "in_1EhAAlCOCguPTL2BHKM8PujL", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0004" - }, - "pending_setup_intent": null, - "plan": null, - "quantity": null, - "schedule": null, - "start": 1558230771, - "start_date": 1559476706, - "status": "active", - "tax_percent": null, - "trial_end": null, - "trial_start": null - }, - { - "id": "sub_fakefakefakefakefake0003", - "object": "subscription", - "application_fee_percent": null, - "billing": "charge_automatically", - "billing_cycle_anchor": 1558230769, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "collection_method": "charge_automatically", - "created": 1558230769, - "current_period_end": 1560909169, - "current_period_start": 1558230769, - "customer": "cus_4UbFSo9tl62jqj", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_F5ukGdpR4EejF9", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230770, - "metadata": {}, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0003", - "tax_rates": [] - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0003" - }, - "latest_invoice": "in_1EhAAjCOCguPTL2BRExZmp4c", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0003" - }, - "pending_setup_intent": null, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "schedule": null, - "start": 1558230769, - "start_date": 1559476704, - "status": "active", - "tax_percent": null, - "trial_end": null, - "trial_start": null - } - ], - "has_more": false, - "total_count": 2, - "url": "/v1/customers/cus_4UbFSo9tl62jqj/subscriptions" - }, - "tax_exempt": "none", - "tax_ids": {}, - "tax_info": null, - "tax_info_verification": null -} \ No newline at end of file + "id": "cus_4UbFSo9tl62jqj", + "object": "customer", + "account_balance": 0, + "address": null, + "balance": 0, + "created": 1557995167, + "currency": "usd", + "default_source": { + "id": "card_fakefakefakefakefake0002", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_4UbFSo9tl62jqj", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": { + "djstripe_test_fake_id": "card_fakefakefakefakefake0002" + }, + "name": null, + "tokenization_method": null + }, + "delinquent": false, + "description": "John Snow", + "discount": null, + "email": "john.snow@thewall.com", + "invoice_prefix": "023474AA", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null + }, + "livemode": false, + "metadata": {}, + "name": null, + "phone": null, + "preferred_locales": [], + "shipping": null, + "sources": { + "object": "list", + "data": [ + { + "id": "card_fakefakefakefakefake0002", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_4UbFSo9tl62jqj", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": { + "djstripe_test_fake_id": "card_fakefakefakefakefake0002" + }, + "name": null, + "tokenization_method": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/customers/cus_4UbFSo9tl62jqj/sources" + }, + "subscriptions": { + "object": "list", + "data": [ + { + "id": "sub_fakefakefakefakefake0004", + "object": "subscription", + "application_fee_percent": null, + "billing": "charge_automatically", + "billing_cycle_anchor": 1558230771, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1558230771, + "current_period_end": 1560909171, + "current_period_start": 1558230771, + "customer": "cus_4UbFSo9tl62jqj", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_F5uk9HMrUwrmUJ", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230772, + "metadata": {}, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0004", + "tax_rates": [] + }, + { + "id": "si_F5uk81B1xGi3Vr", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230772, + "metadata": {}, + "plan": { + "id": "silver41294", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 4000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": 12, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0004", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 2, + "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0004" + }, + "latest_invoice": "in_1EhAAlCOCguPTL2BHKM8PujL", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0004" + }, + "pending_setup_intent": null, + "plan": null, + "quantity": null, + "schedule": null, + "start": 1558230771, + "start_date": 1559476706, + "status": "active", + "tax_percent": null, + "trial_end": null, + "trial_start": null + }, + { + "id": "sub_fakefakefakefakefake0003", + "object": "subscription", + "application_fee_percent": null, + "billing": "charge_automatically", + "billing_cycle_anchor": 1558230769, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1558230769, + "current_period_end": 1560909169, + "current_period_start": 1558230769, + "customer": "cus_4UbFSo9tl62jqj", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_F5ukGdpR4EejF9", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230770, + "metadata": {}, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0003", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0003" + }, + "latest_invoice": "in_1EhAAjCOCguPTL2BRExZmp4c", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0003" + }, + "pending_setup_intent": null, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start": 1558230769, + "start_date": 1559476704, + "status": "active", + "tax_percent": null, + "trial_end": null, + "trial_start": null + } + ], + "has_more": false, + "total_count": 2, + "url": "/v1/customers/cus_4UbFSo9tl62jqj/subscriptions" + }, + "tax_exempt": "none", + "tax_ids": {}, + "tax_info": null, + "tax_info_verification": null +} diff --git a/tests/fixtures/customer_cus_6lsBvm5rJ0zyHc.json b/tests/fixtures/customer_cus_6lsBvm5rJ0zyHc.json index 7285100efd..d45dd29e69 100644 --- a/tests/fixtures/customer_cus_6lsBvm5rJ0zyHc.json +++ b/tests/fixtures/customer_cus_6lsBvm5rJ0zyHc.json @@ -1,318 +1,318 @@ { - "id": "cus_6lsBvm5rJ0zyHc", - "object": "customer", - "account_balance": 0, - "address": null, - "balance": 0, - "created": 1557995166, - "currency": "usd", - "default_source": { - "id": "card_fakefakefakefakefake0001", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_6lsBvm5rJ0zyHc", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": { - "djstripe_test_fake_id": "card_fakefakefakefakefake0001" - }, - "name": "alex-nesnes@hotmail.fr", - "tokenization_method": null - }, - "delinquent": false, - "description": "Michael Smith", - "discount": null, - "email": "michael.smith@example.com", - "invoice_prefix": "3D77FB04", - "invoice_settings": { - "custom_fields": null, - "default_payment_method": null, - "footer": null - }, - "livemode": false, - "metadata": {}, - "name": null, - "phone": null, - "preferred_locales": [], - "shipping": null, - "sources": { - "object": "list", - "data": [ - { - "id": "card_fakefakefakefakefake0001", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_6lsBvm5rJ0zyHc", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": { - "djstripe_test_fake_id": "card_fakefakefakefakefake0001" - }, - "name": "alex-nesnes@hotmail.fr", - "tokenization_method": null - }, - { - "id": "card_fakefakefakefakefake0005", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_6lsBvm5rJ0zyHc", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 6, - "exp_year": 2020, - "fingerprint": "88PuXw9tEmvYe69o", - "funding": "credit", - "last4": "4242", - "metadata": { - "djstripe_test_fake_id": "card_fakefakefakefakefake0005" - }, - "name": null, - "tokenization_method": null - } - ], - "has_more": false, - "total_count": 2, - "url": "/v1/customers/cus_6lsBvm5rJ0zyHc/sources" - }, - "subscriptions": { - "object": "list", - "data": [ - { - "id": "sub_fakefakefakefakefake0002", - "object": "subscription", - "application_fee_percent": null, - "billing": "charge_automatically", - "billing_cycle_anchor": 1558230766, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "collection_method": "charge_automatically", - "created": 1558230766, - "current_period_end": 1560909166, - "current_period_start": 1558230766, - "customer": "cus_6lsBvm5rJ0zyHc", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_F5ukq6eM2QV9g5", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230767, - "metadata": {}, - "plan": { - "id": "silver41294", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 4000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": 12, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0002", - "tax_rates": [] - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0002" - }, - "latest_invoice": "in_1EhAAgCOCguPTL2BajlVgKGV", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0002" - }, - "pending_setup_intent": null, - "plan": { - "id": "silver41294", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 4000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": 12, - "usage_type": "licensed" - }, - "quantity": 1, - "schedule": null, - "start": 1558230766, - "start_date": 1559476702, - "status": "active", - "tax_percent": null, - "trial_end": null, - "trial_start": null - }, - { - "id": "sub_fakefakefakefakefake0001", - "object": "subscription", - "application_fee_percent": null, - "billing": "charge_automatically", - "billing_cycle_anchor": 1558230764, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "collection_method": "charge_automatically", - "created": 1558230764, - "current_period_end": 1560909164, - "current_period_start": 1558230764, - "customer": "cus_6lsBvm5rJ0zyHc", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_F5ukmkS6Bxi90Y", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230764, - "metadata": {}, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0001", - "tax_rates": [] - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0001" - }, - "latest_invoice": "in_fakefakefakefakefake0001", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0001" - }, - "pending_setup_intent": null, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "schedule": null, - "start": 1558230764, - "start_date": 1559476700, - "status": "active", - "tax_percent": null, - "trial_end": null, - "trial_start": null - } - ], - "has_more": false, - "total_count": 2, - "url": "/v1/customers/cus_6lsBvm5rJ0zyHc/subscriptions" - }, - "tax_exempt": "none", - "tax_ids": {}, - "tax_info": null, - "tax_info_verification": null -} \ No newline at end of file + "id": "cus_6lsBvm5rJ0zyHc", + "object": "customer", + "account_balance": 0, + "address": null, + "balance": 0, + "created": 1557995166, + "currency": "usd", + "default_source": { + "id": "card_fakefakefakefakefake0001", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6lsBvm5rJ0zyHc", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": { + "djstripe_test_fake_id": "card_fakefakefakefakefake0001" + }, + "name": "alex-nesnes@hotmail.fr", + "tokenization_method": null + }, + "delinquent": false, + "description": "Michael Smith", + "discount": null, + "email": "michael.smith@example.com", + "invoice_prefix": "3D77FB04", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null + }, + "livemode": false, + "metadata": {}, + "name": null, + "phone": null, + "preferred_locales": [], + "shipping": null, + "sources": { + "object": "list", + "data": [ + { + "id": "card_fakefakefakefakefake0001", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6lsBvm5rJ0zyHc", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": { + "djstripe_test_fake_id": "card_fakefakefakefakefake0001" + }, + "name": "alex-nesnes@hotmail.fr", + "tokenization_method": null + }, + { + "id": "card_fakefakefakefakefake0005", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6lsBvm5rJ0zyHc", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 6, + "exp_year": 2020, + "fingerprint": "88PuXw9tEmvYe69o", + "funding": "credit", + "last4": "4242", + "metadata": { + "djstripe_test_fake_id": "card_fakefakefakefakefake0005" + }, + "name": null, + "tokenization_method": null + } + ], + "has_more": false, + "total_count": 2, + "url": "/v1/customers/cus_6lsBvm5rJ0zyHc/sources" + }, + "subscriptions": { + "object": "list", + "data": [ + { + "id": "sub_fakefakefakefakefake0002", + "object": "subscription", + "application_fee_percent": null, + "billing": "charge_automatically", + "billing_cycle_anchor": 1558230766, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1558230766, + "current_period_end": 1560909166, + "current_period_start": 1558230766, + "customer": "cus_6lsBvm5rJ0zyHc", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_F5ukq6eM2QV9g5", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230767, + "metadata": {}, + "plan": { + "id": "silver41294", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 4000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": 12, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0002", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0002" + }, + "latest_invoice": "in_1EhAAgCOCguPTL2BajlVgKGV", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0002" + }, + "pending_setup_intent": null, + "plan": { + "id": "silver41294", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 4000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": 12, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start": 1558230766, + "start_date": 1559476702, + "status": "active", + "tax_percent": null, + "trial_end": null, + "trial_start": null + }, + { + "id": "sub_fakefakefakefakefake0001", + "object": "subscription", + "application_fee_percent": null, + "billing": "charge_automatically", + "billing_cycle_anchor": 1558230764, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1558230764, + "current_period_end": 1560909164, + "current_period_start": 1558230764, + "customer": "cus_6lsBvm5rJ0zyHc", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_F5ukmkS6Bxi90Y", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230764, + "metadata": {}, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0001", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0001" + }, + "latest_invoice": "in_fakefakefakefakefake0001", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0001" + }, + "pending_setup_intent": null, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start": 1558230764, + "start_date": 1559476700, + "status": "active", + "tax_percent": null, + "trial_end": null, + "trial_start": null + } + ], + "has_more": false, + "total_count": 2, + "url": "/v1/customers/cus_6lsBvm5rJ0zyHc/subscriptions" + }, + "tax_exempt": "none", + "tax_ids": {}, + "tax_info": null, + "tax_info_verification": null +} diff --git a/tests/fixtures/invoice_in_fakefakefakefakefake0001.json b/tests/fixtures/invoice_in_fakefakefakefakefake0001.json index 85095e2441..8657d1f23f 100644 --- a/tests/fixtures/invoice_in_fakefakefakefakefake0001.json +++ b/tests/fixtures/invoice_in_fakefakefakefakefake0001.json @@ -1,119 +1,119 @@ { - "id": "in_fakefakefakefakefake0001", - "object": "invoice", - "account_country": "US", - "account_name": "dj-stripe scratch", - "amount_due": 2000, - "amount_paid": 2000, - "amount_remaining": 0, - "application_fee_amount": null, - "attempt_count": 1, - "attempted": true, - "auto_advance": false, - "billing": "charge_automatically", - "billing_reason": "subscription_create", - "charge": "ch_fakefakefakefakefake0001", - "collection_method": "charge_automatically", - "created": 1557995176, - "currency": "usd", - "custom_fields": null, - "customer": "cus_6lsBvm5rJ0zyHc", - "customer_address": null, - "customer_email": "michael.smith@example.com", - "customer_name": null, - "customer_phone": null, - "customer_shipping": null, - "customer_tax_exempt": "none", - "customer_tax_ids": [], - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "description": null, - "discount": null, - "due_date": null, - "ending_balance": 0, - "footer": null, - "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_5Z1RsP0atfAS4t9CCnnEDTDyUG", - "invoice_pdf": "https://pay.stripe.com/invoice/invst_5Z1RsP0atfAS4t9CCnnEDTDyUG/pdf", - "lines": { - "object": "list", - "data": [ - { - "id": "sli_3445f2b5e035b0", - "object": "line_item", - "amount": 2000, - "currency": "usd", - "description": "1 \u00d7 Fake Product (at $20.00 / month)", - "discountable": true, - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0001" - }, - "period": { - "end": 1562137289, - "start": 1559545289 - }, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "proration": false, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0001", - "subscription_item": "si_FBXFzo2zBOeeMy", - "tax_amounts": [], - "tax_rates": [], - "type": "subscription" - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/invoices/in_fakefakefakefakefake0001/lines" - }, - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "in_fakefakefakefakefake0001" - }, - "next_payment_attempt": null, - "number": "3D77FB04-0001", - "paid": true, - "payment_intent": "pi_fakefakefakefakefake0001", - "period_end": 1557995176, - "period_start": 1557995176, - "post_payment_credit_notes_amount": 0, - "pre_payment_credit_notes_amount": 0, - "receipt_number": null, - "starting_balance": 0, - "statement_descriptor": null, - "status": "paid", - "status_transitions": { - "finalized_at": 1559545289, - "marked_uncollectible_at": null, - "paid_at": 1559545289, - "voided_at": null - }, - "subscription": "sub_fakefakefakefakefake0001", - "subtotal": 2000, - "tax": null, - "tax_percent": null, - "total": 2000, - "total_tax_amounts": [], - "webhooks_delivered_at": 1557995178 -} \ No newline at end of file + "id": "in_fakefakefakefakefake0001", + "object": "invoice", + "account_country": "US", + "account_name": "dj-stripe scratch", + "amount_due": 2000, + "amount_paid": 2000, + "amount_remaining": 0, + "application_fee_amount": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "billing": "charge_automatically", + "billing_reason": "subscription_create", + "charge": "ch_fakefakefakefakefake0001", + "collection_method": "charge_automatically", + "created": 1557995176, + "currency": "usd", + "custom_fields": null, + "customer": "cus_6lsBvm5rJ0zyHc", + "customer_address": null, + "customer_email": "michael.smith@example.com", + "customer_name": null, + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": null, + "due_date": null, + "ending_balance": 0, + "footer": null, + "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_5Z1RsP0atfAS4t9CCnnEDTDyUG", + "invoice_pdf": "https://pay.stripe.com/invoice/invst_5Z1RsP0atfAS4t9CCnnEDTDyUG/pdf", + "lines": { + "object": "list", + "data": [ + { + "id": "sli_3445f2b5e035b0", + "object": "line_item", + "amount": 2000, + "currency": "usd", + "description": "1 \u00d7 Fake Product (at $20.00 / month)", + "discountable": true, + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0001" + }, + "period": { + "end": 1562137289, + "start": 1559545289 + }, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "proration": false, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0001", + "subscription_item": "si_FBXFzo2zBOeeMy", + "tax_amounts": [], + "tax_rates": [], + "type": "subscription" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_fakefakefakefakefake0001/lines" + }, + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "in_fakefakefakefakefake0001" + }, + "next_payment_attempt": null, + "number": "3D77FB04-0001", + "paid": true, + "payment_intent": "pi_fakefakefakefakefake0001", + "period_end": 1557995176, + "period_start": 1557995176, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "receipt_number": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1559545289, + "marked_uncollectible_at": null, + "paid_at": 1559545289, + "voided_at": null + }, + "subscription": "sub_fakefakefakefakefake0001", + "subtotal": 2000, + "tax": null, + "tax_percent": null, + "total": 2000, + "total_tax_amounts": [], + "webhooks_delivered_at": 1557995178 +} diff --git a/tests/fixtures/payment_intent_pi_fakefakefakefakefake0001.json b/tests/fixtures/payment_intent_pi_fakefakefakefakefake0001.json index 790eec846e..e88d7173ba 100644 --- a/tests/fixtures/payment_intent_pi_fakefakefakefakefake0001.json +++ b/tests/fixtures/payment_intent_pi_fakefakefakefakefake0001.json @@ -1,155 +1,155 @@ { - "id": "pi_fakefakefakefakefake0001", - "object": "payment_intent", - "amount": 2000, - "amount_capturable": 0, - "amount_received": 2000, - "application": null, - "application_fee_amount": null, - "canceled_at": null, - "cancellation_reason": null, - "capture_method": "automatic", - "charges": { - "object": "list", - "data": [ - { - "id": "ch_fakefakefakefakefake0001", - "object": "charge", - "amount": 2000, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_fake_ch_fakefakefakefakefake0001", - "billing_details": { - "address": { - "city": null, - "country": null, - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": null, - "name": "alex-nesnes@hotmail.fr", - "phone": null - }, - "captured": true, - "created": 1562801159, - "currency": "usd", - "customer": "cus_6lsBvm5rJ0zyHc", - "description": "Payment for invoice 4DF6B83C-0001", - "destination": null, - "dispute": null, - "failure_code": null, - "failure_message": null, - "fraud_details": {}, - "invoice": "in_fakefakefakefakefake0001", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "ch_fakefakefakefakefake0001" - }, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 60, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": "pi_fakefakefakefakefake0001", - "payment_method": "card_fakefakefakefakefake0001", - "payment_method_details": { - "card": { - "brand": "visa", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": null - }, - "country": "US", - "exp_month": 7, - "exp_year": 2020, - "fingerprint": "XBb4pv4IRQeImoJN", - "funding": "credit", - "last4": "4242", - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": null, - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/acct_1DeuAXKatMEEd998/ch_fakefakefakefakefake0001/rcpt_FPeTB5h3PS9fE4GLTncDw2zC1bRRmmY", - "refunded": false, - "refunds": {}, - "review": null, - "shipping": null, - "source": { - "id": "card_fakefakefakefakefake0001", - "object": "card", - "address_city": null, - "address_country": null, - "address_line1": null, - "address_line1_check": null, - "address_line2": null, - "address_state": null, - "address_zip": null, - "address_zip_check": null, - "brand": "Visa", - "country": "US", - "customer": "cus_6lsBvm5rJ0zyHc", - "cvc_check": null, - "dynamic_last4": null, - "exp_month": 7, - "exp_year": 2020, - "fingerprint": "XBb4pv4IRQeImoJN", - "funding": "credit", - "last4": "4242", - "metadata": {}, - "name": "alex-nesnes@hotmail.fr", - "tokenization_method": null - }, - "source_transfer": null, - "statement_descriptor": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/charges?payment_intent=pi_fakefakefakefakefake0001" - }, - "client_secret": "pi_fakefakefakefakefake0001_secret_ThITPIod1Ij6loKD5KOhEYqMA", - "confirmation_method": "automatic", - "created": 1562801159, - "currency": "usd", - "customer": "cus_6lsBvm5rJ0zyHc", - "description": "Payment for invoice 4DF6B83C-0001", - "invoice": "in_fakefakefakefakefake0001", - "last_payment_error": null, - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "pi_fakefakefakefakefake0001" - }, - "next_action": null, - "on_behalf_of": null, - "payment_method": null, - "payment_method_options": {}, - "payment_method_types": [ - "card" - ], - "receipt_email": null, - "review": null, - "setup_future_usage": null, - "shipping": null, - "source": "card_fakefakefakefakefake0001", - "statement_descriptor": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null -} \ No newline at end of file + "id": "pi_fakefakefakefakefake0001", + "object": "payment_intent", + "amount": 2000, + "amount_capturable": 0, + "amount_received": 2000, + "application": null, + "application_fee_amount": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_fakefakefakefakefake0001", + "object": "charge", + "amount": 2000, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_fake_ch_fakefakefakefakefake0001", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": "alex-nesnes@hotmail.fr", + "phone": null + }, + "captured": true, + "created": 1562801159, + "currency": "usd", + "customer": "cus_6lsBvm5rJ0zyHc", + "description": "Payment for invoice 4DF6B83C-0001", + "destination": null, + "dispute": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": "in_fakefakefakefakefake0001", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "ch_fakefakefakefakefake0001" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 60, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_fakefakefakefakefake0001", + "payment_method": "card_fakefakefakefakefake0001", + "payment_method_details": { + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 7, + "exp_year": 2020, + "fingerprint": "XBb4pv4IRQeImoJN", + "funding": "credit", + "last4": "4242", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/acct_1DeuAXKatMEEd998/ch_fakefakefakefakefake0001/rcpt_FPeTB5h3PS9fE4GLTncDw2zC1bRRmmY", + "refunded": false, + "refunds": {}, + "review": null, + "shipping": null, + "source": { + "id": "card_fakefakefakefakefake0001", + "object": "card", + "address_city": null, + "address_country": null, + "address_line1": null, + "address_line1_check": null, + "address_line2": null, + "address_state": null, + "address_zip": null, + "address_zip_check": null, + "brand": "Visa", + "country": "US", + "customer": "cus_6lsBvm5rJ0zyHc", + "cvc_check": null, + "dynamic_last4": null, + "exp_month": 7, + "exp_year": 2020, + "fingerprint": "XBb4pv4IRQeImoJN", + "funding": "credit", + "last4": "4242", + "metadata": {}, + "name": "alex-nesnes@hotmail.fr", + "tokenization_method": null + }, + "source_transfer": null, + "statement_descriptor": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_fakefakefakefakefake0001" + }, + "client_secret": "pi_fakefakefakefakefake0001_secret_ThITPIod1Ij6loKD5KOhEYqMA", + "confirmation_method": "automatic", + "created": 1562801159, + "currency": "usd", + "customer": "cus_6lsBvm5rJ0zyHc", + "description": "Payment for invoice 4DF6B83C-0001", + "invoice": "in_fakefakefakefakefake0001", + "last_payment_error": null, + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "pi_fakefakefakefakefake0001" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_options": {}, + "payment_method_types": [ + "card" + ], + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": "card_fakefakefakefakefake0001", + "statement_descriptor": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null +} diff --git a/tests/fixtures/payment_method_pm_fakefakefakefake0001.json b/tests/fixtures/payment_method_pm_fakefakefakefake0001.json index 55c8f3d21e..8826fe17cd 100644 --- a/tests/fixtures/payment_method_pm_fakefakefakefake0001.json +++ b/tests/fixtures/payment_method_pm_fakefakefakefake0001.json @@ -1,44 +1,44 @@ { - "id": "pm_fakefakefakefake0001", - "object": "payment_method", - "billing_details": { - "address": { - "city": null, - "country": null, - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": null, - "name": null, - "phone": null - }, - "card": { - "brand": "visa", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": null - }, - "country": "US", - "exp_month": 8, - "exp_year": 2020, - "fingerprint": "WJTDnAWuKKdbSagG", - "funding": "credit", - "generated_from": null, - "last4": "4242", - "three_d_secure_usage": { - "supported": true - }, - "wallet": null - }, - "created": 123456789, - "customer": "cus_6lsBvm5rJ0zyHc", - "livemode": false, - "metadata": { - "order_id": "123456789", - "djstripe_test_fake_id": "pm_fakefakefakefake0001" - }, - "type": "card" -} \ No newline at end of file + "id": "pm_fakefakefakefake0001", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 8, + "exp_year": 2020, + "fingerprint": "WJTDnAWuKKdbSagG", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 123456789, + "customer": "cus_6lsBvm5rJ0zyHc", + "livemode": false, + "metadata": { + "order_id": "123456789", + "djstripe_test_fake_id": "pm_fakefakefakefake0001" + }, + "type": "card" +} diff --git a/tests/fixtures/plan_gold21323.json b/tests/fixtures/plan_gold21323.json index 6881409ad8..43ce4f5c2b 100644 --- a/tests/fixtures/plan_gold21323.json +++ b/tests/fixtures/plan_gold21323.json @@ -1,21 +1,21 @@ { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1557995175, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" -} \ No newline at end of file + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1557995175, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" +} diff --git a/tests/fixtures/plan_silver41294.json b/tests/fixtures/plan_silver41294.json index d0d65f654f..f23d3deabd 100644 --- a/tests/fixtures/plan_silver41294.json +++ b/tests/fixtures/plan_silver41294.json @@ -1,21 +1,21 @@ { - "id": "silver41294", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 4000, - "billing_scheme": "per_unit", - "created": 1557995176, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": 12, - "usage_type": "licensed" -} \ No newline at end of file + "id": "silver41294", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 4000, + "billing_scheme": "per_unit", + "created": 1557995176, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": 12, + "usage_type": "licensed" +} diff --git a/tests/fixtures/product_prod_fake1.json b/tests/fixtures/product_prod_fake1.json index 3c18bcd776..08c9be1ca5 100644 --- a/tests/fixtures/product_prod_fake1.json +++ b/tests/fixtures/product_prod_fake1.json @@ -1,21 +1,21 @@ { - "id": "prod_fake1", - "object": "product", - "active": true, - "attributes": [], - "caption": null, - "created": 1557995174, - "deactivate_on": [], - "description": null, - "images": [], - "livemode": false, - "metadata": {}, - "name": "Fake Product", - "package_dimensions": null, - "shippable": null, - "statement_descriptor": null, - "type": "service", - "unit_label": null, - "updated": 1557995176, - "url": null -} \ No newline at end of file + "id": "prod_fake1", + "object": "product", + "active": true, + "attributes": [], + "caption": null, + "created": 1557995174, + "deactivate_on": [], + "description": null, + "images": [], + "livemode": false, + "metadata": {}, + "name": "Fake Product", + "package_dimensions": null, + "shippable": null, + "statement_descriptor": null, + "type": "service", + "unit_label": null, + "updated": 1557995176, + "url": null +} diff --git a/tests/fixtures/source_src_fakefakefakefakefake0001.json b/tests/fixtures/source_src_fakefakefakefakefake0001.json index c305f7183c..d3139c1592 100644 --- a/tests/fixtures/source_src_fakefakefakefakefake0001.json +++ b/tests/fixtures/source_src_fakefakefakefakefake0001.json @@ -1,44 +1,44 @@ { - "id": "src_fakefakefakefakefake0001", - "object": "source", - "amount": null, - "card": { - "exp_month": 6, - "exp_year": 2020, - "last4": "4242", - "country": "US", - "brand": "Visa", - "funding": "credit", - "fingerprint": "88PuXw9tEmvYe69o", - "three_d_secure": "optional", - "name": null, - "address_line1_check": null, - "address_zip_check": null, - "cvc_check": null, - "tokenization_method": null, - "dynamic_last4": null - }, - "client_secret": "src_client_secret_F4oXYTWIpC9GV8qeLoVS9Wqk", - "created": 1557995173, - "currency": null, - "customer": "cus_4QWKsZuuTHcs7X", - "flow": "none", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "src_fakefakefakefakefake0001" - }, - "owner": { - "address": null, - "email": null, - "name": null, - "phone": null, - "verified_address": null, - "verified_email": null, - "verified_name": null, - "verified_phone": null - }, - "statement_descriptor": null, - "status": "chargeable", - "type": "card", - "usage": "reusable" -} \ No newline at end of file + "id": "src_fakefakefakefakefake0001", + "object": "source", + "amount": null, + "card": { + "exp_month": 6, + "exp_year": 2020, + "last4": "4242", + "country": "US", + "brand": "Visa", + "funding": "credit", + "fingerprint": "88PuXw9tEmvYe69o", + "three_d_secure": "optional", + "name": null, + "address_line1_check": null, + "address_zip_check": null, + "cvc_check": null, + "tokenization_method": null, + "dynamic_last4": null + }, + "client_secret": "src_client_secret_F4oXYTWIpC9GV8qeLoVS9Wqk", + "created": 1557995173, + "currency": null, + "customer": "cus_4QWKsZuuTHcs7X", + "flow": "none", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "src_fakefakefakefakefake0001" + }, + "owner": { + "address": null, + "email": null, + "name": null, + "phone": null, + "verified_address": null, + "verified_email": null, + "verified_name": null, + "verified_phone": null + }, + "statement_descriptor": null, + "status": "chargeable", + "type": "card", + "usage": "reusable" +} diff --git a/tests/fixtures/subscription_sub_fakefakefakefakefake0001.json b/tests/fixtures/subscription_sub_fakefakefakefakefake0001.json index 17e34eb62c..dc6ff7ecee 100644 --- a/tests/fixtures/subscription_sub_fakefakefakefakefake0001.json +++ b/tests/fixtures/subscription_sub_fakefakefakefakefake0001.json @@ -1,96 +1,96 @@ { - "id": "sub_fakefakefakefakefake0001", - "object": "subscription", - "application_fee_percent": null, - "billing": "charge_automatically", - "billing_cycle_anchor": 1557995176, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "collection_method": "charge_automatically", - "created": 1557995176, - "current_period_end": 1560673576, - "current_period_start": 1557995176, - "customer": "cus_6lsBvm5rJ0zyHc", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_F5ukmkS6Bxi90Y", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230764, - "metadata": {}, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0001", - "tax_rates": [] - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0001" - }, - "latest_invoice": "in_fakefakefakefakefake0001", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0001" - }, - "pending_setup_intent": null, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "schedule": null, - "start": 1557995176, - "start_date": 1559476700, - "status": "active", - "tax_percent": null, - "trial_end": null, - "trial_start": null -} \ No newline at end of file + "id": "sub_fakefakefakefakefake0001", + "object": "subscription", + "application_fee_percent": null, + "billing": "charge_automatically", + "billing_cycle_anchor": 1557995176, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1557995176, + "current_period_end": 1560673576, + "current_period_start": 1557995176, + "customer": "cus_6lsBvm5rJ0zyHc", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_F5ukmkS6Bxi90Y", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230764, + "metadata": {}, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0001", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0001" + }, + "latest_invoice": "in_fakefakefakefakefake0001", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0001" + }, + "pending_setup_intent": null, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start": 1557995176, + "start_date": 1559476700, + "status": "active", + "tax_percent": null, + "trial_end": null, + "trial_start": null +} diff --git a/tests/fixtures/subscription_sub_fakefakefakefakefake0002.json b/tests/fixtures/subscription_sub_fakefakefakefakefake0002.json index 8d7d1b5e93..8f0f253251 100644 --- a/tests/fixtures/subscription_sub_fakefakefakefakefake0002.json +++ b/tests/fixtures/subscription_sub_fakefakefakefakefake0002.json @@ -1,96 +1,96 @@ { - "id": "sub_fakefakefakefakefake0002", - "object": "subscription", - "application_fee_percent": null, - "billing": "charge_automatically", - "billing_cycle_anchor": 1557995178, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "collection_method": "charge_automatically", - "created": 1557995178, - "current_period_end": 1560673578, - "current_period_start": 1557995178, - "customer": "cus_6lsBvm5rJ0zyHc", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_F5ukq6eM2QV9g5", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230767, - "metadata": {}, - "plan": { - "id": "silver41294", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 4000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": 12, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0002", - "tax_rates": [] - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0002" - }, - "latest_invoice": "in_1EhAAgCOCguPTL2BajlVgKGV", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0002" - }, - "pending_setup_intent": null, - "plan": { - "id": "silver41294", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 4000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": 12, - "usage_type": "licensed" - }, - "quantity": 1, - "schedule": null, - "start": 1557995178, - "start_date": 1559476702, - "status": "active", - "tax_percent": null, - "trial_end": null, - "trial_start": null -} \ No newline at end of file + "id": "sub_fakefakefakefakefake0002", + "object": "subscription", + "application_fee_percent": null, + "billing": "charge_automatically", + "billing_cycle_anchor": 1557995178, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1557995178, + "current_period_end": 1560673578, + "current_period_start": 1557995178, + "customer": "cus_6lsBvm5rJ0zyHc", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_F5ukq6eM2QV9g5", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230767, + "metadata": {}, + "plan": { + "id": "silver41294", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 4000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": 12, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0002", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0002" + }, + "latest_invoice": "in_1EhAAgCOCguPTL2BajlVgKGV", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0002" + }, + "pending_setup_intent": null, + "plan": { + "id": "silver41294", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 4000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": 12, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start": 1557995178, + "start_date": 1559476702, + "status": "active", + "tax_percent": null, + "trial_end": null, + "trial_start": null +} diff --git a/tests/fixtures/subscription_sub_fakefakefakefakefake0003.json b/tests/fixtures/subscription_sub_fakefakefakefakefake0003.json index 5b3764893f..5774f2eda3 100644 --- a/tests/fixtures/subscription_sub_fakefakefakefakefake0003.json +++ b/tests/fixtures/subscription_sub_fakefakefakefakefake0003.json @@ -1,96 +1,96 @@ { - "id": "sub_fakefakefakefakefake0003", - "object": "subscription", - "application_fee_percent": null, - "billing": "charge_automatically", - "billing_cycle_anchor": 1557995180, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "collection_method": "charge_automatically", - "created": 1557995180, - "current_period_end": 1560673580, - "current_period_start": 1557995180, - "customer": "cus_4UbFSo9tl62jqj", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_F5ukGdpR4EejF9", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230770, - "metadata": {}, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0003", - "tax_rates": [] - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0003" - }, - "latest_invoice": "in_1EhAAjCOCguPTL2BRExZmp4c", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0003" - }, - "pending_setup_intent": null, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "schedule": null, - "start": 1557995180, - "start_date": 1559476704, - "status": "active", - "tax_percent": null, - "trial_end": null, - "trial_start": null -} \ No newline at end of file + "id": "sub_fakefakefakefakefake0003", + "object": "subscription", + "application_fee_percent": null, + "billing": "charge_automatically", + "billing_cycle_anchor": 1557995180, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1557995180, + "current_period_end": 1560673580, + "current_period_start": 1557995180, + "customer": "cus_4UbFSo9tl62jqj", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_F5ukGdpR4EejF9", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230770, + "metadata": {}, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0003", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0003" + }, + "latest_invoice": "in_1EhAAjCOCguPTL2BRExZmp4c", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0003" + }, + "pending_setup_intent": null, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "schedule": null, + "start": 1557995180, + "start_date": 1559476704, + "status": "active", + "tax_percent": null, + "trial_end": null, + "trial_start": null +} diff --git a/tests/fixtures/subscription_sub_fakefakefakefakefake0004.json b/tests/fixtures/subscription_sub_fakefakefakefakefake0004.json index b2e3160f36..246558f6f3 100644 --- a/tests/fixtures/subscription_sub_fakefakefakefakefake0004.json +++ b/tests/fixtures/subscription_sub_fakefakefakefakefake0004.json @@ -1,107 +1,107 @@ { - "id": "sub_fakefakefakefakefake0004", - "object": "subscription", - "application_fee_percent": null, - "billing": "charge_automatically", - "billing_cycle_anchor": 1557995182, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "collection_method": "charge_automatically", - "created": 1557995182, - "current_period_end": 1560673582, - "current_period_start": 1557995182, - "customer": "cus_4UbFSo9tl62jqj", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [], - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_F5uk9HMrUwrmUJ", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230772, - "metadata": {}, - "plan": { - "id": "gold21323", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 2000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0004", - "tax_rates": [] - }, - { - "id": "si_F5uk81B1xGi3Vr", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1558230772, - "metadata": {}, - "plan": { - "id": "silver41294", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 4000, - "billing_scheme": "per_unit", - "created": 1558230763, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": {}, - "nickname": "New plan name", - "product": "prod_fake1", - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": 12, - "usage_type": "licensed" - }, - "quantity": 1, - "subscription": "sub_fakefakefakefakefake0004", - "tax_rates": [] - } - ], - "has_more": false, - "total_count": 2, - "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0004" - }, - "latest_invoice": "in_1EhAAlCOCguPTL2BHKM8PujL", - "livemode": false, - "metadata": { - "djstripe_test_fake_id": "sub_fakefakefakefakefake0004" - }, - "pending_setup_intent": null, - "plan": null, - "quantity": null, - "schedule": null, - "start": 1557995182, - "start_date": 1559476706, - "status": "active", - "tax_percent": null, - "trial_end": null, - "trial_start": null -} \ No newline at end of file + "id": "sub_fakefakefakefakefake0004", + "object": "subscription", + "application_fee_percent": null, + "billing": "charge_automatically", + "billing_cycle_anchor": 1557995182, + "billing_thresholds": null, + "cancel_at": null, + "cancel_at_period_end": false, + "canceled_at": null, + "collection_method": "charge_automatically", + "created": 1557995182, + "current_period_end": 1560673582, + "current_period_start": 1557995182, + "customer": "cus_4UbFSo9tl62jqj", + "days_until_due": null, + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "discount": null, + "ended_at": null, + "items": { + "object": "list", + "data": [ + { + "id": "si_F5uk9HMrUwrmUJ", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230772, + "metadata": {}, + "plan": { + "id": "gold21323", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 2000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0004", + "tax_rates": [] + }, + { + "id": "si_F5uk81B1xGi3Vr", + "object": "subscription_item", + "billing_thresholds": null, + "created": 1558230772, + "metadata": {}, + "plan": { + "id": "silver41294", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 4000, + "billing_scheme": "per_unit", + "created": 1558230763, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "nickname": "New plan name", + "product": "prod_fake1", + "tiers": null, + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": 12, + "usage_type": "licensed" + }, + "quantity": 1, + "subscription": "sub_fakefakefakefakefake0004", + "tax_rates": [] + } + ], + "has_more": false, + "total_count": 2, + "url": "/v1/subscription_items?subscription=sub_fakefakefakefakefake0004" + }, + "latest_invoice": "in_1EhAAlCOCguPTL2BHKM8PujL", + "livemode": false, + "metadata": { + "djstripe_test_fake_id": "sub_fakefakefakefakefake0004" + }, + "pending_setup_intent": null, + "plan": null, + "quantity": null, + "schedule": null, + "start": 1557995182, + "start_date": 1559476706, + "status": "active", + "tax_percent": null, + "trial_end": null, + "trial_start": null +} diff --git a/tests/settings.py b/tests/settings.py index a45ded90d7..4b0508c1d6 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -19,80 +19,80 @@ ALLOWED_HOSTS = json.loads(os.environ.get("DJSTRIPE_TEST_ALLOWED_HOSTS_JSON", "[]")) if test_db_vendor == "postgres": - DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql_psycopg2", - "NAME": test_db_name, - "USER": test_db_user, - "PASSWORD": test_db_pass, - "HOST": test_db_host, - "PORT": test_db_port, - } - } + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": test_db_name, + "USER": test_db_user, + "PASSWORD": test_db_pass, + "HOST": test_db_host, + "PORT": test_db_port, + } + } elif test_db_vendor == "mysql": - DATABASES = { - "default": { - "ENGINE": "django.db.backends.mysql", - "NAME": test_db_name, - "USER": test_db_user, - "PASSWORD": test_db_pass, - "HOST": test_db_host, - "PORT": test_db_port, - } - } + DATABASES = { + "default": { + "ENGINE": "django.db.backends.mysql", + "NAME": test_db_name, + "USER": test_db_user, + "PASSWORD": test_db_pass, + "HOST": test_db_host, + "PORT": test_db_port, + } + } elif test_db_vendor == "sqlite": - # sqlite is not officially supported, but useful for quick testing. - # may be dropped if we can't maintain compatibility. - DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - # use a on-disk db for test so --reuse-db can be used - "TEST": {"NAME": os.path.join(BASE_DIR, "test_db.sqlite3")}, - } - } + # sqlite is not officially supported, but useful for quick testing. + # may be dropped if we can't maintain compatibility. + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + # use a on-disk db for test so --reuse-db can be used + "TEST": {"NAME": os.path.join(BASE_DIR, "test_db.sqlite3")}, + } + } else: - raise NotImplementedError("DJSTRIPE_TEST_DB_VENDOR = {}".format(test_db_vendor)) + raise NotImplementedError("DJSTRIPE_TEST_DB_VENDOR = {}".format(test_db_vendor)) TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ] - }, - } + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } ] ROOT_URLCONF = "tests.urls" INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.sites", - "django.contrib.staticfiles", - "jsonfield", - "djstripe", - "tests", - "tests.apps.testapp", - "tests.apps.example", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.sites", + "django.contrib.staticfiles", + "jsonfield", + "djstripe", + "tests", + "tests.apps.testapp", + "tests.apps.example", ] MIDDLEWARE = ( - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ) STRIPE_LIVE_PUBLIC_KEY = os.environ.get("STRIPE_PUBLIC_KEY", "pk_test_123") @@ -101,10 +101,10 @@ STRIPE_TEST_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "sk_test_123") DJSTRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS = ( - "(admin)", - "test_url_name", - "testapp_namespaced:test_url_namespaced", - "fn:/test_fnmatch*", + "(admin)", + "test_url_name", + "testapp_namespaced:test_url_namespaced", + "fn:/test_fnmatch*", ) DJSTRIPE_USE_NATIVE_JSONFIELD = os.environ.get("USE_NATIVE_JSONFIELD", "") == "1" diff --git a/tests/templates/base.html b/tests/templates/base.html index b41c31809b..c76fe47c70 100644 --- a/tests/templates/base.html +++ b/tests/templates/base.html @@ -1,11 +1,11 @@ - - {% block head_title %}{% endblock %} + + {% block head_title %}{% endblock %} - {% block header_js %}{% endblock %} - {% block header_css %}{% endblock %} + {% block header_js %}{% endblock %} + {% block header_css %}{% endblock %} diff --git a/tests/test_account.py b/tests/test_account.py index b80a9da2ad..6eaef158f7 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -10,80 +10,83 @@ from djstripe.settings import STRIPE_SECRET_KEY from . import ( - FAKE_ACCOUNT, FAKE_FILEUPLOAD_ICON, FAKE_FILEUPLOAD_LOGO, - IS_STATICMETHOD_AUTOSPEC_SUPPORTED, AssertStripeFksMixin + FAKE_ACCOUNT, + FAKE_FILEUPLOAD_ICON, + FAKE_FILEUPLOAD_LOGO, + IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + AssertStripeFksMixin, ) class TestAccount(AssertStripeFksMixin, TestCase): - @patch("stripe.Account.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) - @patch( - "stripe.FileUpload.retrieve", - side_effect=[deepcopy(FAKE_FILEUPLOAD_ICON), deepcopy(FAKE_FILEUPLOAD_LOGO)], - autospec=True, - ) - def test_get_connected_account_from_token( - self, fileupload_retrieve_mock, account_retrieve_mock - ): - account_retrieve_mock.return_value = deepcopy(FAKE_ACCOUNT) - - account = Account.get_connected_account_from_token("fake_token") - - account_retrieve_mock.assert_called_once_with(api_key="fake_token") - - self.assert_fks(account, expected_blank_fks={}) - - @patch("stripe.Account.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) - @patch( - "stripe.FileUpload.retrieve", - side_effect=[deepcopy(FAKE_FILEUPLOAD_ICON), deepcopy(FAKE_FILEUPLOAD_LOGO)], - autospec=True, - ) - def test_get_default_account(self, fileupload_retrieve_mock, account_retrieve_mock): - account_retrieve_mock.return_value = deepcopy(FAKE_ACCOUNT) - - account = Account.get_default_account() - - account_retrieve_mock.assert_called_once_with(api_key=STRIPE_SECRET_KEY) - - self.assertGreater(len(account.business_profile), 0) - self.assertGreater(len(account.settings), 0) - - self.assertEqual(account.branding_icon.id, FAKE_FILEUPLOAD_ICON["id"]) - self.assertEqual(account.branding_logo.id, FAKE_FILEUPLOAD_LOGO["id"]) - - self.assertEqual(account.settings["branding"]["icon"], account.branding_icon.id) - self.assertEqual(account.settings["branding"]["logo"], account.branding_logo.id) - - with self.assertWarns(DeprecationWarning): - self.assertEqual(account.business_logo.id, account.branding_icon.id) - - self.assertNotEqual(account.branding_logo.id, account.branding_icon.id) - - self.assert_fks(account, expected_blank_fks={}) - - @patch("stripe.Account.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) - @patch( - "stripe.FileUpload.retrieve", - return_value=deepcopy(FAKE_FILEUPLOAD_LOGO), - autospec=True, - ) - def test_get_default_account_null_logo( - self, fileupload_retrieve_mock, account_retrieve_mock - ): - fake_account = deepcopy(FAKE_ACCOUNT) - fake_account["settings"]["branding"]["icon"] = None - fake_account["settings"]["branding"]["logo"] = None - account_retrieve_mock.return_value = fake_account - - account = Account.get_default_account() - - account_retrieve_mock.assert_called_once_with(api_key=STRIPE_SECRET_KEY) - - self.assert_fks( - account, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - }, - ) + @patch("stripe.Account.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) + @patch( + "stripe.FileUpload.retrieve", + side_effect=[deepcopy(FAKE_FILEUPLOAD_ICON), deepcopy(FAKE_FILEUPLOAD_LOGO)], + autospec=True, + ) + def test_get_connected_account_from_token( + self, fileupload_retrieve_mock, account_retrieve_mock + ): + account_retrieve_mock.return_value = deepcopy(FAKE_ACCOUNT) + + account = Account.get_connected_account_from_token("fake_token") + + account_retrieve_mock.assert_called_once_with(api_key="fake_token") + + self.assert_fks(account, expected_blank_fks={}) + + @patch("stripe.Account.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) + @patch( + "stripe.FileUpload.retrieve", + side_effect=[deepcopy(FAKE_FILEUPLOAD_ICON), deepcopy(FAKE_FILEUPLOAD_LOGO)], + autospec=True, + ) + def test_get_default_account(self, fileupload_retrieve_mock, account_retrieve_mock): + account_retrieve_mock.return_value = deepcopy(FAKE_ACCOUNT) + + account = Account.get_default_account() + + account_retrieve_mock.assert_called_once_with(api_key=STRIPE_SECRET_KEY) + + self.assertGreater(len(account.business_profile), 0) + self.assertGreater(len(account.settings), 0) + + self.assertEqual(account.branding_icon.id, FAKE_FILEUPLOAD_ICON["id"]) + self.assertEqual(account.branding_logo.id, FAKE_FILEUPLOAD_LOGO["id"]) + + self.assertEqual(account.settings["branding"]["icon"], account.branding_icon.id) + self.assertEqual(account.settings["branding"]["logo"], account.branding_logo.id) + + with self.assertWarns(DeprecationWarning): + self.assertEqual(account.business_logo.id, account.branding_icon.id) + + self.assertNotEqual(account.branding_logo.id, account.branding_icon.id) + + self.assert_fks(account, expected_blank_fks={}) + + @patch("stripe.Account.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) + @patch( + "stripe.FileUpload.retrieve", + return_value=deepcopy(FAKE_FILEUPLOAD_LOGO), + autospec=True, + ) + def test_get_default_account_null_logo( + self, fileupload_retrieve_mock, account_retrieve_mock + ): + fake_account = deepcopy(FAKE_ACCOUNT) + fake_account["settings"]["branding"]["icon"] = None + fake_account["settings"]["branding"]["logo"] = None + account_retrieve_mock.return_value = fake_account + + account = Account.get_default_account() + + account_retrieve_mock.assert_called_once_with(api_key=STRIPE_SECRET_KEY) + + self.assert_fks( + account, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + }, + ) diff --git a/tests/test_admin.py b/tests/test_admin.py index 60939a33fa..c13bf9ad19 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -7,23 +7,25 @@ class TestAdminSite(TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) - def test_search_fields(self): - """ - Search for errors like this: - Bad search field for Customer model. - """ + def test_search_fields(self): + """ + Search for errors like this: + Bad search field for Customer model. + """ - for _model, model_admin in admin.site._registry.items(): - for search_field in getattr(model_admin, "search_fields", []): - model_name = model_admin.model.__name__ - self.assertFalse( - search_field.startswith("{table_name}__".format(table_name=model_name.lower())), - "Bad search field <{search_field}> for {model_name} model.".format( - search_field=search_field, model_name=model_name - ), - ) + for _model, model_admin in admin.site._registry.items(): + for search_field in getattr(model_admin, "search_fields", []): + model_name = model_admin.model.__name__ + self.assertFalse( + search_field.startswith( + "{table_name}__".format(table_name=model_name.lower()) + ), + "Bad search field <{search_field}> for {model_name} model.".format( + search_field=search_field, model_name=model_name + ), + ) diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index 9f7f236131..c3b1e82f6b 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -6,69 +6,77 @@ from djstripe import settings as djstripe_settings try: - reload + reload except NameError: - from importlib import reload + from importlib import reload class TestCheckApiKeySettings(TestCase): - @override_settings( - STRIPE_LIVE_SECRET_KEY="sk_live_foo", - STRIPE_LIVE_PUBLIC_KEY="sk_live_foo", - STRIPE_LIVE_MODE=True, - ) - def test_global_api_keys_live_mode(self): - reload(djstripe_settings) - self.assertEqual(djstripe_settings.STRIPE_LIVE_MODE, True) - self.assertEqual(djstripe_settings.STRIPE_SECRET_KEY, "sk_live_foo") - self.assertEqual(djstripe_settings.LIVE_API_KEY, "sk_live_foo") - self.assertEqual(models.StripeModel(livemode=True).default_api_key, "sk_live_foo") + @override_settings( + STRIPE_LIVE_SECRET_KEY="sk_live_foo", + STRIPE_LIVE_PUBLIC_KEY="sk_live_foo", + STRIPE_LIVE_MODE=True, + ) + def test_global_api_keys_live_mode(self): + reload(djstripe_settings) + self.assertEqual(djstripe_settings.STRIPE_LIVE_MODE, True) + self.assertEqual(djstripe_settings.STRIPE_SECRET_KEY, "sk_live_foo") + self.assertEqual(djstripe_settings.LIVE_API_KEY, "sk_live_foo") + self.assertEqual( + models.StripeModel(livemode=True).default_api_key, "sk_live_foo" + ) - @override_settings( - STRIPE_TEST_SECRET_KEY="sk_test_foo", - STRIPE_TEST_PUBLIC_KEY="pk_test_foo", - STRIPE_LIVE_MODE=False, - ) - def test_global_api_keys_test_mode(self): - reload(djstripe_settings) - self.assertEqual(djstripe_settings.STRIPE_LIVE_MODE, False) - self.assertEqual(djstripe_settings.STRIPE_SECRET_KEY, "sk_test_foo") - self.assertEqual(djstripe_settings.TEST_API_KEY, "sk_test_foo") - self.assertEqual(models.StripeModel(livemode=False).default_api_key, "sk_test_foo") + @override_settings( + STRIPE_TEST_SECRET_KEY="sk_test_foo", + STRIPE_TEST_PUBLIC_KEY="pk_test_foo", + STRIPE_LIVE_MODE=False, + ) + def test_global_api_keys_test_mode(self): + reload(djstripe_settings) + self.assertEqual(djstripe_settings.STRIPE_LIVE_MODE, False) + self.assertEqual(djstripe_settings.STRIPE_SECRET_KEY, "sk_test_foo") + self.assertEqual(djstripe_settings.TEST_API_KEY, "sk_test_foo") + self.assertEqual( + models.StripeModel(livemode=False).default_api_key, "sk_test_foo" + ) - @override_settings( - STRIPE_TEST_SECRET_KEY="sk_test_foo", - STRIPE_LIVE_SECRET_KEY="sk_live_foo", - STRIPE_TEST_PUBLIC_KEY="pk_test_foo", - STRIPE_LIVE_PUBLIC_KEY="pk_live_foo", - STRIPE_LIVE_MODE=True, - ) - def test_api_key_live_mode(self): - del settings.STRIPE_SECRET_KEY, settings.STRIPE_TEST_SECRET_KEY - del settings.STRIPE_PUBLIC_KEY, settings.STRIPE_TEST_PUBLIC_KEY - reload(djstripe_settings) - self.assertEqual(djstripe_settings.STRIPE_LIVE_MODE, True) - self.assertEqual(djstripe_settings.STRIPE_SECRET_KEY, "sk_live_foo") - self.assertEqual(djstripe_settings.STRIPE_PUBLIC_KEY, "pk_live_foo") - self.assertEqual(djstripe_settings.LIVE_API_KEY, "sk_live_foo") - self.assertEqual(models.StripeModel(livemode=True).default_api_key, "sk_live_foo") + @override_settings( + STRIPE_TEST_SECRET_KEY="sk_test_foo", + STRIPE_LIVE_SECRET_KEY="sk_live_foo", + STRIPE_TEST_PUBLIC_KEY="pk_test_foo", + STRIPE_LIVE_PUBLIC_KEY="pk_live_foo", + STRIPE_LIVE_MODE=True, + ) + def test_api_key_live_mode(self): + del settings.STRIPE_SECRET_KEY, settings.STRIPE_TEST_SECRET_KEY + del settings.STRIPE_PUBLIC_KEY, settings.STRIPE_TEST_PUBLIC_KEY + reload(djstripe_settings) + self.assertEqual(djstripe_settings.STRIPE_LIVE_MODE, True) + self.assertEqual(djstripe_settings.STRIPE_SECRET_KEY, "sk_live_foo") + self.assertEqual(djstripe_settings.STRIPE_PUBLIC_KEY, "pk_live_foo") + self.assertEqual(djstripe_settings.LIVE_API_KEY, "sk_live_foo") + self.assertEqual( + models.StripeModel(livemode=True).default_api_key, "sk_live_foo" + ) - @override_settings( - STRIPE_TEST_SECRET_KEY="sk_test_foo", - STRIPE_LIVE_SECRET_KEY="sk_live_foo", - STRIPE_TEST_PUBLIC_KEY="pk_test_foo", - STRIPE_LIVE_PUBLIC_KEY="pk_live_foo", - STRIPE_LIVE_MODE=False, - ) - def test_secret_key_test_mode(self): - del settings.STRIPE_SECRET_KEY - del settings.STRIPE_PUBLIC_KEY - reload(djstripe_settings) - self.assertEqual(djstripe_settings.STRIPE_LIVE_MODE, False) - self.assertEqual(djstripe_settings.STRIPE_SECRET_KEY, "sk_test_foo") - self.assertEqual(djstripe_settings.STRIPE_PUBLIC_KEY, "pk_test_foo") - self.assertEqual(djstripe_settings.TEST_API_KEY, "sk_test_foo") - self.assertEqual(models.StripeModel(livemode=False).default_api_key, "sk_test_foo") + @override_settings( + STRIPE_TEST_SECRET_KEY="sk_test_foo", + STRIPE_LIVE_SECRET_KEY="sk_live_foo", + STRIPE_TEST_PUBLIC_KEY="pk_test_foo", + STRIPE_LIVE_PUBLIC_KEY="pk_live_foo", + STRIPE_LIVE_MODE=False, + ) + def test_secret_key_test_mode(self): + del settings.STRIPE_SECRET_KEY + del settings.STRIPE_PUBLIC_KEY + reload(djstripe_settings) + self.assertEqual(djstripe_settings.STRIPE_LIVE_MODE, False) + self.assertEqual(djstripe_settings.STRIPE_SECRET_KEY, "sk_test_foo") + self.assertEqual(djstripe_settings.STRIPE_PUBLIC_KEY, "pk_test_foo") + self.assertEqual(djstripe_settings.TEST_API_KEY, "sk_test_foo") + self.assertEqual( + models.StripeModel(livemode=False).default_api_key, "sk_test_foo" + ) - def tearDown(self): - reload(djstripe_settings) + def tearDown(self): + reload(djstripe_settings) diff --git a/tests/test_card.py b/tests/test_card.py index de61fe78a0..c4f92b8870 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -12,157 +12,194 @@ from djstripe.models import Card from . import ( - FAKE_CARD, FAKE_CARD_III, FAKE_CARD_V, FAKE_CUSTOMER, - AssertStripeFksMixin, default_account + FAKE_CARD, + FAKE_CARD_III, + FAKE_CARD_V, + FAKE_CUSTOMER, + AssertStripeFksMixin, + default_account, ) class CardTest(AssertStripeFksMixin, TestCase): - def setUp(self): - self.account = default_account() - self.user = get_user_model().objects.create_user( - username="testuser", email="djstripe@example.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - self.customer.sources.all().delete() - self.customer.legacy_cards.all().delete() - - def test_attach_objects_hook_without_customer(self): - card = Card.sync_from_stripe_data(deepcopy(FAKE_CARD_III)) - self.assertEqual(card.customer, None) - - def test_create_card_finds_customer(self): - card = Card.sync_from_stripe_data(deepcopy(FAKE_CARD)) - - self.assertEqual(self.customer, card.customer) - self.assertEqual( - card.get_stripe_dashboard_url(), self.customer.get_stripe_dashboard_url() - ) - - def test_str(self): - fake_card = deepcopy(FAKE_CARD) - card = Card.sync_from_stripe_data(fake_card) - - self.assertEqual( - "".format( - brand=fake_card["brand"], - last4=fake_card["last4"], - exp_month=fake_card["exp_month"], - exp_year=fake_card["exp_year"], - id=fake_card["id"], - ), - str(card), - ) - - self.assert_fks(card, expected_blank_fks={"djstripe.Customer.coupon"}) - - @patch("stripe.Token.create", autospec=True) - def test_card_create_token(self, token_create_mock): - card = {"number": "4242", "exp_month": 5, "exp_year": 2012, "cvc": 445} - Card.create_token(**card) - - token_create_mock.assert_called_with(api_key=ANY, card=card) - - def test_api_call_no_customer(self): - exception_message = "Cards must be manipulated through a Customer. Pass a Customer object into this call." - - with self.assertRaisesMessage(StripeObjectManipulationException, exception_message): - Card._api_create() - - with self.assertRaisesMessage(StripeObjectManipulationException, exception_message): - Card.api_list() - - def test_api_call_bad_customer(self): - exception_message = "Cards must be manipulated through a Customer. Pass a Customer object into this call." - - with self.assertRaisesMessage(StripeObjectManipulationException, exception_message): - Card._api_create(customer="fish") - - with self.assertRaisesMessage(StripeObjectManipulationException, exception_message): - Card.api_list(customer="fish") - - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_api_create(self, customer_retrieve_mock): - stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) - - self.assertEqual(FAKE_CARD, stripe_card) - - @patch("tests.CardDict.delete", autospec=True) - @patch("stripe.Card.retrieve", return_value=deepcopy(FAKE_CARD), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_remove(self, customer_retrieve_mock, card_retrieve_mock, card_delete_mock): - stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) - Card.sync_from_stripe_data(stripe_card) - - self.assertEqual(1, self.customer.legacy_cards.count()) - - card = self.customer.legacy_cards.all()[0] - card.remove() - - self.assertEqual(0, self.customer.legacy_cards.count()) - self.assertTrue(card_delete_mock.called) - - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_remove_already_deleted_card(self, customer_retrieve_mock): - stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) - Card.sync_from_stripe_data(stripe_card) - - self.assertEqual(self.customer.legacy_cards.count(), 1) - card_object = self.customer.legacy_cards.first() - Card.objects.filter(id=stripe_card["id"]).delete() - self.assertEqual(self.customer.legacy_cards.count(), 0) - card_object.remove() - self.assertEqual(self.customer.legacy_cards.count(), 0) - - @patch("djstripe.models.Card._api_delete", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_remove_no_such_source(self, customer_retrieve_mock, card_delete_mock): - stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) - Card.sync_from_stripe_data(stripe_card) - - card_delete_mock.side_effect = InvalidRequestError("No such source:", "blah") - - self.assertEqual(1, self.customer.legacy_cards.count()) - - card = self.customer.legacy_cards.all()[0] - card.remove() - - self.assertEqual(0, self.customer.legacy_cards.count()) - self.assertTrue(card_delete_mock.called) - - @patch("djstripe.models.Card._api_delete", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_remove_no_such_customer(self, customer_retrieve_mock, card_delete_mock): - stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) - Card.sync_from_stripe_data(stripe_card) - - card_delete_mock.side_effect = InvalidRequestError("No such customer:", "blah") - - self.assertEqual(1, self.customer.legacy_cards.count()) - - card = self.customer.legacy_cards.all()[0] - card.remove() - - self.assertEqual(0, self.customer.legacy_cards.count()) - self.assertTrue(card_delete_mock.called) - - @patch("djstripe.models.Card._api_delete", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_remove_unexpected_exception(self, customer_retrieve_mock, card_delete_mock): - stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) - Card.sync_from_stripe_data(stripe_card) - - card_delete_mock.side_effect = InvalidRequestError("Unexpected Exception", "blah") - - self.assertEqual(1, self.customer.legacy_cards.count()) - - card = self.customer.legacy_cards.all()[0] - - with self.assertRaisesMessage(InvalidRequestError, "Unexpected Exception"): - card.remove() - - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_api_list(self, customer_retrieve_mock): - card_list = Card.api_list(customer=self.customer) - - self.assertEqual([FAKE_CARD, FAKE_CARD_V], card_list) + def setUp(self): + self.account = default_account() + self.user = get_user_model().objects.create_user( + username="testuser", email="djstripe@example.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + self.customer.sources.all().delete() + self.customer.legacy_cards.all().delete() + + def test_attach_objects_hook_without_customer(self): + card = Card.sync_from_stripe_data(deepcopy(FAKE_CARD_III)) + self.assertEqual(card.customer, None) + + def test_create_card_finds_customer(self): + card = Card.sync_from_stripe_data(deepcopy(FAKE_CARD)) + + self.assertEqual(self.customer, card.customer) + self.assertEqual( + card.get_stripe_dashboard_url(), self.customer.get_stripe_dashboard_url() + ) + + def test_str(self): + fake_card = deepcopy(FAKE_CARD) + card = Card.sync_from_stripe_data(fake_card) + + self.assertEqual( + "".format( + brand=fake_card["brand"], + last4=fake_card["last4"], + exp_month=fake_card["exp_month"], + exp_year=fake_card["exp_year"], + id=fake_card["id"], + ), + str(card), + ) + + self.assert_fks(card, expected_blank_fks={"djstripe.Customer.coupon"}) + + @patch("stripe.Token.create", autospec=True) + def test_card_create_token(self, token_create_mock): + card = {"number": "4242", "exp_month": 5, "exp_year": 2012, "cvc": 445} + Card.create_token(**card) + + token_create_mock.assert_called_with(api_key=ANY, card=card) + + def test_api_call_no_customer(self): + exception_message = ( + "Cards must be manipulated through a Customer. " + "Pass a Customer object into this call." + ) + + with self.assertRaisesMessage( + StripeObjectManipulationException, exception_message + ): + Card._api_create() + + with self.assertRaisesMessage( + StripeObjectManipulationException, exception_message + ): + Card.api_list() + + def test_api_call_bad_customer(self): + exception_message = ( + "Cards must be manipulated through a Customer. " + "Pass a Customer object into this call." + ) + + with self.assertRaisesMessage( + StripeObjectManipulationException, exception_message + ): + Card._api_create(customer="fish") + + with self.assertRaisesMessage( + StripeObjectManipulationException, exception_message + ): + Card.api_list(customer="fish") + + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_api_create(self, customer_retrieve_mock): + stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) + + self.assertEqual(FAKE_CARD, stripe_card) + + @patch("tests.CardDict.delete", autospec=True) + @patch("stripe.Card.retrieve", return_value=deepcopy(FAKE_CARD), autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_remove(self, customer_retrieve_mock, card_retrieve_mock, card_delete_mock): + stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) + Card.sync_from_stripe_data(stripe_card) + + self.assertEqual(1, self.customer.legacy_cards.count()) + + card = self.customer.legacy_cards.all()[0] + card.remove() + + self.assertEqual(0, self.customer.legacy_cards.count()) + self.assertTrue(card_delete_mock.called) + + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_remove_already_deleted_card(self, customer_retrieve_mock): + stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) + Card.sync_from_stripe_data(stripe_card) + + self.assertEqual(self.customer.legacy_cards.count(), 1) + card_object = self.customer.legacy_cards.first() + Card.objects.filter(id=stripe_card["id"]).delete() + self.assertEqual(self.customer.legacy_cards.count(), 0) + card_object.remove() + self.assertEqual(self.customer.legacy_cards.count(), 0) + + @patch("djstripe.models.Card._api_delete", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_remove_no_such_source(self, customer_retrieve_mock, card_delete_mock): + stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) + Card.sync_from_stripe_data(stripe_card) + + card_delete_mock.side_effect = InvalidRequestError("No such source:", "blah") + + self.assertEqual(1, self.customer.legacy_cards.count()) + + card = self.customer.legacy_cards.all()[0] + card.remove() + + self.assertEqual(0, self.customer.legacy_cards.count()) + self.assertTrue(card_delete_mock.called) + + @patch("djstripe.models.Card._api_delete", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_remove_no_such_customer(self, customer_retrieve_mock, card_delete_mock): + stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) + Card.sync_from_stripe_data(stripe_card) + + card_delete_mock.side_effect = InvalidRequestError("No such customer:", "blah") + + self.assertEqual(1, self.customer.legacy_cards.count()) + + card = self.customer.legacy_cards.all()[0] + card.remove() + + self.assertEqual(0, self.customer.legacy_cards.count()) + self.assertTrue(card_delete_mock.called) + + @patch("djstripe.models.Card._api_delete", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_remove_unexpected_exception( + self, customer_retrieve_mock, card_delete_mock + ): + stripe_card = Card._api_create(customer=self.customer, source=FAKE_CARD["id"]) + Card.sync_from_stripe_data(stripe_card) + + card_delete_mock.side_effect = InvalidRequestError( + "Unexpected Exception", "blah" + ) + + self.assertEqual(1, self.customer.legacy_cards.count()) + + card = self.customer.legacy_cards.all()[0] + + with self.assertRaisesMessage(InvalidRequestError, "Unexpected Exception"): + card.remove() + + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_api_list(self, customer_retrieve_mock): + card_list = Card.api_list(customer=self.customer) + + self.assertEqual([FAKE_CARD, FAKE_CARD_V], card_list) diff --git a/tests/test_charge.py b/tests/test_charge.py index f966f91797..aa11f71741 100644 --- a/tests/test_charge.py +++ b/tests/test_charge.py @@ -12,805 +12,877 @@ from djstripe.models import Account, Charge, Dispute, DjstripePaymentMethod from . import ( - FAKE_ACCOUNT, FAKE_BALANCE_TRANSACTION, FAKE_BALANCE_TRANSACTION_REFUND, - FAKE_CHARGE, FAKE_CHARGE_REFUNDED, FAKE_CUSTOMER, FAKE_FILEUPLOAD_ICON, - FAKE_FILEUPLOAD_LOGO, FAKE_INVOICE, FAKE_PAYMENT_INTENT_I, FAKE_PLAN, FAKE_PRODUCT, - FAKE_REFUND, FAKE_SUBSCRIPTION, FAKE_TRANSFER, IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - IS_STATICMETHOD_AUTOSPEC_SUPPORTED, AssertStripeFksMixin, default_account + FAKE_ACCOUNT, + FAKE_BALANCE_TRANSACTION, + FAKE_BALANCE_TRANSACTION_REFUND, + FAKE_CHARGE, + FAKE_CHARGE_REFUNDED, + FAKE_CUSTOMER, + FAKE_FILEUPLOAD_ICON, + FAKE_FILEUPLOAD_LOGO, + FAKE_INVOICE, + FAKE_PAYMENT_INTENT_I, + FAKE_PLAN, + FAKE_PRODUCT, + FAKE_REFUND, + FAKE_SUBSCRIPTION, + FAKE_TRANSFER, + IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + AssertStripeFksMixin, + default_account, ) class ChargeTest(AssertStripeFksMixin, TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="user", email="user@example.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - self.account = default_account() - - def test_str(self): - charge = Charge( - amount=50, - currency="usd", - id="ch_test", - status=ChargeStatus.failed, - captured=False, - paid=False, - ) - self.assertEqual(str(charge), "$50.00 USD (Uncaptured)") - - charge.captured = True - self.assertEqual(str(charge), "$50.00 USD (Failed)") - charge.status = ChargeStatus.succeeded - - charge.dispute = Dispute() - self.assertEqual(str(charge), "$50.00 USD (Disputed)") - - charge.dispute = None - charge.refunded = True - charge.amount_refunded = 50 - self.assertEqual(str(charge), "$50.00 USD (Refunded)") - - charge.refunded = False - self.assertEqual(str(charge), "$50.00 USD (Partially refunded)") - - charge.amount_refunded = 0 - self.assertEqual(str(charge), "$50.00 USD") - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.PaymentIntent.retrieve", autospec=True) - def test_capture_charge( - self, - payment_intent_retrieve_mock, - balance_transaction_retrieve_mock, - charge_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_no_invoice = deepcopy(FAKE_CHARGE) - fake_charge_no_invoice.update({"invoice": None}) - - charge_retrieve_mock.return_value = fake_charge_no_invoice - - # TODO - I think this is needed in line with above? - fake_payment_intent_no_invoice = deepcopy(FAKE_PAYMENT_INTENT_I) - fake_payment_intent_no_invoice.update({"invoice": None}) - - payment_intent_retrieve_mock.return_value = fake_payment_intent_no_invoice - - charge, created = Charge._get_or_create_from_stripe_object(fake_charge_no_invoice) - self.assertTrue(created) - - captured_charge = charge.capture() - self.assertTrue(captured_charge.captured) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.invoice", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.invoice", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Plan.product", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED and IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - def test_sync_from_stripe_data( - self, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - payment_intent_retrieve_mock, - invoice_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - from djstripe.settings import STRIPE_SECRET_KEY - - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update({"application_fee": {"amount": 0}}) - - charge = Charge.sync_from_stripe_data(fake_charge_copy) - - self.assertEqual(Decimal("20"), charge.amount) - self.assertEqual(True, charge.paid) - self.assertEqual(False, charge.refunded) - self.assertEqual(True, charge.captured) - self.assertEqual(False, charge.disputed) - self.assertEqual( - "Payment for invoice {}".format(FAKE_INVOICE["number"]), charge.description - ) - self.assertEqual(0, charge.amount_refunded) - - self.assertEqual(self.customer.default_source.id, charge.source_id) - self.assertEqual(charge.source.type, LegacySourceType.card) - - charge_retrieve_mock.assert_not_called() - balance_transaction_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] - ) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - def test_sync_from_stripe_data_refunded_on_update( - self, - subscription_retrieve_mock, - product_retrieve_mock, - payment_intent_retrieve_mock, - invoice_retrieve_mock, - charge_retrieve_mock, - default_account_mock, - ): - # first sync charge (as per test_sync_from_stripe_data) then sync refunded version, - # to hit the update code-path instead of insert - - from djstripe.settings import STRIPE_SECRET_KEY - - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - - with patch( - "stripe.BalanceTransaction.retrieve", return_value=deepcopy(FAKE_BALANCE_TRANSACTION) - ): - charge = Charge.sync_from_stripe_data(fake_charge_copy) - - self.assertEqual(Decimal("20"), charge.amount) - self.assertEqual(True, charge.paid) - self.assertEqual(False, charge.refunded) - self.assertEqual(True, charge.captured) - self.assertEqual(False, charge.disputed) - - self.assertEqual(len(charge.refunds.all()), 0) - - fake_charge_refunded_copy = deepcopy(FAKE_CHARGE_REFUNDED) - - with patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION_REFUND), - ) as balance_transaction_retrieve_mock: - charge_refunded = Charge.sync_from_stripe_data(fake_charge_refunded_copy) - - self.assertEqual(charge.id, charge_refunded.id) - - self.assertEqual(Decimal("20"), charge_refunded.amount) - self.assertEqual(True, charge_refunded.paid) - self.assertEqual(True, charge_refunded.refunded) - self.assertEqual(True, charge_refunded.captured) - self.assertEqual(False, charge_refunded.disputed) - self.assertEqual( - "Payment for invoice {}".format(charge.invoice.number), charge_refunded.description - ) - self.assertEqual(charge_refunded.amount, charge_refunded.amount_refunded) - - charge_retrieve_mock.assert_not_called() - balance_transaction_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION_REFUND["id"] - ) - - refunds = list(charge_refunded.refunds.all()) - self.assertEqual(len(refunds), 1) - - refund = refunds[0] - - self.assertEqual(refund.id, FAKE_REFUND["id"]) - - self.assertNotEqual( - charge_refunded.balance_transaction.id, refund.balance_transaction.id - ) - self.assertEqual( - charge_refunded.balance_transaction.id, FAKE_BALANCE_TRANSACTION["id"] - ) - self.assertEqual(refund.balance_transaction.id, FAKE_BALANCE_TRANSACTION_REFUND["id"]) - - self.assert_fks( - charge_refunded, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - side_effect=[ - deepcopy(FAKE_BALANCE_TRANSACTION), - deepcopy(FAKE_BALANCE_TRANSACTION_REFUND), - ], - ) - @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - def test_sync_from_stripe_data_refunded( - self, - subscription_retrieve_mock, - product_retrieve_mock, - payment_intent_retrieve_mock, - invoice_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - from djstripe.settings import STRIPE_SECRET_KEY - - default_account_mock.return_value = self.account - fake_charge_copy = deepcopy(FAKE_CHARGE_REFUNDED) - - charge = Charge.sync_from_stripe_data(fake_charge_copy) - - self.assertEqual(Decimal("20"), charge.amount) - self.assertEqual(True, charge.paid) - self.assertEqual(True, charge.refunded) - self.assertEqual(True, charge.captured) - self.assertEqual(False, charge.disputed) - self.assertEqual( - "Payment for invoice {}".format(charge.invoice.number), charge.description - ) - self.assertEqual(charge.amount, charge.amount_refunded) - - charge_retrieve_mock.assert_not_called() - - # We expect two calls - for charge and then for charge.refunds - balance_transaction_retrieve_mock.assert_has_calls( - [ - call(api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"]), - call( - api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION_REFUND["id"] - ), - ] - ) - - refunds = list(charge.refunds.all()) - self.assertEqual(len(refunds), 1) - - refund = refunds[0] - - self.assertEqual(refund.id, FAKE_REFUND["id"]) - - self.assertNotEqual(charge.balance_transaction.id, refund.balance_transaction.id) - self.assertEqual(charge.balance_transaction.id, FAKE_BALANCE_TRANSACTION["id"]) - self.assertEqual(refund.balance_transaction.id, FAKE_BALANCE_TRANSACTION_REFUND["id"]) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - }, - ) - - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - def test_sync_from_stripe_data_max_amount( - self, - default_account_mock, - subscription_retrieve_mock, - product_retrieve_mock, - payment_intent_retrieve_mock, - invoice_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - # https://support.stripe.com/questions/what-is-the-maximum-amount-i-can-charge-with-stripe - fake_charge_copy.update({"amount": 99999999}) - - charge = Charge.sync_from_stripe_data(fake_charge_copy) - - self.assertEqual(Decimal("999999.99"), charge.amount) - self.assertEqual(True, charge.paid) - self.assertEqual(False, charge.refunded) - self.assertEqual(True, charge.captured) - self.assertEqual(False, charge.disputed) - self.assertEqual(0, charge.amount_refunded) - - charge_retrieve_mock.assert_not_called() - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - def test_sync_from_stripe_data_unsupported_source( - self, - payment_intent_retrieve_mock, - invoice_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - from djstripe.settings import STRIPE_SECRET_KEY - - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update({"source": {"id": "test_id", "object": "unsupported"}}) - - charge = Charge.sync_from_stripe_data(fake_charge_copy) - self.assertEqual("test_id", charge.source_id) - self.assertEqual("unsupported", charge.source.type) - self.assertEqual(charge.source, DjstripePaymentMethod.objects.get(id="test_id")) - - charge_retrieve_mock.assert_not_called() - - balance_transaction_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] - ) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.PaymentIntent.retrieve", autospec=True) - def test_sync_from_stripe_data_no_customer( - self, - payment_intent_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - from djstripe.settings import STRIPE_SECRET_KEY - - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - - fake_charge_copy.pop("customer", None) - # remove invoice since it requires a customer - fake_charge_copy.pop("invoice", None) - - fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) - fake_payment_intent["invoice"] = None - - payment_intent_retrieve_mock.return_value = fake_payment_intent - - Charge.sync_from_stripe_data(fake_charge_copy) - assert Charge.objects.count() == 1 - charge = Charge.objects.get() - assert charge.customer is None - - charge_retrieve_mock.assert_not_called() - balance_transaction_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] - ) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.customer", - "djstripe.Charge.dispute", - "djstripe.Charge.invoice", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.PaymentIntent.invoice", - "djstripe.Plan.product", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Transfer.retrieve", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - def test_sync_from_stripe_data_with_transfer( - self, - default_account_mock, - subscription_retrieve_mock, - product_retrieve_mock, - transfer_retrieve_mock, - payment_intent_retrieve_mock, - invoice_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - ): - from djstripe.settings import STRIPE_SECRET_KEY - - default_account_mock.return_value = self.account - - fake_transfer = deepcopy(FAKE_TRANSFER) - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update({"transfer": fake_transfer["id"]}) - - transfer_retrieve_mock.return_value = fake_transfer - charge_retrieve_mock.return_value = fake_charge_copy - - charge, created = Charge._get_or_create_from_stripe_object( - fake_charge_copy, current_ids={fake_charge_copy["id"]} - ) - self.assertTrue(created) - - self.assertNotEqual(None, charge.transfer) - self.assertEqual(fake_transfer["id"], charge.transfer.id) - - charge_retrieve_mock.assert_not_called() - balance_transaction_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] - ) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.Account.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch( - "stripe.File.retrieve", - side_effect=[deepcopy(FAKE_FILEUPLOAD_ICON), deepcopy(FAKE_FILEUPLOAD_LOGO)], - autospec=True, - ) - def test_sync_from_stripe_data_with_destination( - self, - file_retrieve_mock, - invoice_retrieve_mock, - payment_intent_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - balance_transaction_retrieve_mock, - account_retrieve_mock, - charge_retrieve_mock, - ): - from djstripe.settings import STRIPE_SECRET_KEY - - account_retrieve_mock.return_value = FAKE_ACCOUNT - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update({"destination": FAKE_ACCOUNT["id"]}) - - charge, created = Charge._get_or_create_from_stripe_object( - fake_charge_copy, current_ids={fake_charge_copy["id"]} - ) - self.assertTrue(created) - - self.assertEqual(2, Account.objects.count()) - account = Account.objects.get(id=FAKE_ACCOUNT["id"]) - - self.assertEqual(account, charge.account) - - charge_retrieve_mock.assert_not_called() - balance_transaction_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] - ) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch.object(target=Charge, attribute="source", autospec=True) - @patch.object(target=Charge, attribute="account", autospec=True) - @patch(target="djstripe.models.payment_methods.DjstripePaymentMethod", autospec=True) - @patch(target="djstripe.models.core.Account", autospec=True) - def test__attach_objects_hook( - self, mock_account, mock_payment_method, mock_charge_account, mock_charge_source - ): - """Test that _attach_objects_hook works as expected.""" - charge = Charge( - amount=50, - currency="usd", - id="ch_test", - status=ChargeStatus.failed, - captured=False, - paid=False, - ) - mock_cls = create_autospec(spec=Charge, spec_set=True) - mock_data = {"source": {"object": "foo"}} - mock_payment_method._get_or_create_source.return_value = ("bar", "unused") - - charge._attach_objects_hook(cls=mock_cls, data=mock_data) - - # expect the attributes to be set appropriately - self.assertEqual( - mock_cls._stripe_object_destination_to_account.return_value, charge.account - ) - self.assertEqual( - mock_payment_method._get_or_create_source.return_value[0], charge.source - ) - # expect the appropriate calls to be made - mock_cls._stripe_object_destination_to_account.assert_called_once_with( - target_cls=mock_account, data=mock_data - ) - mock_payment_method._get_or_create_source.assert_called_once_with( - data=mock_data["source"], source_type=mock_data["source"]["object"] - ) - - @patch.object(target=Charge, attribute="source", autospec=True) - @patch.object(target=Charge, attribute="account", autospec=True) - @patch(target="djstripe.models.payment_methods.DjstripePaymentMethod", autospec=True) - @patch(target="djstripe.models.core.Account", autospec=True) - def test__attach_objects_hook_no_destination_account( - self, mock_account, mock_payment_method, mock_charge_account, mock_charge_source - ): - """Test that _attach_objects_hook works as expected when there is no destination account.""" - charge = Charge( - amount=50, - currency="usd", - id="ch_test", - status=ChargeStatus.failed, - captured=False, - paid=False, - ) - mock_cls = create_autospec(spec=Charge, spec_set=True) - mock_cls._stripe_object_destination_to_account.return_value = False - mock_data = {"source": {"object": "foo"}} - mock_payment_method._get_or_create_source.return_value = ("bar", "unused") - - charge._attach_objects_hook(cls=mock_cls, data=mock_data) - - # expect the attributes to be set appropriately - self.assertEqual(mock_account.get_default_account.return_value, charge.account) - self.assertEqual( - mock_payment_method._get_or_create_source.return_value[0], charge.source - ) - # expect the appropriate calls to be made - mock_cls._stripe_object_destination_to_account.assert_called_once_with( - target_cls=mock_account, data=mock_data - ) - mock_payment_method._get_or_create_source.assert_called_once_with( - data=mock_data["source"], source_type=mock_data["source"]["object"] - ) - - @patch.object(target=Charge, attribute="source", autospec=True) - @patch.object(target=Charge, attribute="account", autospec=True) - @patch(target="djstripe.models.payment_methods.DjstripePaymentMethod", autospec=True) - @patch(target="djstripe.models.core.Account", autospec=True) - def test__attach_objects_hook_missing_source_data( - self, mock_account, mock_payment_method, mock_charge_account, mock_charge_source - ): - """Make sure we handle the case where the source data is empty or insufficient.""" - charge = Charge( - amount=50, - currency="usd", - id="ch_test", - status=ChargeStatus.failed, - captured=False, - paid=False, - ) - mock_cls = create_autospec(spec=Charge, spec_set=True) - # Empty data dict works for this test since we only look up the source key and - # everything else is mocked. - mock_data = {} - starting_source = charge.source - - charge._attach_objects_hook(cls=mock_cls, data=mock_data) - - # source shouldn't be touched - self.assertEqual(starting_source, charge.source) - mock_payment_method._get_or_create_source.assert_not_called() - - # try again with a source key, but no object sub key. - mock_data = {"source": {"foo": "bar"}} - - charge._attach_objects_hook(cls=mock_cls, data=mock_data) - - # source shouldn't be touched - self.assertEqual(starting_source, charge.source) - mock_payment_method._get_or_create_source.assert_not_called() + def setUp(self): + self.user = get_user_model().objects.create_user( + username="user", email="user@example.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + self.account = default_account() + + def test_str(self): + charge = Charge( + amount=50, + currency="usd", + id="ch_test", + status=ChargeStatus.failed, + captured=False, + paid=False, + ) + self.assertEqual(str(charge), "$50.00 USD (Uncaptured)") + + charge.captured = True + self.assertEqual(str(charge), "$50.00 USD (Failed)") + charge.status = ChargeStatus.succeeded + + charge.dispute = Dispute() + self.assertEqual(str(charge), "$50.00 USD (Disputed)") + + charge.dispute = None + charge.refunded = True + charge.amount_refunded = 50 + self.assertEqual(str(charge), "$50.00 USD (Refunded)") + + charge.refunded = False + self.assertEqual(str(charge), "$50.00 USD (Partially refunded)") + + charge.amount_refunded = 0 + self.assertEqual(str(charge), "$50.00 USD") + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.PaymentIntent.retrieve", autospec=True) + def test_capture_charge( + self, + payment_intent_retrieve_mock, + balance_transaction_retrieve_mock, + charge_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_no_invoice = deepcopy(FAKE_CHARGE) + fake_charge_no_invoice.update({"invoice": None}) + + charge_retrieve_mock.return_value = fake_charge_no_invoice + + # TODO - I think this is needed in line with above? + fake_payment_intent_no_invoice = deepcopy(FAKE_PAYMENT_INTENT_I) + fake_payment_intent_no_invoice.update({"invoice": None}) + + payment_intent_retrieve_mock.return_value = fake_payment_intent_no_invoice + + charge, created = Charge._get_or_create_from_stripe_object( + fake_charge_no_invoice + ) + self.assertTrue(created) + + captured_charge = charge.capture() + self.assertTrue(captured_charge.captured) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.invoice", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.invoice", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Plan.product", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED + and IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + def test_sync_from_stripe_data( + self, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + payment_intent_retrieve_mock, + invoice_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + from djstripe.settings import STRIPE_SECRET_KEY + + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update({"application_fee": {"amount": 0}}) + + charge = Charge.sync_from_stripe_data(fake_charge_copy) + + self.assertEqual(Decimal("20"), charge.amount) + self.assertEqual(True, charge.paid) + self.assertEqual(False, charge.refunded) + self.assertEqual(True, charge.captured) + self.assertEqual(False, charge.disputed) + self.assertEqual( + "Payment for invoice {}".format(FAKE_INVOICE["number"]), charge.description + ) + self.assertEqual(0, charge.amount_refunded) + + self.assertEqual(self.customer.default_source.id, charge.source_id) + self.assertEqual(charge.source.type, LegacySourceType.card) + + charge_retrieve_mock.assert_not_called() + balance_transaction_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] + ) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + def test_sync_from_stripe_data_refunded_on_update( + self, + subscription_retrieve_mock, + product_retrieve_mock, + payment_intent_retrieve_mock, + invoice_retrieve_mock, + charge_retrieve_mock, + default_account_mock, + ): + # first sync charge (as per test_sync_from_stripe_data) + # then sync refunded version, to hit the update code-path instead of insert + + from djstripe.settings import STRIPE_SECRET_KEY + + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + + with patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + ): + charge = Charge.sync_from_stripe_data(fake_charge_copy) + + self.assertEqual(Decimal("20"), charge.amount) + self.assertEqual(True, charge.paid) + self.assertEqual(False, charge.refunded) + self.assertEqual(True, charge.captured) + self.assertEqual(False, charge.disputed) + + self.assertEqual(len(charge.refunds.all()), 0) + + fake_charge_refunded_copy = deepcopy(FAKE_CHARGE_REFUNDED) + + with patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION_REFUND), + ) as balance_transaction_retrieve_mock: + charge_refunded = Charge.sync_from_stripe_data(fake_charge_refunded_copy) + + self.assertEqual(charge.id, charge_refunded.id) + + self.assertEqual(Decimal("20"), charge_refunded.amount) + self.assertEqual(True, charge_refunded.paid) + self.assertEqual(True, charge_refunded.refunded) + self.assertEqual(True, charge_refunded.captured) + self.assertEqual(False, charge_refunded.disputed) + self.assertEqual( + "Payment for invoice {}".format(charge.invoice.number), + charge_refunded.description, + ) + self.assertEqual(charge_refunded.amount, charge_refunded.amount_refunded) + + charge_retrieve_mock.assert_not_called() + balance_transaction_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, + expand=[], + id=FAKE_BALANCE_TRANSACTION_REFUND["id"], + ) + + refunds = list(charge_refunded.refunds.all()) + self.assertEqual(len(refunds), 1) + + refund = refunds[0] + + self.assertEqual(refund.id, FAKE_REFUND["id"]) + + self.assertNotEqual( + charge_refunded.balance_transaction.id, refund.balance_transaction.id + ) + self.assertEqual( + charge_refunded.balance_transaction.id, FAKE_BALANCE_TRANSACTION["id"] + ) + self.assertEqual( + refund.balance_transaction.id, FAKE_BALANCE_TRANSACTION_REFUND["id"] + ) + + self.assert_fks( + charge_refunded, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + side_effect=[ + deepcopy(FAKE_BALANCE_TRANSACTION), + deepcopy(FAKE_BALANCE_TRANSACTION_REFUND), + ], + ) + @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + def test_sync_from_stripe_data_refunded( + self, + subscription_retrieve_mock, + product_retrieve_mock, + payment_intent_retrieve_mock, + invoice_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + from djstripe.settings import STRIPE_SECRET_KEY + + default_account_mock.return_value = self.account + fake_charge_copy = deepcopy(FAKE_CHARGE_REFUNDED) + + charge = Charge.sync_from_stripe_data(fake_charge_copy) + + self.assertEqual(Decimal("20"), charge.amount) + self.assertEqual(True, charge.paid) + self.assertEqual(True, charge.refunded) + self.assertEqual(True, charge.captured) + self.assertEqual(False, charge.disputed) + self.assertEqual( + "Payment for invoice {}".format(charge.invoice.number), charge.description + ) + self.assertEqual(charge.amount, charge.amount_refunded) + + charge_retrieve_mock.assert_not_called() + + # We expect two calls - for charge and then for charge.refunds + balance_transaction_retrieve_mock.assert_has_calls( + [ + call( + api_key=STRIPE_SECRET_KEY, + expand=[], + id=FAKE_BALANCE_TRANSACTION["id"], + ), + call( + api_key=STRIPE_SECRET_KEY, + expand=[], + id=FAKE_BALANCE_TRANSACTION_REFUND["id"], + ), + ] + ) + + refunds = list(charge.refunds.all()) + self.assertEqual(len(refunds), 1) + + refund = refunds[0] + + self.assertEqual(refund.id, FAKE_REFUND["id"]) + + self.assertNotEqual( + charge.balance_transaction.id, refund.balance_transaction.id + ) + self.assertEqual(charge.balance_transaction.id, FAKE_BALANCE_TRANSACTION["id"]) + self.assertEqual( + refund.balance_transaction.id, FAKE_BALANCE_TRANSACTION_REFUND["id"] + ) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + }, + ) + + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + def test_sync_from_stripe_data_max_amount( + self, + default_account_mock, + subscription_retrieve_mock, + product_retrieve_mock, + payment_intent_retrieve_mock, + invoice_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + # https://support.stripe.com/questions/what-is-the-maximum-amount-i-can-charge-with-stripe + fake_charge_copy.update({"amount": 99999999}) + + charge = Charge.sync_from_stripe_data(fake_charge_copy) + + self.assertEqual(Decimal("999999.99"), charge.amount) + self.assertEqual(True, charge.paid) + self.assertEqual(False, charge.refunded) + self.assertEqual(True, charge.captured) + self.assertEqual(False, charge.disputed) + self.assertEqual(0, charge.amount_refunded) + + charge_retrieve_mock.assert_not_called() + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + ) + @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + def test_sync_from_stripe_data_unsupported_source( + self, + payment_intent_retrieve_mock, + invoice_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + from djstripe.settings import STRIPE_SECRET_KEY + + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update({"source": {"id": "test_id", "object": "unsupported"}}) + + charge = Charge.sync_from_stripe_data(fake_charge_copy) + self.assertEqual("test_id", charge.source_id) + self.assertEqual("unsupported", charge.source.type) + self.assertEqual(charge.source, DjstripePaymentMethod.objects.get(id="test_id")) + + charge_retrieve_mock.assert_not_called() + + balance_transaction_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] + ) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch("stripe.PaymentIntent.retrieve", autospec=True) + def test_sync_from_stripe_data_no_customer( + self, + payment_intent_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + from djstripe.settings import STRIPE_SECRET_KEY + + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + + fake_charge_copy.pop("customer", None) + # remove invoice since it requires a customer + fake_charge_copy.pop("invoice", None) + + fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) + fake_payment_intent["invoice"] = None + + payment_intent_retrieve_mock.return_value = fake_payment_intent + + Charge.sync_from_stripe_data(fake_charge_copy) + assert Charge.objects.count() == 1 + charge = Charge.objects.get() + assert charge.customer is None + + charge_retrieve_mock.assert_not_called() + balance_transaction_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] + ) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.customer", + "djstripe.Charge.dispute", + "djstripe.Charge.invoice", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.PaymentIntent.invoice", + "djstripe.Plan.product", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch("stripe.Transfer.retrieve", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + def test_sync_from_stripe_data_with_transfer( + self, + default_account_mock, + subscription_retrieve_mock, + product_retrieve_mock, + transfer_retrieve_mock, + payment_intent_retrieve_mock, + invoice_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + ): + from djstripe.settings import STRIPE_SECRET_KEY + + default_account_mock.return_value = self.account + + fake_transfer = deepcopy(FAKE_TRANSFER) + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update({"transfer": fake_transfer["id"]}) + + transfer_retrieve_mock.return_value = fake_transfer + charge_retrieve_mock.return_value = fake_charge_copy + + charge, created = Charge._get_or_create_from_stripe_object( + fake_charge_copy, current_ids={fake_charge_copy["id"]} + ) + self.assertTrue(created) + + self.assertNotEqual(None, charge.transfer) + self.assertEqual(fake_transfer["id"], charge.transfer.id) + + charge_retrieve_mock.assert_not_called() + balance_transaction_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] + ) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Charge.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch("stripe.Account.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.File.retrieve", + side_effect=[deepcopy(FAKE_FILEUPLOAD_ICON), deepcopy(FAKE_FILEUPLOAD_LOGO)], + autospec=True, + ) + def test_sync_from_stripe_data_with_destination( + self, + file_retrieve_mock, + invoice_retrieve_mock, + payment_intent_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + balance_transaction_retrieve_mock, + account_retrieve_mock, + charge_retrieve_mock, + ): + from djstripe.settings import STRIPE_SECRET_KEY + + account_retrieve_mock.return_value = FAKE_ACCOUNT + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update({"destination": FAKE_ACCOUNT["id"]}) + + charge, created = Charge._get_or_create_from_stripe_object( + fake_charge_copy, current_ids={fake_charge_copy["id"]} + ) + self.assertTrue(created) + + self.assertEqual(2, Account.objects.count()) + account = Account.objects.get(id=FAKE_ACCOUNT["id"]) + + self.assertEqual(account, charge.account) + + charge_retrieve_mock.assert_not_called() + balance_transaction_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_BALANCE_TRANSACTION["id"] + ) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch.object(target=Charge, attribute="source", autospec=True) + @patch.object(target=Charge, attribute="account", autospec=True) + @patch( + target="djstripe.models.payment_methods.DjstripePaymentMethod", autospec=True + ) + @patch(target="djstripe.models.core.Account", autospec=True) + def test__attach_objects_hook( + self, mock_account, mock_payment_method, mock_charge_account, mock_charge_source + ): + """Test that _attach_objects_hook works as expected.""" + charge = Charge( + amount=50, + currency="usd", + id="ch_test", + status=ChargeStatus.failed, + captured=False, + paid=False, + ) + mock_cls = create_autospec(spec=Charge, spec_set=True) + mock_data = {"source": {"object": "foo"}} + mock_payment_method._get_or_create_source.return_value = ("bar", "unused") + + charge._attach_objects_hook(cls=mock_cls, data=mock_data) + + # expect the attributes to be set appropriately + self.assertEqual( + mock_cls._stripe_object_destination_to_account.return_value, charge.account + ) + self.assertEqual( + mock_payment_method._get_or_create_source.return_value[0], charge.source + ) + # expect the appropriate calls to be made + mock_cls._stripe_object_destination_to_account.assert_called_once_with( + target_cls=mock_account, data=mock_data + ) + mock_payment_method._get_or_create_source.assert_called_once_with( + data=mock_data["source"], source_type=mock_data["source"]["object"] + ) + + @patch.object(target=Charge, attribute="source", autospec=True) + @patch.object(target=Charge, attribute="account", autospec=True) + @patch( + target="djstripe.models.payment_methods.DjstripePaymentMethod", autospec=True + ) + @patch(target="djstripe.models.core.Account", autospec=True) + def test__attach_objects_hook_no_destination_account( + self, mock_account, mock_payment_method, mock_charge_account, mock_charge_source + ): + """ + Test that _attach_objects_hook works as expected when there is no + destination account. + """ + charge = Charge( + amount=50, + currency="usd", + id="ch_test", + status=ChargeStatus.failed, + captured=False, + paid=False, + ) + mock_cls = create_autospec(spec=Charge, spec_set=True) + mock_cls._stripe_object_destination_to_account.return_value = False + mock_data = {"source": {"object": "foo"}} + mock_payment_method._get_or_create_source.return_value = ("bar", "unused") + + charge._attach_objects_hook(cls=mock_cls, data=mock_data) + + # expect the attributes to be set appropriately + self.assertEqual(mock_account.get_default_account.return_value, charge.account) + self.assertEqual( + mock_payment_method._get_or_create_source.return_value[0], charge.source + ) + # expect the appropriate calls to be made + mock_cls._stripe_object_destination_to_account.assert_called_once_with( + target_cls=mock_account, data=mock_data + ) + mock_payment_method._get_or_create_source.assert_called_once_with( + data=mock_data["source"], source_type=mock_data["source"]["object"] + ) + + @patch.object(target=Charge, attribute="source", autospec=True) + @patch.object(target=Charge, attribute="account", autospec=True) + @patch( + target="djstripe.models.payment_methods.DjstripePaymentMethod", autospec=True + ) + @patch(target="djstripe.models.core.Account", autospec=True) + def test__attach_objects_hook_missing_source_data( + self, mock_account, mock_payment_method, mock_charge_account, mock_charge_source + ): + """ + Make sure we handle the case where the source data is empty or insufficient. + """ + charge = Charge( + amount=50, + currency="usd", + id="ch_test", + status=ChargeStatus.failed, + captured=False, + paid=False, + ) + mock_cls = create_autospec(spec=Charge, spec_set=True) + # Empty data dict works for this test since we only look up the source key and + # everything else is mocked. + mock_data = {} + starting_source = charge.source + + charge._attach_objects_hook(cls=mock_cls, data=mock_data) + + # source shouldn't be touched + self.assertEqual(starting_source, charge.source) + mock_payment_method._get_or_create_source.assert_not_called() + + # try again with a source key, but no object sub key. + mock_data = {"source": {"foo": "bar"}} + + charge._attach_objects_hook(cls=mock_cls, data=mock_data) + + # source shouldn't be touched + self.assertEqual(starting_source, charge.source) + mock_payment_method._get_or_create_source.assert_not_called() diff --git a/tests/test_context_managers.py b/tests/test_context_managers.py index b7009a4d93..c074009f76 100644 --- a/tests/test_context_managers.py +++ b/tests/test_context_managers.py @@ -8,20 +8,20 @@ class TestTemporaryVersion(TestCase): - def test_basic_with_exception(self): - version = stripe.api_version + def test_basic_with_exception(self): + version = stripe.api_version - with self.assertRaises(ValueError): - with stripe_temporary_api_version("2016-03-07"): - self.assertEqual(stripe.api_version, "2016-03-07") - raise ValueError("Something happened") + with self.assertRaises(ValueError): + with stripe_temporary_api_version("2016-03-07"): + self.assertEqual(stripe.api_version, "2016-03-07") + raise ValueError("Something happened") - self.assertEqual(stripe.api_version, version) + self.assertEqual(stripe.api_version, version) - def test_basic_without_validation(self): - version = stripe.api_version + def test_basic_without_validation(self): + version = stripe.api_version - with stripe_temporary_api_version("newversion", validate=False): - self.assertEqual(stripe.api_version, "newversion") + with stripe_temporary_api_version("newversion", validate=False): + self.assertEqual(stripe.api_version, "newversion") - self.assertEqual(stripe.api_version, version) + self.assertEqual(stripe.api_version, version) diff --git a/tests/test_contrib/test_rest_framework_permissions.py b/tests/test_contrib/test_rest_framework_permissions.py index 1e354ee277..0ed9a4529c 100644 --- a/tests/test_contrib/test_rest_framework_permissions.py +++ b/tests/test_contrib/test_rest_framework_permissions.py @@ -5,31 +5,37 @@ from .. import FAKE_CUSTOMER try: - import rest_framework + import rest_framework except ImportError: - rest_framework = None + rest_framework = None if rest_framework: - from djstripe.contrib.rest_framework.permissions import DJStripeSubscriptionPermission - - class TestUserHasActiveSubscription(TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - def test_no_user_in_request(self): - request = RequestFactory().get("djstripe/") - - self.assertFalse( - DJStripeSubscriptionPermission().has_permission(request=request, view=None) - ) - - def test_user(self): - request = RequestFactory().get("djstripe/") - request.user = self.user - - self.assertFalse( - DJStripeSubscriptionPermission().has_permission(request=request, view=None) - ) + from djstripe.contrib.rest_framework.permissions import ( + DJStripeSubscriptionPermission, + ) + + class TestUserHasActiveSubscription(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + def test_no_user_in_request(self): + request = RequestFactory().get("djstripe/") + + self.assertFalse( + DJStripeSubscriptionPermission().has_permission( + request=request, view=None + ) + ) + + def test_user(self): + request = RequestFactory().get("djstripe/") + request.user = self.user + + self.assertFalse( + DJStripeSubscriptionPermission().has_permission( + request=request, view=None + ) + ) diff --git a/tests/test_contrib/test_serializers.py b/tests/test_contrib/test_serializers.py index e8b0169d33..3118da3172 100644 --- a/tests/test_contrib/test_serializers.py +++ b/tests/test_contrib/test_serializers.py @@ -1,6 +1,6 @@ """ .. module:: dj-stripe.tests.test_contrib.test_serializers - :synopsis: dj-stripe Serializer Tests. + :synopsis: dj-stripe Serializer Tests. .. moduleauthor:: Philippe Luickx (@philippeluickx) .. moduleauthor:: Alex Kavanaugh (@kavdev) @@ -15,7 +15,8 @@ from django.utils import timezone from djstripe.contrib.rest_framework.serializers import ( - CreateSubscriptionSerializer, SubscriptionSerializer + CreateSubscriptionSerializer, + SubscriptionSerializer, ) from djstripe.enums import SubscriptionStatus from djstripe.models import Plan @@ -24,106 +25,114 @@ class SubscriptionSerializerTest(TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - self.plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - def test_valid_serializer(self): - now = timezone.now() - serializer = SubscriptionSerializer( - data={ - "id": "sub_6lsC8pt7IcFpjA", - "customer": self.customer.djstripe_id, - "billing": "charge_automatically", - "plan": self.plan.djstripe_id, - "quantity": 2, - "start": now, - "status": SubscriptionStatus.active, - "current_period_end": now + timezone.timedelta(days=5), - "current_period_start": now, - } - ) - self.assertTrue(serializer.is_valid()) - self.assertEqual( - serializer.validated_data, - { - "id": "sub_6lsC8pt7IcFpjA", - "customer": self.customer, - "billing": "charge_automatically", - "plan": self.plan, - "quantity": 2, - "start": now, - "status": SubscriptionStatus.active, - "current_period_end": now + timezone.timedelta(days=5), - "current_period_start": now, - }, - ) - self.assertEqual(serializer.errors, {}) - - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invalid_serializer(self, product_retrieve_mock): - now = timezone.now() - serializer = SubscriptionSerializer( - data={ - "id": "sub_6lsC8pt7IcFpjA", - "customer": self.customer.djstripe_id, - "plan": self.plan.djstripe_id, - "start": now, - "status": SubscriptionStatus.active, - "current_period_end": now + timezone.timedelta(days=5), - "current_period_start": now, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertEqual(serializer.validated_data, {}) - self.assertEqual(serializer.errors, {"billing": ["This field is required."]}) + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + self.plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + def test_valid_serializer(self): + now = timezone.now() + serializer = SubscriptionSerializer( + data={ + "id": "sub_6lsC8pt7IcFpjA", + "customer": self.customer.djstripe_id, + "billing": "charge_automatically", + "plan": self.plan.djstripe_id, + "quantity": 2, + "start": now, + "status": SubscriptionStatus.active, + "current_period_end": now + timezone.timedelta(days=5), + "current_period_start": now, + } + ) + self.assertTrue(serializer.is_valid()) + self.assertEqual( + serializer.validated_data, + { + "id": "sub_6lsC8pt7IcFpjA", + "customer": self.customer, + "billing": "charge_automatically", + "plan": self.plan, + "quantity": 2, + "start": now, + "status": SubscriptionStatus.active, + "current_period_end": now + timezone.timedelta(days=5), + "current_period_start": now, + }, + ) + self.assertEqual(serializer.errors, {}) + + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invalid_serializer(self, product_retrieve_mock): + now = timezone.now() + serializer = SubscriptionSerializer( + data={ + "id": "sub_6lsC8pt7IcFpjA", + "customer": self.customer.djstripe_id, + "plan": self.plan.djstripe_id, + "start": now, + "status": SubscriptionStatus.active, + "current_period_end": now + timezone.timedelta(days=5), + "current_period_start": now, + } + ) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.validated_data, {}) + self.assertEqual(serializer.errors, {"billing": ["This field is required."]}) class CreateSubscriptionSerializerTest(TestCase): - def setUp(self): - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - self.plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - @patch( - "stripe.Token.create", return_value=PropertyMock(id="token_test"), autospec=True - ) - def test_valid_serializer(self, stripe_token_mock): - token = stripe_token_mock(card={}) - serializer = CreateSubscriptionSerializer( - data={"plan": self.plan.id, "stripe_token": token.id} - ) - self.assertTrue(serializer.is_valid()) - self.assertEqual(serializer.validated_data["plan"], str(self.plan.id)) - self.assertIn("stripe_token", serializer.validated_data) - self.assertEqual(serializer.errors, {}) - - @patch( - "stripe.Token.create", return_value=PropertyMock(id="token_test"), autospec=True - ) - def test_valid_serializer_non_required_fields(self, stripe_token_mock): - """Test the CreateSubscriptionSerializer is_valid method.""" - token = stripe_token_mock(card={}) - serializer = CreateSubscriptionSerializer( - data={ - "plan": self.plan.id, - "stripe_token": token.id, - "charge_immediately": True, - "tax_percent": 13.00, - } - ) - self.assertTrue(serializer.is_valid()) - - def test_invalid_serializer(self): - serializer = CreateSubscriptionSerializer(data={"plan": self.plan.id}) - self.assertFalse(serializer.is_valid()) - self.assertEqual(serializer.validated_data, {}) - self.assertEqual(serializer.errors, {"stripe_token": ["This field is required."]}) + def setUp(self): + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + self.plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + @patch( + "stripe.Token.create", return_value=PropertyMock(id="token_test"), autospec=True + ) + def test_valid_serializer(self, stripe_token_mock): + token = stripe_token_mock(card={}) + serializer = CreateSubscriptionSerializer( + data={"plan": self.plan.id, "stripe_token": token.id} + ) + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data["plan"], str(self.plan.id)) + self.assertIn("stripe_token", serializer.validated_data) + self.assertEqual(serializer.errors, {}) + + @patch( + "stripe.Token.create", return_value=PropertyMock(id="token_test"), autospec=True + ) + def test_valid_serializer_non_required_fields(self, stripe_token_mock): + """Test the CreateSubscriptionSerializer is_valid method.""" + token = stripe_token_mock(card={}) + serializer = CreateSubscriptionSerializer( + data={ + "plan": self.plan.id, + "stripe_token": token.id, + "charge_immediately": True, + "tax_percent": 13.00, + } + ) + self.assertTrue(serializer.is_valid()) + + def test_invalid_serializer(self): + serializer = CreateSubscriptionSerializer(data={"plan": self.plan.id}) + self.assertFalse(serializer.is_valid()) + self.assertEqual(serializer.validated_data, {}) + self.assertEqual( + serializer.errors, {"stripe_token": ["This field is required."]} + ) diff --git a/tests/test_contrib/test_views.py b/tests/test_contrib/test_views.py index 7caff538a5..cac7a2d960 100644 --- a/tests/test_contrib/test_views.py +++ b/tests/test_contrib/test_views.py @@ -1,6 +1,6 @@ """ .. module:: dj-stripe.tests.test_contrib.test_views - :synopsis: dj-stripe Rest views for Subscription Tests. + :synopsis: dj-stripe Rest views for Subscription Tests. .. moduleauthor:: Philippe Luickx (@philippeluickx) .. moduleauthor:: Alex Kavanaugh (@kavdev) @@ -24,152 +24,160 @@ class RestSubscriptionTest(APITestCase): - """ - Test the REST api for subscriptions. - """ - - def setUp(self): - self.url = reverse("rest_djstripe:subscription") - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com", password="password" - ) - self.assertTrue(self.client.login(username="pydanny", password="password")) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - @patch("djstripe.models.Customer.subscribe", autospec=True) - @patch("djstripe.models.Customer.add_card", autospec=True) - def test_create_subscription(self, add_card_mock, subscribe_mock): - """Test a POST to the SubscriptionRestView. - - Should: - - Create a Customer object - - Add a card to the Customer object - - Subcribe the Customer to a plan - """ - data = {"plan": "test0", "stripe_token": "cake"} - response = self.client.post(self.url, data) - self.assertEqual(1, Customer.objects.count()) - customer = Customer.objects.get() - add_card_mock.assert_called_once_with(customer, "cake") - subscribe_mock.assert_called_once_with(customer, "test0", True) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - data["charge_immediately"] = None - self.assertEqual(response.data, data) - - @patch("djstripe.models.Customer.subscribe", autospec=True) - @patch("djstripe.models.Customer.add_card", autospec=True) - def test_create_subscription_charge_immediately(self, add_card_mock, subscribe_mock): - """Test a POST to the SubscriptionRestView. - - Should be able to accept an charge_immediately. - This will not send an invoice to the customer on subscribe. - """ - data = {"plan": "test0", "stripe_token": "cake", "charge_immediately": False} - response = self.client.post(self.url, data) - self.assertEqual(1, Customer.objects.count()) - customer = Customer.objects.get() - subscribe_mock.assert_called_once_with(customer, "test0", False) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(response.data, data) - - @patch("djstripe.models.Customer.subscribe", autospec=True) - @patch("djstripe.models.Customer.add_card", autospec=True) - def test_create_subscription_exception(self, add_card_mock, subscribe_mock): - """Test a POST to the SubscriptionRestView. - - Should return a 400 when an Exception is raised. - """ - subscribe_mock.side_effect = Exception - data = {"plan": "test0", "stripe_token": "cake"} - response = self.client.post(self.url, data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_create_subscription_incorrect_data(self): - """Test a POST to the SubscriptionRestView. - - Should return a 400 when a the serializer is invalid. - """ - data = {"foo": "bar"} - response = self.client.post(self.url, data) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_get_subscription(self): - """Test a GET to the SubscriptionRestView. - - Should return the correct data. - """ - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - subscription = Subscription.sync_from_stripe_data(deepcopy(FAKE_SUBSCRIPTION)) - - response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["plan"], plan.djstripe_id) - self.assertEqual(response.data["status"], subscription.status) - self.assertEqual( - response.data["cancel_at_period_end"], subscription.cancel_at_period_end - ) - - @patch("djstripe.models.Subscription.cancel", autospec=True) - def test_cancel_subscription(self, cancel_subscription_mock): - """Test a DELETE to the SubscriptionRestView. - - Should cancel a Customer objects subscription. - """ - - def _cancel_sub(*args, **kwargs): - subscription = Subscription.objects.first() - subscription.status = SubscriptionStatus.canceled - subscription.canceled_at = timezone.now() - subscription.ended_at = timezone.now() - subscription.save() - return subscription - - fake_cancelled_subscription = deepcopy(FAKE_SUBSCRIPTION) - - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - Subscription.sync_from_stripe_data(fake_cancelled_subscription) - - cancel_subscription_mock.side_effect = _cancel_sub - - self.assertEqual(1, Subscription.objects.count()) - self.assertEqual(Subscription.objects.first().status, SubscriptionStatus.active) - - response = self.client.delete(self.url) - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - - # Cancelled means flagged as cancelled, so it should still be there - self.assertEqual(1, Subscription.objects.count()) - self.assertEqual(Subscription.objects.first().status, SubscriptionStatus.canceled) - - cancel_subscription_mock.assert_called_once_with( - Subscription.objects.first(), - at_period_end=djstripe_settings.CANCELLATION_AT_PERIOD_END, - ) - self.assertTrue(self.user.is_authenticated) - - def test_cancel_subscription_exception(self): - """Test a DELETE to the SubscriptionRestView. - - Should return a 400 when an exception is raised. - """ - response = self.client.delete(self.url) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + """ + Test the REST api for subscriptions. + """ + + def setUp(self): + self.url = reverse("rest_djstripe:subscription") + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com", password="password" + ) + self.assertTrue(self.client.login(username="pydanny", password="password")) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + @patch("djstripe.models.Customer.subscribe", autospec=True) + @patch("djstripe.models.Customer.add_card", autospec=True) + def test_create_subscription(self, add_card_mock, subscribe_mock): + """Test a POST to the SubscriptionRestView. + + Should: + - Create a Customer object + - Add a card to the Customer object + - Subcribe the Customer to a plan + """ + data = {"plan": "test0", "stripe_token": "cake"} + response = self.client.post(self.url, data) + self.assertEqual(1, Customer.objects.count()) + customer = Customer.objects.get() + add_card_mock.assert_called_once_with(customer, "cake") + subscribe_mock.assert_called_once_with(customer, "test0", True) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + data["charge_immediately"] = None + self.assertEqual(response.data, data) + + @patch("djstripe.models.Customer.subscribe", autospec=True) + @patch("djstripe.models.Customer.add_card", autospec=True) + def test_create_subscription_charge_immediately( + self, add_card_mock, subscribe_mock + ): + """Test a POST to the SubscriptionRestView. + + Should be able to accept an charge_immediately. + This will not send an invoice to the customer on subscribe. + """ + data = {"plan": "test0", "stripe_token": "cake", "charge_immediately": False} + response = self.client.post(self.url, data) + self.assertEqual(1, Customer.objects.count()) + customer = Customer.objects.get() + subscribe_mock.assert_called_once_with(customer, "test0", False) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data, data) + + @patch("djstripe.models.Customer.subscribe", autospec=True) + @patch("djstripe.models.Customer.add_card", autospec=True) + def test_create_subscription_exception(self, add_card_mock, subscribe_mock): + """Test a POST to the SubscriptionRestView. + + Should return a 400 when an Exception is raised. + """ + subscribe_mock.side_effect = Exception + data = {"plan": "test0", "stripe_token": "cake"} + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_subscription_incorrect_data(self): + """Test a POST to the SubscriptionRestView. + + Should return a 400 when a the serializer is invalid. + """ + data = {"foo": "bar"} + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_get_subscription(self): + """Test a GET to the SubscriptionRestView. + + Should return the correct data. + """ + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + subscription = Subscription.sync_from_stripe_data(deepcopy(FAKE_SUBSCRIPTION)) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["plan"], plan.djstripe_id) + self.assertEqual(response.data["status"], subscription.status) + self.assertEqual( + response.data["cancel_at_period_end"], subscription.cancel_at_period_end + ) + + @patch("djstripe.models.Subscription.cancel", autospec=True) + def test_cancel_subscription(self, cancel_subscription_mock): + """Test a DELETE to the SubscriptionRestView. + + Should cancel a Customer objects subscription. + """ + + def _cancel_sub(*args, **kwargs): + subscription = Subscription.objects.first() + subscription.status = SubscriptionStatus.canceled + subscription.canceled_at = timezone.now() + subscription.ended_at = timezone.now() + subscription.save() + return subscription + + fake_cancelled_subscription = deepcopy(FAKE_SUBSCRIPTION) + + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + Subscription.sync_from_stripe_data(fake_cancelled_subscription) + + cancel_subscription_mock.side_effect = _cancel_sub + + self.assertEqual(1, Subscription.objects.count()) + self.assertEqual(Subscription.objects.first().status, SubscriptionStatus.active) + + response = self.client.delete(self.url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + # Cancelled means flagged as cancelled, so it should still be there + self.assertEqual(1, Subscription.objects.count()) + self.assertEqual( + Subscription.objects.first().status, SubscriptionStatus.canceled + ) + + cancel_subscription_mock.assert_called_once_with( + Subscription.objects.first(), + at_period_end=djstripe_settings.CANCELLATION_AT_PERIOD_END, + ) + self.assertTrue(self.user.is_authenticated) + + def test_cancel_subscription_exception(self): + """Test a DELETE to the SubscriptionRestView. + + Should return a 400 when an exception is raised. + """ + response = self.client.delete(self.url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) class RestSubscriptionNotLoggedInTest(APITestCase): - """ - Test the exceptions thrown by the subscription rest views. - """ + """ + Test the exceptions thrown by the subscription rest views. + """ - def setUp(self): - self.url = reverse("rest_djstripe:subscription") + def setUp(self): + self.url = reverse("rest_djstripe:subscription") - def test_create_subscription_not_logged_in(self): - data = {"plan": "test0", "stripe_token": "cake"} - response = self.client.post(self.url, data) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_create_subscription_not_logged_in(self): + data = {"plan": "test0", "stripe_token": "cake"} + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/tests/test_coupon.py b/tests/test_coupon.py index 915aab65a2..138b369e87 100644 --- a/tests/test_coupon.py +++ b/tests/test_coupon.py @@ -8,88 +8,91 @@ class TransferTest(TestCase): - def test_retrieve_coupon(self): - coupon_data = deepcopy(FAKE_COUPON) - coupon = Coupon.sync_from_stripe_data(coupon_data) - self.assertEqual(coupon.id, FAKE_COUPON["id"]) + def test_retrieve_coupon(self): + coupon_data = deepcopy(FAKE_COUPON) + coupon = Coupon.sync_from_stripe_data(coupon_data) + self.assertEqual(coupon.id, FAKE_COUPON["id"]) class HumanReadableCouponTest(TestCase): - def test_str_name(self): - coupon = Coupon.objects.create( - id="coupon-test-amount-off-forever", - amount_off=10, - currency="usd", - duration="forever", - name="Test coupon", - ) - self.assertEqual(str(coupon), "Test coupon") + def test_str_name(self): + coupon = Coupon.objects.create( + id="coupon-test-amount-off-forever", + amount_off=10, + currency="usd", + duration="forever", + name="Test coupon", + ) + self.assertEqual(str(coupon), "Test coupon") - def test_human_readable_usd_off_forever(self): - coupon = Coupon.objects.create( - id="coupon-test-amount-off-forever", - amount_off=10, - currency="usd", - duration="forever", - ) - self.assertEqual(coupon.human_readable, "$10.00 USD off forever") - self.assertEqual(str(coupon), coupon.human_readable) + def test_human_readable_usd_off_forever(self): + coupon = Coupon.objects.create( + id="coupon-test-amount-off-forever", + amount_off=10, + currency="usd", + duration="forever", + ) + self.assertEqual(coupon.human_readable, "$10.00 USD off forever") + self.assertEqual(str(coupon), coupon.human_readable) - def test_human_readable_eur_off_forever(self): - coupon = Coupon.objects.create( - id="coupon-test-amount-off-forever", - amount_off=10, - currency="eur", - duration="forever", - ) - self.assertEqual(coupon.human_readable, "€10.00 EUR off forever") - self.assertEqual(str(coupon), coupon.human_readable) + def test_human_readable_eur_off_forever(self): + coupon = Coupon.objects.create( + id="coupon-test-amount-off-forever", + amount_off=10, + currency="eur", + duration="forever", + ) + self.assertEqual(coupon.human_readable, "€10.00 EUR off forever") + self.assertEqual(str(coupon), coupon.human_readable) - def test_human_readable_percent_off_forever(self): - coupon = Coupon.objects.create( - id="coupon-test-percent-off-forever", - percent_off=10.25, - currency="usd", - duration="forever", - ) - self.assertEqual(coupon.human_readable, "10.25% off forever") - self.assertEqual(str(coupon), coupon.human_readable) + def test_human_readable_percent_off_forever(self): + coupon = Coupon.objects.create( + id="coupon-test-percent-off-forever", + percent_off=10.25, + currency="usd", + duration="forever", + ) + self.assertEqual(coupon.human_readable, "10.25% off forever") + self.assertEqual(str(coupon), coupon.human_readable) - def test_human_readable_percent_off_once(self): - coupon = Coupon.objects.create( - id="coupon-test-percent-off-once", percent_off=10.25, currency="usd", duration="once" - ) - self.assertEqual(coupon.human_readable, "10.25% off once") - self.assertEqual(str(coupon), coupon.human_readable) + def test_human_readable_percent_off_once(self): + coupon = Coupon.objects.create( + id="coupon-test-percent-off-once", + percent_off=10.25, + currency="usd", + duration="once", + ) + self.assertEqual(coupon.human_readable, "10.25% off once") + self.assertEqual(str(coupon), coupon.human_readable) - def test_human_readable_percent_off_one_month(self): - coupon = Coupon.objects.create( - id="coupon-test-percent-off-1month", - percent_off=10.25, - currency="usd", - duration="repeating", - duration_in_months=1, - ) - self.assertEqual(coupon.human_readable, "10.25% off for 1 month") - self.assertEqual(str(coupon), coupon.human_readable) + def test_human_readable_percent_off_one_month(self): + coupon = Coupon.objects.create( + id="coupon-test-percent-off-1month", + percent_off=10.25, + currency="usd", + duration="repeating", + duration_in_months=1, + ) + self.assertEqual(coupon.human_readable, "10.25% off for 1 month") + self.assertEqual(str(coupon), coupon.human_readable) - def test_human_readable_percent_off_three_months(self): - coupon = Coupon.objects.create( - id="coupon-test-percent-off-3month", - percent_off=10.25, - currency="usd", - duration="repeating", - duration_in_months=3, - ) - self.assertEqual(coupon.human_readable, "10.25% off for 3 months") - self.assertEqual(str(coupon), coupon.human_readable) + def test_human_readable_percent_off_three_months(self): + coupon = Coupon.objects.create( + id="coupon-test-percent-off-3month", + percent_off=10.25, + currency="usd", + duration="repeating", + duration_in_months=3, + ) + self.assertEqual(coupon.human_readable, "10.25% off for 3 months") + self.assertEqual(str(coupon), coupon.human_readable) - def test_human_readable_integer_percent_off_forever(self): - coupon = Coupon.objects.create( - id="coupon-test-percent-off-forever", - percent_off=10, - currency="usd", - duration="forever", - ) - self.assertEqual(coupon.human_readable, "10% off forever") - self.assertEqual(str(coupon), coupon.human_readable) + def test_human_readable_integer_percent_off_forever(self): + coupon = Coupon.objects.create( + id="coupon-test-percent-off-forever", + percent_off=10, + currency="usd", + duration="forever", + ) + self.assertEqual(coupon.human_readable, "10% off forever") + self.assertEqual(str(coupon), coupon.human_readable) diff --git a/tests/test_customer.py b/tests/test_customer.py index a00ed226c4..62a454b8f4 100644 --- a/tests/test_customer.py +++ b/tests/test_customer.py @@ -13,1344 +13,1510 @@ from djstripe import settings as djstripe_settings from djstripe.exceptions import MultipleSubscriptionException from djstripe.models import ( - Card, Charge, Coupon, Customer, DjstripePaymentMethod, - IdempotencyKey, Invoice, Plan, Subscription + Card, + Charge, + Coupon, + Customer, + DjstripePaymentMethod, + IdempotencyKey, + Invoice, + Plan, + Subscription, ) from djstripe.settings import STRIPE_SECRET_KEY from . import ( - FAKE_ACCOUNT, FAKE_BALANCE_TRANSACTION, FAKE_CARD, FAKE_CARD_V, FAKE_CHARGE, - FAKE_COUPON, FAKE_CUSTOMER, FAKE_CUSTOMER_II, FAKE_CUSTOMER_III, - FAKE_DISCOUNT_CUSTOMER, FAKE_INVOICE, FAKE_INVOICE_III, FAKE_INVOICEITEM, - FAKE_PAYMENT_INTENT_I, FAKE_PAYMENT_METHOD_I, FAKE_PLAN, FAKE_PRODUCT, - FAKE_SOURCE, FAKE_SUBSCRIPTION, FAKE_SUBSCRIPTION_II, FAKE_UPCOMING_INVOICE, - IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - AssertStripeFksMixin, StripeList, datetime_to_unix, default_account + FAKE_ACCOUNT, + FAKE_BALANCE_TRANSACTION, + FAKE_CARD, + FAKE_CARD_V, + FAKE_CHARGE, + FAKE_COUPON, + FAKE_CUSTOMER, + FAKE_CUSTOMER_II, + FAKE_CUSTOMER_III, + FAKE_DISCOUNT_CUSTOMER, + FAKE_INVOICE, + FAKE_INVOICE_III, + FAKE_INVOICEITEM, + FAKE_PAYMENT_INTENT_I, + FAKE_PAYMENT_METHOD_I, + FAKE_PLAN, + FAKE_PRODUCT, + FAKE_SOURCE, + FAKE_SUBSCRIPTION, + FAKE_SUBSCRIPTION_II, + FAKE_UPCOMING_INVOICE, + IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + AssertStripeFksMixin, + StripeList, + datetime_to_unix, + default_account, ) class TestCustomer(AssertStripeFksMixin, TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - self.payment_method, _ = DjstripePaymentMethod._get_or_create_source( - FAKE_CARD, "card" - ) - self.card = self.payment_method.resolve() - - self.customer.default_source = self.payment_method - self.customer.save() - - self.account = default_account() - - def test_str(self): - self.assertEqual(str(self.customer), self.user.email) - self.customer.subscriber.email = "" - self.assertEqual(str(self.customer), self.customer.id) - self.customer.subscriber = None - self.assertEqual(str(self.customer), "{id} (deleted)".format(id=self.customer.id)) - - def test_balance(self): - self.assertEqual(self.customer.balance, 0) - self.assertEqual(self.customer.credits, 0) - - self.customer.balance = 1000 - self.assertEqual(self.customer.balance, 1000) - self.assertEqual(self.customer.credits, 0) - self.assertEqual(self.customer.pending_charges, 1000) - - with self.assertWarns(DeprecationWarning): - self.assertEqual(self.customer.balance, self.customer.account_balance) - - self.customer.balance = -1000 - self.assertEqual(self.customer.balance, -1000) - self.assertEqual(self.customer.credits, 1000) - self.assertEqual(self.customer.pending_charges, 0) - - with self.assertWarns(DeprecationWarning): - self.assertEqual(self.customer.balance, self.customer.account_balance) - - def test_customer_dashboard_url(self): - expected_url = "https://dashboard.stripe.com/test/customers/{}".format( - self.customer.id - ) - self.assertEqual(self.customer.get_stripe_dashboard_url(), expected_url) - - self.customer.livemode = True - expected_url = "https://dashboard.stripe.com/customers/{}".format(self.customer.id) - self.assertEqual(self.customer.get_stripe_dashboard_url(), expected_url) - - unsaved_customer = Customer() - self.assertEqual(unsaved_customer.get_stripe_dashboard_url(), "") - - def test_customer_sync_unsupported_source(self): - fake_customer = deepcopy(FAKE_CUSTOMER_II) - fake_customer["default_source"]["object"] = fake_customer["sources"]["data"][0][ - "object" - ] = "fish" - - user = get_user_model().objects.create_user( - username="test_user_sync_unsupported_source" - ) - synced_customer = fake_customer.create_for_user(user) - self.assertEqual(0, synced_customer.legacy_cards.count()) - self.assertEqual(0, synced_customer.sources.count()) - self.assertEqual( - synced_customer.default_source, - DjstripePaymentMethod.objects.get(id=fake_customer["default_source"]["id"]), - ) - - def test_customer_sync_has_subscriber_metadata(self): - user = get_user_model().objects.create(username="test_metadata", id=12345) - - fake_customer = deepcopy(FAKE_CUSTOMER) - fake_customer["id"] = "cus_sync_has_subscriber_metadata" - fake_customer["metadata"] = {"djstripe_subscriber": "12345"} - customer = Customer.sync_from_stripe_data(fake_customer) - - self.assertEqual(customer.subscriber, user) - self.assertEqual(customer.metadata, {"djstripe_subscriber": "12345"}) - - def test_customer_sync_has_subscriber_metadata_disabled(self): - user = get_user_model().objects.create(username="test_metadata_disabled", id=98765) - - fake_customer = deepcopy(FAKE_CUSTOMER) - fake_customer["id"] = "cus_test_metadata_disabled" - fake_customer["metadata"] = {"djstripe_subscriber": "98765"} - with patch( - "djstripe.settings.SUBSCRIBER_CUSTOMER_KEY", return_value="", autospec=True - ): - customer = Customer.sync_from_stripe_data(fake_customer) - - self.assertNotEqual(customer.subscriber, user) - self.assertNotEqual(customer.subscriber_id, 98765) - - self.assert_fks( - customer, - expected_blank_fks={"djstripe.Customer.coupon", "djstripe.Customer.subscriber"}, - ) - - def test_customer_sync_has_bad_subscriber_metadata(self): - fake_customer = deepcopy(FAKE_CUSTOMER) - fake_customer["id"] = "cus_sync_has_bad_subscriber_metadata" - fake_customer["metadata"] = {"djstripe_subscriber": "does_not_exist"} - customer = Customer.sync_from_stripe_data(fake_customer) - - self.assertEqual(customer.subscriber, None) - self.assertEqual(customer.metadata, {"djstripe_subscriber": "does_not_exist"}) - - self.assert_fks( - customer, - expected_blank_fks={"djstripe.Customer.coupon", "djstripe.Customer.subscriber"}, - ) - - @patch("stripe.Customer.create", autospec=True) - def test_customer_create_metadata_disabled(self, customer_mock): - user = get_user_model().objects.create_user( - username="test_user_create_metadata_disabled" - ) - - fake_customer = deepcopy(FAKE_CUSTOMER) - fake_customer["id"] = "cus_test_create_metadata_disabled" - customer_mock.return_value = fake_customer - - djstripe_settings.SUBSCRIBER_CUSTOMER_KEY = "" - customer = Customer.create(user) - djstripe_settings.SUBSCRIBER_CUSTOMER_KEY = "djstripe_subscriber" - - customer_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, email="", idempotency_key=None, metadata={} - ) - - self.assertEqual(customer.metadata, None) - - self.assert_fks( - customer, - expected_blank_fks={"djstripe.Customer.coupon", "djstripe.Customer.default_source"}, - ) - - @patch( - "stripe.Card.retrieve", return_value=FAKE_CUSTOMER_II["default_source"], autospec=True - ) - def test_customer_sync_non_local_card(self, card_retrieve_mock): - fake_customer = deepcopy(FAKE_CUSTOMER_II) - fake_customer["id"] = fake_customer["sources"]["data"][0][ - "customer" - ] = "cus_test_sync_non_local_card" - - user = get_user_model().objects.create_user(username="test_user_sync_non_local_card") - customer = fake_customer.create_for_user(user) - - self.assertEqual(customer.sources.count(), 0) - self.assertEqual(customer.legacy_cards.count(), 1) - self.assertEqual(customer.default_source.id, fake_customer["default_source"]["id"]) - - @patch("stripe.Customer.create", autospec=True) - def test_customer_sync_no_sources(self, customer_mock): - fake_customer = deepcopy(FAKE_CUSTOMER) - fake_customer["id"] = "cus_test_sync_no_sources" - fake_customer["default_source"] = None - fake_customer["sources"] = None - customer_mock.return_value = fake_customer - - user = get_user_model().objects.create_user(username="test_user_sync_non_local_card") - customer = Customer.create(user) - self.assertEqual( - customer_mock.call_args_list[0][1].get("metadata"), {"djstripe_subscriber": user.pk} - ) - - self.assertEqual(customer.sources.count(), 0) - self.assertEqual(customer.legacy_cards.count(), 0) - self.assertEqual(customer.default_source, None) - - self.assert_fks( - customer, - expected_blank_fks={"djstripe.Customer.coupon", "djstripe.Customer.default_source"}, - ) - - def test_customer_sync_default_source_string(self): - Customer.objects.all().delete() - Card.objects.all().delete() - customer_fake = deepcopy(FAKE_CUSTOMER) - customer_fake["default_source"] = customer_fake["sources"]["data"][0][ - "id" - ] = "card_sync_source_string" - customer = Customer.sync_from_stripe_data(customer_fake) - self.assertEqual(customer.default_source.id, customer_fake["default_source"]) - self.assertEqual(customer.legacy_cards.count(), 2) - self.assertEqual(len(list(customer.customer_payment_methods)), 2) - - self.assert_fks( - customer, - expected_blank_fks={"djstripe.Customer.coupon", "djstripe.Customer.subscriber"}, - ) - - @patch("stripe.Customer.retrieve", autospec=True) - def test_customer_purge_leaves_customer_record(self, customer_retrieve_fake): - self.customer.purge() - customer = Customer.objects.get(id=self.customer.id) - - self.assertTrue(customer.subscriber is None) - self.assertTrue(customer.default_source is None) - self.assertTrue(not customer.legacy_cards.all()) - self.assertTrue(not customer.sources.all()) - self.assertTrue(get_user_model().objects.filter(pk=self.user.pk).exists()) - - @patch("stripe.Customer.create", autospec=True) - def test_customer_purge_detaches_sources(self, customer_api_create_fake): - fake_customer = deepcopy(FAKE_CUSTOMER_III) - customer_api_create_fake.return_value = fake_customer - - user = get_user_model().objects.create_user( - username="blah", email=FAKE_CUSTOMER_III["email"] - ) - - Customer.get_or_create(user) - customer = Customer.sync_from_stripe_data(deepcopy(FAKE_CUSTOMER_III)) - - self.assertIsNotNone(customer.default_source) - self.assertNotEqual(customer.sources.count(), 0) - - with patch("stripe.Customer.retrieve", autospec=True), patch( - "stripe.Source.retrieve", return_value=deepcopy(FAKE_SOURCE), autospec=True - ): - customer.purge() - - self.assertIsNone(customer.default_source) - self.assertEqual(customer.sources.count(), 0) - - @patch( - "stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True - ) - def test_customer_purge_deletes_idempotency_key(self, customer_api_create_fake): - # We need to call Customer.get_or_create (which setUp doesn't) to get an idempotency key - user = get_user_model().objects.create_user( - username="blah", email=FAKE_CUSTOMER_II["email"] - ) - idempotency_key_action = "customer:create:{}".format(user.pk) - self.assertFalse( - IdempotencyKey.objects.filter(action=idempotency_key_action).exists() - ) - - customer, created = Customer.get_or_create(user) - self.assertTrue(IdempotencyKey.objects.filter(action=idempotency_key_action).exists()) - - with patch("stripe.Customer.retrieve", autospec=True): - customer.purge() - - self.assertFalse( - IdempotencyKey.objects.filter(action=idempotency_key_action).exists() - ) - - @patch("stripe.Customer.retrieve", autospec=True) - def test_customer_delete_same_as_purge(self, customer_retrieve_fake): - self.customer.delete() - customer = Customer.objects.get(id=self.customer.id) - - self.assertTrue(customer.subscriber is None) - self.assertTrue(customer.default_source is None) - self.assertTrue(not customer.legacy_cards.all()) - self.assertTrue(not customer.sources.all()) - self.assertTrue(get_user_model().objects.filter(pk=self.user.pk).exists()) - - @patch("stripe.Customer.retrieve", autospec=True) - def test_customer_purge_raises_customer_exception(self, customer_retrieve_mock): - customer_retrieve_mock.side_effect = InvalidRequestError("No such customer:", "blah") - - self.customer.purge() - customer = Customer.objects.get(id=self.customer.id) - self.assertTrue(customer.subscriber is None) - self.assertTrue(customer.default_source is None) - self.assertTrue(not customer.legacy_cards.all()) - self.assertTrue(not customer.sources.all()) - self.assertTrue(get_user_model().objects.filter(pk=self.user.pk).exists()) - - customer_retrieve_mock.assert_called_with( - id=self.customer.id, api_key=STRIPE_SECRET_KEY, expand=["default_source"] - ) - self.assertEqual(3, customer_retrieve_mock.call_count) - - @patch("stripe.Customer.retrieve", autospec=True) - def test_customer_delete_raises_unexpected_exception(self, customer_retrieve_mock): - customer_retrieve_mock.side_effect = InvalidRequestError( - "Unexpected Exception", "blah" - ) - - with self.assertRaisesMessage(InvalidRequestError, "Unexpected Exception"): - self.customer.purge() - - customer_retrieve_mock.assert_called_once_with( - id=self.customer.id, api_key=STRIPE_SECRET_KEY, expand=["default_source"] - ) - - def test_can_charge(self): - self.assertTrue(self.customer.can_charge()) - - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_add_card_set_default_true(self, customer_retrieve_mock): - self.customer.add_card(FAKE_CARD["id"]) - self.customer.add_card(FAKE_CARD_V["id"]) - - self.assertEqual(2, Card.objects.count()) - self.assertEqual(FAKE_CARD_V["id"], self.customer.default_source.id) - - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_add_card_set_default_false(self, customer_retrieve_mock): - self.customer.add_card(FAKE_CARD["id"], set_default=False) - self.customer.add_card(FAKE_CARD_V["id"], set_default=False) - - self.assertEqual(2, Card.objects.count()) - self.assertEqual(FAKE_CARD["id"], self.customer.default_source.id) - - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_add_card_set_default_false_with_single_card_still_becomes_default( - self, customer_retrieve_mock - ): - self.customer.add_card(FAKE_CARD["id"], set_default=False) - - self.assertEqual(2, Card.objects.count()) - self.assertEqual(FAKE_CARD["id"], self.customer.default_source.id) - - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.PaymentMethod.attach", return_value=deepcopy(FAKE_PAYMENT_METHOD_I)) - def test_add_payment_method(self, customer_retrieve_mock, attach_mock): - self.assertEqual( - self.customer.payment_methods.filter(id=FAKE_PAYMENT_METHOD_I["id"]).count(), 0 - ) - - self.customer.add_payment_method(FAKE_PAYMENT_METHOD_I["id"]) - - self.assertEqual( - self.customer.payment_methods.filter(id=FAKE_PAYMENT_METHOD_I["id"]).count(), 1 - ) - - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_cannot_charge(self, customer_retrieve_fake): - self.customer.delete() - self.assertFalse(self.customer.can_charge()) - - def test_charge_accepts_only_decimals(self): - with self.assertRaises(ValueError): - self.customer.charge(10) - - @patch("stripe.Coupon.retrieve", return_value=deepcopy(FAKE_COUPON), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_add_coupon_by_id(self, customer_retrieve_mock, coupon_retrieve_mock): - self.assertEqual(self.customer.coupon, None) - self.customer.add_coupon(FAKE_COUPON["id"]) - customer_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=["default_source"], id=FAKE_CUSTOMER["id"] - ) - - @patch("stripe.Coupon.retrieve", return_value=deepcopy(FAKE_COUPON), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_add_coupon_by_object(self, customer_retrieve_mock, coupon_retrieve_mock): - self.assertEqual(self.customer.coupon, None) - coupon = Coupon.sync_from_stripe_data(FAKE_COUPON) - fake_discount = deepcopy(FAKE_DISCOUNT_CUSTOMER) - - def fake_customer_save(self, *args, **kwargs): - # fake the api coupon update behaviour - coupon = self.pop("coupon", None) - if coupon: - self["discount"] = fake_discount - else: - self["discount"] = None - - return self - - with patch("tests.CustomerDict.save", new=fake_customer_save): - self.customer.add_coupon(coupon) - - customer_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=["default_source"], id=FAKE_CUSTOMER["id"] - ) - - self.customer.refresh_from_db() - - self.assert_fks(self.customer, expected_blank_fks={}) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch("stripe.PaymentIntent.retrieve", autospec=True) - def test_refund_charge( - self, - payment_intent_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_no_invoice = deepcopy(FAKE_CHARGE) - fake_charge_no_invoice.update({"invoice": None}) - - charge_retrieve_mock.return_value = fake_charge_no_invoice - - fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) - fake_payment_intent.update({"invoice": None}) - - payment_intent_retrieve_mock.return_value = fake_payment_intent - - charge, created = Charge._get_or_create_from_stripe_object(fake_charge_no_invoice) - self.assertTrue(created) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.invoice", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.invoice", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - }, - ) - - charge.refund() - - refunded_charge, created2 = Charge._get_or_create_from_stripe_object( - fake_charge_no_invoice - ) - self.assertFalse(created2) - - self.assertEqual(refunded_charge.refunded, True) - self.assertEqual(refunded_charge.amount_refunded, decimal.Decimal("20.00")) - - self.assert_fks( - refunded_charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.invoice", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.invoice", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch("stripe.PaymentIntent.retrieve", autospec=True) - def test_refund_charge_object_returned( - self, - payment_intent_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_no_invoice = deepcopy(FAKE_CHARGE) - fake_charge_no_invoice.update({"invoice": None}) - - charge_retrieve_mock.return_value = fake_charge_no_invoice - - fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) - fake_payment_intent.update({"invoice": None}) - - payment_intent_retrieve_mock.return_value = fake_payment_intent - - charge, created = Charge._get_or_create_from_stripe_object(fake_charge_no_invoice) - self.assertTrue(created) - - self.assert_fks( - charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.invoice", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.invoice", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - }, - ) - - refunded_charge = charge.refund() - self.assertEqual(refunded_charge.refunded, True) - self.assertEqual(refunded_charge.amount_refunded, decimal.Decimal("20.00")) - - self.assert_fks( - refunded_charge, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.invoice", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.invoice", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - }, - ) - - def test_calculate_refund_amount_full_refund(self): - charge = Charge( - id="ch_111111", customer=self.customer, amount=decimal.Decimal("500.00") - ) - self.assertEqual(charge._calculate_refund_amount(), 50000) - - def test_calculate_refund_amount_partial_refund(self): - charge = Charge( - id="ch_111111", customer=self.customer, amount=decimal.Decimal("500.00") - ) - self.assertEqual( - charge._calculate_refund_amount(amount=decimal.Decimal("300.00")), 30000 - ) - - def test_calculate_refund_above_max_refund(self): - charge = Charge( - id="ch_111111", customer=self.customer, amount=decimal.Decimal("500.00") - ) - self.assertEqual( - charge._calculate_refund_amount(amount=decimal.Decimal("600.00")), 50000 - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch("stripe.Charge.create", autospec=True) - @patch("stripe.PaymentIntent.retrieve", autospec=True) - def test_charge_converts_dollars_into_cents( - self, - payment_intent_retrieve_mock, - charge_create_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update({"invoice": None, "amount": 1000}) - - charge_create_mock.return_value = fake_charge_copy - charge_retrieve_mock.return_value = fake_charge_copy - - fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) - fake_payment_intent.update({"invoice": None}) - - payment_intent_retrieve_mock.return_value = fake_payment_intent - - self.customer.charge(amount=decimal.Decimal("10.00")) - - _, kwargs = charge_create_mock.call_args - self.assertEqual(kwargs["amount"], 1000) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch("stripe.Charge.create", autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Invoice.retrieve", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - def test_charge_doesnt_require_invoice( - self, - subscription_retrieve_mock, - product_retrieve_mock, - invoice_retrieve_mock, - payment_intent_retrieve_mock, - charge_create_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update( - {"invoice": FAKE_INVOICE["id"], "amount": FAKE_INVOICE["amount_due"]} - ) - fake_invoice_copy = deepcopy(FAKE_INVOICE) - - charge_create_mock.return_value = fake_charge_copy - charge_retrieve_mock.return_value = fake_charge_copy - invoice_retrieve_mock.return_value = fake_invoice_copy - - try: - self.customer.charge(amount=decimal.Decimal("20.00")) - except Invoice.DoesNotExist: - self.fail(msg="Stripe Charge shouldn't throw Invoice DoesNotExist.") - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch("stripe.Charge.create", autospec=True) - @patch("stripe.PaymentIntent.retrieve", autospec=True) - def test_charge_passes_extra_arguments( - self, - payment_intent_retrieve_mock, - charge_create_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update({"invoice": None}) - - charge_create_mock.return_value = fake_charge_copy - charge_retrieve_mock.return_value = fake_charge_copy - - fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) - fake_payment_intent.update({"invoice": None}) - - payment_intent_retrieve_mock.return_value = fake_payment_intent - - self.customer.charge( - amount=decimal.Decimal("10.00"), capture=True, destination=FAKE_ACCOUNT["id"] - ) - - _, kwargs = charge_create_mock.call_args - self.assertEqual(kwargs["capture"], True) - self.assertEqual(kwargs["destination"], FAKE_ACCOUNT["id"]) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch("stripe.Charge.create", autospec=True) - @patch("stripe.PaymentIntent.retrieve", autospec=True) - def test_charge_string_source( - self, - payment_intent_retrieve_mock, - charge_create_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update({"invoice": None}) - - charge_create_mock.return_value = fake_charge_copy - charge_retrieve_mock.return_value = fake_charge_copy - - fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) - fake_payment_intent.update({"invoice": None}) - - payment_intent_retrieve_mock.return_value = fake_payment_intent - - self.customer.charge(amount=decimal.Decimal("10.00"), source=self.card.id) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch("stripe.Charge.create", autospec=True) - @patch("stripe.PaymentIntent.retrieve", autospec=True) - def test_charge_card_source( - self, - payment_intent_retrieve_mock, - charge_create_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - fake_charge_copy = deepcopy(FAKE_CHARGE) - fake_charge_copy.update({"invoice": None}) - - charge_create_mock.return_value = fake_charge_copy - charge_retrieve_mock.return_value = fake_charge_copy - - fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) - fake_payment_intent.update({"invoice": None}) - - payment_intent_retrieve_mock.return_value = fake_payment_intent - - self.customer.charge(amount=decimal.Decimal("10.00"), source=self.card) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Invoice.list", - return_value=StripeList(data=[deepcopy(FAKE_INVOICE), deepcopy(FAKE_INVOICE_III)]), - autospec=True, - ) - @patch("djstripe.models.Invoice.retry", autospec=True) - def test_retry_unpaid_invoices( - self, - invoice_retry_mock, - invoice_list_mock, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - self.customer.retry_unpaid_invoices() - - invoice = Invoice.objects.get(id=FAKE_INVOICE_III["id"]) - invoice_retry_mock.assert_called_once_with(invoice) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Invoice.list", - return_value=StripeList(data=[deepcopy(FAKE_INVOICE)]), - autospec=True, - ) - @patch("djstripe.models.Invoice.retry", autospec=True) - def test_retry_unpaid_invoices_none_unpaid( - self, - invoice_retry_mock, - invoice_list_mock, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - self.customer.retry_unpaid_invoices() - - self.assertFalse(invoice_retry_mock.called) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Invoice.list", return_value=StripeList(data=[deepcopy(FAKE_INVOICE_III)]) - ) - @patch("djstripe.models.Invoice.retry", autospec=True) - def test_retry_unpaid_invoices_expected_exception( - self, - invoice_retry_mock, - invoice_list_mock, - product_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - invoice_retry_mock.side_effect = InvalidRequestError( - "Invoice is already paid", "blah" - ) - - try: - self.customer.retry_unpaid_invoices() - except Exception: - self.fail("Exception was unexpectedly raised.") - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Invoice.list", return_value=StripeList(data=[deepcopy(FAKE_INVOICE_III)]) - ) - @patch("djstripe.models.Invoice.retry", autospec=True) - def test_retry_unpaid_invoices_unexpected_exception( - self, - invoice_retry_mock, - invoice_list_mock, - product_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - invoice_retry_mock.side_effect = InvalidRequestError("This should fail!", "blah") - - with self.assertRaisesMessage(InvalidRequestError, "This should fail!"): - self.customer.retry_unpaid_invoices() - - @patch("stripe.Invoice.create", autospec=True) - def test_send_invoice_success(self, invoice_create_mock): - return_status = self.customer.send_invoice() - self.assertTrue(return_status) - - invoice_create_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, customer=self.customer.id - ) - - @patch("stripe.Invoice.create", autospec=True) - def test_send_invoice_failure(self, invoice_create_mock): - invoice_create_mock.side_effect = InvalidRequestError( - "Invoice creation failed.", "blah" - ) - - return_status = self.customer.send_invoice() - self.assertFalse(return_status) - - invoice_create_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, customer=self.customer.id - ) - - @patch("stripe.Coupon.retrieve", return_value=deepcopy(FAKE_COUPON), autospec=True) - def test_sync_customer_with_discount(self, coupon_retrieve_mock): - self.assertIsNone(self.customer.coupon) - fake_customer = deepcopy(FAKE_CUSTOMER) - fake_customer["discount"] = deepcopy(FAKE_DISCOUNT_CUSTOMER) - customer = Customer.sync_from_stripe_data(fake_customer) - self.assertEqual(customer.coupon.id, FAKE_COUPON["id"]) - self.assertIsNotNone(customer.coupon_start) - self.assertIsNone(customer.coupon_end) - - @patch("stripe.Coupon.retrieve", return_value=deepcopy(FAKE_COUPON), autospec=True) - def test_sync_customer_discount_already_present(self, coupon_retrieve_mock): - fake_customer = deepcopy(FAKE_CUSTOMER) - fake_customer["discount"] = deepcopy(FAKE_DISCOUNT_CUSTOMER) - - # Set the customer's coupon to be what we'll sync - customer = Customer.objects.get(id=FAKE_CUSTOMER["id"]) - customer.coupon = Coupon.sync_from_stripe_data(FAKE_COUPON) - customer.save() - - customer = Customer.sync_from_stripe_data(fake_customer) - self.assertEqual(customer.coupon.id, FAKE_COUPON["id"]) - - def test_sync_customer_delete_discount(self): - test_coupon = Coupon.sync_from_stripe_data(FAKE_COUPON) - self.customer.coupon = test_coupon - self.customer.save() - self.assertEqual(self.customer.coupon.id, FAKE_COUPON["id"]) - - customer = Customer.sync_from_stripe_data(FAKE_CUSTOMER) - self.assertEqual(customer.coupon, None) - - @patch( - "djstripe.models.Invoice.sync_from_stripe_data", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.Invoice.list", - return_value=StripeList(data=[deepcopy(FAKE_INVOICE), deepcopy(FAKE_INVOICE_III)]), - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_sync_invoices( - self, customer_retrieve_mock, invoice_list_mock, invoice_sync_mock - ): - self.customer._sync_invoices() - self.assertEqual(2, invoice_sync_mock.call_count) - - @patch( - "djstripe.models.Invoice.sync_from_stripe_data", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Invoice.list", return_value=StripeList(data=[]), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_sync_invoices_none( - self, customer_retrieve_mock, invoice_list_mock, invoice_sync_mock - ): - self.customer._sync_invoices() - self.assertEqual(0, invoice_sync_mock.call_count) - - @patch( - "djstripe.models.Charge.sync_from_stripe_data", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.Charge.list", - return_value=StripeList(data=[deepcopy(FAKE_CHARGE)]), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_sync_charges( - self, customer_retrieve_mock, charge_list_mock, charge_sync_mock - ): - self.customer._sync_charges() - self.assertEqual(1, charge_sync_mock.call_count) - - @patch( - "djstripe.models.Charge.sync_from_stripe_data", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Charge.list", return_value=StripeList(data=[]), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_sync_charges_none( - self, customer_retrieve_mock, charge_list_mock, charge_sync_mock - ): - self.customer._sync_charges() - self.assertEqual(0, charge_sync_mock.call_count) - - @patch( - "djstripe.models.Subscription.sync_from_stripe_data", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.Subscription.list", - return_value=StripeList( - data=[deepcopy(FAKE_SUBSCRIPTION), deepcopy(FAKE_SUBSCRIPTION_II)] - ), - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_sync_subscriptions( - self, customer_retrieve_mock, subscription_list_mock, subscription_sync_mock - ): - self.customer._sync_subscriptions() - self.assertEqual(2, subscription_sync_mock.call_count) - - @patch( - "djstripe.models.Subscription.sync_from_stripe_data", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Subscription.list", return_value=StripeList(data=[]), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_sync_subscriptions_none( - self, customer_retrieve_mock, subscription_list_mock, subscription_sync_mock - ): - self.customer._sync_subscriptions() - self.assertEqual(0, subscription_sync_mock.call_count) - - @patch("djstripe.models.Customer.send_invoice", autospec=True) - @patch( - "stripe.Subscription.create", return_value=deepcopy(FAKE_SUBSCRIPTION), autospec=True - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_subscribe_not_charge_immediately( - self, - product_retrieve_mock, - customer_retrieve_mock, - subscription_create_mock, - send_invoice_mock, - ): - plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - self.customer.subscribe(plan=plan, charge_immediately=False) - self.assertFalse(send_invoice_mock.called) - - @patch("djstripe.models.Customer.send_invoice", autospec=True) - @patch( - "stripe.Subscription.create", return_value=deepcopy(FAKE_SUBSCRIPTION), autospec=True - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_subscribe_charge_immediately( - self, - product_retrieve_mock, - customer_retrieve_mock, - subscription_create_mock, - send_invoice_mock, - ): - plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - self.assert_fks(plan, expected_blank_fks={}) - - self.customer.subscribe(plan=plan, charge_immediately=True) - self.assertTrue(send_invoice_mock.called) - - @patch("djstripe.models.Customer.send_invoice", autospec=True) - @patch( - "stripe.Subscription.create", return_value=deepcopy(FAKE_SUBSCRIPTION), autospec=True - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_subscribe_plan_string( - self, - product_retrieve_mock, - customer_retrieve_mock, - subscription_create_mock, - send_invoice_mock, - ): - plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - self.assert_fks(plan, expected_blank_fks={}) - - self.customer.subscribe(plan=plan.id, charge_immediately=True) - self.assertTrue(send_invoice_mock.called) - - @patch("stripe.Subscription.create", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_subscription_shortcut_with_multiple_subscriptions( - self, product_retrieve_mock, customer_retrieve_mock, subscription_create_mock - ): - plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - self.assert_fks(plan, expected_blank_fks={}) - - subscription_fake_duplicate = deepcopy(FAKE_SUBSCRIPTION) - subscription_fake_duplicate["id"] = "sub_6lsC8pt7IcF8jd" - - subscription_create_mock.side_effect = [ - deepcopy(FAKE_SUBSCRIPTION), - subscription_fake_duplicate, - ] - - self.customer.subscribe(plan=plan, charge_immediately=False) - self.customer.subscribe(plan=plan, charge_immediately=False) - - self.assertEqual(2, self.customer.subscriptions.count()) - - with self.assertRaises(MultipleSubscriptionException): - self.customer.subscription - - @patch("stripe.Subscription.create", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_has_active_subscription_with_unspecified_plan_with_multiple_subscriptions( - self, product_retrieve_mock, customer_retrieve_mock, subscription_create_mock - ): - plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - self.assert_fks(plan, expected_blank_fks={}) - - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription_fake["current_period_end"] = datetime_to_unix( - timezone.now() + timezone.timedelta(days=7) - ) - - subscription_fake_duplicate = deepcopy(FAKE_SUBSCRIPTION) - subscription_fake_duplicate["current_period_end"] = datetime_to_unix( - timezone.now() + timezone.timedelta(days=7) - ) - subscription_fake_duplicate["id"] = "sub_6lsC8pt7IcF8jd" - - subscription_create_mock.side_effect = [ - subscription_fake, - subscription_fake_duplicate, - ] - - self.customer.subscribe(plan=plan, charge_immediately=False) - self.customer.subscribe(plan=plan, charge_immediately=False) - - self.assertEqual(2, self.customer.subscriptions.count()) - - with self.assertRaises(TypeError): - self.customer.has_active_subscription() - - @patch("stripe.Subscription.create", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_has_active_subscription_with_plan( - self, product_retrieve_mock, customer_retrieve_mock, subscription_create_mock - ): - plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription_fake["current_period_end"] = datetime_to_unix( - timezone.now() + timezone.timedelta(days=7) - ) - - subscription_create_mock.return_value = subscription_fake - - self.customer.subscribe(plan=plan, charge_immediately=False) - - self.customer.has_active_subscription(plan=plan) - - @patch("stripe.Subscription.create", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_has_active_subscription_with_plan_string( - self, product_retrieve_mock, customer_retrieve_mock, subscription_create_mock - ): - plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription_fake["current_period_end"] = datetime_to_unix( - timezone.now() + timezone.timedelta(days=7) - ) - - subscription_create_mock.return_value = subscription_fake - - self.customer.subscribe(plan=plan, charge_immediately=False) - - self.customer.has_active_subscription(plan=plan.id) - - @patch( - "djstripe.models.InvoiceItem.sync_from_stripe_data", - return_value="pancakes", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.InvoiceItem.create", return_value=deepcopy(FAKE_INVOICEITEM), autospec=True - ) - def test_add_invoice_item(self, invoiceitem_create_mock, invoiceitem_sync_mock): - invoiceitem = self.customer.add_invoice_item( - amount=decimal.Decimal("50.00"), - currency="eur", - description="test", - invoice=77, - subscription=25, - ) - self.assertEqual("pancakes", invoiceitem) - - invoiceitem_create_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, - amount=5000, - customer=self.customer.id, - currency="eur", - description="test", - discountable=None, - invoice=77, - metadata=None, - subscription=25, - ) - - @patch( - "djstripe.models.InvoiceItem.sync_from_stripe_data", - return_value="pancakes", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.InvoiceItem.create", return_value=deepcopy(FAKE_INVOICEITEM), autospec=True - ) - def test_add_invoice_item_djstripe_objects( - self, invoiceitem_create_mock, invoiceitem_sync_mock - ): - invoiceitem = self.customer.add_invoice_item( - amount=decimal.Decimal("50.00"), - currency="eur", - description="test", - invoice=Invoice(id=77), - subscription=Subscription(id=25), - ) - self.assertEqual("pancakes", invoiceitem) - - invoiceitem_create_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, - amount=5000, - customer=self.customer.id, - currency="eur", - description="test", - discountable=None, - invoice=77, - metadata=None, - subscription=25, - ) - - def test_add_invoice_item_bad_decimal(self): - with self.assertRaisesMessage( - ValueError, "You must supply a decimal value representing dollars." - ): - self.customer.add_invoice_item(amount=5000, currency="usd") - - @patch( - "stripe.Plan.retrieve", - return_value=deepcopy(FAKE_PLAN), - autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch( - "stripe.Invoice.upcoming", - return_value=deepcopy(FAKE_UPCOMING_INVOICE), - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - def test_upcoming_invoice( - self, - invoice_upcoming_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - invoice = self.customer.upcoming_invoice() - self.assertIsNotNone(invoice) - self.assertIsNone(invoice.id) - self.assertIsNone(invoice.save()) - - subscription_retrieve_mock.assert_called_once_with( - api_key=ANY, expand=ANY, id=FAKE_SUBSCRIPTION["id"] - ) - plan_retrieve_mock.assert_not_called() - - items = invoice.invoiceitems.all() - self.assertEqual(1, len(items)) - self.assertEqual(FAKE_SUBSCRIPTION["id"], items[0].id) - - self.assertIsNotNone(invoice.plan) - self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) - - invoice._invoiceitems = [] - items = invoice.invoiceitems.all() - self.assertEqual(0, len(items)) - self.assertIsNotNone(invoice.plan) - - @patch("stripe.Customer.retrieve", autospec=True) - def test_delete_subscriber_purges_customer(self, customer_retrieve_mock): - self.user.delete() - customer = Customer.objects.get(id=FAKE_CUSTOMER["id"]) - self.assertIsNotNone(customer.date_purged) - - @patch("stripe.Customer.retrieve", autospec=True) - def test_delete_subscriber_without_customer_is_noop(self, customer_retrieve_mock): - self.user.delete() - for customer in self.user.djstripe_customers.all(): - self.assertIsNone(customer.date_purged) + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + self.payment_method, _ = DjstripePaymentMethod._get_or_create_source( + FAKE_CARD, "card" + ) + self.card = self.payment_method.resolve() + + self.customer.default_source = self.payment_method + self.customer.save() + + self.account = default_account() + + def test_str(self): + self.assertEqual(str(self.customer), self.user.email) + self.customer.subscriber.email = "" + self.assertEqual(str(self.customer), self.customer.id) + self.customer.subscriber = None + self.assertEqual( + str(self.customer), "{id} (deleted)".format(id=self.customer.id) + ) + + def test_balance(self): + self.assertEqual(self.customer.balance, 0) + self.assertEqual(self.customer.credits, 0) + + self.customer.balance = 1000 + self.assertEqual(self.customer.balance, 1000) + self.assertEqual(self.customer.credits, 0) + self.assertEqual(self.customer.pending_charges, 1000) + + with self.assertWarns(DeprecationWarning): + self.assertEqual(self.customer.balance, self.customer.account_balance) + + self.customer.balance = -1000 + self.assertEqual(self.customer.balance, -1000) + self.assertEqual(self.customer.credits, 1000) + self.assertEqual(self.customer.pending_charges, 0) + + with self.assertWarns(DeprecationWarning): + self.assertEqual(self.customer.balance, self.customer.account_balance) + + def test_customer_dashboard_url(self): + expected_url = "https://dashboard.stripe.com/test/customers/{}".format( + self.customer.id + ) + self.assertEqual(self.customer.get_stripe_dashboard_url(), expected_url) + + self.customer.livemode = True + expected_url = "https://dashboard.stripe.com/customers/{}".format( + self.customer.id + ) + self.assertEqual(self.customer.get_stripe_dashboard_url(), expected_url) + + unsaved_customer = Customer() + self.assertEqual(unsaved_customer.get_stripe_dashboard_url(), "") + + def test_customer_sync_unsupported_source(self): + fake_customer = deepcopy(FAKE_CUSTOMER_II) + fake_customer["default_source"]["object"] = fake_customer["sources"]["data"][0][ + "object" + ] = "fish" + + user = get_user_model().objects.create_user( + username="test_user_sync_unsupported_source" + ) + synced_customer = fake_customer.create_for_user(user) + self.assertEqual(0, synced_customer.legacy_cards.count()) + self.assertEqual(0, synced_customer.sources.count()) + self.assertEqual( + synced_customer.default_source, + DjstripePaymentMethod.objects.get(id=fake_customer["default_source"]["id"]), + ) + + def test_customer_sync_has_subscriber_metadata(self): + user = get_user_model().objects.create(username="test_metadata", id=12345) + + fake_customer = deepcopy(FAKE_CUSTOMER) + fake_customer["id"] = "cus_sync_has_subscriber_metadata" + fake_customer["metadata"] = {"djstripe_subscriber": "12345"} + customer = Customer.sync_from_stripe_data(fake_customer) + + self.assertEqual(customer.subscriber, user) + self.assertEqual(customer.metadata, {"djstripe_subscriber": "12345"}) + + def test_customer_sync_has_subscriber_metadata_disabled(self): + user = get_user_model().objects.create( + username="test_metadata_disabled", id=98765 + ) + + fake_customer = deepcopy(FAKE_CUSTOMER) + fake_customer["id"] = "cus_test_metadata_disabled" + fake_customer["metadata"] = {"djstripe_subscriber": "98765"} + with patch( + "djstripe.settings.SUBSCRIBER_CUSTOMER_KEY", return_value="", autospec=True + ): + customer = Customer.sync_from_stripe_data(fake_customer) + + self.assertNotEqual(customer.subscriber, user) + self.assertNotEqual(customer.subscriber_id, 98765) + + self.assert_fks( + customer, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Customer.subscriber", + }, + ) + + def test_customer_sync_has_bad_subscriber_metadata(self): + fake_customer = deepcopy(FAKE_CUSTOMER) + fake_customer["id"] = "cus_sync_has_bad_subscriber_metadata" + fake_customer["metadata"] = {"djstripe_subscriber": "does_not_exist"} + customer = Customer.sync_from_stripe_data(fake_customer) + + self.assertEqual(customer.subscriber, None) + self.assertEqual(customer.metadata, {"djstripe_subscriber": "does_not_exist"}) + + self.assert_fks( + customer, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Customer.subscriber", + }, + ) + + @patch("stripe.Customer.create", autospec=True) + def test_customer_create_metadata_disabled(self, customer_mock): + user = get_user_model().objects.create_user( + username="test_user_create_metadata_disabled" + ) + + fake_customer = deepcopy(FAKE_CUSTOMER) + fake_customer["id"] = "cus_test_create_metadata_disabled" + customer_mock.return_value = fake_customer + + djstripe_settings.SUBSCRIBER_CUSTOMER_KEY = "" + customer = Customer.create(user) + djstripe_settings.SUBSCRIBER_CUSTOMER_KEY = "djstripe_subscriber" + + customer_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, email="", idempotency_key=None, metadata={} + ) + + self.assertEqual(customer.metadata, None) + + self.assert_fks( + customer, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Customer.default_source", + }, + ) + + @patch( + "stripe.Card.retrieve", + return_value=FAKE_CUSTOMER_II["default_source"], + autospec=True, + ) + def test_customer_sync_non_local_card(self, card_retrieve_mock): + fake_customer = deepcopy(FAKE_CUSTOMER_II) + fake_customer["id"] = fake_customer["sources"]["data"][0][ + "customer" + ] = "cus_test_sync_non_local_card" + + user = get_user_model().objects.create_user( + username="test_user_sync_non_local_card" + ) + customer = fake_customer.create_for_user(user) + + self.assertEqual(customer.sources.count(), 0) + self.assertEqual(customer.legacy_cards.count(), 1) + self.assertEqual( + customer.default_source.id, fake_customer["default_source"]["id"] + ) + + @patch("stripe.Customer.create", autospec=True) + def test_customer_sync_no_sources(self, customer_mock): + fake_customer = deepcopy(FAKE_CUSTOMER) + fake_customer["id"] = "cus_test_sync_no_sources" + fake_customer["default_source"] = None + fake_customer["sources"] = None + customer_mock.return_value = fake_customer + + user = get_user_model().objects.create_user( + username="test_user_sync_non_local_card" + ) + customer = Customer.create(user) + self.assertEqual( + customer_mock.call_args_list[0][1].get("metadata"), + {"djstripe_subscriber": user.pk}, + ) + + self.assertEqual(customer.sources.count(), 0) + self.assertEqual(customer.legacy_cards.count(), 0) + self.assertEqual(customer.default_source, None) + + self.assert_fks( + customer, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Customer.default_source", + }, + ) + + def test_customer_sync_default_source_string(self): + Customer.objects.all().delete() + Card.objects.all().delete() + customer_fake = deepcopy(FAKE_CUSTOMER) + customer_fake["default_source"] = customer_fake["sources"]["data"][0][ + "id" + ] = "card_sync_source_string" + customer = Customer.sync_from_stripe_data(customer_fake) + self.assertEqual(customer.default_source.id, customer_fake["default_source"]) + self.assertEqual(customer.legacy_cards.count(), 2) + self.assertEqual(len(list(customer.customer_payment_methods)), 2) + + self.assert_fks( + customer, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Customer.subscriber", + }, + ) + + @patch("stripe.Customer.retrieve", autospec=True) + def test_customer_purge_leaves_customer_record(self, customer_retrieve_fake): + self.customer.purge() + customer = Customer.objects.get(id=self.customer.id) + + self.assertTrue(customer.subscriber is None) + self.assertTrue(customer.default_source is None) + self.assertTrue(not customer.legacy_cards.all()) + self.assertTrue(not customer.sources.all()) + self.assertTrue(get_user_model().objects.filter(pk=self.user.pk).exists()) + + @patch("stripe.Customer.create", autospec=True) + def test_customer_purge_detaches_sources(self, customer_api_create_fake): + fake_customer = deepcopy(FAKE_CUSTOMER_III) + customer_api_create_fake.return_value = fake_customer + + user = get_user_model().objects.create_user( + username="blah", email=FAKE_CUSTOMER_III["email"] + ) + + Customer.get_or_create(user) + customer = Customer.sync_from_stripe_data(deepcopy(FAKE_CUSTOMER_III)) + + self.assertIsNotNone(customer.default_source) + self.assertNotEqual(customer.sources.count(), 0) + + with patch("stripe.Customer.retrieve", autospec=True), patch( + "stripe.Source.retrieve", return_value=deepcopy(FAKE_SOURCE), autospec=True + ): + customer.purge() + + self.assertIsNone(customer.default_source) + self.assertEqual(customer.sources.count(), 0) + + @patch( + "stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True + ) + def test_customer_purge_deletes_idempotency_key(self, customer_api_create_fake): + # We need to call Customer.get_or_create (which setUp doesn't) + # to get an idempotency key + user = get_user_model().objects.create_user( + username="blah", email=FAKE_CUSTOMER_II["email"] + ) + idempotency_key_action = "customer:create:{}".format(user.pk) + self.assertFalse( + IdempotencyKey.objects.filter(action=idempotency_key_action).exists() + ) + + customer, created = Customer.get_or_create(user) + self.assertTrue( + IdempotencyKey.objects.filter(action=idempotency_key_action).exists() + ) + + with patch("stripe.Customer.retrieve", autospec=True): + customer.purge() + + self.assertFalse( + IdempotencyKey.objects.filter(action=idempotency_key_action).exists() + ) + + @patch("stripe.Customer.retrieve", autospec=True) + def test_customer_delete_same_as_purge(self, customer_retrieve_fake): + self.customer.delete() + customer = Customer.objects.get(id=self.customer.id) + + self.assertTrue(customer.subscriber is None) + self.assertTrue(customer.default_source is None) + self.assertTrue(not customer.legacy_cards.all()) + self.assertTrue(not customer.sources.all()) + self.assertTrue(get_user_model().objects.filter(pk=self.user.pk).exists()) + + @patch("stripe.Customer.retrieve", autospec=True) + def test_customer_purge_raises_customer_exception(self, customer_retrieve_mock): + customer_retrieve_mock.side_effect = InvalidRequestError( + "No such customer:", "blah" + ) + + self.customer.purge() + customer = Customer.objects.get(id=self.customer.id) + self.assertTrue(customer.subscriber is None) + self.assertTrue(customer.default_source is None) + self.assertTrue(not customer.legacy_cards.all()) + self.assertTrue(not customer.sources.all()) + self.assertTrue(get_user_model().objects.filter(pk=self.user.pk).exists()) + + customer_retrieve_mock.assert_called_with( + id=self.customer.id, api_key=STRIPE_SECRET_KEY, expand=["default_source"] + ) + self.assertEqual(3, customer_retrieve_mock.call_count) + + @patch("stripe.Customer.retrieve", autospec=True) + def test_customer_delete_raises_unexpected_exception(self, customer_retrieve_mock): + customer_retrieve_mock.side_effect = InvalidRequestError( + "Unexpected Exception", "blah" + ) + + with self.assertRaisesMessage(InvalidRequestError, "Unexpected Exception"): + self.customer.purge() + + customer_retrieve_mock.assert_called_once_with( + id=self.customer.id, api_key=STRIPE_SECRET_KEY, expand=["default_source"] + ) + + def test_can_charge(self): + self.assertTrue(self.customer.can_charge()) + + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_add_card_set_default_true(self, customer_retrieve_mock): + self.customer.add_card(FAKE_CARD["id"]) + self.customer.add_card(FAKE_CARD_V["id"]) + + self.assertEqual(2, Card.objects.count()) + self.assertEqual(FAKE_CARD_V["id"], self.customer.default_source.id) + + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_add_card_set_default_false(self, customer_retrieve_mock): + self.customer.add_card(FAKE_CARD["id"], set_default=False) + self.customer.add_card(FAKE_CARD_V["id"], set_default=False) + + self.assertEqual(2, Card.objects.count()) + self.assertEqual(FAKE_CARD["id"], self.customer.default_source.id) + + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_add_card_set_default_false_with_single_card_still_becomes_default( + self, customer_retrieve_mock + ): + self.customer.add_card(FAKE_CARD["id"], set_default=False) + + self.assertEqual(2, Card.objects.count()) + self.assertEqual(FAKE_CARD["id"], self.customer.default_source.id) + + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch("stripe.PaymentMethod.attach", return_value=deepcopy(FAKE_PAYMENT_METHOD_I)) + def test_add_payment_method(self, customer_retrieve_mock, attach_mock): + self.assertEqual( + self.customer.payment_methods.filter( + id=FAKE_PAYMENT_METHOD_I["id"] + ).count(), + 0, + ) + + self.customer.add_payment_method(FAKE_PAYMENT_METHOD_I["id"]) + + self.assertEqual( + self.customer.payment_methods.filter( + id=FAKE_PAYMENT_METHOD_I["id"] + ).count(), + 1, + ) + + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_cannot_charge(self, customer_retrieve_fake): + self.customer.delete() + self.assertFalse(self.customer.can_charge()) + + def test_charge_accepts_only_decimals(self): + with self.assertRaises(ValueError): + self.customer.charge(10) + + @patch("stripe.Coupon.retrieve", return_value=deepcopy(FAKE_COUPON), autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_add_coupon_by_id(self, customer_retrieve_mock, coupon_retrieve_mock): + self.assertEqual(self.customer.coupon, None) + self.customer.add_coupon(FAKE_COUPON["id"]) + customer_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, expand=["default_source"], id=FAKE_CUSTOMER["id"] + ) + + @patch("stripe.Coupon.retrieve", return_value=deepcopy(FAKE_COUPON), autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_add_coupon_by_object(self, customer_retrieve_mock, coupon_retrieve_mock): + self.assertEqual(self.customer.coupon, None) + coupon = Coupon.sync_from_stripe_data(FAKE_COUPON) + fake_discount = deepcopy(FAKE_DISCOUNT_CUSTOMER) + + def fake_customer_save(self, *args, **kwargs): + # fake the api coupon update behaviour + coupon = self.pop("coupon", None) + if coupon: + self["discount"] = fake_discount + else: + self["discount"] = None + + return self + + with patch("tests.CustomerDict.save", new=fake_customer_save): + self.customer.add_coupon(coupon) + + customer_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, expand=["default_source"], id=FAKE_CUSTOMER["id"] + ) + + self.customer.refresh_from_db() + + self.assert_fks(self.customer, expected_blank_fks={}) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch("stripe.PaymentIntent.retrieve", autospec=True) + def test_refund_charge( + self, + payment_intent_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_no_invoice = deepcopy(FAKE_CHARGE) + fake_charge_no_invoice.update({"invoice": None}) + + charge_retrieve_mock.return_value = fake_charge_no_invoice + + fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) + fake_payment_intent.update({"invoice": None}) + + payment_intent_retrieve_mock.return_value = fake_payment_intent + + charge, created = Charge._get_or_create_from_stripe_object( + fake_charge_no_invoice + ) + self.assertTrue(created) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.invoice", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.invoice", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + }, + ) + + charge.refund() + + refunded_charge, created2 = Charge._get_or_create_from_stripe_object( + fake_charge_no_invoice + ) + self.assertFalse(created2) + + self.assertEqual(refunded_charge.refunded, True) + self.assertEqual(refunded_charge.amount_refunded, decimal.Decimal("20.00")) + + self.assert_fks( + refunded_charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.invoice", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.invoice", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch("stripe.PaymentIntent.retrieve", autospec=True) + def test_refund_charge_object_returned( + self, + payment_intent_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_no_invoice = deepcopy(FAKE_CHARGE) + fake_charge_no_invoice.update({"invoice": None}) + + charge_retrieve_mock.return_value = fake_charge_no_invoice + + fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) + fake_payment_intent.update({"invoice": None}) + + payment_intent_retrieve_mock.return_value = fake_payment_intent + + charge, created = Charge._get_or_create_from_stripe_object( + fake_charge_no_invoice + ) + self.assertTrue(created) + + self.assert_fks( + charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.invoice", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.invoice", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + }, + ) + + refunded_charge = charge.refund() + self.assertEqual(refunded_charge.refunded, True) + self.assertEqual(refunded_charge.amount_refunded, decimal.Decimal("20.00")) + + self.assert_fks( + refunded_charge, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.invoice", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.invoice", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + }, + ) + + def test_calculate_refund_amount_full_refund(self): + charge = Charge( + id="ch_111111", customer=self.customer, amount=decimal.Decimal("500.00") + ) + self.assertEqual(charge._calculate_refund_amount(), 50000) + + def test_calculate_refund_amount_partial_refund(self): + charge = Charge( + id="ch_111111", customer=self.customer, amount=decimal.Decimal("500.00") + ) + self.assertEqual( + charge._calculate_refund_amount(amount=decimal.Decimal("300.00")), 30000 + ) + + def test_calculate_refund_above_max_refund(self): + charge = Charge( + id="ch_111111", customer=self.customer, amount=decimal.Decimal("500.00") + ) + self.assertEqual( + charge._calculate_refund_amount(amount=decimal.Decimal("600.00")), 50000 + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch("stripe.Charge.create", autospec=True) + @patch("stripe.PaymentIntent.retrieve", autospec=True) + def test_charge_converts_dollars_into_cents( + self, + payment_intent_retrieve_mock, + charge_create_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update({"invoice": None, "amount": 1000}) + + charge_create_mock.return_value = fake_charge_copy + charge_retrieve_mock.return_value = fake_charge_copy + + fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) + fake_payment_intent.update({"invoice": None}) + + payment_intent_retrieve_mock.return_value = fake_payment_intent + + self.customer.charge(amount=decimal.Decimal("10.00")) + + _, kwargs = charge_create_mock.call_args + self.assertEqual(kwargs["amount"], 1000) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch("stripe.Charge.create", autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch("stripe.Invoice.retrieve", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + def test_charge_doesnt_require_invoice( + self, + subscription_retrieve_mock, + product_retrieve_mock, + invoice_retrieve_mock, + payment_intent_retrieve_mock, + charge_create_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update( + {"invoice": FAKE_INVOICE["id"], "amount": FAKE_INVOICE["amount_due"]} + ) + fake_invoice_copy = deepcopy(FAKE_INVOICE) + + charge_create_mock.return_value = fake_charge_copy + charge_retrieve_mock.return_value = fake_charge_copy + invoice_retrieve_mock.return_value = fake_invoice_copy + + try: + self.customer.charge(amount=decimal.Decimal("20.00")) + except Invoice.DoesNotExist: + self.fail(msg="Stripe Charge shouldn't throw Invoice DoesNotExist.") + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch("stripe.Charge.create", autospec=True) + @patch("stripe.PaymentIntent.retrieve", autospec=True) + def test_charge_passes_extra_arguments( + self, + payment_intent_retrieve_mock, + charge_create_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update({"invoice": None}) + + charge_create_mock.return_value = fake_charge_copy + charge_retrieve_mock.return_value = fake_charge_copy + + fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) + fake_payment_intent.update({"invoice": None}) + + payment_intent_retrieve_mock.return_value = fake_payment_intent + + self.customer.charge( + amount=decimal.Decimal("10.00"), + capture=True, + destination=FAKE_ACCOUNT["id"], + ) + + _, kwargs = charge_create_mock.call_args + self.assertEqual(kwargs["capture"], True) + self.assertEqual(kwargs["destination"], FAKE_ACCOUNT["id"]) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch("stripe.Charge.create", autospec=True) + @patch("stripe.PaymentIntent.retrieve", autospec=True) + def test_charge_string_source( + self, + payment_intent_retrieve_mock, + charge_create_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update({"invoice": None}) + + charge_create_mock.return_value = fake_charge_copy + charge_retrieve_mock.return_value = fake_charge_copy + + fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) + fake_payment_intent.update({"invoice": None}) + + payment_intent_retrieve_mock.return_value = fake_payment_intent + + self.customer.charge(amount=decimal.Decimal("10.00"), source=self.card.id) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch("stripe.Charge.create", autospec=True) + @patch("stripe.PaymentIntent.retrieve", autospec=True) + def test_charge_card_source( + self, + payment_intent_retrieve_mock, + charge_create_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + fake_charge_copy = deepcopy(FAKE_CHARGE) + fake_charge_copy.update({"invoice": None}) + + charge_create_mock.return_value = fake_charge_copy + charge_retrieve_mock.return_value = fake_charge_copy + + fake_payment_intent = deepcopy(FAKE_PAYMENT_INTENT_I) + fake_payment_intent.update({"invoice": None}) + + payment_intent_retrieve_mock.return_value = fake_payment_intent + + self.customer.charge(amount=decimal.Decimal("10.00"), source=self.card) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Invoice.list", + return_value=StripeList( + data=[deepcopy(FAKE_INVOICE), deepcopy(FAKE_INVOICE_III)] + ), + autospec=True, + ) + @patch("djstripe.models.Invoice.retry", autospec=True) + def test_retry_unpaid_invoices( + self, + invoice_retry_mock, + invoice_list_mock, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + self.customer.retry_unpaid_invoices() + + invoice = Invoice.objects.get(id=FAKE_INVOICE_III["id"]) + invoice_retry_mock.assert_called_once_with(invoice) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Invoice.list", + return_value=StripeList(data=[deepcopy(FAKE_INVOICE)]), + autospec=True, + ) + @patch("djstripe.models.Invoice.retry", autospec=True) + def test_retry_unpaid_invoices_none_unpaid( + self, + invoice_retry_mock, + invoice_list_mock, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + self.customer.retry_unpaid_invoices() + + self.assertFalse(invoice_retry_mock.called) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Invoice.list", + return_value=StripeList(data=[deepcopy(FAKE_INVOICE_III)]), + ) + @patch("djstripe.models.Invoice.retry", autospec=True) + def test_retry_unpaid_invoices_expected_exception( + self, + invoice_retry_mock, + invoice_list_mock, + product_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + invoice_retry_mock.side_effect = InvalidRequestError( + "Invoice is already paid", "blah" + ) + + try: + self.customer.retry_unpaid_invoices() + except Exception: + self.fail("Exception was unexpectedly raised.") + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Invoice.list", + return_value=StripeList(data=[deepcopy(FAKE_INVOICE_III)]), + ) + @patch("djstripe.models.Invoice.retry", autospec=True) + def test_retry_unpaid_invoices_unexpected_exception( + self, + invoice_retry_mock, + invoice_list_mock, + product_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + invoice_retry_mock.side_effect = InvalidRequestError( + "This should fail!", "blah" + ) + + with self.assertRaisesMessage(InvalidRequestError, "This should fail!"): + self.customer.retry_unpaid_invoices() + + @patch("stripe.Invoice.create", autospec=True) + def test_send_invoice_success(self, invoice_create_mock): + return_status = self.customer.send_invoice() + self.assertTrue(return_status) + + invoice_create_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, customer=self.customer.id + ) + + @patch("stripe.Invoice.create", autospec=True) + def test_send_invoice_failure(self, invoice_create_mock): + invoice_create_mock.side_effect = InvalidRequestError( + "Invoice creation failed.", "blah" + ) + + return_status = self.customer.send_invoice() + self.assertFalse(return_status) + + invoice_create_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, customer=self.customer.id + ) + + @patch("stripe.Coupon.retrieve", return_value=deepcopy(FAKE_COUPON), autospec=True) + def test_sync_customer_with_discount(self, coupon_retrieve_mock): + self.assertIsNone(self.customer.coupon) + fake_customer = deepcopy(FAKE_CUSTOMER) + fake_customer["discount"] = deepcopy(FAKE_DISCOUNT_CUSTOMER) + customer = Customer.sync_from_stripe_data(fake_customer) + self.assertEqual(customer.coupon.id, FAKE_COUPON["id"]) + self.assertIsNotNone(customer.coupon_start) + self.assertIsNone(customer.coupon_end) + + @patch("stripe.Coupon.retrieve", return_value=deepcopy(FAKE_COUPON), autospec=True) + def test_sync_customer_discount_already_present(self, coupon_retrieve_mock): + fake_customer = deepcopy(FAKE_CUSTOMER) + fake_customer["discount"] = deepcopy(FAKE_DISCOUNT_CUSTOMER) + + # Set the customer's coupon to be what we'll sync + customer = Customer.objects.get(id=FAKE_CUSTOMER["id"]) + customer.coupon = Coupon.sync_from_stripe_data(FAKE_COUPON) + customer.save() + + customer = Customer.sync_from_stripe_data(fake_customer) + self.assertEqual(customer.coupon.id, FAKE_COUPON["id"]) + + def test_sync_customer_delete_discount(self): + test_coupon = Coupon.sync_from_stripe_data(FAKE_COUPON) + self.customer.coupon = test_coupon + self.customer.save() + self.assertEqual(self.customer.coupon.id, FAKE_COUPON["id"]) + + customer = Customer.sync_from_stripe_data(FAKE_CUSTOMER) + self.assertEqual(customer.coupon, None) + + @patch( + "djstripe.models.Invoice.sync_from_stripe_data", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Invoice.list", + return_value=StripeList( + data=[deepcopy(FAKE_INVOICE), deepcopy(FAKE_INVOICE_III)] + ), + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_sync_invoices( + self, customer_retrieve_mock, invoice_list_mock, invoice_sync_mock + ): + self.customer._sync_invoices() + self.assertEqual(2, invoice_sync_mock.call_count) + + @patch( + "djstripe.models.Invoice.sync_from_stripe_data", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch("stripe.Invoice.list", return_value=StripeList(data=[]), autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_sync_invoices_none( + self, customer_retrieve_mock, invoice_list_mock, invoice_sync_mock + ): + self.customer._sync_invoices() + self.assertEqual(0, invoice_sync_mock.call_count) + + @patch( + "djstripe.models.Charge.sync_from_stripe_data", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Charge.list", + return_value=StripeList(data=[deepcopy(FAKE_CHARGE)]), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_sync_charges( + self, customer_retrieve_mock, charge_list_mock, charge_sync_mock + ): + self.customer._sync_charges() + self.assertEqual(1, charge_sync_mock.call_count) + + @patch( + "djstripe.models.Charge.sync_from_stripe_data", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch("stripe.Charge.list", return_value=StripeList(data=[]), autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_sync_charges_none( + self, customer_retrieve_mock, charge_list_mock, charge_sync_mock + ): + self.customer._sync_charges() + self.assertEqual(0, charge_sync_mock.call_count) + + @patch( + "djstripe.models.Subscription.sync_from_stripe_data", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Subscription.list", + return_value=StripeList( + data=[deepcopy(FAKE_SUBSCRIPTION), deepcopy(FAKE_SUBSCRIPTION_II)] + ), + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_sync_subscriptions( + self, customer_retrieve_mock, subscription_list_mock, subscription_sync_mock + ): + self.customer._sync_subscriptions() + self.assertEqual(2, subscription_sync_mock.call_count) + + @patch( + "djstripe.models.Subscription.sync_from_stripe_data", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch("stripe.Subscription.list", return_value=StripeList(data=[]), autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_sync_subscriptions_none( + self, customer_retrieve_mock, subscription_list_mock, subscription_sync_mock + ): + self.customer._sync_subscriptions() + self.assertEqual(0, subscription_sync_mock.call_count) + + @patch("djstripe.models.Customer.send_invoice", autospec=True) + @patch( + "stripe.Subscription.create", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_subscribe_not_charge_immediately( + self, + product_retrieve_mock, + customer_retrieve_mock, + subscription_create_mock, + send_invoice_mock, + ): + plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + self.customer.subscribe(plan=plan, charge_immediately=False) + self.assertFalse(send_invoice_mock.called) + + @patch("djstripe.models.Customer.send_invoice", autospec=True) + @patch( + "stripe.Subscription.create", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_subscribe_charge_immediately( + self, + product_retrieve_mock, + customer_retrieve_mock, + subscription_create_mock, + send_invoice_mock, + ): + plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + self.assert_fks(plan, expected_blank_fks={}) + + self.customer.subscribe(plan=plan, charge_immediately=True) + self.assertTrue(send_invoice_mock.called) + + @patch("djstripe.models.Customer.send_invoice", autospec=True) + @patch( + "stripe.Subscription.create", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_subscribe_plan_string( + self, + product_retrieve_mock, + customer_retrieve_mock, + subscription_create_mock, + send_invoice_mock, + ): + plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + self.assert_fks(plan, expected_blank_fks={}) + + self.customer.subscribe(plan=plan.id, charge_immediately=True) + self.assertTrue(send_invoice_mock.called) + + @patch("stripe.Subscription.create", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_subscription_shortcut_with_multiple_subscriptions( + self, product_retrieve_mock, customer_retrieve_mock, subscription_create_mock + ): + plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + self.assert_fks(plan, expected_blank_fks={}) + + subscription_fake_duplicate = deepcopy(FAKE_SUBSCRIPTION) + subscription_fake_duplicate["id"] = "sub_6lsC8pt7IcF8jd" + + subscription_create_mock.side_effect = [ + deepcopy(FAKE_SUBSCRIPTION), + subscription_fake_duplicate, + ] + + self.customer.subscribe(plan=plan, charge_immediately=False) + self.customer.subscribe(plan=plan, charge_immediately=False) + + self.assertEqual(2, self.customer.subscriptions.count()) + + with self.assertRaises(MultipleSubscriptionException): + self.customer.subscription + + @patch("stripe.Subscription.create", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_has_active_subscription_with_unspecified_plan_with_multiple_subscriptions( + self, product_retrieve_mock, customer_retrieve_mock, subscription_create_mock + ): + plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + self.assert_fks(plan, expected_blank_fks={}) + + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription_fake["current_period_end"] = datetime_to_unix( + timezone.now() + timezone.timedelta(days=7) + ) + + subscription_fake_duplicate = deepcopy(FAKE_SUBSCRIPTION) + subscription_fake_duplicate["current_period_end"] = datetime_to_unix( + timezone.now() + timezone.timedelta(days=7) + ) + subscription_fake_duplicate["id"] = "sub_6lsC8pt7IcF8jd" + + subscription_create_mock.side_effect = [ + subscription_fake, + subscription_fake_duplicate, + ] + + self.customer.subscribe(plan=plan, charge_immediately=False) + self.customer.subscribe(plan=plan, charge_immediately=False) + + self.assertEqual(2, self.customer.subscriptions.count()) + + with self.assertRaises(TypeError): + self.customer.has_active_subscription() + + @patch("stripe.Subscription.create", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_has_active_subscription_with_plan( + self, product_retrieve_mock, customer_retrieve_mock, subscription_create_mock + ): + plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription_fake["current_period_end"] = datetime_to_unix( + timezone.now() + timezone.timedelta(days=7) + ) + + subscription_create_mock.return_value = subscription_fake + + self.customer.subscribe(plan=plan, charge_immediately=False) + + self.customer.has_active_subscription(plan=plan) + + @patch("stripe.Subscription.create", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_has_active_subscription_with_plan_string( + self, product_retrieve_mock, customer_retrieve_mock, subscription_create_mock + ): + plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription_fake["current_period_end"] = datetime_to_unix( + timezone.now() + timezone.timedelta(days=7) + ) + + subscription_create_mock.return_value = subscription_fake + + self.customer.subscribe(plan=plan, charge_immediately=False) + + self.customer.has_active_subscription(plan=plan.id) + + @patch( + "djstripe.models.InvoiceItem.sync_from_stripe_data", + return_value="pancakes", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.InvoiceItem.create", + return_value=deepcopy(FAKE_INVOICEITEM), + autospec=True, + ) + def test_add_invoice_item(self, invoiceitem_create_mock, invoiceitem_sync_mock): + invoiceitem = self.customer.add_invoice_item( + amount=decimal.Decimal("50.00"), + currency="eur", + description="test", + invoice=77, + subscription=25, + ) + self.assertEqual("pancakes", invoiceitem) + + invoiceitem_create_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, + amount=5000, + customer=self.customer.id, + currency="eur", + description="test", + discountable=None, + invoice=77, + metadata=None, + subscription=25, + ) + + @patch( + "djstripe.models.InvoiceItem.sync_from_stripe_data", + return_value="pancakes", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.InvoiceItem.create", + return_value=deepcopy(FAKE_INVOICEITEM), + autospec=True, + ) + def test_add_invoice_item_djstripe_objects( + self, invoiceitem_create_mock, invoiceitem_sync_mock + ): + invoiceitem = self.customer.add_invoice_item( + amount=decimal.Decimal("50.00"), + currency="eur", + description="test", + invoice=Invoice(id=77), + subscription=Subscription(id=25), + ) + self.assertEqual("pancakes", invoiceitem) + + invoiceitem_create_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, + amount=5000, + customer=self.customer.id, + currency="eur", + description="test", + discountable=None, + invoice=77, + metadata=None, + subscription=25, + ) + + def test_add_invoice_item_bad_decimal(self): + with self.assertRaisesMessage( + ValueError, "You must supply a decimal value representing dollars." + ): + self.customer.add_invoice_item(amount=5000, currency="usd") + + @patch( + "stripe.Plan.retrieve", + return_value=deepcopy(FAKE_PLAN), + autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Invoice.upcoming", + return_value=deepcopy(FAKE_UPCOMING_INVOICE), + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + def test_upcoming_invoice( + self, + invoice_upcoming_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + invoice = self.customer.upcoming_invoice() + self.assertIsNotNone(invoice) + self.assertIsNone(invoice.id) + self.assertIsNone(invoice.save()) + + subscription_retrieve_mock.assert_called_once_with( + api_key=ANY, expand=ANY, id=FAKE_SUBSCRIPTION["id"] + ) + plan_retrieve_mock.assert_not_called() + + items = invoice.invoiceitems.all() + self.assertEqual(1, len(items)) + self.assertEqual(FAKE_SUBSCRIPTION["id"], items[0].id) + + self.assertIsNotNone(invoice.plan) + self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) + + invoice._invoiceitems = [] + items = invoice.invoiceitems.all() + self.assertEqual(0, len(items)) + self.assertIsNotNone(invoice.plan) + + @patch("stripe.Customer.retrieve", autospec=True) + def test_delete_subscriber_purges_customer(self, customer_retrieve_mock): + self.user.delete() + customer = Customer.objects.get(id=FAKE_CUSTOMER["id"]) + self.assertIsNotNone(customer.date_purged) + + @patch("stripe.Customer.retrieve", autospec=True) + def test_delete_subscriber_without_customer_is_noop(self, customer_retrieve_mock): + self.user.delete() + for customer in self.user.djstripe_customers.all(): + self.assertIsNone(customer.date_purged) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 086e05e4d9..0336e5fe73 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -18,51 +18,53 @@ class TestSubscriptionPaymentRequired(TestCase): - def setUp(self): - self.settings(ROOT_URLCONF="tests.urls") - self.factory = RequestFactory() - - @subscription_payment_required - def test_view(request): - return HttpResponse() - - self.test_view = test_view - - def test_direct(self): - subscription_payment_required(function=None) - - def test_anonymous(self): - request = self.factory.get("/account/") - request.user = AnonymousUser() - - with self.assertRaises(ImproperlyConfigured): - self.test_view(request) - - def test_user_unpaid(self): - user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - FAKE_CUSTOMER.create_for_user(user) - - request = self.factory.get("/account/") - request.user = user - - response = self.test_view(request) - self.assertEqual(response.status_code, 302) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_user_active_subscription(self, product_retrieve_mock, plan_retrieve_mock): - user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - FAKE_CUSTOMER.create_for_user(user) - subscription = Subscription.sync_from_stripe_data(deepcopy(FAKE_SUBSCRIPTION)) - subscription.current_period_end = FUTURE_DATE - subscription.save() - - request = self.factory.get("/account/") - request.user = user - - response = self.test_view(request) - self.assertEqual(response.status_code, 200) + def setUp(self): + self.settings(ROOT_URLCONF="tests.urls") + self.factory = RequestFactory() + + @subscription_payment_required + def test_view(request): + return HttpResponse() + + self.test_view = test_view + + def test_direct(self): + subscription_payment_required(function=None) + + def test_anonymous(self): + request = self.factory.get("/account/") + request.user = AnonymousUser() + + with self.assertRaises(ImproperlyConfigured): + self.test_view(request) + + def test_user_unpaid(self): + user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + FAKE_CUSTOMER.create_for_user(user) + + request = self.factory.get("/account/") + request.user = user + + response = self.test_view(request) + self.assertEqual(response.status_code, 302) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_user_active_subscription(self, product_retrieve_mock, plan_retrieve_mock): + user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + FAKE_CUSTOMER.create_for_user(user) + subscription = Subscription.sync_from_stripe_data(deepcopy(FAKE_SUBSCRIPTION)) + subscription.current_period_end = FUTURE_DATE + subscription.save() + + request = self.factory.get("/account/") + request.user = user + + response = self.test_view(request) + self.assertEqual(response.status_code, 200) diff --git a/tests/test_django.py b/tests/test_django.py index 4aa6096ccf..51ec4ad393 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -3,5 +3,5 @@ class TestRunManagePyCheck(TestCase): - def test_manage_py_check(self): - call_command("check") + def test_manage_py_check(self): + call_command("check") diff --git a/tests/test_enums.py b/tests/test_enums.py index 88bae81d17..7317476124 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -6,6 +6,6 @@ class TestEnumMetaClass(TestCase): - def test_python2_prepare(self): - # Python 2 hack to ensure __prepare__ is called... - self.assertEqual(EnumMetaClass.__prepare__(None, None), OrderedDict()) + def test_python2_prepare(self): + # Python 2 hack to ensure __prepare__ is called... + self.assertEqual(EnumMetaClass.__prepare__(None, None), OrderedDict()) diff --git a/tests/test_event.py b/tests/test_event.py index fe80462417..3bc754cdfb 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -15,157 +15,172 @@ class EventTest(TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - patcher = patch.object(webhooks, "call_handlers") - self.addCleanup(patcher.stop) - self.call_handlers = patcher.start() - - def test_str(self): - event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) - - self.assertEqual( - "".format( - type=FAKE_EVENT_TRANSFER_CREATED["type"], id=FAKE_EVENT_TRANSFER_CREATED["id"] - ), - str(event), - ) - - def test_invoke_webhook_handlers_event_with_log_stripe_error(self): - event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) - self.call_handlers.side_effect = StripeError("Boom!") - with self.assertRaises(StripeError): - event.invoke_webhook_handlers() - - def test_invoke_webhook_handlers_event_with_raise_stripe_error(self): - event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) - self.call_handlers.side_effect = StripeError("Boom!") - with self.assertRaises(StripeError): - event.invoke_webhook_handlers() - - def test_invoke_webhook_handlers_event_when_invalid(self): - event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) - event.valid = False - event.invoke_webhook_handlers() - - @patch(target="djstripe.models.core.transaction.atomic", autospec=True) - @patch.object(target=Event, attribute="_create_from_stripe_object", autospec=True) - @patch.object(target=Event, attribute="objects", autospec=True) - def test_process_event( - self, mock_objects, mock__create_from_stripe_object, mock_atomic - ): - """Test that process event creates a new event and invokes webhooks - when the event doesn't already exist. - """ - # Set up mocks - mock_objects.filter.return_value.exists.return_value = False - mock_data = {"id": "foo_id", "other_stuff": "more_things"} - - result = Event.process(data=mock_data) - - # Check that all the expected work was performed - mock_objects.filter.assert_called_once_with(id=mock_data["id"]) - mock_objects.filter.return_value.exists.assert_called_once_with() - mock_atomic.return_value.__enter__.assert_called_once_with() - mock__create_from_stripe_object.assert_called_once_with(mock_data) - mock__create_from_stripe_object.return_value.invoke_webhook_handlers.assert_called_once_with() - # Make sure the event was returned. - self.assertEqual(mock__create_from_stripe_object.return_value, result) - - @patch(target="djstripe.models.core.transaction.atomic", autospec=True) - @patch.object(target=Event, attribute="_create_from_stripe_object", autospec=True) - @patch.object(target=Event, attribute="objects", autospec=True) - def test_process_event_exists( - self, mock_objects, mock__create_from_stripe_object, mock_atomic - ): - """Test that process event returns the existing event and skips webhook processing - when the event already exists. - """ - # Set up mocks - mock_objects.filter.return_value.exists.return_value = True - mock_data = {"id": "foo_id", "other_stuff": "more_things"} - - result = Event.process(data=mock_data) - - # Make sure that the db was queried and the existing results used. - mock_objects.filter.assert_called_once_with(id=mock_data["id"]) - mock_objects.filter.return_value.exists.assert_called_once_with() - mock_objects.filter.return_value.first.assert_called_once_with() - # Make sure the webhook actions and event object creation were not performed. - mock_atomic.return_value.__enter__.assert_not_called() - # Using assert_not_called() doesn't work on this in Python 3.5 - self.assertEqual(mock__create_from_stripe_object.call_count, 0) - mock__create_from_stripe_object.return_value.invoke_webhook_handlers.assert_not_called() - # Make sure the existing event was returned. - self.assertEqual(mock_objects.filter.return_value.first.return_value, result) - - @patch("djstripe.models.Event.invoke_webhook_handlers", autospec=True) - def test_process_event_failure_rolls_back(self, invoke_webhook_handlers_mock): - """Test that process event rolls back event creation on error - """ - - class HandlerException(Exception): - pass - - invoke_webhook_handlers_mock.side_effect = HandlerException - real_create_from_stripe_object = Event._create_from_stripe_object - - def side_effect(*args, **kwargs): - return real_create_from_stripe_object(*args, **kwargs) - - event_data = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - - self.assertFalse(Event.objects.filter(id=FAKE_EVENT_TRANSFER_CREATED["id"]).exists()) - - with self.assertRaises(HandlerException), patch( - "djstripe.models.Event._create_from_stripe_object", - side_effect=side_effect, - autospec=True, - ) as create_from_stripe_object_mock: - Event.process(data=event_data) - - create_from_stripe_object_mock.assert_called_once_with(event_data) - self.assertFalse(Event.objects.filter(id=FAKE_EVENT_TRANSFER_CREATED["id"]).exists()) - - # - # Helpers - # - - @patch("stripe.Event.retrieve", autospec=True) - def _create_event(self, event_data, event_retrieve_mock): - event_data = deepcopy(event_data) - event_retrieve_mock.return_value = event_data - event = Event.sync_from_stripe_data(event_data) - return event + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + patcher = patch.object(webhooks, "call_handlers") + self.addCleanup(patcher.stop) + self.call_handlers = patcher.start() + + def test_str(self): + event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) + + self.assertEqual( + "".format( + type=FAKE_EVENT_TRANSFER_CREATED["type"], + id=FAKE_EVENT_TRANSFER_CREATED["id"], + ), + str(event), + ) + + def test_invoke_webhook_handlers_event_with_log_stripe_error(self): + event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) + self.call_handlers.side_effect = StripeError("Boom!") + with self.assertRaises(StripeError): + event.invoke_webhook_handlers() + + def test_invoke_webhook_handlers_event_with_raise_stripe_error(self): + event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) + self.call_handlers.side_effect = StripeError("Boom!") + with self.assertRaises(StripeError): + event.invoke_webhook_handlers() + + def test_invoke_webhook_handlers_event_when_invalid(self): + event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) + event.valid = False + event.invoke_webhook_handlers() + + @patch(target="djstripe.models.core.transaction.atomic", autospec=True) + @patch.object(target=Event, attribute="_create_from_stripe_object", autospec=True) + @patch.object(target=Event, attribute="objects", autospec=True) + def test_process_event( + self, mock_objects, mock__create_from_stripe_object, mock_atomic + ): + """Test that process event creates a new event and invokes webhooks + when the event doesn't already exist. + """ + # Set up mocks + mock_objects.filter.return_value.exists.return_value = False + mock_data = {"id": "foo_id", "other_stuff": "more_things"} + + result = Event.process(data=mock_data) + + # Check that all the expected work was performed + mock_objects.filter.assert_called_once_with(id=mock_data["id"]) + mock_objects.filter.return_value.exists.assert_called_once_with() + mock_atomic.return_value.__enter__.assert_called_once_with() + mock__create_from_stripe_object.assert_called_once_with(mock_data) + ( + mock__create_from_stripe_object.return_value.invoke_webhook_handlers + ).assert_called_once_with() + # Make sure the event was returned. + self.assertEqual(mock__create_from_stripe_object.return_value, result) + + @patch(target="djstripe.models.core.transaction.atomic", autospec=True) + @patch.object(target=Event, attribute="_create_from_stripe_object", autospec=True) + @patch.object(target=Event, attribute="objects", autospec=True) + def test_process_event_exists( + self, mock_objects, mock__create_from_stripe_object, mock_atomic + ): + """ + Test that process event returns the existing event and skips webhook processing + when the event already exists. + """ + # Set up mocks + mock_objects.filter.return_value.exists.return_value = True + mock_data = {"id": "foo_id", "other_stuff": "more_things"} + + result = Event.process(data=mock_data) + + # Make sure that the db was queried and the existing results used. + mock_objects.filter.assert_called_once_with(id=mock_data["id"]) + mock_objects.filter.return_value.exists.assert_called_once_with() + mock_objects.filter.return_value.first.assert_called_once_with() + # Make sure the webhook actions and event object creation were not performed. + mock_atomic.return_value.__enter__.assert_not_called() + # Using assert_not_called() doesn't work on this in Python 3.5 + self.assertEqual(mock__create_from_stripe_object.call_count, 0) + ( + mock__create_from_stripe_object.return_value.invoke_webhook_handlers + ).assert_not_called() + # Make sure the existing event was returned. + self.assertEqual(mock_objects.filter.return_value.first.return_value, result) + + @patch("djstripe.models.Event.invoke_webhook_handlers", autospec=True) + def test_process_event_failure_rolls_back(self, invoke_webhook_handlers_mock): + """Test that process event rolls back event creation on error + """ + + class HandlerException(Exception): + pass + + invoke_webhook_handlers_mock.side_effect = HandlerException + real_create_from_stripe_object = Event._create_from_stripe_object + + def side_effect(*args, **kwargs): + return real_create_from_stripe_object(*args, **kwargs) + + event_data = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + + self.assertFalse( + Event.objects.filter(id=FAKE_EVENT_TRANSFER_CREATED["id"]).exists() + ) + + with self.assertRaises(HandlerException), patch( + "djstripe.models.Event._create_from_stripe_object", + side_effect=side_effect, + autospec=True, + ) as create_from_stripe_object_mock: + Event.process(data=event_data) + + create_from_stripe_object_mock.assert_called_once_with(event_data) + self.assertFalse( + Event.objects.filter(id=FAKE_EVENT_TRANSFER_CREATED["id"]).exists() + ) + + # + # Helpers + # + + @patch("stripe.Event.retrieve", autospec=True) + def _create_event(self, event_data, event_retrieve_mock): + event_data = deepcopy(event_data) + event_retrieve_mock.return_value = event_data + event = Event.sync_from_stripe_data(event_data) + return event class EventRaceConditionTest(TestCase): - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - def test_process_event_race_condition(self, transfer_retrieve_mock): - transfer = Transfer.sync_from_stripe_data(deepcopy(FAKE_TRANSFER)) - transfer_retrieve_mock.reset_mock() - event_data = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - - # emulate the race condition in _get_or_create_from_stripe_object where an object is created - # by a different request during the call - # - # Sequence of events: - # 1) first Transfer.stripe_objects.get fails with DoesNotExist (due to it not existing in reality, - # but due to our side_effect in the test) - # 2) object is really created by a different request in reality - # 3) Transfer._create_from_stripe_object fails with IntegrityError due to duplicate id - # 4) second Transfer.stripe_objects.get succeeds (due to being created by step 2 in reality, due to - # side effect in the test) - side_effect = [Transfer.DoesNotExist(), transfer] - - with patch( - "djstripe.models.Transfer.stripe_objects.get", side_effect=side_effect, autospec=True - ) as transfer_objects_get_mock: - Event.process(event_data) - - self.assertEqual(transfer_objects_get_mock.call_count, 2) - self.assertEqual(transfer_retrieve_mock.call_count, 1) + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + def test_process_event_race_condition(self, transfer_retrieve_mock): + transfer = Transfer.sync_from_stripe_data(deepcopy(FAKE_TRANSFER)) + transfer_retrieve_mock.reset_mock() + event_data = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + + # emulate the race condition in _get_or_create_from_stripe_object where + # an object is created by a different request during the call + # + # Sequence of events: + # 1) first Transfer.stripe_objects.get fails with DoesNotExist + # (due to it not existing in reality, but due to our side_effect in the test) + # 2) object is really created by a different request in reality + # 3) Transfer._create_from_stripe_object fails with IntegrityError due to + # duplicate id + # 4) second Transfer.stripe_objects.get succeeds + # (due to being created by step 2 in reality, due to side effect in the test) + side_effect = [Transfer.DoesNotExist(), transfer] + + with patch( + "djstripe.models.Transfer.stripe_objects.get", + side_effect=side_effect, + autospec=True, + ) as transfer_objects_get_mock: + Event.process(event_data) + + self.assertEqual(transfer_objects_get_mock.call_count, 2) + self.assertEqual(transfer_retrieve_mock.call_count, 1) diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py index 60992bd3cc..2eba422026 100644 --- a/tests/test_event_handlers.py +++ b/tests/test_event_handlers.py @@ -9,662 +9,763 @@ from django.test import TestCase from djstripe.models import ( - Card, Charge, Coupon, Customer, Dispute, DjstripePaymentMethod, - Event, Invoice, InvoiceItem, Plan, Subscription, Transfer + Card, + Charge, + Coupon, + Customer, + Dispute, + DjstripePaymentMethod, + Event, + Invoice, + InvoiceItem, + Plan, + Subscription, + Transfer, ) from . import ( - FAKE_BALANCE_TRANSACTION, FAKE_CARD, FAKE_CHARGE, FAKE_CHARGE_II, FAKE_COUPON, - FAKE_CUSTOMER, FAKE_CUSTOMER_II, FAKE_DISPUTE, - FAKE_EVENT_ACCOUNT_APPLICATION_DEAUTHORIZED, FAKE_EVENT_CHARGE_SUCCEEDED, - FAKE_EVENT_CUSTOMER_CREATED, FAKE_EVENT_CUSTOMER_DELETED, - FAKE_EVENT_CUSTOMER_DISCOUNT_CREATED, FAKE_EVENT_CUSTOMER_DISCOUNT_DELETED, - FAKE_EVENT_CUSTOMER_SOURCE_CREATED, FAKE_EVENT_CUSTOMER_SOURCE_DELETED, - FAKE_EVENT_CUSTOMER_SOURCE_DELETED_DUPE, FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED, - FAKE_EVENT_CUSTOMER_SUBSCRIPTION_DELETED, FAKE_EVENT_DISPUTE_CREATED, - FAKE_EVENT_INVOICE_CREATED, FAKE_EVENT_INVOICE_DELETED, FAKE_EVENT_INVOICE_UPCOMING, - FAKE_EVENT_INVOICEITEM_CREATED, FAKE_EVENT_INVOICEITEM_DELETED, - FAKE_EVENT_PLAN_CREATED, FAKE_EVENT_PLAN_DELETED, FAKE_EVENT_PLAN_REQUEST_IS_OBJECT, - FAKE_EVENT_TRANSFER_CREATED, FAKE_EVENT_TRANSFER_DELETED, FAKE_INVOICE, - FAKE_INVOICE_II, FAKE_INVOICEITEM, FAKE_PAYMENT_INTENT_I, FAKE_PLAN, FAKE_PRODUCT, - FAKE_SUBSCRIPTION, FAKE_SUBSCRIPTION_III, FAKE_TRANSFER, - IS_STATICMETHOD_AUTOSPEC_SUPPORTED, default_account + FAKE_BALANCE_TRANSACTION, + FAKE_CARD, + FAKE_CHARGE, + FAKE_CHARGE_II, + FAKE_COUPON, + FAKE_CUSTOMER, + FAKE_CUSTOMER_II, + FAKE_DISPUTE, + FAKE_EVENT_ACCOUNT_APPLICATION_DEAUTHORIZED, + FAKE_EVENT_CHARGE_SUCCEEDED, + FAKE_EVENT_CUSTOMER_CREATED, + FAKE_EVENT_CUSTOMER_DELETED, + FAKE_EVENT_CUSTOMER_DISCOUNT_CREATED, + FAKE_EVENT_CUSTOMER_DISCOUNT_DELETED, + FAKE_EVENT_CUSTOMER_SOURCE_CREATED, + FAKE_EVENT_CUSTOMER_SOURCE_DELETED, + FAKE_EVENT_CUSTOMER_SOURCE_DELETED_DUPE, + FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED, + FAKE_EVENT_CUSTOMER_SUBSCRIPTION_DELETED, + FAKE_EVENT_DISPUTE_CREATED, + FAKE_EVENT_INVOICE_CREATED, + FAKE_EVENT_INVOICE_DELETED, + FAKE_EVENT_INVOICE_UPCOMING, + FAKE_EVENT_INVOICEITEM_CREATED, + FAKE_EVENT_INVOICEITEM_DELETED, + FAKE_EVENT_PLAN_CREATED, + FAKE_EVENT_PLAN_DELETED, + FAKE_EVENT_PLAN_REQUEST_IS_OBJECT, + FAKE_EVENT_TRANSFER_CREATED, + FAKE_EVENT_TRANSFER_DELETED, + FAKE_INVOICE, + FAKE_INVOICE_II, + FAKE_INVOICEITEM, + FAKE_PAYMENT_INTENT_I, + FAKE_PLAN, + FAKE_PRODUCT, + FAKE_SUBSCRIPTION, + FAKE_SUBSCRIPTION_III, + FAKE_TRANSFER, + IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + default_account, ) class EventTestCase(TestCase): - # - # Helpers - # + # + # Helpers + # - @patch("stripe.Event.retrieve", autospec=True) - def _create_event(self, event_data, event_retrieve_mock, patch_data=None): - event_data = deepcopy(event_data) + @patch("stripe.Event.retrieve", autospec=True) + def _create_event(self, event_data, event_retrieve_mock, patch_data=None): + event_data = deepcopy(event_data) - if patch_data: - event_data.update(patch_data) + if patch_data: + event_data.update(patch_data) - event_retrieve_mock.return_value = event_data - event = Event.sync_from_stripe_data(event_data) + event_retrieve_mock.return_value = event_data + event = Event.sync_from_stripe_data(event_data) - return event + return event class TestAccountEvents(EventTestCase): - @patch("stripe.Event.retrieve", autospec=True) - def test_account_deauthorized_event(self, event_retrieve_mock): - fake_stripe_event = deepcopy(FAKE_EVENT_ACCOUNT_APPLICATION_DEAUTHORIZED) + @patch("stripe.Event.retrieve", autospec=True) + def test_account_deauthorized_event(self, event_retrieve_mock): + fake_stripe_event = deepcopy(FAKE_EVENT_ACCOUNT_APPLICATION_DEAUTHORIZED) - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() class TestChargeEvents(EventTestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", return_value=deepcopy(FAKE_BALANCE_TRANSACTION) - ) - @patch("stripe.Charge.retrieve", autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Event.retrieve", autospec=True) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - def test_charge_created( - self, - subscription_retrieve_mock, - product_retrieve_mock, - invoice_retrieve_mock, - event_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - balance_transaction_retrieve_mock, - account_mock, - ): - FAKE_CUSTOMER.create_for_user(self.user) - fake_stripe_event = deepcopy(FAKE_EVENT_CHARGE_SUCCEEDED) - event_retrieve_mock.return_value = fake_stripe_event - charge_retrieve_mock.return_value = fake_stripe_event["data"]["object"] - account_mock.return_value = default_account() - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - charge = Charge.objects.get(id=fake_stripe_event["data"]["object"]["id"]) - self.assertEqual( - charge.amount, fake_stripe_event["data"]["object"]["amount"] / decimal.Decimal("100") - ) - self.assertEqual(charge.status, fake_stripe_event["data"]["object"]["status"]) + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + ) + @patch("stripe.Charge.retrieve", autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch("stripe.Event.retrieve", autospec=True) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + def test_charge_created( + self, + subscription_retrieve_mock, + product_retrieve_mock, + invoice_retrieve_mock, + event_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + balance_transaction_retrieve_mock, + account_mock, + ): + FAKE_CUSTOMER.create_for_user(self.user) + fake_stripe_event = deepcopy(FAKE_EVENT_CHARGE_SUCCEEDED) + event_retrieve_mock.return_value = fake_stripe_event + charge_retrieve_mock.return_value = fake_stripe_event["data"]["object"] + account_mock.return_value = default_account() + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + charge = Charge.objects.get(id=fake_stripe_event["data"]["object"]["id"]) + self.assertEqual( + charge.amount, + fake_stripe_event["data"]["object"]["amount"] / decimal.Decimal("100"), + ) + self.assertEqual(charge.status, fake_stripe_event["data"]["object"]["status"]) class TestCustomerEvents(EventTestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - @patch("stripe.Customer.retrieve", return_value=FAKE_CUSTOMER, autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_customer_created(self, event_retrieve_mock, customer_retrieve_mock): - fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_CREATED) - event_retrieve_mock.return_value = fake_stripe_event - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - customer = Customer.objects.get(id=fake_stripe_event["data"]["object"]["id"]) - self.assertEqual(customer.balance, fake_stripe_event["data"]["object"]["balance"]) - self.assertEqual(customer.currency, fake_stripe_event["data"]["object"]["currency"]) - - @patch("stripe.Customer.retrieve", return_value=FAKE_CUSTOMER, autospec=True) - def test_customer_deleted(self, customer_retrieve_mock): - FAKE_CUSTOMER.create_for_user(self.user) - event = self._create_event(FAKE_EVENT_CUSTOMER_CREATED) - event.invoke_webhook_handlers() - - event = self._create_event(FAKE_EVENT_CUSTOMER_DELETED) - event.invoke_webhook_handlers() - customer = Customer.objects.get(id=FAKE_CUSTOMER["id"]) - self.assertIsNotNone(customer.date_purged) - - @patch("stripe.Coupon.retrieve", return_value=FAKE_COUPON, autospec=True) - @patch( - "stripe.Event.retrieve", - return_value=FAKE_EVENT_CUSTOMER_DISCOUNT_CREATED, - autospec=True, - ) - def test_customer_discount_created(self, event_retrieve_mock, coupon_retrieve_mock): - fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_DISCOUNT_CREATED) - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - self.assertIsNotNone(event.customer) - self.assertEqual(event.customer.id, FAKE_CUSTOMER["id"]) - self.assertIsNotNone(event.customer.coupon) - - @patch("stripe.Coupon.retrieve", return_value=FAKE_COUPON, autospec=True) - @patch( - "stripe.Event.retrieve", - return_value=FAKE_EVENT_CUSTOMER_DISCOUNT_DELETED, - autospec=True, - ) - def test_customer_discount_deleted(self, event_retrieve_mock, coupon_retrieve_mock): - coupon = Coupon.sync_from_stripe_data(FAKE_COUPON) - self.customer.coupon = coupon - - fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_DISCOUNT_DELETED) - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - self.assertIsNotNone(event.customer) - self.assertEqual(event.customer.id, FAKE_CUSTOMER["id"]) - self.assertIsNone(event.customer.coupon) - - @patch("stripe.Customer.retrieve", return_value=FAKE_CUSTOMER, autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_customer_card_created(self, event_retrieve_mock, customer_retrieve_mock): - fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_SOURCE_CREATED) - event_retrieve_mock.return_value = fake_stripe_event - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - card = Card.objects.get(id=fake_stripe_event["data"]["object"]["id"]) - self.assertIn(card, self.customer.legacy_cards.all()) - self.assertEqual(card.brand, fake_stripe_event["data"]["object"]["brand"]) - self.assertEqual(card.last4, fake_stripe_event["data"]["object"]["last4"]) - - @patch("stripe.Event.retrieve", autospec=True) - def test_customer_unknown_source_created(self, event_retrieve_mock): - fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_SOURCE_CREATED) - fake_stripe_event["data"]["object"]["object"] = "unknown" - fake_stripe_event["data"]["object"][ - "id" - ] = "card_xxx_test_customer_unk_source_created" - event_retrieve_mock.return_value = fake_stripe_event - - FAKE_CUSTOMER.create_for_user(self.user) - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - self.assertFalse( - Card.objects.filter(id=fake_stripe_event["data"]["object"]["id"]).exists() - ) - - def test_customer_default_source_deleted(self): - self.customer.default_source = DjstripePaymentMethod.objects.get(id=FAKE_CARD["id"]) - self.customer.save() - self.assertIsNotNone(self.customer.default_source) - self.assertTrue(self.customer.has_valid_source()) - - event = self._create_event(FAKE_EVENT_CUSTOMER_SOURCE_DELETED) - event.invoke_webhook_handlers() - - customer = Customer.objects.get(id=FAKE_CUSTOMER["id"]) - self.assertIsNone(customer.default_source) - self.assertFalse(customer.has_valid_source()) - - def test_customer_source_double_delete(self): - event = self._create_event(FAKE_EVENT_CUSTOMER_SOURCE_DELETED) - event.invoke_webhook_handlers() - - event = self._create_event(FAKE_EVENT_CUSTOMER_SOURCE_DELETED_DUPE) - event.invoke_webhook_handlers() - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_customer_subscription_created( - self, - event_retrieve_mock, - product_retrieve_mock, - subscription_retrieve_mock, - plan_retrieve_mock, - ): - fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED) - event_retrieve_mock.return_value = fake_stripe_event - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - subscription = Subscription.objects.get(id=fake_stripe_event["data"]["object"]["id"]) - self.assertIn(subscription, self.customer.subscriptions.all()) - self.assertEqual(subscription.status, fake_stripe_event["data"]["object"]["status"]) - self.assertEqual( - subscription.quantity, fake_stripe_event["data"]["object"]["quantity"] - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_customer_subscription_deleted( - self, - customer_retrieve_mock, - product_retrieve_mock, - subscription_retrieve_mock, - plan_retrieve_mock, - ): - event = self._create_event(FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED) - event.invoke_webhook_handlers() - - Subscription.objects.get(id=FAKE_SUBSCRIPTION["id"]) - - event = self._create_event(FAKE_EVENT_CUSTOMER_SUBSCRIPTION_DELETED) - event.invoke_webhook_handlers() - - with self.assertRaises(Subscription.DoesNotExist): - Subscription.objects.get(id=FAKE_SUBSCRIPTION["id"]) - - @patch("stripe.Customer.retrieve", autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_customer_bogus_event_type(self, event_retrieve_mock, customer_retrieve_mock): - fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_CREATED) - fake_stripe_event["data"]["object"]["customer"] = fake_stripe_event["data"][ - "object" - ]["id"] - fake_stripe_event["type"] = "customer.praised" - - event_retrieve_mock.return_value = fake_stripe_event - customer_retrieve_mock.return_value = fake_stripe_event["data"]["object"] - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + @patch("stripe.Customer.retrieve", return_value=FAKE_CUSTOMER, autospec=True) + @patch("stripe.Event.retrieve", autospec=True) + def test_customer_created(self, event_retrieve_mock, customer_retrieve_mock): + fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_CREATED) + event_retrieve_mock.return_value = fake_stripe_event + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + customer = Customer.objects.get(id=fake_stripe_event["data"]["object"]["id"]) + self.assertEqual( + customer.balance, fake_stripe_event["data"]["object"]["balance"] + ) + self.assertEqual( + customer.currency, fake_stripe_event["data"]["object"]["currency"] + ) + + @patch("stripe.Customer.retrieve", return_value=FAKE_CUSTOMER, autospec=True) + def test_customer_deleted(self, customer_retrieve_mock): + FAKE_CUSTOMER.create_for_user(self.user) + event = self._create_event(FAKE_EVENT_CUSTOMER_CREATED) + event.invoke_webhook_handlers() + + event = self._create_event(FAKE_EVENT_CUSTOMER_DELETED) + event.invoke_webhook_handlers() + customer = Customer.objects.get(id=FAKE_CUSTOMER["id"]) + self.assertIsNotNone(customer.date_purged) + + @patch("stripe.Coupon.retrieve", return_value=FAKE_COUPON, autospec=True) + @patch( + "stripe.Event.retrieve", + return_value=FAKE_EVENT_CUSTOMER_DISCOUNT_CREATED, + autospec=True, + ) + def test_customer_discount_created(self, event_retrieve_mock, coupon_retrieve_mock): + fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_DISCOUNT_CREATED) + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + self.assertIsNotNone(event.customer) + self.assertEqual(event.customer.id, FAKE_CUSTOMER["id"]) + self.assertIsNotNone(event.customer.coupon) + + @patch("stripe.Coupon.retrieve", return_value=FAKE_COUPON, autospec=True) + @patch( + "stripe.Event.retrieve", + return_value=FAKE_EVENT_CUSTOMER_DISCOUNT_DELETED, + autospec=True, + ) + def test_customer_discount_deleted(self, event_retrieve_mock, coupon_retrieve_mock): + coupon = Coupon.sync_from_stripe_data(FAKE_COUPON) + self.customer.coupon = coupon + + fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_DISCOUNT_DELETED) + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + self.assertIsNotNone(event.customer) + self.assertEqual(event.customer.id, FAKE_CUSTOMER["id"]) + self.assertIsNone(event.customer.coupon) + + @patch("stripe.Customer.retrieve", return_value=FAKE_CUSTOMER, autospec=True) + @patch("stripe.Event.retrieve", autospec=True) + def test_customer_card_created(self, event_retrieve_mock, customer_retrieve_mock): + fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_SOURCE_CREATED) + event_retrieve_mock.return_value = fake_stripe_event + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + card = Card.objects.get(id=fake_stripe_event["data"]["object"]["id"]) + self.assertIn(card, self.customer.legacy_cards.all()) + self.assertEqual(card.brand, fake_stripe_event["data"]["object"]["brand"]) + self.assertEqual(card.last4, fake_stripe_event["data"]["object"]["last4"]) + + @patch("stripe.Event.retrieve", autospec=True) + def test_customer_unknown_source_created(self, event_retrieve_mock): + fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_SOURCE_CREATED) + fake_stripe_event["data"]["object"]["object"] = "unknown" + fake_stripe_event["data"]["object"][ + "id" + ] = "card_xxx_test_customer_unk_source_created" + event_retrieve_mock.return_value = fake_stripe_event + + FAKE_CUSTOMER.create_for_user(self.user) + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + self.assertFalse( + Card.objects.filter(id=fake_stripe_event["data"]["object"]["id"]).exists() + ) + + def test_customer_default_source_deleted(self): + self.customer.default_source = DjstripePaymentMethod.objects.get( + id=FAKE_CARD["id"] + ) + self.customer.save() + self.assertIsNotNone(self.customer.default_source) + self.assertTrue(self.customer.has_valid_source()) + + event = self._create_event(FAKE_EVENT_CUSTOMER_SOURCE_DELETED) + event.invoke_webhook_handlers() + + customer = Customer.objects.get(id=FAKE_CUSTOMER["id"]) + self.assertIsNone(customer.default_source) + self.assertFalse(customer.has_valid_source()) + + def test_customer_source_double_delete(self): + event = self._create_event(FAKE_EVENT_CUSTOMER_SOURCE_DELETED) + event.invoke_webhook_handlers() + + event = self._create_event(FAKE_EVENT_CUSTOMER_SOURCE_DELETED_DUPE) + event.invoke_webhook_handlers() + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Event.retrieve", autospec=True) + def test_customer_subscription_created( + self, + event_retrieve_mock, + product_retrieve_mock, + subscription_retrieve_mock, + plan_retrieve_mock, + ): + fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED) + event_retrieve_mock.return_value = fake_stripe_event + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + subscription = Subscription.objects.get( + id=fake_stripe_event["data"]["object"]["id"] + ) + self.assertIn(subscription, self.customer.subscriptions.all()) + self.assertEqual( + subscription.status, fake_stripe_event["data"]["object"]["status"] + ) + self.assertEqual( + subscription.quantity, fake_stripe_event["data"]["object"]["quantity"] + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_customer_subscription_deleted( + self, + customer_retrieve_mock, + product_retrieve_mock, + subscription_retrieve_mock, + plan_retrieve_mock, + ): + event = self._create_event(FAKE_EVENT_CUSTOMER_SUBSCRIPTION_CREATED) + event.invoke_webhook_handlers() + + Subscription.objects.get(id=FAKE_SUBSCRIPTION["id"]) + + event = self._create_event(FAKE_EVENT_CUSTOMER_SUBSCRIPTION_DELETED) + event.invoke_webhook_handlers() + + with self.assertRaises(Subscription.DoesNotExist): + Subscription.objects.get(id=FAKE_SUBSCRIPTION["id"]) + + @patch("stripe.Customer.retrieve", autospec=True) + @patch("stripe.Event.retrieve", autospec=True) + def test_customer_bogus_event_type( + self, event_retrieve_mock, customer_retrieve_mock + ): + fake_stripe_event = deepcopy(FAKE_EVENT_CUSTOMER_CREATED) + fake_stripe_event["data"]["object"]["customer"] = fake_stripe_event["data"][ + "object" + ]["id"] + fake_stripe_event["type"] = "customer.praised" + + event_retrieve_mock.return_value = fake_stripe_event + customer_retrieve_mock.return_value = fake_stripe_event["data"]["object"] + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() class TestDisputeEvents(EventTestCase): - @patch("stripe.Dispute.retrieve", return_value=deepcopy(FAKE_DISPUTE), autospec=True) - @patch( - "stripe.Event.retrieve", - return_value=deepcopy(FAKE_EVENT_DISPUTE_CREATED), - autospec=True, - ) - def test_dispute_created(self, event_retrieve_mock, dispute_retrieve_mock): - fake_stripe_event = deepcopy(FAKE_EVENT_DISPUTE_CREATED) - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - dispute = Dispute.objects.get() - self.assertEqual(dispute.id, FAKE_DISPUTE["id"]) + @patch( + "stripe.Dispute.retrieve", return_value=deepcopy(FAKE_DISPUTE), autospec=True + ) + @patch( + "stripe.Event.retrieve", + return_value=deepcopy(FAKE_EVENT_DISPUTE_CREATED), + autospec=True, + ) + def test_dispute_created(self, event_retrieve_mock, dispute_retrieve_mock): + fake_stripe_event = deepcopy(FAKE_EVENT_DISPUTE_CREATED) + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + dispute = Dispute.objects.get() + self.assertEqual(dispute.id, FAKE_DISPUTE["id"]) class TestInvoiceEvents(EventTestCase): - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoice_created_no_existing_customer( - self, - product_retrieve_mock, - event_retrieve_mock, - invoice_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = default_account() - - fake_stripe_event = deepcopy(FAKE_EVENT_INVOICE_CREATED) - event_retrieve_mock.return_value = fake_stripe_event - - invoice_retrieve_mock.return_value = fake_stripe_event["data"]["object"] - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - self.assertEqual(Customer.objects.count(), 1) - customer = Customer.objects.get() - self.assertEqual(customer.subscriber, None) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Invoice.retrieve", autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoice_created( - self, - product_retrieve_mock, - event_retrieve_mock, - invoice_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = default_account() - - user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - FAKE_CUSTOMER.create_for_user(user) - - fake_stripe_event = deepcopy(FAKE_EVENT_INVOICE_CREATED) - event_retrieve_mock.return_value = fake_stripe_event - - invoice_retrieve_mock.return_value = fake_stripe_event["data"]["object"] - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - invoice = Invoice.objects.get(id=fake_stripe_event["data"]["object"]["id"]) - self.assertEqual( - invoice.amount_due, - fake_stripe_event["data"]["object"]["amount_due"] / decimal.Decimal("100"), - ) - self.assertEqual(invoice.paid, fake_stripe_event["data"]["object"]["paid"]) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoice_deleted( - self, - product_retrieve_mock, - invoice_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = default_account() - - user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - FAKE_CUSTOMER.create_for_user(user) - - event = self._create_event(FAKE_EVENT_INVOICE_CREATED) - event.invoke_webhook_handlers() - - Invoice.objects.get(id=FAKE_INVOICE["id"]) - - event = self._create_event(FAKE_EVENT_INVOICE_DELETED) - event.invoke_webhook_handlers() - - with self.assertRaises(Invoice.DoesNotExist): - Invoice.objects.get(id=FAKE_INVOICE["id"]) - - def test_invoice_upcoming(self): - # Ensure that invoice upcoming events are processed - No actual - # process occurs so the operation is an effective no-op. - event = self._create_event(FAKE_EVENT_INVOICE_UPCOMING) - event.invoke_webhook_handlers() + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch("stripe.Event.retrieve", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoice_created_no_existing_customer( + self, + product_retrieve_mock, + event_retrieve_mock, + invoice_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = default_account() + + fake_stripe_event = deepcopy(FAKE_EVENT_INVOICE_CREATED) + event_retrieve_mock.return_value = fake_stripe_event + + invoice_retrieve_mock.return_value = fake_stripe_event["data"]["object"] + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + self.assertEqual(Customer.objects.count(), 1) + customer = Customer.objects.get() + self.assertEqual(customer.subscriber, None) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch("stripe.Invoice.retrieve", autospec=True) + @patch("stripe.Event.retrieve", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoice_created( + self, + product_retrieve_mock, + event_retrieve_mock, + invoice_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = default_account() + + user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + FAKE_CUSTOMER.create_for_user(user) + + fake_stripe_event = deepcopy(FAKE_EVENT_INVOICE_CREATED) + event_retrieve_mock.return_value = fake_stripe_event + + invoice_retrieve_mock.return_value = fake_stripe_event["data"]["object"] + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + invoice = Invoice.objects.get(id=fake_stripe_event["data"]["object"]["id"]) + self.assertEqual( + invoice.amount_due, + fake_stripe_event["data"]["object"]["amount_due"] / decimal.Decimal("100"), + ) + self.assertEqual(invoice.paid, fake_stripe_event["data"]["object"]["paid"]) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoice_deleted( + self, + product_retrieve_mock, + invoice_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = default_account() + + user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + FAKE_CUSTOMER.create_for_user(user) + + event = self._create_event(FAKE_EVENT_INVOICE_CREATED) + event.invoke_webhook_handlers() + + Invoice.objects.get(id=FAKE_INVOICE["id"]) + + event = self._create_event(FAKE_EVENT_INVOICE_DELETED) + event.invoke_webhook_handlers() + + with self.assertRaises(Invoice.DoesNotExist): + Invoice.objects.get(id=FAKE_INVOICE["id"]) + + def test_invoice_upcoming(self): + # Ensure that invoice upcoming events are processed - No actual + # process occurs so the operation is an effective no-op. + event = self._create_event(FAKE_EVENT_INVOICE_UPCOMING) + event.invoke_webhook_handlers() class TestInvoiceItemEvents(EventTestCase): - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION_III), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True) - @patch( - "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True - ) - @patch("stripe.InvoiceItem.retrieve", autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoiceitem_created( - self, - product_retrieve_mock, - event_retrieve_mock, - invoiceitem_retrieve_mock, - invoice_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = default_account() - - user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - FAKE_CUSTOMER_II.create_for_user(user) - - fake_stripe_event = deepcopy(FAKE_EVENT_INVOICEITEM_CREATED) - event_retrieve_mock.return_value = fake_stripe_event - - invoiceitem_retrieve_mock.return_value = fake_stripe_event["data"]["object"] - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - invoiceitem = InvoiceItem.objects.get(id=fake_stripe_event["data"]["object"]["id"]) - self.assertEqual( - invoiceitem.amount, - fake_stripe_event["data"]["object"]["amount"] / decimal.Decimal("100"), - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION_III), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True) - @patch( - "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True - ) - @patch( - "stripe.InvoiceItem.retrieve", return_value=deepcopy(FAKE_INVOICEITEM), autospec=True - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoiceitem_deleted( - self, - product_retrieve_mock, - invoiceitem_retrieve_mock, - invoice_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = default_account() - - user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - FAKE_CUSTOMER_II.create_for_user(user) - - event = self._create_event(FAKE_EVENT_INVOICEITEM_CREATED) - event.invoke_webhook_handlers() - - InvoiceItem.objects.get(id=FAKE_INVOICEITEM["id"]) - - event = self._create_event(FAKE_EVENT_INVOICEITEM_DELETED) - event.invoke_webhook_handlers() - - with self.assertRaises(InvoiceItem.DoesNotExist): - InvoiceItem.objects.get(id=FAKE_INVOICEITEM["id"]) + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_III), + autospec=True, + ) + @patch( + "stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True + ) + @patch("stripe.InvoiceItem.retrieve", autospec=True) + @patch("stripe.Event.retrieve", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoiceitem_created( + self, + product_retrieve_mock, + event_retrieve_mock, + invoiceitem_retrieve_mock, + invoice_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = default_account() + + user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + FAKE_CUSTOMER_II.create_for_user(user) + + fake_stripe_event = deepcopy(FAKE_EVENT_INVOICEITEM_CREATED) + event_retrieve_mock.return_value = fake_stripe_event + + invoiceitem_retrieve_mock.return_value = fake_stripe_event["data"]["object"] + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + invoiceitem = InvoiceItem.objects.get( + id=fake_stripe_event["data"]["object"]["id"] + ) + self.assertEqual( + invoiceitem.amount, + fake_stripe_event["data"]["object"]["amount"] / decimal.Decimal("100"), + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_III), + autospec=True, + ) + @patch( + "stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True + ) + @patch( + "stripe.InvoiceItem.retrieve", + return_value=deepcopy(FAKE_INVOICEITEM), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoiceitem_deleted( + self, + product_retrieve_mock, + invoiceitem_retrieve_mock, + invoice_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = default_account() + + user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + FAKE_CUSTOMER_II.create_for_user(user) + + event = self._create_event(FAKE_EVENT_INVOICEITEM_CREATED) + event.invoke_webhook_handlers() + + InvoiceItem.objects.get(id=FAKE_INVOICEITEM["id"]) + + event = self._create_event(FAKE_EVENT_INVOICEITEM_DELETED) + event.invoke_webhook_handlers() + + with self.assertRaises(InvoiceItem.DoesNotExist): + InvoiceItem.objects.get(id=FAKE_INVOICEITEM["id"]) class TestPlanEvents(EventTestCase): - @patch("stripe.Plan.retrieve", autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_plan_created( - self, product_retrieve_mock, event_retrieve_mock, plan_retrieve_mock - ): - fake_stripe_event = deepcopy(FAKE_EVENT_PLAN_CREATED) - event_retrieve_mock.return_value = fake_stripe_event - plan_retrieve_mock.return_value = fake_stripe_event["data"]["object"] - - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() - - plan = Plan.objects.get(id=fake_stripe_event["data"]["object"]["id"]) - self.assertEqual(plan.nickname, fake_stripe_event["data"]["object"]["nickname"]) - - @patch("stripe.Plan.retrieve", return_value=FAKE_PLAN, autospec=True) - @patch( - "stripe.Event.retrieve", return_value=FAKE_EVENT_PLAN_REQUEST_IS_OBJECT, autospec=True - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_plan_updated_request_object( - self, product_retrieve_mock, event_retrieve_mock, plan_retrieve_mock - ): - plan_retrieve_mock.return_value = FAKE_EVENT_PLAN_REQUEST_IS_OBJECT["data"]["object"] - - event = Event.sync_from_stripe_data(FAKE_EVENT_PLAN_REQUEST_IS_OBJECT) - event.invoke_webhook_handlers() - - plan = Plan.objects.get(id=FAKE_EVENT_PLAN_REQUEST_IS_OBJECT["data"]["object"]["id"]) - self.assertEqual( - plan.nickname, FAKE_EVENT_PLAN_REQUEST_IS_OBJECT["data"]["object"]["nickname"] - ) - - @patch("stripe.Plan.retrieve", return_value=FAKE_PLAN, autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_plan_deleted(self, product_retrieve_mock, plan_retrieve_mock): - - event = self._create_event(FAKE_EVENT_PLAN_CREATED) - event.invoke_webhook_handlers() - - Plan.objects.get(id=FAKE_PLAN["id"]) - - event = self._create_event(FAKE_EVENT_PLAN_DELETED) - event.invoke_webhook_handlers() - - with self.assertRaises(Plan.DoesNotExist): - Plan.objects.get(id=FAKE_PLAN["id"]) + @patch("stripe.Plan.retrieve", autospec=True) + @patch("stripe.Event.retrieve", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_plan_created( + self, product_retrieve_mock, event_retrieve_mock, plan_retrieve_mock + ): + fake_stripe_event = deepcopy(FAKE_EVENT_PLAN_CREATED) + event_retrieve_mock.return_value = fake_stripe_event + plan_retrieve_mock.return_value = fake_stripe_event["data"]["object"] + + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() + + plan = Plan.objects.get(id=fake_stripe_event["data"]["object"]["id"]) + self.assertEqual(plan.nickname, fake_stripe_event["data"]["object"]["nickname"]) + + @patch("stripe.Plan.retrieve", return_value=FAKE_PLAN, autospec=True) + @patch( + "stripe.Event.retrieve", + return_value=FAKE_EVENT_PLAN_REQUEST_IS_OBJECT, + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_plan_updated_request_object( + self, product_retrieve_mock, event_retrieve_mock, plan_retrieve_mock + ): + plan_retrieve_mock.return_value = FAKE_EVENT_PLAN_REQUEST_IS_OBJECT["data"][ + "object" + ] + + event = Event.sync_from_stripe_data(FAKE_EVENT_PLAN_REQUEST_IS_OBJECT) + event.invoke_webhook_handlers() + + plan = Plan.objects.get( + id=FAKE_EVENT_PLAN_REQUEST_IS_OBJECT["data"]["object"]["id"] + ) + self.assertEqual( + plan.nickname, + FAKE_EVENT_PLAN_REQUEST_IS_OBJECT["data"]["object"]["nickname"], + ) + + @patch("stripe.Plan.retrieve", return_value=FAKE_PLAN, autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_plan_deleted(self, product_retrieve_mock, plan_retrieve_mock): + + event = self._create_event(FAKE_EVENT_PLAN_CREATED) + event.invoke_webhook_handlers() + + Plan.objects.get(id=FAKE_PLAN["id"]) + + event = self._create_event(FAKE_EVENT_PLAN_DELETED) + event.invoke_webhook_handlers() + + with self.assertRaises(Plan.DoesNotExist): + Plan.objects.get(id=FAKE_PLAN["id"]) class TestTransferEvents(EventTestCase): - @patch("stripe.Transfer.retrieve", autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_transfer_created(self, event_retrieve_mock, transfer_retrieve_mock): - fake_stripe_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - event_retrieve_mock.return_value = fake_stripe_event - transfer_retrieve_mock.return_value = fake_stripe_event["data"]["object"] + @patch("stripe.Transfer.retrieve", autospec=True) + @patch("stripe.Event.retrieve", autospec=True) + def test_transfer_created(self, event_retrieve_mock, transfer_retrieve_mock): + fake_stripe_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + event_retrieve_mock.return_value = fake_stripe_event + transfer_retrieve_mock.return_value = fake_stripe_event["data"]["object"] - event = Event.sync_from_stripe_data(fake_stripe_event) - event.invoke_webhook_handlers() + event = Event.sync_from_stripe_data(fake_stripe_event) + event.invoke_webhook_handlers() - transfer = Transfer.objects.get(id=fake_stripe_event["data"]["object"]["id"]) - self.assertEqual( - transfer.amount, - fake_stripe_event["data"]["object"]["amount"] / decimal.Decimal("100"), - ) + transfer = Transfer.objects.get(id=fake_stripe_event["data"]["object"]["id"]) + self.assertEqual( + transfer.amount, + fake_stripe_event["data"]["object"]["amount"] / decimal.Decimal("100"), + ) - @patch("stripe.Transfer.retrieve", return_value=FAKE_TRANSFER, autospec=True) - def test_transfer_deleted(self, transfer_retrieve_mock): - event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) - event.invoke_webhook_handlers() + @patch("stripe.Transfer.retrieve", return_value=FAKE_TRANSFER, autospec=True) + def test_transfer_deleted(self, transfer_retrieve_mock): + event = self._create_event(FAKE_EVENT_TRANSFER_CREATED) + event.invoke_webhook_handlers() - Transfer.objects.get(id=FAKE_TRANSFER["id"]) + Transfer.objects.get(id=FAKE_TRANSFER["id"]) - event = self._create_event(FAKE_EVENT_TRANSFER_DELETED) - event.invoke_webhook_handlers() + event = self._create_event(FAKE_EVENT_TRANSFER_DELETED) + event.invoke_webhook_handlers() - with self.assertRaises(Transfer.DoesNotExist): - Transfer.objects.get(id=FAKE_TRANSFER["id"]) + with self.assertRaises(Transfer.DoesNotExist): + Transfer.objects.get(id=FAKE_TRANSFER["id"]) - event = self._create_event(FAKE_EVENT_TRANSFER_DELETED) - event.invoke_webhook_handlers() + event = self._create_event(FAKE_EVENT_TRANSFER_DELETED) + event.invoke_webhook_handlers() diff --git a/tests/test_fields.py b/tests/test_fields.py index ecebc5b8a7..5f0d6e1939 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -7,7 +7,7 @@ class TestStripeCurrencyField(TestCase): - noval = StripeDecimalCurrencyAmountField(name="noval") + noval = StripeDecimalCurrencyAmountField(name="noval") - def test_stripe_to_db_none_val(self): - self.assertEqual(None, self.noval.stripe_to_db({"noval": None})) + def test_stripe_to_db_none_val(self): + self.assertEqual(None, self.noval.stripe_to_db({"noval": None})) diff --git a/tests/test_idempotency_keys.py b/tests/test_idempotency_keys.py index 2f4ec855b0..6a1bd0eb1c 100644 --- a/tests/test_idempotency_keys.py +++ b/tests/test_idempotency_keys.py @@ -9,33 +9,35 @@ class IdempotencyKeyTest(TestCase): - def test_generate_idempotency_key(self): - key1 = get_idempotency_key("customer", "create:1", False) - key2 = get_idempotency_key("customer", "create:1", False) - self.assertTrue(key1 == key2) + def test_generate_idempotency_key(self): + key1 = get_idempotency_key("customer", "create:1", False) + key2 = get_idempotency_key("customer", "create:1", False) + self.assertTrue(key1 == key2) - key3 = get_idempotency_key("customer", "create:2", False) - self.assertTrue(key1 != key3) + key3 = get_idempotency_key("customer", "create:2", False) + self.assertTrue(key1 != key3) - key4 = get_idempotency_key("charge", "create:1", False) - self.assertTrue(key1 != key4) + key4 = get_idempotency_key("charge", "create:1", False) + self.assertTrue(key1 != key4) - self.assertEqual(IdempotencyKey.objects.count(), 3) - key1_obj = IdempotencyKey.objects.get(action="customer:create:1", livemode=False) - self.assertFalse(key1_obj.is_expired) - self.assertEqual(str(key1_obj), str(key1_obj.uuid)) + self.assertEqual(IdempotencyKey.objects.count(), 3) + key1_obj = IdempotencyKey.objects.get( + action="customer:create:1", livemode=False + ) + self.assertFalse(key1_obj.is_expired) + self.assertEqual(str(key1_obj), str(key1_obj.uuid)) - def test_clear_expired_idempotency_keys(self): - expired_key = get_idempotency_key("customer", "create:1", False) - expired_key_obj = IdempotencyKey.objects.get(uuid=expired_key) - expired_key_obj.created = now() - timedelta(hours=25) - expired_key_obj.save() + def test_clear_expired_idempotency_keys(self): + expired_key = get_idempotency_key("customer", "create:1", False) + expired_key_obj = IdempotencyKey.objects.get(uuid=expired_key) + expired_key_obj.created = now() - timedelta(hours=25) + expired_key_obj.save() - valid_key = get_idempotency_key("customer", "create:2", False) + valid_key = get_idempotency_key("customer", "create:2", False) - self.assertEqual(IdempotencyKey.objects.count(), 2) + self.assertEqual(IdempotencyKey.objects.count(), 2) - clear_expired_idempotency_keys() + clear_expired_idempotency_keys() - self.assertEqual(IdempotencyKey.objects.count(), 1) - self.assertEqual(str(IdempotencyKey.objects.get().uuid), valid_key) + self.assertEqual(IdempotencyKey.objects.count(), 1) + self.assertEqual(str(IdempotencyKey.objects.get().uuid), valid_key) diff --git a/tests/test_invoice.py b/tests/test_invoice.py index 7855fae818..efa37bb459 100644 --- a/tests/test_invoice.py +++ b/tests/test_invoice.py @@ -12,1068 +12,1126 @@ from djstripe.settings import STRIPE_SECRET_KEY from . import ( - FAKE_BALANCE_TRANSACTION, FAKE_CHARGE, FAKE_CUSTOMER, FAKE_INVOICE, - FAKE_INVOICEITEM_II, FAKE_PAYMENT_INTENT_I, FAKE_PLAN, FAKE_PRODUCT, - FAKE_SUBSCRIPTION, FAKE_UPCOMING_INVOICE, IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - IS_STATICMETHOD_AUTOSPEC_SUPPORTED, AssertStripeFksMixin, default_account + FAKE_BALANCE_TRANSACTION, + FAKE_CHARGE, + FAKE_CUSTOMER, + FAKE_INVOICE, + FAKE_INVOICEITEM_II, + FAKE_PAYMENT_INTENT_I, + FAKE_PLAN, + FAKE_PRODUCT, + FAKE_SUBSCRIPTION, + FAKE_UPCOMING_INVOICE, + IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + AssertStripeFksMixin, + default_account, ) class InvoiceTest(AssertStripeFksMixin, TestCase): - def setUp(self): - self.account = default_account() - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_sync_from_stripe_data( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - invoice = Invoice.sync_from_stripe_data(deepcopy(FAKE_INVOICE)) - self.assertEqual( - invoice.get_stripe_dashboard_url(), self.customer.get_stripe_dashboard_url() - ) - self.assertEqual(str(invoice), "Invoice #{}".format(FAKE_INVOICE["number"])) - self.assertGreater(len(invoice.status_transitions.keys()), 1) - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Invoice.retrieve", autospec=True) - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_retry_true( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - invoice_retrieve_mock, - ): - default_account_mock.return_value = self.account - - fake_invoice = deepcopy(FAKE_INVOICE) - fake_invoice.update({"paid": False, "closed": False}) - invoice_retrieve_mock.return_value = fake_invoice - - invoice = Invoice.sync_from_stripe_data(fake_invoice) - return_value = invoice.retry() - - invoice_retrieve_mock.assert_called_once_with( - id=invoice.id, api_key=STRIPE_SECRET_KEY, expand=[] - ) - self.assertTrue(return_value) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Invoice.retrieve", autospec=True) - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_retry_false( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - invoice_retrieve_mock, - ): - default_account_mock.return_value = self.account - - fake_invoice = deepcopy(FAKE_INVOICE) - invoice_retrieve_mock.return_value = fake_invoice - - invoice = Invoice.sync_from_stripe_data(fake_invoice) - return_value = invoice.retry() - - self.assertFalse(invoice_retrieve_mock.called) - self.assertFalse(return_value) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_status_paid( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice = Invoice.sync_from_stripe_data(deepcopy(FAKE_INVOICE)) - - self.assertEqual(Invoice.STATUS_PAID, invoice.status) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_status_open( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data.update({"paid": False, "closed": False}) - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertEqual(Invoice.STATUS_OPEN, invoice.status) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_status_forgiven( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data.update({"paid": False, "closed": False, "forgiven": True}) - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertEqual(Invoice.STATUS_FORGIVEN, invoice.status) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_status_forgiven_deprecated( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - # forgiven parameter deprecated in API 2018-11-08 - see https://stripe.com/docs/upgrades#2018-11-08 - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data.update({"paid": False, "closed": False}) - invoice_data.pop("forgiven", None) # TODO remove - invoice_data["status"] = "uncollectible" - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertEqual(Invoice.STATUS_FORGIVEN, invoice.status) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_status_forgiven_default( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - # forgiven parameter deprecated in API 2018-11-08 - see https://stripe.com/docs/upgrades#2018-11-08 - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data.update({"paid": False, "closed": False}) - invoice_data.pop("forgiven", None) # TODO remove - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertEqual(Invoice.STATUS_OPEN, invoice.status) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_status_closed( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data.update({"paid": False}) - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertEqual(Invoice.STATUS_CLOSED, invoice.status) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_status_closed_deprecated( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - # closed parameter deprecated in API 2018-11-08 - see https://stripe.com/docs/upgrades#2018-11-08 - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data.update({"paid": False}) - invoice_data["auto_advance"] = False - - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertEqual(Invoice.STATUS_CLOSED, invoice.status) - self.assertEqual(invoice.auto_advance, invoice_data["auto_advance"]) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_status_closed_default( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - # closed parameter deprecated in API 2018-11-08 - see https://stripe.com/docs/upgrades#2018-11-08 - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data.update({"paid": False}) - invoice_data.pop("auto_advance") - invoice_data.pop("closed", None) # TODO remove - invoice_data.pop("status") - - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertEqual(Invoice.STATUS_OPEN, invoice.status) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Plan.retrieve", - return_value=deepcopy(FAKE_PLAN), - autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Subscription.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_sync_no_subscription( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - plan_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data.update({"subscription": None}) - invoice_data["lines"]["data"][0]["subscription"] = None - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertEqual(None, invoice.subscription) - - self.assertEqual(FAKE_CHARGE["id"], invoice.charge.id) - self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) - - # charge_retrieve_mock.assert_not_called() - plan_retrieve_mock.assert_not_called() - subscription_retrieve_mock.assert_not_called() - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.Invoice.subscription", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoice_with_subscription_invoice_items( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice = Invoice.sync_from_stripe_data(invoice_data) - - items = invoice.invoiceitems.all() - self.assertEqual(1, len(items)) - - # Previously the test asserted item_id="{invoice_id}-{subscription_id}", - # but this doesn't match what I'm seeing from Stripe - # I'm not sure if it's possible to predict the whole item id now, sli seems to not reference anything - item_id_prefix = "{invoice_id}-sli_".format(invoice_id=invoice.id) - self.assertTrue(items[0].id.startswith(item_id_prefix)) - self.assertEqual(items[0].subscription.id, FAKE_SUBSCRIPTION["id"]) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoice_with_no_invoice_items( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data["lines"] = [] - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertIsNotNone(invoice.plan) # retrieved from invoice item - self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoice_with_non_subscription_invoice_items( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data["lines"]["data"].append(deepcopy(FAKE_INVOICEITEM_II)) - invoice_data["lines"]["total_count"] += 1 - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertIsNotNone(invoice) - self.assertEqual(2, len(invoice.invoiceitems.all())) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoice_plan_from_invoice_items( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice = Invoice.sync_from_stripe_data(invoice_data) - - self.assertIsNotNone(invoice.plan) # retrieved from invoice item - self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_invoice_plan_from_subscription( - self, - product_retrieve_mock, - payment_intent_retrieve_mock, - charge_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data["lines"]["data"][0]["plan"] = None - invoice = Invoice.sync_from_stripe_data(invoice_data) - self.assertIsNotNone(invoice.plan) # retrieved from subscription - self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Subscription.retrieve", autospec=True) - @patch( - "stripe.PaymentIntent.retrieve", - return_value=deepcopy(FAKE_PAYMENT_INTENT_I), - autospec=True, - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) - def test_invoice_without_plan( - self, - charge_retrieve_mock, - payment_intent_retrieve_mock, - subscription_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account - - invoice_data = deepcopy(FAKE_INVOICE) - invoice_data["lines"]["data"][0]["plan"] = None - invoice_data["lines"]["data"][0]["subscription"] = None - invoice_data["subscription"] = None - invoice = Invoice.sync_from_stripe_data(invoice_data) - self.assertIsNone(invoice.plan) - - self.assert_fks( - invoice, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.Invoice.subscription", - "djstripe.PaymentIntent.on_behalf_of", - "djstripe.PaymentIntent.payment_method", - "djstripe.Plan.product", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch( - "stripe.Plan.retrieve", - return_value=deepcopy(FAKE_PLAN), - autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch( - "stripe.Invoice.upcoming", - return_value=deepcopy(FAKE_UPCOMING_INVOICE), - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_upcoming_invoice( - self, - product_retrieve_mock, - invoice_upcoming_mock, - subscription_retrieve_mock, - plan_retrieve_mock, - ): - invoice = UpcomingInvoice.upcoming() - self.assertIsNotNone(invoice) - self.assertIsNone(invoice.id) - self.assertIsNone(invoice.save()) - self.assertEqual(invoice.get_stripe_dashboard_url(), "") - - subscription_retrieve_mock.assert_called_once_with( - api_key=ANY, expand=ANY, id=FAKE_SUBSCRIPTION["id"] - ) - plan_retrieve_mock.assert_not_called() - - items = invoice.invoiceitems.all() - self.assertEqual(1, len(items)) - self.assertEqual(FAKE_SUBSCRIPTION["id"], items[0].id) - - # delete/update should do nothing - self.assertEqual(invoice.invoiceitems.update(), 0) - self.assertEqual(invoice.invoiceitems.delete(), 0) - - self.assertIsNotNone(invoice.plan) - self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) - - invoice._invoiceitems = [] - items = invoice.invoiceitems.all() - self.assertEqual(0, len(items)) - self.assertIsNotNone(invoice.plan) - - @patch("stripe.Plan.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch( - "stripe.Invoice.upcoming", - return_value=deepcopy(FAKE_UPCOMING_INVOICE), - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - def test_upcoming_invoice_with_subscription( - self, - invoice_upcoming_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - invoice = Invoice.upcoming(subscription=Subscription(id=FAKE_SUBSCRIPTION["id"])) - self.assertIsNotNone(invoice) - self.assertIsNone(invoice.id) - self.assertIsNone(invoice.save()) - - subscription_retrieve_mock.assert_called_once_with( - api_key=ANY, expand=ANY, id=FAKE_SUBSCRIPTION["id"] - ) - plan_retrieve_mock.assert_not_called() - - self.assertIsNotNone(invoice.plan) - self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) - - @patch("stripe.Plan.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch( - "stripe.Invoice.upcoming", - return_value=deepcopy(FAKE_UPCOMING_INVOICE), - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_upcoming_invoice_with_subscription_plan( - self, - product_retrieve_mock, - invoice_upcoming_mock, - subscription_retrieve_mock, - plan_retrieve_mock, - ): - invoice = Invoice.upcoming(subscription_plan=Plan(id=FAKE_PLAN["id"])) - self.assertIsNotNone(invoice) - self.assertIsNone(invoice.id) - self.assertIsNone(invoice.save()) - - subscription_retrieve_mock.assert_called_once_with( - api_key=ANY, expand=ANY, id=FAKE_SUBSCRIPTION["id"] - ) - plan_retrieve_mock.assert_not_called() - - self.assertIsNotNone(invoice.plan) - self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) - - @patch( - "stripe.Invoice.upcoming", - side_effect=InvalidRequestError("Nothing to invoice for customer", None), - ) - def test_no_upcoming_invoices(self, invoice_upcoming_mock): - invoice = Invoice.upcoming() - self.assertIsNone(invoice) - - @patch( - "stripe.Invoice.upcoming", side_effect=InvalidRequestError("Some other error", None) - ) - def test_upcoming_invoice_error(self, invoice_upcoming_mock): - with self.assertRaises(InvalidRequestError): - Invoice.upcoming() + def setUp(self): + self.account = default_account() + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_sync_from_stripe_data( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + invoice = Invoice.sync_from_stripe_data(deepcopy(FAKE_INVOICE)) + self.assertEqual( + invoice.get_stripe_dashboard_url(), self.customer.get_stripe_dashboard_url() + ) + self.assertEqual(str(invoice), "Invoice #{}".format(FAKE_INVOICE["number"])) + self.assertGreater(len(invoice.status_transitions.keys()), 1) + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Invoice.retrieve", autospec=True) + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_retry_true( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + invoice_retrieve_mock, + ): + default_account_mock.return_value = self.account + + fake_invoice = deepcopy(FAKE_INVOICE) + fake_invoice.update({"paid": False, "closed": False}) + invoice_retrieve_mock.return_value = fake_invoice + + invoice = Invoice.sync_from_stripe_data(fake_invoice) + return_value = invoice.retry() + + invoice_retrieve_mock.assert_called_once_with( + id=invoice.id, api_key=STRIPE_SECRET_KEY, expand=[] + ) + self.assertTrue(return_value) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Invoice.retrieve", autospec=True) + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_retry_false( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + invoice_retrieve_mock, + ): + default_account_mock.return_value = self.account + + fake_invoice = deepcopy(FAKE_INVOICE) + invoice_retrieve_mock.return_value = fake_invoice + + invoice = Invoice.sync_from_stripe_data(fake_invoice) + return_value = invoice.retry() + + self.assertFalse(invoice_retrieve_mock.called) + self.assertFalse(return_value) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_status_paid( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice = Invoice.sync_from_stripe_data(deepcopy(FAKE_INVOICE)) + + self.assertEqual(Invoice.STATUS_PAID, invoice.status) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_status_open( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data.update({"paid": False, "closed": False}) + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertEqual(Invoice.STATUS_OPEN, invoice.status) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_status_forgiven( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data.update({"paid": False, "closed": False, "forgiven": True}) + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertEqual(Invoice.STATUS_FORGIVEN, invoice.status) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_status_forgiven_deprecated( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + # forgiven parameter deprecated in API 2018-11-08 + # see https://stripe.com/docs/upgrades#2018-11-08 + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data.update({"paid": False, "closed": False}) + invoice_data.pop("forgiven", None) # TODO remove + invoice_data["status"] = "uncollectible" + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertEqual(Invoice.STATUS_FORGIVEN, invoice.status) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_status_forgiven_default( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + # forgiven parameter deprecated in API 2018-11-08 + # see https://stripe.com/docs/upgrades#2018-11-08 + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data.update({"paid": False, "closed": False}) + invoice_data.pop("forgiven", None) # TODO remove + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertEqual(Invoice.STATUS_OPEN, invoice.status) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_status_closed( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data.update({"paid": False}) + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertEqual(Invoice.STATUS_CLOSED, invoice.status) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_status_closed_deprecated( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + # closed parameter deprecated in API 2018-11-08 + # see https://stripe.com/docs/upgrades#2018-11-08 + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data.update({"paid": False}) + invoice_data["auto_advance"] = False + + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertEqual(Invoice.STATUS_CLOSED, invoice.status) + self.assertEqual(invoice.auto_advance, invoice_data["auto_advance"]) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_status_closed_default( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + # closed parameter deprecated in API 2018-11-08 + # see https://stripe.com/docs/upgrades#2018-11-08 + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data.update({"paid": False}) + invoice_data.pop("auto_advance") + invoice_data.pop("closed", None) # TODO remove + invoice_data.pop("status") + + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertEqual(Invoice.STATUS_OPEN, invoice.status) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Plan.retrieve", + return_value=deepcopy(FAKE_PLAN), + autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + ) + @patch("stripe.Subscription.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_sync_no_subscription( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + plan_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data.update({"subscription": None}) + invoice_data["lines"]["data"][0]["subscription"] = None + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertEqual(None, invoice.subscription) + + self.assertEqual(FAKE_CHARGE["id"], invoice.charge.id) + self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) + + # charge_retrieve_mock.assert_not_called() + plan_retrieve_mock.assert_not_called() + subscription_retrieve_mock.assert_not_called() + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.Invoice.subscription", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoice_with_subscription_invoice_items( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice = Invoice.sync_from_stripe_data(invoice_data) + + items = invoice.invoiceitems.all() + self.assertEqual(1, len(items)) + + # Previously the test asserted item_id="{invoice_id}-{subscription_id}", + # but this doesn't match what I'm seeing from Stripe + # I'm not sure if it's possible to predict the whole item id now, + # sli seems to not reference anything + item_id_prefix = "{invoice_id}-sli_".format(invoice_id=invoice.id) + self.assertTrue(items[0].id.startswith(item_id_prefix)) + self.assertEqual(items[0].subscription.id, FAKE_SUBSCRIPTION["id"]) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoice_with_no_invoice_items( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data["lines"] = [] + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertIsNotNone(invoice.plan) # retrieved from invoice item + self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoice_with_non_subscription_invoice_items( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data["lines"]["data"].append(deepcopy(FAKE_INVOICEITEM_II)) + invoice_data["lines"]["total_count"] += 1 + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertIsNotNone(invoice) + self.assertEqual(2, len(invoice.invoiceitems.all())) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoice_plan_from_invoice_items( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice = Invoice.sync_from_stripe_data(invoice_data) + + self.assertIsNotNone(invoice.plan) # retrieved from invoice item + self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_invoice_plan_from_subscription( + self, + product_retrieve_mock, + payment_intent_retrieve_mock, + charge_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data["lines"]["data"][0]["plan"] = None + invoice = Invoice.sync_from_stripe_data(invoice_data) + self.assertIsNotNone(invoice.plan) # retrieved from subscription + self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Subscription.retrieve", autospec=True) + @patch( + "stripe.PaymentIntent.retrieve", + return_value=deepcopy(FAKE_PAYMENT_INTENT_I), + autospec=True, + ) + @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE), autospec=True) + def test_invoice_without_plan( + self, + charge_retrieve_mock, + payment_intent_retrieve_mock, + subscription_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account + + invoice_data = deepcopy(FAKE_INVOICE) + invoice_data["lines"]["data"][0]["plan"] = None + invoice_data["lines"]["data"][0]["subscription"] = None + invoice_data["subscription"] = None + invoice = Invoice.sync_from_stripe_data(invoice_data) + self.assertIsNone(invoice.plan) + + self.assert_fks( + invoice, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.Invoice.subscription", + "djstripe.PaymentIntent.on_behalf_of", + "djstripe.PaymentIntent.payment_method", + "djstripe.Plan.product", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch( + "stripe.Plan.retrieve", + return_value=deepcopy(FAKE_PLAN), + autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Invoice.upcoming", + return_value=deepcopy(FAKE_UPCOMING_INVOICE), + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_upcoming_invoice( + self, + product_retrieve_mock, + invoice_upcoming_mock, + subscription_retrieve_mock, + plan_retrieve_mock, + ): + invoice = UpcomingInvoice.upcoming() + self.assertIsNotNone(invoice) + self.assertIsNone(invoice.id) + self.assertIsNone(invoice.save()) + self.assertEqual(invoice.get_stripe_dashboard_url(), "") + + subscription_retrieve_mock.assert_called_once_with( + api_key=ANY, expand=ANY, id=FAKE_SUBSCRIPTION["id"] + ) + plan_retrieve_mock.assert_not_called() + + items = invoice.invoiceitems.all() + self.assertEqual(1, len(items)) + self.assertEqual(FAKE_SUBSCRIPTION["id"], items[0].id) + + # delete/update should do nothing + self.assertEqual(invoice.invoiceitems.update(), 0) + self.assertEqual(invoice.invoiceitems.delete(), 0) + + self.assertIsNotNone(invoice.plan) + self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) + + invoice._invoiceitems = [] + items = invoice.invoiceitems.all() + self.assertEqual(0, len(items)) + self.assertIsNotNone(invoice.plan) + + @patch("stripe.Plan.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Invoice.upcoming", + return_value=deepcopy(FAKE_UPCOMING_INVOICE), + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + def test_upcoming_invoice_with_subscription( + self, + invoice_upcoming_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + invoice = Invoice.upcoming( + subscription=Subscription(id=FAKE_SUBSCRIPTION["id"]) + ) + self.assertIsNotNone(invoice) + self.assertIsNone(invoice.id) + self.assertIsNone(invoice.save()) + + subscription_retrieve_mock.assert_called_once_with( + api_key=ANY, expand=ANY, id=FAKE_SUBSCRIPTION["id"] + ) + plan_retrieve_mock.assert_not_called() + + self.assertIsNotNone(invoice.plan) + self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) + + @patch("stripe.Plan.retrieve", autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Invoice.upcoming", + return_value=deepcopy(FAKE_UPCOMING_INVOICE), + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_upcoming_invoice_with_subscription_plan( + self, + product_retrieve_mock, + invoice_upcoming_mock, + subscription_retrieve_mock, + plan_retrieve_mock, + ): + invoice = Invoice.upcoming(subscription_plan=Plan(id=FAKE_PLAN["id"])) + self.assertIsNotNone(invoice) + self.assertIsNone(invoice.id) + self.assertIsNone(invoice.save()) + + subscription_retrieve_mock.assert_called_once_with( + api_key=ANY, expand=ANY, id=FAKE_SUBSCRIPTION["id"] + ) + plan_retrieve_mock.assert_not_called() + + self.assertIsNotNone(invoice.plan) + self.assertEqual(FAKE_PLAN["id"], invoice.plan.id) + + @patch( + "stripe.Invoice.upcoming", + side_effect=InvalidRequestError("Nothing to invoice for customer", None), + ) + def test_no_upcoming_invoices(self, invoice_upcoming_mock): + invoice = Invoice.upcoming() + self.assertIsNone(invoice) + + @patch( + "stripe.Invoice.upcoming", + side_effect=InvalidRequestError("Some other error", None), + ) + def test_upcoming_invoice_error(self, invoice_upcoming_mock): + with self.assertRaises(InvalidRequestError): + Invoice.upcoming() diff --git a/tests/test_invoiceitem.py b/tests/test_invoiceitem.py index dec6072f77..72459df090 100644 --- a/tests/test_invoiceitem.py +++ b/tests/test_invoiceitem.py @@ -10,295 +10,334 @@ from djstripe.settings import STRIPE_SECRET_KEY from . import ( - FAKE_BALANCE_TRANSACTION, FAKE_CHARGE_II, FAKE_CUSTOMER_II, FAKE_INVOICE_II, - FAKE_INVOICEITEM, FAKE_PLAN_II, FAKE_PRODUCT, FAKE_SUBSCRIPTION_III, - IS_STATICMETHOD_AUTOSPEC_SUPPORTED, AssertStripeFksMixin, default_account + FAKE_BALANCE_TRANSACTION, + FAKE_CHARGE_II, + FAKE_CUSTOMER_II, + FAKE_INVOICE_II, + FAKE_INVOICEITEM, + FAKE_PLAN_II, + FAKE_PRODUCT, + FAKE_SUBSCRIPTION_III, + IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + AssertStripeFksMixin, + default_account, ) class InvoiceItemTest(AssertStripeFksMixin, TestCase): - def setUp(self): - self.account = default_account() + def setUp(self): + self.account = default_account() - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION_III), - autospec=True, - ) - @patch( - "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True) - @patch( - "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True - ) - def test_str( - self, - invoice_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_III), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", + return_value=deepcopy(FAKE_CUSTOMER_II), + autospec=True, + ) + @patch( + "stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True + ) + def test_str( + self, + invoice_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account - invoiceitem_data = deepcopy(FAKE_INVOICEITEM) - invoiceitem_data["plan"] = FAKE_PLAN_II - invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) - self.assertEqual( - invoiceitem.get_stripe_dashboard_url(), - invoiceitem.invoice.get_stripe_dashboard_url(), - ) + invoiceitem_data = deepcopy(FAKE_INVOICEITEM) + invoiceitem_data["plan"] = FAKE_PLAN_II + invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) + self.assertEqual( + invoiceitem.get_stripe_dashboard_url(), + invoiceitem.invoice.get_stripe_dashboard_url(), + ) - self.assertEqual(str(invoiceitem), FAKE_PRODUCT["name"]) - invoiceitem.plan = None - self.assertEqual( - str(invoiceitem), - "", - ) + self.assertEqual(str(invoiceitem), FAKE_PRODUCT["name"]) + invoiceitem.plan = None + self.assertEqual( + str(invoiceitem), + "", + ) - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION_III), - autospec=True, - ) - @patch( - "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True) - @patch( - "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True - ) - def test_sync_with_subscription( - self, - invoice_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_III), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", + return_value=deepcopy(FAKE_CUSTOMER_II), + autospec=True, + ) + @patch( + "stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True + ) + def test_sync_with_subscription( + self, + invoice_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account - invoiceitem_data = deepcopy(FAKE_INVOICEITEM) - invoiceitem_data.update({"subscription": FAKE_SUBSCRIPTION_III["id"]}) - invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) + invoiceitem_data = deepcopy(FAKE_INVOICEITEM) + invoiceitem_data.update({"subscription": FAKE_SUBSCRIPTION_III["id"]}) + invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) - expected_blank_fks = { - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.payment_intent", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.Customer.subscriber", - "djstripe.InvoiceItem.plan", - "djstripe.Invoice.payment_intent", - "djstripe.Subscription.pending_setup_intent", - } + expected_blank_fks = { + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.payment_intent", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.Customer.subscriber", + "djstripe.InvoiceItem.plan", + "djstripe.Invoice.payment_intent", + "djstripe.Subscription.pending_setup_intent", + } - self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks) + self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks) - # Coverage of sync of existing data - invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) + # Coverage of sync of existing data + invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) - self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks) + self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks) - invoice_retrieve_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_INVOICE_II["id"] - ) + invoice_retrieve_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, expand=[], id=FAKE_INVOICE_II["id"] + ) - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION_III), - autospec=True, - ) - @patch( - "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True) - @patch("stripe.Invoice.retrieve", autospec=True) - def test_sync_expanded_invoice_with_subscription( - self, - invoice_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_III), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", + return_value=deepcopy(FAKE_CUSTOMER_II), + autospec=True, + ) + @patch( + "stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True + ) + @patch("stripe.Invoice.retrieve", autospec=True) + def test_sync_expanded_invoice_with_subscription( + self, + invoice_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account - invoiceitem_data = deepcopy(FAKE_INVOICEITEM) - # Expand the Invoice data - invoiceitem_data.update( - { - "subscription": FAKE_SUBSCRIPTION_III["id"], - "invoice": deepcopy(dict(FAKE_INVOICE_II)), - } - ) - invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) + invoiceitem_data = deepcopy(FAKE_INVOICEITEM) + # Expand the Invoice data + invoiceitem_data.update( + { + "subscription": FAKE_SUBSCRIPTION_III["id"], + "invoice": deepcopy(dict(FAKE_INVOICE_II)), + } + ) + invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) - expected_blank_fks = { - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.payment_intent", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.Customer.subscriber", - "djstripe.InvoiceItem.plan", - "djstripe.Invoice.payment_intent", - "djstripe.Subscription.pending_setup_intent", - } + expected_blank_fks = { + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.payment_intent", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.Customer.subscriber", + "djstripe.InvoiceItem.plan", + "djstripe.Invoice.payment_intent", + "djstripe.Subscription.pending_setup_intent", + } - self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks) + self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks) - # Coverage of sync of existing data - invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) + # Coverage of sync of existing data + invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) - self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks) + self.assert_fks(invoiceitem, expected_blank_fks=expected_blank_fks) - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch( - "stripe.BalanceTransaction.retrieve", - return_value=deepcopy(FAKE_BALANCE_TRANSACTION), - autospec=True, - ) - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN_II), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION_III), - autospec=True, - ) - @patch( - "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True) - @patch( - "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True - ) - def test_sync_proration( - self, - invoice_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - balance_transaction_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.BalanceTransaction.retrieve", + return_value=deepcopy(FAKE_BALANCE_TRANSACTION), + autospec=True, + ) + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN_II), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_III), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", + return_value=deepcopy(FAKE_CUSTOMER_II), + autospec=True, + ) + @patch( + "stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True + ) + @patch( + "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE_II), autospec=True + ) + def test_sync_proration( + self, + invoice_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + balance_transaction_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account - invoiceitem_data = deepcopy(FAKE_INVOICEITEM) - invoiceitem_data.update({"proration": True, "plan": FAKE_PLAN_II["id"]}) - invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) + invoiceitem_data = deepcopy(FAKE_INVOICEITEM) + invoiceitem_data.update({"proration": True, "plan": FAKE_PLAN_II["id"]}) + invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) - self.assertEqual(FAKE_PLAN_II["id"], invoiceitem.plan.id) + self.assertEqual(FAKE_PLAN_II["id"], invoiceitem.plan.id) - self.assert_fks( - invoiceitem, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.payment_intent", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.Customer.subscriber", - "djstripe.InvoiceItem.subscription", - "djstripe.Invoice.payment_intent", - "djstripe.Subscription.pending_setup_intent", - }, - ) + self.assert_fks( + invoiceitem, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.payment_intent", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.Customer.subscriber", + "djstripe.InvoiceItem.subscription", + "djstripe.Invoice.payment_intent", + "djstripe.Subscription.pending_setup_intent", + }, + ) - @patch( - "djstripe.models.Account.get_default_account", - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN_II), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION_III), - autospec=True, - ) - @patch( - "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True - ) - @patch("stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True) - @patch("stripe.Invoice.retrieve", autospec=True) - def test_sync_null_invoice( - self, - invoice_retrieve_mock, - charge_retrieve_mock, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - default_account_mock, - ): - default_account_mock.return_value = self.account + @patch( + "djstripe.models.Account.get_default_account", + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN_II), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_III), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", + return_value=deepcopy(FAKE_CUSTOMER_II), + autospec=True, + ) + @patch( + "stripe.Charge.retrieve", return_value=deepcopy(FAKE_CHARGE_II), autospec=True + ) + @patch("stripe.Invoice.retrieve", autospec=True) + def test_sync_null_invoice( + self, + invoice_retrieve_mock, + charge_retrieve_mock, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + default_account_mock, + ): + default_account_mock.return_value = self.account - invoiceitem_data = deepcopy(FAKE_INVOICEITEM) - invoiceitem_data.update( - {"proration": True, "plan": FAKE_PLAN_II["id"], "invoice": None} - ) - invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) + invoiceitem_data = deepcopy(FAKE_INVOICEITEM) + invoiceitem_data.update( + {"proration": True, "plan": FAKE_PLAN_II["id"], "invoice": None} + ) + invoiceitem = InvoiceItem.sync_from_stripe_data(invoiceitem_data) - self.assertEqual(FAKE_PLAN_II["id"], invoiceitem.plan.id) + self.assertEqual(FAKE_PLAN_II["id"], invoiceitem.plan.id) - self.assert_fks( - invoiceitem, - expected_blank_fks={ - "djstripe.Account.branding_logo", - "djstripe.Account.branding_icon", - "djstripe.Charge.dispute", - "djstripe.Charge.transfer", - "djstripe.Customer.coupon", - "djstripe.Customer.subscriber", - "djstripe.InvoiceItem.invoice", - "djstripe.InvoiceItem.subscription", - "djstripe.Invoice.payment_intent", - "djstripe.Subscription.pending_setup_intent", - }, - ) + self.assert_fks( + invoiceitem, + expected_blank_fks={ + "djstripe.Account.branding_logo", + "djstripe.Account.branding_icon", + "djstripe.Charge.dispute", + "djstripe.Charge.transfer", + "djstripe.Customer.coupon", + "djstripe.Customer.subscriber", + "djstripe.InvoiceItem.invoice", + "djstripe.InvoiceItem.subscription", + "djstripe.Invoice.payment_intent", + "djstripe.Subscription.pending_setup_intent", + }, + ) diff --git a/tests/test_managers.py b/tests/test_managers.py index a9abeb34f7..dff99c9f40 100644 --- a/tests/test_managers.py +++ b/tests/test_managers.py @@ -13,261 +13,276 @@ from djstripe.models import Charge, Customer, Plan, Subscription, Transfer from . import ( - FAKE_PLAN, FAKE_PLAN_II, FAKE_PRODUCT, - FAKE_TRANSFER, FAKE_TRANSFER_II, FAKE_TRANSFER_III + FAKE_PLAN, + FAKE_PLAN_II, + FAKE_PRODUCT, + FAKE_TRANSFER, + FAKE_TRANSFER_II, + FAKE_TRANSFER_III, ) class SubscriptionManagerTest(TestCase): - def setUp(self): - # create customers and current subscription records - period_start = datetime.datetime(2013, 4, 1, tzinfo=timezone.utc) - period_end = datetime.datetime(2013, 4, 30, tzinfo=timezone.utc) - start = datetime.datetime( - 2013, 1, 1, 0, 0, 1, tzinfo=timezone.utc - ) # more realistic start - - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - self.plan = Plan.sync_from_stripe_data(FAKE_PLAN) - self.plan2 = Plan.sync_from_stripe_data(FAKE_PLAN_II) - - for i in range(10): - user = get_user_model().objects.create_user( - username="patrick{0}".format(i), email="patrick{0}@example.com".format(i) - ) - customer = Customer.objects.create( - subscriber=user, - id="cus_xxxxxxxxxxxxxx{0}".format(i), - livemode=False, - balance=0, - delinquent=False, - ) - - Subscription.objects.create( - id="sub_xxxxxxxxxxxxxx{0}".format(i), - customer=customer, - plan=self.plan, - current_period_start=period_start, - current_period_end=period_end, - status="active", - start=start, - quantity=1, - ) - - user = get_user_model().objects.create_user( - username="patrick{0}".format(11), email="patrick{0}@example.com".format(11) - ) - customer = Customer.objects.create( - subscriber=user, - id="cus_xxxxxxxxxxxxxx{0}".format(11), - livemode=False, - balance=0, - delinquent=False, - ) - Subscription.objects.create( - id="sub_xxxxxxxxxxxxxx{0}".format(11), - customer=customer, - plan=self.plan, - current_period_start=period_start, - current_period_end=period_end, - status="canceled", - canceled_at=period_end, - start=start, - quantity=1, - ) - - user = get_user_model().objects.create_user( - username="patrick{0}".format(12), email="patrick{0}@example.com".format(12) - ) - customer = Customer.objects.create( - subscriber=user, - id="cus_xxxxxxxxxxxxxx{0}".format(12), - livemode=False, - balance=0, - delinquent=False, - ) - Subscription.objects.create( - id="sub_xxxxxxxxxxxxxx{0}".format(12), - customer=customer, - plan=self.plan2, - current_period_start=period_start, - current_period_end=period_end, - status="active", - start=start, - quantity=1, - ) - - def test_started_during_no_records(self): - self.assertEqual(Subscription.objects.started_during(2013, 4).count(), 0) - - def test_started_during_has_records(self): - self.assertEqual(Subscription.objects.started_during(2013, 1).count(), 12) - - def test_canceled_during(self): - self.assertEqual(Subscription.objects.canceled_during(2013, 4).count(), 1) - - def test_canceled_all(self): - self.assertEqual(Subscription.objects.canceled().count(), 1) - - def test_active_all(self): - self.assertEqual(Subscription.objects.active().count(), 11) - - def test_started_plan_summary(self): - for plan in Subscription.objects.started_plan_summary_for(2013, 1): - if plan["plan"] == self.plan: - self.assertEqual(plan["count"], 11) - if plan["plan"] == self.plan2: - self.assertEqual(plan["count"], 1) - - def test_active_plan_summary(self): - for plan in Subscription.objects.active_plan_summary(): - if plan["plan"] == self.plan: - self.assertEqual(plan["count"], 10) - if plan["plan"] == self.plan2: - self.assertEqual(plan["count"], 1) - - def test_canceled_plan_summary(self): - for plan in Subscription.objects.canceled_plan_summary_for(2013, 1): - if plan["plan"] == self.plan: - self.assertEqual(plan["count"], 1) - if plan["plan"] == self.plan2: - self.assertEqual(plan["count"], 0) - - def test_churn(self): - self.assertEqual( - Subscription.objects.churn(), decimal.Decimal("1") / decimal.Decimal("11") - ) + def setUp(self): + # create customers and current subscription records + period_start = datetime.datetime(2013, 4, 1, tzinfo=timezone.utc) + period_end = datetime.datetime(2013, 4, 30, tzinfo=timezone.utc) + start = datetime.datetime( + 2013, 1, 1, 0, 0, 1, tzinfo=timezone.utc + ) # more realistic start + + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + self.plan = Plan.sync_from_stripe_data(FAKE_PLAN) + self.plan2 = Plan.sync_from_stripe_data(FAKE_PLAN_II) + + for i in range(10): + user = get_user_model().objects.create_user( + username="patrick{0}".format(i), + email="patrick{0}@example.com".format(i), + ) + customer = Customer.objects.create( + subscriber=user, + id="cus_xxxxxxxxxxxxxx{0}".format(i), + livemode=False, + balance=0, + delinquent=False, + ) + + Subscription.objects.create( + id="sub_xxxxxxxxxxxxxx{0}".format(i), + customer=customer, + plan=self.plan, + current_period_start=period_start, + current_period_end=period_end, + status="active", + start=start, + quantity=1, + ) + + user = get_user_model().objects.create_user( + username="patrick{0}".format(11), email="patrick{0}@example.com".format(11) + ) + customer = Customer.objects.create( + subscriber=user, + id="cus_xxxxxxxxxxxxxx{0}".format(11), + livemode=False, + balance=0, + delinquent=False, + ) + Subscription.objects.create( + id="sub_xxxxxxxxxxxxxx{0}".format(11), + customer=customer, + plan=self.plan, + current_period_start=period_start, + current_period_end=period_end, + status="canceled", + canceled_at=period_end, + start=start, + quantity=1, + ) + + user = get_user_model().objects.create_user( + username="patrick{0}".format(12), email="patrick{0}@example.com".format(12) + ) + customer = Customer.objects.create( + subscriber=user, + id="cus_xxxxxxxxxxxxxx{0}".format(12), + livemode=False, + balance=0, + delinquent=False, + ) + Subscription.objects.create( + id="sub_xxxxxxxxxxxxxx{0}".format(12), + customer=customer, + plan=self.plan2, + current_period_start=period_start, + current_period_end=period_end, + status="active", + start=start, + quantity=1, + ) + + def test_started_during_no_records(self): + self.assertEqual(Subscription.objects.started_during(2013, 4).count(), 0) + + def test_started_during_has_records(self): + self.assertEqual(Subscription.objects.started_during(2013, 1).count(), 12) + + def test_canceled_during(self): + self.assertEqual(Subscription.objects.canceled_during(2013, 4).count(), 1) + + def test_canceled_all(self): + self.assertEqual(Subscription.objects.canceled().count(), 1) + + def test_active_all(self): + self.assertEqual(Subscription.objects.active().count(), 11) + + def test_started_plan_summary(self): + for plan in Subscription.objects.started_plan_summary_for(2013, 1): + if plan["plan"] == self.plan: + self.assertEqual(plan["count"], 11) + if plan["plan"] == self.plan2: + self.assertEqual(plan["count"], 1) + + def test_active_plan_summary(self): + for plan in Subscription.objects.active_plan_summary(): + if plan["plan"] == self.plan: + self.assertEqual(plan["count"], 10) + if plan["plan"] == self.plan2: + self.assertEqual(plan["count"], 1) + + def test_canceled_plan_summary(self): + for plan in Subscription.objects.canceled_plan_summary_for(2013, 1): + if plan["plan"] == self.plan: + self.assertEqual(plan["count"], 1) + if plan["plan"] == self.plan2: + self.assertEqual(plan["count"], 0) + + def test_churn(self): + self.assertEqual( + Subscription.objects.churn(), decimal.Decimal("1") / decimal.Decimal("11") + ) class TransferManagerTest(TestCase): - def test_transfer_summary(self): - Transfer.sync_from_stripe_data(deepcopy(FAKE_TRANSFER)) - Transfer.sync_from_stripe_data(deepcopy(FAKE_TRANSFER_II)) - Transfer.sync_from_stripe_data(deepcopy(FAKE_TRANSFER_III)) + def test_transfer_summary(self): + Transfer.sync_from_stripe_data(deepcopy(FAKE_TRANSFER)) + Transfer.sync_from_stripe_data(deepcopy(FAKE_TRANSFER_II)) + Transfer.sync_from_stripe_data(deepcopy(FAKE_TRANSFER_III)) - self.assertEqual(Transfer.objects.during(2015, 8).count(), 2) + self.assertEqual(Transfer.objects.during(2015, 8).count(), 2) - totals = Transfer.objects.paid_totals_for(2015, 12) - self.assertEqual(totals["total_amount"], decimal.Decimal("190.10")) + totals = Transfer.objects.paid_totals_for(2015, 12) + self.assertEqual(totals["total_amount"], decimal.Decimal("190.10")) class ChargeManagerTest(TestCase): - def setUp(self): - customer = Customer.objects.create( - id="cus_XXXXXXX", livemode=False, balance=0, delinquent=False - ) - - self.march_charge = Charge.objects.create( - id="ch_XXXXMAR1", - customer=customer, - created=datetime.datetime(2015, 3, 31, tzinfo=timezone.utc), - amount=0, - amount_refunded=0, - currency="usd", - status="pending", - ) - - self.april_charge_1 = Charge.objects.create( - id="ch_XXXXAPR1", - customer=customer, - created=datetime.datetime(2015, 4, 1, tzinfo=timezone.utc), - amount=decimal.Decimal("20.15"), - amount_refunded=0, - currency="usd", - status="succeeded", - paid=True, - ) - - self.april_charge_2 = Charge.objects.create( - id="ch_XXXXAPR2", - customer=customer, - created=datetime.datetime(2015, 4, 18, tzinfo=timezone.utc), - amount=decimal.Decimal("10.35"), - amount_refunded=decimal.Decimal("5.35"), - currency="usd", - status="succeeded", - paid=True, - ) - - self.april_charge_3 = Charge.objects.create( - id="ch_XXXXAPR3", - customer=customer, - created=datetime.datetime(2015, 4, 30, tzinfo=timezone.utc), - amount=decimal.Decimal("100.00"), - amount_refunded=decimal.Decimal("80.00"), - currency="usd", - status="pending", - paid=False, - ) - - self.may_charge = Charge.objects.create( - id="ch_XXXXMAY1", - customer=customer, - created=datetime.datetime(2015, 5, 1, tzinfo=timezone.utc), - amount=0, - amount_refunded=0, - currency="usd", - status="pending", - ) - - self.november_charge = Charge.objects.create( - id="ch_XXXXNOV1", - customer=customer, - created=datetime.datetime(2015, 11, 16, tzinfo=timezone.utc), - amount=0, - amount_refunded=0, - currency="usd", - status="pending", - ) - - self.charge_2014 = Charge.objects.create( - id="ch_XXXX20141", - customer=customer, - created=datetime.datetime(2014, 12, 31, tzinfo=timezone.utc), - amount=0, - amount_refunded=0, - currency="usd", - status="pending", - ) - - self.charge_2016 = Charge.objects.create( - id="ch_XXXX20161", - customer=customer, - created=datetime.datetime(2016, 1, 1, tzinfo=timezone.utc), - amount=0, - amount_refunded=0, - currency="usd", - status="pending", - ) - - def test_is_during_april_2015(self): - raw_charges = Charge.objects.during(year=2015, month=4) - charges = [charge.id for charge in raw_charges] - - self.assertIn(self.april_charge_1.id, charges, "April charge 1 not in charges.") - self.assertIn(self.april_charge_2.id, charges, "April charge 2 not in charges.") - self.assertIn(self.april_charge_3.id, charges, "April charge 3 not in charges.") - - self.assertNotIn( - self.march_charge.id, charges, "March charge unexpectedly in charges." - ) - self.assertNotIn(self.may_charge.id, charges, "May charge unexpectedly in charges.") - self.assertNotIn( - self.november_charge.id, charges, "November charge unexpectedly in charges." - ) - self.assertNotIn(self.charge_2014.id, charges, "2014 charge unexpectedly in charges.") - self.assertNotIn(self.charge_2016.id, charges, "2016 charge unexpectedly in charges.") - - def test_get_paid_totals_for_april_2015(self): - paid_totals = Charge.objects.paid_totals_for(year=2015, month=4) - - self.assertEqual( - decimal.Decimal("30.50"), paid_totals["total_amount"], "Total amount is not correct." - ) - self.assertEqual( - decimal.Decimal("5.35"), - paid_totals["total_refunded"], - "Total amount refunded is not correct.", - ) + def setUp(self): + customer = Customer.objects.create( + id="cus_XXXXXXX", livemode=False, balance=0, delinquent=False + ) + + self.march_charge = Charge.objects.create( + id="ch_XXXXMAR1", + customer=customer, + created=datetime.datetime(2015, 3, 31, tzinfo=timezone.utc), + amount=0, + amount_refunded=0, + currency="usd", + status="pending", + ) + + self.april_charge_1 = Charge.objects.create( + id="ch_XXXXAPR1", + customer=customer, + created=datetime.datetime(2015, 4, 1, tzinfo=timezone.utc), + amount=decimal.Decimal("20.15"), + amount_refunded=0, + currency="usd", + status="succeeded", + paid=True, + ) + + self.april_charge_2 = Charge.objects.create( + id="ch_XXXXAPR2", + customer=customer, + created=datetime.datetime(2015, 4, 18, tzinfo=timezone.utc), + amount=decimal.Decimal("10.35"), + amount_refunded=decimal.Decimal("5.35"), + currency="usd", + status="succeeded", + paid=True, + ) + + self.april_charge_3 = Charge.objects.create( + id="ch_XXXXAPR3", + customer=customer, + created=datetime.datetime(2015, 4, 30, tzinfo=timezone.utc), + amount=decimal.Decimal("100.00"), + amount_refunded=decimal.Decimal("80.00"), + currency="usd", + status="pending", + paid=False, + ) + + self.may_charge = Charge.objects.create( + id="ch_XXXXMAY1", + customer=customer, + created=datetime.datetime(2015, 5, 1, tzinfo=timezone.utc), + amount=0, + amount_refunded=0, + currency="usd", + status="pending", + ) + + self.november_charge = Charge.objects.create( + id="ch_XXXXNOV1", + customer=customer, + created=datetime.datetime(2015, 11, 16, tzinfo=timezone.utc), + amount=0, + amount_refunded=0, + currency="usd", + status="pending", + ) + + self.charge_2014 = Charge.objects.create( + id="ch_XXXX20141", + customer=customer, + created=datetime.datetime(2014, 12, 31, tzinfo=timezone.utc), + amount=0, + amount_refunded=0, + currency="usd", + status="pending", + ) + + self.charge_2016 = Charge.objects.create( + id="ch_XXXX20161", + customer=customer, + created=datetime.datetime(2016, 1, 1, tzinfo=timezone.utc), + amount=0, + amount_refunded=0, + currency="usd", + status="pending", + ) + + def test_is_during_april_2015(self): + raw_charges = Charge.objects.during(year=2015, month=4) + charges = [charge.id for charge in raw_charges] + + self.assertIn(self.april_charge_1.id, charges, "April charge 1 not in charges.") + self.assertIn(self.april_charge_2.id, charges, "April charge 2 not in charges.") + self.assertIn(self.april_charge_3.id, charges, "April charge 3 not in charges.") + + self.assertNotIn( + self.march_charge.id, charges, "March charge unexpectedly in charges." + ) + self.assertNotIn( + self.may_charge.id, charges, "May charge unexpectedly in charges." + ) + self.assertNotIn( + self.november_charge.id, charges, "November charge unexpectedly in charges." + ) + self.assertNotIn( + self.charge_2014.id, charges, "2014 charge unexpectedly in charges." + ) + self.assertNotIn( + self.charge_2016.id, charges, "2016 charge unexpectedly in charges." + ) + + def test_get_paid_totals_for_april_2015(self): + paid_totals = Charge.objects.paid_totals_for(year=2015, month=4) + + self.assertEqual( + decimal.Decimal("30.50"), + paid_totals["total_amount"], + "Total amount is not correct.", + ) + self.assertEqual( + decimal.Decimal("5.35"), + paid_totals["total_refunded"], + "Total amount refunded is not correct.", + ) diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 67362890f3..377879de37 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -14,156 +14,163 @@ from djstripe.models import Customer, Subscription from . import ( - FAKE_CUSTOMER, FAKE_PRODUCT, FAKE_SUBSCRIPTION, - FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT, FUTURE_DATE + FAKE_CUSTOMER, + FAKE_PRODUCT, + FAKE_SUBSCRIPTION, + FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT, + FUTURE_DATE, ) class MiddlewareURLTest(TestCase): - urlconf = "tests.urls" - - def setUp(self): - self.settings(ROOT_URLCONF=self.urlconf) - self.factory = RequestFactory() - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.middleware = SubscriptionPaymentMiddleware() - - def test_appname(self): - request = self.factory.get("/admin/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - def test_namespace(self): - request = self.factory.get("/djstripe/webhook/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - def test_namespace_and_url(self): - request = self.factory.get("/testapp_namespaced/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - def test_url(self): - request = self.factory.get("/testapp/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - @override_settings(DEBUG=True) - def test_djdt(self): - request = self.factory.get("/__debug__/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - def test_fnmatch(self): - request = self.factory.get("/test_fnmatch/extra_text/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - @override_settings(DEBUG=True) - @modify_settings( - MIDDLEWARE={"append": ["djstripe.middleware.SubscriptionPaymentMiddleware"]} - ) - def test_middleware_loads(self): - """Check that the middleware can be loaded by django's - middleware handlers. This is to check for compatibility across - the change to django's middleware class structure. See - https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware - """ - self.client.get("/__debug__") + urlconf = "tests.urls" + + def setUp(self): + self.settings(ROOT_URLCONF=self.urlconf) + self.factory = RequestFactory() + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.middleware = SubscriptionPaymentMiddleware() + + def test_appname(self): + request = self.factory.get("/admin/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + def test_namespace(self): + request = self.factory.get("/djstripe/webhook/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + def test_namespace_and_url(self): + request = self.factory.get("/testapp_namespaced/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + def test_url(self): + request = self.factory.get("/testapp/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + @override_settings(DEBUG=True) + def test_djdt(self): + request = self.factory.get("/__debug__/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + def test_fnmatch(self): + request = self.factory.get("/test_fnmatch/extra_text/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + @override_settings(DEBUG=True) + @modify_settings( + MIDDLEWARE={"append": ["djstripe.middleware.SubscriptionPaymentMiddleware"]} + ) + def test_middleware_loads(self): + """Check that the middleware can be loaded by django's + middleware handlers. This is to check for compatibility across + the change to django's middleware class structure. See + https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware + """ + self.client.get("/__debug__") class MiddlewareLogicTest(TestCase): - urlconf = "tests.urls" - - def setUp(self): - self.settings(ROOT_URLCONF=self.urlconf) - self.factory = RequestFactory() - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = Customer.sync_from_stripe_data(FAKE_CUSTOMER) - self.customer.subscriber = self.user - self.customer.save() - - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - self.subscription = Subscription.sync_from_stripe_data(FAKE_SUBSCRIPTION) - - self.middleware = SubscriptionPaymentMiddleware() - - def test_anonymous(self): - request = self.factory.get("/djstripe/webhook/") - request.user = AnonymousUser() - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - def test_is_staff(self): - self.user.is_staff = True - self.user.save() - - request = self.factory.get("/djstripe/webhook/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - def test_is_superuser(self): - self.user.is_superuser = True - self.user.save() - - request = self.factory.get("/djstripe/webhook/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) - - def test_customer_has_inactive_subscription(self): - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - self.subscription = Subscription.sync_from_stripe_data( - FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT - ) - - request = self.factory.get("/testapp_content/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response.status_code, 302) - - def test_customer_has_active_subscription(self): - self.subscription.current_period_end = FUTURE_DATE - self.subscription.save() - - request = self.factory.get("/testapp_content/") - request.user = self.user - request.urlconf = self.urlconf - - response = self.middleware.process_request(request) - self.assertEqual(response, None) + urlconf = "tests.urls" + + def setUp(self): + self.settings(ROOT_URLCONF=self.urlconf) + self.factory = RequestFactory() + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = Customer.sync_from_stripe_data(FAKE_CUSTOMER) + self.customer.subscriber = self.user + self.customer.save() + + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + self.subscription = Subscription.sync_from_stripe_data(FAKE_SUBSCRIPTION) + + self.middleware = SubscriptionPaymentMiddleware() + + def test_anonymous(self): + request = self.factory.get("/djstripe/webhook/") + request.user = AnonymousUser() + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + def test_is_staff(self): + self.user.is_staff = True + self.user.save() + + request = self.factory.get("/djstripe/webhook/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + def test_is_superuser(self): + self.user.is_superuser = True + self.user.save() + + request = self.factory.get("/djstripe/webhook/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) + + def test_customer_has_inactive_subscription(self): + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + self.subscription = Subscription.sync_from_stripe_data( + FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT + ) + + request = self.factory.get("/testapp_content/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response.status_code, 302) + + def test_customer_has_active_subscription(self): + self.subscription.current_period_end = FUTURE_DATE + self.subscription.save() + + request = self.factory.get("/testapp_content/") + request.user = self.user + request.urlconf = self.urlconf + + response = self.middleware.process_request(request) + self.assertEqual(response, None) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 1d652d1adf..93b18b63ea 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -16,50 +16,62 @@ class TestPaymentsContextMixin(TestCase): - def test_get_context_data(self): - class TestSuperView(object): - def get_context_data(self): - return {} - - class TestView(PaymentsContextMixin, TestSuperView): - pass - - context = TestView().get_context_data() - self.assertIn("STRIPE_PUBLIC_KEY", context, "STRIPE_PUBLIC_KEY missing from context.") - self.assertEqual( - context["STRIPE_PUBLIC_KEY"], STRIPE_PUBLIC_KEY, "Incorrect STRIPE_PUBLIC_KEY." - ) - - self.assertIn("plans", context, "pans missing from context.") - self.assertEqual(list(Plan.objects.all()), list(context["plans"]), "Incorrect plans.") + def test_get_context_data(self): + class TestSuperView(object): + def get_context_data(self): + return {} + + class TestView(PaymentsContextMixin, TestSuperView): + pass + + context = TestView().get_context_data() + self.assertIn( + "STRIPE_PUBLIC_KEY", context, "STRIPE_PUBLIC_KEY missing from context." + ) + self.assertEqual( + context["STRIPE_PUBLIC_KEY"], + STRIPE_PUBLIC_KEY, + "Incorrect STRIPE_PUBLIC_KEY.", + ) + + self.assertIn("plans", context, "pans missing from context.") + self.assertEqual( + list(Plan.objects.all()), list(context["plans"]), "Incorrect plans." + ) class TestSubscriptionMixin(TestCase): - def setUp(self): - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN_II)) - - @patch("stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_get_context_data(self, stripe_create_customer_mock): - class TestSuperView(object): - def get_context_data(self): - return {} - - class TestView(SubscriptionMixin, TestSuperView): - pass - - test_view = TestView() - - test_view.request = RequestFactory() - test_view.request.user = get_user_model().objects.create( - username="x", email="user@test.com" - ) - - context = test_view.get_context_data() - self.assertIn("is_plans_plural", context, "is_plans_plural missing from context.") - self.assertTrue(context["is_plans_plural"], "Incorrect is_plans_plural.") - - self.assertIn("customer", context, "customer missing from context.") + def setUp(self): + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN_II)) + + @patch( + "stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_get_context_data(self, stripe_create_customer_mock): + class TestSuperView(object): + def get_context_data(self): + return {} + + class TestView(SubscriptionMixin, TestSuperView): + pass + + test_view = TestView() + + test_view.request = RequestFactory() + test_view.request.user = get_user_model().objects.create( + username="x", email="user@test.com" + ) + + context = test_view.get_context_data() + self.assertIn( + "is_plans_plural", context, "is_plans_plural missing from context." + ) + self.assertTrue(context["is_plans_plural"], "Incorrect is_plans_plural.") + + self.assertIn("customer", context, "customer missing from context.") diff --git a/tests/test_payment_method.py b/tests/test_payment_method.py index 63aaa68e1e..0a868d167f 100644 --- a/tests/test_payment_method.py +++ b/tests/test_payment_method.py @@ -7,45 +7,48 @@ from django.contrib.auth import get_user_model from django.test import TestCase from tests import ( - FAKE_CUSTOMER, FAKE_PAYMENT_METHOD_I, AssertStripeFksMixin, default_account + FAKE_CUSTOMER, + FAKE_PAYMENT_METHOD_I, + AssertStripeFksMixin, + default_account, ) from djstripe.models import PaymentMethod class PaymentMethodTest(AssertStripeFksMixin, TestCase): - def setUp(self): - self.account = default_account() - self.user = get_user_model().objects.create_user( - username="testuser", email="djstripe@example.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - # TODO - this should use autospec=True, but it's failing for some reason - # with unexpected keyword argument "customer" - @patch("stripe.PaymentMethod.attach", return_value=deepcopy(FAKE_PAYMENT_METHOD_I)) - def test_attach(self, attach_mock): - payment_method = PaymentMethod.attach( - FAKE_PAYMENT_METHOD_I["id"], stripe_customer=FAKE_CUSTOMER - ) - - self.assert_fks(payment_method, expected_blank_fks={"djstripe.Customer.coupon"}) - - # TODO - this should use autospec=True, but it's failing for some reason - # with unexpected keyword argument "customer" - @patch("stripe.PaymentMethod.attach", return_value=deepcopy(FAKE_PAYMENT_METHOD_I)) - def test_attach_synced(self, attach_mock): - fake_payment_method = deepcopy(FAKE_PAYMENT_METHOD_I) - fake_payment_method["customer"] = None - - payment_method = PaymentMethod.sync_from_stripe_data(fake_payment_method) - - self.assert_fks( - payment_method, expected_blank_fks={"djstripe.PaymentMethod.customer"} - ) - - payment_method = PaymentMethod.attach( - payment_method.id, stripe_customer=FAKE_CUSTOMER - ) - - self.assert_fks(payment_method, expected_blank_fks={"djstripe.Customer.coupon"}) + def setUp(self): + self.account = default_account() + self.user = get_user_model().objects.create_user( + username="testuser", email="djstripe@example.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + # TODO - this should use autospec=True, but it's failing for some reason + # with unexpected keyword argument "customer" + @patch("stripe.PaymentMethod.attach", return_value=deepcopy(FAKE_PAYMENT_METHOD_I)) + def test_attach(self, attach_mock): + payment_method = PaymentMethod.attach( + FAKE_PAYMENT_METHOD_I["id"], stripe_customer=FAKE_CUSTOMER + ) + + self.assert_fks(payment_method, expected_blank_fks={"djstripe.Customer.coupon"}) + + # TODO - this should use autospec=True, but it's failing for some reason + # with unexpected keyword argument "customer" + @patch("stripe.PaymentMethod.attach", return_value=deepcopy(FAKE_PAYMENT_METHOD_I)) + def test_attach_synced(self, attach_mock): + fake_payment_method = deepcopy(FAKE_PAYMENT_METHOD_I) + fake_payment_method["customer"] = None + + payment_method = PaymentMethod.sync_from_stripe_data(fake_payment_method) + + self.assert_fks( + payment_method, expected_blank_fks={"djstripe.PaymentMethod.customer"} + ) + + payment_method = PaymentMethod.attach( + payment_method.id, stripe_customer=FAKE_CUSTOMER + ) + + self.assert_fks(payment_method, expected_blank_fks={"djstripe.Customer.coupon"}) diff --git a/tests/test_plan.py b/tests/test_plan.py index d86643f665..04072f50e9 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -14,267 +14,288 @@ from djstripe.settings import STRIPE_SECRET_KEY from . import ( - FAKE_PLAN, FAKE_PLAN_II, FAKE_PLAN_METERED, - FAKE_PRODUCT, FAKE_TIER_PLAN, AssertStripeFksMixin + FAKE_PLAN, + FAKE_PLAN_II, + FAKE_PLAN_METERED, + FAKE_PRODUCT, + FAKE_TIER_PLAN, + AssertStripeFksMixin, ) class TestPlanAdmin(TestCase): - class FakeForm(object): - cleaned_data = {} + class FakeForm(object): + cleaned_data = {} - class FakeRequest(object): - pass + class FakeRequest(object): + pass - def setUp(self): - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - self.plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) + def setUp(self): + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + self.plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN)) - self.site = AdminSite() - self.plan_admin = PlanAdmin(Plan, self.site) + self.site = AdminSite() + self.plan_admin = PlanAdmin(Plan, self.site) - @patch("stripe.Plan.retrieve", autospec=True) - def test_update_name(self, plan_retrieve_mock): - new_name = "Updated Plan Name" + @patch("stripe.Plan.retrieve", autospec=True) + def test_update_name(self, plan_retrieve_mock): + new_name = "Updated Plan Name" - self.plan.name = new_name - self.plan.update_name() + self.plan.name = new_name + self.plan.update_name() - # Would throw DoesNotExist if it didn't work - Plan.objects.get(name="Updated Plan Name") + # Would throw DoesNotExist if it didn't work + Plan.objects.get(name="Updated Plan Name") - @patch("stripe.Plan.create", return_value=FAKE_PLAN_II, autospec=True) - @patch("stripe.Plan.retrieve", autospec=True) - def test_that_admin_save_does_create_new_object( - self, plan_retrieve_mock, plan_create_mock - ): - fake_form = self.FakeForm() - plan_data = Plan._stripe_object_to_record(deepcopy(FAKE_PLAN_II)) + @patch("stripe.Plan.create", return_value=FAKE_PLAN_II, autospec=True) + @patch("stripe.Plan.retrieve", autospec=True) + def test_that_admin_save_does_create_new_object( + self, plan_retrieve_mock, plan_create_mock + ): + fake_form = self.FakeForm() + plan_data = Plan._stripe_object_to_record(deepcopy(FAKE_PLAN_II)) - fake_form.cleaned_data = plan_data + fake_form.cleaned_data = plan_data - self.plan_admin.save_model( - request=self.FakeRequest(), obj=None, form=fake_form, change=False - ) + self.plan_admin.save_model( + request=self.FakeRequest(), obj=None, form=fake_form, change=False + ) - # Would throw DoesNotExist if it didn't work - Plan.objects.get(id=plan_data["id"]) + # Would throw DoesNotExist if it didn't work + Plan.objects.get(id=plan_data["id"]) - @patch("stripe.Plan.create", autospec=True) - @patch("stripe.Plan.retrieve", autospec=True) - def test_that_admin_save_does_update_object( - self, plan_retrieve_mock, plan_create_mock - ): - self.plan.name = "A new name (again)" + @patch("stripe.Plan.create", autospec=True) + @patch("stripe.Plan.retrieve", autospec=True) + def test_that_admin_save_does_update_object( + self, plan_retrieve_mock, plan_create_mock + ): + self.plan.name = "A new name (again)" - self.plan_admin.save_model( - request=self.FakeRequest(), obj=self.plan, form=self.FakeForm(), change=True - ) + self.plan_admin.save_model( + request=self.FakeRequest(), obj=self.plan, form=self.FakeForm(), change=True + ) - # Would throw DoesNotExist if it didn't work - Plan.objects.get(name=self.plan.name) + # Would throw DoesNotExist if it didn't work + Plan.objects.get(name=self.plan.name) class PlanCreateTest(AssertStripeFksMixin, TestCase): - def setUp(self): - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - self.stripe_product = Product(id=FAKE_PRODUCT["id"]).api_retrieve() - - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Plan.create", return_value=deepcopy(FAKE_PLAN), autospec=True) - def test_create_from_product_id(self, plan_create_mock, product_retrieve_mock): - fake_plan = deepcopy(FAKE_PLAN) - fake_plan["amount"] = fake_plan["amount"] / 100 - self.assertIsInstance(fake_plan["product"], str) - - plan = Plan.create(**fake_plan) - - expected_create_kwargs = deepcopy(FAKE_PLAN) - expected_create_kwargs["api_key"] = STRIPE_SECRET_KEY - - plan_create_mock.assert_called_once_with(**expected_create_kwargs) - - self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) - - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Plan.create", return_value=deepcopy(FAKE_PLAN), autospec=True) - def test_create_from_stripe_product(self, plan_create_mock, product_retrieve_mock): - fake_plan = deepcopy(FAKE_PLAN) - fake_plan["product"] = self.stripe_product - fake_plan["amount"] = fake_plan["amount"] / 100 - self.assertIsInstance(fake_plan["product"], dict) - - plan = Plan.create(**fake_plan) - - expected_create_kwargs = deepcopy(FAKE_PLAN) - expected_create_kwargs["product"] = self.stripe_product - - plan_create_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, **expected_create_kwargs - ) - - self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) - - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Plan.create", return_value=deepcopy(FAKE_PLAN), autospec=True) - def test_create_from_djstripe_product(self, plan_create_mock, product_retrieve_mock): - fake_plan = deepcopy(FAKE_PLAN) - fake_plan["product"] = Product.sync_from_stripe_data(self.stripe_product) - fake_plan["amount"] = fake_plan["amount"] / 100 - self.assertIsInstance(fake_plan["product"], Product) - - plan = Plan.create(**fake_plan) - - plan_create_mock.assert_called_once_with(api_key=STRIPE_SECRET_KEY, **FAKE_PLAN) - - self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) - - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Plan.create", return_value=deepcopy(FAKE_PLAN), autospec=True) - def test_create_with_metadata(self, plan_create_mock, product_retrieve_mock): - metadata = {"other_data": "more_data"} - fake_plan = deepcopy(FAKE_PLAN) - fake_plan["amount"] = fake_plan["amount"] / 100 - fake_plan["metadata"] = metadata - self.assertIsInstance(fake_plan["product"], str) - - plan = Plan.create(**fake_plan) - - expected_create_kwargs = deepcopy(FAKE_PLAN) - expected_create_kwargs["metadata"] = metadata - - plan_create_mock.assert_called_once_with( - api_key=STRIPE_SECRET_KEY, **expected_create_kwargs - ) - - self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) + def setUp(self): + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + self.stripe_product = Product(id=FAKE_PRODUCT["id"]).api_retrieve() + + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Plan.create", return_value=deepcopy(FAKE_PLAN), autospec=True) + def test_create_from_product_id(self, plan_create_mock, product_retrieve_mock): + fake_plan = deepcopy(FAKE_PLAN) + fake_plan["amount"] = fake_plan["amount"] / 100 + self.assertIsInstance(fake_plan["product"], str) + + plan = Plan.create(**fake_plan) + + expected_create_kwargs = deepcopy(FAKE_PLAN) + expected_create_kwargs["api_key"] = STRIPE_SECRET_KEY + + plan_create_mock.assert_called_once_with(**expected_create_kwargs) + + self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) + + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Plan.create", return_value=deepcopy(FAKE_PLAN), autospec=True) + def test_create_from_stripe_product(self, plan_create_mock, product_retrieve_mock): + fake_plan = deepcopy(FAKE_PLAN) + fake_plan["product"] = self.stripe_product + fake_plan["amount"] = fake_plan["amount"] / 100 + self.assertIsInstance(fake_plan["product"], dict) + + plan = Plan.create(**fake_plan) + + expected_create_kwargs = deepcopy(FAKE_PLAN) + expected_create_kwargs["product"] = self.stripe_product + + plan_create_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, **expected_create_kwargs + ) + + self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) + + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Plan.create", return_value=deepcopy(FAKE_PLAN), autospec=True) + def test_create_from_djstripe_product( + self, plan_create_mock, product_retrieve_mock + ): + fake_plan = deepcopy(FAKE_PLAN) + fake_plan["product"] = Product.sync_from_stripe_data(self.stripe_product) + fake_plan["amount"] = fake_plan["amount"] / 100 + self.assertIsInstance(fake_plan["product"], Product) + + plan = Plan.create(**fake_plan) + + plan_create_mock.assert_called_once_with(api_key=STRIPE_SECRET_KEY, **FAKE_PLAN) + + self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) + + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Plan.create", return_value=deepcopy(FAKE_PLAN), autospec=True) + def test_create_with_metadata(self, plan_create_mock, product_retrieve_mock): + metadata = {"other_data": "more_data"} + fake_plan = deepcopy(FAKE_PLAN) + fake_plan["amount"] = fake_plan["amount"] / 100 + fake_plan["metadata"] = metadata + self.assertIsInstance(fake_plan["product"], str) + + plan = Plan.create(**fake_plan) + + expected_create_kwargs = deepcopy(FAKE_PLAN) + expected_create_kwargs["metadata"] = metadata + + plan_create_mock.assert_called_once_with( + api_key=STRIPE_SECRET_KEY, **expected_create_kwargs + ) + + self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) class PlanTest(AssertStripeFksMixin, TestCase): - def setUp(self): - self.plan_data = deepcopy(FAKE_PLAN) - with patch( - "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True - ): - self.plan = Plan.sync_from_stripe_data(self.plan_data) - - def test_str(self): - self.assertEqual(str(self.plan), self.plan_data["nickname"]) - - @patch("stripe.Plan.retrieve", return_value=FAKE_PLAN, autospec=True) - def test_stripe_plan(self, plan_retrieve_mock): - stripe_plan = self.plan.api_retrieve() - plan_retrieve_mock.assert_called_once_with( - id=self.plan_data["id"], api_key=STRIPE_SECRET_KEY, expand=[] - ) - plan = Plan.sync_from_stripe_data(stripe_plan) - assert plan.amount_in_cents == plan.amount * 100 - assert isinstance(plan.amount_in_cents, int) - - self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) - - @patch("stripe.Product.retrieve", autospec=True) - def test_stripe_plan_null_product(self, product_retrieve_mock): - """ - assert that plan.Product can be null for backwards compatibility - though note that it is a Stripe required field - """ - plan_data = deepcopy(FAKE_PLAN_II) - del plan_data["product"] - plan = Plan.sync_from_stripe_data(plan_data) - - self.assert_fks( - plan, expected_blank_fks={"djstripe.Customer.coupon", "djstripe.Plan.product"} - ) - - @patch("stripe.Plan.retrieve", autospec=True) - def test_stripe_tier_plan(self, plan_retrieve_mock): - tier_plan_data = deepcopy(FAKE_TIER_PLAN) - plan = Plan.sync_from_stripe_data(tier_plan_data) - self.assertEqual(plan.id, tier_plan_data["id"]) - self.assertIsNone(plan.amount) - self.assertIsNotNone(plan.tiers) - - self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) - - @patch("stripe.Plan.retrieve", autospec=True) - def test_stripe_metered_plan(self, plan_retrieve_mock): - plan_data = deepcopy(FAKE_PLAN_METERED) - plan = Plan.sync_from_stripe_data(plan_data) - self.assertEqual(plan.id, plan_data["id"]) - self.assertEqual(plan.usage_type, PlanUsageType.metered) - self.assertIsNotNone(plan.amount) - - self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) + def setUp(self): + self.plan_data = deepcopy(FAKE_PLAN) + with patch( + "stripe.Product.retrieve", + return_value=deepcopy(FAKE_PRODUCT), + autospec=True, + ): + self.plan = Plan.sync_from_stripe_data(self.plan_data) + + def test_str(self): + self.assertEqual(str(self.plan), self.plan_data["nickname"]) + + @patch("stripe.Plan.retrieve", return_value=FAKE_PLAN, autospec=True) + def test_stripe_plan(self, plan_retrieve_mock): + stripe_plan = self.plan.api_retrieve() + plan_retrieve_mock.assert_called_once_with( + id=self.plan_data["id"], api_key=STRIPE_SECRET_KEY, expand=[] + ) + plan = Plan.sync_from_stripe_data(stripe_plan) + assert plan.amount_in_cents == plan.amount * 100 + assert isinstance(plan.amount_in_cents, int) + + self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) + + @patch("stripe.Product.retrieve", autospec=True) + def test_stripe_plan_null_product(self, product_retrieve_mock): + """ + assert that plan.Product can be null for backwards compatibility + though note that it is a Stripe required field + """ + plan_data = deepcopy(FAKE_PLAN_II) + del plan_data["product"] + plan = Plan.sync_from_stripe_data(plan_data) + + self.assert_fks( + plan, + expected_blank_fks={"djstripe.Customer.coupon", "djstripe.Plan.product"}, + ) + + @patch("stripe.Plan.retrieve", autospec=True) + def test_stripe_tier_plan(self, plan_retrieve_mock): + tier_plan_data = deepcopy(FAKE_TIER_PLAN) + plan = Plan.sync_from_stripe_data(tier_plan_data) + self.assertEqual(plan.id, tier_plan_data["id"]) + self.assertIsNone(plan.amount) + self.assertIsNotNone(plan.tiers) + + self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) + + @patch("stripe.Plan.retrieve", autospec=True) + def test_stripe_metered_plan(self, plan_retrieve_mock): + plan_data = deepcopy(FAKE_PLAN_METERED) + plan = Plan.sync_from_stripe_data(plan_data) + self.assertEqual(plan.id, plan_data["id"]) + self.assertEqual(plan.usage_type, PlanUsageType.metered) + self.assertIsNotNone(plan.amount) + + self.assert_fks(plan, expected_blank_fks={"djstripe.Customer.coupon"}) class HumanReadablePlanTest(TestCase): - def test_human_readable_free_usd_daily(self): - plan = Plan.objects.create( - id="plan-test-free-usd-daily", - active=True, - amount=0, - currency="usd", - interval="day", - interval_count=1, - ) - self.assertEqual(plan.human_readable_price, "$0.00 USD/day") - - def test_human_readable_10_usd_weekly(self): - plan = Plan.objects.create( - id="plan-test-10-usd-weekly", - active=True, - amount=10, - currency="usd", - interval="week", - interval_count=1, - ) - self.assertEqual(plan.human_readable_price, "$10.00 USD/week") - - def test_human_readable_10_usd_2weeks(self): - plan = Plan.objects.create( - id="plan-test-10-usd-2w", - active=True, - amount=10, - currency="usd", - interval="week", - interval_count=2, - ) - self.assertEqual(plan.human_readable_price, "$10.00 USD every 2 weeks") - - def test_human_readable_499_usd_monthly(self): - plan = Plan.objects.create( - id="plan-test-499-usd-monthly", - active=True, - amount=Decimal("4.99"), - currency="usd", - interval="month", - interval_count=1, - ) - self.assertEqual(plan.human_readable_price, "$4.99 USD/month") - - def test_human_readable_25_usd_6months(self): - plan = Plan.objects.create( - id="plan-test-25-usd-6m", - active=True, - amount=25, - currency="usd", - interval="month", - interval_count=6, - ) - self.assertEqual(plan.human_readable_price, "$25.00 USD every 6 months") - - def test_human_readable_10_usd_yearly(self): - plan = Plan.objects.create( - id="plan-test-10-usd-yearly", - active=True, - amount=10, - currency="usd", - interval="year", - interval_count=1, - ) - self.assertEqual(plan.human_readable_price, "$10.00 USD/year") + def test_human_readable_free_usd_daily(self): + plan = Plan.objects.create( + id="plan-test-free-usd-daily", + active=True, + amount=0, + currency="usd", + interval="day", + interval_count=1, + ) + self.assertEqual(plan.human_readable_price, "$0.00 USD/day") + + def test_human_readable_10_usd_weekly(self): + plan = Plan.objects.create( + id="plan-test-10-usd-weekly", + active=True, + amount=10, + currency="usd", + interval="week", + interval_count=1, + ) + self.assertEqual(plan.human_readable_price, "$10.00 USD/week") + + def test_human_readable_10_usd_2weeks(self): + plan = Plan.objects.create( + id="plan-test-10-usd-2w", + active=True, + amount=10, + currency="usd", + interval="week", + interval_count=2, + ) + self.assertEqual(plan.human_readable_price, "$10.00 USD every 2 weeks") + + def test_human_readable_499_usd_monthly(self): + plan = Plan.objects.create( + id="plan-test-499-usd-monthly", + active=True, + amount=Decimal("4.99"), + currency="usd", + interval="month", + interval_count=1, + ) + self.assertEqual(plan.human_readable_price, "$4.99 USD/month") + + def test_human_readable_25_usd_6months(self): + plan = Plan.objects.create( + id="plan-test-25-usd-6m", + active=True, + amount=25, + currency="usd", + interval="month", + interval_count=6, + ) + self.assertEqual(plan.human_readable_price, "$25.00 USD every 6 months") + + def test_human_readable_10_usd_yearly(self): + plan = Plan.objects.create( + id="plan-test-10-usd-yearly", + active=True, + amount=10, + currency="usd", + interval="year", + interval_count=1, + ) + self.assertEqual(plan.human_readable_price, "$10.00 USD/year") diff --git a/tests/test_settings.py b/tests/test_settings.py index 14e7c8b3c0..d27d1182e0 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -11,141 +11,146 @@ from djstripe import settings as djstripe_settings from djstripe.settings import ( - get_callback_function, get_stripe_api_version, - get_subscriber_model, set_stripe_api_version + get_callback_function, + get_stripe_api_version, + get_subscriber_model, + set_stripe_api_version, ) class TestSubscriberModelRetrievalMethod(TestCase): - def test_with_user(self): - user_model = get_subscriber_model() - self.assertTrue(isinstance(user_model, ModelBase)) - - @override_settings( - DJSTRIPE_SUBSCRIBER_MODEL="testapp.Organization", - DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), - ) - def test_with_org(self): - org_model = get_subscriber_model() - self.assertTrue(isinstance(org_model, ModelBase)) - - @override_settings( - DJSTRIPE_SUBSCRIBER_MODEL="testapp.StaticEmailOrganization", - DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), - ) - def test_with_org_static(self): - org_model = get_subscriber_model() - self.assertTrue(isinstance(org_model, ModelBase)) - - @override_settings( - DJSTRIPE_SUBSCRIBER_MODEL="testappStaticEmailOrganization", - DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), - ) - def test_bad_model_name(self): - self.assertRaisesMessage( - ImproperlyConfigured, - "DJSTRIPE_SUBSCRIBER_MODEL must be of the form 'app_label.model_name'.", - get_subscriber_model, - ) - - @override_settings( - DJSTRIPE_SUBSCRIBER_MODEL="testapp.UnknownModel", - DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), - ) - def test_unknown_model(self): - self.assertRaisesMessage( - ImproperlyConfigured, - "DJSTRIPE_SUBSCRIBER_MODEL refers to model 'testapp.UnknownModel' that has not been installed.", - get_subscriber_model, - ) - - @override_settings( - DJSTRIPE_SUBSCRIBER_MODEL="testapp.NoEmailOrganization", - DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), - ) - def test_no_email_model(self): - self.assertRaisesMessage( - ImproperlyConfigured, - "DJSTRIPE_SUBSCRIBER_MODEL must have an email attribute.", - get_subscriber_model, - ) - - @override_settings(DJSTRIPE_SUBSCRIBER_MODEL="testapp.Organization") - def test_no_callback(self): - self.assertRaisesMessage( - ImproperlyConfigured, - "DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK must be implemented if a DJSTRIPE_SUBSCRIBER_MODEL is " - "defined.", - get_subscriber_model, - ) - - @override_settings( - DJSTRIPE_SUBSCRIBER_MODEL="testapp.Organization", - DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=5, - ) - def test_bad_callback(self): - self.assertRaisesMessage( - ImproperlyConfigured, - "DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK must be callable.", - get_subscriber_model, - ) - - @override_settings(DJSTRIPE_TEST_CALLBACK=(lambda: "ok")) - def test_get_callback_function_with_valid_func_callable(self): - func = get_callback_function("DJSTRIPE_TEST_CALLBACK") - self.assertEqual("ok", func()) - - @override_settings(DJSTRIPE_TEST_CALLBACK="foo.valid_callback") - @patch.object(djstripe_settings, "import_string", return_value=(lambda: "ok")) - def test_get_callback_function_with_valid_string_callable(self, import_string_mock): - func = get_callback_function("DJSTRIPE_TEST_CALLBACK") - self.assertEqual("ok", func()) - import_string_mock.assert_called_with("foo.valid_callback") - - @override_settings(DJSTRIPE_TEST_CALLBACK="foo.non_existant_callback") - def test_get_callback_function_import_error(self): - with self.assertRaises(ImportError): - get_callback_function("DJSTRIPE_TEST_CALLBACK") - - @override_settings(DJSTRIPE_TEST_CALLBACK="foo.invalid_callback") - @patch.object(djstripe_settings, "import_string", return_value="not_callable") - def test_get_callback_function_with_non_callable_string(self, import_string_mock): - with self.assertRaises(ImproperlyConfigured): - get_callback_function("DJSTRIPE_TEST_CALLBACK") - import_string_mock.assert_called_with("foo.invalid_callback") - - @override_settings(DJSTRIPE_TEST_CALLBACK="foo.non_existant_callback") - def test_get_callback_function_(self): - with self.assertRaises(ImportError): - get_callback_function("DJSTRIPE_TEST_CALLBACK") + def test_with_user(self): + user_model = get_subscriber_model() + self.assertTrue(isinstance(user_model, ModelBase)) + + @override_settings( + DJSTRIPE_SUBSCRIBER_MODEL="testapp.Organization", + DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), + ) + def test_with_org(self): + org_model = get_subscriber_model() + self.assertTrue(isinstance(org_model, ModelBase)) + + @override_settings( + DJSTRIPE_SUBSCRIBER_MODEL="testapp.StaticEmailOrganization", + DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), + ) + def test_with_org_static(self): + org_model = get_subscriber_model() + self.assertTrue(isinstance(org_model, ModelBase)) + + @override_settings( + DJSTRIPE_SUBSCRIBER_MODEL="testappStaticEmailOrganization", + DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), + ) + def test_bad_model_name(self): + self.assertRaisesMessage( + ImproperlyConfigured, + "DJSTRIPE_SUBSCRIBER_MODEL must be of the form 'app_label.model_name'.", + get_subscriber_model, + ) + + @override_settings( + DJSTRIPE_SUBSCRIBER_MODEL="testapp.UnknownModel", + DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), + ) + def test_unknown_model(self): + self.assertRaisesMessage( + ImproperlyConfigured, + "DJSTRIPE_SUBSCRIBER_MODEL refers to model 'testapp.UnknownModel' " + "that has not been installed.", + get_subscriber_model, + ) + + @override_settings( + DJSTRIPE_SUBSCRIBER_MODEL="testapp.NoEmailOrganization", + DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=(lambda request: request.org), + ) + def test_no_email_model(self): + self.assertRaisesMessage( + ImproperlyConfigured, + "DJSTRIPE_SUBSCRIBER_MODEL must have an email attribute.", + get_subscriber_model, + ) + + @override_settings(DJSTRIPE_SUBSCRIBER_MODEL="testapp.Organization") + def test_no_callback(self): + self.assertRaisesMessage( + ImproperlyConfigured, + "DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK must be implemented " + "if a DJSTRIPE_SUBSCRIBER_MODEL is defined.", + get_subscriber_model, + ) + + @override_settings( + DJSTRIPE_SUBSCRIBER_MODEL="testapp.Organization", + DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK=5, + ) + def test_bad_callback(self): + self.assertRaisesMessage( + ImproperlyConfigured, + "DJSTRIPE_SUBSCRIBER_MODEL_REQUEST_CALLBACK must be callable.", + get_subscriber_model, + ) + + @override_settings(DJSTRIPE_TEST_CALLBACK=(lambda: "ok")) + def test_get_callback_function_with_valid_func_callable(self): + func = get_callback_function("DJSTRIPE_TEST_CALLBACK") + self.assertEqual("ok", func()) + + @override_settings(DJSTRIPE_TEST_CALLBACK="foo.valid_callback") + @patch.object(djstripe_settings, "import_string", return_value=(lambda: "ok")) + def test_get_callback_function_with_valid_string_callable(self, import_string_mock): + func = get_callback_function("DJSTRIPE_TEST_CALLBACK") + self.assertEqual("ok", func()) + import_string_mock.assert_called_with("foo.valid_callback") + + @override_settings(DJSTRIPE_TEST_CALLBACK="foo.non_existant_callback") + def test_get_callback_function_import_error(self): + with self.assertRaises(ImportError): + get_callback_function("DJSTRIPE_TEST_CALLBACK") + + @override_settings(DJSTRIPE_TEST_CALLBACK="foo.invalid_callback") + @patch.object(djstripe_settings, "import_string", return_value="not_callable") + def test_get_callback_function_with_non_callable_string(self, import_string_mock): + with self.assertRaises(ImproperlyConfigured): + get_callback_function("DJSTRIPE_TEST_CALLBACK") + import_string_mock.assert_called_with("foo.invalid_callback") + + @override_settings(DJSTRIPE_TEST_CALLBACK="foo.non_existant_callback") + def test_get_callback_function_(self): + with self.assertRaises(ImportError): + get_callback_function("DJSTRIPE_TEST_CALLBACK") @override_settings(STRIPE_API_VERSION=None) class TestGetStripeApiVersion(TestCase): - def test_with_default(self): - self.assertEqual( - djstripe_settings.DEFAULT_STRIPE_API_VERSION, get_stripe_api_version() - ) + def test_with_default(self): + self.assertEqual( + djstripe_settings.DEFAULT_STRIPE_API_VERSION, get_stripe_api_version() + ) - @override_settings(STRIPE_API_VERSION="2016-03-07") - def test_with_override(self): - self.assertEqual("2016-03-07", get_stripe_api_version()) + @override_settings(STRIPE_API_VERSION="2016-03-07") + def test_with_override(self): + self.assertEqual("2016-03-07", get_stripe_api_version()) @override_settings(STRIPE_API_VERSION=None) class TestSetStripeApiVersion(TestCase): - def test_with_default(self): - djstripe_settings.set_stripe_api_version() - self.assertEqual(djstripe_settings.DEFAULT_STRIPE_API_VERSION, stripe.api_version) - - def test_with_valid_date(self): - djstripe_settings.set_stripe_api_version(version="2016-03-07") - self.assertEqual("2016-03-07", stripe.api_version) - - def test_with_invalid_date(self): - with self.assertRaises(ValueError): - set_stripe_api_version(version="foobar") - - def test_with_invalid_date_and_no_validation(self): - set_stripe_api_version(version="foobar", validate=False) - self.assertEqual("foobar", stripe.api_version) + def test_with_default(self): + djstripe_settings.set_stripe_api_version() + self.assertEqual( + djstripe_settings.DEFAULT_STRIPE_API_VERSION, stripe.api_version + ) + + def test_with_valid_date(self): + djstripe_settings.set_stripe_api_version(version="2016-03-07") + self.assertEqual("2016-03-07", stripe.api_version) + + def test_with_invalid_date(self): + with self.assertRaises(ValueError): + set_stripe_api_version(version="foobar") + + def test_with_invalid_date_and_no_validation(self): + set_stripe_api_version(version="foobar", validate=False) + self.assertEqual("foobar", stripe.api_version) diff --git a/tests/test_source.py b/tests/test_source.py index 909e5c8a62..6c27a76b7a 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -11,73 +11,79 @@ from djstripe.models import Source from . import ( - FAKE_CUSTOMER_III, FAKE_SOURCE, FAKE_SOURCE_II, - AssertStripeFksMixin, SourceDict, default_account + FAKE_CUSTOMER_III, + FAKE_SOURCE, + FAKE_SOURCE_II, + AssertStripeFksMixin, + SourceDict, + default_account, ) class SourceTest(AssertStripeFksMixin, TestCase): - def setUp(self): - self.account = default_account() - self.user = get_user_model().objects.create_user( - username="testuser", email="djstripe@example.com" - ) - self.customer = FAKE_CUSTOMER_III.create_for_user(self.user) - self.customer.sources.all().delete() - self.customer.legacy_cards.all().delete() + def setUp(self): + self.account = default_account() + self.user = get_user_model().objects.create_user( + username="testuser", email="djstripe@example.com" + ) + self.customer = FAKE_CUSTOMER_III.create_for_user(self.user) + self.customer.sources.all().delete() + self.customer.legacy_cards.all().delete() - def test_attach_objects_hook_without_customer(self): - source = Source.sync_from_stripe_data(deepcopy(FAKE_SOURCE_II)) - self.assertEqual(source.customer, None) + def test_attach_objects_hook_without_customer(self): + source = Source.sync_from_stripe_data(deepcopy(FAKE_SOURCE_II)) + self.assertEqual(source.customer, None) - self.assert_fks(source, expected_blank_fks={"djstripe.Source.customer"}) + self.assert_fks(source, expected_blank_fks={"djstripe.Source.customer"}) - def test_sync_source_finds_customer(self): - source = Source.sync_from_stripe_data(deepcopy(FAKE_SOURCE)) + def test_sync_source_finds_customer(self): + source = Source.sync_from_stripe_data(deepcopy(FAKE_SOURCE)) - self.assertEqual(self.customer, source.customer) + self.assertEqual(self.customer, source.customer) - self.assert_fks(source, expected_blank_fks={"djstripe.Customer.coupon"}) + self.assert_fks(source, expected_blank_fks={"djstripe.Customer.coupon"}) - def test_str(self): - fake_source = deepcopy(FAKE_SOURCE) - source = Source.sync_from_stripe_data(fake_source) + def test_str(self): + fake_source = deepcopy(FAKE_SOURCE) + source = Source.sync_from_stripe_data(fake_source) - self.assertEqual("".format(fake_source["id"]), str(source)) + self.assertEqual("".format(fake_source["id"]), str(source)) - self.assert_fks(source, expected_blank_fks={"djstripe.Customer.coupon"}) + self.assert_fks(source, expected_blank_fks={"djstripe.Customer.coupon"}) - @patch("stripe.Source.retrieve", return_value=deepcopy(FAKE_SOURCE), autospec=True) - def test_detach(self, source_retrieve_mock): - original_detach = SourceDict.detach + @patch("stripe.Source.retrieve", return_value=deepcopy(FAKE_SOURCE), autospec=True) + def test_detach(self, source_retrieve_mock): + original_detach = SourceDict.detach - def mocked_detach(self): - return original_detach(self) + def mocked_detach(self): + return original_detach(self) - Source.sync_from_stripe_data(deepcopy(FAKE_SOURCE)) + Source.sync_from_stripe_data(deepcopy(FAKE_SOURCE)) - self.assertEqual(0, self.customer.legacy_cards.count()) - self.assertEqual(1, self.customer.sources.count()) + self.assertEqual(0, self.customer.legacy_cards.count()) + self.assertEqual(1, self.customer.sources.count()) - source = self.customer.sources.first() + source = self.customer.sources.first() - with patch( - "tests.SourceDict.detach", side_effect=mocked_detach, autospec=True - ) as mock_detach: - source.detach() + with patch( + "tests.SourceDict.detach", side_effect=mocked_detach, autospec=True + ) as mock_detach: + source.detach() - self.assertEqual(0, self.customer.sources.count()) - # need to refresh_from_db since default_source was cleared with a query - self.customer.refresh_from_db() - self.assertIsNone(self.customer.default_source) + self.assertEqual(0, self.customer.sources.count()) + # need to refresh_from_db since default_source was cleared with a query + self.customer.refresh_from_db() + self.assertIsNone(self.customer.default_source) - # need to refresh_from_db due to the implementation of Source.detach() - see TODO in method - source.refresh_from_db() - self.assertIsNone(source.customer) - self.assertEqual(source.status, "consumed") + # need to refresh_from_db due to the implementation of Source.detach() - + # see TODO in method + source.refresh_from_db() + self.assertIsNone(source.customer) + self.assertEqual(source.status, "consumed") - if sys.version_info >= (3, 6): - # this mock isn't working on py34, py35, but it's not strictly necessary for the test - mock_detach.assert_called() + if sys.version_info >= (3, 6): + # this mock isn't working on py34, py35, but it's not strictly necessary + # for the test + mock_detach.assert_called() - self.assert_fks(source, expected_blank_fks={"djstripe.Source.customer"}) + self.assert_fks(source, expected_blank_fks={"djstripe.Source.customer"}) diff --git a/tests/test_stripe_model.py b/tests/test_stripe_model.py index c5fb4943e3..1a682b2486 100644 --- a/tests/test_stripe_model.py +++ b/tests/test_stripe_model.py @@ -7,18 +7,20 @@ class StripeModelExceptionsTest(TestCase): - def test_no_object_value(self): - # Instantiate a stripeobject model class - class BasicModel(StripeModel): - pass + def test_no_object_value(self): + # Instantiate a stripeobject model class + class BasicModel(StripeModel): + pass - with self.assertRaises(ValueError): - # Errors because there's no object value - BasicModel._stripe_object_to_record({"id": "test_XXXXXXXX", "livemode": False}) + with self.assertRaises(ValueError): + # Errors because there's no object value + BasicModel._stripe_object_to_record( + {"id": "test_XXXXXXXX", "livemode": False} + ) - def test_bad_object_value(self): - with self.assertRaises(ValueError): - # Errors because the object is not correct - Customer._stripe_object_to_record( - {"id": "test_XXXXXXXX", "livemode": False, "object": "not_a_customer"} - ) + def test_bad_object_value(self): + with self.assertRaises(ValueError): + # Errors because the object is not correct + Customer._stripe_object_to_record( + {"id": "test_XXXXXXXX", "livemode": False, "object": "not_a_customer"} + ) diff --git a/tests/test_subscription.py b/tests/test_subscription.py index e67903854f..8f81b4c273 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -14,633 +14,724 @@ from djstripe.models import Plan, Subscription from . import ( - FAKE_CUSTOMER, FAKE_CUSTOMER_II, FAKE_PLAN, FAKE_PLAN_II, FAKE_PLAN_METERED, - FAKE_PRODUCT, FAKE_SUBSCRIPTION, FAKE_SUBSCRIPTION_CANCELED, - FAKE_SUBSCRIPTION_METERED, FAKE_SUBSCRIPTION_MULTI_PLAN, - FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT, AssertStripeFksMixin, datetime_to_unix + FAKE_CUSTOMER, + FAKE_CUSTOMER_II, + FAKE_PLAN, + FAKE_PLAN_II, + FAKE_PLAN_METERED, + FAKE_PRODUCT, + FAKE_SUBSCRIPTION, + FAKE_SUBSCRIPTION_CANCELED, + FAKE_SUBSCRIPTION_METERED, + FAKE_SUBSCRIPTION_MULTI_PLAN, + FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT, + AssertStripeFksMixin, + datetime_to_unix, ) class SubscriptionTest(AssertStripeFksMixin, TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_str(self, customer_retrieve_mock, product_retrieve_mock, plan_retrieve_mock): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - - self.assertEqual( - str(subscription), - "{email} on {plan}".format(email=self.user.email, plan=str(subscription.plan)), - ) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_is_status_temporarily_current( - self, customer_retrieve_mock, product_retrieve_mock, plan_retrieve_mock - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - subscription.canceled_at = timezone.now() + timezone.timedelta(days=7) - subscription.current_period_end = timezone.now() + timezone.timedelta(days=7) - subscription.cancel_at_period_end = True - subscription.save() - - self.assertTrue(subscription.is_status_current()) - self.assertTrue(subscription.is_status_temporarily_current()) - self.assertTrue(subscription.is_valid()) - self.assertTrue(subscription in self.customer.active_subscriptions) - self.assertTrue(self.customer.has_active_subscription()) - self.assertTrue(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_is_status_temporarily_current_false( - self, customer_retrieve_mock, product_retrieve_mock, plan_retrieve_mock - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - subscription.current_period_end = timezone.now() + timezone.timedelta(days=7) - subscription.save() - - self.assertTrue(subscription.is_status_current()) - self.assertFalse(subscription.is_status_temporarily_current()) - self.assertTrue(subscription.is_valid()) - self.assertTrue(subscription in self.customer.active_subscriptions) - self.assertTrue(self.customer.has_active_subscription()) - self.assertTrue(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_is_status_temporarily_current_false_and_cancelled( - self, customer_retrieve_mock, product_retrieve_mock, plan_retrieve_mock - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - subscription.status = SubscriptionStatus.canceled - subscription.current_period_end = timezone.now() + timezone.timedelta(days=7) - subscription.save() - - self.assertFalse(subscription.is_status_current()) - self.assertFalse(subscription.is_status_temporarily_current()) - self.assertFalse(subscription.is_valid()) - self.assertFalse(subscription in self.customer.active_subscriptions) - self.assertFalse(self.customer.has_active_subscription()) - self.assertFalse(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Subscription.retrieve", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_extend( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription_fake["current_period_end"] = datetime_to_unix( - timezone.now() - timezone.timedelta(days=20) - ) - - subscription_retrieve_mock.return_value = subscription_fake - - subscription = Subscription.sync_from_stripe_data(subscription_fake) - self.assertFalse(subscription in self.customer.active_subscriptions) - self.assertEqual(self.customer.active_subscriptions.count(), 0) - - delta = timezone.timedelta(days=30) - extended_subscription = subscription.extend(delta) - - self.assertNotEqual(None, extended_subscription.trial_end) - self.assertTrue(self.customer.has_active_subscription()) - self.assertTrue(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_extend_negative_delta( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - - with self.assertRaises(ValueError): - subscription.extend(timezone.timedelta(days=-30)) - - self.assertFalse(self.customer.has_active_subscription()) - self.assertFalse(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_extend_with_trial( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - subscription.trial_end = timezone.now() + timezone.timedelta(days=5) - subscription.save() - - delta = timezone.timedelta(days=30) - new_trial_end = subscription.trial_end + delta - - extended_subscription = subscription.extend(delta) - - self.assertEqual( - new_trial_end.replace(microsecond=0), extended_subscription.trial_end - ) - self.assertTrue(self.customer.has_active_subscription()) - self.assertTrue(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_update( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - - self.assertEqual(1, subscription.quantity) - - new_subscription = subscription.update(quantity=4) - - self.assertEqual(4, new_subscription.quantity) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Subscription.retrieve", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_update_set_empty_value( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription_fake.update({"tax_percent": Decimal(20.0)}) - subscription_retrieve_mock.return_value = subscription_fake - subscription = Subscription.sync_from_stripe_data(subscription_fake) - - self.assertEqual(Decimal(20.0), subscription.tax_percent) - - new_subscription = subscription.update(tax_percent=Decimal(0.0)) - - self.assertEqual(Decimal(0.0), new_subscription.tax_percent) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", - return_value=deepcopy(FAKE_SUBSCRIPTION), - autospec=True, - ) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_update_with_plan_model( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - new_plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN_II)) - - self.assertEqual(FAKE_PLAN["id"], subscription.plan.id) - - new_subscription = subscription.update(plan=new_plan) - - self.assertEqual(FAKE_PLAN_II["id"], new_subscription.plan.id) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - self.assert_fks(new_plan, expected_blank_fks={}) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Subscription.retrieve", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_cancel_now( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - subscription.current_period_end = timezone.now() + timezone.timedelta(days=7) - subscription.save() - - cancel_timestamp = datetime_to_unix(timezone.now()) - canceled_subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - canceled_subscription_fake["status"] = SubscriptionStatus.canceled - canceled_subscription_fake["canceled_at"] = cancel_timestamp - canceled_subscription_fake["ended_at"] = cancel_timestamp - subscription_retrieve_mock.return_value = ( - canceled_subscription_fake - ) # retrieve().delete() - - self.assertTrue(self.customer.has_active_subscription()) - self.assertEqual(self.customer.active_subscriptions.count(), 1) - self.assertTrue(self.customer.has_any_active_subscription()) - - new_subscription = subscription.cancel(at_period_end=False) - - self.assertEqual(SubscriptionStatus.canceled, new_subscription.status) - self.assertEqual(False, new_subscription.cancel_at_period_end) - self.assertEqual(new_subscription.canceled_at, new_subscription.ended_at) - self.assertFalse(new_subscription.is_valid()) - self.assertFalse(new_subscription in self.customer.active_subscriptions) - self.assertFalse(self.customer.has_active_subscription()) - self.assertFalse(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Subscription.retrieve", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_cancel_at_period_end( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - current_period_end = timezone.now() + timezone.timedelta(days=7) - - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - subscription.current_period_end = current_period_end - subscription.save() - - canceled_subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - canceled_subscription_fake["current_period_end"] = datetime_to_unix( - current_period_end - ) - canceled_subscription_fake["canceled_at"] = datetime_to_unix(timezone.now()) - subscription_retrieve_mock.return_value = ( - canceled_subscription_fake - ) # retrieve().delete() - - self.assertTrue(self.customer.has_active_subscription()) - self.assertTrue(self.customer.has_any_active_subscription()) - self.assertEqual(self.customer.active_subscriptions.count(), 1) - self.assertTrue(subscription in self.customer.active_subscriptions) - - new_subscription = subscription.cancel(at_period_end=True) - self.assertEqual(self.customer.active_subscriptions.count(), 1) - self.assertTrue(new_subscription in self.customer.active_subscriptions) - - self.assertEqual(SubscriptionStatus.active, new_subscription.status) - self.assertEqual(True, new_subscription.cancel_at_period_end) - self.assertNotEqual(new_subscription.canceled_at, new_subscription.ended_at) - self.assertTrue(new_subscription.is_valid()) - self.assertTrue(self.customer.has_active_subscription()) - self.assertTrue(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Subscription.retrieve", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_cancel_during_trial_sets_at_period_end( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - subscription.trial_end = timezone.now() + timezone.timedelta(days=7) - subscription.save() - - cancel_timestamp = datetime_to_unix(timezone.now()) - canceled_subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - canceled_subscription_fake["status"] = SubscriptionStatus.canceled - canceled_subscription_fake["canceled_at"] = cancel_timestamp - canceled_subscription_fake["ended_at"] = cancel_timestamp - subscription_retrieve_mock.return_value = ( - canceled_subscription_fake - ) # retrieve().delete() - - self.assertTrue(self.customer.has_active_subscription()) - self.assertTrue(self.customer.has_any_active_subscription()) - - new_subscription = subscription.cancel(at_period_end=False) - - self.assertEqual(SubscriptionStatus.canceled, new_subscription.status) - self.assertEqual(False, new_subscription.cancel_at_period_end) - self.assertEqual(new_subscription.canceled_at, new_subscription.ended_at) - self.assertFalse(new_subscription.is_valid()) - self.assertFalse(self.customer.has_active_subscription()) - self.assertFalse(self.customer.has_any_active_subscription()) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch("stripe.Subscription.retrieve", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_cancel_and_reactivate( - self, - customer_retrieve_mock, - subscription_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - current_period_end = timezone.now() + timezone.timedelta(days=7) - - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - subscription.current_period_end = current_period_end - subscription.save() - - canceled_subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - canceled_subscription_fake["current_period_end"] = datetime_to_unix( - current_period_end - ) - canceled_subscription_fake["canceled_at"] = datetime_to_unix(timezone.now()) - subscription_retrieve_mock.return_value = canceled_subscription_fake - - self.assertTrue(self.customer.has_active_subscription()) - self.assertTrue(self.customer.has_any_active_subscription()) - - new_subscription = subscription.cancel(at_period_end=True) - self.assertEqual(new_subscription.cancel_at_period_end, True) - - new_subscription.reactivate() - subscription_reactivate_fake = deepcopy(FAKE_SUBSCRIPTION) - reactivated_subscription = Subscription.sync_from_stripe_data( - subscription_reactivate_fake - ) - self.assertEqual(reactivated_subscription.cancel_at_period_end, False) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("djstripe.models.Subscription._api_delete", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Subscription.retrieve", return_value=deepcopy(FAKE_SUBSCRIPTION_CANCELED) - ) - def test_cancel_already_canceled( - self, subscription_retrieve_mock, product_retrieve_mock, subscription_delete_mock - ): - subscription_delete_mock.side_effect = InvalidRequestError( - "No such subscription: sub_xxxx", "blah" - ) - - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - - self.assertEqual(Subscription.objects.filter(status="canceled").count(), 0) - subscription.cancel(at_period_end=False) - self.assertEqual(Subscription.objects.filter(status="canceled").count(), 1) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("djstripe.models.Subscription._api_delete", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_cancel_error_in_cancel(self, product_retrieve_mock, subscription_delete_mock): - subscription_delete_mock.side_effect = InvalidRequestError("Unexpected error", "blah") - - subscription_fake = deepcopy(FAKE_SUBSCRIPTION) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - - with self.assertRaises(InvalidRequestError): - subscription.cancel(at_period_end=False) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True - ) - @patch( - "stripe.Subscription.retrieve", return_value=deepcopy(FAKE_SUBSCRIPTION_MULTI_PLAN) - ) - def test_sync_multi_plan( - self, - subscription_retrieve_mock, - customer_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION_MULTI_PLAN) - subscription = Subscription.sync_from_stripe_data(subscription_fake) - - self.assertIsNone(subscription.plan) - self.assertIsNone(subscription.quantity) - - items = subscription.items.all() - self.assertEqual(2, len(items)) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Customer.subscriber", - "djstripe.Subscription.plan", - "djstripe.Subscription.pending_setup_intent", - }, - ) - - @patch("stripe.Plan.retrieve", autospec=True) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - @patch( - "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER_II), autospec=True - ) - @patch( - "stripe.Subscription.retrieve", return_value=deepcopy(FAKE_SUBSCRIPTION_METERED) - ) - def test_sync_metered_plan( - self, - subscription_retrieve_mock, - customer_retrieve_mock, - product_retrieve_mock, - plan_retrieve_mock, - ): - subscription_fake = deepcopy(FAKE_SUBSCRIPTION_METERED) - self.assertNotIn( - "quantity", - subscription_fake["items"]["data"], - "Expect Metered plan SubscriptionItem to have no quantity", - ) - - subscription = Subscription.sync_from_stripe_data(subscription_fake) - - items = subscription.items.all() - self.assertEqual(1, len(items)) - - item = items[0] - - self.assertEqual(subscription.quantity, 1) - # Note that subscription.quantity is 1, but item.quantity isn't set on metered plans - self.assertIsNone(item.quantity) - self.assertEqual(item.plan.id, FAKE_PLAN_METERED["id"]) - - self.assert_fks( - subscription, - expected_blank_fks={ - "djstripe.Customer.coupon", - "djstripe.Subscription.pending_setup_intent", - }, - ) + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_str( + self, customer_retrieve_mock, product_retrieve_mock, plan_retrieve_mock + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + + self.assertEqual( + str(subscription), + "{email} on {plan}".format( + email=self.user.email, plan=str(subscription.plan) + ), + ) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_is_status_temporarily_current( + self, customer_retrieve_mock, product_retrieve_mock, plan_retrieve_mock + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + subscription.canceled_at = timezone.now() + timezone.timedelta(days=7) + subscription.current_period_end = timezone.now() + timezone.timedelta(days=7) + subscription.cancel_at_period_end = True + subscription.save() + + self.assertTrue(subscription.is_status_current()) + self.assertTrue(subscription.is_status_temporarily_current()) + self.assertTrue(subscription.is_valid()) + self.assertTrue(subscription in self.customer.active_subscriptions) + self.assertTrue(self.customer.has_active_subscription()) + self.assertTrue(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_is_status_temporarily_current_false( + self, customer_retrieve_mock, product_retrieve_mock, plan_retrieve_mock + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + subscription.current_period_end = timezone.now() + timezone.timedelta(days=7) + subscription.save() + + self.assertTrue(subscription.is_status_current()) + self.assertFalse(subscription.is_status_temporarily_current()) + self.assertTrue(subscription.is_valid()) + self.assertTrue(subscription in self.customer.active_subscriptions) + self.assertTrue(self.customer.has_active_subscription()) + self.assertTrue(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_is_status_temporarily_current_false_and_cancelled( + self, customer_retrieve_mock, product_retrieve_mock, plan_retrieve_mock + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + subscription.status = SubscriptionStatus.canceled + subscription.current_period_end = timezone.now() + timezone.timedelta(days=7) + subscription.save() + + self.assertFalse(subscription.is_status_current()) + self.assertFalse(subscription.is_status_temporarily_current()) + self.assertFalse(subscription.is_valid()) + self.assertFalse(subscription in self.customer.active_subscriptions) + self.assertFalse(self.customer.has_active_subscription()) + self.assertFalse(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Subscription.retrieve", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_extend( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription_fake["current_period_end"] = datetime_to_unix( + timezone.now() - timezone.timedelta(days=20) + ) + + subscription_retrieve_mock.return_value = subscription_fake + + subscription = Subscription.sync_from_stripe_data(subscription_fake) + self.assertFalse(subscription in self.customer.active_subscriptions) + self.assertEqual(self.customer.active_subscriptions.count(), 0) + + delta = timezone.timedelta(days=30) + extended_subscription = subscription.extend(delta) + + self.assertNotEqual(None, extended_subscription.trial_end) + self.assertTrue(self.customer.has_active_subscription()) + self.assertTrue(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_extend_negative_delta( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION_NOT_PERIOD_CURRENT) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + + with self.assertRaises(ValueError): + subscription.extend(timezone.timedelta(days=-30)) + + self.assertFalse(self.customer.has_active_subscription()) + self.assertFalse(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_extend_with_trial( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + subscription.trial_end = timezone.now() + timezone.timedelta(days=5) + subscription.save() + + delta = timezone.timedelta(days=30) + new_trial_end = subscription.trial_end + delta + + extended_subscription = subscription.extend(delta) + + self.assertEqual( + new_trial_end.replace(microsecond=0), extended_subscription.trial_end + ) + self.assertTrue(self.customer.has_active_subscription()) + self.assertTrue(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_update( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + + self.assertEqual(1, subscription.quantity) + + new_subscription = subscription.update(quantity=4) + + self.assertEqual(4, new_subscription.quantity) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Subscription.retrieve", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_update_set_empty_value( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription_fake.update({"tax_percent": Decimal(20.0)}) + subscription_retrieve_mock.return_value = subscription_fake + subscription = Subscription.sync_from_stripe_data(subscription_fake) + + self.assertEqual(Decimal(20.0), subscription.tax_percent) + + new_subscription = subscription.update(tax_percent=Decimal(0.0)) + + self.assertEqual(Decimal(0.0), new_subscription.tax_percent) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION), + autospec=True, + ) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_update_with_plan_model( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + new_plan = Plan.sync_from_stripe_data(deepcopy(FAKE_PLAN_II)) + + self.assertEqual(FAKE_PLAN["id"], subscription.plan.id) + + new_subscription = subscription.update(plan=new_plan) + + self.assertEqual(FAKE_PLAN_II["id"], new_subscription.plan.id) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + self.assert_fks(new_plan, expected_blank_fks={}) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Subscription.retrieve", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_cancel_now( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + subscription.current_period_end = timezone.now() + timezone.timedelta(days=7) + subscription.save() + + cancel_timestamp = datetime_to_unix(timezone.now()) + canceled_subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + canceled_subscription_fake["status"] = SubscriptionStatus.canceled + canceled_subscription_fake["canceled_at"] = cancel_timestamp + canceled_subscription_fake["ended_at"] = cancel_timestamp + subscription_retrieve_mock.return_value = ( + canceled_subscription_fake + ) # retrieve().delete() + + self.assertTrue(self.customer.has_active_subscription()) + self.assertEqual(self.customer.active_subscriptions.count(), 1) + self.assertTrue(self.customer.has_any_active_subscription()) + + new_subscription = subscription.cancel(at_period_end=False) + + self.assertEqual(SubscriptionStatus.canceled, new_subscription.status) + self.assertEqual(False, new_subscription.cancel_at_period_end) + self.assertEqual(new_subscription.canceled_at, new_subscription.ended_at) + self.assertFalse(new_subscription.is_valid()) + self.assertFalse(new_subscription in self.customer.active_subscriptions) + self.assertFalse(self.customer.has_active_subscription()) + self.assertFalse(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Subscription.retrieve", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_cancel_at_period_end( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + current_period_end = timezone.now() + timezone.timedelta(days=7) + + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + subscription.current_period_end = current_period_end + subscription.save() + + canceled_subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + canceled_subscription_fake["current_period_end"] = datetime_to_unix( + current_period_end + ) + canceled_subscription_fake["canceled_at"] = datetime_to_unix(timezone.now()) + subscription_retrieve_mock.return_value = ( + canceled_subscription_fake + ) # retrieve().delete() + + self.assertTrue(self.customer.has_active_subscription()) + self.assertTrue(self.customer.has_any_active_subscription()) + self.assertEqual(self.customer.active_subscriptions.count(), 1) + self.assertTrue(subscription in self.customer.active_subscriptions) + + new_subscription = subscription.cancel(at_period_end=True) + self.assertEqual(self.customer.active_subscriptions.count(), 1) + self.assertTrue(new_subscription in self.customer.active_subscriptions) + + self.assertEqual(SubscriptionStatus.active, new_subscription.status) + self.assertEqual(True, new_subscription.cancel_at_period_end) + self.assertNotEqual(new_subscription.canceled_at, new_subscription.ended_at) + self.assertTrue(new_subscription.is_valid()) + self.assertTrue(self.customer.has_active_subscription()) + self.assertTrue(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Subscription.retrieve", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_cancel_during_trial_sets_at_period_end( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + subscription.trial_end = timezone.now() + timezone.timedelta(days=7) + subscription.save() + + cancel_timestamp = datetime_to_unix(timezone.now()) + canceled_subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + canceled_subscription_fake["status"] = SubscriptionStatus.canceled + canceled_subscription_fake["canceled_at"] = cancel_timestamp + canceled_subscription_fake["ended_at"] = cancel_timestamp + subscription_retrieve_mock.return_value = ( + canceled_subscription_fake + ) # retrieve().delete() + + self.assertTrue(self.customer.has_active_subscription()) + self.assertTrue(self.customer.has_any_active_subscription()) + + new_subscription = subscription.cancel(at_period_end=False) + + self.assertEqual(SubscriptionStatus.canceled, new_subscription.status) + self.assertEqual(False, new_subscription.cancel_at_period_end) + self.assertEqual(new_subscription.canceled_at, new_subscription.ended_at) + self.assertFalse(new_subscription.is_valid()) + self.assertFalse(self.customer.has_active_subscription()) + self.assertFalse(self.customer.has_any_active_subscription()) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch("stripe.Subscription.retrieve", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_cancel_and_reactivate( + self, + customer_retrieve_mock, + subscription_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + current_period_end = timezone.now() + timezone.timedelta(days=7) + + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + subscription.current_period_end = current_period_end + subscription.save() + + canceled_subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + canceled_subscription_fake["current_period_end"] = datetime_to_unix( + current_period_end + ) + canceled_subscription_fake["canceled_at"] = datetime_to_unix(timezone.now()) + subscription_retrieve_mock.return_value = canceled_subscription_fake + + self.assertTrue(self.customer.has_active_subscription()) + self.assertTrue(self.customer.has_any_active_subscription()) + + new_subscription = subscription.cancel(at_period_end=True) + self.assertEqual(new_subscription.cancel_at_period_end, True) + + new_subscription.reactivate() + subscription_reactivate_fake = deepcopy(FAKE_SUBSCRIPTION) + reactivated_subscription = Subscription.sync_from_stripe_data( + subscription_reactivate_fake + ) + self.assertEqual(reactivated_subscription.cancel_at_period_end, False) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("djstripe.models.Subscription._api_delete", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_CANCELED), + ) + def test_cancel_already_canceled( + self, + subscription_retrieve_mock, + product_retrieve_mock, + subscription_delete_mock, + ): + subscription_delete_mock.side_effect = InvalidRequestError( + "No such subscription: sub_xxxx", "blah" + ) + + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + + self.assertEqual(Subscription.objects.filter(status="canceled").count(), 0) + subscription.cancel(at_period_end=False) + self.assertEqual(Subscription.objects.filter(status="canceled").count(), 1) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("djstripe.models.Subscription._api_delete", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_cancel_error_in_cancel( + self, product_retrieve_mock, subscription_delete_mock + ): + subscription_delete_mock.side_effect = InvalidRequestError( + "Unexpected error", "blah" + ) + + subscription_fake = deepcopy(FAKE_SUBSCRIPTION) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + + with self.assertRaises(InvalidRequestError): + subscription.cancel(at_period_end=False) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Customer.retrieve", + return_value=deepcopy(FAKE_CUSTOMER_II), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", + return_value=deepcopy(FAKE_SUBSCRIPTION_MULTI_PLAN), + ) + def test_sync_multi_plan( + self, + subscription_retrieve_mock, + customer_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION_MULTI_PLAN) + subscription = Subscription.sync_from_stripe_data(subscription_fake) + + self.assertIsNone(subscription.plan) + self.assertIsNone(subscription.quantity) + + items = subscription.items.all() + self.assertEqual(2, len(items)) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Customer.subscriber", + "djstripe.Subscription.plan", + "djstripe.Subscription.pending_setup_intent", + }, + ) + + @patch("stripe.Plan.retrieve", autospec=True) + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + @patch( + "stripe.Customer.retrieve", + return_value=deepcopy(FAKE_CUSTOMER_II), + autospec=True, + ) + @patch( + "stripe.Subscription.retrieve", return_value=deepcopy(FAKE_SUBSCRIPTION_METERED) + ) + def test_sync_metered_plan( + self, + subscription_retrieve_mock, + customer_retrieve_mock, + product_retrieve_mock, + plan_retrieve_mock, + ): + subscription_fake = deepcopy(FAKE_SUBSCRIPTION_METERED) + self.assertNotIn( + "quantity", + subscription_fake["items"]["data"], + "Expect Metered plan SubscriptionItem to have no quantity", + ) + + subscription = Subscription.sync_from_stripe_data(subscription_fake) + + items = subscription.items.all() + self.assertEqual(1, len(items)) + + item = items[0] + + self.assertEqual(subscription.quantity, 1) + # Note that subscription.quantity is 1, + # but item.quantity isn't set on metered plans + self.assertIsNone(item.quantity) + self.assertEqual(item.plan.id, FAKE_PLAN_METERED["id"]) + + self.assert_fks( + subscription, + expected_blank_fks={ + "djstripe.Customer.coupon", + "djstripe.Subscription.pending_setup_intent", + }, + ) diff --git a/tests/test_sync.py b/tests/test_sync.py index 27e95df71a..55e4cd0e0d 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -17,59 +17,67 @@ @contextlib.contextmanager def capture_stdout(): - import sys - from io import StringIO + import sys + from io import StringIO - old_stdout = sys.stdout - sys.stdout = StringIO() + old_stdout = sys.stdout + sys.stdout = StringIO() - try: - yield sys.stdout - finally: - sys.stdout = old_stdout + try: + yield sys.stdout + finally: + sys.stdout = old_stdout class TestSyncSubscriber(TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="testuser", email="test@example.com", password="123" - ) + def setUp(self): + self.user = get_user_model().objects.create_user( + username="testuser", email="test@example.com", password="123" + ) - @patch("djstripe.models.Customer._sync_charges", autospec=True) - @patch("djstripe.models.Customer._sync_invoices", autospec=True) - @patch("djstripe.models.Customer._sync_subscriptions", autospec=True) - @patch("stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - @patch("stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_sync_success( - self, - stripe_customer_create_mock, - api_retrieve_mock, - _sync_subscriptions_mock, - _sync_invoices_mock, - _sync_charges_mock, - ): + @patch("djstripe.models.Customer._sync_charges", autospec=True) + @patch("djstripe.models.Customer._sync_invoices", autospec=True) + @patch("djstripe.models.Customer._sync_subscriptions", autospec=True) + @patch( + "stripe.Customer.retrieve", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + @patch( + "stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_sync_success( + self, + stripe_customer_create_mock, + api_retrieve_mock, + _sync_subscriptions_mock, + _sync_invoices_mock, + _sync_charges_mock, + ): - sync_subscriber(self.user) - self.assertEqual(1, Customer.objects.count()) - self.assertEqual( - FAKE_CUSTOMER, Customer.objects.get(subscriber=self.user).api_retrieve() - ) + sync_subscriber(self.user) + self.assertEqual(1, Customer.objects.count()) + self.assertEqual( + FAKE_CUSTOMER, Customer.objects.get(subscriber=self.user).api_retrieve() + ) - _sync_subscriptions_mock.assert_called_once_with(Customer.objects.first()) - _sync_invoices_mock.assert_called_once_with(Customer.objects.first()) - _sync_charges_mock.assert_called_once_with(Customer.objects.first()) + _sync_subscriptions_mock.assert_called_once_with(Customer.objects.first()) + _sync_invoices_mock.assert_called_once_with(Customer.objects.first()) + _sync_charges_mock.assert_called_once_with(Customer.objects.first()) - @patch("djstripe.models.Customer._sync", autospec=True) - @patch( - "djstripe.models.Customer.api_retrieve", - return_value=deepcopy(FAKE_CUSTOMER), - autospec=True, - ) - @patch("stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER), autospec=True) - def test_sync_fail(self, stripe_customer_create_mock, api_retrieve_mock, _sync_mock): - _sync_mock.side_effect = InvalidRequestError("No such customer:", "blah") + @patch("djstripe.models.Customer._sync", autospec=True) + @patch( + "djstripe.models.Customer.api_retrieve", + return_value=deepcopy(FAKE_CUSTOMER), + autospec=True, + ) + @patch( + "stripe.Customer.create", return_value=deepcopy(FAKE_CUSTOMER), autospec=True + ) + def test_sync_fail( + self, stripe_customer_create_mock, api_retrieve_mock, _sync_mock + ): + _sync_mock.side_effect = InvalidRequestError("No such customer:", "blah") - with capture_stdout() as stdout: - sync_subscriber(self.user) + with capture_stdout() as stdout: + sync_subscriber(self.user) - self.assertEqual("ERROR: No such customer:", stdout.getvalue().strip()) + self.assertEqual("ERROR: No such customer:", stdout.getvalue().strip()) diff --git a/tests/test_utils.py b/tests/test_utils.py index 566e8f42d8..27c04eb36a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -17,12 +17,17 @@ from djstripe.models import Subscription from djstripe.utils import ( - convert_tstamp, get_friendly_currency_amount, - get_supported_currency_choices, subscriber_has_active_subscription + convert_tstamp, + get_friendly_currency_amount, + get_supported_currency_choices, + subscriber_has_active_subscription, ) from . import ( - FAKE_CUSTOMER, FAKE_PRODUCT, FAKE_SUBSCRIPTION, IS_STATICMETHOD_AUTOSPEC_SUPPORTED + FAKE_CUSTOMER, + FAKE_PRODUCT, + FAKE_SUBSCRIPTION, + IS_STATICMETHOD_AUTOSPEC_SUPPORTED, ) from .apps.testapp.models import Organization @@ -30,102 +35,116 @@ class TestTimestampConversion(TestCase): - def test_conversion(self): - stamp = convert_tstamp(1365567407) - self.assertEqual(stamp, datetime(2013, 4, 10, 4, 16, 47, tzinfo=timezone.utc)) + def test_conversion(self): + stamp = convert_tstamp(1365567407) + self.assertEqual(stamp, datetime(2013, 4, 10, 4, 16, 47, tzinfo=timezone.utc)) - # NOTE: These next two tests will fail if your system clock is not in UTC - # Travis CI is, and coverage is good, so... + # NOTE: These next two tests will fail if your system clock is not in UTC + # Travis CI is, and coverage is good, so... - @skipIf(not TZ_IS_UTC, "Skipped because timezone is not UTC.") - @override_settings(USE_TZ=False) - def test_conversion_no_tz(self): - stamp = convert_tstamp(1365567407) - self.assertEqual(stamp, datetime(2013, 4, 10, 4, 16, 47)) + @skipIf(not TZ_IS_UTC, "Skipped because timezone is not UTC.") + @override_settings(USE_TZ=False) + def test_conversion_no_tz(self): + stamp = convert_tstamp(1365567407) + self.assertEqual(stamp, datetime(2013, 4, 10, 4, 16, 47)) class TestUserHasActiveSubscription(TestCase): - def setUp(self): - self.user = get_user_model().objects.create_user( - username="pydanny", email="pydanny@gmail.com" - ) - self.customer = FAKE_CUSTOMER.create_for_user(self.user) + def setUp(self): + self.user = get_user_model().objects.create_user( + username="pydanny", email="pydanny@gmail.com" + ) + self.customer = FAKE_CUSTOMER.create_for_user(self.user) - def test_user_has_inactive_subscription(self): - self.assertFalse(subscriber_has_active_subscription(self.user)) + def test_user_has_inactive_subscription(self): + self.assertFalse(subscriber_has_active_subscription(self.user)) - @patch("stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True) - def test_user_has_active_subscription(self, product_retrieve_mock): - subscription = Subscription.sync_from_stripe_data(deepcopy(FAKE_SUBSCRIPTION)) - subscription.current_period_end = timezone.now() + timezone.timedelta(days=10) - subscription.save() + @patch( + "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True + ) + def test_user_has_active_subscription(self, product_retrieve_mock): + subscription = Subscription.sync_from_stripe_data(deepcopy(FAKE_SUBSCRIPTION)) + subscription.current_period_end = timezone.now() + timezone.timedelta(days=10) + subscription.save() - # Assert that the customer's subscription is valid - self.assertTrue(subscriber_has_active_subscription(self.user)) + # Assert that the customer's subscription is valid + self.assertTrue(subscriber_has_active_subscription(self.user)) - def test_custom_subscriber(self): - """ - ``subscriber_has_active_subscription`` attempts to create a customer object - for the current user. This causes a ValueError in this test because the - database has already been established with auth.User. - """ + def test_custom_subscriber(self): + """ + ``subscriber_has_active_subscription`` attempts to create a customer object + for the current user. This causes a ValueError in this test because the + database has already been established with auth.User. + """ - subscriber = Organization.objects.create(email="email@test.com") - self.assertRaises(ValueError, subscriber_has_active_subscription, subscriber) + subscriber = Organization.objects.create(email="email@test.com") + self.assertRaises(ValueError, subscriber_has_active_subscription, subscriber) - def test_anonymous_user(self): - """ - This needs to throw an ImproperlyConfigured error so the developer - can be guided to properly protect the subscription content. - """ + def test_anonymous_user(self): + """ + This needs to throw an ImproperlyConfigured error so the developer + can be guided to properly protect the subscription content. + """ - anon_user = AnonymousUser() + anon_user = AnonymousUser() - with self.assertRaises(ImproperlyConfigured): - subscriber_has_active_subscription(anon_user) + with self.assertRaises(ImproperlyConfigured): + subscriber_has_active_subscription(anon_user) - def test_staff_user(self): - self.user.is_staff = True - self.user.save() + def test_staff_user(self): + self.user.is_staff = True + self.user.save() - self.assertTrue(subscriber_has_active_subscription(self.user)) + self.assertTrue(subscriber_has_active_subscription(self.user)) - def test_superuser(self): - self.user.is_superuser = True - self.user.save() + def test_superuser(self): + self.user.is_superuser = True + self.user.save() - self.assertTrue(subscriber_has_active_subscription(self.user)) + self.assertTrue(subscriber_has_active_subscription(self.user)) class TestGetSupportedCurrencyChoices(TestCase): - @patch( - "stripe.CountrySpec.retrieve", - return_value={"supported_payment_currencies": ["usd", "cad", "eur"]}, - ) - @patch( - "stripe.Account.retrieve", - return_value={"country": "US"}, - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, - ) - def test_get_choices( - self, stripe_account_retrieve_mock, stripe_countryspec_retrieve_mock - ): - # Simple test to test sure that at least one currency choice tuple is returned. - - currency_choices = get_supported_currency_choices(None) - stripe_account_retrieve_mock.assert_called_once_with() - stripe_countryspec_retrieve_mock.assert_called_once_with("US") - self.assertGreaterEqual( - len(currency_choices), 1, "Currency choices pull returned an empty list." - ) - self.assertEqual(tuple, type(currency_choices[0]), "Currency choices are not tuples.") - self.assertIn(("usd", "USD"), currency_choices, "USD not in currency choices.") + @patch( + "stripe.CountrySpec.retrieve", + return_value={"supported_payment_currencies": ["usd", "cad", "eur"]}, + ) + @patch( + "stripe.Account.retrieve", + return_value={"country": "US"}, + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED, + ) + def test_get_choices( + self, stripe_account_retrieve_mock, stripe_countryspec_retrieve_mock + ): + # Simple test to test sure that at least one currency choice tuple is returned. + + currency_choices = get_supported_currency_choices(None) + stripe_account_retrieve_mock.assert_called_once_with() + stripe_countryspec_retrieve_mock.assert_called_once_with("US") + self.assertGreaterEqual( + len(currency_choices), 1, "Currency choices pull returned an empty list." + ) + self.assertEqual( + tuple, type(currency_choices[0]), "Currency choices are not tuples." + ) + self.assertIn(("usd", "USD"), currency_choices, "USD not in currency choices.") class TestUtils(TestCase): - def test_get_friendly_currency_amount(self): - self.assertEqual(get_friendly_currency_amount(Decimal("1.001"), "usd"), "$1.00 USD") - self.assertEqual(get_friendly_currency_amount(Decimal("10"), "usd"), "$10.00 USD") - self.assertEqual(get_friendly_currency_amount(Decimal("10.50"), "usd"), "$10.50 USD") - self.assertEqual(get_friendly_currency_amount(Decimal("10.51"), "cad"), "$10.51 CAD") - self.assertEqual(get_friendly_currency_amount(Decimal("9.99"), "eur"), "€9.99 EUR") + def test_get_friendly_currency_amount(self): + self.assertEqual( + get_friendly_currency_amount(Decimal("1.001"), "usd"), "$1.00 USD" + ) + self.assertEqual( + get_friendly_currency_amount(Decimal("10"), "usd"), "$10.00 USD" + ) + self.assertEqual( + get_friendly_currency_amount(Decimal("10.50"), "usd"), "$10.50 USD" + ) + self.assertEqual( + get_friendly_currency_amount(Decimal("10.51"), "cad"), "$10.51 CAD" + ) + self.assertEqual( + get_friendly_currency_amount(Decimal("9.99"), "eur"), "€9.99 EUR" + ) diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 888bfe85ab..9a042afeff 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -18,373 +18,398 @@ from djstripe.webhooks import TEST_EVENT_ID, call_handlers, handler, handler_all from . import ( - FAKE_EVENT_TEST_CHARGE_SUCCEEDED, FAKE_EVENT_TRANSFER_CREATED, FAKE_TRANSFER, - IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, IS_STATICMETHOD_AUTOSPEC_SUPPORTED + FAKE_EVENT_TEST_CHARGE_SUCCEEDED, + FAKE_EVENT_TRANSFER_CREATED, + FAKE_TRANSFER, + IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + IS_STATICMETHOD_AUTOSPEC_SUPPORTED, ) def mock_webhook_handler(webhook_event_trigger): - webhook_event_trigger.process() + webhook_event_trigger.process() class TestWebhook(TestCase): - def tearDown(self): - reload(djstripe_settings) - - def _send_event(self, event_data): - return Client().post( - reverse("djstripe:webhook"), - json.dumps(event_data), - content_type="application/json", - HTTP_STRIPE_SIGNATURE="PLACEHOLDER", - ) - - def test_webhook_test_event(self): - self.assertEqual(WebhookEventTrigger.objects.count(), 0) - resp = self._send_event(FAKE_EVENT_TEST_CHARGE_SUCCEEDED) - self.assertEqual(resp.status_code, 200) - self.assertFalse(Event.objects.filter(id=TEST_EVENT_ID).exists()) - self.assertEqual(WebhookEventTrigger.objects.count(), 1) - event_trigger = WebhookEventTrigger.objects.first() - self.assertTrue(event_trigger.is_test_event) - - @override_settings(DJSTRIPE_WEBHOOK_VALIDATION="retrieve_event") - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch( - "stripe.Event.retrieve", - return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), - autospec=True, - ) - def test_webhook_retrieve_event_fail( - self, event_retrieve_mock, transfer_retrieve_mock - ): - reload(djstripe_settings) - - invalid_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - invalid_event["id"] = "evt_invalid" - invalid_event["data"]["valid"] = "not really" - - resp = self._send_event(invalid_event) - - self.assertEqual(resp.status_code, 400) - self.assertFalse(Event.objects.filter(id="evt_invalid").exists()) - - @override_settings(DJSTRIPE_WEBHOOK_VALIDATION="retrieve_event") - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch( - "stripe.Event.retrieve", - return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), - autospec=True, - ) - def test_webhook_retrieve_event_pass( - self, event_retrieve_mock, transfer_retrieve_mock - ): - reload(djstripe_settings) - - resp = self._send_event(FAKE_EVENT_TRANSFER_CREATED) - - self.assertEqual(resp.status_code, 200) - event_retrieve_mock.assert_called_once_with( - api_key=djstripe_settings.STRIPE_SECRET_KEY, id=FAKE_EVENT_TRANSFER_CREATED["id"] - ) - - @override_settings( - DJSTRIPE_WEBHOOK_VALIDATION="verify_signature", DJSTRIPE_WEBHOOK_SECRET="whsec_XXXXX" - ) - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch( - "stripe.Event.retrieve", - return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), - autospec=True, - ) - def test_webhook_invalid_verify_signature_fail( - self, event_retrieve_mock, transfer_retrieve_mock - ): - reload(djstripe_settings) - - invalid_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - invalid_event["id"] = "evt_invalid" - invalid_event["data"]["valid"] = "not really" - - resp = self._send_event(invalid_event) - - self.assertEqual(resp.status_code, 400) - self.assertFalse(Event.objects.filter(id="evt_invalid").exists()) - - @override_settings( - DJSTRIPE_WEBHOOK_VALIDATION="verify_signature", DJSTRIPE_WEBHOOK_SECRET="whsec_XXXXX" - ) - @patch( - "stripe.WebhookSignature.verify_header", - return_value=True, - autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED and IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - ) - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch( - "stripe.Event.retrieve", - return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), - autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - ) - def test_webhook_verify_signature_pass( - self, event_retrieve_mock, transfer_retrieve_mock, verify_header_mock - ): - reload(djstripe_settings) - - resp = self._send_event(FAKE_EVENT_TRANSFER_CREATED) - - self.assertEqual(resp.status_code, 200) - self.assertFalse(Event.objects.filter(id="evt_invalid").exists()) - verify_header_mock.assert_called_once_with( - json.dumps(FAKE_EVENT_TRANSFER_CREATED), - "PLACEHOLDER", - djstripe_settings.WEBHOOK_SECRET, - djstripe_settings.WEBHOOK_TOLERANCE, - ) - event_retrieve_mock.assert_not_called() - - @override_settings(DJSTRIPE_WEBHOOK_VALIDATION=None) - @patch("stripe.WebhookSignature.verify_header", autospec=True) - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch( - "stripe.Event.retrieve", - return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), - autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, - ) - def test_webhook_no_validation_pass( - self, event_retrieve_mock, transfer_retrieve_mock, verify_header_mock - ): - reload(djstripe_settings) - - invalid_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - invalid_event["id"] = "evt_invalid" - invalid_event["data"]["valid"] = "not really" - - resp = self._send_event(invalid_event) - - self.assertEqual(resp.status_code, 200) - self.assertTrue(Event.objects.filter(id="evt_invalid").exists()) - event_retrieve_mock.assert_not_called() - verify_header_mock.assert_not_called() - - def test_webhook_no_signature(self): - self.assertEqual(WebhookEventTrigger.objects.count(), 0) - resp = Client().post( - reverse("djstripe:webhook"), "{}", content_type="application/json" - ) - self.assertEqual(resp.status_code, 400) - self.assertEqual(WebhookEventTrigger.objects.count(), 0) - - def test_webhook_remote_addr_is_none(self): - self.assertEqual(WebhookEventTrigger.objects.count(), 0) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - Client().post( - reverse("djstripe:webhook"), - "{}", - content_type="application/json", - HTTP_STRIPE_SIGNATURE="PLACEHOLDER", - REMOTE_ADDR=None, - ) - - self.assertEqual(WebhookEventTrigger.objects.count(), 1) - event_trigger = WebhookEventTrigger.objects.first() - self.assertEqual(event_trigger.remote_ip, "0.0.0.0") - - def test_webhook_remote_addr_is_empty_string(self): - self.assertEqual(WebhookEventTrigger.objects.count(), 0) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - Client().post( - reverse("djstripe:webhook"), - "{}", - content_type="application/json", - HTTP_STRIPE_SIGNATURE="PLACEHOLDER", - REMOTE_ADDR="", - ) - - self.assertEqual(WebhookEventTrigger.objects.count(), 1) - event_trigger = WebhookEventTrigger.objects.first() - self.assertEqual(event_trigger.remote_ip, "0.0.0.0") - - @patch( - "djstripe.models.WebhookEventTrigger.validate", return_value=True, autospec=True - ) - @patch("djstripe.models.WebhookEventTrigger.process", autospec=True) - def test_webhook_reraise_exception( - self, webhook_event_process_mock, webhook_event_validate_mock - ): - class ProcessException(Exception): - pass - - exception_message = "process fail" - - webhook_event_process_mock.side_effect = ProcessException(exception_message) - - self.assertEqual(WebhookEventTrigger.objects.count(), 0) - - fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - - with self.assertRaisesMessage(ProcessException, exception_message): - self._send_event(fake_event) - - self.assertEqual(WebhookEventTrigger.objects.count(), 1) - event_trigger = WebhookEventTrigger.objects.first() - self.assertEqual(event_trigger.exception, exception_message) - - @patch.object( - djstripe_settings, "WEBHOOK_EVENT_CALLBACK", return_value=mock_webhook_handler - ) - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_webhook_with_custom_callback( - self, event_retrieve_mock, transfer_retrieve_mock, webhook_event_callback_mock - ): - fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - event_retrieve_mock.return_value = fake_event - - djstripe_settings.WEBHOOK_SECRET = "" - resp = self._send_event(fake_event) - self.assertEqual(resp.status_code, 200) - webhook_event_trigger = WebhookEventTrigger.objects.get() - webhook_event_callback_mock.called_once_with(webhook_event_trigger) - - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_webhook_with_transfer_event_duplicate( - self, event_retrieve_mock, transfer_retrieve_mock - ): - fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - event_retrieve_mock.return_value = fake_event - - djstripe_settings.WEBHOOK_SECRET = "" - resp = self._send_event(fake_event) - self.assertEqual(resp.status_code, 200) - self.assertTrue(Event.objects.filter(type="transfer.created").exists()) - self.assertEqual(1, Event.objects.filter(type="transfer.created").count()) - - # Duplication - resp = self._send_event(fake_event) - self.assertEqual(resp.status_code, 200) - self.assertEqual(1, Event.objects.filter(type="transfer.created").count()) - - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_webhook_good(self, event_retrieve_mock, transfer_retrieve_mock): - djstripe_settings.WEBHOOK_SECRET = "" - - fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - event_retrieve_mock.return_value = fake_event - resp = self._send_event(fake_event) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(Event.objects.count(), 1) - self.assertEqual(WebhookEventTrigger.objects.count(), 1) - - event_trigger = WebhookEventTrigger.objects.first() - self.assertEqual(event_trigger.is_test_event, False) - - @patch.object(target=Event, attribute="invoke_webhook_handlers", autospec=True) - @patch("stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True) - @patch("stripe.Event.retrieve", autospec=True) - def test_webhook_error( - self, event_retrieve_mock, transfer_retrieve_mock, mock_invoke_webhook_handlers - ): - """Test the case where webhook processing fails to ensure we rollback - and do not commit the Event object to the database. - """ - mock_invoke_webhook_handlers.side_effect = KeyError("Test error") - djstripe_settings.WEBHOOK_SECRET = "" - - fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) - event_retrieve_mock.return_value = fake_event - with self.assertRaises(KeyError): - self._send_event(fake_event) - - self.assertEqual(Event.objects.count(), 0) - self.assertEqual(WebhookEventTrigger.objects.count(), 1) - - event_trigger = WebhookEventTrigger.objects.first() - self.assertEqual(event_trigger.is_test_event, False) - self.assertEqual(event_trigger.exception, "'Test error'") + def tearDown(self): + reload(djstripe_settings) + + def _send_event(self, event_data): + return Client().post( + reverse("djstripe:webhook"), + json.dumps(event_data), + content_type="application/json", + HTTP_STRIPE_SIGNATURE="PLACEHOLDER", + ) + + def test_webhook_test_event(self): + self.assertEqual(WebhookEventTrigger.objects.count(), 0) + resp = self._send_event(FAKE_EVENT_TEST_CHARGE_SUCCEEDED) + self.assertEqual(resp.status_code, 200) + self.assertFalse(Event.objects.filter(id=TEST_EVENT_ID).exists()) + self.assertEqual(WebhookEventTrigger.objects.count(), 1) + event_trigger = WebhookEventTrigger.objects.first() + self.assertTrue(event_trigger.is_test_event) + + @override_settings(DJSTRIPE_WEBHOOK_VALIDATION="retrieve_event") + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch( + "stripe.Event.retrieve", + return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), + autospec=True, + ) + def test_webhook_retrieve_event_fail( + self, event_retrieve_mock, transfer_retrieve_mock + ): + reload(djstripe_settings) + + invalid_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + invalid_event["id"] = "evt_invalid" + invalid_event["data"]["valid"] = "not really" + + resp = self._send_event(invalid_event) + + self.assertEqual(resp.status_code, 400) + self.assertFalse(Event.objects.filter(id="evt_invalid").exists()) + + @override_settings(DJSTRIPE_WEBHOOK_VALIDATION="retrieve_event") + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch( + "stripe.Event.retrieve", + return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), + autospec=True, + ) + def test_webhook_retrieve_event_pass( + self, event_retrieve_mock, transfer_retrieve_mock + ): + reload(djstripe_settings) + + resp = self._send_event(FAKE_EVENT_TRANSFER_CREATED) + + self.assertEqual(resp.status_code, 200) + event_retrieve_mock.assert_called_once_with( + api_key=djstripe_settings.STRIPE_SECRET_KEY, + id=FAKE_EVENT_TRANSFER_CREATED["id"], + ) + + @override_settings( + DJSTRIPE_WEBHOOK_VALIDATION="verify_signature", + DJSTRIPE_WEBHOOK_SECRET="whsec_XXXXX", + ) + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch( + "stripe.Event.retrieve", + return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), + autospec=True, + ) + def test_webhook_invalid_verify_signature_fail( + self, event_retrieve_mock, transfer_retrieve_mock + ): + reload(djstripe_settings) + + invalid_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + invalid_event["id"] = "evt_invalid" + invalid_event["data"]["valid"] = "not really" + + resp = self._send_event(invalid_event) + + self.assertEqual(resp.status_code, 400) + self.assertFalse(Event.objects.filter(id="evt_invalid").exists()) + + @override_settings( + DJSTRIPE_WEBHOOK_VALIDATION="verify_signature", + DJSTRIPE_WEBHOOK_SECRET="whsec_XXXXX", + ) + @patch( + "stripe.WebhookSignature.verify_header", + return_value=True, + autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED + and IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + ) + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch( + "stripe.Event.retrieve", + return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), + autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + ) + def test_webhook_verify_signature_pass( + self, event_retrieve_mock, transfer_retrieve_mock, verify_header_mock + ): + reload(djstripe_settings) + + resp = self._send_event(FAKE_EVENT_TRANSFER_CREATED) + + self.assertEqual(resp.status_code, 200) + self.assertFalse(Event.objects.filter(id="evt_invalid").exists()) + verify_header_mock.assert_called_once_with( + json.dumps(FAKE_EVENT_TRANSFER_CREATED), + "PLACEHOLDER", + djstripe_settings.WEBHOOK_SECRET, + djstripe_settings.WEBHOOK_TOLERANCE, + ) + event_retrieve_mock.assert_not_called() + + @override_settings(DJSTRIPE_WEBHOOK_VALIDATION=None) + @patch("stripe.WebhookSignature.verify_header", autospec=True) + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch( + "stripe.Event.retrieve", + return_value=deepcopy(FAKE_EVENT_TRANSFER_CREATED), + autospec=IS_ASSERT_CALLED_AUTOSPEC_SUPPORTED, + ) + def test_webhook_no_validation_pass( + self, event_retrieve_mock, transfer_retrieve_mock, verify_header_mock + ): + reload(djstripe_settings) + + invalid_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + invalid_event["id"] = "evt_invalid" + invalid_event["data"]["valid"] = "not really" + + resp = self._send_event(invalid_event) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(Event.objects.filter(id="evt_invalid").exists()) + event_retrieve_mock.assert_not_called() + verify_header_mock.assert_not_called() + + def test_webhook_no_signature(self): + self.assertEqual(WebhookEventTrigger.objects.count(), 0) + resp = Client().post( + reverse("djstripe:webhook"), "{}", content_type="application/json" + ) + self.assertEqual(resp.status_code, 400) + self.assertEqual(WebhookEventTrigger.objects.count(), 0) + + def test_webhook_remote_addr_is_none(self): + self.assertEqual(WebhookEventTrigger.objects.count(), 0) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + Client().post( + reverse("djstripe:webhook"), + "{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="PLACEHOLDER", + REMOTE_ADDR=None, + ) + + self.assertEqual(WebhookEventTrigger.objects.count(), 1) + event_trigger = WebhookEventTrigger.objects.first() + self.assertEqual(event_trigger.remote_ip, "0.0.0.0") + + def test_webhook_remote_addr_is_empty_string(self): + self.assertEqual(WebhookEventTrigger.objects.count(), 0) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + Client().post( + reverse("djstripe:webhook"), + "{}", + content_type="application/json", + HTTP_STRIPE_SIGNATURE="PLACEHOLDER", + REMOTE_ADDR="", + ) + + self.assertEqual(WebhookEventTrigger.objects.count(), 1) + event_trigger = WebhookEventTrigger.objects.first() + self.assertEqual(event_trigger.remote_ip, "0.0.0.0") + + @patch( + "djstripe.models.WebhookEventTrigger.validate", return_value=True, autospec=True + ) + @patch("djstripe.models.WebhookEventTrigger.process", autospec=True) + def test_webhook_reraise_exception( + self, webhook_event_process_mock, webhook_event_validate_mock + ): + class ProcessException(Exception): + pass + + exception_message = "process fail" + + webhook_event_process_mock.side_effect = ProcessException(exception_message) + + self.assertEqual(WebhookEventTrigger.objects.count(), 0) + + fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + + with self.assertRaisesMessage(ProcessException, exception_message): + self._send_event(fake_event) + + self.assertEqual(WebhookEventTrigger.objects.count(), 1) + event_trigger = WebhookEventTrigger.objects.first() + self.assertEqual(event_trigger.exception, exception_message) + + @patch.object( + djstripe_settings, "WEBHOOK_EVENT_CALLBACK", return_value=mock_webhook_handler + ) + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch("stripe.Event.retrieve", autospec=True) + def test_webhook_with_custom_callback( + self, event_retrieve_mock, transfer_retrieve_mock, webhook_event_callback_mock + ): + fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + event_retrieve_mock.return_value = fake_event + + djstripe_settings.WEBHOOK_SECRET = "" + resp = self._send_event(fake_event) + self.assertEqual(resp.status_code, 200) + webhook_event_trigger = WebhookEventTrigger.objects.get() + webhook_event_callback_mock.called_once_with(webhook_event_trigger) + + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch("stripe.Event.retrieve", autospec=True) + def test_webhook_with_transfer_event_duplicate( + self, event_retrieve_mock, transfer_retrieve_mock + ): + fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + event_retrieve_mock.return_value = fake_event + + djstripe_settings.WEBHOOK_SECRET = "" + resp = self._send_event(fake_event) + self.assertEqual(resp.status_code, 200) + self.assertTrue(Event.objects.filter(type="transfer.created").exists()) + self.assertEqual(1, Event.objects.filter(type="transfer.created").count()) + + # Duplication + resp = self._send_event(fake_event) + self.assertEqual(resp.status_code, 200) + self.assertEqual(1, Event.objects.filter(type="transfer.created").count()) + + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch("stripe.Event.retrieve", autospec=True) + def test_webhook_good(self, event_retrieve_mock, transfer_retrieve_mock): + djstripe_settings.WEBHOOK_SECRET = "" + + fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + event_retrieve_mock.return_value = fake_event + resp = self._send_event(fake_event) + + self.assertEqual(resp.status_code, 200) + self.assertEqual(Event.objects.count(), 1) + self.assertEqual(WebhookEventTrigger.objects.count(), 1) + + event_trigger = WebhookEventTrigger.objects.first() + self.assertEqual(event_trigger.is_test_event, False) + + @patch.object(target=Event, attribute="invoke_webhook_handlers", autospec=True) + @patch( + "stripe.Transfer.retrieve", return_value=deepcopy(FAKE_TRANSFER), autospec=True + ) + @patch("stripe.Event.retrieve", autospec=True) + def test_webhook_error( + self, event_retrieve_mock, transfer_retrieve_mock, mock_invoke_webhook_handlers + ): + """Test the case where webhook processing fails to ensure we rollback + and do not commit the Event object to the database. + """ + mock_invoke_webhook_handlers.side_effect = KeyError("Test error") + djstripe_settings.WEBHOOK_SECRET = "" + + fake_event = deepcopy(FAKE_EVENT_TRANSFER_CREATED) + event_retrieve_mock.return_value = fake_event + with self.assertRaises(KeyError): + self._send_event(fake_event) + + self.assertEqual(Event.objects.count(), 0) + self.assertEqual(WebhookEventTrigger.objects.count(), 1) + + event_trigger = WebhookEventTrigger.objects.first() + self.assertEqual(event_trigger.is_test_event, False) + self.assertEqual(event_trigger.exception, "'Test error'") class TestWebhookHandlers(TestCase): - def setUp(self): - # Reset state of registrations per test - patcher = patch.object( - webhooks, "registrations", new_callable=(lambda: defaultdict(list)) - ) - self.addCleanup(patcher.stop) - self.registrations = patcher.start() - - patcher = patch.object(webhooks, "registrations_global", new_callable=list) - self.addCleanup(patcher.stop) - self.registrations_global = patcher.start() - - def test_global_handler_registration(self): - func_mock = Mock() - handler_all()(func_mock) - event = self._call_handlers("wib.ble", {"data": "foo"}) # handled - self.assertEqual(1, func_mock.call_count) - func_mock.assert_called_with(event=event) - - def test_event_handler_registration(self): - global_func_mock = Mock() - handler_all()(global_func_mock) - func_mock = Mock() - handler("foo")(func_mock) - event = self._call_handlers("foo.bar", {"data": "foo"}) # handled - self._call_handlers("bar.foo", {"data": "foo"}) # not handled - self.assertEqual(2, global_func_mock.call_count) # called each time - self.assertEqual(1, func_mock.call_count) - func_mock.assert_called_with(event=event) - - def test_event_subtype_handler_registration(self): - global_func_mock = Mock() - handler_all()(global_func_mock) - func_mock = Mock() - handler("foo.bar")(func_mock) - event1 = self._call_handlers("foo.bar", {"data": "foo"}) # handled - event2 = self._call_handlers("foo.bar.wib", {"data": "foo"}) # handled - self._call_handlers("foo.baz", {"data": "foo"}) # not handled - self.assertEqual(3, global_func_mock.call_count) # called each time - self.assertEqual(2, func_mock.call_count) - func_mock.assert_has_calls([call(event=event1), call(event=event2)]) - - def test_global_handler_registration_with_function(self): - func_mock = Mock() - handler_all(func_mock) - event = self._call_handlers("wib.ble", {"data": "foo"}) # handled - self.assertEqual(1, func_mock.call_count) - func_mock.assert_called_with(event=event) - - def test_event_handle_registation_with_string(self): - func_mock = Mock() - handler("foo")(func_mock) - event = self._call_handlers("foo.bar", {"data": "foo"}) # handled - self.assertEqual(1, func_mock.call_count) - func_mock.assert_called_with(event=event) - - def test_event_handle_registation_with_list_of_strings(self): - func_mock = Mock() - handler("foo", "bar")(func_mock) - event1 = self._call_handlers("foo.bar", {"data": "foo"}) # handled - event2 = self._call_handlers("bar.foo", {"data": "bar"}) # handled - self.assertEqual(2, func_mock.call_count) - func_mock.assert_has_calls([call(event=event1), call(event=event2)]) - - # - # Helpers - # - - @staticmethod - def _call_handlers(event_spec, data): - event = Mock(spec=Event) - parts = event_spec.split(".") - category = parts[0] - verb = ".".join(parts[1:]) - type(event).parts = PropertyMock(return_value=parts) - type(event).category = PropertyMock(return_value=category) - type(event).verb = PropertyMock(return_value=verb) - call_handlers(event=event) - return event + def setUp(self): + # Reset state of registrations per test + patcher = patch.object( + webhooks, "registrations", new_callable=(lambda: defaultdict(list)) + ) + self.addCleanup(patcher.stop) + self.registrations = patcher.start() + + patcher = patch.object(webhooks, "registrations_global", new_callable=list) + self.addCleanup(patcher.stop) + self.registrations_global = patcher.start() + + def test_global_handler_registration(self): + func_mock = Mock() + handler_all()(func_mock) + event = self._call_handlers("wib.ble", {"data": "foo"}) # handled + self.assertEqual(1, func_mock.call_count) + func_mock.assert_called_with(event=event) + + def test_event_handler_registration(self): + global_func_mock = Mock() + handler_all()(global_func_mock) + func_mock = Mock() + handler("foo")(func_mock) + event = self._call_handlers("foo.bar", {"data": "foo"}) # handled + self._call_handlers("bar.foo", {"data": "foo"}) # not handled + self.assertEqual(2, global_func_mock.call_count) # called each time + self.assertEqual(1, func_mock.call_count) + func_mock.assert_called_with(event=event) + + def test_event_subtype_handler_registration(self): + global_func_mock = Mock() + handler_all()(global_func_mock) + func_mock = Mock() + handler("foo.bar")(func_mock) + event1 = self._call_handlers("foo.bar", {"data": "foo"}) # handled + event2 = self._call_handlers("foo.bar.wib", {"data": "foo"}) # handled + self._call_handlers("foo.baz", {"data": "foo"}) # not handled + self.assertEqual(3, global_func_mock.call_count) # called each time + self.assertEqual(2, func_mock.call_count) + func_mock.assert_has_calls([call(event=event1), call(event=event2)]) + + def test_global_handler_registration_with_function(self): + func_mock = Mock() + handler_all(func_mock) + event = self._call_handlers("wib.ble", {"data": "foo"}) # handled + self.assertEqual(1, func_mock.call_count) + func_mock.assert_called_with(event=event) + + def test_event_handle_registation_with_string(self): + func_mock = Mock() + handler("foo")(func_mock) + event = self._call_handlers("foo.bar", {"data": "foo"}) # handled + self.assertEqual(1, func_mock.call_count) + func_mock.assert_called_with(event=event) + + def test_event_handle_registation_with_list_of_strings(self): + func_mock = Mock() + handler("foo", "bar")(func_mock) + event1 = self._call_handlers("foo.bar", {"data": "foo"}) # handled + event2 = self._call_handlers("bar.foo", {"data": "bar"}) # handled + self.assertEqual(2, func_mock.call_count) + func_mock.assert_has_calls([call(event=event1), call(event=event2)]) + + # + # Helpers + # + + @staticmethod + def _call_handlers(event_spec, data): + event = Mock(spec=Event) + parts = event_spec.split(".") + category = parts[0] + verb = ".".join(parts[1:]) + type(event).parts = PropertyMock(return_value=parts) + type(event).category = PropertyMock(return_value=category) + type(event).verb = PropertyMock(return_value=verb) + call_handlers(event=event) + return event diff --git a/tests/test_zz_jsonfield.py b/tests/test_zz_jsonfield.py index 09374d1d55..b923179e40 100644 --- a/tests/test_zz_jsonfield.py +++ b/tests/test_zz_jsonfield.py @@ -12,32 +12,32 @@ from djstripe import settings as djstripe_settings try: - reload + reload except NameError: - from importlib import reload + from importlib import reload @override_settings(DJSTRIPE_USE_NATIVE_JSONFIELD=False) class TestFallbackJSONField(TestCase): - def test_jsonfield_inheritance(self): - reload(djstripe_settings) - reload(fields) + def test_jsonfield_inheritance(self): + reload(djstripe_settings) + reload(fields) - self.assertTrue(issubclass(fields.JSONField, UglyJSONField)) + self.assertTrue(issubclass(fields.JSONField, UglyJSONField)) - def tearDown(self): - reload(djstripe_settings) - reload(fields) + def tearDown(self): + reload(djstripe_settings) + reload(fields) @override_settings(DJSTRIPE_USE_NATIVE_JSONFIELD=True) class TestNativeJSONField(TestCase): - def test_jsonfield_inheritance(self): - reload(djstripe_settings) - reload(fields) + def test_jsonfield_inheritance(self): + reload(djstripe_settings) + reload(fields) - self.assertTrue(issubclass(fields.JSONField, DjangoJSONField)) + self.assertTrue(issubclass(fields.JSONField, DjangoJSONField)) - def tearDown(self): - reload(djstripe_settings) - reload(fields) + def tearDown(self): + reload(djstripe_settings) + reload(fields) diff --git a/tests/urls.py b/tests/urls.py index 654acf0a16..0729eef52f 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -6,23 +6,23 @@ def empty_view(request): - return HttpResponse() + return HttpResponse() urlpatterns = [ - url(r"^home/", empty_view, name="home"), - url(r"^admin/", admin.site.urls), - url(r"^djstripe/", include("djstripe.urls", namespace="djstripe")), - url(r"^example/", include("tests.apps.example.urls")), - url(r"^testapp/", include("tests.apps.testapp.urls")), - url( - r"^testapp_namespaced/", - include("tests.apps.testapp_namespaced.urls", namespace="testapp_namespaced"), - ), - # Represents protected content - url(r"^testapp_content/", include("tests.apps.testapp_content.urls")), - # For testing fnmatches - url(r"test_fnmatch/extra_text/$", empty_view, name="test_fnmatch"), - # Default for DJSTRIPE_SUBSCRIPTION_REDIRECT - url(r"subscribe/$", empty_view, name="test_url_subscribe"), + url(r"^home/", empty_view, name="home"), + url(r"^admin/", admin.site.urls), + url(r"^djstripe/", include("djstripe.urls", namespace="djstripe")), + url(r"^example/", include("tests.apps.example.urls")), + url(r"^testapp/", include("tests.apps.testapp.urls")), + url( + r"^testapp_namespaced/", + include("tests.apps.testapp_namespaced.urls", namespace="testapp_namespaced"), + ), + # Represents protected content + url(r"^testapp_content/", include("tests.apps.testapp_content.urls")), + # For testing fnmatches + url(r"test_fnmatch/extra_text/$", empty_view, name="test_fnmatch"), + # Default for DJSTRIPE_SUBSCRIPTION_REDIRECT + url(r"subscribe/$", empty_view, name="test_url_subscribe"), ] diff --git a/tox.ini b/tox.ini index a330c7ed0b..455f5f9df7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,35 +1,35 @@ [tox] envlist = - py35-django{21,22} - py36-django{21,22} - py37-django{21,22,master} - py37-django22-checkmigrations - flake8 + py35-django{21,22} + py36-django{21,22} + py37-django{21,22,master} + py37-django22-checkmigrations + flake8 [testenv] passenv = DJSTRIPE_* setenv = - PYTHONWARNINGS = all - PYTEST_ADDOPTS = --cov --cov-fail-under=95 + PYTHONWARNINGS = all + PYTEST_ADDOPTS = --cov --cov-fail-under=95 commands = pytest {posargs} deps = - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 - django22: Django>=2.2,<2.3 - djangomaster: https://github.com/django/django/archive/master.tar.gz - djangorestframework - psycopg2 - pytest-django - pytest-cov + django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 + django22: Django>=2.2,<2.3 + djangomaster: https://github.com/django/django/archive/master.tar.gz + djangorestframework + psycopg2 + pytest-django + pytest-cov [testenv:flake8] skip_install = True deps = - flake8 - isort + flake8 + isort commands = - flake8 {toxinidir} {posargs} - isort {toxinidir} -c + flake8 {toxinidir} {posargs} + isort {toxinidir} -c [testenv:checkmigrations] setenv = DJSTRIPE_TEST_DB_VENDOR=sqlite @@ -43,20 +43,20 @@ commands = ./manage.py makemigrations whitelist_externals = mkdir changedir = {toxinidir}/djstripe commands = - - mkdir -p {toxinidir}/djstripe/locale - - django-admin.py makemessages {posargs} + - mkdir -p {toxinidir}/djstripe/locale + - django-admin.py makemessages {posargs} deps = - Django>=2.2,<2.3 + Django>=2.2,<2.3 [testenv:docs] changedir = docs whitelist_externals = make commands = make html deps = - sphinx - sphinx_rtd_theme - sphinx-autobuild - sphinxcontrib-django + sphinx + sphinx_rtd_theme + sphinx-autobuild + sphinxcontrib-django [pytest] DJANGO_SETTINGS_MODULE = tests.settings @@ -65,10 +65,10 @@ DJANGO_SETTINGS_MODULE = tests.settings branch = True source = djstripe omit = - djstripe/migrations/* - djstripe/management/* - djstripe/admin.py - djstripe/checks.py + djstripe/migrations/* + djstripe/management/* + djstripe/admin.py + djstripe/checks.py [coverage:html] directory = cover @@ -79,13 +79,16 @@ exclude = djstripe/migrations/, .tox/, build/lib/ # TODO - stop ignoring E117 once fix for # https://github.com/PyCQA/pycodestyle/issues/836 is released ignore = W191, W503, E203, E117 -max-line-length = 113 +max-line-length = 88 [isort] default_section = THIRDPARTY known_first_party = djstripe -multi_line_output = 5 skip = .tox/ +# black compatibility, as per +# https://black.readthedocs.io/en/stable/the_black_code_style.html?highlight=.isort.cfg#how-black-wraps-lines +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True line_length = 88 -balanced_wrapping = True -indent = tab