From be18c8c2990596329221b8af60e81ada5b85dafa Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 20 Oct 2018 11:37:37 -0500 Subject: [PATCH 01/49] Initial pass at moving away from previous model This is per comment: https://github.com/pinax/pinax-stripe/issues/594#issuecomment-431586360 --- pinax/stripe/actions/__init__.py | 0 pinax/stripe/actions/accounts.py | 225 -- pinax/stripe/actions/charges.py | 235 -- pinax/stripe/actions/coupons.py | 32 - pinax/stripe/actions/customers.py | 232 -- pinax/stripe/actions/events.py | 52 - pinax/stripe/actions/exceptions.py | 24 - pinax/stripe/actions/externalaccounts.py | 64 - pinax/stripe/actions/invoices.py | 195 - pinax/stripe/actions/plans.py | 39 - pinax/stripe/actions/refunds.py | 24 - pinax/stripe/actions/sources.py | 142 - pinax/stripe/actions/subscriptions.py | 199 - pinax/stripe/actions/transfers.py | 105 - pinax/stripe/admin.py | 421 +-- pinax/stripe/conf.py | 32 - pinax/stripe/forms.py | 50 +- pinax/stripe/hooks.py | 65 - pinax/stripe/management/__init__.py | 0 pinax/stripe/management/commands/__init__.py | 0 .../management/commands/init_customers.py | 15 - .../management/commands/sync_coupons.py | 11 - .../management/commands/sync_customers.py | 36 - .../stripe/management/commands/sync_plans.py | 11 - .../commands/update_charge_availability.py | 11 - pinax/stripe/managers.py | 73 - pinax/stripe/middleware.py | 32 - pinax/stripe/mixins.py | 40 - pinax/stripe/models.py | 612 +-- .../templates/pinax/stripe/email/body.txt | 1 - .../pinax/stripe/email/body_base.txt | 27 - .../templates/pinax/stripe/email/subject.txt | 1 - pinax/stripe/tests/hooks.py | 28 - pinax/stripe/tests/settings.py | 3 - pinax/stripe/tests/test_actions.py | 3299 ----------------- pinax/stripe/tests/test_admin.py | 216 -- pinax/stripe/tests/test_commands.py | 160 - pinax/stripe/tests/test_email.py | 68 - pinax/stripe/tests/test_event.py | 321 -- pinax/stripe/tests/test_forms.py | 398 -- pinax/stripe/tests/test_hooks.py | 81 - pinax/stripe/tests/test_managers.py | 195 - pinax/stripe/tests/test_middleware.py | 119 - pinax/stripe/tests/test_models.py | 294 +- pinax/stripe/tests/test_views.py | 484 --- pinax/stripe/tests/test_webhooks.py | 469 +-- pinax/stripe/urls.py | 25 +- pinax/stripe/views.py | 218 +- pinax/stripe/webhooks.py | 175 +- 49 files changed, 137 insertions(+), 9422 deletions(-) delete mode 100644 pinax/stripe/actions/__init__.py delete mode 100644 pinax/stripe/actions/accounts.py delete mode 100644 pinax/stripe/actions/charges.py delete mode 100644 pinax/stripe/actions/coupons.py delete mode 100644 pinax/stripe/actions/customers.py delete mode 100644 pinax/stripe/actions/events.py delete mode 100644 pinax/stripe/actions/exceptions.py delete mode 100644 pinax/stripe/actions/externalaccounts.py delete mode 100644 pinax/stripe/actions/invoices.py delete mode 100644 pinax/stripe/actions/plans.py delete mode 100644 pinax/stripe/actions/refunds.py delete mode 100644 pinax/stripe/actions/sources.py delete mode 100644 pinax/stripe/actions/subscriptions.py delete mode 100644 pinax/stripe/actions/transfers.py delete mode 100644 pinax/stripe/hooks.py delete mode 100644 pinax/stripe/management/__init__.py delete mode 100644 pinax/stripe/management/commands/__init__.py delete mode 100644 pinax/stripe/management/commands/init_customers.py delete mode 100644 pinax/stripe/management/commands/sync_coupons.py delete mode 100644 pinax/stripe/management/commands/sync_customers.py delete mode 100644 pinax/stripe/management/commands/sync_plans.py delete mode 100644 pinax/stripe/management/commands/update_charge_availability.py delete mode 100644 pinax/stripe/managers.py delete mode 100644 pinax/stripe/middleware.py delete mode 100644 pinax/stripe/mixins.py delete mode 100644 pinax/stripe/templates/pinax/stripe/email/body.txt delete mode 100644 pinax/stripe/templates/pinax/stripe/email/body_base.txt delete mode 100644 pinax/stripe/templates/pinax/stripe/email/subject.txt delete mode 100644 pinax/stripe/tests/hooks.py delete mode 100644 pinax/stripe/tests/test_actions.py delete mode 100644 pinax/stripe/tests/test_admin.py delete mode 100644 pinax/stripe/tests/test_commands.py delete mode 100644 pinax/stripe/tests/test_email.py delete mode 100644 pinax/stripe/tests/test_event.py delete mode 100644 pinax/stripe/tests/test_forms.py delete mode 100644 pinax/stripe/tests/test_hooks.py delete mode 100644 pinax/stripe/tests/test_managers.py delete mode 100644 pinax/stripe/tests/test_middleware.py delete mode 100644 pinax/stripe/tests/test_views.py diff --git a/pinax/stripe/actions/__init__.py b/pinax/stripe/actions/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/actions/accounts.py b/pinax/stripe/actions/accounts.py deleted file mode 100644 index 5ca658cdf..000000000 --- a/pinax/stripe/actions/accounts.py +++ /dev/null @@ -1,225 +0,0 @@ -import datetime - -import stripe - -from .. import models, utils -from .externalaccounts import sync_bank_account_from_stripe_data - - -def create(user, country, **kwargs): - """ - Create an Account. - - Args: - country: two letter country code for where the individual lives - - Returns: - a pinax.stripe.models.Account object - """ - kwargs["country"] = country - stripe_account = stripe.Account.create(**kwargs) - return sync_account_from_stripe_data( - stripe_account, user=user - ) - - -def update(account, data): - """ - Update the given account with extra data. - - Args: - account: a pinax.stripe.models.Account object - data: dict of account fields to update via API: - - first_name -> legal_entity.first_name - last_name -> legal_entity.last_name - dob -> legal_entity.dob - personal_id_number -> legal_entity.personal_id_number - document -> legal_entity.verification.document - - Returns: - a pinax.stripe.models.Account object - """ - stripe_account = stripe.Account.retrieve(id=account.stripe_id) - - if data.get("dob"): - stripe_account.legal_entity.dob = data["dob"] - - if data.get("first_name"): - stripe_account.legal_entity.first_name = data["first_name"] - - if data.get("last_name"): - stripe_account.legal_entity.last_name = data["last_name"] - - if data.get("personal_id_number"): - stripe_account.legal_entity.personal_id_number = data["personal_id_number"] - - if data.get("document"): - response = stripe.FileUpload.create( - purpose="identity_document", - file=data["document"], - stripe_account=stripe_account.id - ) - stripe_account.legal_entity.verification.document = response["id"] - - stripe_account.save() - return sync_account_from_stripe_data(stripe_account) - - -def sync_account(account): - """ - Update the given local Account instance from remote data. - - Args: - account: a pinax.stripe.models.Account object - - Returns: - a pinax.stripe.models.Account object - """ - stripe_account = stripe.Account.retrieve(id=account.stripe_id) - return sync_account_from_stripe_data(stripe_account) - - -def sync_account_from_stripe_data(data, user=None): - """ - Create or update using the account object from a Stripe API query. - - Args: - data: the data representing an account object in the Stripe API - - Returns: - a pinax.stripe.models.Account object - """ - kwargs = {"stripe_id": data["id"]} - if user: - kwargs["user"] = user - obj, created = models.Account.objects.get_or_create( - **kwargs - ) - common_attrs = ( - "business_name", "business_url", "charges_enabled", "country", - "default_currency", "details_submitted", "display_name", - "email", "type", "statement_descriptor", "support_email", - "support_phone", "timezone", "payouts_enabled" - ) - - custom_attrs = ( - "debit_negative_balances", "metadata", "product_description", - "payout_statement_descriptor" - ) - - if data["type"] == "custom": - top_level_attrs = common_attrs + custom_attrs - else: - top_level_attrs = common_attrs - - for a in [x for x in top_level_attrs if x in data]: - setattr(obj, a, data[a]) - - # that's all we get for standard and express accounts! - if data["type"] != "custom": - obj.save() - return obj - - # otherwise we continue on to gather a range of details available - # to us on custom accounts - - # legal entity for individual accounts - le = data["legal_entity"] - address = le["address"] - obj.legal_entity_address_city = address["city"] - obj.legal_entity_address_country = address["country"] - obj.legal_entity_address_line1 = address["line1"] - obj.legal_entity_address_line2 = address["line2"] - obj.legal_entity_address_postal_code = address["postal_code"] - obj.legal_entity_address_state = address["state"] - - dob = le["dob"] - if dob: - obj.legal_entity_dob = datetime.date( - dob["year"], dob["month"], dob["day"] - ) - else: - obj.legal_entity_dob = None - - obj.legal_entity_type = le["type"] - obj.legal_entity_first_name = le["first_name"] - obj.legal_entity_last_name = le["last_name"] - obj.legal_entity_personal_id_number_provided = le["personal_id_number_provided"] - - # these attributes are not always present - obj.legal_entity_gender = le.get( - "gender", obj.legal_entity_gender - ) - obj.legal_entity_maiden_name = le.get( - "maiden_name", obj.legal_entity_maiden_name - ) - obj.legal_entity_phone_number = le.get( - "phone_number", obj.legal_entity_phone_number - ) - obj.legal_entity_ssn_last_4_provided = le.get( - "ssn_last_4_provided", obj.legal_entity_ssn_last_4_provided - ) - - verification = le["verification"] - if verification: - obj.legal_entity_verification_details = verification.get("details") - obj.legal_entity_verification_details_code = verification.get("details_code") - obj.legal_entity_verification_document = verification.get("document") - obj.legal_entity_verification_status = verification.get("status") - else: - obj.legal_entity_verification_details = None - obj.legal_entity_verification_details_code = None - obj.legal_entity_verification_document = None - obj.legal_entity_verification_status = None - - # tos state - if data["tos_acceptance"]["date"]: - obj.tos_acceptance_date = datetime.datetime.utcfromtimestamp( - data["tos_acceptance"]["date"] - ) - else: - obj.tos_acceptance_date = None - obj.tos_acceptance_ip = data["tos_acceptance"]["ip"] - obj.tos_acceptance_user_agent = data["tos_acceptance"]["user_agent"] - - # decline charge on certain conditions - obj.decline_charge_on_avs_failure = data["decline_charge_on"]["avs_failure"] - obj.decline_charge_on_cvc_failure = data["decline_charge_on"]["cvc_failure"] - - # transfer schedule to external account - ps = data["payout_schedule"] - obj.payout_schedule_interval = ps["interval"] - obj.payout_schedule_delay_days = ps.get("delay_days") - obj.payout_schedule_weekly_anchor = ps.get("weekly_anchor") - obj.payout_schedule_monthly_anchor = ps.get("monthly_anchor") - - # verification status, key to progressing account setup - obj.verification_disabled_reason = data["verification"]["disabled_reason"] - obj.verification_due_by = utils.convert_tstamp(data["verification"], "due_by") - obj.verification_fields_needed = data["verification"]["fields_needed"] - - obj.save() - - # sync any external accounts (bank accounts only for now) included - for external_account in data["external_accounts"]["data"]: - if external_account["object"] == "bank_account": - sync_bank_account_from_stripe_data(external_account) - - return obj - - -def delete(account): - """ - Delete an account both remotely and locally. - - Note that this will fail if the account's balance is - non-zero. - """ - account.stripe_account.delete() - account.delete() - - -def deauthorize(account): - account.authorized = False - account.save() diff --git a/pinax/stripe/actions/charges.py b/pinax/stripe/actions/charges.py deleted file mode 100644 index 375f8ffda..000000000 --- a/pinax/stripe/actions/charges.py +++ /dev/null @@ -1,235 +0,0 @@ -import decimal - -from django.conf import settings -from django.db.models import Q - -import stripe -from six import string_types - -from .. import hooks, models, utils - - -def calculate_refund_amount(charge, amount=None): - """ - Calculate refund amount given a charge and optional amount. - - Args: - charge: a pinax.stripe.models.Charge object - amount: optionally, the decimal.Decimal amount you wish to refund - """ - eligible_to_refund = charge.amount - (charge.amount_refunded or 0) - if amount: - return min(eligible_to_refund, amount) - return eligible_to_refund - - -def capture(charge, amount=None, idempotency_key=None): - """ - Capture the payment of an existing, uncaptured, charge. - - Args: - charge: a pinax.stripe.models.Charge object - amount: the decimal.Decimal amount of the charge to capture - idempotency_key: Any string that allows retries to be performed safely. - """ - amount = utils.convert_amount_for_api( - amount if amount else charge.amount, - charge.currency - ) - stripe_charge = stripe.Charge( - charge.stripe_id, - stripe_account=charge.stripe_account_stripe_id, - ).capture( - amount=amount, - idempotency_key=idempotency_key, - expand=["balance_transaction"], - ) - sync_charge_from_stripe_data(stripe_charge) - - -def _validate_create_params(customer, source, amount, application_fee, destination_account, destination_amount, on_behalf_of): - if not customer and not source: - raise ValueError("Must provide `customer` or `source`.") - if not isinstance(amount, decimal.Decimal): - raise ValueError( - "You must supply a decimal value for `amount`." - ) - if application_fee and not isinstance(application_fee, decimal.Decimal): - raise ValueError( - "You must supply a decimal value for `application_fee`." - ) - if application_fee and not destination_account: - raise ValueError( - "You can only specify `application_fee` with `destination_account`" - ) - if application_fee and destination_account and destination_amount: - raise ValueError( - "You can't specify `application_fee` with `destination_amount`" - ) - if destination_account and on_behalf_of: - raise ValueError( - "`destination_account` and `on_behalf_of` are mutualy exclusive") - - -def create( - amount, customer=None, source=None, currency="usd", description=None, - send_receipt=settings.PINAX_STRIPE_SEND_EMAIL_RECEIPTS, capture=True, - email=None, destination_account=None, destination_amount=None, - application_fee=None, on_behalf_of=None, idempotency_key=None, - stripe_account=None -): - """ - Create a charge for the given customer or source. - - If both customer and source are provided, the source must belong to the - customer. - - See https://stripe.com/docs/api#create_charge-customer. - - Args: - amount: should be a decimal.Decimal amount - customer: the Customer object to charge - source: the Stripe id of the source to charge - currency: the currency with which to charge the amount in - description: a description of the charge - send_receipt: send a receipt upon successful charge - capture: immediately capture the charge instead of doing a pre-authorization - destination_account: stripe_id of a connected account - destination_amount: amount to transfer to the `destination_account` without creating an application fee - application_fee: used with `destination_account` to add a fee destined for the platform account - on_behalf_of: Stripe account ID that these funds are intended for. Automatically set if you use the destination parameter. - idempotency_key: Any string that allows retries to be performed safely. - - Returns: - a pinax.stripe.models.Charge object - """ - # Handle customer as stripe_id for backward compatibility. - if customer and not isinstance(customer, models.Customer): - customer, _ = models.Customer.objects.get_or_create(stripe_id=customer) - _validate_create_params(customer, source, amount, application_fee, destination_account, destination_amount, on_behalf_of) - stripe_account_stripe_id = None - if stripe_account: - stripe_account_stripe_id = stripe_account.stripe_id - if customer and customer.stripe_account_stripe_id: - stripe_account_stripe_id = customer.stripe_account_stripe_id - kwargs = dict( - amount=utils.convert_amount_for_api(amount, currency), # find the final amount - currency=currency, - source=source, - customer=customer.stripe_id if customer else None, - stripe_account=stripe_account_stripe_id, - description=description, - capture=capture, - idempotency_key=idempotency_key, - ) - if destination_account: - kwargs["destination"] = {"account": destination_account} - if destination_amount: - kwargs["destination"]["amount"] = utils.convert_amount_for_api( - destination_amount, - currency - ) - if application_fee: - kwargs["application_fee"] = utils.convert_amount_for_api( - application_fee, currency - ) - elif on_behalf_of: - kwargs["on_behalf_of"] = on_behalf_of - stripe_charge = stripe.Charge.create( - **kwargs - ) - charge = sync_charge_from_stripe_data(stripe_charge) - if send_receipt: - hooks.hookset.send_receipt(charge, email) - return charge - - -def retrieve(stripe_id, stripe_account=None): - """Retrieve a Charge plus its balance info.""" - return stripe.Charge.retrieve( - stripe_id, - stripe_account=stripe_account, - expand=["balance_transaction"] - ) - - -def sync_charges_for_customer(customer): - """ - Populate database with all the charges for a customer. - - Args: - customer: a pinax.stripe.models.Customer object - """ - for charge in customer.stripe_customer.charges().data: - sync_charge_from_stripe_data(charge) - - -def sync_charge(stripe_id, stripe_account=None): - """Sync a charge given a Stripe charge ID.""" - return sync_charge_from_stripe_data( - retrieve(stripe_id, stripe_account=stripe_account) - ) - - -def sync_charge_from_stripe_data(data): - """ - Create or update the charge represented by the data from a Stripe API query. - - Args: - data: the data representing a charge object in the Stripe API - - Returns: - a pinax.stripe.models.Charge object - """ - obj, _ = models.Charge.objects.get_or_create(stripe_id=data["id"]) - obj.customer = models.Customer.objects.filter(stripe_id=data["customer"]).first() - obj.source = data["source"]["id"] - obj.currency = data["currency"] - obj.invoice = models.Invoice.objects.filter(stripe_id=data["invoice"]).first() - obj.amount = utils.convert_amount_for_db(data["amount"], obj.currency) - obj.paid = data["paid"] - obj.refunded = data["refunded"] - obj.captured = data["captured"] - obj.disputed = data["dispute"] is not None - obj.charge_created = utils.convert_tstamp(data, "created") - if data.get("description"): - obj.description = data["description"] - if data.get("amount_refunded"): - obj.amount_refunded = utils.convert_amount_for_db(data["amount_refunded"], obj.currency) - if data["refunded"]: - obj.amount_refunded = obj.amount - balance_transaction = data.get("balance_transaction") - if balance_transaction and not isinstance(balance_transaction, string_types): - obj.available = balance_transaction["status"] == "available" - obj.available_on = utils.convert_tstamp( - balance_transaction, "available_on" - ) - obj.fee = utils.convert_amount_for_db( - balance_transaction["fee"], balance_transaction["currency"] - ) - obj.fee_currency = balance_transaction["currency"] - obj.transfer_group = data.get("transfer_group") - obj.outcome = data.get("outcome") - obj.save() - return obj - - -def update_charge_availability(): - """ - Update `available` and `available_on` attributes of Charges. - - We only bother checking those Charges that can become available. - """ - charges = models.Charge.objects.filter( - paid=True, - captured=True - ).exclude( - Q(available=True) | Q(refunded=True) - ).select_related( - "customer" - ) - for c in charges.iterator(): - sync_charge( - c.stripe_id, - stripe_account=c.customer.stripe_account - ) diff --git a/pinax/stripe/actions/coupons.py b/pinax/stripe/actions/coupons.py deleted file mode 100644 index bcf6d08a7..000000000 --- a/pinax/stripe/actions/coupons.py +++ /dev/null @@ -1,32 +0,0 @@ -import stripe - -from .. import models, utils - - -def sync_coupons(): - """ - Synchronizes all coupons from the Stripe API - """ - coupons = stripe.Coupon.auto_paging_iter() - for coupon in coupons: - defaults = dict( - amount_off=( - utils.convert_amount_for_db(coupon["amount_off"], coupon["currency"]) - if coupon["amount_off"] - else None - ), - currency=coupon["currency"] or "", - duration=coupon["duration"], - duration_in_months=coupon["duration_in_months"], - max_redemptions=coupon["max_redemptions"], - metadata=coupon["metadata"], - percent_off=coupon["percent_off"], - redeem_by=utils.convert_tstamp(coupon["redeem_by"]) if coupon["redeem_by"] else None, - times_redeemed=coupon["times_redeemed"], - valid=coupon["valid"], - ) - obj, created = models.Coupon.objects.get_or_create( - stripe_id=coupon["id"], - defaults=defaults - ) - utils.update_with_defaults(obj, defaults, created) diff --git a/pinax/stripe/actions/customers.py b/pinax/stripe/actions/customers.py deleted file mode 100644 index ff82d4351..000000000 --- a/pinax/stripe/actions/customers.py +++ /dev/null @@ -1,232 +0,0 @@ -import logging - -from django.utils import timezone -from django.utils.encoding import smart_str - -import stripe - -from . import invoices, sources, subscriptions -from .. import hooks, models, utils -from ..conf import settings - -logger = logging.getLogger(__name__) - - -def can_charge(customer): - """ - Can the given customer create a charge - - Args: - customer: a pinax.stripe.models.Customer object - """ - if customer.date_purged is not None: - return False - if customer.default_source: - return True - return False - - -def _create_without_account(user, card=None, plan=settings.PINAX_STRIPE_DEFAULT_PLAN, charge_immediately=True, quantity=None): - cus = models.Customer.objects.filter(user=user).first() - if cus is not None: - try: - stripe.Customer.retrieve(cus.stripe_id) - return cus - except stripe.error.InvalidRequestError: - pass - - # At this point we maybe have a local Customer but no stripe customer - # let's create one and make the binding - trial_end = hooks.hookset.trial_period(user, plan) - stripe_customer = stripe.Customer.create( - email=user.email, - source=card, - plan=plan, - quantity=quantity, - trial_end=trial_end - ) - cus, created = models.Customer.objects.get_or_create( - user=user, - defaults={ - "stripe_id": stripe_customer["id"] - } - ) - if not created: - cus.stripe_id = stripe_customer["id"] # sync_customer will call cus.save() - sync_customer(cus, stripe_customer) - if plan and charge_immediately: - invoices.create_and_pay(cus) - return cus - - -def _create_with_account(user, stripe_account, card=None, plan=settings.PINAX_STRIPE_DEFAULT_PLAN, charge_immediately=True, quantity=None): - cus = user.customers.filter(user_account__account=stripe_account).first() - if cus is not None: - try: - stripe.Customer.retrieve(cus.stripe_id, stripe_account=stripe_account.stripe_id) - return cus - except stripe.error.InvalidRequestError: - pass - - # At this point we maybe have a local Customer but no stripe customer - # let's create one and make the binding - trial_end = hooks.hookset.trial_period(user, plan) - stripe_customer = stripe.Customer.create( - email=user.email, - source=card, - plan=plan, - quantity=quantity, - trial_end=trial_end, - stripe_account=stripe_account.stripe_id, - ) - - if cus is None: - cus = models.Customer.objects.create(stripe_id=stripe_customer["id"], stripe_account=stripe_account) - models.UserAccount.objects.create(user=user, account=stripe_account, customer=cus) - else: - logger.debug("Update local customer %s with new remote customer %s for user %s, and account %s", - cus.stripe_id, stripe_customer["id"], user, stripe_account) - cus.stripe_id = stripe_customer["id"] # sync_customer() will call cus.save() - sync_customer(cus, stripe_customer) - if plan and charge_immediately: - invoices.create_and_pay(cus) - return cus - - -def create(user, card=None, plan=settings.PINAX_STRIPE_DEFAULT_PLAN, charge_immediately=True, quantity=None, stripe_account=None): - """ - Creates a Stripe customer. - - If a customer already exists, the existing customer will be returned. - - Args: - user: a user object - card: optionally, the token for a new card - plan: a plan to subscribe the user to - charge_immediately: whether or not the user should be immediately - charged for the subscription - quantity: the quantity (multiplier) of the subscription - stripe_account: An account object. If given, the Customer and User relation will be established for you through the UserAccount model. - Because a single User might have several Customers, one per Account. - - Returns: - the pinax.stripe.models.Customer object that was created - """ - if stripe_account is None: - return _create_without_account(user, card=card, plan=plan, charge_immediately=charge_immediately, quantity=quantity) - return _create_with_account(user, stripe_account, card=card, plan=plan, charge_immediately=charge_immediately, quantity=quantity) - - -def get_customer_for_user(user, stripe_account=None): - """ - Get a customer object for a given user - - Args: - user: a user object - stripe_account: An Account object - - Returns: - a pinax.stripe.models.Customer object - """ - if stripe_account is None: - return models.Customer.objects.filter(user=user).first() - return user.customers.filter(user_account__account=stripe_account).first() - - -def purge_local(customer): - customer.user_accounts.all().delete() - customer.user = None - customer.date_purged = timezone.now() - customer.save() - - -def purge(customer): - """ - Deletes the Stripe customer data and purges the linking of the transaction - data to the Django user. - - Args: - customer: the pinax.stripe.models.Customer object to purge - """ - try: - customer.stripe_customer.delete() - except stripe.error.InvalidRequestError as e: - if "no such customer:" not in smart_str(e).lower(): - # The exception was thrown because the customer was already - # deleted on the stripe side, ignore the exception - raise - purge_local(customer) - - -def link_customer(event): - """ - Links a customer referenced in a webhook event message to the event object - - Args: - event: the pinax.stripe.models.Event object to link - """ - cus_id = None - customer_crud_events = [ - "customer.created", - "customer.updated", - "customer.deleted" - ] - event_data_object = event.message["data"]["object"] - if event.kind in customer_crud_events: - cus_id = event_data_object["id"] - else: - cus_id = event_data_object.get("customer", None) - - if cus_id is not None: - customer, created = models.Customer.objects.get_or_create( - stripe_id=cus_id, - stripe_account=event.stripe_account, - ) - if event.kind in customer_crud_events: - sync_customer(customer, event_data_object) - - event.customer = customer - event.save() - - -def set_default_source(customer, source): - """ - Sets the default payment source for a customer - - Args: - customer: a Customer object - source: the Stripe ID of the payment source - """ - stripe_customer = customer.stripe_customer - stripe_customer.default_source = source - cu = stripe_customer.save() - sync_customer(customer, cu=cu) - - -def sync_customer(customer, cu=None): - """ - Synchronizes a local Customer object with details from the Stripe API - - Args: - customer: a Customer object - cu: optionally, data from the Stripe API representing the customer - """ - if customer.date_purged is not None: - return - - if cu is None: - cu = customer.stripe_customer - - if cu.get("deleted", False): - purge_local(customer) - return - - customer.account_balance = utils.convert_amount_for_db(cu["account_balance"], cu["currency"]) - customer.currency = cu["currency"] or "" - customer.delinquent = cu["delinquent"] - customer.default_source = cu["default_source"] or "" - customer.save() - for source in cu["sources"]["data"]: - sources.sync_payment_source_from_stripe_data(customer, source) - for subscription in cu["subscriptions"]["data"]: - subscriptions.sync_subscription_from_stripe_data(customer, subscription) diff --git a/pinax/stripe/actions/events.py b/pinax/stripe/actions/events.py deleted file mode 100644 index 6046694af..000000000 --- a/pinax/stripe/actions/events.py +++ /dev/null @@ -1,52 +0,0 @@ -from .. import models -from ..webhooks import registry - - -def add_event(stripe_id, kind, livemode, message, api_version="", - request_id="", pending_webhooks=0): - """ - Adds and processes an event from a received webhook - - Args: - stripe_id: the stripe id of the event - kind: the label of the event - livemode: True or False if the webhook was sent from livemode or not - message: the data of the webhook - api_version: the version of the Stripe API used - request_id: the id of the request that initiated the webhook - pending_webhooks: the number of pending webhooks - """ - stripe_account_id = message.get("account") - if stripe_account_id: - stripe_account, _ = models.Account.objects.get_or_create( - stripe_id=stripe_account_id - ) - else: - stripe_account = None - event = models.Event.objects.create( - stripe_account=stripe_account, - stripe_id=stripe_id, - kind=kind, - livemode=livemode, - webhook_message=message, - api_version=api_version, - request=request_id, - pending_webhooks=pending_webhooks - ) - WebhookClass = registry.get(kind) - if WebhookClass is not None: - webhook = WebhookClass(event) - webhook.process() - - -def dupe_event_exists(stripe_id): - """ - Checks if a duplicate event exists - - Args: - stripe_id: the Stripe ID of the event to check - - Returns: - True if the event already exists, False otherwise - """ - return models.Event.objects.filter(stripe_id=stripe_id).exists() diff --git a/pinax/stripe/actions/exceptions.py b/pinax/stripe/actions/exceptions.py deleted file mode 100644 index ee3b0a2ea..000000000 --- a/pinax/stripe/actions/exceptions.py +++ /dev/null @@ -1,24 +0,0 @@ -import sys -import traceback - -from .. import models - - -def log_exception(data, exception, event=None): - """ - Log an exception that was captured as a result of processing events - - Args: - data: the data to log about the exception - exception: a string describing the exception (can be the exception - object itself - `str()` gets called on it) - event: optionally, the event object from which the exception occurred - """ - info = sys.exc_info() - info_formatted = "".join(traceback.format_exception(*info)) if info[1] is not None else "" - models.EventProcessingException.objects.create( - event=event, - data=data or "", - message=str(exception), - traceback=info_formatted - ) diff --git a/pinax/stripe/actions/externalaccounts.py b/pinax/stripe/actions/externalaccounts.py deleted file mode 100644 index a1d92cf4e..000000000 --- a/pinax/stripe/actions/externalaccounts.py +++ /dev/null @@ -1,64 +0,0 @@ -from .. import models - - -def create_bank_account(account, account_number, country, currency, **kwargs): - """ - Create a Bank Account. - - Args: - account: the stripe.Account object we're attaching - the bank account to - account_number: the Bank Account number - country: two letter country code - currency: three letter currency code - - There are additional properties that can be set, please see: - https://stripe.com/docs/api#account_create_bank_account - - Returns: - a pinax.stripe.models.BankAccount object - """ - external_account = account.external_accounts.create( - external_account=dict( - object="bank_account", - account_number=account_number, - country=country, - currency=currency, - **kwargs - ) - ) - return sync_bank_account_from_stripe_data( - external_account - ) - - -def sync_bank_account_from_stripe_data(data): - """ - Create or update using the account object from a Stripe API query. - - Args: - data: the data representing an account object in the Stripe API - - Returns: - a pinax.stripe.models.Account object - """ - account = models.Account.objects.get( - stripe_id=data["account"] - ) - kwargs = { - "stripe_id": data["id"], - "account": account - } - obj, created = models.BankAccount.objects.get_or_create( - **kwargs - ) - top_level_attrs = ( - "account_holder_name", "account_holder_type", - "bank_name", "country", "currency", "default_for_currency", - "fingerprint", "last4", "metadata", "routing_number", - "status" - ) - for a in top_level_attrs: - setattr(obj, a, data.get(a)) - obj.save() - return obj diff --git a/pinax/stripe/actions/invoices.py b/pinax/stripe/actions/invoices.py deleted file mode 100644 index 100b621c7..000000000 --- a/pinax/stripe/actions/invoices.py +++ /dev/null @@ -1,195 +0,0 @@ -import decimal - -import stripe - -from . import charges, subscriptions -from .. import hooks, models, utils -from ..conf import settings - - -def create(customer): - """ - Creates a Stripe invoice - - Args: - customer: the customer to create the invoice for (Customer) - - Returns: - the data from the Stripe API that represents the invoice object that - was created - - TODO: - We should go ahead and sync the data so the Invoice object does - not have to wait on the webhook to be received and processed for the - data to be available locally. - """ - return stripe.Invoice.create(customer=customer.stripe_id) - - -def create_and_pay(customer): - """ - Creates and and immediately pays an invoice for a customer - - Args: - customer: the customer to create the invoice for (Customer) - - Returns: - True, if invoice was created, False if there was an error - """ - try: - invoice = create(customer) - if invoice.amount_due > 0: - invoice.pay() - return True - except stripe.error.InvalidRequestError: - return False # There was nothing to Invoice - - -def pay(invoice, send_receipt=True): - """ - Cause an invoice to be paid - - Args: - invoice: the invoice object to have paid - send_receipt: if True, send the receipt as a result of paying - - Returns: - True if the invoice was paid, False if it was unable to be paid - """ - if not invoice.paid and not invoice.closed: - stripe_invoice = invoice.stripe_invoice.pay() - sync_invoice_from_stripe_data(stripe_invoice, send_receipt=send_receipt) - return True - return False - - -def sync_invoice_from_stripe_data(stripe_invoice, send_receipt=settings.PINAX_STRIPE_SEND_EMAIL_RECEIPTS): - """ - Synchronizes a local invoice with data from the Stripe API - - Args: - stripe_invoice: data that represents the invoice from the Stripe API - send_receipt: if True, send the receipt as a result of paying - - Returns: - the pinax.stripe.models.Invoice that was created or updated - """ - c = models.Customer.objects.get(stripe_id=stripe_invoice["customer"]) - period_end = utils.convert_tstamp(stripe_invoice, "period_end") - period_start = utils.convert_tstamp(stripe_invoice, "period_start") - date = utils.convert_tstamp(stripe_invoice, "date") - sub_id = stripe_invoice.get("subscription") - stripe_account_id = c.stripe_account_stripe_id - - if stripe_invoice.get("charge"): - charge = charges.sync_charge(stripe_invoice["charge"], stripe_account=stripe_account_id) - if send_receipt: - hooks.hookset.send_receipt(charge) - else: - charge = None - - stripe_subscription = subscriptions.retrieve(c, sub_id) - subscription = subscriptions.sync_subscription_from_stripe_data(c, stripe_subscription) if stripe_subscription else None - - defaults = dict( - customer=c, - attempted=stripe_invoice["attempted"], - attempt_count=stripe_invoice["attempt_count"], - amount_due=utils.convert_amount_for_db(stripe_invoice["amount_due"], stripe_invoice["currency"]), - closed=stripe_invoice["closed"], - paid=stripe_invoice["paid"], - period_end=period_end, - period_start=period_start, - subtotal=utils.convert_amount_for_db(stripe_invoice["subtotal"], stripe_invoice["currency"]), - tax=utils.convert_amount_for_db(stripe_invoice["tax"], stripe_invoice["currency"]) if stripe_invoice["tax"] is not None else None, - tax_percent=decimal.Decimal(stripe_invoice["tax_percent"]) if stripe_invoice["tax_percent"] is not None else None, - total=utils.convert_amount_for_db(stripe_invoice["total"], stripe_invoice["currency"]), - currency=stripe_invoice["currency"], - date=date, - charge=charge, - subscription=subscription, - receipt_number=stripe_invoice["receipt_number"] or "", - ) - invoice, created = models.Invoice.objects.get_or_create( - stripe_id=stripe_invoice["id"], - defaults=defaults - ) - if charge is not None: - charge.invoice = invoice - charge.save() - - invoice = utils.update_with_defaults(invoice, defaults, created) - sync_invoice_items(invoice, stripe_invoice["lines"].get("data", [])) - - return invoice - - -def sync_invoices_for_customer(customer): - """ - Synchronizes all invoices for a customer - - Args: - customer: the customer for whom to synchronize all invoices - """ - for invoice in customer.stripe_customer.invoices().data: - sync_invoice_from_stripe_data(invoice, send_receipt=False) - - -def sync_invoice_items(invoice, items): - """ - Synchronizes all invoice line items for a particular invoice - - This assumes line items from a Stripe invoice.lines property and not through - the invoicesitems resource calls. At least according to the documentation - the data for an invoice item is slightly different between the two calls. - - For example, going through the invoiceitems resource you don't get a "type" - field on the object. - - Args: - invoice_: the invoice objects to synchronize - items: the data from the Stripe API representing the line items - """ - for item in items: - period_end = utils.convert_tstamp(item["period"], "end") - period_start = utils.convert_tstamp(item["period"], "start") - - if item.get("plan"): - plan = models.Plan.objects.get(stripe_id=item["plan"]["id"]) - else: - plan = None - - if item["type"] == "subscription": - if invoice.subscription and invoice.subscription.stripe_id == item["id"]: - item_subscription = invoice.subscription - else: - stripe_subscription = subscriptions.retrieve( - invoice.customer, - item["id"] - ) - item_subscription = subscriptions.sync_subscription_from_stripe_data( - invoice.customer, - stripe_subscription - ) if stripe_subscription else None - if plan is None and item_subscription is not None and item_subscription.plan is not None: - plan = item_subscription.plan - else: - item_subscription = None - - defaults = dict( - amount=utils.convert_amount_for_db(item["amount"], item["currency"]), - currency=item["currency"], - proration=item["proration"], - description=item.get("description") or "", - line_type=item["type"], - plan=plan, - period_start=period_start, - period_end=period_end, - quantity=item.get("quantity"), - subscription=item_subscription - ) - inv_item, inv_item_created = invoice.items.get_or_create( - stripe_id=item["id"], - defaults=defaults - ) - utils.update_with_defaults(inv_item, defaults, inv_item_created) diff --git a/pinax/stripe/actions/plans.py b/pinax/stripe/actions/plans.py deleted file mode 100644 index b304a5110..000000000 --- a/pinax/stripe/actions/plans.py +++ /dev/null @@ -1,39 +0,0 @@ -import stripe - -from .. import models, utils - - -def sync_plans(): - """ - Synchronizes all plans from the Stripe API - """ - plans = stripe.Plan.auto_paging_iter() - for plan in plans: - sync_plan(plan) - - -def sync_plan(plan, event=None): - """ - Synchronizes a plan from the Stripe API - - Args: - plan: data from Stripe API representing a plan - event: the event associated with the plan - """ - - defaults = { - "amount": utils.convert_amount_for_db(plan["amount"], plan["currency"]), - "currency": plan["currency"] or "", - "interval": plan["interval"], - "interval_count": plan["interval_count"], - "name": plan["name"], - "statement_descriptor": plan["statement_descriptor"] or "", - "trial_period_days": plan["trial_period_days"], - "metadata": plan["metadata"] - } - - obj, created = models.Plan.objects.get_or_create( - stripe_id=plan["id"], - defaults=defaults - ) - utils.update_with_defaults(obj, defaults, created) diff --git a/pinax/stripe/actions/refunds.py b/pinax/stripe/actions/refunds.py deleted file mode 100644 index 3c0c35347..000000000 --- a/pinax/stripe/actions/refunds.py +++ /dev/null @@ -1,24 +0,0 @@ -import stripe - -from . import charges -from .. import utils - - -def create(charge, amount=None): - """ - Creates a refund for a particular charge - - Args: - charge: the charge against which to create the refund - amount: how much should the refund be, defaults to None, in which case - the full amount of the charge will be refunded - """ - if amount is None: - stripe.Refund.create(charge=charge.stripe_id, stripe_account=charge.stripe_account_stripe_id) - else: - stripe.Refund.create( - charge=charge.stripe_id, - stripe_account=charge.stripe_account_stripe_id, - amount=utils.convert_amount_for_api(charges.calculate_refund_amount(charge, amount=amount), charge.currency) - ) - charges.sync_charge_from_stripe_data(charge.stripe_charge) diff --git a/pinax/stripe/actions/sources.py b/pinax/stripe/actions/sources.py deleted file mode 100644 index 52d8b9d77..000000000 --- a/pinax/stripe/actions/sources.py +++ /dev/null @@ -1,142 +0,0 @@ -from .. import models, utils - - -def create_card(customer, token): - """ - Creates a new card for a customer - - Args: - customer: the customer to create the card for - token: the token created from Stripe.js - """ - source = customer.stripe_customer.sources.create(source=token) - return sync_payment_source_from_stripe_data(customer, source) - - -def delete_card(customer, source): - """ - Deletes a card from a customer - - Args: - customer: the customer to delete the card from - source: the Stripe ID of the payment source to delete - """ - customer.stripe_customer.sources.retrieve(source).delete() - return delete_card_object(source) - - -def delete_card_object(source): - """ - Deletes the local card object (Card) - - Args: - source: the Stripe ID of the card - """ - if source.startswith("card_"): - return models.Card.objects.filter(stripe_id=source).delete() - - -def sync_card(customer, source): - """ - Synchronizes the data for a card locally for a given customer - - Args: - customer: the customer to create or update a card for - source: data representing the card from the Stripe API - """ - defaults = dict( - customer=customer, - name=source["name"] or "", - address_line_1=source["address_line1"] or "", - address_line_1_check=source["address_line1_check"] or "", - address_line_2=source["address_line2"] or "", - address_city=source["address_city"] or "", - address_state=source["address_state"] or "", - address_country=source["address_country"] or "", - address_zip=source["address_zip"] or "", - address_zip_check=source["address_zip_check"] or "", - brand=source["brand"], - country=source["country"] or "", - cvc_check=source["cvc_check"] or "", - dynamic_last4=source["dynamic_last4"] or "", - exp_month=source["exp_month"], - exp_year=source["exp_year"], - funding=source["funding"] or "", - last4=source["last4"] or "", - fingerprint=source["fingerprint"] or "" - ) - card, created = models.Card.objects.get_or_create( - stripe_id=source["id"], - defaults=defaults - ) - return utils.update_with_defaults(card, defaults, created) - - -def sync_bitcoin(customer, source): - """ - Synchronizes the data for a Bitcoin receiver locally for a given customer - - Args: - customer: the customer to create or update a Bitcoin receiver for - source: data reprenting the Bitcoin receiver from the Stripe API - """ - defaults = dict( - customer=customer, - active=source["active"], - amount=utils.convert_amount_for_db(source["amount"], source["currency"]), - amount_received=utils.convert_amount_for_db(source["amount_received"], source["currency"]), - bitcoin_amount=source["bitcoin_amount"], - bitcoin_amount_received=source["bitcoin_amount_received"], - bitcoin_uri=source["bitcoin_uri"], - currency=source["currency"], - description=source["description"], - email=source["email"], - filled=source["filled"], - inbound_address=source["inbound_address"], - payment=source["payment"] if "payment" in source else "", - refund_address=source["refund_address"] or "", - uncaptured_funds=source["uncaptured_funds"], - used_for_payment=source["used_for_payment"] - ) - receiver, created = models.BitcoinReceiver.objects.get_or_create( - stripe_id=source["id"], - defaults=defaults - ) - return utils.update_with_defaults(receiver, defaults, created) - - -def sync_payment_source_from_stripe_data(customer, source): - """ - Synchronizes the data for a payment source locally for a given customer - - Args: - customer: the customer to create or update a Bitcoin receiver for - source: data reprenting the payment source from the Stripe API - """ - if source["object"] == "card": - return sync_card(customer, source) - # NOTE: this does not seem to be a thing anymore?! - if source["object"] == "bitcoin_receiver": - return sync_bitcoin(customer, source) - - -def update_card(customer, source, name=None, exp_month=None, exp_year=None): - """ - Updates a card for a given customer - - Args: - customer: the customer for whom to update the card - source: the Stripe ID of the card to update - name: optionally, a name to give the card - exp_month: optionally, the expiration month for the card - exp_year: optionally, the expiration year for the card - """ - stripe_source = customer.stripe_customer.sources.retrieve(source) - if name is not None: - stripe_source.name = name - if exp_month is not None: - stripe_source.exp_month = exp_month - if exp_year is not None: - stripe_source.exp_year = exp_year - s = stripe_source.save() - return sync_payment_source_from_stripe_data(customer, s) diff --git a/pinax/stripe/actions/subscriptions.py b/pinax/stripe/actions/subscriptions.py deleted file mode 100644 index 1a2e77853..000000000 --- a/pinax/stripe/actions/subscriptions.py +++ /dev/null @@ -1,199 +0,0 @@ -import datetime - -from django.db.models import Q -from django.utils import timezone - -import stripe - -from .. import hooks, models, utils - - -def cancel(subscription, at_period_end=True): - """ - Cancels a subscription - - Args: - subscription: the subscription to cancel - at_period_end: True to cancel at the end of the period, otherwise cancels immediately - """ - sub = stripe.Subscription( - subscription.stripe_id, - stripe_account=subscription.stripe_account_stripe_id, - ).delete( - at_period_end=at_period_end, - ) - return sync_subscription_from_stripe_data(subscription.customer, sub) - - -def create(customer, plan, quantity=None, trial_days=None, token=None, coupon=None, tax_percent=None): - """ - Creates a subscription for the given customer - - Args: - customer: the customer to create the subscription for - plan: the plan to subscribe to - quantity: if provided, the number to subscribe to - trial_days: if provided, the number of days to trial before starting - token: if provided, a token from Stripe.js that will be used as the - payment source for the subscription and set as the default - source for the customer, otherwise the current default source - will be used - coupon: if provided, a coupon to apply towards the subscription - tax_percent: if provided, add percentage as tax - - Returns: - the pinax.stripe.models.Subscription object (created or updated) - """ - quantity = hooks.hookset.adjust_subscription_quantity(customer=customer, plan=plan, quantity=quantity) - - subscription_params = {} - if trial_days: - subscription_params["trial_end"] = datetime.datetime.utcnow() + datetime.timedelta(days=trial_days) - if token: - subscription_params["source"] = token - - subscription_params["stripe_account"] = customer.stripe_account_stripe_id - subscription_params["customer"] = customer.stripe_id - subscription_params["plan"] = plan - subscription_params["quantity"] = quantity - subscription_params["coupon"] = coupon - subscription_params["tax_percent"] = tax_percent - resp = stripe.Subscription.create(**subscription_params) - - return sync_subscription_from_stripe_data(customer, resp) - - -def has_active_subscription(customer): - """ - Checks if the given customer has an active subscription - - Args: - customer: the customer to check - - Returns: - True, if there is an active subscription, otherwise False - """ - return models.Subscription.objects.filter( - customer=customer - ).filter( - Q(ended_at__isnull=True) | Q(ended_at__gt=timezone.now()) - ).exists() - - -def is_period_current(subscription): - """ - Tests if the provided subscription object for the current period - - Args: - subscription: a pinax.stripe.models.Subscription object to test - """ - return subscription.current_period_end > timezone.now() - - -def is_status_current(subscription): - """ - Tests if the provided subscription object has a status that means current - - Args: - subscription: a pinax.stripe.models.Subscription object to test - """ - return subscription.status in subscription.STATUS_CURRENT - - -def is_valid(subscription): - """ - Tests if the provided subscription object is valid - - Args: - subscription: a pinax.stripe.models.Subscription object to test - """ - if not is_status_current(subscription): - return False - - if subscription.cancel_at_period_end and not is_period_current(subscription): - return False - - return True - - -def retrieve(customer, sub_id): - """ - Retrieve a subscription object from Stripe's API - - Args: - customer: a legacy argument, we check that the given - subscription belongs to the given customer - sub_id: the Stripe ID of the subscription you are fetching - - Returns: - the data for a subscription object from the Stripe API - """ - if not sub_id: - return - subscription = stripe.Subscription.retrieve(sub_id, stripe_account=customer.stripe_account_stripe_id) - if subscription and subscription.customer != customer.stripe_id: - return - return subscription - - -def sync_subscription_from_stripe_data(customer, subscription): - """ - Synchronizes data from the Stripe API for a subscription - - Args: - customer: the customer who's subscription you are syncronizing - subscription: data from the Stripe API representing a subscription - - Returns: - the pinax.stripe.models.Subscription object (created or updated) - """ - defaults = dict( - customer=customer, - application_fee_percent=subscription["application_fee_percent"], - cancel_at_period_end=subscription["cancel_at_period_end"], - canceled_at=utils.convert_tstamp(subscription["canceled_at"]), - current_period_start=utils.convert_tstamp(subscription["current_period_start"]), - current_period_end=utils.convert_tstamp(subscription["current_period_end"]), - ended_at=utils.convert_tstamp(subscription["ended_at"]), - plan=models.Plan.objects.get(stripe_id=subscription["plan"]["id"]), - quantity=subscription["quantity"], - start=utils.convert_tstamp(subscription["start"]), - status=subscription["status"], - trial_start=utils.convert_tstamp(subscription["trial_start"]) if subscription["trial_start"] else None, - trial_end=utils.convert_tstamp(subscription["trial_end"]) if subscription["trial_end"] else None - ) - sub, created = models.Subscription.objects.get_or_create( - stripe_id=subscription["id"], - defaults=defaults - ) - sub = utils.update_with_defaults(sub, defaults, created) - return sub - - -def update(subscription, plan=None, quantity=None, prorate=True, coupon=None, charge_immediately=False): - """ - Updates a subscription - - Args: - subscription: the subscription to update - plan: optionally, the plan to change the subscription to - quantity: optionally, the quantity of the subscription to change - prorate: optionally, if the subscription should be prorated or not - coupon: optionally, a coupon to apply to the subscription - charge_immediately: optionally, whether or not to charge immediately - """ - stripe_subscription = subscription.stripe_subscription - if plan: - stripe_subscription.plan = plan - if quantity: - stripe_subscription.quantity = quantity - if not prorate: - stripe_subscription.prorate = False - if coupon: - stripe_subscription.coupon = coupon - if charge_immediately: - if stripe_subscription.trial_end is not None and utils.convert_tstamp(stripe_subscription.trial_end) > timezone.now(): - stripe_subscription.trial_end = "now" - sub = stripe_subscription.save() - customer = models.Customer.objects.get(pk=subscription.customer.pk) - return sync_subscription_from_stripe_data(customer, sub) diff --git a/pinax/stripe/actions/transfers.py b/pinax/stripe/actions/transfers.py deleted file mode 100644 index 1310c41ab..000000000 --- a/pinax/stripe/actions/transfers.py +++ /dev/null @@ -1,105 +0,0 @@ -import stripe - -from .. import models, utils - - -def during(year, month): - """ - Return a queryset of pinax.stripe.models.Transfer objects for the provided - year and month. - - Args: - year: 4-digit year - month: month as a integer, 1=January through 12=December - """ - return models.Transfer.objects.filter( - date__year=year, - date__month=month - ) - - -def sync_transfer(transfer, event=None): - """ - Synchronize a transfer from the Stripe API - - Args: - transfer: data from Stripe API representing transfer - event: the event associated with the transfer - """ - defaults = { - "amount": utils.convert_amount_for_db( - transfer["amount"], transfer["currency"] - ), - "amount_reversed": utils.convert_amount_for_db( - transfer["amount_reversed"], transfer["currency"] - ) if transfer.get("amount_reversed") else None, - "application_fee": utils.convert_amount_for_db( - transfer["application_fee"], transfer["currency"] - ) if transfer.get("application_fee") else None, - "created": utils.convert_tstamp(transfer["created"]) if transfer.get("created") else None, - "currency": transfer["currency"], - "date": utils.convert_tstamp(transfer.get("date")), - "description": transfer.get("description"), - "destination": transfer.get("destination"), - "destination_payment": transfer.get("destination_payment"), - "event": event, - "failure_code": transfer.get("failure_code"), - "failure_message": transfer.get("failure_message"), - "livemode": transfer.get("livemode"), - "metadata": dict(transfer.get("metadata", {})), - "method": transfer.get("method"), - "reversed": transfer.get("reversed"), - "source_transaction": transfer.get("source_transaction"), - "source_type": transfer.get("source_type"), - "statement_descriptor": transfer.get("statement_descriptor"), - "status": transfer.get("status"), - "transfer_group": transfer.get("transfer_group"), - "type": transfer.get("type") - } - obj, created = models.Transfer.objects.update_or_create( - stripe_id=transfer["id"], - defaults=defaults - ) - if not created: - obj.status = transfer["status"] - obj.save() - return obj - - -def update_status(transfer): - """ - Update the status of a pinax.stripe.models.Transfer object from Stripe API - - Args: - transfer: a pinax.stripe.models.Transfer object to update - """ - transfer.status = stripe.Transfer.retrieve(transfer.stripe_id).status - transfer.save() - - -def create(amount, currency, destination, description, transfer_group=None, - stripe_account=None, **kwargs): - """ - Create a transfer. - - Args: - amount: quantity of money to be sent - currency: currency for the transfer - destination: stripe_id of either a connected Stripe Account or Bank Account - description: an arbitrary string displayed in the webui alongside the transfer - transfer_group: a string that identifies this transfer as part of a group - stripe_account: the stripe_id of a Connect account if creating a transfer on - their behalf - """ - kwargs.update(dict( - amount=utils.convert_amount_for_api(amount, currency), - currency=currency, - destination=destination, - description=description - )) - if transfer_group: - kwargs["transfer_group"] = transfer_group - if stripe_account: - kwargs["stripe_account"] = stripe_account - transfer = stripe.Transfer.create(**kwargs) - return sync_transfer(transfer) diff --git a/pinax/stripe/admin.py b/pinax/stripe/admin.py index cbe6be417..7c5b9c656 100644 --- a/pinax/stripe/admin.py +++ b/pinax/stripe/admin.py @@ -1,142 +1,15 @@ from django.contrib import admin -from django.contrib.admin.views.main import ChangeList -from django.contrib.auth import get_user_model -from django.db.models import Count from django.utils.encoding import force_text from django.utils.translation import ugettext as _ from .models import ( - Account, - BankAccount, - BitcoinReceiver, - Card, - Charge, - Coupon, - Customer, Event, - EventProcessingException, - Invoice, - InvoiceItem, - Plan, - Subscription, - Transfer, - TransferChargeFee, - UserAccount + EventProcessingException ) -def user_search_fields(): - User = get_user_model() - fields = [ - "user__{0}".format(User.USERNAME_FIELD) - ] - if "email" in [f.name for f in User._meta.fields]: # pragma: no branch - fields += ["user__email"] - return fields - - -def customer_search_fields(): - return [ - "customer__{0}".format(field) - for field in user_search_fields() - ] - - -class CustomerHasCardListFilter(admin.SimpleListFilter): - title = "card presence" - parameter_name = "has_card" - - def lookups(self, request, model_admin): - return [ - ["yes", "Has Card"], - ["no", "Does Not Have a Card"] - ] - - def queryset(self, request, queryset): - if self.value() == "yes": - return queryset.filter(card__isnull=True) - elif self.value() == "no": - return queryset.filter(card__isnull=False) - return queryset.all() - - -class InvoiceCustomerHasCardListFilter(admin.SimpleListFilter): - title = "card presence" - parameter_name = "has_card" - - def lookups(self, request, model_admin): - return [ - ["yes", "Has Card"], - ["no", "Does Not Have a Card"] - ] - - def queryset(self, request, queryset): - if self.value() == "yes": - return queryset.filter(customer__card__isnull=True) - elif self.value() == "no": - return queryset.filter(customer__card__isnull=False) - return queryset.all() - - -class CustomerSubscriptionStatusListFilter(admin.SimpleListFilter): - title = "subscription status" - parameter_name = "sub_status" - - def lookups(self, request, model_admin): - statuses = [ - [x, x.replace("_", " ").title()] - for x in Subscription.objects.all().values_list( - "status", - flat=True - ).distinct() - ] - statuses.append(["none", "No Subscription"]) - return statuses - - def queryset(self, request, queryset): - if self.value() == "none": - # Get customers with 0 subscriptions - return queryset.annotate(subs=Count("subscription")).filter(subs=0) - elif self.value(): - # Get customer pks without a subscription with this status - customers = Subscription.objects.filter( - status=self.value()).values_list( - "customer", flat=True).distinct() - # Filter by those customers - return queryset.filter(pk__in=customers) - return queryset.all() - - -class AccountListFilter(admin.SimpleListFilter): - title = "account" - parameter_name = "stripe_account" - - def lookups(self, request, model_admin): - return [("none", "Without Account")] + [(a.pk, str(a)) for a in Account.objects.all()] - - def queryset(self, request, queryset): - if self.value() == "none": - return queryset.filter(stripe_account__isnull=True) - if self.value(): - return queryset.filter(stripe_account__pk=self.value()) - return queryset - - -class PrefetchingChangeList(ChangeList): - """A custom changelist to prefetch related fields.""" - def get_queryset(self, request): - qs = super(PrefetchingChangeList, self).get_queryset(request) - - if subscription_status in self.list_display: - qs = qs.prefetch_related("subscription_set") - if "customer" in self.list_display: - qs = qs.prefetch_related("customer") - if "user" in self.list_display: - qs = qs.prefetch_related("user") - return qs - - class ModelAdmin(admin.ModelAdmin): + def has_add_permission(self, request, obj=None): return False @@ -155,48 +28,6 @@ def has_change_permission(self, request, obj=None): return False return True - def get_changelist(self, request, **kwargs): - return PrefetchingChangeList - - -class ChargeAdmin(ModelAdmin): - list_display = [ - "stripe_id", - "customer", - "total_amount", - "description", - "paid", - "disputed", - "refunded", - "receipt_sent", - "created_at", - ] - list_select_related = [ - "customer", - ] - search_fields = [ - "stripe_id", - "customer__stripe_id", - "invoice__stripe_id", - ] + customer_search_fields() - list_filter = [ - "paid", - "disputed", - "refunded", - "created_at", - ] - raw_id_fields = [ - "customer", - "invoice", - ] - readonly_fields = [ - "stripe_account_stripe_id", - ] - - def get_queryset(self, request): - qs = super(ChargeAdmin, self).get_queryset(request) - return qs.prefetch_related("customer__user", "customer__users") - class EventProcessingExceptionAdmin(ModelAdmin): list_display = [ @@ -215,7 +46,6 @@ class EventProcessingExceptionAdmin(ModelAdmin): class EventAdmin(ModelAdmin): - raw_id_fields = ["customer", "stripe_account"] list_display = [ "stripe_id", "kind", @@ -223,259 +53,22 @@ class EventAdmin(ModelAdmin): "valid", "processed", "created_at", - "stripe_account", + "account_id", + "customer_id" ] list_filter = [ "kind", "created_at", "valid", - "processed", - AccountListFilter, + "processed" ] search_fields = [ "stripe_id", - "customer__stripe_id", + "customer_id", "validated_message", - "=stripe_account__stripe_id", - ] + customer_search_fields() - - -class SubscriptionInline(admin.TabularInline): - model = Subscription - extra = 0 - max_num = 0 - - -class CardInline(admin.TabularInline): - model = Card - extra = 0 - max_num = 0 - - -class BitcoinReceiverInline(admin.TabularInline): - model = BitcoinReceiver - extra = 0 - max_num = 0 - - -def subscription_status(obj): - return ", ".join([subscription.status for subscription in obj.subscription_set.all()]) -subscription_status.short_description = "Subscription Status" # noqa - - -class CustomerAdmin(ModelAdmin): - raw_id_fields = ["user", "stripe_account"] - list_display = [ - "stripe_id", - "user", - "account_balance", - "currency", - "delinquent", - "default_source", - subscription_status, - "date_purged", - "stripe_account", - ] - list_filter = [ - "delinquent", - CustomerHasCardListFilter, - CustomerSubscriptionStatusListFilter, - AccountListFilter, - ] - search_fields = [ - "stripe_id", - ] + user_search_fields() - inlines = [ - SubscriptionInline, - CardInline, - BitcoinReceiverInline - ] - - -class InvoiceItemInline(admin.TabularInline): - model = InvoiceItem - extra = 0 - max_num = 0 - - -def customer_has_card(obj): - return obj.customer.card_set.exclude(fingerprint="").exists() -customer_has_card.short_description = "Customer Has Card" # noqa - - -def customer_user(obj): - if not obj.customer.user: - return "" - User = get_user_model() - username = getattr(obj.customer.user, User.USERNAME_FIELD) - email = getattr(obj, "email", "") - return "{0} <{1}>".format( - username, - email - ) -customer_user.short_description = "Customer" # noqa - - -class InvoiceAdmin(ModelAdmin): - raw_id_fields = ["customer"] - list_display = [ - "stripe_id", - "paid", - "closed", - customer_user, - customer_has_card, - "period_start", - "period_end", - "subtotal", - "total" - ] - search_fields = [ - "stripe_id", - "customer__stripe_id", - ] + customer_search_fields() - list_filter = [ - InvoiceCustomerHasCardListFilter, - "paid", - "closed", - "attempted", - "attempt_count", - "created_at", - "date", - "period_end", - "total" - ] - inlines = [ - InvoiceItemInline - ] - readonly_fields = [ - "stripe_account_stripe_id", - ] - - -class PlanAdmin(ModelAdmin): - raw_id_fields = ["stripe_account"] - list_display = [ - "stripe_id", - "name", - "amount", - "currency", - "interval", - "interval_count", - "trial_period_days", - "stripe_account", - ] - search_fields = [ - "stripe_id", - "name", - "=stripe_account__stripe_id", - ] + customer_search_fields() - list_filter = [ - "currency", - AccountListFilter, - ] - - -class CouponAdmin(ModelAdmin): - list_display = [ - "stripe_id", - "amount_off", - "currency", - "percent_off", - "duration", - "duration_in_months", - "redeem_by", - "valid" - ] - search_fields = [ - "stripe_id", - ] - list_filter = [ - "currency", - "valid", - ] - - -class TransferChargeFeeInline(admin.TabularInline): - model = TransferChargeFee - extra = 0 - max_num = 0 - - -class TransferAdmin(ModelAdmin): - Transfer - raw_id_fields = ["event", "stripe_account"] - list_display = [ - "stripe_id", - "amount", - "status", - "date", - "description", - "stripe_account", - ] - search_fields = [ - "stripe_id", - "event__stripe_id", - "=stripe_account__stripe_id", - ] - inlines = [ - TransferChargeFeeInline - ] - list_filter = [ - AccountListFilter, - ] - - -class AccountAdmin(ModelAdmin): - raw_id_fields = ["user"] - list_display = [ - "display_name", - "type", - "country", - "payouts_enabled", - "charges_enabled", - "stripe_id", - "created_at", - ] - search_fields = [ - "display_name", - "stripe_id", - ] - - -class BankAccountAdmin(ModelAdmin): - raw_id_fields = ["account"] - list_display = [ - "stripe_id", - "account", - "account_holder_type", - "account_holder_name", - "currency", - "default_for_currency", - "bank_name", - "country", - "last4" - ] - search_fields = [ - "stripe_id", - ] - - -class UserAccountAdmin(ModelAdmin): - raw_id_fields = ["user", "customer"] - list_display = ["user", "customer"] - search_fields = [ - "=customer__stripe_id", - "=user__email", + "account_id", ] -admin.site.register(Account, AccountAdmin) -admin.site.register(BankAccount, BankAccountAdmin) -admin.site.register(Charge, ChargeAdmin) -admin.site.register(Coupon, CouponAdmin) admin.site.register(Event, EventAdmin) admin.site.register(EventProcessingException, EventProcessingExceptionAdmin) -admin.site.register(Invoice, InvoiceAdmin) -admin.site.register(Customer, CustomerAdmin) -admin.site.register(Plan, PlanAdmin) -admin.site.register(UserAccount, UserAccountAdmin) diff --git a/pinax/stripe/conf.py b/pinax/stripe/conf.py index c7c2fc6f5..ff71e7054 100644 --- a/pinax/stripe/conf.py +++ b/pinax/stripe/conf.py @@ -1,43 +1,14 @@ -import importlib - from django.conf import settings # noqa -from django.core.exceptions import ImproperlyConfigured import stripe from appconf import AppConf -def load_path_attr(path): - i = path.rfind(".") - module, attr = path[:i], path[i + 1:] - try: - mod = importlib.import_module(module) - except ImportError as e: - raise ImproperlyConfigured( - "Error importing {0}: '{1}'".format(module, e) - ) - try: - attr = getattr(mod, attr) - except AttributeError: - raise ImproperlyConfigured( - "Module '{0}' does not define a '{1}'".format(module, attr) - ) - return attr - - class PinaxStripeAppConf(AppConf): PUBLIC_KEY = None SECRET_KEY = None API_VERSION = "2015-10-16" - INVOICE_FROM_EMAIL = "billing@example.com" - DEFAULT_PLAN = None - HOOKSET = "pinax.stripe.hooks.DefaultHookSet" - SEND_EMAIL_RECEIPTS = True - SUBSCRIPTION_REQUIRED_EXCEPTION_URLS = [] - SUBSCRIPTION_REQUIRED_REDIRECT = None - SUBSCRIPTION_TAX_PERCENT = None - DOCUMENT_MAX_SIZE_KB = 20 * 1024 * 1024 class Meta: prefix = "pinax_stripe" @@ -50,6 +21,3 @@ def configure_api_version(self, value): def configure_secret_key(self, value): stripe.api_key = value return value - - def configure_hookset(self, value): - return load_path_attr(value)() diff --git a/pinax/stripe/forms.py b/pinax/stripe/forms.py index 09f3eb28d..6019dc39a 100644 --- a/pinax/stripe/forms.py +++ b/pinax/stripe/forms.py @@ -7,20 +7,7 @@ import stripe from ipware.ip import get_ip, get_real_ip -from .actions import accounts from .conf import settings -from .models import Plan - - -class PaymentMethodForm(forms.Form): - - expMonth = forms.IntegerField(min_value=1, max_value=12) - expYear = forms.IntegerField(min_value=2015, max_value=9999) - - -class PlanForm(forms.Form): - plan = forms.ModelChoiceField(queryset=Plan.objects.all()) - """ The Connect forms here are designed to get users through the multi-stage @@ -306,6 +293,10 @@ def get_ipaddress(self): def get_user_agent(self): return self.request.META.get("HTTP_USER_AGENT") + def account_create(self, user, **kwargs): + stripe_account = stripe.Account.create(**kwargs) + return stripe_account + def save(self): """ Create a custom account, handling Stripe errors. @@ -317,7 +308,7 @@ def save(self): """ data = self.cleaned_data try: - return accounts.create( + return self.account_create( self.request.user, country=data["address_country"], type="custom", @@ -392,6 +383,10 @@ class AdditionalCustomAccountForm(DynamicManagedAccountForm): dob = forms.DateField() def __init__(self, *args, **kwargs): + """ + Assumes you are instantiating with an instance of a model that represents + a local cache of a Stripe Account with a `stripe_id` property. + """ self.account = kwargs.pop("account") kwargs.update( { @@ -405,11 +400,34 @@ def __init__(self, *args, **kwargs): self.fields["last_name"].initial = self.account.legal_entity_last_name self.fields["dob"].initial = self.account.legal_entity_dob + def account_update(self, data): + stripe_account = stripe.Account.retrieve(id=self.account.stripe_id) + if data.get("dob"): + stripe_account.legal_entity.dob = data["dob"] + + if data.get("first_name"): + stripe_account.legal_entity.first_name = data["first_name"] + + if data.get("last_name"): + stripe_account.legal_entity.last_name = data["last_name"] + + if data.get("personal_id_number"): + stripe_account.legal_entity.personal_id_number = data["personal_id_number"] + + if data.get("document"): + response = stripe.FileUpload.create( + purpose="identity_document", + file=data["document"], + stripe_account=stripe_account.id + ) + stripe_account.legal_entity.verification.document = response["id"] + stripe_account.save() + return stripe_account + def save(self): data = self.cleaned_data try: - return accounts.update( - self.account, + return self.account_update( { "dob": { "day": data["dob"].day, diff --git a/pinax/stripe/hooks.py b/pinax/stripe/hooks.py deleted file mode 100644 index 671cc6b79..000000000 --- a/pinax/stripe/hooks.py +++ /dev/null @@ -1,65 +0,0 @@ -from django.core.mail import EmailMessage -from django.template.loader import render_to_string - - -class DefaultHookSet(object): - - def adjust_subscription_quantity(self, customer, plan, quantity): - """ - Given a customer, plan, and quantity, when calling Customer.subscribe - you have the opportunity to override the quantity that was specified. - - Previously this was handled in the setting `PAYMENTS_PLAN_QUANTITY_CALLBACK` - and was only passed a customer object. - """ - if quantity is None: - quantity = 1 - return quantity - - def trial_period(self, user, plan): - """ - Given a user and plan, return an end date for a trial period, or None - for no trial period. - - Was previously in the setting `TRIAL_PERIOD_FOR_USER_CALLBACK` - """ - return None - - def send_receipt(self, charge, email=None): - from django.conf import settings - if not charge.receipt_sent: - # Import here to not add a hard dependency on the Sites framework - from django.contrib.sites.models import Site - - site = Site.objects.get_current() - protocol = getattr(settings, "DEFAULT_HTTP_PROTOCOL", "http") - ctx = { - "charge": charge, - "site": site, - "protocol": protocol, - } - subject = render_to_string("pinax/stripe/email/subject.txt", ctx) - subject = subject.strip() - message = render_to_string("pinax/stripe/email/body.txt", ctx) - - if not email and charge.customer: - email = charge.customer.user.email - - num_sent = EmailMessage( - subject, - message, - to=[email], - from_email=settings.PINAX_STRIPE_INVOICE_FROM_EMAIL - ).send() - charge.receipt_sent = num_sent and num_sent > 0 - charge.save() - - -class HookProxy(object): - - def __getattr__(self, attr): - from .conf import settings # if put globally there is a race condition - return getattr(settings.PINAX_STRIPE_HOOKSET, attr) - - -hookset = HookProxy() diff --git a/pinax/stripe/management/__init__.py b/pinax/stripe/management/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/management/commands/__init__.py b/pinax/stripe/management/commands/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/management/commands/init_customers.py b/pinax/stripe/management/commands/init_customers.py deleted file mode 100644 index 8bc917e97..000000000 --- a/pinax/stripe/management/commands/init_customers.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand - -from ...actions import customers - - -class Command(BaseCommand): - - help = "Create customer objects for existing users that do not have one" - - def handle(self, *args, **options): - User = get_user_model() - for user in User.objects.filter(customer__isnull=True): - customers.create(user=user, charge_immediately=False) - self.stdout.write("Created customer for {0}\n".format(user.email)) diff --git a/pinax/stripe/management/commands/sync_coupons.py b/pinax/stripe/management/commands/sync_coupons.py deleted file mode 100644 index 4f5f068b2..000000000 --- a/pinax/stripe/management/commands/sync_coupons.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.core.management.base import BaseCommand - -from ...actions import coupons - - -class Command(BaseCommand): - - help = "Make sure your Stripe account has the coupons" - - def handle(self, *args, **options): - coupons.sync_coupons() diff --git a/pinax/stripe/management/commands/sync_customers.py b/pinax/stripe/management/commands/sync_customers.py deleted file mode 100644 index d83d1ea98..000000000 --- a/pinax/stripe/management/commands/sync_customers.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand - -from stripe.error import InvalidRequestError - -from ...actions import charges, customers, invoices - - -class Command(BaseCommand): - - help = "Sync customer data" - - def handle(self, *args, **options): - User = get_user_model() - qs = User.objects.exclude(customer__isnull=True) - count = 0 - total = qs.count() - for user in qs: - count += 1 - perc = int(round(100 * (float(count) / float(total)))) - username = getattr(user, user.USERNAME_FIELD) - self.stdout.write(u"[{0}/{1} {2}%] Syncing {3} [{4}]\n".format( - count, total, perc, username, user.pk - )) - customer = customers.get_customer_for_user(user) - try: - customers.sync_customer(customer) - except InvalidRequestError as exc: - if exc.http_status == 404: # pragma: no branch - # This user doesn't exist (might be in test mode) - continue - raise exc - - if customer.date_purged is None: - invoices.sync_invoices_for_customer(customer) - charges.sync_charges_for_customer(customer) diff --git a/pinax/stripe/management/commands/sync_plans.py b/pinax/stripe/management/commands/sync_plans.py deleted file mode 100644 index ce3f1203c..000000000 --- a/pinax/stripe/management/commands/sync_plans.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.core.management.base import BaseCommand - -from ...actions import plans - - -class Command(BaseCommand): - - help = "Make sure your Stripe account has the plans" - - def handle(self, *args, **options): - plans.sync_plans() diff --git a/pinax/stripe/management/commands/update_charge_availability.py b/pinax/stripe/management/commands/update_charge_availability.py deleted file mode 100644 index 31e1de96d..000000000 --- a/pinax/stripe/management/commands/update_charge_availability.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.core.management.base import BaseCommand - -from ...actions import charges - - -class Command(BaseCommand): - - help = "Check for newly available Charges." - - def handle(self, *args, **options): - charges.update_charge_availability() diff --git a/pinax/stripe/managers.py b/pinax/stripe/managers.py deleted file mode 100644 index bfc849926..000000000 --- a/pinax/stripe/managers.py +++ /dev/null @@ -1,73 +0,0 @@ -import decimal - -from django.db import models - - -class CustomerManager(models.Manager): - - def started_during(self, year, month): - return self.exclude( - subscription__status="trialing" - ).filter( - subscription__start__year=year, - subscription__start__month=month - ) - - def active(self): - return self.filter( - subscription__status="active" - ) - - def canceled(self): - return self.filter( - subscription__status="canceled" - ) - - def canceled_during(self, year, month): - return self.canceled().filter( - subscription__canceled_at__year=year, - subscription__canceled_at__month=month, - ) - - def started_plan_summary_for(self, year, month): - return self.started_during(year, month).values( - "subscription__plan" - ).order_by().annotate( - count=models.Count("subscription__plan") - ) - - def active_plan_summary(self): - return self.active().values( - "subscription__plan" - ).order_by().annotate( - count=models.Count("subscription__plan") - ) - - def canceled_plan_summary_for(self, year, month): - return self.canceled_during(year, month).values( - "subscription__plan" - ).order_by().annotate( - count=models.Count("subscription__plan") - ) - - def churn(self): - canceled = self.canceled().count() - active = self.active().count() - return decimal.Decimal(str(canceled)) / decimal.Decimal(str(active)) - - -class ChargeManager(models.Manager): - - def during(self, year, month): - return self.filter( - charge_created__year=year, - charge_created__month=month - ) - - def paid_totals_for(self, year, month): - return self.during(year, month).filter( - paid=True - ).aggregate( - total_amount=models.Sum("amount"), - total_refunded=models.Sum("amount_refunded") - ) diff --git a/pinax/stripe/middleware.py b/pinax/stripe/middleware.py deleted file mode 100644 index 38687909d..000000000 --- a/pinax/stripe/middleware.py +++ /dev/null @@ -1,32 +0,0 @@ -import django -from django.shortcuts import redirect - -from .actions import customers, subscriptions -from .conf import settings - -try: - from django.urls import resolve -except ImportError: - from django.core.urlresolvers import resolve - -try: - from django.utils.deprecation import MiddlewareMixin as MixinorObject -except ImportError: - MixinorObject = object - - -class ActiveSubscriptionMiddleware(MixinorObject): - - def process_request(self, request): - is_authenticated = request.user.is_authenticated - if django.VERSION < (1, 10): - is_authenticated = is_authenticated() - - if is_authenticated and not request.user.is_staff: - url_name = resolve(request.path).url_name - if url_name not in settings.PINAX_STRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS: - customer = customers.get_customer_for_user(request.user) - if not subscriptions.has_active_subscription(customer): - return redirect( - settings.PINAX_STRIPE_SUBSCRIPTION_REQUIRED_REDIRECT - ) diff --git a/pinax/stripe/mixins.py b/pinax/stripe/mixins.py deleted file mode 100644 index 114f96a9a..000000000 --- a/pinax/stripe/mixins.py +++ /dev/null @@ -1,40 +0,0 @@ -from django.utils.decorators import method_decorator - -from .actions import customers -from .conf import settings - -try: - from account.decorators import login_required -except ImportError: - from django.contrib.auth.decorators import login_required - - -class LoginRequiredMixin(object): - - @method_decorator(login_required) - def dispatch(self, request, *args, **kwargs): - return super(LoginRequiredMixin, self).dispatch(request, *args, **kwargs) - - -class CustomerMixin(object): - - @property - def customer(self): - if not hasattr(self, "_customer"): - self._customer = customers.get_customer_for_user(self.request.user) - return self._customer - - def get_queryset(self): - return super(CustomerMixin, self).get_queryset().filter( - customer=self.customer - ) - - -class PaymentsContextMixin(object): - - def get_context_data(self, **kwargs): - context = super(PaymentsContextMixin, self).get_context_data(**kwargs) - context.update({ - "PINAX_STRIPE_PUBLIC_KEY": settings.PINAX_STRIPE_PUBLIC_KEY - }) - return context diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index 584f34e56..49f409c32 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -1,22 +1,12 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import decimal - -from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible -from django.utils.functional import cached_property -from django.utils.translation import ugettext_lazy as _ -import stripe from jsonfield.fields import JSONField -from .conf import settings -from .managers import ChargeManager, CustomerManager -from .utils import CURRENCY_SYMBOLS - class StripeObject(models.Model): @@ -27,130 +17,13 @@ class Meta: abstract = True -class AccountRelatedStripeObjectMixin(models.Model): - - stripe_account = models.ForeignKey( - "pinax_stripe.Account", - on_delete=models.CASCADE, - null=True, - default=None, - blank=True, - ) - - @property - def stripe_account_stripe_id(self): - return getattr(self.stripe_account, "stripe_id", None) - stripe_account_stripe_id.fget.short_description = "Stripe Account" - - class Meta: - abstract = True - - -class AccountRelatedStripeObject(AccountRelatedStripeObjectMixin, StripeObject): - """Uses a mixin to support Django 1.8 (name clash for stripe_id)""" - - class Meta: - abstract = True - - -class UniquePerAccountStripeObject(AccountRelatedStripeObjectMixin): - stripe_id = models.CharField(max_length=191) - created_at = models.DateTimeField(default=timezone.now) - - class Meta: - abstract = True - unique_together = ("stripe_id", "stripe_account") - - -class StripeAccountFromCustomerMixin(object): - @property - def stripe_account(self): - customer = getattr(self, "customer", None) - return customer.stripe_account if customer else None - - @property - def stripe_account_stripe_id(self): - return self.stripe_account.stripe_id if self.stripe_account else None - stripe_account_stripe_id.fget.short_description = "Stripe Account" - - -@python_2_unicode_compatible -class Plan(UniquePerAccountStripeObject): - amount = models.DecimalField(decimal_places=2, max_digits=9) - currency = models.CharField(max_length=15, blank=False) - interval = models.CharField(max_length=15) - interval_count = models.IntegerField() - name = models.CharField(max_length=150) - statement_descriptor = models.TextField(blank=True) - trial_period_days = models.IntegerField(null=True, blank=True) - metadata = JSONField(null=True, blank=True) - - def __str__(self): - return "{} ({}{})".format(self.name, CURRENCY_SYMBOLS.get(self.currency, ""), self.amount) - - def __repr__(self): - return "Plan(pk={!r}, name={!r}, amount={!r}, currency={!r}, interval={!r}, interval_count={!r}, trial_period_days={!r}, stripe_id={!r})".format( - self.pk, - self.name, - self.amount, - self.currency, - self.interval, - self.interval_count, - self.trial_period_days, - self.stripe_id, - ) - - @property - def stripe_plan(self): - return stripe.Plan.retrieve( - self.stripe_id, - stripe_account=self.stripe_account_stripe_id, - ) - - -@python_2_unicode_compatible -class Coupon(StripeObject): - - amount_off = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) - currency = models.CharField(max_length=10, default="usd") - duration = models.CharField(max_length=10, default="once") - duration_in_months = models.PositiveIntegerField(null=True, blank=True) - livemode = models.BooleanField(default=False) - max_redemptions = models.PositiveIntegerField(null=True, blank=True) - metadata = JSONField(null=True, blank=True) - percent_off = models.PositiveIntegerField(null=True, blank=True) - redeem_by = models.DateTimeField(null=True, blank=True) - times_redeemed = models.PositiveIntegerField(null=True, blank=True) - valid = models.BooleanField(default=False) - - def __str__(self): - if self.amount_off is None: - description = "{}% off".format(self.percent_off,) - else: - description = "{}{}".format(CURRENCY_SYMBOLS.get(self.currency, ""), self.amount_off) - - return "Coupon for {}, {}".format(description, self.duration) - - @python_2_unicode_compatible -class EventProcessingException(models.Model): - - event = models.ForeignKey("Event", null=True, blank=True, on_delete=models.CASCADE) - data = models.TextField() - message = models.CharField(max_length=500) - traceback = models.TextField() - created_at = models.DateTimeField(default=timezone.now) - - def __str__(self): - return "<{}, pk={}, Event={}>".format(self.message, self.pk, self.event) - - -@python_2_unicode_compatible -class Event(AccountRelatedStripeObject): +class Event(StripeObject): kind = models.CharField(max_length=250) livemode = models.BooleanField(default=False) - customer = models.ForeignKey("Customer", null=True, blank=True, on_delete=models.CASCADE) + customer_id = models.CharField(max_length=200, blank=True) + account_id = models.CharField(max_length=200, blank=True) webhook_message = JSONField() validated_message = JSONField(null=True, blank=True) valid = models.NullBooleanField(null=True, blank=True) @@ -170,486 +43,21 @@ def __repr__(self): return "Event(pk={!r}, kind={!r}, customer={!r}, valid={!r}, created_at={!s}, stripe_id={!r})".format( self.pk, self.kind, - self.customer, + self.customer_id, self.valid, self.created_at.replace(microsecond=0).isoformat(), self.stripe_id, ) -class Transfer(AccountRelatedStripeObject): - - amount = models.DecimalField(decimal_places=2, max_digits=9) - amount_reversed = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) - application_fee = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) - created = models.DateTimeField(null=True, blank=True) - currency = models.CharField(max_length=25, default="usd") - date = models.DateTimeField() - description = models.TextField(null=True, blank=True) - destination = models.TextField(null=True, blank=True) - destination_payment = models.TextField(null=True, blank=True) - event = models.ForeignKey( - Event, related_name="transfers", - on_delete=models.CASCADE, - null=True, - blank=True - ) - failure_code = models.TextField(null=True, blank=True) - failure_message = models.TextField(null=True, blank=True) - livemode = models.BooleanField(default=False) - metadata = JSONField(null=True, blank=True) - method = models.TextField(null=True, blank=True) - reversed = models.BooleanField(default=False) - source_transaction = models.TextField(null=True, blank=True) - source_type = models.TextField(null=True, blank=True) - statement_descriptor = models.TextField(null=True, blank=True) - status = models.CharField(max_length=25) - transfer_group = models.TextField(null=True, blank=True) - type = models.TextField(null=True, blank=True) - - @property - def stripe_transfer(self): - return stripe.Transfer.retrieve( - self.stripe_id, - stripe_account=self.stripe_account_stripe_id, - ) - - -class TransferChargeFee(models.Model): - - transfer = models.ForeignKey(Transfer, related_name="charge_fee_details", on_delete=models.CASCADE) - amount = models.DecimalField(decimal_places=2, max_digits=9) - currency = models.CharField(max_length=10, default="usd") - application = models.TextField(null=True, blank=True) - description = models.TextField(null=True, blank=True) - kind = models.CharField(max_length=150) - created_at = models.DateTimeField(default=timezone.now) - - -class UserAccount(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name="user_accounts", - related_query_name="user_account", - on_delete=models.CASCADE) - account = models.ForeignKey("pinax_stripe.Account", - related_name="user_accounts", - related_query_name="user_account", - on_delete=models.CASCADE) - customer = models.ForeignKey("pinax_stripe.Customer", - related_name="user_accounts", - related_query_name="user_account", - on_delete=models.CASCADE) - - class Meta: - unique_together = ("user", "account") - - def clean(self): - if not self.customer.stripe_account == self.account: - raise ValidationError(_("customer.stripe_account must be account.")) - return super(UserAccount, self).clean() - - def save(self, *args, **kwargs): - self.full_clean() - return super(UserAccount, self).save(*args, **kwargs) - - def __repr__(self): - return "UserAccount(pk={self.pk!r}, user={self.user!r}, account={self.account!r}, customer={self.customer!r})".format(self=self) - - @python_2_unicode_compatible -class Customer(AccountRelatedStripeObject): - - user = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE) - users = models.ManyToManyField(settings.AUTH_USER_MODEL, through=UserAccount, - related_name="customers", - related_query_name="customers") - account_balance = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) - currency = models.CharField(max_length=10, default="usd", blank=True) - delinquent = models.BooleanField(default=False) - default_source = models.TextField(blank=True) - date_purged = models.DateTimeField(null=True, blank=True, editable=False) - - objects = CustomerManager() - - @cached_property - def stripe_customer(self): - return stripe.Customer.retrieve( - self.stripe_id, - stripe_account=self.stripe_account_stripe_id, - ) - - def __str__(self): - if self.user: - return str(self.user) - elif self.id: - users = self.users.all() - if users: - return ", ".join(str(user) for user in users) - if self.stripe_id: - return "No User(s) ({})".format(self.stripe_id) - return "No User(s)" - - def __repr__(self): - if self.user: - return "Customer(pk={!r}, user={!r}, stripe_id={!r})".format( - self.pk, - self.user, - self.stripe_id, - ) - elif self.id: - return "Customer(pk={!r}, users={}, stripe_id={!r})".format( - self.pk, - ", ".join(repr(user) for user in self.users.all()), - self.stripe_id, - ) - return "Customer(pk={!r}, stripe_id={!r})".format(self.pk, self.stripe_id) - - -class Card(StripeObject): - - customer = models.ForeignKey(Customer, on_delete=models.CASCADE) - name = models.TextField(blank=True) - address_line_1 = models.TextField(blank=True) - address_line_1_check = models.CharField(max_length=15) - address_line_2 = models.TextField(blank=True) - address_city = models.TextField(blank=True) - address_state = models.TextField(blank=True) - address_country = models.TextField(blank=True) - address_zip = models.TextField(blank=True) - address_zip_check = models.CharField(max_length=15) - brand = models.TextField(blank=True) - country = models.CharField(max_length=2, blank=True) - cvc_check = models.CharField(max_length=15, blank=True) - dynamic_last4 = models.CharField(max_length=4, blank=True) - tokenization_method = models.CharField(max_length=15, blank=True) - exp_month = models.IntegerField() - exp_year = models.IntegerField() - funding = models.CharField(max_length=15) - last4 = models.CharField(max_length=4, blank=True) - fingerprint = models.TextField() - - def __repr__(self): - return "Card(pk={!r}, customer={!r})".format( - self.pk, - getattr(self, "customer", None), - ) - - -class BitcoinReceiver(StripeObject): - - customer = models.ForeignKey(Customer, on_delete=models.CASCADE) - active = models.BooleanField(default=False) - amount = models.DecimalField(decimal_places=2, max_digits=9) - amount_received = models.DecimalField(decimal_places=2, max_digits=9, default=decimal.Decimal("0")) - bitcoin_amount = models.PositiveIntegerField() # Satoshi (10^8 Satoshi in one bitcoin) - bitcoin_amount_received = models.PositiveIntegerField(default=0) - bitcoin_uri = models.TextField(blank=True) - currency = models.CharField(max_length=10, default="usd") - description = models.TextField(blank=True) - email = models.TextField(blank=True) - filled = models.BooleanField(default=False) - inbound_address = models.TextField(blank=True) - payment = models.TextField(blank=True) - refund_address = models.TextField(blank=True) - uncaptured_funds = models.BooleanField(default=False) - used_for_payment = models.BooleanField(default=False) - - -class Subscription(StripeAccountFromCustomerMixin, StripeObject): - - STATUS_CURRENT = ["trialing", "active"] - - customer = models.ForeignKey(Customer, on_delete=models.CASCADE) - application_fee_percent = models.DecimalField(decimal_places=2, max_digits=3, default=None, null=True, blank=True) - cancel_at_period_end = models.BooleanField(default=False) - canceled_at = models.DateTimeField(null=True, blank=True) - current_period_end = models.DateTimeField(null=True, blank=True) - current_period_start = models.DateTimeField(null=True, blank=True) - ended_at = models.DateTimeField(null=True, blank=True) - plan = models.ForeignKey(Plan, on_delete=models.CASCADE) - quantity = models.IntegerField() - start = models.DateTimeField() - status = models.CharField(max_length=25) # trialing, active, past_due, canceled, or unpaid - trial_end = models.DateTimeField(null=True, blank=True) - trial_start = models.DateTimeField(null=True, blank=True) - - @property - def stripe_subscription(self): - return stripe.Subscription.retrieve(self.stripe_id, stripe_account=self.stripe_account_stripe_id) - - @property - def total_amount(self): - return self.plan.amount * self.quantity - - def plan_display(self): - return self.plan.name - - def status_display(self): - return self.status.replace("_", " ").title() - - def delete(self, using=None): - """ - Set values to None while deleting the object so that any lingering - references will not show previous values (such as when an Event - signal is triggered after a subscription has been deleted) - """ - super(Subscription, self).delete(using=using) - self.status = None - self.quantity = 0 - self.amount = 0 - - def __repr__(self): - return "Subscription(pk={!r}, customer={!r}, plan={!r}, status={!r}, stripe_id={!r})".format( - self.pk, - getattr(self, "customer", None), - getattr(self, "plan", None), - self.status, - self.stripe_id, - ) - - -class Invoice(StripeAccountFromCustomerMixin, StripeObject): - - customer = models.ForeignKey(Customer, related_name="invoices", on_delete=models.CASCADE) - amount_due = models.DecimalField(decimal_places=2, max_digits=9) - attempted = models.NullBooleanField() - attempt_count = models.PositiveIntegerField(null=True, blank=True) - charge = models.ForeignKey("Charge", null=True, blank=True, related_name="invoices", on_delete=models.CASCADE) - subscription = models.ForeignKey(Subscription, null=True, blank=True, on_delete=models.CASCADE) - statement_descriptor = models.TextField(blank=True) - currency = models.CharField(max_length=10, default="usd") - closed = models.BooleanField(default=False) - description = models.TextField(blank=True) - paid = models.BooleanField(default=False) - receipt_number = models.TextField(blank=True) - period_end = models.DateTimeField() - period_start = models.DateTimeField() - subtotal = models.DecimalField(decimal_places=2, max_digits=9) - tax = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) - tax_percent = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) - total = models.DecimalField(decimal_places=2, max_digits=9) - date = models.DateTimeField() - webhooks_delivered_at = models.DateTimeField(null=True, blank=True) - - @property - def status(self): - return "Paid" if self.paid else "Open" - - @property - def stripe_invoice(self): - return stripe.Invoice.retrieve( - self.stripe_id, - stripe_account=self.stripe_account_stripe_id, - ) - - -class InvoiceItem(models.Model): +class EventProcessingException(models.Model): - stripe_id = models.CharField(max_length=255) + event = models.ForeignKey("Event", null=True, blank=True, on_delete=models.CASCADE) + data = models.TextField() + message = models.CharField(max_length=500) + traceback = models.TextField() created_at = models.DateTimeField(default=timezone.now) - invoice = models.ForeignKey(Invoice, related_name="items", on_delete=models.CASCADE) - amount = models.DecimalField(decimal_places=2, max_digits=9) - currency = models.CharField(max_length=10, default="usd") - kind = models.CharField(max_length=25, blank=True) - subscription = models.ForeignKey(Subscription, null=True, blank=True, on_delete=models.CASCADE) - period_start = models.DateTimeField() - period_end = models.DateTimeField() - proration = models.BooleanField(default=False) - line_type = models.CharField(max_length=50) - description = models.CharField(max_length=200, blank=True) - plan = models.ForeignKey(Plan, null=True, blank=True, on_delete=models.CASCADE) - quantity = models.IntegerField(null=True, blank=True) - - def plan_display(self): - return self.plan.name if self.plan else "" - - -class Charge(StripeAccountFromCustomerMixin, StripeObject): - - customer = models.ForeignKey(Customer, null=True, blank=True, related_name="charges", on_delete=models.CASCADE) - invoice = models.ForeignKey(Invoice, null=True, blank=True, related_name="charges", on_delete=models.CASCADE) - source = models.CharField(max_length=100, blank=True) - currency = models.CharField(max_length=10, default="usd") - amount = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) - amount_refunded = models.DecimalField(decimal_places=2, max_digits=9, null=True, blank=True) - description = models.TextField(blank=True) - paid = models.NullBooleanField(null=True, blank=True) - disputed = models.NullBooleanField(null=True, blank=True) - refunded = models.NullBooleanField(null=True, blank=True) - captured = models.NullBooleanField(null=True, blank=True) - receipt_sent = models.BooleanField(default=False) - charge_created = models.DateTimeField(null=True, blank=True) - - # These fields are extracted from the BalanceTransaction for the - # charge and help us to know when funds from a charge are added to - # our Stripe account's balance. - available = models.BooleanField(default=False) - available_on = models.DateTimeField(null=True, blank=True) - fee = models.DecimalField( - decimal_places=2, max_digits=9, null=True, blank=True - ) - fee_currency = models.CharField(max_length=10, null=True, blank=True) - - transfer_group = models.TextField(null=True, blank=True) - outcome = JSONField(null=True, blank=True) - - objects = ChargeManager() - - def __str__(self): - info = [] - if not self.paid: - info += ["unpaid"] - if not self.captured: - info += ["uncaptured"] - if self.refunded: - info += ["refunded"] - currency = CURRENCY_SYMBOLS.get(self.currency, "") - return "{}{}{}".format( - currency, - self.total_amount, - " ({})".format(", ".join(info)) if info else "", - ) - - def __repr__(self): - return "Charge(pk={!r}, customer={!r}, source={!r}, amount={!r}, captured={!r}, paid={!r}, stripe_id={!r})".format( - self.pk, - self.customer, - self.source, - self.amount, - self.captured, - self.paid, - self.stripe_id, - ) - - @property - def total_amount(self): - amount = self.amount if self.amount else 0 - amount_refunded = self.amount_refunded if self.amount_refunded else 0 - return amount - amount_refunded - total_amount.fget.short_description = "Σ amount" - - @property - def stripe_charge(self): - return stripe.Charge.retrieve( - self.stripe_id, - stripe_account=self.stripe_account_stripe_id, - expand=["balance_transaction"] - ) - - @property - def card(self): - return Card.objects.filter(stripe_id=self.source).first() - - -@python_2_unicode_compatible -class Account(StripeObject): - - INTERVAL_CHOICES = ( - ("Manual", "manual"), - ("Daily", "daily"), - ("Weekly", "weekly"), - ("Monthly", "monthly"), - ) - user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE, related_name="stripe_accounts") - - business_name = models.TextField(null=True, blank=True) - business_url = models.TextField(null=True, blank=True) - - charges_enabled = models.BooleanField(default=False) - country = models.CharField(max_length=2) - debit_negative_balances = models.BooleanField(default=False) - decline_charge_on_avs_failure = models.BooleanField(default=False) - decline_charge_on_cvc_failure = models.BooleanField(default=False) - default_currency = models.CharField(max_length=3) - details_submitted = models.BooleanField(default=False) - display_name = models.TextField(blank=True, null=True) - email = models.TextField(null=True, blank=True) - - legal_entity_address_city = models.TextField(null=True, blank=True) - legal_entity_address_country = models.TextField(null=True, blank=True) - legal_entity_address_line1 = models.TextField(null=True, blank=True) - legal_entity_address_line2 = models.TextField(null=True, blank=True) - legal_entity_address_postal_code = models.TextField(null=True, blank=True) - legal_entity_address_state = models.TextField(null=True, blank=True) - legal_entity_dob = models.DateField(null=True, blank=True) - legal_entity_first_name = models.TextField(null=True, blank=True) - legal_entity_gender = models.TextField(null=True, blank=True) - legal_entity_last_name = models.TextField(null=True, blank=True) - legal_entity_maiden_name = models.TextField(null=True, blank=True) - legal_entity_personal_id_number_provided = models.BooleanField(default=False) - legal_entity_phone_number = models.TextField(null=True, blank=True) - legal_entity_ssn_last_4_provided = models.BooleanField(default=False) - legal_entity_type = models.TextField(null=True, blank=True) - legal_entity_verification_details = models.TextField(null=True, blank=True) - legal_entity_verification_details_code = models.TextField(null=True, blank=True) - legal_entity_verification_document = models.TextField(null=True, blank=True) - legal_entity_verification_status = models.TextField(null=True, blank=True) - - # The type of the Stripe account. Can be "standard", "express", or "custom". - type = models.TextField(null=True, blank=True) - - metadata = JSONField(null=True, blank=True) - - stripe_publishable_key = models.CharField(null=True, blank=True, max_length=100) - - product_description = models.TextField(null=True, blank=True) - statement_descriptor = models.TextField(null=True, blank=True) - support_email = models.TextField(null=True, blank=True) - support_phone = models.TextField(null=True, blank=True) - - timezone = models.TextField(null=True, blank=True) - - tos_acceptance_date = models.DateField(null=True, blank=True) - tos_acceptance_ip = models.TextField(null=True, blank=True) - tos_acceptance_user_agent = models.TextField(null=True, blank=True) - - payout_schedule_delay_days = models.PositiveSmallIntegerField(null=True, blank=True) - payout_schedule_interval = models.CharField(max_length=7, choices=INTERVAL_CHOICES, null=True, blank=True) - payout_schedule_monthly_anchor = models.PositiveSmallIntegerField(null=True, blank=True) - payout_schedule_weekly_anchor = models.TextField(null=True, blank=True) - payout_statement_descriptor = models.TextField(null=True, blank=True) - payouts_enabled = models.BooleanField(default=False) - - verification_disabled_reason = models.TextField(null=True, blank=True) - verification_due_by = models.DateTimeField(null=True, blank=True) - verification_timestamp = models.DateTimeField(null=True, blank=True) - verification_fields_needed = JSONField(null=True, blank=True) - authorized = models.BooleanField(default=True) - - @property - def stripe_account(self): - return stripe.Account.retrieve(self.stripe_id) def __str__(self): - return "{} - {}".format(self.display_name or "", self.stripe_id) - - def __repr__(self): - return "Account(pk={!r}, display_name={!r}, type={!r}, authorized={!r}, stripe_id={!r})".format( - self.pk, - self.display_name or "", - self.type, - self.authorized, - self.stripe_id, - ) - - -class BankAccount(StripeObject): - - account = models.ForeignKey(Account, related_name="bank_accounts", on_delete=models.CASCADE) - account_holder_name = models.TextField() - account_holder_type = models.TextField() - bank_name = models.TextField(null=True, blank=True) - country = models.TextField() - currency = models.TextField() - default_for_currency = models.BooleanField(default=False) - fingerprint = models.TextField() - last4 = models.CharField(max_length=4) - metadata = JSONField(null=True, blank=True) - routing_number = models.TextField() - status = models.TextField() - - @property - def stripe_bankaccount(self): - return self.account.stripe_account.external_accounts.retrieve( - self.stripe_id - ) + return "<{}, pk={}, Event={}>".format(self.message, self.pk, self.event) diff --git a/pinax/stripe/templates/pinax/stripe/email/body.txt b/pinax/stripe/templates/pinax/stripe/email/body.txt deleted file mode 100644 index dba968e32..000000000 --- a/pinax/stripe/templates/pinax/stripe/email/body.txt +++ /dev/null @@ -1 +0,0 @@ -{% extends "pinax/stripe/email/body_base.txt" %} diff --git a/pinax/stripe/templates/pinax/stripe/email/body_base.txt b/pinax/stripe/templates/pinax/stripe/email/body_base.txt deleted file mode 100644 index 56f1dc329..000000000 --- a/pinax/stripe/templates/pinax/stripe/email/body_base.txt +++ /dev/null @@ -1,27 +0,0 @@ -{% if charge.paid %}Your {{ site.name }} account was successfully charged ${{ charge.amount|floatformat:2 }} to the credit card ending in {{ charge.card.last4 }}. The invoice below is for your records. - - -======================================================== -INVOICE #{{ charge.pk }} {{ charge.created_at|date:"F d, Y" }} -........................................................ - - -CUSTOMER: {% block customer_name %}{{ charge.customer.user }}{% endblock %} - - -DETAILS -------- -{{ charge.customer.current_subscription.plan_display }} - ${{ charge.amount|floatformat:2 }} - -TOTAL: ${{ charge.amount|floatformat:2 }} USD -PAID BY CREDIT CARD: -${{ charge.amount|floatformat:2 }} -======================================================== -{% else %}{% if charge.refunded %}Your credit card ending in {{ charge.card.last4 }} was refunded ${{ charge.amount|floatformat:2 }}. -{% else %}We are sorry, but we failed to charge your credit card ending in {{ charge.card.last4 }} for the amount ${{ charge.amount|floatformat:2 }}. -{% endif %}{% endif %} - -Please contact us with any questions regarding this invoice. - ---- -Your {{ site.name }} Team -{{ protocol }}://{{ site.domain }} diff --git a/pinax/stripe/templates/pinax/stripe/email/subject.txt b/pinax/stripe/templates/pinax/stripe/email/subject.txt deleted file mode 100644 index be4a37b62..000000000 --- a/pinax/stripe/templates/pinax/stripe/email/subject.txt +++ /dev/null @@ -1 +0,0 @@ -[{{ site.name }}] Payment Receipt (Invoice #{{ charge.pk }}) \ No newline at end of file diff --git a/pinax/stripe/tests/hooks.py b/pinax/stripe/tests/hooks.py deleted file mode 100644 index 24c817db7..000000000 --- a/pinax/stripe/tests/hooks.py +++ /dev/null @@ -1,28 +0,0 @@ -from datetime import timedelta - -from django.utils import timezone - -from ..hooks import DefaultHookSet - - -class TestHookSet(DefaultHookSet): - - def adjust_subscription_quantity(self, customer, plan, quantity): - """ - Given a customer, plan, and quantity, when calling Customer.subscribe - you have the opportunity to override the quantity that was specified. - - Previously this was handled in the setting `PAYMENTS_PLAN_QUANTITY_CALLBACK` - and was only passed a customer object. - """ - return quantity or 4 - - def trial_period(self, user, plan): - """ - Given a user and plan, return an end date for a trial period, or None - for no trial period. - - Was previously in the setting `TRIAL_PERIOD_FOR_USER_CALLBACK` - """ - if plan is not None: - return timezone.now() + timedelta(days=3) diff --git a/pinax/stripe/tests/settings.py b/pinax/stripe/tests/settings.py index 5dbee04ce..bf0faa023 100644 --- a/pinax/stripe/tests/settings.py +++ b/pinax/stripe/tests/settings.py @@ -33,9 +33,6 @@ SITE_ID = 1 PINAX_STRIPE_PUBLIC_KEY = "" PINAX_STRIPE_SECRET_KEY = "sk_test_01234567890123456789abcd" -PINAX_STRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS = ["pinax_stripe_subscription_create"] -PINAX_STRIPE_SUBSCRIPTION_REQUIRED_REDIRECT = "pinax_stripe_subscription_create" -PINAX_STRIPE_HOOKSET = "pinax.stripe.tests.hooks.TestHookSet" TEMPLATES = [{ "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [ diff --git a/pinax/stripe/tests/test_actions.py b/pinax/stripe/tests/test_actions.py deleted file mode 100644 index 4d5dc1b40..000000000 --- a/pinax/stripe/tests/test_actions.py +++ /dev/null @@ -1,3299 +0,0 @@ -import datetime -import decimal -import json -import time -from unittest import skipIf - -import django -from django.contrib.auth import get_user_model -from django.test import TestCase -from django.utils import timezone - -import stripe -from mock import Mock, patch - -from ..actions import ( - accounts, - charges, - customers, - events, - externalaccounts, - invoices, - plans, - refunds, - sources, - subscriptions, - transfers -) -from ..models import ( - Account, - BitcoinReceiver, - Card, - Charge, - Customer, - Event, - Invoice, - Plan, - Subscription, - Transfer, - UserAccount -) - - -class ChargesTests(TestCase): - - def setUp(self): - self.User = get_user_model() - self.user = self.User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - self.customer = Customer.objects.create( - user=self.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - - def test_calculate_refund_amount(self): - charge = Charge(amount=decimal.Decimal("100"), amount_refunded=decimal.Decimal("50")) - expected = decimal.Decimal("50") - actual = charges.calculate_refund_amount(charge) - self.assertEqual(expected, actual) - - def test_calculate_refund_amount_with_amount_under(self): - charge = Charge(amount=decimal.Decimal("100"), amount_refunded=decimal.Decimal("50")) - expected = decimal.Decimal("25") - actual = charges.calculate_refund_amount(charge, amount=decimal.Decimal("25")) - self.assertEqual(expected, actual) - - def test_calculate_refund_amount_with_amount_over(self): - charge = Charge(amount=decimal.Decimal("100"), amount_refunded=decimal.Decimal("50")) - expected = decimal.Decimal("50") - actual = charges.calculate_refund_amount(charge, amount=decimal.Decimal("100")) - self.assertEqual(expected, actual) - - def test_create_amount_not_decimal_raises_error(self): - with self.assertRaises(ValueError): - charges.create(customer=self.customer, amount=10) - - def test_create_no_customer_nor_source_raises_error(self): - with self.assertRaises(ValueError) as exc: - charges.create(amount=decimal.Decimal("10"), - customer=None) - self.assertEqual(exc.exception.args, ("Must provide `customer` or `source`.",)) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_send_receipt_False_skips_sending_receipt(self, CreateMock, SyncMock, SendReceiptMock): - charges.create(amount=decimal.Decimal("10"), customer=self.customer, send_receipt=False) - self.assertTrue(CreateMock.called) - self.assertTrue(SyncMock.called) - self.assertFalse(SendReceiptMock.called) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_with_customer(self, CreateMock, SyncMock, SendReceiptMock): - charges.create(amount=decimal.Decimal("10"), customer=self.customer) - self.assertTrue(CreateMock.called) - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs, { - "amount": 1000, - "currency": "usd", - "source": None, - "customer": "cus_xxxxxxxxxxxxxxx", - "stripe_account": None, - "description": None, - "capture": True, - "idempotency_key": None, - }) - self.assertTrue(SyncMock.called) - self.assertTrue(SendReceiptMock.called) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_with_customer_id(self, CreateMock, SyncMock, SendReceiptMock): - charges.create(amount=decimal.Decimal("10"), customer=self.customer.stripe_id) - self.assertTrue(CreateMock.called) - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs, { - "amount": 1000, - "currency": "usd", - "source": None, - "customer": "cus_xxxxxxxxxxxxxxx", - "stripe_account": None, - "description": None, - "capture": True, - "idempotency_key": None, - }) - self.assertTrue(SyncMock.called) - self.assertTrue(SendReceiptMock.called) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_with_new_customer_id(self, CreateMock, SyncMock, SendReceiptMock): - charges.create(amount=decimal.Decimal("10"), customer="cus_NEW") - self.assertTrue(CreateMock.called) - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs, { - "amount": 1000, - "currency": "usd", - "source": None, - "customer": "cus_NEW", - "stripe_account": None, - "description": None, - "capture": True, - "idempotency_key": None, - }) - self.assertTrue(SyncMock.called) - self.assertTrue(SendReceiptMock.called) - self.assertTrue(Customer.objects.get(stripe_id="cus_NEW")) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_with_idempotency_key(self, CreateMock, SyncMock, SendReceiptMock): - charges.create(amount=decimal.Decimal("10"), customer=self.customer.stripe_id, idempotency_key="a") - CreateMock.assert_called_once_with( - amount=1000, - capture=True, - customer=self.customer.stripe_id, - stripe_account=self.customer.stripe_account_stripe_id, - idempotency_key="a", - description=None, - currency="usd", - source=None, - ) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_with_app_fee(self, CreateMock, SyncMock, SendReceiptMock): - charges.create( - amount=decimal.Decimal("10"), - customer=self.customer, - destination_account="xxx", - application_fee=decimal.Decimal("25") - ) - self.assertTrue(CreateMock.called) - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["application_fee"], 2500) - self.assertEqual(kwargs["destination"]["account"], "xxx") - self.assertEqual(kwargs["destination"].get("amount"), None) - self.assertTrue(SyncMock.called) - self.assertTrue(SendReceiptMock.called) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_with_destination(self, CreateMock, SyncMock, SendReceiptMock): - charges.create( - amount=decimal.Decimal("10"), - customer=self.customer, - destination_account="xxx", - destination_amount=decimal.Decimal("45") - ) - self.assertTrue(CreateMock.called) - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["destination"]["account"], "xxx") - self.assertEqual(kwargs["destination"]["amount"], 4500) - self.assertTrue(SyncMock.called) - self.assertTrue(SendReceiptMock.called) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_with_on_behalf_of(self, CreateMock, SyncMock, SendReceiptMock): - charges.create( - amount=decimal.Decimal("10"), - customer=self.customer, - on_behalf_of="account", - ) - self.assertTrue(CreateMock.called) - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["on_behalf_of"], "account") - self.assertTrue(SyncMock.called) - self.assertTrue(SendReceiptMock.called) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.create") - def test_create_with_destination_and_on_behalf_of(self, CreateMock, SyncMock, SendReceiptMock): - with self.assertRaises(ValueError): - charges.create( - amount=decimal.Decimal("10"), - customer=self.customer, - destination_account="xxx", - on_behalf_of="account", - ) - - @patch("stripe.Charge.create") - def test_create_not_decimal_raises_exception(self, CreateMock): - with self.assertRaises(ValueError): - charges.create( - amount=decimal.Decimal("100"), - customer=self.customer, - application_fee=10 - ) - - @patch("stripe.Charge.create") - def test_create_app_fee_no_dest_raises_exception(self, CreateMock): - with self.assertRaises(ValueError): - charges.create( - amount=decimal.Decimal("100"), - customer=self.customer, - application_fee=decimal.Decimal("10") - ) - - @patch("stripe.Charge.create") - def test_create_app_fee_dest_acct_and_dest_amt_raises_exception(self, CreateMock): - with self.assertRaises(ValueError): - charges.create( - amount=decimal.Decimal("100"), - customer=self.customer, - application_fee=decimal.Decimal("10"), - destination_account="xxx", - destination_amount=decimal.Decimal("15") - ) - - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.capture") - def test_capture(self, CaptureMock, SyncMock): - charges.capture(Charge(stripe_id="ch_A", amount=decimal.Decimal("100"), currency="usd")) - self.assertTrue(CaptureMock.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.capture") - def test_capture_with_amount(self, CaptureMock, SyncMock): - charge = Charge(stripe_id="ch_A", amount=decimal.Decimal("100"), currency="usd") - charges.capture(charge, amount=decimal.Decimal("50"), idempotency_key="IDEM") - self.assertTrue(CaptureMock.called) - _, kwargs = CaptureMock.call_args - self.assertEqual(kwargs["amount"], 5000) - self.assertEqual(kwargs["idempotency_key"], "IDEM") - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Charge.capture") - def test_capture_with_connect(self, CaptureMock, SyncMock): - account = Account(stripe_id="acc_001") - customer = Customer(stripe_id="cus_001", stripe_account=account) - charges.capture(Charge(stripe_id="ch_A", amount=decimal.Decimal("100"), currency="usd", customer=customer)) - self.assertTrue(CaptureMock.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.charges.sync_charge") - def test_update_availability(self, SyncMock): - Charge.objects.create(customer=self.customer, amount=decimal.Decimal("100"), currency="usd", paid=True, captured=True, available=False, refunded=False) - charges.update_charge_availability() - self.assertTrue(SyncMock.called) - - -class CustomersTests(TestCase): - - def setUp(self): - self.User = get_user_model() - self.user = self.User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - self.plan = Plan.objects.create( - stripe_id="p1", - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - - def test_get_customer_for_user(self): - expected = Customer.objects.create(stripe_id="x", user=self.user) - actual = customers.get_customer_for_user(self.user) - self.assertEqual(expected, actual) - - def test_get_customer_for_user_not_exists(self): - actual = customers.get_customer_for_user(self.user) - self.assertIsNone(actual) - - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("stripe.Customer.retrieve") - def test_set_default_source(self, RetrieveMock, SyncMock): - customers.set_default_source(Customer(), "the source") - self.assertEqual(RetrieveMock().default_source, "the source") - self.assertTrue(RetrieveMock().save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("stripe.Customer.create") - def test_customer_create_user_only(self, CreateMock, SyncMock): - CreateMock.return_value = dict(id="cus_XXXXX") - customer = customers.create(self.user) - self.assertEqual(customer.user, self.user) - self.assertEqual(customer.stripe_id, "cus_XXXXX") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["email"], self.user.email) - self.assertIsNone(kwargs["source"]) - self.assertIsNone(kwargs["plan"]) - self.assertIsNone(kwargs["trial_end"]) - self.assertTrue(SyncMock.called) - - @patch("stripe.Customer.retrieve") - @patch("stripe.Customer.create") - def test_customer_create_user_duplicate(self, CreateMock, RetrieveMock): - # Create an existing database customer for this user - original = Customer.objects.create(user=self.user, stripe_id="cus_XXXXX") - - new_customer = Mock() - RetrieveMock.return_value = new_customer - customer = customers.create(self.user) - - # But only one customer will exist - the original one - self.assertEqual(Customer.objects.count(), 1) - self.assertEqual(customer.stripe_id, original.stripe_id) - - # Check that the customer hasn't been modified - self.assertEqual(customer.user, self.user) - self.assertEqual(customer.stripe_id, "cus_XXXXX") - CreateMock.assert_not_called() - - @patch("stripe.Customer.retrieve") - @patch("stripe.Customer.create") - def test_customer_create_local_customer_but_no_remote(self, CreateMock, RetrieveMock): - # Create an existing database customer for this user - Customer.objects.create(user=self.user, stripe_id="cus_XXXXX") - - RetrieveMock.side_effect = stripe.error.InvalidRequestError( - message="invalid", param=None) - - # customers.Create will return a new customer instance - CreateMock.return_value = { - "id": "cus_YYYYY", - "account_balance": 0, - "currency": "us", - "delinquent": False, - "default_source": "", - "sources": {"data": []}, - "subscriptions": {"data": []}, - } - customer = customers.create(self.user) - - # But a customer *was* retrieved, but not found - RetrieveMock.assert_called_once_with("cus_XXXXX") - - # But only one customer will exist - the original one - self.assertEqual(Customer.objects.count(), 1) - self.assertEqual(customer.stripe_id, "cus_YYYYY") - - # Check that the customer hasn't been modified - self.assertEqual(customer.user, self.user) - self.assertEqual(customer.stripe_id, "cus_YYYYY") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["email"], self.user.email) - self.assertIsNone(kwargs["source"]) - self.assertIsNone(kwargs["plan"]) - self.assertIsNone(kwargs["trial_end"]) - - @patch("pinax.stripe.actions.invoices.create_and_pay") - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("stripe.Customer.create") - def test_customer_create_user_with_plan(self, CreateMock, SyncMock, CreateAndPayMock): - Plan.objects.create( - stripe_id="pro-monthly", - name="Pro ($19.99/month)", - amount=19.99, - interval="monthly", - interval_count=1, - currency="usd" - ) - CreateMock.return_value = dict(id="cus_YYYYYYYYYYYYY") - customer = customers.create(self.user, card="token232323", plan=self.plan) - self.assertEqual(customer.user, self.user) - self.assertEqual(customer.stripe_id, "cus_YYYYYYYYYYYYY") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["email"], self.user.email) - self.assertEqual(kwargs["source"], "token232323") - self.assertEqual(kwargs["plan"], self.plan) - self.assertIsNotNone(kwargs["trial_end"]) - self.assertTrue(SyncMock.called) - self.assertTrue(CreateAndPayMock.called) - - @patch("pinax.stripe.actions.invoices.create_and_pay") - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("stripe.Customer.create") - def test_customer_create_user_with_plan_and_quantity(self, CreateMock, SyncMock, CreateAndPayMock): - Plan.objects.create( - stripe_id="pro-monthly", - name="Pro ($19.99/month each)", - amount=19.99, - interval="monthly", - interval_count=1, - currency="usd" - ) - CreateMock.return_value = dict(id="cus_YYYYYYYYYYYYY") - customer = customers.create(self.user, card="token232323", plan=self.plan, quantity=42) - self.assertEqual(customer.user, self.user) - self.assertEqual(customer.stripe_id, "cus_YYYYYYYYYYYYY") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["email"], self.user.email) - self.assertEqual(kwargs["source"], "token232323") - self.assertEqual(kwargs["plan"], self.plan) - self.assertEqual(kwargs["quantity"], 42) - self.assertIsNotNone(kwargs["trial_end"]) - self.assertTrue(SyncMock.called) - self.assertTrue(CreateAndPayMock.called) - - @patch("stripe.Customer.retrieve") - def test_purge(self, RetrieveMock): - customer = Customer.objects.create( - user=self.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - customers.purge(customer) - self.assertTrue(RetrieveMock().delete.called) - self.assertIsNone(Customer.objects.get(stripe_id=customer.stripe_id).user) - self.assertIsNotNone(Customer.objects.get(stripe_id=customer.stripe_id).date_purged) - - @patch("stripe.Customer.retrieve") - def test_purge_connected(self, RetrieveMock): - account = Account.objects.create(stripe_id="acc_XXX") - customer = Customer.objects.create( - user=self.user, - stripe_account=account, - stripe_id="cus_xxxxxxxxxxxxxxx", - ) - UserAccount.objects.create(user=self.user, account=account, customer=customer) - customers.purge(customer) - self.assertTrue(RetrieveMock().delete.called) - self.assertIsNone(Customer.objects.get(stripe_id=customer.stripe_id).user) - self.assertIsNotNone(Customer.objects.get(stripe_id=customer.stripe_id).date_purged) - self.assertFalse(UserAccount.objects.exists()) - self.assertTrue(self.User.objects.exists()) - - @patch("stripe.Customer.retrieve") - def test_purge_already_deleted(self, RetrieveMock): - RetrieveMock().delete.side_effect = stripe.error.InvalidRequestError("No such customer:", "error") - customer = Customer.objects.create( - user=self.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - customers.purge(customer) - self.assertTrue(RetrieveMock().delete.called) - self.assertIsNone(Customer.objects.get(stripe_id=customer.stripe_id).user) - self.assertIsNotNone(Customer.objects.get(stripe_id=customer.stripe_id).date_purged) - - @patch("stripe.Customer.retrieve") - def test_purge_already_some_other_error(self, RetrieveMock): - RetrieveMock().delete.side_effect = stripe.error.InvalidRequestError("Bad", "error") - customer = Customer.objects.create( - user=self.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - with self.assertRaises(stripe.error.InvalidRequestError): - customers.purge(customer) - self.assertTrue(RetrieveMock().delete.called) - self.assertIsNotNone(Customer.objects.get(stripe_id=customer.stripe_id).user) - self.assertIsNone(Customer.objects.get(stripe_id=customer.stripe_id).date_purged) - - def test_can_charge(self): - customer = Customer(default_source="card_001") - self.assertTrue(customers.can_charge(customer)) - - def test_can_charge_false_purged(self): - customer = Customer(default_source="card_001", date_purged=timezone.now()) - self.assertFalse(customers.can_charge(customer)) - - def test_can_charge_false_no_default_source(self): - customer = Customer() - self.assertFalse(customers.can_charge(customer)) - - @patch("pinax.stripe.actions.customers.sync_customer") - def test_link_customer(self, SyncMock): - Customer.objects.create(stripe_id="cu_123") - message = dict(data=dict(object=dict(id="cu_123"))) - event = Event.objects.create(validated_message=message, kind="customer.created") - customers.link_customer(event) - self.assertEqual(event.customer.stripe_id, "cu_123") - self.assertTrue(SyncMock.called) - - def test_link_customer_non_customer_event(self): - Customer.objects.create(stripe_id="cu_123") - message = dict(data=dict(object=dict(customer="cu_123"))) - event = Event.objects.create(validated_message=message, kind="invoice.created") - customers.link_customer(event) - self.assertEqual(event.customer.stripe_id, "cu_123") - - def test_link_customer_non_customer_event_no_customer(self): - Customer.objects.create(stripe_id="cu_123") - message = dict(data=dict(object=dict())) - event = Event.objects.create(validated_message=message, kind="transfer.created") - customers.link_customer(event) - self.assertIsNone(event.customer, "cu_123") - - @patch("pinax.stripe.actions.customers.sync_customer") - def test_link_customer_does_not_exist(self, SyncMock): - message = dict(data=dict(object=dict(id="cu_123"))) - event = Event.objects.create(stripe_id="evt_1", validated_message=message, kind="customer.created") - customers.link_customer(event) - Customer.objects.get(stripe_id="cu_123") - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.customers.sync_customer") - def test_link_customer_does_not_exist_connected(self, SyncMock): - message = dict(data=dict(object=dict(id="cu_123"))) - account = Account.objects.create(stripe_id="acc_XXX") - event = Event.objects.create(stripe_id="evt_1", validated_message=message, kind="customer.created", stripe_account=account) - customers.link_customer(event) - Customer.objects.get(stripe_id="cu_123", stripe_account=account) - self.assertTrue(SyncMock.called) - - -class CustomersWithConnectTests(TestCase): - - def setUp(self): - self.User = get_user_model() - self.user = self.User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - self.plan = Plan.objects.create( - stripe_id="p1", - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - self.account = Account.objects.create( - stripe_id="acc_XXX" - ) - - def test_get_customer_for_user_with_stripe_account(self): - expected = Customer.objects.create( - stripe_id="x", - stripe_account=self.account) - UserAccount.objects.create(user=self.user, account=self.account, customer=expected) - actual = customers.get_customer_for_user( - self.user, stripe_account=self.account) - self.assertEqual(expected, actual) - - def test_get_customer_for_user_with_stripe_account_and_legacy_customer(self): - Customer.objects.create(user=self.user, stripe_id="x") - self.assertIsNone(customers.get_customer_for_user( - self.user, stripe_account=self.account)) - - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("stripe.Customer.create") - def test_customer_create_with_connect(self, CreateMock, SyncMock): - CreateMock.return_value = dict(id="cus_XXXXX") - customer = customers.create(self.user, stripe_account=self.account) - self.assertIsNone(customer.user) - self.assertEqual(customer.stripe_id, "cus_XXXXX") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["email"], self.user.email) - self.assertEqual(kwargs["stripe_account"], self.account.stripe_id) - self.assertIsNone(kwargs["source"]) - self.assertIsNone(kwargs["plan"]) - self.assertIsNone(kwargs["trial_end"]) - self.assertTrue(SyncMock.called) - - @patch("stripe.Customer.retrieve") - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("stripe.Customer.create") - def test_customer_create_with_connect_and_stale_user_account(self, CreateMock, SyncMock, RetrieveMock): - CreateMock.return_value = dict(id="cus_XXXXX") - RetrieveMock.side_effect = stripe.error.InvalidRequestError( - message="Not Found", param="stripe_id" - ) - ua = UserAccount.objects.create( - user=self.user, - account=self.account, - customer=Customer.objects.create(stripe_id="cus_Z", stripe_account=self.account)) - customer = customers.create(self.user, stripe_account=self.account) - self.assertIsNone(customer.user) - self.assertEqual(customer.stripe_id, "cus_XXXXX") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["email"], self.user.email) - self.assertEqual(kwargs["stripe_account"], self.account.stripe_id) - self.assertIsNone(kwargs["source"]) - self.assertIsNone(kwargs["plan"]) - self.assertIsNone(kwargs["trial_end"]) - self.assertTrue(SyncMock.called) - self.assertEqual(self.user.user_accounts.get(), ua) - self.assertEqual(ua.customer, customer) - RetrieveMock.assert_called_once_with("cus_Z", stripe_account=self.account.stripe_id) - - @patch("stripe.Customer.retrieve") - def test_customer_create_with_connect_with_existing_customer(self, RetrieveMock): - expected = Customer.objects.create( - stripe_id="x", - stripe_account=self.account) - UserAccount.objects.create(user=self.user, account=self.account, customer=expected) - customer = customers.create(self.user, stripe_account=self.account) - self.assertEqual(customer, expected) - RetrieveMock.assert_called_once_with("x", stripe_account=self.account.stripe_id) - - @patch("pinax.stripe.actions.invoices.create_and_pay") - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("stripe.Customer.create") - def test_customer_create_user_with_plan(self, CreateMock, SyncMock, CreateAndPayMock): - Plan.objects.create( - stripe_id="pro-monthly", - name="Pro ($19.99/month)", - amount=19.99, - interval="monthly", - interval_count=1, - currency="usd" - ) - CreateMock.return_value = dict(id="cus_YYYYYYYYYYYYY") - customer = customers.create(self.user, card="token232323", plan=self.plan, stripe_account=self.account) - self.assertEqual(customer.stripe_id, "cus_YYYYYYYYYYYYY") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["email"], self.user.email) - self.assertEqual(kwargs["source"], "token232323") - self.assertEqual(kwargs["plan"], self.plan) - self.assertIsNotNone(kwargs["trial_end"]) - self.assertTrue(SyncMock.called) - self.assertTrue(CreateAndPayMock.called) - - -class EventsTests(TestCase): - - @classmethod - def setUpClass(cls): - super(EventsTests, cls).setUpClass() - cls.account = Account.objects.create(stripe_id="acc_001") - - def test_dupe_event_exists(self): - Event.objects.create(stripe_id="evt_003", kind="foo", livemode=True, webhook_message="{}", api_version="", request="", pending_webhooks=0) - self.assertTrue(events.dupe_event_exists("evt_003")) - - @patch("pinax.stripe.webhooks.AccountUpdatedWebhook.process") - def test_add_event(self, ProcessMock): - events.add_event(stripe_id="evt_001", kind="account.updated", livemode=True, message={}) - event = Event.objects.get(stripe_id="evt_001") - self.assertEqual(event.kind, "account.updated") - self.assertTrue(ProcessMock.called) - - @patch("pinax.stripe.webhooks.AccountUpdatedWebhook.process") - def test_add_event_connect(self, ProcessMock): - events.add_event(stripe_id="evt_001", kind="account.updated", livemode=True, message={"account": self.account.stripe_id}) - event = Event.objects.get(stripe_id="evt_001", stripe_account=self.account) - self.assertEqual(event.kind, "account.updated") - self.assertTrue(ProcessMock.called) - - @patch("pinax.stripe.webhooks.AccountUpdatedWebhook.process") - def test_add_event_missing_account_connect(self, ProcessMock): - events.add_event(stripe_id="evt_001", kind="account.updated", livemode=True, message={"account": "acc_NEW"}) - event = Event.objects.get(stripe_id="evt_001", stripe_account=Account.objects.get(stripe_id="acc_NEW")) - self.assertEqual(event.kind, "account.updated") - self.assertTrue(ProcessMock.called) - - def test_add_event_new_webhook_kind(self): - events.add_event(stripe_id="evt_002", kind="patrick.got.coffee", livemode=True, message={}) - event = Event.objects.get(stripe_id="evt_002") - self.assertEqual(event.processed, False) - self.assertIsNone(event.validated_message) - - -class InvoicesTests(TestCase): - - @patch("stripe.Invoice.create") - def test_create(self, CreateMock): - invoices.create(Mock()) - self.assertTrue(CreateMock.called) - - @patch("pinax.stripe.actions.invoices.sync_invoice_from_stripe_data") - def test_pay(self, SyncMock): - invoice = Mock() - invoice.paid = False - invoice.closed = False - self.assertTrue(invoices.pay(invoice)) - self.assertTrue(invoice.stripe_invoice.pay.called) - self.assertTrue(SyncMock.called) - - def test_pay_invoice_paid(self): - invoice = Mock() - invoice.paid = True - invoice.closed = False - self.assertFalse(invoices.pay(invoice)) - self.assertFalse(invoice.stripe_invoice.pay.called) - - def test_pay_invoice_closed(self): - invoice = Mock() - invoice.paid = False - invoice.closed = True - self.assertFalse(invoices.pay(invoice)) - self.assertFalse(invoice.stripe_invoice.pay.called) - - @patch("stripe.Invoice.create") - def test_create_and_pay(self, CreateMock): - invoice = CreateMock() - invoice.amount_due = 100 - self.assertTrue(invoices.create_and_pay(Mock())) - self.assertTrue(invoice.pay.called) - - @patch("stripe.Invoice.create") - def test_create_and_pay_amount_due_0(self, CreateMock): - invoice = CreateMock() - invoice.amount_due = 0 - self.assertTrue(invoices.create_and_pay(Mock())) - self.assertFalse(invoice.pay.called) - - @patch("stripe.Invoice.create") - def test_create_and_pay_invalid_request_error(self, CreateMock): - invoice = CreateMock() - invoice.amount_due = 100 - invoice.pay.side_effect = stripe.error.InvalidRequestError("Bad", "error") - self.assertFalse(invoices.create_and_pay(Mock())) - self.assertTrue(invoice.pay.called) - - @patch("stripe.Invoice.create") - def test_create_and_pay_invalid_request_error_on_create(self, CreateMock): - CreateMock.side_effect = stripe.error.InvalidRequestError("Bad", "error") - self.assertFalse(invoices.create_and_pay(Mock())) - - -class RefundsTests(TestCase): - - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Refund.create") - def test_create_amount_none(self, RefundMock, SyncMock): - refunds.create(Mock()) - self.assertTrue(RefundMock.called) - _, kwargs = RefundMock.call_args - self.assertFalse("amount" in kwargs) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.charges.calculate_refund_amount") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Refund.create") - def test_create_with_amount(self, RefundMock, SyncMock, CalcMock): - ChargeMock = Mock() - CalcMock.return_value = decimal.Decimal("10") - refunds.create(ChargeMock, amount=decimal.Decimal("10")) - self.assertTrue(RefundMock.called) - _, kwargs = RefundMock.call_args - self.assertTrue("amount" in kwargs) - self.assertEqual(kwargs["amount"], 1000) - self.assertTrue(SyncMock.called) - - -class SourcesTests(TestCase): - - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - def test_create_card(self, SyncMock): - CustomerMock = Mock() - result = sources.create_card(CustomerMock, token="token") - self.assertTrue(result is not None) - self.assertTrue(CustomerMock.stripe_customer.sources.create.called) - _, kwargs = CustomerMock.stripe_customer.sources.create.call_args - self.assertEqual(kwargs["source"], "token") - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - def test_update_card(self, SyncMock): - CustomerMock = Mock() - SourceMock = CustomerMock.stripe_customer.sources.retrieve() - result = sources.update_card(CustomerMock, "") - self.assertTrue(result is not None) - self.assertTrue(CustomerMock.stripe_customer.sources.retrieve.called) - self.assertTrue(SourceMock.save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - def test_update_card_name_not_none(self, SyncMock): - CustomerMock = Mock() - SourceMock = CustomerMock.stripe_customer.sources.retrieve() - sources.update_card(CustomerMock, "", name="My Visa") - self.assertTrue(CustomerMock.stripe_customer.sources.retrieve.called) - self.assertTrue(SourceMock.save.called) - self.assertEqual(SourceMock.name, "My Visa") - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - def test_update_card_exp_month_not_none(self, SyncMock): - CustomerMock = Mock() - SourceMock = CustomerMock.stripe_customer.sources.retrieve() - sources.update_card(CustomerMock, "", exp_month="My Visa") - self.assertTrue(CustomerMock.stripe_customer.sources.retrieve.called) - self.assertTrue(SourceMock.save.called) - self.assertEqual(SourceMock.exp_month, "My Visa") - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - def test_update_card_exp_year_not_none(self, SyncMock): - CustomerMock = Mock() - SourceMock = CustomerMock.stripe_customer.sources.retrieve() - sources.update_card(CustomerMock, "", exp_year="My Visa") - self.assertTrue(CustomerMock.stripe_customer.sources.retrieve.called) - self.assertTrue(SourceMock.save.called) - self.assertEqual(SourceMock.exp_year, "My Visa") - self.assertTrue(SyncMock.called) - - @skipIf(django.VERSION < (1, 9), "Only for django 1.9+") - def test_delete_card_dj19(self): - CustomerMock = Mock() - result = sources.delete_card(CustomerMock, source="card_token") - self.assertEqual(result, (0, {"pinax_stripe.Card": 0})) - self.assertTrue(CustomerMock.stripe_customer.sources.retrieve().delete.called) - - @skipIf(django.VERSION >= (1, 9), "Only for django before 1.9") - def test_delete_card(self): - CustomerMock = Mock() - result = sources.delete_card(CustomerMock, source="card_token") - self.assertTrue(result is None) - self.assertTrue(CustomerMock.stripe_customer.sources.retrieve().delete.called) - - def test_delete_card_object(self): - User = get_user_model() - user = User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - customer = Customer.objects.create( - user=user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - card = Card.objects.create( - customer=customer, - stripe_id="card_stripe", - address_line_1_check="check", - address_zip_check="check", - country="us", - cvc_check="check", - exp_month=1, - exp_year=2000, - funding="funding", - fingerprint="fingerprint" - ) - pk = card.pk - sources.delete_card_object("card_stripe") - self.assertFalse(Card.objects.filter(pk=pk).exists()) - - def test_delete_card_object_not_card(self): - User = get_user_model() - user = User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - customer = Customer.objects.create( - user=user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - card = Card.objects.create( - customer=customer, - stripe_id="bitcoin_stripe", - address_line_1_check="check", - address_zip_check="check", - country="us", - cvc_check="check", - exp_month=1, - exp_year=2000, - funding="funding", - fingerprint="fingerprint" - ) - pk = card.pk - sources.delete_card_object("bitcoin_stripe") - self.assertTrue(Card.objects.filter(pk=pk).exists()) - - -class SubscriptionsTests(TestCase): - - @classmethod - def setUpClass(cls): - super(SubscriptionsTests, cls).setUpClass() - cls.User = get_user_model() - cls.user = cls.User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - cls.customer = Customer.objects.create( - user=cls.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - cls.plan = Plan.objects.create( - stripe_id="the-plan", - amount=2, - interval_count=1, - ) - cls.account = Account.objects.create(stripe_id="acct_xx") - cls.connected_customer = Customer.objects.create( - stripe_id="cus_yyyyyyyyyyyyyyy", - stripe_account=cls.account, - ) - UserAccount.objects.create(user=cls.user, - customer=cls.connected_customer, - account=cls.account) - - def test_has_active_subscription(self): - plan = Plan.objects.create( - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - Subscription.objects.create( - customer=self.customer, - plan=plan, - quantity=1, - start=timezone.now(), - status="active", - cancel_at_period_end=False - ) - self.assertTrue(subscriptions.has_active_subscription(self.customer)) - - def test_has_active_subscription_False_no_subscription(self): - self.assertFalse(subscriptions.has_active_subscription(self.customer)) - - def test_has_active_subscription_False_expired(self): - plan = Plan.objects.create( - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - Subscription.objects.create( - customer=self.customer, - plan=plan, - quantity=1, - start=timezone.now(), - status="active", - cancel_at_period_end=False, - ended_at=timezone.now() - datetime.timedelta(days=3) - ) - self.assertFalse(subscriptions.has_active_subscription(self.customer)) - - def test_has_active_subscription_ended_but_not_expired(self): - plan = Plan.objects.create( - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - Subscription.objects.create( - customer=self.customer, - plan=plan, - quantity=1, - start=timezone.now(), - status="active", - cancel_at_period_end=False, - ended_at=timezone.now() + datetime.timedelta(days=3) - ) - self.assertTrue(subscriptions.has_active_subscription(self.customer)) - - @patch("stripe.Subscription") - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_cancel_subscription(self, SyncMock, StripeSubMock): - subscription = Subscription(stripe_id="sub_X", customer=self.customer) - obj = object() - SyncMock.return_value = obj - sub = subscriptions.cancel(subscription) - self.assertIs(sub, obj) - self.assertTrue(SyncMock.called) - _, kwargs = StripeSubMock.call_args - self.assertEqual(kwargs["stripe_account"], None) - - @patch("stripe.Subscription") - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_cancel_subscription_with_account(self, SyncMock, StripeSubMock): - subscription = Subscription(stripe_id="sub_X", customer=self.connected_customer) - subscriptions.cancel(subscription) - _, kwargs = StripeSubMock.call_args - self.assertEqual(kwargs["stripe_account"], self.account.stripe_id) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_update(self, SyncMock): - SubMock = Mock() - SubMock.customer = self.customer - obj = object() - SyncMock.return_value = obj - sub = subscriptions.update(SubMock) - self.assertIs(sub, obj) - self.assertTrue(SubMock.stripe_subscription.save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_update_plan(self, SyncMock): - SubMock = Mock() - SubMock.customer = self.customer - subscriptions.update(SubMock, plan="test_value") - self.assertEqual(SubMock.stripe_subscription.plan, "test_value") - self.assertTrue(SubMock.stripe_subscription.save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_update_plan_quantity(self, SyncMock): - SubMock = Mock() - SubMock.customer = self.customer - subscriptions.update(SubMock, quantity="test_value") - self.assertEqual(SubMock.stripe_subscription.quantity, "test_value") - self.assertTrue(SubMock.stripe_subscription.save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_update_plan_prorate(self, SyncMock): - SubMock = Mock() - SubMock.customer = self.customer - subscriptions.update(SubMock, prorate=False) - self.assertEqual(SubMock.stripe_subscription.prorate, False) - self.assertTrue(SubMock.stripe_subscription.save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_update_plan_coupon(self, SyncMock): - SubMock = Mock() - SubMock.customer = self.customer - subscriptions.update(SubMock, coupon="test_value") - self.assertEqual(SubMock.stripe_subscription.coupon, "test_value") - self.assertTrue(SubMock.stripe_subscription.save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_update_plan_charge_now(self, SyncMock): - SubMock = Mock() - SubMock.customer = self.customer - SubMock.stripe_subscription.trial_end = time.time() + 1000000.0 - - subscriptions.update(SubMock, charge_immediately=True) - self.assertEqual(SubMock.stripe_subscription.trial_end, "now") - self.assertTrue(SubMock.stripe_subscription.save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_update_plan_charge_now_old_trial(self, SyncMock): - trial_end = time.time() - 1000000.0 - SubMock = Mock() - SubMock.customer = self.customer - SubMock.stripe_subscription.trial_end = trial_end - - subscriptions.update(SubMock, charge_immediately=True) - # Trial end date hasn't changed - self.assertEqual(SubMock.stripe_subscription.trial_end, trial_end) - self.assertTrue(SubMock.stripe_subscription.save.called) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("stripe.Subscription.create") - def test_subscription_create(self, SubscriptionCreateMock, SyncMock): - subscriptions.create(self.customer, "the-plan") - self.assertTrue(SyncMock.called) - self.assertTrue(SubscriptionCreateMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("stripe.Subscription.create") - def test_subscription_create_with_trial(self, SubscriptionCreateMock, SyncMock): - subscriptions.create(self.customer, "the-plan", trial_days=3) - self.assertTrue(SubscriptionCreateMock.called) - _, kwargs = SubscriptionCreateMock.call_args - self.assertEqual(kwargs["trial_end"].date(), (datetime.datetime.utcnow() + datetime.timedelta(days=3)).date()) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("stripe.Subscription.create") - def test_subscription_create_token(self, SubscriptionCreateMock, CustomerMock): - subscriptions.create(self.customer, "the-plan", token="token") - self.assertTrue(SubscriptionCreateMock.called) - _, kwargs = SubscriptionCreateMock.call_args - self.assertEqual(kwargs["source"], "token") - - @patch("stripe.Subscription.create") - def test_subscription_create_with_connect(self, SubscriptionCreateMock): - SubscriptionCreateMock.return_value = { - "object": "subscription", - "id": "sub_XX", - "application_fee_percent": None, - "cancel_at_period_end": False, - "canceled_at": None, - "current_period_start": 1509978774, - "current_period_end": 1512570774, - "ended_at": None, - "quantity": 1, - "start": 1509978774, - "status": "active", - "trial_start": None, - "trial_end": None, - "plan": { - "id": self.plan.stripe_id, - }} - subscriptions.create(self.connected_customer, self.plan.stripe_id) - SubscriptionCreateMock.assert_called_once_with( - coupon=None, - customer=self.connected_customer.stripe_id, - plan="the-plan", - quantity=4, - stripe_account="acct_xx", - tax_percent=None) - subscription = Subscription.objects.get() - self.assertEqual(subscription.customer, self.connected_customer) - - @patch("stripe.Subscription.retrieve") - @patch("stripe.Subscription.create") - def test_retrieve_subscription_with_connect(self, CreateMock, RetrieveMock): - CreateMock.return_value = { - "object": "subscription", - "id": "sub_XX", - "application_fee_percent": None, - "cancel_at_period_end": False, - "canceled_at": None, - "current_period_start": 1509978774, - "current_period_end": 1512570774, - "ended_at": None, - "quantity": 1, - "start": 1509978774, - "status": "active", - "trial_start": None, - "trial_end": None, - "plan": { - "id": self.plan.stripe_id, - }} - subscriptions.create(self.connected_customer, self.plan.stripe_id) - subscriptions.retrieve(self.connected_customer, "sub_XX") - RetrieveMock.assert_called_once_with("sub_XX", stripe_account=self.account.stripe_id) - - def test_is_period_current(self): - sub = Subscription(current_period_end=(timezone.now() + datetime.timedelta(days=2))) - self.assertTrue(subscriptions.is_period_current(sub)) - - def test_is_period_current_false(self): - sub = Subscription(current_period_end=(timezone.now() - datetime.timedelta(days=2))) - self.assertFalse(subscriptions.is_period_current(sub)) - - def test_is_status_current(self): - sub = Subscription(status="trialing") - self.assertTrue(subscriptions.is_status_current(sub)) - - def test_is_status_current_false(self): - sub = Subscription(status="canceled") - self.assertFalse(subscriptions.is_status_current(sub)) - - def test_is_valid(self): - sub = Subscription(status="trialing") - self.assertTrue(subscriptions.is_valid(sub)) - - def test_is_valid_false(self): - sub = Subscription(status="canceled") - self.assertFalse(subscriptions.is_valid(sub)) - - def test_is_valid_false_canceled(self): - sub = Subscription(status="trialing", cancel_at_period_end=True, current_period_end=(timezone.now() - datetime.timedelta(days=2))) - self.assertFalse(subscriptions.is_valid(sub)) - - -class SyncsTests(TestCase): - - def setUp(self): - self.User = get_user_model() - self.user = self.User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - self.customer = Customer.objects.create( - user=self.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - - @patch("stripe.Plan.auto_paging_iter", create=True) - def test_sync_plans(self, PlanAutoPagerMock): - PlanAutoPagerMock.return_value = [ - { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": {}, - "name": "The Pro Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - { - "id": "simple1", - "object": "plan", - "amount": 999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": {}, - "name": "The Simple Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - ] - plans.sync_plans() - self.assertTrue(Plan.objects.all().count(), 2) - self.assertEqual(Plan.objects.get(stripe_id="simple1").amount, decimal.Decimal("9.99")) - - @patch("stripe.Plan.auto_paging_iter", create=True) - def test_sync_plans_update(self, PlanAutoPagerMock): - PlanAutoPagerMock.return_value = [ - { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": {}, - "name": "The Pro Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - { - "id": "simple1", - "object": "plan", - "amount": 999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": {}, - "name": "The Simple Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - ] - plans.sync_plans() - self.assertTrue(Plan.objects.all().count(), 2) - self.assertEqual(Plan.objects.get(stripe_id="simple1").amount, decimal.Decimal("9.99")) - PlanAutoPagerMock.return_value[1].update({"amount": 499}) - plans.sync_plans() - self.assertEqual(Plan.objects.get(stripe_id="simple1").amount, decimal.Decimal("4.99")) - - def test_sync_plan(self): - """ - Test that a single Plan is updated - """ - Plan.objects.create( - stripe_id="pro2", - name="Plan Plan", - interval="month", - interval_count=1, - amount=decimal.Decimal("19.99") - ) - plan = { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": {}, - "name": "Gold Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - } - plans.sync_plan(plan) - self.assertTrue(Plan.objects.all().count(), 1) - self.assertEqual(Plan.objects.get(stripe_id="pro2").name, plan["name"]) - - def test_sync_payment_source_from_stripe_data_card(self): - source = { - "id": "card_17AMEBI10iPhvocM1LnJ0dBc", - "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": "MasterCard", - "country": "US", - "customer": "cus_7PAYYALEwPuDJE", - "cvc_check": "pass", - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2018, - "funding": "credit", - "last4": "4444", - "metadata": { - }, - "name": None, - "tokenization_method": None, - "fingerprint": "xyz" - } - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertEqual(Card.objects.get(stripe_id=source["id"]).exp_year, 2018) - - def test_sync_payment_source_from_stripe_data_card_blank_cvc_check(self): - source = { - "id": "card_17AMEBI10iPhvocM1LnJ0dBc", - "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": "MasterCard", - "country": "US", - "customer": "cus_7PAYYALEwPuDJE", - "cvc_check": None, - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2018, - "funding": "credit", - "last4": "4444", - "metadata": { - }, - "name": None, - "tokenization_method": None, - "fingerprint": "xyz" - } - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertEqual(Card.objects.get(stripe_id=source["id"]).cvc_check, "") - - def test_sync_payment_source_from_stripe_data_card_blank_country(self): - source = { - "id": "card_17AMEBI10iPhvocM1LnJ0dBc", - "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": "MasterCard", - "country": None, - "customer": "cus_7PAYYALEwPuDJE", - "cvc_check": "pass", - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2018, - "funding": "credit", - "last4": "4444", - "metadata": { - }, - "name": None, - "tokenization_method": None, - "fingerprint": "xyz" - } - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertEqual(Card.objects.get(stripe_id=source["id"]).country, "") - - def test_sync_payment_source_from_stripe_data_card_updated(self): - source = { - "id": "card_17AMEBI10iPhvocM1LnJ0dBc", - "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": "MasterCard", - "country": "US", - "customer": "cus_7PAYYALEwPuDJE", - "cvc_check": "pass", - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2018, - "funding": "credit", - "last4": "4444", - "metadata": { - }, - "name": None, - "tokenization_method": None, - "fingerprint": "xyz" - } - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertEqual(Card.objects.get(stripe_id=source["id"]).exp_year, 2018) - source.update({"exp_year": 2022}) - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertEqual(Card.objects.get(stripe_id=source["id"]).exp_year, 2022) - - def test_sync_payment_source_from_stripe_data_source_card(self): - source = { - "id": "src_123", - "object": "source", - "amount": None, - "client_secret": "src_client_secret_123", - "created": 1483575790, - "currency": None, - "flow": "none", - "livemode": False, - "metadata": {}, - "owner": { - "address": None, - "email": None, - "name": None, - "phone": None, - "verified_address": None, - "verified_email": None, - "verified_name": None, - "verified_phone": None, - }, - "status": "chargeable", - "type": "card", - "usage": "reusable", - "card": { - "brand": "Visa", - "country": "US", - "exp_month": 12, - "exp_year": 2034, - "funding": "debit", - "last4": "5556", - "three_d_secure": "not_supported" - } - } - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertFalse(Card.objects.exists()) - - def test_sync_payment_source_from_stripe_data_bitcoin(self): - source = { - "id": "btcrcv_17BE32I10iPhvocMqViUU1w4", - "object": "bitcoin_receiver", - "active": False, - "amount": 100, - "amount_received": 0, - "bitcoin_amount": 1757908, - "bitcoin_amount_received": 0, - "bitcoin_uri": "bitcoin:test_7i9Fo4b5wXcUAuoVBFrc7nc9HDxD1?amount=0.01757908", - "created": 1448499344, - "currency": "usd", - "description": "Receiver for John Doe", - "email": "test@example.com", - "filled": False, - "inbound_address": "test_7i9Fo4b5wXcUAuoVBFrc7nc9HDxD1", - "livemode": False, - "metadata": { - }, - "refund_address": None, - "uncaptured_funds": False, - "used_for_payment": False - } - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertEqual(BitcoinReceiver.objects.get(stripe_id=source["id"]).bitcoin_amount, 1757908) - - def test_sync_payment_source_from_stripe_data_bitcoin_updated(self): - source = { - "id": "btcrcv_17BE32I10iPhvocMqViUU1w4", - "object": "bitcoin_receiver", - "active": False, - "amount": 100, - "amount_received": 0, - "bitcoin_amount": 1757908, - "bitcoin_amount_received": 0, - "bitcoin_uri": "bitcoin:test_7i9Fo4b5wXcUAuoVBFrc7nc9HDxD1?amount=0.01757908", - "created": 1448499344, - "currency": "usd", - "description": "Receiver for John Doe", - "email": "test@example.com", - "filled": False, - "inbound_address": "test_7i9Fo4b5wXcUAuoVBFrc7nc9HDxD1", - "livemode": False, - "metadata": { - }, - "refund_address": None, - "uncaptured_funds": False, - "used_for_payment": False - } - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertEqual(BitcoinReceiver.objects.get(stripe_id=source["id"]).bitcoin_amount, 1757908) - source.update({"bitcoin_amount": 1886800}) - sources.sync_payment_source_from_stripe_data(self.customer, source) - self.assertEqual(BitcoinReceiver.objects.get(stripe_id=source["id"]).bitcoin_amount, 1886800) - - def test_sync_subscription_from_stripe_data(self): - Plan.objects.create(stripe_id="pro2", interval="month", interval_count=1, amount=decimal.Decimal("19.99")) - subscription = { - "id": "sub_7Q4BX0HMfqTpN8", - "object": "subscription", - "application_fee_percent": None, - "cancel_at_period_end": False, - "canceled_at": None, - "current_period_end": 1448758544, - "current_period_start": 1448499344, - "customer": self.customer.stripe_id, - "discount": None, - "ended_at": None, - "metadata": { - }, - "plan": { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": { - }, - "name": "The Pro Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - "quantity": 1, - "start": 1448499344, - "status": "trialing", - "tax_percent": None, - "trial_end": 1448758544, - "trial_start": 1448499344 - } - sub = subscriptions.sync_subscription_from_stripe_data(self.customer, subscription) - self.assertEqual(Subscription.objects.get(stripe_id=subscription["id"]), sub) - self.assertEqual(sub.status, "trialing") - - def test_sync_subscription_from_stripe_data_updated(self): - Plan.objects.create(stripe_id="pro2", interval="month", interval_count=1, amount=decimal.Decimal("19.99")) - subscription = { - "id": "sub_7Q4BX0HMfqTpN8", - "object": "subscription", - "application_fee_percent": None, - "cancel_at_period_end": False, - "canceled_at": None, - "current_period_end": 1448758544, - "current_period_start": 1448499344, - "customer": self.customer.stripe_id, - "discount": None, - "ended_at": None, - "metadata": { - }, - "plan": { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": { - }, - "name": "The Pro Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - "quantity": 1, - "start": 1448499344, - "status": "trialing", - "tax_percent": None, - "trial_end": 1448758544, - "trial_start": 1448499344 - } - subscriptions.sync_subscription_from_stripe_data(self.customer, subscription) - self.assertEqual(Subscription.objects.get(stripe_id=subscription["id"]).status, "trialing") - subscription.update({"status": "active"}) - subscriptions.sync_subscription_from_stripe_data(self.customer, subscription) - self.assertEqual(Subscription.objects.get(stripe_id=subscription["id"]).status, "active") - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - @patch("stripe.Customer.retrieve") - def test_sync_customer(self, RetreiveMock, SyncPaymentSourceMock, SyncSubscriptionMock): - RetreiveMock.return_value = dict( - account_balance=1999, - currency="usd", - delinquent=False, - default_source=None, - sources=dict(data=[Mock()]), - subscriptions=dict(data=[Mock()]) - ) - customers.sync_customer(self.customer) - customer = Customer.objects.get(user=self.user) - self.assertEqual(customer.account_balance, decimal.Decimal("19.99")) - self.assertEqual(customer.currency, "usd") - self.assertEqual(customer.delinquent, False) - self.assertEqual(customer.default_source, "") - self.assertTrue(SyncPaymentSourceMock.called) - self.assertTrue(SyncSubscriptionMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - def test_sync_customer_no_cu_provided(self, SyncPaymentSourceMock, SyncSubscriptionMock): - cu = dict( - account_balance=1999, - currency="usd", - delinquent=False, - default_source=None, - sources=dict(data=[Mock()]), - subscriptions=dict(data=[Mock()]) - ) - customers.sync_customer(self.customer, cu=cu) - customer = Customer.objects.get(user=self.user) - self.assertEqual(customer.account_balance, decimal.Decimal("19.99")) - self.assertEqual(customer.currency, "usd") - self.assertEqual(customer.delinquent, False) - self.assertEqual(customer.default_source, "") - self.assertTrue(SyncPaymentSourceMock.called) - self.assertTrue(SyncSubscriptionMock.called) - - @patch("pinax.stripe.actions.customers.purge_local") - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - @patch("stripe.Customer.retrieve") - def test_sync_customer_purged_locally(self, RetrieveMock, SyncPaymentSourceMock, SyncSubscriptionMock, PurgeLocalMock): - self.customer.date_purged = timezone.now() - customers.sync_customer(self.customer) - self.assertFalse(RetrieveMock.called) - self.assertFalse(SyncPaymentSourceMock.called) - self.assertFalse(SyncSubscriptionMock.called) - self.assertFalse(PurgeLocalMock.called) - - @patch("pinax.stripe.actions.customers.purge_local") - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - @patch("stripe.Customer.retrieve") - def test_sync_customer_purged_remotely_not_locally(self, RetrieveMock, SyncPaymentSourceMock, SyncSubscriptionMock, PurgeLocalMock): - RetrieveMock.return_value = dict( - deleted=True - ) - customers.sync_customer(self.customer) - self.assertFalse(SyncPaymentSourceMock.called) - self.assertFalse(SyncSubscriptionMock.called) - self.assertTrue(PurgeLocalMock.called) - - @patch("pinax.stripe.actions.invoices.sync_invoice_from_stripe_data") - @patch("stripe.Customer.retrieve") - def test_sync_invoices_for_customer(self, RetreiveMock, SyncMock): - RetreiveMock().invoices().data = [Mock()] - invoices.sync_invoices_for_customer(self.customer) - self.assertTrue(SyncMock.called) - - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("stripe.Customer.retrieve") - def test_sync_charges_for_customer(self, RetreiveMock, SyncMock): - RetreiveMock().charges().data = [Mock()] - charges.sync_charges_for_customer(self.customer) - self.assertTrue(SyncMock.called) - - def test_sync_charge_from_stripe_data(self): - data = { - "id": "ch_17A1dUI10iPhvocMOecpvQlI", - "object": "charge", - "amount": 200, - "amount_refunded": 0, - "application_fee": None, - "balance_transaction": "txn_179l3zI10iPhvocMhvKxAer7", - "captured": True, - "created": 1448213304, - "currency": "usd", - "customer": self.customer.stripe_id, - "description": None, - "destination": None, - "dispute": None, - "failure_code": None, - "failure_message": None, - "fraud_details": { - }, - "invoice": "in_17A1dUI10iPhvocMSGtIfUDF", - "livemode": False, - "metadata": { - }, - "paid": True, - "receipt_email": None, - "receipt_number": None, - "refunded": False, - "refunds": { - "object": "list", - "data": [ - - ], - "has_more": False, - "total_count": 0, - "url": "/v1/charges/ch_17A1dUI10iPhvocMOecpvQlI/refunds" - }, - "shipping": None, - "source": { - "id": "card_179o0lI10iPhvocMZgdPiR5M", - "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": "cus_7ObCqsp1NGVT6o", - "cvc_check": None, - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2019, - "funding": "credit", - "last4": "4242", - "metadata": { - }, - "name": None, - "tokenization_method": None - }, - "statement_descriptor": "A descriptor", - "status": "succeeded" - } - charges.sync_charge_from_stripe_data(data) - charge = Charge.objects.get(customer=self.customer, stripe_id=data["id"]) - self.assertEqual(charge.amount, decimal.Decimal("2")) - - def test_sync_charge_from_stripe_data_balance_transaction(self): - data = { - "id": "ch_17A1dUI10iPhvocMOecpvQlI", - "object": "charge", - "amount": 200, - "amount_refunded": 0, - "application_fee": None, - "balance_transaction": { - "id": "txn_19XJJ02eZvKYlo2ClwuJ1rbA", - "object": "balance_transaction", - "amount": 999, - "available_on": 1483920000, - "created": 1483315442, - "currency": "usd", - "description": None, - "fee": 59, - "fee_details": [ - { - "amount": 59, - "application": None, - "currency": "usd", - "description": "Stripe processing fees", - "type": "stripe_fee" - } - ], - "net": 940, - "source": "ch_19XJJ02eZvKYlo2CHfSUsSpl", - "status": "pending", - "type": "charge" - }, - "captured": True, - "created": 1448213304, - "currency": "usd", - "customer": self.customer.stripe_id, - "description": None, - "destination": None, - "dispute": None, - "failure_code": None, - "failure_message": None, - "fraud_details": { - }, - "invoice": "in_17A1dUI10iPhvocMSGtIfUDF", - "livemode": False, - "metadata": { - }, - "paid": True, - "receipt_email": None, - "receipt_number": None, - "refunded": False, - "refunds": { - "object": "list", - "data": [ - - ], - "has_more": False, - "total_count": 0, - "url": "/v1/charges/ch_17A1dUI10iPhvocMOecpvQlI/refunds" - }, - "shipping": None, - "source": { - "id": "card_179o0lI10iPhvocMZgdPiR5M", - "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": "cus_7ObCqsp1NGVT6o", - "cvc_check": None, - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2019, - "funding": "credit", - "last4": "4242", - "metadata": { - }, - "name": None, - "tokenization_method": None - }, - "statement_descriptor": "A descriptor", - "status": "succeeded" - } - charges.sync_charge_from_stripe_data(data) - charge = Charge.objects.get(customer=self.customer, stripe_id=data["id"]) - self.assertEqual(charge.amount, decimal.Decimal("2")) - self.assertEqual(charge.available, False) - self.assertEqual(charge.fee, decimal.Decimal("0.59")) - self.assertEqual(charge.currency, "usd") - - def test_sync_charge_from_stripe_data_description(self): - data = { - "id": "ch_17A1dUI10iPhvocMOecpvQlI", - "object": "charge", - "amount": 200, - "amount_refunded": 0, - "application_fee": None, - "balance_transaction": "txn_179l3zI10iPhvocMhvKxAer7", - "captured": True, - "created": 1448213304, - "currency": "usd", - "customer": self.customer.stripe_id, - "description": "This was a charge for awesome.", - "destination": None, - "dispute": None, - "failure_code": None, - "failure_message": None, - "fraud_details": { - }, - "invoice": "in_17A1dUI10iPhvocMSGtIfUDF", - "livemode": False, - "metadata": { - }, - "paid": True, - "receipt_email": None, - "receipt_number": None, - "refunded": False, - "refunds": { - "object": "list", - "data": [ - - ], - "has_more": False, - "total_count": 0, - "url": "/v1/charges/ch_17A1dUI10iPhvocMOecpvQlI/refunds" - }, - "shipping": None, - "source": { - "id": "card_179o0lI10iPhvocMZgdPiR5M", - "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": "cus_7ObCqsp1NGVT6o", - "cvc_check": None, - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2019, - "funding": "credit", - "last4": "4242", - "metadata": { - }, - "name": None, - "tokenization_method": None - }, - "statement_descriptor": "A descriptor", - "status": "succeeded" - } - charges.sync_charge_from_stripe_data(data) - charge = Charge.objects.get(customer=self.customer, stripe_id=data["id"]) - self.assertEqual(charge.amount, decimal.Decimal("2")) - self.assertEqual(charge.description, "This was a charge for awesome.") - - def test_sync_charge_from_stripe_data_amount_refunded(self): - data = { - "id": "ch_17A1dUI10iPhvocMOecpvQlI", - "object": "charge", - "amount": 200, - "amount_refunded": 10000, - "application_fee": None, - "balance_transaction": "txn_179l3zI10iPhvocMhvKxAer7", - "captured": True, - "created": 1448213304, - "currency": "usd", - "customer": self.customer.stripe_id, - "description": None, - "destination": None, - "dispute": None, - "failure_code": None, - "failure_message": None, - "fraud_details": { - }, - "invoice": "in_17A1dUI10iPhvocMSGtIfUDF", - "livemode": False, - "metadata": { - }, - "paid": True, - "receipt_email": None, - "receipt_number": None, - "refunded": False, - "refunds": { - "object": "list", - "data": [ - - ], - "has_more": False, - "total_count": 0, - "url": "/v1/charges/ch_17A1dUI10iPhvocMOecpvQlI/refunds" - }, - "shipping": None, - "source": { - "id": "card_179o0lI10iPhvocMZgdPiR5M", - "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": "cus_7ObCqsp1NGVT6o", - "cvc_check": None, - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2019, - "funding": "credit", - "last4": "4242", - "metadata": { - }, - "name": None, - "tokenization_method": None - }, - "statement_descriptor": "A descriptor", - "status": "succeeded" - } - charges.sync_charge_from_stripe_data(data) - charge = Charge.objects.get(customer=self.customer, stripe_id=data["id"]) - self.assertEqual(charge.amount, decimal.Decimal("2")) - self.assertEqual(charge.amount_refunded, decimal.Decimal("100")) - - def test_sync_charge_from_stripe_data_refunded(self): - data = { - "id": "ch_17A1dUI10iPhvocMOecpvQlI", - "object": "charge", - "amount": 200, - "amount_refunded": 0, - "application_fee": None, - "balance_transaction": "txn_179l3zI10iPhvocMhvKxAer7", - "captured": True, - "created": 1448213304, - "currency": "usd", - "customer": self.customer.stripe_id, - "description": None, - "destination": None, - "dispute": None, - "failure_code": None, - "failure_message": None, - "fraud_details": { - }, - "invoice": "in_17A1dUI10iPhvocMSGtIfUDF", - "livemode": False, - "metadata": { - }, - "paid": True, - "receipt_email": None, - "receipt_number": None, - "refunded": True, - "refunds": { - "object": "list", - "data": [ - - ], - "has_more": False, - "total_count": 0, - "url": "/v1/charges/ch_17A1dUI10iPhvocMOecpvQlI/refunds" - }, - "shipping": None, - "source": { - "id": "card_179o0lI10iPhvocMZgdPiR5M", - "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": "cus_7ObCqsp1NGVT6o", - "cvc_check": None, - "dynamic_last4": None, - "exp_month": 10, - "exp_year": 2019, - "funding": "credit", - "last4": "4242", - "metadata": { - }, - "name": None, - "tokenization_method": None - }, - "statement_descriptor": "A descriptor", - "status": "succeeded" - } - charges.sync_charge_from_stripe_data(data) - charge = Charge.objects.get(customer=self.customer, stripe_id=data["id"]) - self.assertEqual(charge.amount, decimal.Decimal("2")) - self.assertEqual(charge.refunded, True) - - def test_sync_charge_from_stripe_data_failed(self): - data = { - "id": "ch_xxxxxxxxxxxxxxxxxxxxxxxx", - "object": "charge", - "amount": 200, - "amount_refunded": 0, - "application": None, - "application_fee": None, - "balance_transaction": None, - "captured": False, - "created": 1488208611, - "currency": "usd", - "customer": None, - "description": None, - "destination": None, - "dispute": None, - "failure_code": "card_declined", - "failure_message": "Your card was declined.", - "fraud_details": {}, - "invoice": None, - "livemode": False, - "metadata": {}, - "on_behalf_of": None, - "order": None, - "outcome": { - "network_status": "declined_by_network", - "reason": "generic_decline", - "risk_level": "normal", - "seller_message": "The bank did not return any further details with this decline.", - "type": "issuer_declined" - }, - "paid": False, - "receipt_email": None, - "receipt_number": None, - "refunded": False, - "refunds": { - "object": "list", - "data": [], - "has_more": False, - "total_count": 0, - "url": "/v1/charges/ch_xxxxxxxxxxxxxxxxxxxxxxxx/refunds" - }, - "review": None, - "shipping": None, - "source": { - "id": "card_xxxxxxxxxxxxxxxxxxxxxxxx", - "object": "card", - "address_city": None, - "address_country": None, - "address_line1": None, - "address_line1_check": None, - "address_line2": None, - "address_state": None, - "address_zip": "424", - "address_zip_check": "pass", - "brand": "Visa", - "country": "US", - "customer": None, - "cvc_check": "pass", - "dynamic_last4": None, - "exp_month": 4, - "exp_year": 2024, - "fingerprint": "xxxxxxxxxxxxxxxx", - "funding": "credit", - "last4": "0341", - "metadata": {}, - "name": "example@example.com", - "tokenization_method": None - }, - "source_transfer": None, - "statement_descriptor": None, - "status": "failed", - "transfer_group": None - } - charges.sync_charge_from_stripe_data(data) - charge = Charge.objects.get(stripe_id=data["id"]) - self.assertEqual(charge.amount, decimal.Decimal("2")) - self.assertEqual(charge.customer, None) - self.assertEqual(charge.outcome["risk_level"], "normal") - - @patch("stripe.Subscription.retrieve") - def test_retrieve_stripe_subscription(self, RetrieveMock): - RetrieveMock.return_value = stripe.Subscription( - customer="cus_xxxxxxxxxxxxxxx" - ) - value = subscriptions.retrieve(self.customer, "sub id") - self.assertEqual(value, RetrieveMock.return_value) - - def test_retrieve_stripe_subscription_no_sub_id(self): - value = subscriptions.retrieve(self.customer, None) - self.assertIsNone(value) - - @patch("stripe.Subscription.retrieve") - def test_retrieve_stripe_subscription_diff_customer(self, RetrieveMock): - class Subscription: - customer = "cus_xxxxxxxxxxxxZZZ" - - RetrieveMock.return_value = Subscription() - - value = subscriptions.retrieve(self.customer, "sub_id") - self.assertIsNone(value) - - @patch("stripe.Subscription.retrieve") - def test_retrieve_stripe_subscription_missing_subscription(self, RetrieveMock): - RetrieveMock.return_value = None - value = subscriptions.retrieve(self.customer, "sub id") - self.assertIsNone(value) - - @patch("stripe.Subscription.retrieve") - def test_retrieve_stripe_subscription_invalid_request(self, RetrieveMock): - def bad_request(*args, **kwargs): - raise stripe.error.InvalidRequestError("Bad", "error") - RetrieveMock.side_effect = bad_request - with self.assertRaises(stripe.error.InvalidRequestError): - subscriptions.retrieve(self.customer, "sub id") - - def test_sync_invoice_items(self): - plan = Plan.objects.create(stripe_id="pro2", interval="month", interval_count=1, amount=decimal.Decimal("19.99")) - subscription = Subscription.objects.create( - stripe_id="sub_7Q4BX0HMfqTpN8", - customer=self.customer, - plan=plan, - quantity=1, - status="active", - start=timezone.now() - ) - invoice = Invoice.objects.create( - stripe_id="inv_001", - customer=self.customer, - amount_due=100, - period_end=timezone.now(), - period_start=timezone.now(), - subtotal=100, - total=100, - date=timezone.now(), - subscription=subscription - ) - items = [{ - "id": subscription.stripe_id, - "object": "line_item", - "amount": 0, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "plan": { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": { - }, - "name": "The Pro Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription" - }] - invoices.sync_invoice_items(invoice, items) - self.assertTrue(invoice.items.all().count(), 1) - - def test_sync_invoice_items_no_plan(self): - plan = Plan.objects.create(stripe_id="pro2", interval="month", interval_count=1, amount=decimal.Decimal("19.99")) - subscription = Subscription.objects.create( - stripe_id="sub_7Q4BX0HMfqTpN8", - customer=self.customer, - plan=plan, - quantity=1, - status="active", - start=timezone.now() - ) - invoice = Invoice.objects.create( - stripe_id="inv_001", - customer=self.customer, - amount_due=100, - period_end=timezone.now(), - period_start=timezone.now(), - subtotal=100, - total=100, - date=timezone.now(), - subscription=subscription - ) - items = [{ - "id": subscription.stripe_id, - "object": "line_item", - "amount": 0, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription" - }] - invoices.sync_invoice_items(invoice, items) - self.assertTrue(invoice.items.all().count(), 1) - self.assertEqual(invoice.items.all()[0].plan, plan) - - def test_sync_invoice_items_type_not_subscription(self): - invoice = Invoice.objects.create( - stripe_id="inv_001", - customer=self.customer, - amount_due=100, - period_end=timezone.now(), - period_start=timezone.now(), - subtotal=100, - total=100, - date=timezone.now() - ) - items = [{ - "id": "ii_23lkj2lkj", - "object": "line_item", - "amount": 2000, - "currency": "usd", - "description": "Something random", - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "line_item" - }] - invoices.sync_invoice_items(invoice, items) - self.assertTrue(invoice.items.all().count(), 1) - self.assertEqual(invoice.items.all()[0].description, "Something random") - self.assertEqual(invoice.items.all()[0].amount, decimal.Decimal("20")) - - @patch("pinax.stripe.actions.subscriptions.retrieve") - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - def test_sync_invoice_items_different_stripe_id_than_invoice(self, SyncMock, RetrieveSubscriptionMock): # two subscriptions on invoice? - Plan.objects.create(stripe_id="simple", interval="month", interval_count=1, amount=decimal.Decimal("9.99")) - plan = Plan.objects.create(stripe_id="pro2", interval="month", interval_count=1, amount=decimal.Decimal("19.99")) - subscription = Subscription.objects.create( - stripe_id="sub_7Q4BX0HMfqTpN8", - customer=self.customer, - plan=plan, - quantity=1, - status="active", - start=timezone.now() - ) - invoice = Invoice.objects.create( - stripe_id="inv_001", - customer=self.customer, - amount_due=100, - period_end=timezone.now(), - period_start=timezone.now(), - subtotal=100, - total=100, - date=timezone.now(), - subscription=subscription - ) - SyncMock.return_value = subscription - items = [{ - "id": subscription.stripe_id, - "object": "line_item", - "amount": 0, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "plan": { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": { - }, - "name": "The Pro Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription" - }, { - "id": "sub_7Q4BX0HMfqTpN9", - "object": "line_item", - "amount": 0, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "plan": { - "id": "simple", - "object": "plan", - "amount": 999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": { - }, - "name": "The Simple Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription" - }] - invoices.sync_invoice_items(invoice, items) - self.assertTrue(invoice.items.all().count(), 2) - - @patch("pinax.stripe.actions.subscriptions.retrieve") - def test_sync_invoice_items_updating(self, RetrieveSubscriptionMock): - RetrieveSubscriptionMock.return_value = None - Plan.objects.create(stripe_id="simple", interval="month", interval_count=1, amount=decimal.Decimal("9.99")) - plan = Plan.objects.create(stripe_id="pro2", interval="month", interval_count=1, amount=decimal.Decimal("19.99")) - subscription = Subscription.objects.create( - stripe_id="sub_7Q4BX0HMfqTpN8", - customer=self.customer, - plan=plan, - quantity=1, - status="active", - start=timezone.now() - ) - invoice = Invoice.objects.create( - stripe_id="inv_001", - customer=self.customer, - amount_due=100, - period_end=timezone.now(), - period_start=timezone.now(), - subtotal=100, - total=100, - date=timezone.now(), - subscription=subscription - ) - items = [{ - "id": subscription.stripe_id, - "object": "line_item", - "amount": 0, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "plan": { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": { - }, - "name": "The Pro Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription" - }, { - "id": "sub_7Q4BX0HMfqTpN9", - "object": "line_item", - "amount": 0, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "plan": { - "id": "simple", - "object": "plan", - "amount": 999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": { - }, - "name": "The Simple Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription" - }] - invoices.sync_invoice_items(invoice, items) - self.assertEqual(invoice.items.count(), 2) - - items[1].update({"description": "This is your second subscription"}) - invoices.sync_invoice_items(invoice, items) - self.assertEqual(invoice.items.count(), 2) - self.assertEqual(invoice.items.get(stripe_id="sub_7Q4BX0HMfqTpN9").description, "This is your second subscription") - - -class InvoiceSyncsTests(TestCase): - - def setUp(self): - self.User = get_user_model() - self.user = self.User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - self.customer = Customer.objects.create( - user=self.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - - plan = Plan.objects.create(stripe_id="pro2", interval="month", interval_count=1, amount=decimal.Decimal("19.99")) - self.subscription = Subscription.objects.create( - stripe_id="sub_7Q4BX0HMfqTpN8", - customer=self.customer, - plan=plan, - quantity=1, - status="active", - start=timezone.now() - ) - self.invoice_data = { - "id": "in_17B6e8I10iPhvocMGtYd4hDD", - "object": "invoice", - "amount_due": 1999, - "application_fee": None, - "attempt_count": 0, - "attempted": False, - "charge": None, - "closed": False, - "currency": "usd", - "customer": self.customer.stripe_id, - "date": 1448470892, - "description": None, - "discount": None, - "ending_balance": None, - "forgiven": False, - "lines": { - "data": [{ - "id": self.subscription.stripe_id, - "object": "line_item", - "amount": 0, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "plan": { - "id": "pro2", - "object": "plan", - "amount": 1999, - "created": 1448121054, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": False, - "metadata": { - }, - "name": "The Pro Plan", - "statement_descriptor": "ALTMAN", - "trial_period_days": 3 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "subscription" - }], - "total_count": 1, - "object": "list", - "url": "/v1/invoices/in_17B6e8I10iPhvocMGtYd4hDD/lines" - }, - "livemode": False, - "metadata": { - }, - "next_payment_attempt": 1448474492, - "paid": False, - "period_end": 1448470739, - "period_start": 1448211539, - "receipt_number": None, - "starting_balance": 0, - "statement_descriptor": None, - "subscription": self.subscription.stripe_id, - "subtotal": 1999, - "tax": None, - "tax_percent": None, - "total": 1999, - "webhooks_delivered_at": None - } - self.account = Account.objects.create(stripe_id="acct_X") - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("stripe.Charge.retrieve") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("pinax.stripe.actions.invoices.sync_invoice_items") - @patch("pinax.stripe.actions.subscriptions.retrieve") - def test_sync_invoice_from_stripe_data(self, RetrieveSubscriptionMock, SyncInvoiceItemsMock, SyncChargeMock, ChargeFetchMock, SyncSubscriptionMock, SendReceiptMock): - charge = Charge.objects.create( - stripe_id="ch_XXXXXX", - customer=self.customer, - source="card_01", - amount=decimal.Decimal("10.00"), - currency="usd", - paid=True, - refunded=False, - disputed=False - ) - self.invoice_data["charge"] = charge.stripe_id - SyncChargeMock.return_value = charge - SyncSubscriptionMock.return_value = self.subscription - invoices.sync_invoice_from_stripe_data(self.invoice_data) - self.assertTrue(SyncInvoiceItemsMock.called) - self.assertEqual(Invoice.objects.filter(customer=self.customer).count(), 1) - self.assertTrue(ChargeFetchMock.called) - self.assertTrue(SyncChargeMock.called) - self.assertTrue(SendReceiptMock.called) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("stripe.Charge.retrieve") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("pinax.stripe.actions.invoices.sync_invoice_items") - @patch("pinax.stripe.actions.subscriptions.retrieve") - def test_sync_invoice_from_stripe_data_no_send_receipt(self, RetrieveSubscriptionMock, SyncInvoiceItemsMock, SyncChargeMock, ChargeFetchMock, SyncSubscriptionMock, SendReceiptMock): - charge = Charge.objects.create( - stripe_id="ch_XXXXXX", - customer=self.customer, - source="card_01", - amount=decimal.Decimal("10.00"), - currency="usd", - paid=True, - refunded=False, - disputed=False - ) - self.invoice_data["charge"] = charge.stripe_id - SyncChargeMock.return_value = charge - SyncSubscriptionMock.return_value = self.subscription - invoices.sync_invoice_from_stripe_data(self.invoice_data, send_receipt=False) - self.assertTrue(SyncInvoiceItemsMock.called) - self.assertEqual(Invoice.objects.filter(customer=self.customer).count(), 1) - self.assertTrue(ChargeFetchMock.called) - self.assertTrue(SyncChargeMock.called) - self.assertFalse(SendReceiptMock.called) - - @patch("pinax.stripe.hooks.hookset.send_receipt") - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("stripe.Charge.retrieve") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - @patch("pinax.stripe.actions.invoices.sync_invoice_items") - @patch("pinax.stripe.actions.subscriptions.retrieve") - def test_sync_invoice_from_stripe_data_connect(self, RetrieveSubscriptionMock, SyncInvoiceItemsMock, SyncChargeMock, ChargeFetchMock, SyncSubscriptionMock, SendReceiptMock): - self.invoice_data["charge"] = "ch_XXXXXX" - self.customer.stripe_account = self.account - self.customer.save() - charge = Charge.objects.create( - stripe_id="ch_XXXXXX", - customer=self.customer, - source="card_01", - amount=decimal.Decimal("10.00"), - currency="usd", - paid=True, - refunded=False, - disputed=False - ) - SyncChargeMock.return_value = charge - SyncSubscriptionMock.return_value = self.subscription - invoices.sync_invoice_from_stripe_data(self.invoice_data) - self.assertTrue(SyncInvoiceItemsMock.called) - self.assertEqual(Invoice.objects.filter(customer=self.customer).count(), 1) - self.assertTrue(ChargeFetchMock.called) - args, kwargs = ChargeFetchMock.call_args - self.assertEqual(args, ("ch_XXXXXX",)) - self.assertEqual(kwargs, {"stripe_account": "acct_X", - "expand": ["balance_transaction"]}) - self.assertTrue(SyncChargeMock.called) - self.assertTrue(SendReceiptMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.invoices.sync_invoice_items") - @patch("pinax.stripe.actions.subscriptions.retrieve") - def test_sync_invoice_from_stripe_data_no_charge(self, RetrieveSubscriptionMock, SyncInvoiceItemsMock, SyncSubscriptionMock): - SyncSubscriptionMock.return_value = self.subscription - self.invoice_data["charge"] = None - invoices.sync_invoice_from_stripe_data(self.invoice_data) - self.assertTrue(SyncInvoiceItemsMock.called) - self.assertEqual(Invoice.objects.filter(customer=self.customer).count(), 1) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.invoices.sync_invoice_items") - @patch("pinax.stripe.actions.subscriptions.retrieve") - def test_sync_invoice_from_stripe_data_no_subscription(self, RetrieveSubscriptionMock, SyncInvoiceItemsMock, SyncSubscriptionMock): - SyncSubscriptionMock.return_value = None - data = { - "id": "in_17B6e8I10iPhvocMGtYd4hDD", - "object": "invoice", - "amount_due": 1999, - "application_fee": None, - "attempt_count": 0, - "attempted": False, - "charge": None, - "closed": False, - "currency": "usd", - "customer": self.customer.stripe_id, - "date": 1448470892, - "description": None, - "discount": None, - "ending_balance": None, - "forgiven": False, - "lines": { - "data": [{ - "id": "ii_2342342", - "object": "line_item", - "amount": 2000, - "currency": "usd", - "description": None, - "discountable": True, - "livemode": True, - "metadata": { - }, - "period": { - "start": 1448499344, - "end": 1448758544 - }, - "proration": False, - "quantity": 1, - "subscription": None, - "type": "line_item" - }], - "total_count": 1, - "object": "list", - "url": "/v1/invoices/in_17B6e8I10iPhvocMGtYd4hDD/lines" - }, - "livemode": False, - "metadata": { - }, - "next_payment_attempt": 1448474492, - "paid": False, - "period_end": 1448470739, - "period_start": 1448211539, - "receipt_number": None, - "starting_balance": 0, - "statement_descriptor": None, - "subscription": "", - "subtotal": 2000, - "tax": None, - "tax_percent": None, - "total": 2000, - "webhooks_delivered_at": None - } - invoices.sync_invoice_from_stripe_data(data) - self.assertTrue(SyncInvoiceItemsMock.called) - self.assertEqual(Invoice.objects.filter(customer=self.customer).count(), 1) - self.assertIsNone(Invoice.objects.filter(customer=self.customer)[0].subscription) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.invoices.sync_invoice_items") - @patch("pinax.stripe.actions.subscriptions.retrieve") - def test_sync_invoice_from_stripe_data_updated(self, RetrieveSubscriptionMock, SyncInvoiceItemsMock, SyncSubscriptionMock): - SyncSubscriptionMock.return_value = self.subscription - data = self.invoice_data - invoices.sync_invoice_from_stripe_data(data) - self.assertTrue(SyncInvoiceItemsMock.called) - self.assertEqual(Invoice.objects.filter(customer=self.customer).count(), 1) - data.update({"paid": True}) - invoices.sync_invoice_from_stripe_data(data) - self.assertEqual(Invoice.objects.filter(customer=self.customer).count(), 1) - self.assertEqual(Invoice.objects.filter(customer=self.customer)[0].paid, True) - - -class TransfersTests(TestCase): - - def setUp(self): - self.data = { - "id": "tr_17BE31I10iPhvocMDwiBi4Pk", - "object": "transfer", - "amount": 1100, - "amount_reversed": 0, - "application_fee": None, - "balance_transaction": "txn_179l3zI10iPhvocMhvKxAer7", - "created": 1448499343, - "currency": "usd", - "date": 1448499343, - "description": "Transfer to test@example.com", - "destination": "ba_17BE31I10iPhvocMOUp6E9If", - "failure_code": None, - "failure_message": None, - "livemode": False, - "metadata": { - }, - "recipient": "rp_17BE31I10iPhvocM14ZKPFfR", - "reversals": { - "object": "list", - "data": [ - - ], - "has_more": False, - "total_count": 0, - "url": "/v1/transfers/tr_17BE31I10iPhvocMDwiBi4Pk/reversals" - }, - "reversed": False, - "source_transaction": None, - "statement_descriptor": None, - "status": "in_transit", - "type": "bank_account" - } - self.event = Event.objects.create( - stripe_id="evt_001", - kind="transfer.paid", - webhook_message={"data": {"object": self.data}}, - validated_message={"data": {"object": self.data}}, - valid=True, - processed=False - ) - - def test_sync_transfer(self): - transfers.sync_transfer(self.data, self.event) - qs = Transfer.objects.filter(stripe_id=self.event.message["data"]["object"]["id"]) - self.assertEqual(qs.count(), 1) - self.assertEqual(qs[0].event, self.event) - - def test_sync_transfer_update(self): - transfers.sync_transfer(self.data, self.event) - qs = Transfer.objects.filter(stripe_id=self.event.message["data"]["object"]["id"]) - self.assertEqual(qs.count(), 1) - self.assertEqual(qs[0].event, self.event) - self.event.validated_message["data"]["object"]["status"] = "paid" - transfers.sync_transfer(self.event.message["data"]["object"], self.event) - qs = Transfer.objects.filter(stripe_id=self.event.message["data"]["object"]["id"]) - self.assertEqual(qs[0].status, "paid") - - def test_transfer_during(self): - Transfer.objects.create( - stripe_id="tr_002", - event=Event.objects.create(kind="transfer.created", webhook_message={}), - amount=decimal.Decimal("100"), - status="pending", - date=timezone.now().replace(year=2015, month=1) - ) - qs = transfers.during(2015, 1) - self.assertEqual(qs.count(), 1) - - @patch("stripe.Transfer.retrieve") - def test_transfer_update_status(self, RetrieveMock): - RetrieveMock().status = "complete" - transfer = Transfer.objects.create( - stripe_id="tr_001", - event=Event.objects.create(kind="transfer.created", webhook_message={}), - amount=decimal.Decimal("100"), - status="pending", - date=timezone.now().replace(year=2015, month=1) - ) - transfers.update_status(transfer) - self.assertEqual(transfer.status, "complete") - - @patch("stripe.Transfer.create") - def test_transfer_create(self, CreateMock): - CreateMock.return_value = self.data - transfers.create(decimal.Decimal("100"), "usd", None, None) - self.assertTrue(CreateMock.called) - - @patch("stripe.Transfer.create") - def test_transfer_create_with_transfer_group(self, CreateMock): - CreateMock.return_value = self.data - transfers.create(decimal.Decimal("100"), "usd", None, None, transfer_group="foo") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["transfer_group"], "foo") - - @patch("stripe.Transfer.create") - def test_transfer_create_with_stripe_account(self, CreateMock): - CreateMock.return_value = self.data - transfers.create(decimal.Decimal("100"), "usd", None, None, stripe_account="foo") - _, kwargs = CreateMock.call_args - self.assertEqual(kwargs["stripe_account"], "foo") - - -class AccountsSyncTestCase(TestCase): - - @classmethod - def setUpClass(cls): - super(AccountsSyncTestCase, cls).setUpClass() - - cls.custom_account_data = json.loads( - """{ - "type":"custom", - "tos_acceptance":{ - "date":1490903452, - "ip":"123.107.1.28", - "user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" - }, - "business_logo":null, - "email":"operations@someurl.com", - "timezone":"Etc/UTC", - "statement_descriptor":"SOME COMP", - "default_currency":"cad", - "payout_schedule":{ - "delay_days":3, - "interval":"manual" - }, - "display_name":"Some Company", - "payout_statement_descriptor": "For reals", - "id":"acct_1A39IGDwqdd5icDO", - "payouts_enabled":true, - "external_accounts":{ - "has_more":false, - "total_count":1, - "object":"list", - "data":[ - { - "routing_number":"11000-000", - "bank_name":"SOME CREDIT UNION", - "account":"acct_1A39IGDwqdd5icDO", - "object":"bank_account", - "currency":"cad", - "country":"CA", - "account_holder_name":"Luke Burden", - "last4":"6789", - "status":"new", - "fingerprint":"bZJnuqqS4qIX0SX0", - "account_holder_type":"individual", - "default_for_currency":true, - "id":"ba_1A39IGDwqdd5icDOn9VrFXlQ", - "metadata":{} - } - ], - "url":"/v1/accounts/acct_1A39IGDwqdd5icDO/external_accounts" - }, - "support_email":"support@someurl.com", - "metadata":{ - "user_id":"9428" - }, - "support_phone":"7788188181", - "business_name":"Woop Woop", - "object":"account", - "charges_enabled":true, - "business_name":"Woop Woop", - "debit_negative_balances":false, - "country":"CA", - "decline_charge_on":{ - "avs_failure":true, - "cvc_failure":true - }, - "product_description":"Monkey Magic", - "legal_entity":{ - "personal_id_number_provided":false, - "first_name":"Luke", - "last_name":"Baaard", - "dob":{ - "month":2, - "day":3, - "year":1999 - }, - "personal_address":{ - "city":null, - "country":"CA", - "line2":null, - "line1":null, - "state":null, - "postal_code":null - }, - "business_tax_id_provided":false, - "verification":{ - "status":"unverified", - "details_code":"failed_keyed_identity", - "document":null, - "details":"Provided identity information could not be verified" - }, - "address":{ - "city":"Vancouver", - "country":"CA", - "line2":null, - "line1":"14 Alberta St", - "state":"BC", - "postal_code":"V5Y4Z2" - }, - "business_name":null, - "type":"individual" - }, - "details_submitted":true, - "verification":{ - "due_by":null, - "fields_needed":[ - "legal_entity.personal_id_number" - ], - "disabled_reason":null - } - }""") - cls.custom_account_data_no_dob_no_verification_no_tosacceptance = json.loads( - """{ - "type":"custom", - "tos_acceptance":{ - "date":null, - "ip":"123.107.1.28", - "user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" - }, - "business_logo":null, - "email":"operations@someurl.com", - "timezone":"Etc/UTC", - "statement_descriptor":"SOME COMP", - "default_currency":"cad", - "payout_schedule":{ - "delay_days":3, - "interval":"manual" - }, - "display_name":"Some Company", - "payout_statement_descriptor": "For reals", - "id":"acct_1A39IGDwqdd5icDO", - "payouts_enabled":true, - "external_accounts":{ - "has_more":false, - "total_count":1, - "object":"list", - "data":[ - { - "routing_number":"11000-000", - "bank_name":"SOME CREDIT UNION", - "account":"acct_1A39IGDwqdd5icDO", - "object":"other", - "currency":"cad", - "country":"CA", - "account_holder_name":"Luke Burden", - "last4":"6789", - "status":"new", - "fingerprint":"bZJnuqqS4qIX0SX0", - "account_holder_type":"individual", - "default_for_currency":true, - "id":"ba_1A39IGDwqdd5icDOn9VrFXlQ", - "metadata":{} - } - ], - "url":"/v1/accounts/acct_1A39IGDwqdd5icDO/external_accounts" - }, - "support_email":"support@someurl.com", - "metadata":{ - "user_id":"9428" - }, - "support_phone":"7788188181", - "business_name":"Woop Woop", - "object":"account", - "charges_enabled":true, - "business_name":"Woop Woop", - "debit_negative_balances":false, - "country":"CA", - "decline_charge_on":{ - "avs_failure":true, - "cvc_failure":true - }, - "product_description":"Monkey Magic", - "legal_entity":{ - "dob": null, - "verification": null, - "personal_id_number_provided":false, - "first_name":"Luke", - "last_name":"Baaard", - "personal_address":{ - "city":null, - "country":"CA", - "line2":null, - "line1":null, - "state":null, - "postal_code":null - }, - "business_tax_id_provided":false, - "address":{ - "city":"Vancouver", - "country":"CA", - "line2":null, - "line1":"14 Alberta St", - "state":"BC", - "postal_code":"V5Y4Z2" - }, - "business_name":null, - "type":"individual" - }, - "details_submitted":true, - "verification":{ - "due_by":null, - "fields_needed":[ - "legal_entity.personal_id_number" - ], - "disabled_reason":null - } - }""") - cls.not_custom_account_data = json.loads( - """{ - "business_logo":null, - "business_name":"Woop Woop", - "business_url":"https://www.someurl.com", - "charges_enabled":true, - "country":"CA", - "default_currency":"cad", - "details_submitted":true, - "display_name":"Some Company", - "email":"operations@someurl.com", - "id":"acct_102t2K2m3chDH8uL", - "object":"account", - "payouts_enabled": true, - "statement_descriptor":"SOME COMP", - "support_address": { - "city": null, - "country": "DE", - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "support_email":"support@someurl.com", - "support_phone":"7788188181", - "support_url":"https://support.someurl.com", - "timezone":"Etc/UTC", - "type":"standard" - }""") - - def assert_common_attributes(self, account): - self.assertEqual(account.support_phone, "7788188181") - self.assertEqual(account.business_name, "Woop Woop") - self.assertEqual(account.country, "CA") - self.assertEqual(account.charges_enabled, True) - self.assertEqual(account.support_email, "support@someurl.com") - self.assertEqual(account.details_submitted, True) - self.assertEqual(account.email, "operations@someurl.com") - self.assertEqual(account.timezone, "Etc/UTC") - self.assertEqual(account.display_name, "Some Company") - self.assertEqual(account.statement_descriptor, "SOME COMP") - self.assertEqual(account.default_currency, "cad") - - def assert_custom_attributes(self, account, dob=None, verification=None, acceptance_date=None, bank_accounts=0): - - # extra top level attributes - self.assertEqual(account.debit_negative_balances, False) - self.assertEqual(account.product_description, "Monkey Magic") - self.assertEqual(account.metadata, {"user_id": "9428"}) - self.assertEqual(account.payout_statement_descriptor, "For reals") - - # legal entity - self.assertEqual(account.legal_entity_address_city, "Vancouver") - self.assertEqual(account.legal_entity_address_country, "CA") - self.assertEqual(account.legal_entity_address_line1, "14 Alberta St") - self.assertEqual(account.legal_entity_address_line2, None) - self.assertEqual(account.legal_entity_address_postal_code, "V5Y4Z2") - self.assertEqual(account.legal_entity_address_state, "BC") - self.assertEqual(account.legal_entity_dob, dob) - self.assertEqual(account.legal_entity_type, "individual") - self.assertEqual(account.legal_entity_first_name, "Luke") - self.assertEqual(account.legal_entity_last_name, "Baaard") - self.assertEqual(account.legal_entity_personal_id_number_provided, False) - - # verification - if verification is not None: - self.assertEqual( - account.legal_entity_verification_details, - "Provided identity information could not be verified" - ) - self.assertEqual( - account.legal_entity_verification_details_code, "failed_keyed_identity" - ) - self.assertEqual(account.legal_entity_verification_document, None) - self.assertEqual(account.legal_entity_verification_status, "unverified") - - self.assertEqual( - account.tos_acceptance_date, - acceptance_date - ) - - self.assertEqual(account.tos_acceptance_ip, "123.107.1.28") - self.assertEqual( - account.tos_acceptance_user_agent, - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" - ) - - # decline charge on certain conditions - self.assertEqual(account.decline_charge_on_avs_failure, True) - self.assertEqual(account.decline_charge_on_cvc_failure, True) - - # Payout schedule - self.assertEqual(account.payout_schedule_interval, "manual") - self.assertEqual(account.payout_schedule_delay_days, 3) - self.assertEqual(account.payout_schedule_weekly_anchor, None) - self.assertEqual(account.payout_schedule_monthly_anchor, None) - - # verification status, key to progressing account setup - self.assertEqual(account.verification_disabled_reason, None) - self.assertEqual(account.verification_due_by, None) - self.assertEqual( - account.verification_fields_needed, - [ - "legal_entity.personal_id_number" - ] - ) - - # external accounts should be sync'd - leave the detail check to - # its own test - self.assertEqual( - account.bank_accounts.all().count(), bank_accounts - ) - - def test_sync_custom_account(self): - User = get_user_model() - user = User.objects.create_user( - username="snuffle", - email="upagus@test" - ) - account = accounts.sync_account_from_stripe_data( - self.custom_account_data, user=user - ) - self.assertEqual(account.type, "custom") - self.assert_common_attributes(account) - self.assert_custom_attributes( - account, - dob=datetime.date(1999, 2, 3), - verification="full", - acceptance_date=datetime.datetime(2017, 3, 30, 19, 50, 52), - bank_accounts=1 - ) - - @patch("pinax.stripe.actions.externalaccounts.sync_bank_account_from_stripe_data") - def test_sync_custom_account_no_dob_no_verification(self, SyncMock): - User = get_user_model() - user = User.objects.create_user( - username="snuffle", - email="upagus@test" - ) - account = accounts.sync_account_from_stripe_data( - self.custom_account_data_no_dob_no_verification_no_tosacceptance, user=user - ) - self.assertEqual(account.type, "custom") - self.assert_common_attributes(account) - self.assert_custom_attributes(account) - self.assertFalse(SyncMock.called) - - def test_sync_not_custom_account(self): - account = accounts.sync_account_from_stripe_data( - self.not_custom_account_data - ) - self.assertNotEqual(account.type, "custom") - self.assert_common_attributes(account) - - def test_deauthorize_account(self): - account = accounts.sync_account_from_stripe_data( - self.not_custom_account_data - ) - accounts.deauthorize(account) - self.assertFalse(account.authorized) - - -class BankAccountsSyncTestCase(TestCase): - - def setUp(self): - self.data = json.loads( - """{ - "id": "ba_19VZfo2m3chDH8uLo0r6WCia", - "object": "bank_account", - "account": "acct_102t2K2m3chDH8uL", - "account_holder_name": "Jane Austen", - "account_holder_type": "individual", - "bank_name": "STRIPE TEST BANK", - "country": "US", - "currency": "cad", - "default_for_currency": false, - "fingerprint": "ObHHcvjOGrhaeWhC", - "last4": "6789", - "metadata": { - }, - "routing_number": "110000000", - "status": "new" -} -""") - - def test_sync(self): - User = get_user_model() - user = User.objects.create_user( - username="snuffle", - email="upagus@test" - ) - account = Account.objects.create( - stripe_id="acct_102t2K2m3chDH8uL", - type="custom", - user=user - ) - bankaccount = externalaccounts.sync_bank_account_from_stripe_data( - self.data - ) - self.assertEqual(bankaccount.account_holder_name, "Jane Austen") - self.assertEqual(bankaccount.account, account) - - @patch("pinax.stripe.actions.externalaccounts.sync_bank_account_from_stripe_data") - def test_create_bank_account(self, SyncMock): - account = Mock() - externalaccounts.create_bank_account(account, 123455, "US", "usd") - self.assertTrue(account.external_accounts.create.called) - self.assertTrue(SyncMock.called) diff --git a/pinax/stripe/tests/test_admin.py b/pinax/stripe/tests/test_admin.py deleted file mode 100644 index 742979216..000000000 --- a/pinax/stripe/tests/test_admin.py +++ /dev/null @@ -1,216 +0,0 @@ -import datetime - -from django.contrib.auth import get_user_model -from django.db import connection -from django.test import Client, RequestFactory, SimpleTestCase, TestCase -from django.test.utils import CaptureQueriesContext -from django.utils import timezone - -from ..models import Account, Customer, Invoice, Plan, Subscription - -try: - from django.urls import reverse -except ImportError: - from django.core.urlresolvers import reverse - - -User = get_user_model() - - -class AdminTestCase(TestCase): - - @classmethod - def setUpClass(cls): - super(AdminTestCase, cls).setUpClass() - - # 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, tzinfo=timezone.utc) - cls.plan = Plan.objects.create( - stripe_id="p1", - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - cls.plan2 = Plan.objects.create( - stripe_id="p2", - amount=5, - currency="usd", - interval="monthly", - interval_count=1, - name="Light" - ) - for i in range(10): - customer = Customer.objects.create( - user=User.objects.create_user(username="patrick{0}".format(i)), - stripe_id="cus_xxxxxxxxxxxxxx{0}".format(i) - ) - Subscription.objects.create( - stripe_id="sub_{}".format(i), - customer=customer, - plan=cls.plan, - current_period_start=period_start, - current_period_end=period_end, - status="active", - start=start, - quantity=1 - ) - customer = Customer.objects.create( - user=User.objects.create_user(username="patrick{0}".format(11)), - stripe_id="cus_xxxxxxxxxxxxxx{0}".format(11) - ) - Subscription.objects.create( - stripe_id="sub_{}".format(11), - customer=customer, - plan=cls.plan, - current_period_start=period_start, - current_period_end=period_end, - status="canceled", - canceled_at=period_end, - start=start, - quantity=1 - ) - cls.customer = Customer.objects.create( - user=User.objects.create_user(username="patrick{0}".format(12)), - stripe_id="cus_xxxxxxxxxxxxxx{0}".format(12) - ) - Subscription.objects.create( - stripe_id="sub_{}".format(12), - customer=customer, - plan=cls.plan2, - current_period_start=period_start, - current_period_end=period_end, - status="active", - start=start, - quantity=1 - ) - Invoice.objects.create( - customer=customer, - date=timezone.now(), - amount_due=100, - subtotal=100, - total=100, - period_end=period_end, - period_start=period_start - ) - cls.user = User.objects.create_superuser( - username="admin", email="admin@test.com", password="admin") - cls.account = Account.objects.create(stripe_id="acc_abcd") - cls.client = Client() - - def setUp(self): - try: - self.client.force_login(self.user) - except AttributeError: - # Django 1.8 - self.client.login(username="admin", password="admin") - - def test_readonly_change_form(self): - url = reverse("admin:pinax_stripe_customer_change", args=(self.customer.pk,)) - response = self.client.get(url) - self.assertNotContains(response, "submit-row") - - response = self.client.post(url) - self.assertEqual(response.status_code, 403) - - def test_customer_admin(self): - """Make sure we get good responses for all filter options""" - url = reverse("admin:pinax_stripe_customer_changelist") - - response = self.client.get(url + "?sub_status=active") - self.assertEqual(response.status_code, 200) - - response = self.client.get(url + "?sub_status=cancelled") - self.assertEqual(response.status_code, 200) - - response = self.client.get(url + "?sub_status=none") - self.assertEqual(response.status_code, 200) - - response = self.client.get(url + "?has_card=yes") - self.assertEqual(response.status_code, 200) - - response = self.client.get(url + "?has_card=no") - self.assertEqual(response.status_code, 200) - - def test_customer_admin_prefetch(self): - url = reverse("admin:pinax_stripe_customer_changelist") - - with CaptureQueriesContext(connection) as captured: - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - Customer.objects.create( - user=User.objects.create_user(username="patrick{0}".format(13)), - stripe_id="cus_xxxxxxxxxxxxxx{0}".format(13) - ) - with self.assertNumQueries(len(captured)): - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_invoice_admin(self): - url = reverse("admin:pinax_stripe_invoice_changelist") - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - response = self.client.get(url + "?has_card=no") - self.assertEqual(response.status_code, 200) - - response = self.client.get(url + "?has_card=yes") - self.assertEqual(response.status_code, 200) - - def test_plan_admin(self): - url = reverse("admin:pinax_stripe_plan_changelist") - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_charge_admin(self): - url = reverse("admin:pinax_stripe_charge_changelist") - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_account_filter(self): - url = reverse("admin:pinax_stripe_customer_changelist") - response = self.client.get(url + "?stripe_account={}".format(self.account.pk)) - self.assertEqual(response.status_code, 200) - response = self.client.get(url + "?stripe_account=none") - self.assertEqual(response.status_code, 200) - - @classmethod - def get_changelist(cls, model_class, data=None): - from django.contrib.admin.sites import AdminSite - from django.utils.module_loading import import_string - - admin_class = import_string("pinax.stripe.admin.{}Admin".format( - model_class.__name__)) - - ma = admin_class(model_class, AdminSite()) - - info = ma.model._meta.app_label, ma.model._meta.model_name - url = reverse("admin:%s_%s_changelist" % info) - request = RequestFactory().get(url, data=data) - request.user = cls.user - return ma.changelist_view(request).context_data["cl"] - - def test_account_search(self): - cl = self.get_changelist(Account) - self.assertEqual(list(cl.queryset), [self.account]) - - cl = self.get_changelist(Account, {"q": "acc_doesnotexist"}) - self.assertEqual(list(cl.queryset), []) - - -class AdminSimpleTestCase(SimpleTestCase): - - def test_customer_user_without_user(self): - from ..admin import customer_user - - class CustomerWithoutUser(object): - user = None - - class Obj(object): - customer = CustomerWithoutUser() - - self.assertEqual(customer_user(Obj()), "") diff --git a/pinax/stripe/tests/test_commands.py b/pinax/stripe/tests/test_commands.py deleted file mode 100644 index 51f520e02..000000000 --- a/pinax/stripe/tests/test_commands.py +++ /dev/null @@ -1,160 +0,0 @@ -import decimal - -from django.contrib.auth import get_user_model -from django.core import management -from django.test import TestCase - -from mock import patch -from stripe.error import InvalidRequestError - -from ..models import Coupon, Customer, Plan - - -class CommandTests(TestCase): - - def setUp(self): - User = get_user_model() - self.user = User.objects.create_user(username="patrick") - - @patch("stripe.Customer.retrieve") - @patch("stripe.Customer.create") - def test_init_customer_creates_customer(self, CreateMock, RetrieveMock): - CreateMock.return_value = dict( - account_balance=0, - delinquent=False, - default_source="card_178Zqj2eZvKYlo2Cr2fUZZz7", - currency="usd", - id="cus_XXXXX", - sources=dict(data=[]), - subscriptions=dict(data=[]), - ) - management.call_command("init_customers") - customer = Customer.objects.get(user=self.user) - self.assertEqual(customer.stripe_id, "cus_XXXXX") - - @patch("stripe.Plan.auto_paging_iter", create=True) - def test_plans_create(self, PlanAutoPagerMock): - PlanAutoPagerMock.return_value = [{ - "id": "entry-monthly", - "amount": 954, - "interval": "monthly", - "interval_count": 1, - "currency": None, - "statement_descriptor": None, - "trial_period_days": None, - "name": "Pro", - "metadata": {} - }] - management.call_command("sync_plans") - self.assertEqual(Plan.objects.count(), 1) - self.assertEqual(Plan.objects.all()[0].stripe_id, "entry-monthly") - self.assertEqual(Plan.objects.all()[0].amount, decimal.Decimal("9.54")) - - @patch("stripe.Coupon.auto_paging_iter", create=True) - def test_coupons_create(self, CouponAutoPagerMock): - CouponAutoPagerMock.return_value = [{ - "id": "test-coupon", - "object": "coupon", - "amount_off": None, - "created": 1482132502, - "currency": "aud", - "duration": "repeating", - "duration_in_months": 3, - "livemode": False, - "max_redemptions": None, - "metadata": { - }, - "percent_off": 25, - "redeem_by": None, - "times_redeemed": 0, - "valid": True - }] - management.call_command("sync_coupons") - self.assertEqual(Coupon.objects.count(), 1) - self.assertEqual(Coupon.objects.all()[0].stripe_id, "test-coupon") - self.assertEqual(Coupon.objects.all()[0].percent_off, 25) - - @patch("stripe.Customer.retrieve") - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("pinax.stripe.actions.invoices.sync_invoices_for_customer") - @patch("pinax.stripe.actions.charges.sync_charges_for_customer") - def test_sync_customers(self, SyncChargesMock, SyncInvoicesMock, SyncMock, RetrieveMock): - user2 = get_user_model().objects.create_user(username="thomas") - get_user_model().objects.create_user(username="altman") - Customer.objects.create(stripe_id="cus_XXXXX", user=self.user) - Customer.objects.create(stripe_id="cus_YYYYY", user=user2) - management.call_command("sync_customers") - self.assertEqual(SyncChargesMock.call_count, 2) - self.assertEqual(SyncInvoicesMock.call_count, 2) - self.assertEqual(SyncMock.call_count, 2) - - @patch("stripe.Customer.retrieve") - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("pinax.stripe.actions.invoices.sync_invoices_for_customer") - @patch("pinax.stripe.actions.charges.sync_charges_for_customer") - def test_sync_customers_with_test_customer(self, SyncChargesMock, SyncInvoicesMock, SyncMock, RetrieveMock): - user2 = get_user_model().objects.create_user(username="thomas") - get_user_model().objects.create_user(username="altman") - Customer.objects.create(stripe_id="cus_XXXXX", user=self.user) - Customer.objects.create(stripe_id="cus_YYYYY", user=user2) - - SyncMock.side_effect = InvalidRequestError("Unknown customer", None, http_status=404) - - management.call_command("sync_customers") - self.assertEqual(SyncChargesMock.call_count, 0) - self.assertEqual(SyncInvoicesMock.call_count, 0) - self.assertEqual(SyncMock.call_count, 2) - - @patch("stripe.Customer.retrieve") - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("pinax.stripe.actions.invoices.sync_invoices_for_customer") - @patch("pinax.stripe.actions.charges.sync_charges_for_customer") - def test_sync_customers_with_test_customer_unknown_error(self, SyncChargesMock, SyncInvoicesMock, SyncMock, RetrieveMock): - user2 = get_user_model().objects.create_user(username="thomas") - get_user_model().objects.create_user(username="altman") - Customer.objects.create(stripe_id="cus_XXXXX", user=self.user) - Customer.objects.create(stripe_id="cus_YYYYY", user=user2) - - SyncMock.side_effect = InvalidRequestError("Unknown error", None, http_status=500) - - with self.assertRaises(InvalidRequestError): - management.call_command("sync_customers") - self.assertEqual(SyncChargesMock.call_count, 0) - self.assertEqual(SyncInvoicesMock.call_count, 0) - self.assertEqual(SyncMock.call_count, 1) - - @patch("stripe.Customer.retrieve") - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("pinax.stripe.actions.invoices.sync_invoices_for_customer") - @patch("pinax.stripe.actions.charges.sync_charges_for_customer") - def test_sync_customers_with_unicode_username(self, SyncChargesMock, SyncInvoicesMock, SyncMock, RetrieveMock): - user2 = get_user_model().objects.create_user(username=u"tom\xe1s") - Customer.objects.create(stripe_id="cus_YYYYY", user=user2) - management.call_command("sync_customers") - self.assertEqual(SyncChargesMock.call_count, 1) - self.assertEqual(SyncInvoicesMock.call_count, 1) - self.assertEqual(SyncMock.call_count, 1) - - @patch("stripe.Customer.retrieve") - @patch("pinax.stripe.actions.invoices.sync_invoices_for_customer") - @patch("pinax.stripe.actions.charges.sync_charges_for_customer") - def test_sync_customers_with_remotely_purged_customer(self, SyncChargesMock, SyncInvoicesMock, RetrieveMock): - customer = Customer.objects.create( - user=self.user, - stripe_id="cus_XXXXX" - ) - - RetrieveMock.return_value = dict( - deleted=True - ) - - management.call_command("sync_customers") - self.assertIsNone(Customer.objects.get(stripe_id=customer.stripe_id).user) - self.assertIsNotNone(Customer.objects.get(stripe_id=customer.stripe_id).date_purged) - self.assertEqual(SyncChargesMock.call_count, 0) - self.assertEqual(SyncInvoicesMock.call_count, 0) - - @patch("pinax.stripe.actions.charges.update_charge_availability") - def test_update_charge_availability(self, UpdateChargeMock): - management.call_command("update_charge_availability") - self.assertEqual(UpdateChargeMock.call_count, 1) diff --git a/pinax/stripe/tests/test_email.py b/pinax/stripe/tests/test_email.py deleted file mode 100644 index 0b25ca8fd..000000000 --- a/pinax/stripe/tests/test_email.py +++ /dev/null @@ -1,68 +0,0 @@ -import decimal - -from django.contrib.auth import get_user_model -from django.core import mail -from django.test import TestCase - -from mock import patch - -from ..actions import charges -from ..models import Customer - - -class EmailReceiptTest(TestCase): - - def setUp(self): - User = get_user_model() - self.user = User.objects.create_user(username="patrick", email="user@test.com") - self.customer = Customer.objects.create( - user=self.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - - @patch("stripe.Charge.create") - def test_email_receipt_renders_amount_properly(self, ChargeMock): - ChargeMock.return_value = { - "id": "ch_XXXXXX", - "source": { - "id": "card_01" - }, - "amount": 40000, - "currency": "usd", - "paid": True, - "refunded": False, - "invoice": None, - "captured": True, - "dispute": None, - "created": 1363911708, - "customer": "cus_xxxxxxxxxxxxxxx" - } - charges.create( - customer=self.customer, - amount=decimal.Decimal("400.00") - ) - self.assertTrue("$400.00" in mail.outbox[0].body) - - @patch("stripe.Charge.create") - def test_email_receipt_renders_amount_in_JPY_properly(self, ChargeMock): - ChargeMock.return_value = { - "id": "ch_XXXXXX", - "source": { - "id": "card_01" - }, - "amount": 40000, - "currency": "jpy", - "paid": True, - "refunded": False, - "invoice": None, - "captured": True, - "dispute": None, - "created": 1363911708, - "customer": "cus_xxxxxxxxxxxxxxx" - } - charges.create( - customer=self.customer, - amount=decimal.Decimal("40000"), - currency="jpy" - ) - self.assertTrue("$40000.00" in mail.outbox[0].body) diff --git a/pinax/stripe/tests/test_event.py b/pinax/stripe/tests/test_event.py deleted file mode 100644 index 5d511c110..000000000 --- a/pinax/stripe/tests/test_event.py +++ /dev/null @@ -1,321 +0,0 @@ -from django.contrib.auth import get_user_model -from django.test import TestCase -from django.utils import timezone - -from mock import patch - -from ..actions import customers -from ..models import Customer, Event, Plan, Subscription -from ..signals import WEBHOOK_SIGNALS -from ..webhooks import registry - - -class TestEventMethods(TestCase): - def setUp(self): - User = get_user_model() - self.user = User.objects.create_user(username="testuser") - self.user.save() - self.customer = Customer.objects.create( - stripe_id="cus_xxxxxxxxxxxxxxx", - user=self.user - ) - self.plan = Plan.objects.create( - stripe_id="p1", - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - - def test_link_customer_customer_created(self): - msg = { - "created": 1363911708, - "data": { - "object": { - "account_balance": 0, - "active_card": None, - "created": 1363911708, - "currency": None, - "default_source": None, - "delinquent": False, - "description": None, - "discount": None, - "email": "xxxxxxxxxx@yahoo.com", - "id": "cus_xxxxxxxxxxxxxxx", - "livemode": True, - "object": "customer", - "sources": { - "data": [], - }, - "subscriptions": { - "data": [], - }, - } - }, - "id": "evt_xxxxxxxxxxxxx", - "livemode": True, - "object": "event", - "pending_webhooks": 1, - "type": "customer.created" - } - event = Event.objects.create( - stripe_id=msg["id"], - kind="customer.created", - livemode=True, - webhook_message=msg, - validated_message=msg - ) - self.assertIsNone(self.customer.account_balance) - customers.link_customer(event) - self.assertEqual(event.customer, self.customer) - self.customer.refresh_from_db() - self.assertEqual(self.customer.account_balance, 0) - - def test_link_customer_customer_updated(self): - msg = { - "created": 1346855599, - "data": { - "object": { - "account_balance": 0, - "active_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, - "country": "MX", - "cvc_check": "pass", - "exp_month": 1, - "exp_year": 2014, - "fingerprint": "XXXXXXXXXXX", - "last4": "7992", - "name": None, - "object": "card", - "type": "MasterCard" - }, - "created": 1346855596, - "currency": None, - "default_source": None, - "delinquent": False, - "description": None, - "discount": None, - "email": "xxxxxxxxxx@yahoo.com", - "id": "cus_xxxxxxxxxxxxxxx", - "livemode": True, - "object": "customer", - "sources": { - "data": [], - }, - "subscriptions": { - "data": [], - }, - }, - "previous_attributes": { - "active_card": None - } - }, - "id": "evt_xxxxxxxxxxxxx", - "livemode": True, - "object": "event", - "pending_webhooks": 1, - "type": "customer.updated" - } - event = Event.objects.create( - stripe_id=msg["id"], - kind="customer.updated", - livemode=True, - webhook_message=msg, - validated_message=msg - ) - customers.link_customer(event) - self.assertEqual(event.customer, self.customer) - - def test_link_customer_customer_deleted(self): - msg = { - "created": 1348286560, - "data": { - "object": { - "account_balance": 0, - "active_card": None, - "created": 1348286302, - "currency": None, - "default_source": None, - "delinquent": False, - "description": None, - "discount": None, - "email": "paltman+test@gmail.com", - "id": "cus_xxxxxxxxxxxxxxx", - "livemode": True, - "object": "customer", - "sources": { - "data": [], - }, - "subscriptions": { - "data": [], - }, - } - }, - "id": "evt_xxxxxxxxxxxxx", - "livemode": True, - "object": "event", - "pending_webhooks": 1, - "type": "customer.deleted" - } - event = Event.objects.create( - stripe_id=msg["id"], - kind="customer.deleted", - livemode=True, - webhook_message=msg, - validated_message=msg - ) - customers.link_customer(event) - self.assertEqual(event.customer, self.customer) - - @patch("stripe.Event.retrieve") - @patch("stripe.Customer.retrieve") - def test_process_customer_deleted(self, CustomerMock, EventMock): - ev = EventMock() - msg = { - "created": 1348286560, - "data": { - "object": { - "account_balance": 0, - "active_card": None, - "created": 1348286302, - "currency": None, - "default_source": None, - "delinquent": False, - "description": None, - "discount": None, - "email": "paltman+test@gmail.com", - "id": "cus_xxxxxxxxxxxxxxx", - "livemode": True, - "object": "customer", - "sources": { - "data": [], - }, - "subscriptions": { - "data": [], - } - } - }, - "id": "evt_xxxxxxxxxxxxx", - "livemode": True, - "object": "event", - "pending_webhooks": 1, - "type": "customer.deleted" - } - ev.to_dict.return_value = msg - event = Event.objects.create( - stripe_id=msg["id"], - kind="customer.deleted", - livemode=True, - webhook_message=msg, - validated_message=msg, - valid=True - ) - registry.get(event.kind)(event).process() - self.assertEqual(event.customer, self.customer) - self.assertEqual(event.customer.user, None) - - @staticmethod - def send_signal(customer, kind): - event = Event(customer=customer, kind=kind) - signal = WEBHOOK_SIGNALS.get(kind) - signal.send(sender=Event, event=event) - - @staticmethod - def connect_webhook_signal(kind, func, **kwargs): - signal = WEBHOOK_SIGNALS.get(kind) - signal.connect(func, **kwargs) - - @staticmethod - def disconnect_webhook_signal(kind, func, **kwargs): - signal = WEBHOOK_SIGNALS.get(kind) - signal.disconnect(func, **kwargs) - - @patch("pinax.stripe.actions.customers.sync_customer") - @patch("stripe.Event.retrieve") - @patch("stripe.Customer.retrieve") - def test_customer_subscription_deleted(self, CustomerMock, EventMock, SyncMock): - """ - Tests to make sure downstream signal handlers do not see stale Subscription object properties - after a customer.subscription.deleted event occurs. While the delete method is called - on the affected Subscription object's properties are still accessible (unless the - Customer object for the event gets refreshed before sending the complimentary signal) - """ - ev = EventMock() - cm = CustomerMock() - cm.currency = "usd" - cm.delinquent = False - cm.default_source = "" - cm.account_balance = 0 - kind = "customer.subscription.deleted" - plan = self.plan - - cs = Subscription(stripe_id="su_2ZDdGxJ3EQQc7Q", customer=self.customer, quantity=1, start=timezone.now(), plan=plan) - cs.save() - customer = Customer.objects.get(pk=self.customer.pk) - - # Stripe objects will not have this attribute so we must delete it from the mocked object - del customer.stripe_customer.subscription - self.assertIsNotNone(customer.subscription_set.all()[0]) - - # This is the expected format of a customer.subscription.delete message - msg = { - "id": "evt_2eRjeAlnH1XMe8", - "created": 1380317537, - "livemode": True, - "type": kind, - "data": { - "object": { - "id": "su_2ZDdGxJ3EQQc7Q", - "plan": { - "interval": "month", - "name": "xxx", - "amount": 200, - "currency": "usd", - "id": plan.stripe_id, - "object": "plan", - "livemode": True, - "interval_count": 1, - "trial_period_days": None - }, - "object": "subscription", - "start": 1379111889, - "status": "canceled", - "customer": self.customer.stripe_id, - "cancel_at_period_end": False, - "current_period_start": 1378738246, - "current_period_end": 1381330246, - "ended_at": 1380317537, - "trial_start": None, - "trial_end": None, - "canceled_at": 1380317537, - "quantity": 1, - "application_fee_percent": None - } - }, - "object": "event", - "pending_webhooks": 1, - "request": "iar_2eRjQZmn0i3G9M" - } - ev.to_dict.return_value = msg - - # Create a test event for the message - test_event = Event.objects.create( - stripe_id=msg["id"], - kind=kind, - livemode=msg["livemode"], - webhook_message=msg, - validated_message=msg, - valid=True, - customer=customer, - ) - - registry.get(test_event.kind)(test_event).process() - self.assertTrue(SyncMock.called) diff --git a/pinax/stripe/tests/test_forms.py b/pinax/stripe/tests/test_forms.py deleted file mode 100644 index dacfef29c..000000000 --- a/pinax/stripe/tests/test_forms.py +++ /dev/null @@ -1,398 +0,0 @@ -import datetime -import json -from base64 import b64decode -from copy import copy - -from django import forms -from django.contrib.auth import get_user_model -from django.core.files.uploadedfile import InMemoryUploadedFile -from django.test import TestCase -from django.test.client import RequestFactory -from django.test.utils import override_settings -from django.utils import timezone - -from mock import patch -from stripe.error import InvalidRequestError - -from ..forms import ( - AdditionalCustomAccountForm, - InitialCustomAccountForm, - extract_ipaddress -) -from ..models import Account - - -def get_stripe_error(field_name=None, message=None): - if field_name is None: - field_name = u"legal_entity[dob][year]" - if message is None: - message = u"This value must be greater than 1900 (it currently is '1800')." - json_body = { - "error": { - "type": "invalid_request_error", - "message": message, - "param": field_name - } - } - http_body = json.dumps(json_body) - return InvalidRequestError( - message, - field_name, - http_body=http_body, - json_body=json_body - ) - - -def get_image(name=None, _type=None): - # https://raw.githubusercontent.com/mathiasbynens/small/master/jpeg.jpg - if _type is None: - _type = "image/jpeg" - if name is None: - name = "random-name.jpg" - image = b64decode( - "/9j/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOC" - "wkJDRENDg8QEBEQCgwSExIQEw8QEBD/yQALCAABAAEBAREA/8wABgAQEAX/2gAIAQ" - "EAAD8A0s8g/9k=" - ) - return InMemoryUploadedFile( - image, None, name, _type, len(image), None - ) - - -class InitialCustomAccountFormTestCase(TestCase): - - def setUp(self): - self.user = get_user_model().objects.create_user( - username="luke", - email="luke@example.com" - ) - self.data = { - "first_name": "Donkey", - "last_name": "McGee", - "dob": "1980-01-01", - "address_line1": "2993 Steve St", - "address_city": "Fake Town", - "address_state": "CA", - "address_country": "US", - "address_postal_code": "V5Y3Z9", - "routing_number": "11000-000", - "account_number": "12345678900", - "tos_accepted": "true", - "currency": "USD" - } - self.request = RequestFactory().get("/user/account/create") - self.request.user = self.user - - def test_conditional_state_field(self): - form = InitialCustomAccountForm( - self.data, - request=self.request, - country="CA" - ) - self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors["address_state"][0], - "Select a valid choice. CA is not one of the available choices." - ) - - def test_fields_needed(self): - form = InitialCustomAccountForm( - self.data, - request=self.request, - country="CA", - fields_needed=["legal_entity.verification.document"] - ) - self.assertTrue("document" in form.fields) - - def test_conditional_currency_field(self): - data = copy(self.data) - data["currency"] = "AUD" - form = InitialCustomAccountForm( - data, - request=self.request, - country="US" - ) - self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors["currency"][0], - "Select a valid choice. AUD is not one of the available choices." - ) - - @patch("pinax.stripe.actions.accounts.sync_account_from_stripe_data") - @patch("stripe.Account.create") - def test_save(self, create_mock, sync_mock): - form = InitialCustomAccountForm( - self.data, - request=self.request, - country="US" - ) - self.assertTrue(form.is_valid()) - form.save() - self.assertTrue(create_mock.called) - self.assertTrue(sync_mock.called) - - @patch("pinax.stripe.actions.accounts.sync_account_from_stripe_data") - @patch("stripe.Account.create") - def test_save_with_stripe_error(self, create_mock, sync_mock): - create_mock.side_effect = get_stripe_error() - form = InitialCustomAccountForm( - self.data, - request=self.request, - country="US" - ) - self.assertTrue(form.is_valid()) - with self.assertRaises(InvalidRequestError): - form.save() - self.assertTrue(create_mock.called) - self.assertFalse(sync_mock.called) - self.assertEqual( - form.errors["dob"], - [u"This value must be greater than 1900 (it currently is '1800')."] - ) - - @patch("stripe.Account.create") - def test_save_with_stripe_error_unknown_field(self, create_mock): - create_mock.side_effect = get_stripe_error( - field_name="unknown", - message="Oopsie daisy" - ) - form = InitialCustomAccountForm( - self.data, - request=self.request, - country="US" - ) - self.assertTrue(form.is_valid()) - with self.assertRaises(InvalidRequestError): - form.save() - self.assertEqual( - form.non_field_errors()[0], - "Oopsie daisy" - ) - - @override_settings(DEBUG=True) - @patch("ipware.ip.get_real_ip") - @patch("ipware.ip.get_ip") - def test_extract_ipaddress(self, ip_mock, ip_real_mock): - # force hit of get_ip when get_real_ip returns None - ip_real_mock.return_value = None - ip_mock.return_value = "192.168.0.1" - ip = extract_ipaddress(self.request) - self.assertEqual(ip, "192.168.0.1") - - -class AdditionalCustomAccountFormTestCase(TestCase): - - def setUp(self): - self.user = get_user_model().objects.create_user( - username="luke", - email="luke@example.com" - ) - self.data = { - "first_name": "Donkey", - "last_name": "McGee", - "dob": "1980-01-01", - "personal_id_number": "123123123" - } - self.account = Account.objects.create( - user=self.user, - stripe_id="acct_123123", - country="US", - legal_entity_first_name="Donkey", - legal_entity_last_name="McGee", - legal_entity_dob=datetime.datetime(1980, 1, 1), - type="custom", - verification_due_by=timezone.now() + datetime.timedelta(days=2), - verification_fields_needed=["legal_entity.personal_id_number"], - ) - - def test_initial_data_from_account(self): - form = AdditionalCustomAccountForm( - account=self.account - ) - self.assertEqual( - form.fields["first_name"].initial, - self.account.legal_entity_first_name - ) - self.assertEqual( - form.fields["last_name"].initial, - self.account.legal_entity_last_name - ) - self.assertEqual( - form.fields["dob"].initial, - self.account.legal_entity_dob - ) - - def test_country_from_account(self): - form = AdditionalCustomAccountForm( - account=self.account - ) - self.assertEqual( - form.country, self.account.country - ) - - def test_fields_needed_from_account(self): - form = AdditionalCustomAccountForm( - account=self.account - ) - self.assertEqual( - form.fields_needed, self.account.verification_fields_needed - ) - - def test_dynamic_personal_id_field_added(self): - form = AdditionalCustomAccountForm( - account=self.account - ) - self.assertIn("personal_id_number", form.fields) - self.assertTrue( - isinstance(form.fields["personal_id_number"], forms.CharField) - ) - - def test_dynamic_document_field_added(self): - self.account.verification_fields_needed = [ - "legal_entity.verification.document" - ] - form = AdditionalCustomAccountForm( - account=self.account - ) - self.assertIn("document", form.fields) - self.assertTrue( - isinstance(form.fields["document"], forms.FileField) - ) - - def test_multiple_dynamic_fields_added(self): - self.account.verification_fields_needed = [ - "legal_entity.verification.document", - "legal_entity.personal_id_number" - ] - form = AdditionalCustomAccountForm( - account=self.account - ) - self.assertIn("document", form.fields) - self.assertTrue( - isinstance(form.fields["document"], forms.FileField) - ) - self.assertIn("personal_id_number", form.fields) - self.assertTrue( - isinstance(form.fields["personal_id_number"], forms.CharField) - ) - - @override_settings( - PINAX_STRIPE_DOCUMENT_MAX_SIZE_KB=0 - ) - def test_clean_document_too_large(self): - self.account.verification_fields_needed = [ - "legal_entity.verification.document" - ] - form = AdditionalCustomAccountForm( - self.data, - account=self.account, - files={"document": get_image()} - ) - self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors["document"], - [u"Document image is too large (> 0.0 MB)"] - ) - - def test_clean_document_wrong_type(self): - self.account.verification_fields_needed = [ - "legal_entity.verification.document" - ] - form = AdditionalCustomAccountForm( - self.data, - account=self.account, - files={"document": get_image(name="donkey.gif", _type="image/gif")} - ) - self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors["document"], - [u"The type of image you supplied is not supported. Please upload a JPG or PNG file."] - ) - - def test_clean_dob_too_old(self): - data = copy(self.data) - data["dob"] = "1780-01-01" - form = AdditionalCustomAccountForm( - data, - account=self.account - ) - self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors["dob"], - [u"This must be greater than 1900-01-01."] - ) - - @patch("pinax.stripe.actions.accounts.sync_account_from_stripe_data") - @patch("stripe.Account.retrieve") - @patch("stripe.FileUpload.create") - def test_save(self, file_upload_mock, retrieve_mock, sync_mock): - self.account.verification_fields_needed = [ - "legal_entity.personal_id_number" - ] - form = AdditionalCustomAccountForm( - self.data, - account=self.account - ) - self.assertTrue(form.is_valid()) - form.save() - self.assertEqual( - retrieve_mock.return_value.legal_entity.first_name, - "Donkey" - ) - self.assertEqual( - retrieve_mock.return_value.legal_entity.personal_id_number, - "123123123" - ) - self.assertFalse(file_upload_mock.called) - - @patch("pinax.stripe.actions.accounts.sync_account_from_stripe_data") - @patch("stripe.Account.retrieve") - @patch("stripe.FileUpload.create") - def test_save_with_document(self, file_upload_mock, retrieve_mock, sync_mock): - file_upload_mock.return_value = {"id": 5555} - self.account.verification_fields_needed = [ - "legal_entity.personal_id_number", - "legal_entity.verification.document" - ] - form = AdditionalCustomAccountForm( - self.data, - account=self.account, - files={"document": get_image()} - ) - self.assertTrue(form.is_valid()) - form.save() - self.assertEqual( - retrieve_mock.return_value.legal_entity.first_name, - "Donkey" - ) - self.assertEqual( - retrieve_mock.return_value.legal_entity.personal_id_number, - "123123123" - ) - self.assertTrue(file_upload_mock.called) - self.assertEqual( - retrieve_mock.return_value.legal_entity.verification.document, - file_upload_mock.return_value["id"] - ) - - @patch("pinax.stripe.actions.accounts.sync_account_from_stripe_data") - @patch("stripe.Account.retrieve") - @patch("stripe.FileUpload.create") - def test_save_with_stripe_error(self, file_upload_mock, retrieve_mock, sync_mock): - retrieve_mock.return_value.save.side_effect = get_stripe_error() - self.account.verification_fields_needed = [ - "legal_entity.personal_id_number", - "legal_entity.verification.document" - ] - form = AdditionalCustomAccountForm( - self.data, - account=self.account, - files={"document": get_image()} - ) - self.assertTrue(form.is_valid()) - with self.assertRaises(InvalidRequestError): - form.save() - self.assertEqual( - form.errors["dob"], - [u"This value must be greater than 1900 (it currently is '1800')."] - ) diff --git a/pinax/stripe/tests/test_hooks.py b/pinax/stripe/tests/test_hooks.py deleted file mode 100644 index ea87714ee..000000000 --- a/pinax/stripe/tests/test_hooks.py +++ /dev/null @@ -1,81 +0,0 @@ -import decimal - -from django.contrib.auth import get_user_model -from django.core import mail -from django.test import TestCase - -from ..hooks import DefaultHookSet -from ..models import Charge, Customer - - -class HooksTestCase(TestCase): - - def setUp(self): - self.User = get_user_model() - self.user = self.User.objects.create_user( - username="patrick", - email="paltman@example.com" - ) - self.customer = Customer.objects.create( - user=self.user, - stripe_id="cus_xxxxxxxxxxxxxxx" - ) - self.hookset = DefaultHookSet() - - def test_adjust_subscription_quantity(self): - new_qty = self.hookset.adjust_subscription_quantity(customer=None, plan=None, quantity=3) - self.assertEqual(new_qty, 3) - - def test_adjust_subscription_quantity_none(self): - new_qty = self.hookset.adjust_subscription_quantity(customer=None, plan=None, quantity=None) - self.assertEqual(new_qty, 1) - - def test_trial_period(self): - period = self.hookset.trial_period(self.user, "some plan") - self.assertIsNone(period) - - def test_send_receipt(self): - charge = Charge.objects.create( - stripe_id="ch_XXXXXX", - customer=self.customer, - source="card_01", - amount=decimal.Decimal("10.00"), - currency="usd", - paid=True, - refunded=False, - disputed=False, - receipt_sent=False - ) - self.hookset.send_receipt(charge) - self.assertTrue(Charge.objects.get(pk=charge.pk).receipt_sent) - - def test_send_receipt_with_email(self): - charge = Charge.objects.create( - stripe_id="ch_XXXXXX", - customer=self.customer, - source="card_01", - amount=decimal.Decimal("10.00"), - currency="usd", - paid=True, - refunded=False, - disputed=False, - receipt_sent=False - ) - self.hookset.send_receipt(charge, email="goose@topgun.com") - self.assertTrue(Charge.objects.get(pk=charge.pk).receipt_sent) - self.assertEqual(mail.outbox[0].to, ["goose@topgun.com"]) - - def test_send_receipt_already_sent(self): - charge = Charge.objects.create( - stripe_id="ch_XXXXXX", - customer=self.customer, - source="card_01", - amount=decimal.Decimal("10.00"), - currency="usd", - paid=True, - refunded=False, - disputed=False, - receipt_sent=True - ) - self.hookset.send_receipt(charge) - self.assertTrue(Charge.objects.get(pk=charge.pk).receipt_sent) diff --git a/pinax/stripe/tests/test_managers.py b/pinax/stripe/tests/test_managers.py deleted file mode 100644 index 5f1b787e7..000000000 --- a/pinax/stripe/tests/test_managers.py +++ /dev/null @@ -1,195 +0,0 @@ -import datetime -import decimal - -from django.contrib.auth import get_user_model -from django.test import TestCase -from django.utils import timezone - -from ..models import Charge, Customer, Plan, Subscription - - -class CustomerManagerTest(TestCase): - - def setUp(self): - User = get_user_model() - # 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, tzinfo=timezone.utc) - self.plan = Plan.objects.create( - stripe_id="p1", - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - self.plan2 = Plan.objects.create( - stripe_id="p2", - amount=5, - currency="usd", - interval="monthly", - interval_count=1, - name="Light" - ) - for i in range(10): - customer = Customer.objects.create( - user=User.objects.create_user(username="patrick{0}".format(i)), - stripe_id="cus_xxxxxxxxxxxxxx{0}".format(i) - ) - Subscription.objects.create( - stripe_id="sub_{}".format(i), - customer=customer, - plan=self.plan, - current_period_start=period_start, - current_period_end=period_end, - status="active", - start=start, - quantity=1 - ) - customer = Customer.objects.create( - user=User.objects.create_user(username="patrick{0}".format(11)), - stripe_id="cus_xxxxxxxxxxxxxx{0}".format(11) - ) - Subscription.objects.create( - stripe_id="sub_{}".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 - ) - customer = Customer.objects.create( - user=User.objects.create_user(username="patrick{0}".format(12)), - stripe_id="cus_xxxxxxxxxxxxxx{0}".format(12) - ) - Subscription.objects.create( - stripe_id="sub_{}".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( - Customer.objects.started_during(2013, 4).count(), - 0 - ) - - def test_started_during_has_records(self): - self.assertEqual( - Customer.objects.started_during(2013, 1).count(), - 12 - ) - - def test_canceled_during(self): - self.assertEqual( - Customer.objects.canceled_during(2013, 4).count(), - 1 - ) - - def test_canceled_all(self): - self.assertEqual( - Customer.objects.canceled().count(), - 1 - ) - - def test_active_all(self): - self.assertEqual( - Customer.objects.active().count(), - 11 - ) - - def test_started_plan_summary(self): - for plan in Customer.objects.started_plan_summary_for(2013, 1): - if plan["subscription__plan"] == self.plan: - self.assertEqual(plan["count"], 11) - if plan["subscription__plan"] == self.plan2: - self.assertEqual(plan["count"], 1) - - def test_active_plan_summary(self): - for plan in Customer.objects.active_plan_summary(): - if plan["subscription__plan"] == self.plan: - self.assertEqual(plan["count"], 10) - if plan["subscription__plan"] == self.plan2: - self.assertEqual(plan["count"], 1) - - def test_canceled_plan_summary(self): - for plan in Customer.objects.canceled_plan_summary_for(2013, 1): - if plan["subscription__plan"] == self.plan: - self.assertEqual(plan["count"], 1) - if plan["subscription__plan"] == self.plan2: - self.assertEqual(plan["count"], 0) - - def test_churn(self): - self.assertEqual( - Customer.objects.churn(), - decimal.Decimal("1") / decimal.Decimal("11") - ) - - -class ChargeManagerTests(TestCase): - - def setUp(self): - customer = Customer.objects.create( - user=get_user_model().objects.create_user(username="patrick"), - stripe_id="cus_xxxxxxxxxxxxxx" - ) - Charge.objects.create( - stripe_id="ch_1", - customer=customer, - charge_created=datetime.datetime(2013, 1, 1, tzinfo=timezone.utc), - paid=True, - amount=decimal.Decimal("100"), - amount_refunded=decimal.Decimal("0") - ) - Charge.objects.create( - stripe_id="ch_2", - customer=customer, - charge_created=datetime.datetime(2013, 1, 1, tzinfo=timezone.utc), - paid=True, - amount=decimal.Decimal("100"), - amount_refunded=decimal.Decimal("10") - ) - Charge.objects.create( - stripe_id="ch_3", - customer=customer, - charge_created=datetime.datetime(2013, 1, 1, tzinfo=timezone.utc), - paid=False, - amount=decimal.Decimal("100"), - amount_refunded=decimal.Decimal("0") - ) - Charge.objects.create( - stripe_id="ch_4", - customer=customer, - charge_created=datetime.datetime(2013, 4, 1, tzinfo=timezone.utc), - paid=True, - amount=decimal.Decimal("500"), - amount_refunded=decimal.Decimal("15.42") - ) - - def test_charges_during(self): - charges = Charge.objects.during(2013, 1) - self.assertEqual(charges.count(), 3) - - def test_paid_totals_for_jan(self): - totals = Charge.objects.paid_totals_for(2013, 1) - self.assertEqual(totals["total_amount"], decimal.Decimal("200")) - self.assertEqual(totals["total_refunded"], decimal.Decimal("10")) - - def test_paid_totals_for_apr(self): - totals = Charge.objects.paid_totals_for(2013, 4) - self.assertEqual(totals["total_amount"], decimal.Decimal("500")) - self.assertEqual(totals["total_refunded"], decimal.Decimal("15.42")) - - def test_paid_totals_for_dec(self): - totals = Charge.objects.paid_totals_for(2013, 12) - self.assertEqual(totals["total_amount"], None) - self.assertEqual(totals["total_refunded"], None) diff --git a/pinax/stripe/tests/test_middleware.py b/pinax/stripe/tests/test_middleware.py deleted file mode 100644 index c92c74dbf..000000000 --- a/pinax/stripe/tests/test_middleware.py +++ /dev/null @@ -1,119 +0,0 @@ -from django.contrib.auth import authenticate, get_user_model, login, logout -from django.test import TestCase -from django.utils import timezone - -from mock import Mock - -from ..conf import settings -from ..middleware import ActiveSubscriptionMiddleware -from ..models import Customer, Plan, Subscription - -try: - from django.urls import reverse -except ImportError: - from django.core.urlresolvers import reverse - - -class DummySession(dict): - - def cycle_key(self): - return - - def flush(self): - return - - -class ActiveSubscriptionMiddlewareTests(TestCase): - urls = "pinax.stripe.tests.urls" - - def setUp(self): - self.middleware = ActiveSubscriptionMiddleware() - self.request = Mock() - self.request.META = {} - self.request.session = DummySession() - - self.old_urls = settings.PINAX_STRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS - settings.PINAX_STRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS += ( - "signup", - "password_reset" - ) - - user = get_user_model().objects.create_user(username="patrick") - user.set_password("eldarion") - user.save() - user = authenticate(username="patrick", password="eldarion") - login(self.request, user) - - def tearDown(self): - settings.PINAX_STRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS = self.old_urls - - def test_authed_user_with_no_customer_redirects_on_non_exempt_url(self): - self.request.path = "/the/app/" - response = self.middleware.process_request(self.request) - self.assertEqual(response.status_code, 302) - self.assertEqual( - response._headers["location"][1], - reverse(settings.PINAX_STRIPE_SUBSCRIPTION_REQUIRED_REDIRECT) - ) - - def test_authed_user_with_no_customer_passes_with_exempt_url(self): - self.request.path = "/accounts/signup/" - response = self.middleware.process_request(self.request) - self.assertIsNone(response) - - def test_authed_user_with_no_customer_passes_with_exempt_url_containing_pattern(self): - self.request.path = "/password/reset/confirm/test-token/" - response = self.middleware.process_request(self.request) - self.assertIsNone(response) - - def test_authed_user_with_no_active_subscription_passes_with_exempt_url(self): - Customer.objects.create(stripe_id="cus_1", user=self.request.user) - self.request.path = "/accounts/signup/" - response = self.middleware.process_request(self.request) - self.assertIsNone(response) - - def test_authed_user_with_no_active_subscription_redirects_on_non_exempt_url(self): - Customer.objects.create(stripe_id="cus_1", user=self.request.user) - self.request.path = "/the/app/" - response = self.middleware.process_request(self.request) - self.assertEqual(response.status_code, 302) - self.assertEqual( - response._headers["location"][1], - reverse(settings.PINAX_STRIPE_SUBSCRIPTION_REQUIRED_REDIRECT) - ) - - def test_authed_user_with_active_subscription_redirects_on_non_exempt_url(self): - customer = Customer.objects.create( - stripe_id="cus_1", - user=self.request.user - ) - plan = Plan.objects.create( - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - Subscription.objects.create( - customer=customer, - plan=plan, - quantity=1, - start=timezone.now(), - status="active", - cancel_at_period_end=False - ) - self.request.path = "/the/app/" - response = self.middleware.process_request(self.request) - self.assertIsNone(response) - - def test_unauthed_user_passes(self): - logout(self.request) - self.request.path = "/the/app/" - response = self.middleware.process_request(self.request) - self.assertIsNone(response) - - def test_staff_user_passes(self): - self.request.user.is_staff = True - self.request.path = "/the/app/" - response = self.middleware.process_request(self.request) - self.assertIsNone(response) diff --git a/pinax/stripe/tests/test_models.py b/pinax/stripe/tests/test_models.py index ea9564eb9..128853b2e 100644 --- a/pinax/stripe/tests/test_models.py +++ b/pinax/stripe/tests/test_models.py @@ -14,20 +14,8 @@ from mock import call, patch from ..models import ( - Account, - BankAccount, - Card, - Charge, - Coupon, - Customer, Event, - EventProcessingException, - Invoice, - InvoiceItem, - Plan, - Subscription, - Transfer, - UserAccount + EventProcessingException ) try: @@ -40,46 +28,6 @@ class ModelTests(TestCase): - def test_plan_str_and_repr(self): - p = Plan(amount=decimal.Decimal("5"), name="My Plan", interval="monthly", interval_count=1) - self.assertTrue(p.name in _str(p)) - self.assertEqual(repr(p), "Plan(pk=None, name={p.name!r}, amount=Decimal('5'), currency={p.currency!r}, interval={p.interval!r}, interval_count=1, trial_period_days=None, stripe_id={p.stripe_id!r})".format(p=p)) - - def test_plan_repr_unicode(self): - p = Plan(amount=decimal.Decimal("5"), name=u"öre", interval="monthly", interval_count=1, stripe_id=u"öre") - if PY2: - self.assertEqual(repr(p), "Plan(pk=None, name=u'\\xf6re', amount=Decimal('5'), currency=u'', interval=u'monthly', interval_count=1, trial_period_days=None, stripe_id=u'\\xf6re')") - else: - self.assertEqual(repr(p), "Plan(pk=None, name='öre', amount=Decimal('5'), currency='', interval='monthly', interval_count=1, trial_period_days=None, stripe_id='öre')") - - def test_plan_str_usd(self): - p = Plan(amount=decimal.Decimal("5"), name="My Plan", currency="usd", interval="monthly", interval_count=1) - self.assertTrue(u"\u0024" in _str(p)) - - def test_plan_str_jpy(self): - p = Plan(amount=decimal.Decimal("5"), name="My Plan", currency="jpy", interval="monthly", interval_count=1) - self.assertTrue(u"\u00a5" in _str(p)) - - @patch("stripe.Plan.retrieve") - def test_plan_stripe_plan(self, RetrieveMock): - c = Plan(stripe_id="plan") - self.assertEqual(c.stripe_plan, RetrieveMock.return_value) - self.assertTrue(RetrieveMock.call_args_list, [ - call("plan", stripe_account=None)]) - - @patch("stripe.Plan.retrieve") - def test_plan_stripe_plan_with_account(self, RetrieveMock): - c = Plan(stripe_id="plan", stripe_account=Account(stripe_id="acct_A")) - self.assertEqual(c.stripe_plan, RetrieveMock.return_value) - self.assertTrue(RetrieveMock.call_args_list, [ - call("plan", stripe_account="acct_A")]) - - def test_plan_per_account(self): - Plan.objects.create(stripe_id="plan", amount=decimal.Decimal("100"), interval="monthly", interval_count=1) - account = Account.objects.create(stripe_id="acct_A") - Plan.objects.create(stripe_id="plan", stripe_account=account, amount=decimal.Decimal("100"), interval="monthly", interval_count=1) - self.assertEqual(Plan.objects.count(), 2) - def test_event_processing_exception_str(self): e = EventProcessingException(data="hello", message="hi there", traceback="fake") self.assertTrue("Event=" in str(e)) @@ -90,247 +38,17 @@ def test_event_str_and_repr(self): e = Event(kind="customer.deleted", webhook_message={}, created_at=created_at) self.assertTrue("customer.deleted" in str(e)) if PY2: - self.assertEqual(repr(e), "Event(pk=None, kind=u'customer.deleted', customer=None, valid=None, created_at={!s}, stripe_id=u'')".format( + self.assertEqual(repr(e), "Event(pk=None, kind=u'customer.deleted', customer=u'', valid=None, created_at={!s}, stripe_id=u'')".format( created_at_iso)) else: - self.assertEqual(repr(e), "Event(pk=None, kind='customer.deleted', customer=None, valid=None, created_at={!s}, stripe_id='')".format( + self.assertEqual(repr(e), "Event(pk=None, kind='customer.deleted', customer='', valid=None, created_at={!s}, stripe_id='')".format( created_at_iso)) e.stripe_id = "evt_X" - e.customer = Customer() + e.customer_id = "cus_YYY" if PY2: self.assertEqual(repr(e), "Event(pk=None, kind=u'customer.deleted', customer={!r}, valid=None, created_at={!s}, stripe_id=u'evt_X')".format( - e.customer, created_at_iso)) + e.customer_id, created_at_iso)) else: self.assertEqual(repr(e), "Event(pk=None, kind='customer.deleted', customer={!r}, valid=None, created_at={!s}, stripe_id='evt_X')".format( - e.customer, created_at_iso)) - - def test_customer_str_and_repr(self): - c = Customer() - self.assertEqual(str(c), "No User(s)") - if PY2: - self.assertEqual(repr(c), "Customer(pk=None, stripe_id=u'')") - else: - self.assertEqual(repr(c), "Customer(pk=None, stripe_id='')") - - def test_customer_with_user_str_and_repr(self): - User = get_user_model() - c = Customer(user=User()) - self.assertEqual(str(c), "") - if PY2: - self.assertEqual(repr(c), "Customer(pk=None, user=, stripe_id=u'')") - else: - self.assertEqual(repr(c), "Customer(pk=None, user=, stripe_id='')") - - def test_customer_saved_without_users_str(self): - c = Customer.objects.create() - self.assertEqual(str(c), "No User(s)") - c.stripe_id = "cu_XXX" - self.assertEqual(str(c), "No User(s) (cu_XXX)") - - def test_connected_customer_str_and_repr(self): - User = get_user_model() - user = User.objects.create() - account = Account.objects.create(stripe_id="acc_A") - customer = Customer.objects.create(stripe_id="cus_A", stripe_account=account) - UserAccount.objects.create(customer=customer, user=user, account=account) - self.assertEqual(str(customer), "") - if PY2: - self.assertEqual(repr(customer), "Customer(pk={c.pk}, users=, stripe_id=u'cus_A')".format(c=customer)) - else: - self.assertEqual(repr(customer), "Customer(pk={c.pk}, users=, stripe_id='cus_A')".format(c=customer)) - - def test_charge_repr(self): - charge = Charge() - if PY2: - self.assertEqual(repr(charge), "Charge(pk=None, customer=None, source=u'', amount=None, captured=None, paid=None, stripe_id=u'')") - else: - self.assertEqual(repr(charge), "Charge(pk=None, customer=None, source='', amount=None, captured=None, paid=None, stripe_id='')") - - def test_charge_str(self): - charge = Charge() - self.assertEqual(str(charge), "$0 (unpaid, uncaptured)") - charge.stripe_id = "ch_XXX" - charge.captured = True - charge.paid = True - charge.amount = decimal.Decimal(5) - self.assertEqual(str(charge), "$5") - charge.refunded = True - self.assertEqual(str(charge), "$5 (refunded)") - - def test_charge_total_amount(self): - charge = Charge() - self.assertEqual(charge.total_amount, 0) - charge.amount = decimal.Decimal(17) - self.assertEqual(charge.total_amount, 17) - charge.amount_refunded = decimal.Decimal(15.5) - self.assertEqual(charge.total_amount, 1.5) - - def test_plan_display_invoiceitem(self): - p = Plan(amount=decimal.Decimal("5"), name="My Plan", interval="monthly", interval_count=1) - p.save() - i = InvoiceItem(plan=p) - self.assertEqual(i.plan_display(), "My Plan") - - def test_coupon_percent(self): - c = Coupon(percent_off=25, duration="repeating", duration_in_months=3) - self.assertEqual(str(c), "Coupon for 25% off, repeating") - - def test_coupon_absolute(self): - c = Coupon(amount_off=decimal.Decimal(50.00), duration="once", currency="usd") - self.assertEqual(str(c), "Coupon for $50, once") - - def test_model_table_name(self): - self.assertEqual(Customer()._meta.db_table, "pinax_stripe_customer") - - def test_event_message(self): - event = Event(validated_message={"foo": 1}) - self.assertEqual(event.validated_message, event.message) - - def test_invoice_status(self): - self.assertEqual(Invoice(paid=True).status, "Paid") - - def test_invoice_status_not_paid(self): - self.assertEqual(Invoice(paid=False).status, "Open") - - def test_subscription_repr(self): - s = Subscription() - if PY2: - self.assertEqual(repr(s), "Subscription(pk=None, customer=None, plan=None, status=u'', stripe_id=u'')") - else: - self.assertEqual(repr(s), "Subscription(pk=None, customer=None, plan=None, status='', stripe_id='')") - s.customer = Customer() - s.plan = Plan() - s.status = "active" - s.stripe_id = "sub_X" - if PY2: - self.assertEqual( - repr(s), - "Subscription(pk=None, customer={o.customer!r}, plan={o.plan!r}, status=u'active', stripe_id=u'sub_X')".format(o=s)) - else: - self.assertEqual( - repr(s), - "Subscription(pk=None, customer={o.customer!r}, plan={o.plan!r}, status='active', stripe_id='sub_X')".format(o=s)) - - def test_subscription_total_amount(self): - sub = Subscription(plan=Plan(name="Pro Plan", amount=decimal.Decimal("100")), quantity=2) - self.assertEqual(sub.total_amount, decimal.Decimal("200")) - - def test_subscription_plan_display(self): - sub = Subscription(plan=Plan(name="Pro Plan")) - self.assertEqual(sub.plan_display(), "Pro Plan") - - def test_subscription_status_display(self): - sub = Subscription(status="overly_active") - self.assertEqual(sub.status_display(), "Overly Active") - - def test_subscription_delete(self): - plan = Plan.objects.create(stripe_id="pro2", amount=decimal.Decimal("100"), interval="monthly", interval_count=1) - customer = Customer.objects.create(stripe_id="foo") - sub = Subscription.objects.create(customer=customer, status="trialing", start=timezone.now(), plan=plan, quantity=1, cancel_at_period_end=True, current_period_end=(timezone.now() - datetime.timedelta(days=2))) - sub.delete() - self.assertIsNone(sub.status) - self.assertEqual(sub.quantity, 0) - self.assertEqual(sub.amount, 0) - - def test_account_str_and_repr(self): - a = Account() - self.assertEqual(str(a), " - ") - if PY2: - self.assertEqual(repr(a), "Account(pk=None, display_name=u'', type=None, authorized=True, stripe_id=u'')") - else: - self.assertEqual(repr(a), "Account(pk=None, display_name='', type=None, authorized=True, stripe_id='')") - a.stripe_id = "acct_X" - self.assertEqual(str(a), " - acct_X") - if PY2: - self.assertEqual(repr(a), "Account(pk=None, display_name=u'', type=None, authorized=True, stripe_id=u'acct_X')") - else: - self.assertEqual(repr(a), "Account(pk=None, display_name='', type=None, authorized=True, stripe_id='acct_X')") - a.display_name = "Display name" - a.authorized = False - self.assertEqual(str(a), "Display name - acct_X") - if PY2: - self.assertEqual(repr(a), "Account(pk=None, display_name=u'Display name', type=None, authorized=False, stripe_id=u'acct_X')") - else: - self.assertEqual(repr(a), "Account(pk=None, display_name='Display name', type=None, authorized=False, stripe_id='acct_X')") - - @patch("stripe.Subscription.retrieve") - def test_subscription_stripe_subscription_with_connnect(self, RetrieveMock): - a = Account(stripe_id="acc_X") - c = Customer(stripe_id="cus_X", stripe_account=a) - s = Subscription(stripe_id="sub_X", customer=c) - s.stripe_subscription - RetrieveMock.assert_called_once_with("sub_X", stripe_account="acc_X") - - def test_customer_required_fields(self): - c = Customer(stripe_id="cus_A") - c.full_clean() - - def test_user_account_validation(self): - User = get_user_model() - a = Account() - ua = UserAccount(user=User(), account=a, customer=Customer(stripe_account=Account())) - with self.assertRaises(ValidationError): - ua.clean() - - def test_user_account_repr(self): - User = get_user_model() - ua = UserAccount(user=User(), account=Account(), customer=Customer()) - self.assertEqual( - repr(ua), - "UserAccount(pk=None, user=, account={o.account!r}, customer={o.customer!r})".format( - o=ua)) - - def test_card_repr(self): - card = Card(exp_month=1, exp_year=2000) - self.assertEqual(repr(card), "Card(pk=None, customer=None)") - - card.customer = Customer.objects.create() - card.save() - self.assertEqual(repr(card), "Card(pk={c.pk}, customer={c.customer!r})".format(c=card)) - - def test_blank_with_null(self): - import inspect - import pinax.stripe.models - - clsmembers = inspect.getmembers(pinax.stripe.models, inspect.isclass) - classes = [x[1] for x in clsmembers - if issubclass(x[1], models.Model)] - - for klass in classes[0:1]: - for f in klass._meta.fields: - if f.null: - self.assertTrue(f.blank, msg="%s.%s should be blank=True" % (klass.__name__, f.name)) - - -class StripeObjectTests(TestCase): - - @patch("stripe.Charge.retrieve") - def test_stripe_charge(self, RetrieveMock): - Charge().stripe_charge - self.assertTrue(RetrieveMock.called) - - @patch("stripe.Customer.retrieve") - def test_stripe_customer(self, RetrieveMock): - Customer().stripe_customer - self.assertTrue(RetrieveMock.called) - - @patch("stripe.Invoice.retrieve") - def test_stripe_invoice(self, RetrieveMock): - Invoice().stripe_invoice - self.assertTrue(RetrieveMock.called) - - @patch("stripe.Subscription.retrieve") - def test_stripe_subscription(self, RetrieveMock): - Subscription(stripe_id="sub_X", customer=Customer(stripe_id="foo")).stripe_subscription - RetrieveMock.assert_called_once_with("sub_X", stripe_account=None) - - @patch("stripe.Transfer.retrieve") - def test_stripe_transfer(self, RetrieveMock): - Transfer(amount=10).stripe_transfer - self.assertTrue(RetrieveMock.called) - - @patch("stripe.Account.retrieve") - def test_stripe_bankaccount(self, RetrieveMock): - BankAccount(account=Account(stripe_id="foo")).stripe_bankaccount - self.assertTrue(RetrieveMock.return_value.external_accounts.retrieve.called) + e.customer_id, created_at_iso)) diff --git a/pinax/stripe/tests/test_views.py b/pinax/stripe/tests/test_views.py deleted file mode 100644 index b175aa699..000000000 --- a/pinax/stripe/tests/test_views.py +++ /dev/null @@ -1,484 +0,0 @@ -from django.contrib.auth import get_user_model -from django.test import TestCase -from django.utils import timezone - -import stripe -from mock import patch - -from ..models import Card, Customer, Invoice, Plan, Subscription -from ..views import PaymentMethodCreateView - -try: - from django.urls import reverse -except ImportError: - from django.core.urlresolvers import reverse - - -class PaymentsContextMixinTests(TestCase): - - def test_payments_context_mixin_get_context_data(self): - data = PaymentMethodCreateView().get_context_data() - self.assertTrue("PINAX_STRIPE_PUBLIC_KEY" in data) - - -class InvoiceListViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - customer = Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - Invoice.objects.create( - stripe_id="inv_001", - customer=customer, - amount_due=100, - period_end=timezone.now(), - period_start=timezone.now(), - subtotal=100, - total=100, - date=timezone.now() - ) - Invoice.objects.create( - stripe_id="inv_002", - customer=customer, - amount_due=50, - period_end=timezone.now(), - period_start=timezone.now(), - subtotal=50, - total=50, - date=timezone.now() - ) - - def test_context(self): - self.client.login(username=self.user.username, password=self.password) - response = self.client.get( - reverse("pinax_stripe_invoice_list") - ) - self.assertTrue("invoice_list" in response.context_data) - self.assertEqual(response.context_data["invoice_list"].count(), 2) - self.assertEqual(response.context_data["invoice_list"][0].total, 100) - self.assertEqual(response.context_data["invoice_list"][1].total, 50) - - -class PaymentMethodListViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - customer = Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - Card.objects.create( - stripe_id="card_001", - customer=customer, - address_line_1_check="nothing", - address_zip_check="nothing", - country="US", - cvc_check="passed", - exp_month=1, - exp_year=2020, - funding="yes", - fingerprint="abc" - ) - - def test_context(self): - self.client.login(username=self.user.username, password=self.password) - response = self.client.get( - reverse("pinax_stripe_payment_method_list") - ) - self.assertTrue("payment_method_list" in response.context_data) - self.assertEqual(response.context_data["payment_method_list"].count(), 1) - self.assertEqual(response.context_data["payment_method_list"][0].stripe_id, "card_001") - - -class PaymentMethodCreateViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - - @patch("pinax.stripe.actions.sources.create_card") - def test_post(self, CreateMock): - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_payment_method_create"), - {} - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse("pinax_stripe_payment_method_list")) - - @patch("pinax.stripe.actions.sources.create_card") - def test_post_on_error(self, CreateMock): - CreateMock.side_effect = stripe.error.CardError("Bad card", "Param", "CODE") - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_payment_method_create"), - {} - ) - self.assertEqual(response.status_code, 200) - self.assertTrue("errors" in response.context_data) - - -class PaymentMethodDeleteViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - customer = Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - self.card = Card.objects.create( - stripe_id="card_001", - customer=customer, - address_line_1_check="nothing", - address_zip_check="nothing", - country="US", - cvc_check="passed", - exp_month=1, - exp_year=2020, - funding="yes", - fingerprint="abc" - ) - - @patch("pinax.stripe.actions.sources.delete_card") - def test_post(self, CreateMock): - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_payment_method_delete", args=[self.card.pk]), - {} - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse("pinax_stripe_payment_method_list")) - - @patch("pinax.stripe.actions.sources.delete_card") - def test_post_on_error(self, CreateMock): - CreateMock.side_effect = stripe.error.CardError("Bad card", "Param", "CODE") - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_payment_method_delete", args=[self.card.pk]), - {} - ) - self.assertEqual(response.status_code, 200) - self.assertTrue("errors" in response.context_data) - - -class PaymentMethodUpdateViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - customer = Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - self.card = Card.objects.create( - stripe_id="card_001", - customer=customer, - address_line_1_check="nothing", - address_zip_check="nothing", - country="US", - cvc_check="passed", - exp_month=1, - exp_year=2020, - funding="yes", - fingerprint="abc" - ) - - @patch("pinax.stripe.actions.sources.update_card") - def test_post(self, CreateMock): - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_payment_method_update", args=[self.card.pk]), - { - "expMonth": 1, - "expYear": 2018 - } - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse("pinax_stripe_payment_method_list")) - - @patch("pinax.stripe.actions.sources.update_card") - def test_post_invalid_form(self, CreateMock): - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_payment_method_update", args=[self.card.pk]), - { - "expMonth": 13, - "expYear": 2014 - } - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context_data["form"].is_valid(), False) - - @patch("pinax.stripe.actions.sources.update_card") - def test_post_on_error(self, CreateMock): - CreateMock.side_effect = stripe.error.CardError("Bad card", "Param", "CODE") - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_payment_method_update", args=[self.card.pk]), - { - "expMonth": 1, - "expYear": 2018 - } - ) - self.assertEqual(response.status_code, 200) - self.assertTrue("errors" in response.context_data) - - -class SubscriptionListViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - customer = Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - plan = Plan.objects.create( - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - Subscription.objects.create( - stripe_id="sub_001", - customer=customer, - plan=plan, - quantity=1, - start=timezone.now(), - status="active" - ) - - def test_context(self): - self.client.login(username=self.user.username, password=self.password) - response = self.client.get( - reverse("pinax_stripe_subscription_list") - ) - self.assertTrue("subscription_list" in response.context_data) - self.assertEqual(response.context_data["subscription_list"].count(), 1) - self.assertEqual(response.context_data["subscription_list"][0].stripe_id, "sub_001") - - -class SubscriptionCreateViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - self.plan = Plan.objects.create( - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - - @patch("pinax.stripe.actions.subscriptions.create") - def test_post(self, CreateMock): - Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_subscription_create"), - { - "plan": self.plan.id - } - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse("pinax_stripe_subscription_list")) - - @patch("pinax.stripe.actions.customers.create") - @patch("pinax.stripe.actions.subscriptions.create") - def test_post_no_prior_customer(self, CreateMock, CustomerCreateMock): - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_subscription_create"), - { - "plan": self.plan.id - } - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse("pinax_stripe_subscription_list")) - self.assertTrue(CustomerCreateMock.called) - - @patch("pinax.stripe.actions.sources.create_card") - def test_post_on_error(self, CreateMock): - Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - CreateMock.side_effect = stripe.error.StripeError("Bad Mojo", "Param", "CODE") - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_subscription_create"), - { - "plan": self.plan.id - } - ) - self.assertEqual(response.status_code, 200) - self.assertTrue("errors" in response.context_data) - - -class SubscriptionDeleteViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - customer = Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - plan = Plan.objects.create( - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - self.subscription = Subscription.objects.create( - stripe_id="sub_001", - customer=customer, - plan=plan, - quantity=1, - start=timezone.now(), - status="active" - ) - - @patch("pinax.stripe.actions.subscriptions.cancel") - def test_post(self, CancelMock): - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_subscription_delete", args=[self.subscription.pk]), - {} - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse("pinax_stripe_subscription_list")) - - @patch("pinax.stripe.actions.subscriptions.cancel") - def test_post_on_error(self, CancelMock): - CancelMock.side_effect = stripe.error.StripeError("Bad Foo", "Param", "CODE") - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_subscription_delete", args=[self.subscription.pk]), - {} - ) - self.assertEqual(response.status_code, 200) - self.assertTrue("errors" in response.context_data) - - -class SubscriptionUpdateViewTests(TestCase): - - def setUp(self): - self.password = "eldarion" - self.user = get_user_model().objects.create_user( - username="patrick", - password=self.password - ) - self.user.save() - customer = Customer.objects.create( - stripe_id="cus_1", - user=self.user - ) - plan = Plan.objects.create( - amount=10, - currency="usd", - interval="monthly", - interval_count=1, - name="Pro" - ) - self.subscription = Subscription.objects.create( - stripe_id="sub_001", - customer=customer, - plan=plan, - quantity=1, - start=timezone.now(), - status="active" - ) - - def test_get(self): - self.client.login(username=self.user.username, password=self.password) - response = self.client.get( - reverse("pinax_stripe_subscription_update", args=[self.subscription.pk]) - ) - self.assertEqual(response.status_code, 200) - self.assertTrue("form" in response.context_data) - self.assertTrue(response.context_data["form"].initial["plan"], self.subscription.plan) - - @patch("pinax.stripe.actions.subscriptions.update") - def test_post(self, UpdateMock): - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_subscription_update", args=[self.subscription.pk]), - { - "plan": self.subscription.plan.id - } - ) - self.assertEqual(response.status_code, 302) - self.assertRedirects(response, reverse("pinax_stripe_subscription_list")) - - @patch("pinax.stripe.actions.subscriptions.update") - def test_post_invalid(self, UpdateMock): - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_subscription_update", args=[self.subscription.pk]), - { - "plan": "not a real plan" - } - ) - self.assertEqual(response.status_code, 200) - self.assertTrue(len(response.context_data["form"].errors) > 0) - - @patch("pinax.stripe.actions.subscriptions.update") - def test_post_on_error(self, UpdateMock): - UpdateMock.side_effect = stripe.error.StripeError("Bad Foo", "Param", "CODE") - self.client.login(username=self.user.username, password=self.password) - response = self.client.post( - reverse("pinax_stripe_subscription_update", args=[self.subscription.pk]), - { - "plan": self.subscription.plan.id - } - ) - self.assertEqual(response.status_code, 200) - self.assertTrue("errors" in response.context_data) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index 5c9bbd0d9..0deb5867c 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -1,7 +1,5 @@ -import decimal import json -from django.contrib.auth import get_user_model from django.dispatch import Signal from django.test import TestCase from django.test.client import Client @@ -10,32 +8,14 @@ import stripe from mock import patch -from . import ( - PLAN_CREATED_TEST_DATA, - TRANSFER_CREATED_TEST_DATA, - TRANSFER_PENDING_TEST_DATA -) from ..models import ( - Account, - Customer, Event, EventProcessingException, - Plan, - Transfer ) from ..webhooks import ( - AccountApplicationDeauthorizeWebhook, - AccountExternalAccountCreatedWebhook, - AccountUpdatedWebhook, - ChargeCapturedWebhook, - CustomerDeletedWebhook, - CustomerSourceCreatedWebhook, - CustomerSourceDeletedWebhook, - CustomerSubscriptionCreatedWebhook, - CustomerUpdatedWebhook, - InvoiceCreatedWebhook, Webhook, - registry + registry, + AccountExternalAccountCreatedWebhook ) try: @@ -125,13 +105,10 @@ def test_webhook_with_transfer_event(self, TransferMock, StripeEventMock): self.assertTrue(Event.objects.filter(kind="transfer.created").exists()) @patch("stripe.Event.retrieve") - @patch("stripe.Transfer.retrieve") - def test_webhook_associated_with_stripe_account(self, TransferMock, StripeEventMock): + def test_webhook_associated_with_stripe_account(self, StripeEventMock): connect_event_data = self.event_data.copy() - account = Account.objects.create(stripe_id="acc_XXX") - connect_event_data["account"] = account.stripe_id + connect_event_data["account"] = "acc_XXX" StripeEventMock.return_value.to_dict.return_value = connect_event_data - TransferMock.return_value = connect_event_data["data"]["object"] msg = json.dumps(connect_event_data) resp = Client().post( reverse("pinax_stripe_webhook"), @@ -141,12 +118,9 @@ def test_webhook_associated_with_stripe_account(self, TransferMock, StripeEventM self.assertEqual(resp.status_code, 200) self.assertTrue(Event.objects.filter(kind="transfer.created").exists()) self.assertEqual( - Event.objects.filter(kind="transfer.created").first().stripe_account, - account + Event.objects.filter(kind="transfer.created").first().account_id, + "acc_XXX" ) - self.assertEqual(TransferMock.call_args_list, [ - [("ach_XXXXXXXXXXXX",), {"stripe_account": "acc_XXX"}], - ]) def test_webhook_duplicate_event(self): data = {"id": 123} @@ -158,9 +132,7 @@ def test_webhook_duplicate_event(self): content_type="application/json" ) self.assertEqual(resp.status_code, 200) - dupe_event_exception = EventProcessingException.objects.get() - self.assertEqual(dupe_event_exception.message, "Duplicate event record") - self.assertEqual(str(dupe_event_exception.data), '{"id": 123}') + self.assertEqual(Event.objects.filter(stripe_id="123").count(), 1) def test_webhook_event_mismatch(self): event = Event(kind="account.updated") @@ -186,10 +158,9 @@ def signal_handler(sender, *args, **kwargs): webhook.name = "mismatch name" # Not sure how this ever happens due to the registry webhook.send_signal() - @patch("pinax.stripe.actions.customers.link_customer") @patch("pinax.stripe.webhooks.Webhook.validate") @patch("pinax.stripe.webhooks.Webhook.process_webhook") - def test_process_exception_is_logged(self, ProcessWebhookMock, ValidateMock, LinkMock): + def test_process_exception_is_logged(self, ProcessWebhookMock, ValidateMock): # note: we choose an event type for which we do no processing event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=False) ProcessWebhookMock.side_effect = stripe.error.StripeError("Message", "error") @@ -197,10 +168,9 @@ def test_process_exception_is_logged(self, ProcessWebhookMock, ValidateMock, Lin AccountExternalAccountCreatedWebhook(event).process() self.assertTrue(EventProcessingException.objects.filter(event=event).exists()) - @patch("pinax.stripe.actions.customers.link_customer") @patch("pinax.stripe.webhooks.Webhook.validate") @patch("pinax.stripe.webhooks.Webhook.process_webhook") - def test_process_exception_is_logged_non_stripeerror(self, ProcessWebhookMock, ValidateMock, LinkMock): + def test_process_exception_is_logged_non_stripeerror(self, ProcessWebhookMock, ValidateMock): # note: we choose an event type for which we do no processing event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=False) ProcessWebhookMock.side_effect = Exception("generic exception") @@ -208,428 +178,9 @@ def test_process_exception_is_logged_non_stripeerror(self, ProcessWebhookMock, V AccountExternalAccountCreatedWebhook(event).process() self.assertTrue(EventProcessingException.objects.filter(event=event).exists()) - @patch("pinax.stripe.actions.customers.link_customer") @patch("pinax.stripe.webhooks.Webhook.validate") - def test_process_return_none(self, ValidateMock, LinkMock): + def test_process_return_none(self, ValidateMock): # note: we choose an event type for which we do no processing event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=False) self.assertIsNone(AccountExternalAccountCreatedWebhook(event).process()) - -class ChargeWebhookTest(TestCase): - - @patch("stripe.Charge.retrieve") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - def test_process_webhook(self, SyncMock, RetrieveMock): - event = Event.objects.create(kind=ChargeCapturedWebhook.name, webhook_message={}, valid=True, processed=False) - event.validated_message = dict(data=dict(object=dict(id=1))) - ChargeCapturedWebhook(event).process_webhook() - self.assertTrue(SyncMock.called) - _, kwargs = RetrieveMock.call_args - self.assertEqual(kwargs["expand"], ["balance_transaction"]) - self.assertEqual(kwargs["stripe_account"], None) - - @patch("stripe.Charge.retrieve") - @patch("pinax.stripe.actions.charges.sync_charge_from_stripe_data") - def test_process_webhook_connect(self, SyncMock, RetrieveMock): - account = Account.objects.create(stripe_id="acc_A") - event = Event.objects.create(kind=ChargeCapturedWebhook.name, webhook_message={}, valid=True, processed=False, stripe_account=account) - event.validated_message = dict(data=dict(object=dict(id=1))) - ChargeCapturedWebhook(event).process_webhook() - self.assertTrue(SyncMock.called) - _, kwargs = RetrieveMock.call_args - self.assertEqual(kwargs["expand"], ["balance_transaction"]) - self.assertEqual(kwargs["stripe_account"], "acc_A") - - -class CustomerDeletedWebhookTest(TestCase): - - def test_process_webhook_without_linked_customer(self): - event = Event.objects.create(kind=CustomerDeletedWebhook.name, webhook_message={}, valid=True, processed=False) - CustomerDeletedWebhook(event).process_webhook() - - def test_process_webhook_with_linked_customer(self): - User = get_user_model() - customer = Customer.objects.create(user=User.objects.create()) - self.assertIsNotNone(customer.user) - event = Event.objects.create(kind=CustomerDeletedWebhook.name, webhook_message={}, valid=True, processed=False, customer=customer) - CustomerDeletedWebhook(event).process_webhook() - self.assertIsNone(customer.user) - - -class CustomerUpdatedWebhookTest(TestCase): - - @patch("pinax.stripe.actions.customers.sync_customer") - def test_process_webhook_without_customer(self, SyncMock): - event = Event.objects.create(kind=CustomerUpdatedWebhook.name, webhook_message={}, valid=True, processed=False) - CustomerUpdatedWebhook(event).process_webhook() - self.assertEqual(SyncMock.call_count, 0) - - @patch("pinax.stripe.actions.customers.sync_customer") - def test_process_webhook_without_customer_with_data(self, SyncMock): - event = Event.objects.create(kind=CustomerUpdatedWebhook.name, webhook_message={}, valid=True, processed=False) - obj = object() - event.validated_message = dict(data=dict(object=obj)) - CustomerUpdatedWebhook(event).process_webhook() - self.assertEqual(SyncMock.call_count, 0) - - @patch("pinax.stripe.actions.customers.sync_customer") - def test_process_webhook_with_customer_with_data(self, SyncMock): - customer = Customer.objects.create() - event = Event.objects.create(kind=CustomerUpdatedWebhook.name, customer=customer, webhook_message={}, valid=True, processed=False) - obj = object() - event.validated_message = dict(data=dict(object=obj)) - CustomerUpdatedWebhook(event).process_webhook() - self.assertEqual(SyncMock.call_count, 1) - self.assertIs(SyncMock.call_args[0][0], customer) - self.assertIs(SyncMock.call_args[0][1], obj) - - -class CustomerSourceCreatedWebhookTest(TestCase): - - @patch("pinax.stripe.actions.sources.sync_payment_source_from_stripe_data") - def test_process_webhook(self, SyncMock): - event = Event.objects.create(kind=CustomerSourceCreatedWebhook.name, webhook_message={}, valid=True, processed=False) - event.validated_message = dict(data=dict(object=dict())) - CustomerSourceCreatedWebhook(event).process_webhook() - self.assertTrue(SyncMock.called) - - -class CustomerSourceDeletedWebhookTest(TestCase): - - @patch("pinax.stripe.actions.sources.delete_card_object") - def test_process_webhook(self, SyncMock): - event = Event.objects.create(kind=CustomerSourceDeletedWebhook.name, webhook_message={}, valid=True, processed=False) - event.validated_message = dict(data=dict(object=dict(id=1))) - CustomerSourceDeletedWebhook(event).process_webhook() - self.assertTrue(SyncMock.called) - - -class PlanCreatedWebhookTest(TestCase): - - @patch("stripe.Event.retrieve") - def test_plan_created(self, EventMock): - ev = EventMock() - ev.to_dict.return_value = PLAN_CREATED_TEST_DATA - event = Event.objects.create( - stripe_id=PLAN_CREATED_TEST_DATA["id"], - kind="plan.created", - livemode=True, - webhook_message=PLAN_CREATED_TEST_DATA, - validated_message=PLAN_CREATED_TEST_DATA, - valid=True - ) - registry.get(event.kind)(event).process() - self.assertEqual(Plan.objects.all().count(), 1) - - -class PlanUpdatedWebhookTest(TestCase): - - @patch("stripe.Event.retrieve") - def test_plan_created(self, EventMock): - Plan.objects.create( - stripe_id="gold1", - name="Gold Plan", - interval="month", - interval_count=1, - amount=decimal.Decimal("9.99") - ) - ev = EventMock() - ev.to_dict.return_value = PLAN_CREATED_TEST_DATA - event = Event.objects.create( - stripe_id=PLAN_CREATED_TEST_DATA["id"], - kind="plan.updated", - livemode=True, - webhook_message=PLAN_CREATED_TEST_DATA, - validated_message=PLAN_CREATED_TEST_DATA, - valid=True - ) - registry.get(event.kind)(event).process() - plan = Plan.objects.get(stripe_id="gold1") - self.assertEqual(plan.name, PLAN_CREATED_TEST_DATA["data"]["object"]["name"]) - - -class CustomerSubscriptionCreatedWebhookTest(TestCase): - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.customers.sync_customer") - def test_process_webhook(self, SyncMock, SubSyncMock): - event = Event.objects.create( - kind=CustomerSubscriptionCreatedWebhook.name, - customer=Customer.objects.create(), - validated_message={"data": {"object": {}}}, - valid=True, - processed=False) - CustomerSubscriptionCreatedWebhook(event).process_webhook() - self.assertTrue(SyncMock.called) - self.assertTrue(SubSyncMock.called) - - @patch("pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data") - @patch("pinax.stripe.actions.customers.sync_customer") - def test_process_webhook_no_customer(self, SyncMock, SubSyncMock): - event = Event.objects.create( - kind=CustomerSubscriptionCreatedWebhook.name, - validated_message={"data": {"object": {}}}, - valid=True, - processed=False) - CustomerSubscriptionCreatedWebhook(event).process_webhook() - self.assertFalse(SyncMock.called) - self.assertTrue(SubSyncMock.called) - - -class CustomerSubscriptionUpdatedWebhookTest(TestCase): - - WEBHOOK_MESSAGE_DATA = { - "object": {"livemode": False} - } - - VALIDATED_MESSAGE_DATA = { - "previous_attributes": {"days_until_due": 30, "billing": "send_invoice"}, - "object": {"livemode": False} - } - - VALIDATED_MESSAGE_DATA_NOT_VALID = { - "previous_attributes": {"days_until_due": 30, "billing": "send_invoice"}, - "object": {"livemode": True} - } - - def test_is_event_valid_yes(self): - self.assertTrue(Webhook.is_event_valid(self.WEBHOOK_MESSAGE_DATA, self.VALIDATED_MESSAGE_DATA)) - - def test_is_event_valid_no(self): - self.assertFalse(Webhook.is_event_valid(self.WEBHOOK_MESSAGE_DATA, self.VALIDATED_MESSAGE_DATA_NOT_VALID)) - - -class InvoiceCreatedWebhookTest(TestCase): - - @patch("pinax.stripe.actions.invoices.sync_invoice_from_stripe_data") - def test_process_webhook(self, SyncMock): - event = Event.objects.create(kind=InvoiceCreatedWebhook.name, webhook_message={}, valid=True, processed=False) - event.validated_message = dict(data=dict(object=dict(id=1))) - InvoiceCreatedWebhook(event).process_webhook() - self.assertTrue(SyncMock.called) - - -class TestTransferWebhooks(TestCase): - - @patch("stripe.Event.retrieve") - @patch("stripe.Transfer.retrieve") - def test_transfer_created(self, TransferMock, EventMock): - ev = EventMock() - ev.to_dict.return_value = TRANSFER_CREATED_TEST_DATA - TransferMock.return_value = TRANSFER_CREATED_TEST_DATA["data"]["object"] - event = Event.objects.create( - stripe_id=TRANSFER_CREATED_TEST_DATA["id"], - kind="transfer.created", - livemode=True, - webhook_message=TRANSFER_CREATED_TEST_DATA, - validated_message=TRANSFER_CREATED_TEST_DATA, - valid=True - ) - registry.get(event.kind)(event).process() - transfer = Transfer.objects.get(stripe_id="tr_XXXXXXXXXXXX") - self.assertEqual(transfer.amount, decimal.Decimal("4.55")) - self.assertEqual(transfer.status, "paid") - - @patch("stripe.Event.retrieve") - @patch("stripe.Transfer.retrieve") - def test_transfer_pending_create(self, TransferMock, EventMock): - ev = EventMock() - ev.to_dict.return_value = TRANSFER_PENDING_TEST_DATA - TransferMock.return_value = TRANSFER_PENDING_TEST_DATA["data"]["object"] - event = Event.objects.create( - stripe_id=TRANSFER_PENDING_TEST_DATA["id"], - kind="transfer.created", - livemode=True, - webhook_message=TRANSFER_PENDING_TEST_DATA, - validated_message=TRANSFER_PENDING_TEST_DATA, - valid=True - ) - registry.get(event.kind)(event).process() - transfer = Transfer.objects.get(stripe_id="tr_adlkj2l3kj23") - self.assertEqual(transfer.amount, decimal.Decimal("9.41")) - self.assertEqual(transfer.status, "pending") - - @patch("stripe.Event.retrieve") - @patch("stripe.Transfer.retrieve") - def test_transfer_paid_updates_existing_record(self, TransferMock, EventMock): - ev = EventMock() - ev.to_dict.return_value = TRANSFER_CREATED_TEST_DATA - TransferMock.return_value = TRANSFER_CREATED_TEST_DATA["data"]["object"] - event = Event.objects.create( - stripe_id=TRANSFER_CREATED_TEST_DATA["id"], - kind="transfer.created", - livemode=True, - webhook_message=TRANSFER_CREATED_TEST_DATA, - validated_message=TRANSFER_CREATED_TEST_DATA, - valid=True - ) - registry.get(event.kind)(event).process() - data = { - "created": 1364658818, - "data": { - "object": { - "account": { - "bank_name": "BANK OF AMERICA, N.A.", - "country": "US", - "last4": "9999", - "object": "bank_account" - }, - "amount": 455, - "currency": "usd", - "date": 1364601600, - "description": "STRIPE TRANSFER", - "fee": 0, - "fee_details": [], - "id": "tr_XXXXXXXXXXXX", - "livemode": True, - "object": "transfer", - "other_transfers": [], - "status": "paid", - "summary": { - "adjustment_count": 0, - "adjustment_fee_details": [], - "adjustment_fees": 0, - "adjustment_gross": 0, - "charge_count": 1, - "charge_fee_details": [{ - "amount": 45, - "application": None, - "currency": "usd", - "description": None, - "type": "stripe_fee" - }], - "charge_fees": 45, - "charge_gross": 500, - "collected_fee_count": 0, - "collected_fee_gross": 0, - "collected_fee_refund_count": 0, - "collected_fee_refund_gross": 0, - "currency": "usd", - "net": 455, - "refund_count": 0, - "refund_fee_details": [], - "refund_fees": 0, - "refund_gross": 0, - "validation_count": 0, - "validation_fees": 0 - }, - "transactions": { - "count": 1, - "data": [{ - "amount": 500, - "created": 1364064631, - "description": None, - "fee": 45, - "fee_details": [{ - "amount": 45, - "application": None, - "currency": "usd", - "description": "Stripe processing fees", - "type": "stripe_fee" - }], - "id": "ch_XXXXXXXXXX", - "net": 455, - "type": "charge" - }], - "object": "list", - "url": "/v1/transfers/XX/transactions" - } - } - }, - "id": "evt_YYYYYYYYYYYY", - "livemode": True, - "object": "event", - "pending_webhooks": 1, - "type": "transfer.paid" - } - paid_event = Event.objects.create( - stripe_id=data["id"], - kind="transfer.paid", - livemode=True, - webhook_message=data, - validated_message=data, - valid=True - ) - registry.get(paid_event.kind)(paid_event).process() - transfer = Transfer.objects.get(stripe_id="tr_XXXXXXXXXXXX") - self.assertEqual(transfer.status, "paid") - - -class AccountWebhookTest(TestCase): - - @classmethod - def setUpClass(cls): - super(AccountWebhookTest, cls).setUpClass() - cls.account = Account.objects.create(stripe_id="acc_aa") - - @patch("stripe.Account.retrieve") - @patch("pinax.stripe.actions.accounts.sync_account_from_stripe_data") - def test_process_webhook(self, SyncMock, RetrieveMock): - event = Event.objects.create( - kind=AccountUpdatedWebhook.name, - webhook_message={}, - valid=True, - processed=False - ) - event.validated_message = dict(data=dict(object=dict(id=1))) - AccountUpdatedWebhook(event).process_webhook() - self.assertTrue(SyncMock.called) - - @patch("stripe.Event.retrieve") - def test_process_deauthorize(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_001"}}, - "account": self.account.stripe_id} - event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, - webhook_message=data, - ) - RetrieveMock.side_effect = stripe.error.PermissionError( - "The provided key 'sk_test_********************abcd' does not have access to account 'acc_aa' (or that account does not exist). Application access may have been revoked.") - AccountApplicationDeauthorizeWebhook(event).process() - self.assertTrue(event.valid) - self.assertTrue(event.processed) - self.account.refresh_from_db() - self.assertFalse(self.account.authorized) - - @patch("stripe.Event.retrieve") - def test_process_deauthorize_fake_response(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_001"}}, - "account": self.account.stripe_id} - event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, - webhook_message=data, - ) - RetrieveMock.side_effect = stripe.error.PermissionError( - "The provided key 'sk_test_********************ABCD' does not have access to account 'acc_aa' (or that account does not exist). Application access may have been revoked.") - with self.assertRaises(stripe.error.PermissionError): - AccountApplicationDeauthorizeWebhook(event).process() - - @patch("stripe.Event.retrieve") - def test_process_deauthorize_with_delete_account(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_002"}}, - "account": "acct_bb"} - event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, - webhook_message=data, - ) - RetrieveMock.side_effect = stripe.error.PermissionError( - "The provided key 'sk_test_********************abcd' does not have access to account 'acct_bb' (or that account does not exist). Application access may have been revoked.") - AccountApplicationDeauthorizeWebhook(event).process() - self.assertTrue(event.valid) - self.assertTrue(event.processed) - self.assertIsNone(event.stripe_account) - - @patch("stripe.Event.retrieve") - def test_process_deauthorize_without_account(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_001"}}} - event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, - webhook_message=data, - ) - RetrieveMock.return_value.to_dict.return_value = data - AccountApplicationDeauthorizeWebhook(event).process() - self.assertTrue(event.valid) - self.assertTrue(event.processed) - self.account.refresh_from_db() - self.assertTrue(self.account.authorized) diff --git a/pinax/stripe/urls.py b/pinax/stripe/urls.py index ec0db5f14..68c548870 100644 --- a/pinax/stripe/urls.py +++ b/pinax/stripe/urls.py @@ -1,30 +1,7 @@ from django.conf.urls import url -from .views import ( - InvoiceListView, - PaymentMethodCreateView, - PaymentMethodDeleteView, - PaymentMethodListView, - PaymentMethodUpdateView, - SubscriptionCreateView, - SubscriptionDeleteView, - SubscriptionListView, - SubscriptionUpdateView, - Webhook -) +from .views import Webhook urlpatterns = [ - url(r"^subscriptions/$", SubscriptionListView.as_view(), name="pinax_stripe_subscription_list"), - url(r"^subscriptions/create/$", SubscriptionCreateView.as_view(), name="pinax_stripe_subscription_create"), - url(r"^subscriptions/(?P\d+)/delete/$", SubscriptionDeleteView.as_view(), name="pinax_stripe_subscription_delete"), - url(r"^subscriptions/(?P\d+)/update/$", SubscriptionUpdateView.as_view(), name="pinax_stripe_subscription_update"), - - url(r"^payment-methods/$", PaymentMethodListView.as_view(), name="pinax_stripe_payment_method_list"), - url(r"^payment-methods/create/$", PaymentMethodCreateView.as_view(), name="pinax_stripe_payment_method_create"), - url(r"^payment-methods/(?P\d+)/delete/$", PaymentMethodDeleteView.as_view(), name="pinax_stripe_payment_method_delete"), - url(r"^payment-methods/(?P\d+)/update/$", PaymentMethodUpdateView.as_view(), name="pinax_stripe_payment_method_update"), - - url(r"^invoices/$", InvoiceListView.as_view(), name="pinax_stripe_invoice_list"), - url(r"^webhook/$", Webhook.as_view(), name="pinax_stripe_webhook"), ] diff --git a/pinax/stripe/views.py b/pinax/stripe/views.py index df6ba27b0..937a89ac1 100644 --- a/pinax/stripe/views.py +++ b/pinax/stripe/views.py @@ -1,212 +1,40 @@ import json from django.http import HttpResponse -from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.utils.encoding import smart_str from django.views.decorators.csrf import csrf_exempt -from django.views.generic import ( - DetailView, - FormView, - ListView, - TemplateView, - View -) -from django.views.generic.edit import FormMixin +from django.views.generic import View -import stripe - -from .actions import customers, events, exceptions, sources, subscriptions -from .conf import settings -from .forms import PaymentMethodForm, PlanForm -from .mixins import CustomerMixin, LoginRequiredMixin, PaymentsContextMixin -from .models import Card, Event, Invoice, Subscription - - -class InvoiceListView(LoginRequiredMixin, CustomerMixin, ListView): - model = Invoice - context_object_name = "invoice_list" - template_name = "pinax/stripe/invoice_list.html" - - def get_queryset(self): - return super(InvoiceListView, self).get_queryset().order_by("date") - - -class PaymentMethodListView(LoginRequiredMixin, CustomerMixin, ListView): - model = Card - context_object_name = "payment_method_list" - template_name = "pinax/stripe/paymentmethod_list.html" - - def get_queryset(self): - return super(PaymentMethodListView, self).get_queryset().order_by("created_at") - - -class PaymentMethodCreateView(LoginRequiredMixin, CustomerMixin, PaymentsContextMixin, TemplateView): - model = Card - template_name = "pinax/stripe/paymentmethod_create.html" - - def create_card(self, stripe_token): - sources.create_card(self.customer, token=stripe_token) - - def post(self, request, *args, **kwargs): - try: - self.create_card(request.POST.get("stripeToken")) - return redirect("pinax_stripe_payment_method_list") - except stripe.error.CardError as e: - return self.render_to_response(self.get_context_data(errors=smart_str(e))) - - -class PaymentMethodDeleteView(LoginRequiredMixin, CustomerMixin, DetailView): - model = Card - template_name = "pinax/stripe/paymentmethod_delete.html" - - def delete_card(self, stripe_id): - sources.delete_card(self.customer, stripe_id) - - def post(self, request, *args, **kwargs): - self.object = self.get_object() - try: - self.delete_card(self.object.stripe_id) - return redirect("pinax_stripe_payment_method_list") - except stripe.error.CardError as e: - return self.render_to_response(self.get_context_data(errors=smart_str(e))) - - -class PaymentMethodUpdateView(LoginRequiredMixin, CustomerMixin, PaymentsContextMixin, FormMixin, DetailView): - model = Card - form_class = PaymentMethodForm - template_name = "pinax/stripe/paymentmethod_update.html" - - def update_card(self, exp_month, exp_year): - sources.update_card(self.customer, self.object.stripe_id, exp_month=exp_month, exp_year=exp_year) - - def form_valid(self, form): - try: - self.update_card(form.cleaned_data["expMonth"], form.cleaned_data["expYear"]) - return redirect("pinax_stripe_payment_method_list") - except stripe.error.CardError as e: - return self.render_to_response(self.get_context_data(errors=smart_str(e))) - - def post(self, request, *args, **kwargs): - self.object = self.get_object() - form = self.get_form(form_class=self.form_class) - if form.is_valid(): - return self.form_valid(form) - else: - return self.form_invalid(form) - - -class SubscriptionListView(LoginRequiredMixin, CustomerMixin, ListView): - model = Subscription - context_object_name = "subscription_list" - template_name = "pinax/stripe/subscription_list.html" - - def get_queryset(self): - return super(SubscriptionListView, self).get_queryset().order_by("created_at") - - -class SubscriptionCreateView(LoginRequiredMixin, PaymentsContextMixin, CustomerMixin, FormView): - template_name = "pinax/stripe/subscription_create.html" - form_class = PlanForm - - @property - def tax_percent(self): - return settings.PINAX_STRIPE_SUBSCRIPTION_TAX_PERCENT - - def set_customer(self): - if self.customer is None: - self._customer = customers.create(self.request.user) - - def subscribe(self, customer, plan, token): - subscriptions.create(customer, plan, token=token, tax_percent=self.tax_percent) - - def form_valid(self, form): - self.set_customer() - try: - self.subscribe(self.customer, plan=form.cleaned_data["plan"], token=self.request.POST.get("stripeToken")) - return redirect("pinax_stripe_subscription_list") - except stripe.error.StripeError as e: - return self.render_to_response(self.get_context_data(form=form, errors=smart_str(e))) - - -class SubscriptionDeleteView(LoginRequiredMixin, CustomerMixin, DetailView): - model = Subscription - template_name = "pinax/stripe/subscription_delete.html" - - def cancel(self): - subscriptions.cancel(self.object) - - def post(self, request, *args, **kwargs): - self.object = self.get_object() - try: - self.cancel() - return redirect("pinax_stripe_subscription_list") - except stripe.error.StripeError as e: - return self.render_to_response(self.get_context_data(errors=smart_str(e))) - - -class SubscriptionUpdateView(LoginRequiredMixin, CustomerMixin, FormMixin, DetailView): - model = Subscription - form_class = PlanForm - template_name = "pinax/stripe/subscription_update.html" - - @property - def current_plan(self): - if not hasattr(self, "_current_plan"): - self._current_plan = self.object.plan - return self._current_plan - - def get_context_data(self, **kwargs): - context = super(SubscriptionUpdateView, self).get_context_data(**kwargs) - context.update({ - "form": self.get_form(form_class=self.form_class) - }) - return context - - def update_subscription(self, plan_id): - subscriptions.update(self.object, plan_id) - - def get_initial(self): - initial = super(SubscriptionUpdateView, self).get_initial() - initial.update({ - "plan": self.current_plan - }) - return initial - - def form_valid(self, form): - try: - self.update_subscription(form.cleaned_data["plan"]) - return redirect("pinax_stripe_subscription_list") - except stripe.error.StripeError as e: - return self.render_to_response(self.get_context_data(form=form, errors=smart_str(e))) - - def post(self, request, *args, **kwargs): - self.object = self.get_object() - form = self.get_form(form_class=self.form_class) - if form.is_valid(): - return self.form_valid(form) - else: - return self.form_invalid(form) +from .models import Event +from .webhooks import registry class Webhook(View): + def add_event(self, data): + kind = data["type"] + event = Event.objects.create( + account_id=data.get("account", ""), + stripe_id=data["id"], + kind=kind, + livemode=data["livemode"], + webhook_message=data, + api_version=data["api_version"], + request=data.get("request", {}).get("id", ""), + pending_webhooks=data["pending_webhooks"] + ) + WebhookClass = registry.get(kind) + if WebhookClass is not None: + webhook = WebhookClass(event) + webhook.process() + @method_decorator(csrf_exempt) def dispatch(self, *args, **kwargs): return super(Webhook, self).dispatch(*args, **kwargs) def post(self, request, *args, **kwargs): - body = smart_str(self.request.body) - data = json.loads(body) - event = Event.objects.filter(stripe_id=data["id"]).first() - if event: - exceptions.log_exception(body, "Duplicate event record", event=event) - else: - events.add_event( - stripe_id=data["id"], - kind=data["type"], - livemode=data["livemode"], - api_version=data["api_version"], - message=data - ) + data = json.loads(smart_str(self.request.body)) + if not Event.objects.filter(stripe_id=data["id"]).exists(): + self.add_event(data) return HttpResponse() diff --git a/pinax/stripe/webhooks.py b/pinax/stripe/webhooks.py index d40fea2d9..f5846d21b 100644 --- a/pinax/stripe/webhooks.py +++ b/pinax/stripe/webhooks.py @@ -1,4 +1,6 @@ import json +import sys +import traceback from django.dispatch import Signal @@ -6,17 +8,6 @@ from six import with_metaclass from . import models -from .actions import ( - accounts, - charges, - customers, - exceptions, - invoices, - plans, - sources, - subscriptions, - transfers -) from .conf import settings from .utils import obfuscate_secret_key @@ -87,12 +78,10 @@ def validate(self): For Connect accounts we must fetch the event using the `stripe_account` parameter. """ - self.stripe_account = models.Account.objects.filter( - stripe_id=self.event.webhook_message.get("account")).first() - self.event.stripe_account = self.stripe_account + self.stripe_account = self.event.webhook_message.get("account", None) evt = stripe.Event.retrieve( self.event.stripe_id, - stripe_account=getattr(self.stripe_account, "stripe_id", None) + stripe_account=self.stripe_account ) self.event.validated_message = json.loads( json.dumps( @@ -116,6 +105,16 @@ def send_signal(self): if signal: return signal.send(sender=self.__class__, event=self.event) + def log_exception(self, data, exception): + info = sys.exc_info() + info_formatted = "".join(traceback.format_exception(*info)) if info[1] is not None else "" + models.EventProcessingException.objects.create( + event=self.event, + data=data or "", + message=str(exception), + traceback=info_formatted + ) + def process(self): if self.event.processed: return @@ -124,7 +123,6 @@ def process(self): return try: - customers.link_customer(self.event) self.process_webhook() self.send_signal() self.event.processed = True @@ -133,22 +131,14 @@ def process(self): data = None if isinstance(e, stripe.error.StripeError): data = e.http_body - exceptions.log_exception(data=data, exception=e, event=self.event) + self.log_exception(data=data, exception=e) raise e def process_webhook(self): return -class AccountWebhook(Webhook): - - def process_webhook(self): - accounts.sync_account_from_stripe_data( - stripe.Account.retrieve(self.event.message["data"]["object"]["id"]) - ) - - -class AccountUpdatedWebhook(AccountWebhook): +class AccountUpdatedWebhook(Webhook): name = "account.updated" description = "Occurs whenever an account status or property has changed." @@ -173,16 +163,13 @@ def validate(self): super(AccountApplicationDeauthorizeWebhook, self).validate() except stripe.error.PermissionError as exc: if self.stripe_account: - stripe_account_id = self.stripe_account.stripe_id - if not(stripe_account_id in str(exc) and obfuscate_secret_key(settings.PINAX_STRIPE_SECRET_KEY) in str(exc)): + if not(self.stripe_account in str(exc) and obfuscate_secret_key(settings.PINAX_STRIPE_SECRET_KEY) in str(exc)): raise exc self.event.valid = True self.event.validated_message = self.event.webhook_message - def process_webhook(self): - if self.stripe_account is not None: - accounts.deauthorize(self.stripe_account) +# @@@ with signals not sure we need all these class AccountExternalAccountCreatedWebhook(Webhook): name = "account.external_account.created" @@ -239,61 +226,52 @@ class BitcoinReceiverTransactionCreatedWebhook(Webhook): description = "Occurs whenever bitcoin is pushed to a receiver." -class ChargeWebhook(Webhook): - - def process_webhook(self): - charges.sync_charge( - self.event.message["data"]["object"]["id"], - stripe_account=self.event.stripe_account_stripe_id, - ) - - -class ChargeCapturedWebhook(ChargeWebhook): +class ChargeCapturedWebhook(Webhook): name = "charge.captured" description = "Occurs whenever a previously uncaptured charge is captured." -class ChargeFailedWebhook(ChargeWebhook): +class ChargeFailedWebhook(Webhook): name = "charge.failed" description = "Occurs whenever a failed charge attempt occurs." -class ChargeRefundedWebhook(ChargeWebhook): +class ChargeRefundedWebhook(Webhook): name = "charge.refunded" description = "Occurs whenever a charge is refunded, including partial refunds." -class ChargeSucceededWebhook(ChargeWebhook): +class ChargeSucceededWebhook(Webhook): name = "charge.succeeded" description = "Occurs whenever a new charge is created and is successful." -class ChargeUpdatedWebhook(ChargeWebhook): +class ChargeUpdatedWebhook(Webhook): name = "charge.updated" description = "Occurs whenever a charge description or metadata is updated." -class ChargeDisputeClosedWebhook(ChargeWebhook): +class ChargeDisputeClosedWebhook(Webhook): name = "charge.dispute.closed" description = "Occurs when the dispute is resolved and the dispute status changes to won or lost." -class ChargeDisputeCreatedWebhook(ChargeWebhook): +class ChargeDisputeCreatedWebhook(Webhook): name = "charge.dispute.created" description = "Occurs whenever a customer disputes a charge with their bank (chargeback)." -class ChargeDisputeFundsReinstatedWebhook(ChargeWebhook): +class ChargeDisputeFundsReinstatedWebhook(Webhook): name = "charge.dispute.funds_reinstated" description = "Occurs when funds are reinstated to your account after a dispute is won." -class ChargeDisputeFundsWithdrawnWebhook(ChargeWebhook): +class ChargeDisputeFundsWithdrawnWebhook(Webhook): name = "charge.dispute.funds_withdrawn" description = "Occurs when funds are removed from your account due to a dispute." -class ChargeDisputeUpdatedWebhook(ChargeWebhook): +class ChargeDisputeUpdatedWebhook(Webhook): name = "charge.dispute.updated" description = "Occurs when the dispute is updated (usually with evidence)." @@ -322,20 +300,11 @@ class CustomerDeletedWebhook(Webhook): name = "customer.deleted" description = "Occurs whenever a customer is deleted." - def process_webhook(self): - if self.event.customer: - customers.purge_local(self.event.customer) - class CustomerUpdatedWebhook(Webhook): name = "customer.updated" description = "Occurs whenever any property of a customer changes." - def process_webhook(self): - if self.event.customer: - cu = self.event.message["data"]["object"] - customers.sync_customer(self.event.customer, cu) - class CustomerDiscountCreatedWebhook(Webhook): name = "customer.discount.created" @@ -352,91 +321,57 @@ class CustomerDiscountUpdatedWebhook(Webhook): description = "Occurs whenever a customer is switched from one coupon to another." -class CustomerSourceWebhook(Webhook): - - def process_webhook(self): - sources.sync_payment_source_from_stripe_data( - self.event.customer, - self.event.validated_message["data"]["object"] - ) - - -class CustomerSourceCreatedWebhook(CustomerSourceWebhook): +class CustomerSourceCreatedWebhook(Webhook): name = "customer.source.created" description = "Occurs whenever a new source is created for the customer." -class CustomerSourceDeletedWebhook(CustomerSourceWebhook): +class CustomerSourceDeletedWebhook(Webhook): name = "customer.source.deleted" description = "Occurs whenever a source is removed from a customer." - def process_webhook(self): - sources.delete_card_object(self.event.validated_message["data"]["object"]["id"]) - -class CustomerSourceUpdatedWebhook(CustomerSourceWebhook): +class CustomerSourceUpdatedWebhook(Webhook): name = "customer.source.updated" description = "Occurs whenever a source's details are changed." -class CustomerSubscriptionWebhook(Webhook): - - def process_webhook(self): - if self.event.validated_message: - subscriptions.sync_subscription_from_stripe_data( - self.event.customer, - self.event.validated_message["data"]["object"], - ) - - if self.event.customer: - customers.sync_customer(self.event.customer) - - -class CustomerSubscriptionCreatedWebhook(CustomerSubscriptionWebhook): +class CustomerSubscriptionCreatedWebhook(Webhook): name = "customer.subscription.created" description = "Occurs whenever a customer with no subscription is signed up for a plan." -class CustomerSubscriptionDeletedWebhook(CustomerSubscriptionWebhook): +class CustomerSubscriptionDeletedWebhook(Webhook): name = "customer.subscription.deleted" description = "Occurs whenever a customer ends their subscription." -class CustomerSubscriptionTrialWillEndWebhook(CustomerSubscriptionWebhook): +class CustomerSubscriptionTrialWillEndWebhook(Webhook): name = "customer.subscription.trial_will_end" description = "Occurs three days before the trial period of a subscription is scheduled to end." -class CustomerSubscriptionUpdatedWebhook(CustomerSubscriptionWebhook): +class CustomerSubscriptionUpdatedWebhook(Webhook): name = "customer.subscription.updated" description = "Occurs whenever a subscription changes. Examples would include switching from one plan to another, or switching status from trial to active." -class InvoiceWebhook(Webhook): - - def process_webhook(self): - invoices.sync_invoice_from_stripe_data( - self.event.validated_message["data"]["object"], - send_receipt=settings.PINAX_STRIPE_SEND_EMAIL_RECEIPTS - ) - - -class InvoiceCreatedWebhook(InvoiceWebhook): +class InvoiceCreatedWebhook(Webhook): name = "invoice.created" description = "Occurs whenever a new invoice is created. If you are using webhooks, Stripe will wait one hour after they have all succeeded to attempt to pay the invoice; the only exception here is on the first invoice, which gets created and paid immediately when you subscribe a customer to a plan. If your webhooks do not all respond successfully, Stripe will continue retrying the webhooks every hour and will not attempt to pay the invoice. After 3 days, Stripe will attempt to pay the invoice regardless of whether or not your webhooks have succeeded. See how to respond to a webhook." -class InvoicePaymentFailedWebhook(InvoiceWebhook): +class InvoicePaymentFailedWebhook(Webhook): name = "invoice.payment_failed" description = "Occurs whenever an invoice attempts to be paid, and the payment fails. This can occur either due to a declined payment, or because the customer has no active card. A particular case of note is that if a customer with no active card reaches the end of its free trial, an invoice.payment_failed notification will occur." -class InvoicePaymentSucceededWebhook(InvoiceWebhook): +class InvoicePaymentSucceededWebhook(Webhook): name = "invoice.payment_succeeded" description = "Occurs whenever an invoice attempts to be paid, and the payment succeeds." -class InvoiceUpdatedWebhook(InvoiceWebhook): +class InvoiceUpdatedWebhook(Webhook): name = "invoice.updated" description = "Occurs whenever an invoice changes (for example, the amount could change)." @@ -481,13 +416,7 @@ class PaymentCreatedWebhook(Webhook): description = "A payment has been received by a Connect account via Transfer from the platform account." -class PlanWebhook(Webhook): - - def process_webhook(self): - plans.sync_plan(self.event.message["data"]["object"], self.event) - - -class PlanCreatedWebhook(PlanWebhook): +class PlanCreatedWebhook(Webhook): name = "plan.created" description = "Occurs whenever a plan is created." @@ -497,7 +426,7 @@ class PlanDeletedWebhook(Webhook): description = "Occurs whenever a plan is deleted." -class PlanUpdatedWebhook(PlanWebhook): +class PlanUpdatedWebhook(Webhook): name = "plan.updated" description = "Occurs whenever a plan is updated." @@ -537,39 +466,27 @@ class SKUUpdatedWebhook(Webhook): description = "Occurs whenever a SKU is updated." -class TransferWebhook(Webhook): - - def process_webhook(self): - transfers.sync_transfer( - stripe.Transfer.retrieve( - self.event.message["data"]["object"]["id"], - stripe_account=self.event.stripe_account_stripe_id, - ), - self.event - ) - - -class TransferCreatedWebhook(TransferWebhook): +class TransferCreatedWebhook(Webhook): name = "transfer.created" description = "Occurs whenever a new transfer is created." -class TransferFailedWebhook(TransferWebhook): +class TransferFailedWebhook(Webhook): name = "transfer.failed" description = "Occurs whenever Stripe attempts to send a transfer and that transfer fails." -class TransferPaidWebhook(TransferWebhook): +class TransferPaidWebhook(Webhook): name = "transfer.paid" description = "Occurs whenever a sent transfer is expected to be available in the destination bank account. If the transfer failed, a transfer.failed webhook will additionally be sent at a later time. Note to Connect users: this event is only created for transfers from your connected Stripe accounts to their bank accounts, not for transfers to the connected accounts themselves." -class TransferReversedWebhook(TransferWebhook): +class TransferReversedWebhook(Webhook): name = "transfer.reversed" description = "Occurs whenever a transfer is reversed, including partial reversals." -class TransferUpdatedWebhook(TransferWebhook): +class TransferUpdatedWebhook(Webhook): name = "transfer.updated" description = "Occurs whenever the description or metadata of a transfer is updated." From 101d6bc6d62e7e08d7c6867a77e58b0b168afc17 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sun, 20 Jan 2019 12:40:01 -0600 Subject: [PATCH 02/49] Add migrations to remove all the models --- .../migrations/0015_auto_20190120_1239.py | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 pinax/stripe/migrations/0015_auto_20190120_1239.py diff --git a/pinax/stripe/migrations/0015_auto_20190120_1239.py b/pinax/stripe/migrations/0015_auto_20190120_1239.py new file mode 100644 index 000000000..ff9670958 --- /dev/null +++ b/pinax/stripe/migrations/0015_auto_20190120_1239.py @@ -0,0 +1,177 @@ +# Generated by Django 2.1.5 on 2019-01-20 18:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0014_auto_20180413_1959'), + ] + + operations = [ + migrations.RemoveField( + model_name='account', + name='user', + ), + migrations.RemoveField( + model_name='bankaccount', + name='account', + ), + migrations.RemoveField( + model_name='bitcoinreceiver', + name='customer', + ), + migrations.RemoveField( + model_name='card', + name='customer', + ), + migrations.RemoveField( + model_name='charge', + name='customer', + ), + migrations.RemoveField( + model_name='charge', + name='invoice', + ), + migrations.DeleteModel( + name='Coupon', + ), + migrations.RemoveField( + model_name='customer', + name='stripe_account', + ), + migrations.RemoveField( + model_name='customer', + name='user', + ), + migrations.RemoveField( + model_name='customer', + name='users', + ), + migrations.RemoveField( + model_name='invoice', + name='charge', + ), + migrations.RemoveField( + model_name='invoice', + name='customer', + ), + migrations.RemoveField( + model_name='invoice', + name='subscription', + ), + migrations.RemoveField( + model_name='invoiceitem', + name='invoice', + ), + migrations.RemoveField( + model_name='invoiceitem', + name='plan', + ), + migrations.RemoveField( + model_name='invoiceitem', + name='subscription', + ), + migrations.AlterUniqueTogether( + name='plan', + unique_together=set(), + ), + migrations.RemoveField( + model_name='plan', + name='stripe_account', + ), + migrations.RemoveField( + model_name='subscription', + name='customer', + ), + migrations.RemoveField( + model_name='subscription', + name='plan', + ), + migrations.RemoveField( + model_name='transfer', + name='event', + ), + migrations.RemoveField( + model_name='transfer', + name='stripe_account', + ), + migrations.RemoveField( + model_name='transferchargefee', + name='transfer', + ), + migrations.AlterUniqueTogether( + name='useraccount', + unique_together=set(), + ), + migrations.RemoveField( + model_name='useraccount', + name='account', + ), + migrations.RemoveField( + model_name='useraccount', + name='customer', + ), + migrations.RemoveField( + model_name='useraccount', + name='user', + ), + migrations.RemoveField( + model_name='event', + name='customer', + ), + migrations.RemoveField( + model_name='event', + name='stripe_account', + ), + migrations.AddField( + model_name='event', + name='account_id', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='event', + name='customer_id', + field=models.CharField(blank=True, max_length=200), + ), + migrations.DeleteModel( + name='Account', + ), + migrations.DeleteModel( + name='BankAccount', + ), + migrations.DeleteModel( + name='BitcoinReceiver', + ), + migrations.DeleteModel( + name='Card', + ), + migrations.DeleteModel( + name='Charge', + ), + migrations.DeleteModel( + name='Customer', + ), + migrations.DeleteModel( + name='Invoice', + ), + migrations.DeleteModel( + name='InvoiceItem', + ), + migrations.DeleteModel( + name='Plan', + ), + migrations.DeleteModel( + name='Subscription', + ), + migrations.DeleteModel( + name='Transfer', + ), + migrations.DeleteModel( + name='TransferChargeFee', + ), + migrations.DeleteModel( + name='UserAccount', + ), + ] From 6e3ba8df12a9f6fdd0b57d6e45a27233f2d4e993 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sun, 21 Apr 2019 15:55:11 -0500 Subject: [PATCH 03/49] Update version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 31fc32bfc..a04f7dee5 100644 --- a/setup.py +++ b/setup.py @@ -65,7 +65,7 @@ author_email=AUTHOR_EMAIL, description=DESCRIPTION, long_description=LONG_DESCRIPTION, - version="4.4.0", + version="5.0.0", license="MIT", url=URL, packages=find_packages(), From 34248686a7da20ce0668bb41949838cf3f5fe5b3 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sun, 21 Apr 2019 21:03:21 -0500 Subject: [PATCH 04/49] Update handling of webhooks --- pinax/stripe/conf.py | 2 +- .../migrations/0016_remove_event_request.py | 17 +++++++++++++++++ pinax/stripe/models.py | 1 - pinax/stripe/views.py | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 pinax/stripe/migrations/0016_remove_event_request.py diff --git a/pinax/stripe/conf.py b/pinax/stripe/conf.py index ff71e7054..24aca6eb7 100644 --- a/pinax/stripe/conf.py +++ b/pinax/stripe/conf.py @@ -8,7 +8,7 @@ class PinaxStripeAppConf(AppConf): PUBLIC_KEY = None SECRET_KEY = None - API_VERSION = "2015-10-16" + API_VERSION = "2019-03-14" class Meta: prefix = "pinax_stripe" diff --git a/pinax/stripe/migrations/0016_remove_event_request.py b/pinax/stripe/migrations/0016_remove_event_request.py new file mode 100644 index 000000000..8be09ee88 --- /dev/null +++ b/pinax/stripe/migrations/0016_remove_event_request.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1.5 on 2019-04-22 02:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0015_auto_20190120_1239'), + ] + + operations = [ + migrations.RemoveField( + model_name='event', + name='request', + ), + ] diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index 49f409c32..e1284e411 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -28,7 +28,6 @@ class Event(StripeObject): validated_message = JSONField(null=True, blank=True) valid = models.NullBooleanField(null=True, blank=True) processed = models.BooleanField(default=False) - request = models.CharField(max_length=100, blank=True) pending_webhooks = models.PositiveIntegerField(default=0) api_version = models.CharField(max_length=100, blank=True) diff --git a/pinax/stripe/views.py b/pinax/stripe/views.py index 937a89ac1..dc86abbf7 100644 --- a/pinax/stripe/views.py +++ b/pinax/stripe/views.py @@ -1,5 +1,6 @@ import json +from django.conf import settings from django.http import HttpResponse from django.utils.decorators import method_decorator from django.utils.encoding import smart_str @@ -21,7 +22,6 @@ def add_event(self, data): livemode=data["livemode"], webhook_message=data, api_version=data["api_version"], - request=data.get("request", {}).get("id", ""), pending_webhooks=data["pending_webhooks"] ) WebhookClass = registry.get(kind) From a13c0fb4760f8599c4f34175c180eafc50208838 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sun, 21 Apr 2019 21:21:26 -0500 Subject: [PATCH 05/49] Bump --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a04f7dee5..a37d414bc 100644 --- a/setup.py +++ b/setup.py @@ -65,7 +65,7 @@ author_email=AUTHOR_EMAIL, description=DESCRIPTION, long_description=LONG_DESCRIPTION, - version="5.0.0", + version="5.1.0", license="MIT", url=URL, packages=find_packages(), From 909ecdae79aa00bbeefbc50b885a990a583aa424 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 21 Feb 2020 22:40:58 -0600 Subject: [PATCH 06/49] Not ready for 3.0 yet --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a37d414bc..7d316c6f0 100644 --- a/setup.py +++ b/setup.py @@ -91,7 +91,7 @@ "django-appconf>=1.0.1", "jsonfield>=1.0.3", "stripe>=2.0", - "django>=1.8", + "django>=1.8,<3", "pytz", "six", "django-ipware==2.1.0" From aec2080ea673cc8695272f59ab92c2ec2c9209ad Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Tue, 28 Apr 2020 20:54:31 -0500 Subject: [PATCH 07/49] Upgrade to latest supported Django+Python matrix --- .circleci/config.yml | 152 ++++++++-------------------- makemigrations.py | 2 - pinax/__init__.py | 1 + pinax/stripe/__init__.py | 1 - pinax/stripe/admin.py | 5 +- pinax/stripe/models.py | 3 - pinax/stripe/tests/test_models.py | 12 +-- pinax/stripe/tests/test_webhooks.py | 12 +-- pinax/stripe/tests/urls.py | 2 - pinax/stripe/views.py | 1 - setup.py | 3 +- tox.ini | 22 ++-- 12 files changed, 60 insertions(+), 156 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 02657f6b2..2a426eb84 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -32,101 +32,65 @@ jobs: lint: <<: *common docker: - - image: circleci/python:3.6.1 + - image: circleci/python:3.8 environment: - TOXENV=checkqa,check_migrated - UPLOAD_COVERAGE=0 - py27dj18: + py35dj22: <<: *common docker: - - image: circleci/python:2.7 - environment: - TOXENV=py27-dj18-coverage - py27dj110: - <<: *common - docker: - - image: circleci/python:2.7 - environment: - TOXENV=py27-dj110-coverage - py27dj111: - <<: *common - docker: - - image: circleci/python:2.7 - environment: - TOXENV=py27-dj111-coverage - py34dj18: - <<: *common - docker: - - image: circleci/python:3.4 - environment: - TOXENV=py34-dj18-coverage - py34dj110: - <<: *common - docker: - - image: circleci/python:3.4 - environment: - TOXENV=py34-dj110-coverage - py34dj111: - <<: *common - docker: - - image: circleci/python:3.4 + - image: circleci/python:3.5 environment: - TOXENV=py34-dj111-coverage - py34dj20: + TOXENV=py35-dj22-coverage + py36dj22: <<: *common docker: - - image: circleci/python:3.4 + - image: circleci/python:3.6 environment: - TOXENV=py34-dj20-coverage - py35dj18: + TOXENV=py36-dj22-coverage + py36dj3: <<: *common docker: - - image: circleci/python:3.5 + - image: circleci/python:3.6 environment: - TOXENV=py35-dj18-coverage - py35dj110: + TOXENV=py36-dj22-coverage + py37dj22: <<: *common docker: - - image: circleci/python:3.5 + - image: circleci/python:3.7 environment: - TOXENV=py35-dj110-coverage - py35dj111: + TOXENV=py37-dj22-coverage + py37dj3: <<: *common docker: - - image: circleci/python:3.5 + - image: circleci/python:3.7 environment: - TOXENV=py35-dj111-coverage - py35dj20: + TOXENV=py37-dj3-coverage + py38dj22: <<: *common docker: - - image: circleci/python:3.5 + - image: circleci/python:3.8 environment: - TOXENV=py35-dj20-coverage - py36dj111: + TOXENV=py38-dj22-coverage + py38dj3: <<: *common docker: - - image: circleci/python:3.6 + - image: circleci/python:3.8 environment: - TOXENV=py36-dj111-coverage - py36dj20: + TOXENV=py38-dj3-coverage + py38dj3psql: <<: *common docker: - - image: circleci/python:3.6 + - image: circleci/python:3.8 environment: - TOXENV=py36-dj20-coverage - py36dj20psql: - <<: *common - docker: - - image: circleci/python:3.6 - environment: - - TOXENV=py36-dj20-postgres-coverage + - TOXENV=py38-dj3-postgres-coverage - PINAX_STRIPE_DATABASE_HOST=127.0.0.1 - PINAX_STRIPE_DATABASE_USER=postgres - PINAX_STRIPE_DATABASE_NAME=circle_test - - image: circleci/postgres:9.6-alpine + - image: circleci/postgres:11.7 release: docker: - - image: circleci/python:3.6 + - image: circleci/python:3.8 steps: - checkout - run: @@ -155,59 +119,35 @@ workflows: filters: tags: only: /.*/ - - py27dj18: - filters: - tags: - only: /.*/ - - py27dj110: - filters: - tags: - only: /.*/ - - py27dj111: - filters: - tags: - only: /.*/ - - py34dj18: - filters: - tags: - only: /.*/ - - py34dj110: - filters: - tags: - only: /.*/ - - py34dj111: - filters: - tags: - only: /.*/ - - py34dj20: + - py35dj22: filters: tags: only: /.*/ - - py35dj18: + - py36dj22: filters: tags: only: /.*/ - - py35dj110: + - py36dj3: filters: tags: only: /.*/ - - py35dj111: + - py37dj22: filters: tags: only: /.*/ - - py35dj20: + - py37dj3: filters: tags: only: /.*/ - - py36dj111: + - py38dj22: filters: tags: only: /.*/ - - py36dj20: + - py38dj3: filters: tags: only: /.*/ - - py36dj20psql: + - py38dj3psql: filters: tags: only: /.*/ @@ -215,20 +155,14 @@ workflows: context: org-global requires: - lint - - py27dj18 - - py27dj110 - - py27dj111 - - py34dj18 - - py34dj110 - - py34dj111 - - py34dj20 - - py35dj18 - - py35dj110 - - py35dj111 - - py35dj20 - - py36dj111 - - py36dj20 - - py36dj20psql + - py35dj22 + - py36dj3 + - py36dj22 + - py37dj3 + - py37dj22 + - py38dj3 + - py38dj22 + - py38dj3psql filters: tags: only: /[0-9]+(\.[0-9]+)*/ diff --git a/makemigrations.py b/makemigrations.py index 3c9966533..4c4460786 100644 --- a/makemigrations.py +++ b/makemigrations.py @@ -3,10 +3,8 @@ import sys import django - from django.conf import settings - DEFAULT_SETTINGS = dict( DEBUG=True, USE_TZ=True, diff --git a/pinax/__init__.py b/pinax/__init__.py index ef3b67872..6fda430aa 100644 --- a/pinax/__init__.py +++ b/pinax/__init__.py @@ -1,2 +1,3 @@ from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) # noqa diff --git a/pinax/stripe/__init__.py b/pinax/stripe/__init__.py index d37e68987..985d7dbca 100644 --- a/pinax/stripe/__init__.py +++ b/pinax/stripe/__init__.py @@ -1,5 +1,4 @@ import pkg_resources - default_app_config = "pinax.stripe.apps.AppConfig" __version__ = pkg_resources.get_distribution("pinax-stripe").version diff --git a/pinax/stripe/admin.py b/pinax/stripe/admin.py index 7c5b9c656..741497f61 100644 --- a/pinax/stripe/admin.py +++ b/pinax/stripe/admin.py @@ -2,10 +2,7 @@ from django.utils.encoding import force_text from django.utils.translation import ugettext as _ -from .models import ( - Event, - EventProcessingException -) +from .models import Event, EventProcessingException class ModelAdmin(admin.ModelAdmin): diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index e1284e411..087a28718 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -3,7 +3,6 @@ from django.db import models from django.utils import timezone -from django.utils.encoding import python_2_unicode_compatible from jsonfield.fields import JSONField @@ -17,7 +16,6 @@ class Meta: abstract = True -@python_2_unicode_compatible class Event(StripeObject): kind = models.CharField(max_length=250) @@ -49,7 +47,6 @@ def __repr__(self): ) -@python_2_unicode_compatible class EventProcessingException(models.Model): event = models.ForeignKey("Event", null=True, blank=True, on_delete=models.CASCADE) diff --git a/pinax/stripe/tests/test_models.py b/pinax/stripe/tests/test_models.py index 128853b2e..d00bf14b1 100644 --- a/pinax/stripe/tests/test_models.py +++ b/pinax/stripe/tests/test_models.py @@ -2,21 +2,11 @@ from __future__ import unicode_literals import datetime -import decimal import sys -from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError -from django.db import models from django.test import TestCase -from django.utils import timezone -from mock import call, patch - -from ..models import ( - Event, - EventProcessingException -) +from ..models import Event, EventProcessingException try: _str = unicode diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index 0deb5867c..dd9f57f4c 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -8,15 +8,8 @@ import stripe from mock import patch -from ..models import ( - Event, - EventProcessingException, -) -from ..webhooks import ( - Webhook, - registry, - AccountExternalAccountCreatedWebhook -) +from ..models import Event, EventProcessingException +from ..webhooks import AccountExternalAccountCreatedWebhook, Webhook, registry try: from django.urls import reverse @@ -183,4 +176,3 @@ def test_process_return_none(self, ValidateMock): # note: we choose an event type for which we do no processing event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=False) self.assertIsNone(AccountExternalAccountCreatedWebhook(event).process()) - diff --git a/pinax/stripe/tests/urls.py b/pinax/stripe/tests/urls.py index 272b0bfcd..51aea10ef 100644 --- a/pinax/stripe/tests/urls.py +++ b/pinax/stripe/tests/urls.py @@ -1,5 +1,4 @@ from django.conf.urls import url -from django.contrib import admin from ..urls import urlpatterns @@ -10,7 +9,6 @@ def __call__(self): urlpatterns += [ - url(r"^admin/", admin.site.urls), url(r"^the/app/$", FakeViewForUrl, name="the_app"), url(r"^accounts/signup/$", FakeViewForUrl, name="signup"), url(r"^password/reset/confirm/(?P.+)/$", FakeViewForUrl, diff --git a/pinax/stripe/views.py b/pinax/stripe/views.py index dc86abbf7..491fe939c 100644 --- a/pinax/stripe/views.py +++ b/pinax/stripe/views.py @@ -1,6 +1,5 @@ import json -from django.conf import settings from django.http import HttpResponse from django.utils.decorators import method_decorator from django.utils.encoding import smart_str diff --git a/setup.py b/setup.py index 7d316c6f0..411df6297 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,5 @@ from setuptools import find_packages, setup - NAME = "pinax-stripe" DESCRIPTION = "a payments Django app for Stripe" AUTHOR = "Pinax Team" @@ -91,7 +90,7 @@ "django-appconf>=1.0.1", "jsonfield>=1.0.3", "stripe>=2.0", - "django>=1.8,<3", + "django>=2.2", "pytz", "six", "django-ipware==2.1.0" diff --git a/tox.ini b/tox.ini index 2cf792f14..25ebd4830 100644 --- a/tox.ini +++ b/tox.ini @@ -18,10 +18,10 @@ show_missing = True [tox] envlist = checkqa - py27-dj{18,110,111} - py34-dj{18,110,111,20} - py35-dj{18,110,111,20} - py36-dj{111,20} + py35-dj{22} + py36-dj{22,3} + py37-dj{22,3} + py38-dj{22,3} [testenv] extras = testing @@ -32,11 +32,11 @@ passenv = PINAX_STRIPE_DATABASE_NAME PINAX_STRIPE_DATABASE_USER deps = + pytest + pytest-django coverage: pytest-cov - dj18: Django>=1.8,<1.9 - dj110: Django>=1.10,<1.11 - dj111: Django>=1.11a1,<2.0 - dj20: Django<2.1 + dj22: Django>=2.2,<3 + dj3: Django>=3.0,<3.1 master: https://github.com/django/django/tarball/master postgres: psycopg2-binary usedevelop = True @@ -51,9 +51,9 @@ commands = commands = flake8 pinax deps = - flake8 == 3.4.1 - flake8-isort == 2.2.2 - flake8-quotes == 0.11.0 + flake8 == 3.7.9 + flake8-isort == 3.0.0 + flake8-quotes == 3.0.0 [testenv:check_migrated] setenv = From d254318d505b382ae05a75fa472ac3717f3f7c29 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 15 Aug 2020 10:37:36 -0500 Subject: [PATCH 08/49] NullBooleanField is deprecated now --- .../migrations/0017_auto_20200815_1037.py | 18 ++++++++++++++++++ pinax/stripe/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 pinax/stripe/migrations/0017_auto_20200815_1037.py diff --git a/pinax/stripe/migrations/0017_auto_20200815_1037.py b/pinax/stripe/migrations/0017_auto_20200815_1037.py new file mode 100644 index 000000000..aad95dbf6 --- /dev/null +++ b/pinax/stripe/migrations/0017_auto_20200815_1037.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1 on 2020-08-15 15:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0016_remove_event_request'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='valid', + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index 087a28718..d7d33093c 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -24,7 +24,7 @@ class Event(StripeObject): account_id = models.CharField(max_length=200, blank=True) webhook_message = JSONField() validated_message = JSONField(null=True, blank=True) - valid = models.NullBooleanField(null=True, blank=True) + valid = models.BooleanField(null=True, blank=True) processed = models.BooleanField(default=False) pending_webhooks = models.PositiveIntegerField(default=0) api_version = models.CharField(max_length=100, blank=True) From bf2610b7790ec5f6ac064d219ae52f0c7842e1a4 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 24 Apr 2021 16:38:43 -0500 Subject: [PATCH 09/49] Fix deprecations --- CONTRIBUTING.md | 2 +- pinax/stripe/admin.py | 2 +- pinax/stripe/apps.py | 2 +- pinax/stripe/forms.py | 2 +- pinax/stripe/urls.py | 4 ++-- pinax/stripe/webhooks.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13e43be2d..e2c123877 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,7 +98,7 @@ Here is an example of these rules applied: from django.db import models from django.urls import reverse from django.utils import timezone - from django.utils.translation import ugettext_lazy as _ + from django.utils.translation import gettext_lazy as _ # third set of imports are external apps (if applicable) from tagging.fields import TagField diff --git a/pinax/stripe/admin.py b/pinax/stripe/admin.py index 741497f61..8b0536ffc 100644 --- a/pinax/stripe/admin.py +++ b/pinax/stripe/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.utils.encoding import force_text -from django.utils.translation import ugettext as _ +from django.utils.translation import gettext_lazy as _ from .models import Event, EventProcessingException diff --git a/pinax/stripe/apps.py b/pinax/stripe/apps.py index 737428743..39e348dc3 100644 --- a/pinax/stripe/apps.py +++ b/pinax/stripe/apps.py @@ -1,7 +1,7 @@ import importlib from django.apps import AppConfig as BaseAppConfig -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class AppConfig(BaseAppConfig): diff --git a/pinax/stripe/forms.py b/pinax/stripe/forms.py index 6019dc39a..04bb59116 100644 --- a/pinax/stripe/forms.py +++ b/pinax/stripe/forms.py @@ -2,7 +2,7 @@ import time from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ import stripe from ipware.ip import get_ip, get_real_ip diff --git a/pinax/stripe/urls.py b/pinax/stripe/urls.py index 68c548870..2bfa23897 100644 --- a/pinax/stripe/urls.py +++ b/pinax/stripe/urls.py @@ -1,7 +1,7 @@ -from django.conf.urls import url +from django.urls import path from .views import Webhook urlpatterns = [ - url(r"^webhook/$", Webhook.as_view(), name="pinax_stripe_webhook"), + path("webhook/", Webhook.as_view(), name="pinax_stripe_webhook"), ] diff --git a/pinax/stripe/webhooks.py b/pinax/stripe/webhooks.py index f5846d21b..0950b1d38 100644 --- a/pinax/stripe/webhooks.py +++ b/pinax/stripe/webhooks.py @@ -20,7 +20,7 @@ def __init__(self): def register(self, webhook): self._registry[webhook.name] = { "webhook": webhook, - "signal": Signal(providing_args=["event"]) + "signal": Signal() } def keys(self): From 0ecec0f5fcf42117b48873e842a631a1a92cf2c8 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 24 Apr 2021 23:22:37 -0500 Subject: [PATCH 10/49] No longer needed in Django 3.2 --- pinax/stripe/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pinax/stripe/__init__.py b/pinax/stripe/__init__.py index 985d7dbca..05275cb70 100644 --- a/pinax/stripe/__init__.py +++ b/pinax/stripe/__init__.py @@ -1,4 +1,3 @@ import pkg_resources -default_app_config = "pinax.stripe.apps.AppConfig" __version__ = pkg_resources.get_distribution("pinax-stripe").version From d860ddfdf15904426a585dc98d9f56f757f2734c Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:07:45 -0600 Subject: [PATCH 11/49] Update CI --- .circleci/config.yml | 170 ------------------ .github/workflows/ci.yaml | 23 +++ .../admin/pinax_stripe/change_form.html | 5 - pyproject.toml | 6 + requirements.testing.txt | 5 + setup.cfg | 52 +++++- setup.py | 102 ----------- test.sh | 6 + 8 files changed, 84 insertions(+), 285 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/ci.yaml delete mode 100644 pinax/stripe/templates/admin/pinax_stripe/change_form.html create mode 100644 pyproject.toml create mode 100644 requirements.testing.txt delete mode 100644 setup.py create mode 100755 test.sh diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 2a426eb84..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,170 +0,0 @@ -version: 2.0 - -common: &common - working_directory: ~/repo - steps: - - checkout - - restore_cache: - keys: - - v3-deps-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }}-{{ checksum "tox.ini" }} - - run: - name: install dependencies - command: pip install --user tox - - run: - name: run tox - command: ~/.local/bin/tox - - run: - name: upload coverage report - command: | - if [[ "${TOXENV%-coverage}" != "$TOXENV" ]]; then - .tox/$TOXENV/bin/coverage xml - bash <(curl -s https://codecov.io/bash) -Z -X gcov -X coveragepy -X fix -X search -X xcode -f coverage.xml -F $CIRCLE_JOB - fi - - save_cache: - paths: - - .tox - - ~/.cache/pip - - ~/.local - - ./eggs - key: v3-deps-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.py" }}-{{ checksum "tox.ini" }} - -jobs: - lint: - <<: *common - docker: - - image: circleci/python:3.8 - environment: - - TOXENV=checkqa,check_migrated - - UPLOAD_COVERAGE=0 - py35dj22: - <<: *common - docker: - - image: circleci/python:3.5 - environment: - TOXENV=py35-dj22-coverage - py36dj22: - <<: *common - docker: - - image: circleci/python:3.6 - environment: - TOXENV=py36-dj22-coverage - py36dj3: - <<: *common - docker: - - image: circleci/python:3.6 - environment: - TOXENV=py36-dj22-coverage - py37dj22: - <<: *common - docker: - - image: circleci/python:3.7 - environment: - TOXENV=py37-dj22-coverage - py37dj3: - <<: *common - docker: - - image: circleci/python:3.7 - environment: - TOXENV=py37-dj3-coverage - py38dj22: - <<: *common - docker: - - image: circleci/python:3.8 - environment: - TOXENV=py38-dj22-coverage - py38dj3: - <<: *common - docker: - - image: circleci/python:3.8 - environment: - TOXENV=py38-dj3-coverage - py38dj3psql: - <<: *common - docker: - - image: circleci/python:3.8 - environment: - - TOXENV=py38-dj3-postgres-coverage - - PINAX_STRIPE_DATABASE_HOST=127.0.0.1 - - PINAX_STRIPE_DATABASE_USER=postgres - - PINAX_STRIPE_DATABASE_NAME=circle_test - - image: circleci/postgres:11.7 - release: - docker: - - image: circleci/python:3.8 - steps: - - checkout - - run: - name: verify git tag vs. version - command: | - if [[ `python setup.py --version` == ${CIRCLE_TAG/v/} ]]; then - echo "Tag matches version, proceed with the release!"; - else - echo "Fix version in setup.py and re-tag so they match!"; exit 1 - fi - - run: - name: init .pypirc - command: | - echo -e "[pypi]" >> ~/.pypirc - echo -e "username = $PYPI_USERNAME" >> ~/.pypirc - echo -e "password = $PYPI_PASSWORD" >> ~/.pypirc - - run: - name: create and upload packages - command: python setup.py sdist bdist_wheel upload - -workflows: - version: 2 - test: - jobs: - - lint: - filters: - tags: - only: /.*/ - - py35dj22: - filters: - tags: - only: /.*/ - - py36dj22: - filters: - tags: - only: /.*/ - - py36dj3: - filters: - tags: - only: /.*/ - - py37dj22: - filters: - tags: - only: /.*/ - - py37dj3: - filters: - tags: - only: /.*/ - - py38dj22: - filters: - tags: - only: /.*/ - - py38dj3: - filters: - tags: - only: /.*/ - - py38dj3psql: - filters: - tags: - only: /.*/ - - release: - context: org-global - requires: - - lint - - py35dj22 - - py36dj3 - - py36dj22 - - py37dj3 - - py37dj22 - - py38dj3 - - py38dj22 - - py38dj3psql - filters: - tags: - only: /[0-9]+(\.[0-9]+)*/ - branches: - ignore: /.*/ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 000000000..062bb815d --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,23 @@ +name: Lints and Tests +on: [push] +jobs: + lint: + name: Linting + runs-on: ubuntu-latest + + steps: + - uses: pinax/linting@v2 + + test: + name: Testing + runs-on: ubuntu-latest + strategy: + matrix: + python: [3.6, 3.7, 3.8, 3.9, "3.10"] + django: [2.2.*, 3.2.*] + + steps: + - uses: pinax/testing@v3 + with: + python: ${{ matrix.python }} + django: ${{ matrix.django }} diff --git a/pinax/stripe/templates/admin/pinax_stripe/change_form.html b/pinax/stripe/templates/admin/pinax_stripe/change_form.html deleted file mode 100644 index b21bace38..000000000 --- a/pinax/stripe/templates/admin/pinax_stripe/change_form.html +++ /dev/null @@ -1,5 +0,0 @@ -{# Custom template to remove submit_row (readonly, POST is forbidden) #} -{% extends "admin/change_form.html" %} - -{% block submit_buttons_top %}{% endblock %} -{% block submit_buttons_bottom %}{% endblock %} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..374b58cbf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,6 @@ +[build-system] +requires = [ + "setuptools>=42", + "wheel" +] +build-backend = "setuptools.build_meta" diff --git a/requirements.testing.txt b/requirements.testing.txt new file mode 100644 index 000000000..b3715019e --- /dev/null +++ b/requirements.testing.txt @@ -0,0 +1,5 @@ +mock +pytest +pytest-django +coverage +codecov diff --git a/setup.cfg b/setup.cfg index 8accd9d4e..f765a274e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,14 +1,50 @@ -[bdist_wheel] -universal = 1 - -[tool:pytest] -testpaths = pinax/stripe/tests -DJANGO_SETTINGS_MODULE = pinax.stripe.tests.settings -addopts = --reuse-db -ra --nomigrations - [isort] multi_line_output=3 known_django=django known_third_party=stripe,six,mock,appconf,jsonfield sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER skip_glob=*/pinax/stripe/migrations/* + +[tool:pytest] +testpaths = pinax/stripe/tests +DJANGO_SETTINGS_MODULE = pinax.stripe.tests.settings +addopts = --reuse-db -ra --nomigrations + +[metadata] +name = pinax-stripe +version = 5.0.0 +author = Pinax Team +author_email = team@pinaxproject.com +description = an app for integrating Stripe into Django +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/pinax/pinax-stripe/ +license = MIT +classifiers = + Development Status :: 5 - Production/Stable + Environment :: Web Environment + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Framework :: Django + Framework :: Django :: 2.2 + Framework :: Django :: 3.2 + Topic :: Software Development :: Libraries :: Python Modules + +[options] +package_dir = + = . +packages = find: +install_requires = + django-appconf>=1.0.1 + jsonfield>=1.0.3 + stripe>=2.0 + django>=2.2 + pytz>=2021.3 + django-ipware>=2.1.0 +zip_safe = False + +[options.packages.find] +where = . diff --git a/setup.py b/setup.py deleted file mode 100644 index 411df6297..000000000 --- a/setup.py +++ /dev/null @@ -1,102 +0,0 @@ -from setuptools import find_packages, setup - -NAME = "pinax-stripe" -DESCRIPTION = "a payments Django app for Stripe" -AUTHOR = "Pinax Team" -AUTHOR_EMAIL = "team@pinaxproject.com" -URL = "https://github.com/pinax/pinax-stripe" -LONG_DESCRIPTION = """ -============ -Pinax Stripe -============ - -.. image:: http://slack.pinaxproject.com/badge.svg - :target: http://slack.pinaxproject.com/ - -.. image:: https://img.shields.io/travis/pinax/pinax-stripe.svg - :target: https://travis-ci.org/pinax/pinax-stripe - -.. image:: https://img.shields.io/codecov/c/github/pinax/pinax-stripe.svg - :target: https://codecov.io/gh/pinax/pinax-stripe - -.. image:: https://img.shields.io/pypi/dm/pinax-stripe.svg - :target: https://pypi.python.org/pypi/pinax-stripe/ - -.. image:: https://img.shields.io/pypi/v/pinax-stripe.svg - :target: https://pypi.python.org/pypi/pinax-stripe/ - -.. image:: https://img.shields.io/badge/license-MIT-blue.svg - :target: https://pypi.python.org/pypi/pinax-stripe/ - - -This app was formerly called ``django-stripe-payments`` and has been renamed to -avoid namespace collisions and to have more consistancy with Pinax. - -Pinax ------- - -Pinax is an open-source platform built on the Django Web Framework. It is an -ecosystem of reusable Django apps, themes, and starter project templates. -This collection can be found at http://pinaxproject.com. - -This app was developed as part of the Pinax ecosystem but is just a Django app -and can be used independently of other Pinax apps. - - -pinax-stripe ------------- - -``pinax-stripe`` is a payments Django app for Stripe. - -This app allows you to process one off charges as well as signup users for -recurring subscriptions managed by Stripe. -""" - -tests_require = [ - "mock", - "pytest", - "pytest-django", -] - -setup( - name=NAME, - author=AUTHOR, - author_email=AUTHOR_EMAIL, - description=DESCRIPTION, - long_description=LONG_DESCRIPTION, - version="5.1.0", - license="MIT", - url=URL, - packages=find_packages(), - package_data={ - "pinax.stripe": [ - "templates/pinax/stripe/email/body_base.txt", - "templates/pinax/stripe/email/body.txt", - "templates/pinax/stripe/email/subject.txt" - ] - }, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Web Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 3", - "Framework :: Django", - ], - install_requires=[ - "django-appconf>=1.0.1", - "jsonfield>=1.0.3", - "stripe>=2.0", - "django>=2.2", - "pytz", - "six", - "django-ipware==2.1.0" - ], - extras_require={ - "testing": tests_require, - }, - zip_safe=False, -) diff --git a/test.sh b/test.sh new file mode 100755 index 000000000..9039e7585 --- /dev/null +++ b/test.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +DJANGO_SETTINGS_MODULE=pinax.stripe.tests.settings + +python -m pytest --cov --cov-report=term-missing:skip-covered pinax From 0a2a1e6dbe45f561f96738706d4f967c586fae97 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:10:17 -0600 Subject: [PATCH 12/49] Clean up warnings --- pinax/stripe/tests/urls.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pinax/stripe/tests/urls.py b/pinax/stripe/tests/urls.py index 51aea10ef..0651829d6 100644 --- a/pinax/stripe/tests/urls.py +++ b/pinax/stripe/tests/urls.py @@ -1,16 +1,16 @@ -from django.conf.urls import url +from django.urls import path from ..urls import urlpatterns -class FakeViewForUrl(object): +class FakeViewForUrl: def __call__(self): raise Exception("Should not get called.") urlpatterns += [ - url(r"^the/app/$", FakeViewForUrl, name="the_app"), - url(r"^accounts/signup/$", FakeViewForUrl, name="signup"), - url(r"^password/reset/confirm/(?P.+)/$", FakeViewForUrl, + path("the/app/$", FakeViewForUrl, name="the_app"), + path("accounts/signup/$", FakeViewForUrl, name="signup"), + path("password/reset/confirm//$", FakeViewForUrl, name="password_reset"), ] From f833139dd6be9dd1cf91e2a04da528ea7129ef18 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:25:41 -0600 Subject: [PATCH 13/49] Clean up / out more unsupported things - don't need test templates - not going to use forms - remove py2 support --- pinax/stripe/forms.py | 445 ------------------ pinax/stripe/models.py | 3 - .../templates/pinax/stripe/invoice_list.html | 0 .../pinax/stripe/paymentmethod_create.html | 0 .../pinax/stripe/paymentmethod_delete.html | 0 .../pinax/stripe/paymentmethod_list.html | 0 .../pinax/stripe/paymentmethod_update.html | 0 .../pinax/stripe/subscription_create.html | 0 .../pinax/stripe/subscription_delete.html | 0 .../pinax/stripe/subscription_list.html | 0 .../pinax/stripe/subscription_update.html | 0 pinax/stripe/tests/test_models.py | 31 +- pinax/stripe/tests/test_webhooks.py | 13 +- pinax/stripe/utils.py | 2 - pinax/stripe/webhooks.py | 5 +- 15 files changed, 14 insertions(+), 485 deletions(-) delete mode 100644 pinax/stripe/forms.py delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/invoice_list.html delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/paymentmethod_create.html delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/paymentmethod_delete.html delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/paymentmethod_list.html delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/paymentmethod_update.html delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/subscription_create.html delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/subscription_delete.html delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/subscription_list.html delete mode 100644 pinax/stripe/tests/templates/pinax/stripe/subscription_update.html diff --git a/pinax/stripe/forms.py b/pinax/stripe/forms.py deleted file mode 100644 index 04bb59116..000000000 --- a/pinax/stripe/forms.py +++ /dev/null @@ -1,445 +0,0 @@ -import datetime -import time - -from django import forms -from django.utils.translation import gettext_lazy as _ - -import stripe -from ipware.ip import get_ip, get_real_ip - -from .conf import settings - -""" -The Connect forms here are designed to get users through the multi-stage -verification process Stripe uses for custom accounts, as detailed here: - -https://stripe.com/docs/connect/testing-verification - -You can view the required fields on a per-country basis using the API: - -https://stripe.com/docs/api#country_spec_object - -The following forms are sufficient for the US and Canada. -""" - -# Note: undocumented, determined through experimentation -STRIPE_MINIMUM_DOB = datetime.date(1900, 1, 1) - - -ACCEPTED_DOCUMENT_CONTENT_TYPES = ( - "image/jpg", "image/jpeg", "image/png" -) - -COUNTRY_CHOICES = [ - ("CA", _("Canada")), - ("US", _("United States")) -] - -STATE_CHOICES_BY_COUNTRY = { - "CA": [ - ("AB", _("Alberta")), - ("BC", _("British Columbia")), - ("MB", _("Manitoba")), - ("NB", _("New Brunswick")), - ("NL", _("Newfoundland and Labrador")), - ("NT", _("Northwest Territories")), - ("NS", _("Nova Scotia")), - ("NU", _("Nunavut")), - ("ON", _("Ontario")), - ("PE", _("Prince Edward Island")), - ("QC", _("Quebec")), - ("SK", _("Saskatchewan")), - ("YT", _("Yukon")) - ], - "US": [ - ("AL", _("Alabama")), - ("AK", _("Alaska")), - ("AZ", _("Arizona")), - ("AR", _("Arkansas")), - ("CA", _("California")), - ("CO", _("Colorado")), - ("CT", _("Connecticut")), - ("DE", _("Delaware")), - ("DC", _("District of Columbia")), - ("FL", _("Florida")), - ("GA", _("Georgia")), - ("HI", _("Hawaii")), - ("ID", _("Idaho")), - ("IL", _("Illinois")), - ("IN", _("Indiana")), - ("IA", _("Iowa")), - ("KS", _("Kansas")), - ("KY", _("Kentucky")), - ("LA", _("Louisiana")), - ("ME", _("Maine")), - ("MD", _("Maryland")), - ("MA", _("Massachusetts")), - ("MI", _("Michigan")), - ("MN", _("Minnesota")), - ("MS", _("Mississippi")), - ("MO", _("Missouri")), - ("MT", _("Montana")), - ("NE", _("Nebraska")), - ("NV", _("Nevada")), - ("NH", _("New Hampshire")), - ("NJ", _("New Jersey")), - ("NM", _("New Mexico")), - ("NY", _("New York")), - ("NC", _("North Carolina")), - ("ND", _("North Dakota")), - ("OH", _("Ohio")), - ("OK", _("Oklahoma")), - ("OR", _("Oregon")), - ("PA", _("Pennsylvania")), - ("RI", _("Rhode Island")), - ("SC", _("South Carolina")), - ("SD", _("South Dakota")), - ("TN", _("Tennessee")), - ("TX", _("Texas")), - ("UT", _("Utah")), - ("VT", _("Vermont")), - ("VA", _("Virginia")), - ("WA", _("Washington")), - ("WV", _("West Virginia")), - ("WI", _("Wisconsin")), - ("WY", _("Wyoming")) - ] -} - -CURRENCY_CHOICES_BY_COUNTRY = { - "CA": [ - ("CAD", _("CAD: Canadian Dollars")), - ("USD", _("USD: US Dollars")), - ], - "US": [ - ("USD", _("USD: US Dollars")), - ] -} - -FIELDS_BY_COUNTRY = { - "CA": { - "legal_entity.personal_id_number": ( - "personal_id_number", - forms.CharField( - label=_("SIN") - ), - ), - "legal_entity.verification.document": ( - "document", - forms.FileField( - label=_("Scan of government-issued ID") - ), - ) - }, - "US": { - "legal_entity.personal_id_number": ( - "personal_id_number", - forms.CharField( - label=_("SSN") - ) - ), - "legal_entity.verification.document": ( - "document", - forms.FileField( - label=_("Scan of government-issued ID") - ), - ) - } -} - -# lookup local form fields for Stripe field errors -# we use `contains` so the stripe side (left) need -# not be super specific - -STRIPE_FIELDS_TO_LOCAL_FIELDS = { - "dob": "dob", - "first_name": "first_name", - "second_name": "second_name", - "routing_number": "routing_number", - "currency": "currency", - "account_number": "account_number", - "personal_id_number": "personal_id_number", - "file": "document" -} - - -class DynamicManagedAccountForm(forms.Form): - """Set up fields according to fields needed and relevant country.""" - - def __init__(self, *args, **kwargs): - self.country = kwargs.pop("country") - self.fields_needed = kwargs.pop("fields_needed", []) - super(DynamicManagedAccountForm, self).__init__(*args, **kwargs) - # build our form using the country specific fields and falling - # back to our default set - for f in self.fields_needed: - if f in FIELDS_BY_COUNTRY.get(self.country, {}): # pragma: no branch - field_name, field = FIELDS_BY_COUNTRY[self.country][f] - self.fields[field_name] = field - - # clean methods only kick in if the form has the relevant field - - def clean_document(self): - document = self.cleaned_data.get("document") - if document._size > settings.PINAX_STRIPE_DOCUMENT_MAX_SIZE_KB: - raise forms.ValidationError( - _("Document image is too large (> %(maxsize)s MB)") % { - "maxsize": round( - float( - settings.PINAX_STRIPE_DOCUMENT_MAX_SIZE_KB - ) / float( - 1024 * 1024 - ), 1 - ) - } - ) - if document.content_type not in ACCEPTED_DOCUMENT_CONTENT_TYPES: - raise forms.ValidationError( - _( - "The type of image you supplied is not supported. " - "Please upload a JPG or PNG file." - ) - ) - return document - - def clean_dob(self): - data = self.cleaned_data["dob"] - if data < STRIPE_MINIMUM_DOB: - raise forms.ValidationError( - "This must be greater than {}.".format( - STRIPE_MINIMUM_DOB - ) - ) - return data - - def stripe_field_to_local_field(self, stripe_field): - for r, l in STRIPE_FIELDS_TO_LOCAL_FIELDS.items(): - if r in stripe_field: - return l - - def stripe_error_to_form_error(self, error): - """ - Translate a Stripe error into meaningful form feedback. - - error.json_body = { - u'error': { - u'message': - u"This value must be greater than 1900.", - u'type': u'invalid_request_error', - u'param': u'legal_entity[dob][year]' - } - } - """ - message = error.json_body["error"]["message"] - stripe_field = error.json_body["error"]["param"] - local_field = self.stripe_field_to_local_field(stripe_field) - if local_field: - self.add_error(local_field, message) - else: - self.add_error(None, message) - - -def extract_ipaddress(request): - """Extract IP address from request.""" - ipaddress = get_real_ip(request) - if not ipaddress and settings.DEBUG: # pragma: no cover - ipaddress = get_ip(request) - return ipaddress - - -class InitialCustomAccountForm(DynamicManagedAccountForm): - """ - Collect `minimum` fields for CA and US CountrySpecs. - - Note: for US, `legal_entity.ssn_last_4` appears in the `minimum` - set but in fact is not required for the account to be functional. - Similarly for CA, `legal_entity.personal_id_number` is listed as - `minimum` but in practice is not required to be able to charge - and transfer. - """ - - first_name = forms.CharField(max_length=100) - last_name = forms.CharField(max_length=100) - dob = forms.DateField() - - address_line1 = forms.CharField(max_length=300) - address_city = forms.CharField(max_length=100) - address_state = forms.CharField(max_length=100) - address_country = forms.ChoiceField(choices=COUNTRY_CHOICES) - address_postal_code = forms.CharField(max_length=100) - - # for external_account - routing_number = forms.CharField(max_length=100) - account_number = forms.CharField(max_length=100) - - tos_accepted = forms.BooleanField() - - def __init__(self, *args, **kwargs): - """Instantiate no fields based on `fields_needed` initially.""" - self.request = kwargs.pop("request") - super(InitialCustomAccountForm, self).__init__( - *args, **kwargs - ) - self.fields["address_state"] = forms.ChoiceField( - choices=STATE_CHOICES_BY_COUNTRY[self.country] - ) - self.fields["currency"] = forms.ChoiceField( - choices=CURRENCY_CHOICES_BY_COUNTRY[self.country] - ) - - def get_ipaddress(self): - return extract_ipaddress(self.request) - - def get_user_agent(self): - return self.request.META.get("HTTP_USER_AGENT") - - def account_create(self, user, **kwargs): - stripe_account = stripe.Account.create(**kwargs) - return stripe_account - - def save(self): - """ - Create a custom account, handling Stripe errors. - - Note: the below will create a custom, manually paid out - account. This is here mostly as an example, you will likely - need to override this method and do your own application - specific special sauce. - """ - data = self.cleaned_data - try: - return self.account_create( - self.request.user, - country=data["address_country"], - type="custom", - legal_entity={ - "dob": { - "day": data["dob"].day, - "month": data["dob"].month, - "year": data["dob"].year - }, - "first_name": data["first_name"], - "last_name": data["last_name"], - "type": "individual", - "address": { - "line1": data["address_line1"], - "city": data["address_city"], - "postal_code": data["address_postal_code"], - "state": data["address_state"], - "country": data["address_country"] - } - }, - tos_acceptance={ - "date": int(time.time()), - "ip": self.get_ipaddress(), - "user_agent": self.get_user_agent() - }, - transfer_schedule={ - "interval": "manual" - }, - external_account={ - "object": "bank_account", - "account_holder_name": u" ".join( - [ - data["first_name"], - data["last_name"] - ] - ), - "country": data["address_country"], - "currency": data["currency"], - "account_holder_type": "individual", - "default_for_currency": True, - "account_number": data["account_number"], - "routing_number": data["routing_number"] - }, - # useful reference to our local user instance - metadata={ - "user_id": self.request.user.id - } - ) - - except stripe.error.InvalidRequestError as se: - self.stripe_error_to_form_error(se) - raise - - -class AdditionalCustomAccountForm(DynamicManagedAccountForm): - """ - Collect `additional` fields for CA and US CountrySpecs. - - Note: for US, `legal_entity.ssn_last_4` appears in the `minimum` - set but in fact is not required for the account to be functional. - Similarly for CA, `legal_entity.personal_id_number` is listed as - `minimum` but in practice is not required to be able to charge - and transfer. - - It's possible when further verification is needed that the user - made a mistake with their name or dob, so we include these - fields so the user can make any adjustments. - """ - - first_name = forms.CharField(max_length=100) - last_name = forms.CharField(max_length=100) - dob = forms.DateField() - - def __init__(self, *args, **kwargs): - """ - Assumes you are instantiating with an instance of a model that represents - a local cache of a Stripe Account with a `stripe_id` property. - """ - self.account = kwargs.pop("account") - kwargs.update( - { - "country": self.account.country, - "fields_needed": self.account.verification_fields_needed, - } - ) - super(AdditionalCustomAccountForm, self).__init__(*args, **kwargs) - # prepopulate with the existing account details - self.fields["first_name"].initial = self.account.legal_entity_first_name - self.fields["last_name"].initial = self.account.legal_entity_last_name - self.fields["dob"].initial = self.account.legal_entity_dob - - def account_update(self, data): - stripe_account = stripe.Account.retrieve(id=self.account.stripe_id) - if data.get("dob"): - stripe_account.legal_entity.dob = data["dob"] - - if data.get("first_name"): - stripe_account.legal_entity.first_name = data["first_name"] - - if data.get("last_name"): - stripe_account.legal_entity.last_name = data["last_name"] - - if data.get("personal_id_number"): - stripe_account.legal_entity.personal_id_number = data["personal_id_number"] - - if data.get("document"): - response = stripe.FileUpload.create( - purpose="identity_document", - file=data["document"], - stripe_account=stripe_account.id - ) - stripe_account.legal_entity.verification.document = response["id"] - stripe_account.save() - return stripe_account - - def save(self): - data = self.cleaned_data - try: - return self.account_update( - { - "dob": { - "day": data["dob"].day, - "month": data["dob"].month, - "year": data["dob"].year - }, - "first_name": data["first_name"], - "last_name": data["last_name"], - "personal_id_number": data.get("personal_id_number"), - "document": data.get("document") - } - ) - except stripe.error.InvalidRequestError as se: - self.stripe_error_to_form_error(se) - raise diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index d7d33093c..8ab64bf20 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -1,6 +1,3 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - from django.db import models from django.utils import timezone diff --git a/pinax/stripe/tests/templates/pinax/stripe/invoice_list.html b/pinax/stripe/tests/templates/pinax/stripe/invoice_list.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/templates/pinax/stripe/paymentmethod_create.html b/pinax/stripe/tests/templates/pinax/stripe/paymentmethod_create.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/templates/pinax/stripe/paymentmethod_delete.html b/pinax/stripe/tests/templates/pinax/stripe/paymentmethod_delete.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/templates/pinax/stripe/paymentmethod_list.html b/pinax/stripe/tests/templates/pinax/stripe/paymentmethod_list.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/templates/pinax/stripe/paymentmethod_update.html b/pinax/stripe/tests/templates/pinax/stripe/paymentmethod_update.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/templates/pinax/stripe/subscription_create.html b/pinax/stripe/tests/templates/pinax/stripe/subscription_create.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/templates/pinax/stripe/subscription_delete.html b/pinax/stripe/tests/templates/pinax/stripe/subscription_delete.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/templates/pinax/stripe/subscription_list.html b/pinax/stripe/tests/templates/pinax/stripe/subscription_list.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/templates/pinax/stripe/subscription_update.html b/pinax/stripe/tests/templates/pinax/stripe/subscription_update.html deleted file mode 100644 index e69de29bb..000000000 diff --git a/pinax/stripe/tests/test_models.py b/pinax/stripe/tests/test_models.py index d00bf14b1..75f27a03b 100644 --- a/pinax/stripe/tests/test_models.py +++ b/pinax/stripe/tests/test_models.py @@ -1,20 +1,9 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - import datetime -import sys from django.test import TestCase from ..models import Event, EventProcessingException -try: - _str = unicode -except NameError: - _str = str - -PY2 = sys.version_info[0] == 2 - class ModelTests(TestCase): @@ -27,18 +16,14 @@ def test_event_str_and_repr(self): created_at_iso = created_at.replace(microsecond=0).isoformat() e = Event(kind="customer.deleted", webhook_message={}, created_at=created_at) self.assertTrue("customer.deleted" in str(e)) - if PY2: - self.assertEqual(repr(e), "Event(pk=None, kind=u'customer.deleted', customer=u'', valid=None, created_at={!s}, stripe_id=u'')".format( - created_at_iso)) - else: - self.assertEqual(repr(e), "Event(pk=None, kind='customer.deleted', customer='', valid=None, created_at={!s}, stripe_id='')".format( - created_at_iso)) + self.assertEqual( + repr(e), + f"Event(pk=None, kind='customer.deleted', customer='', valid=None, created_at={created_at_iso}, stripe_id='')" + ) e.stripe_id = "evt_X" e.customer_id = "cus_YYY" - if PY2: - self.assertEqual(repr(e), "Event(pk=None, kind=u'customer.deleted', customer={!r}, valid=None, created_at={!s}, stripe_id=u'evt_X')".format( - e.customer_id, created_at_iso)) - else: - self.assertEqual(repr(e), "Event(pk=None, kind='customer.deleted', customer={!r}, valid=None, created_at={!s}, stripe_id='evt_X')".format( - e.customer_id, created_at_iso)) + self.assertEqual( + repr(e), + f"Event(pk=None, kind='customer.deleted', customer='{e.customer_id}', valid=None, created_at={created_at_iso}, stripe_id='evt_X')" + ) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index dd9f57f4c..a96d11188 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -1,21 +1,16 @@ import json from django.dispatch import Signal +from django.urls import reverse from django.test import TestCase from django.test.client import Client -import six import stripe from mock import patch from ..models import Event, EventProcessingException from ..webhooks import AccountExternalAccountCreatedWebhook, Webhook, registry -try: - from django.urls import reverse -except ImportError: - from django.core.urlresolvers import reverse - class WebhookRegistryTest(TestCase): @@ -91,7 +86,7 @@ def test_webhook_with_transfer_event(self, TransferMock, StripeEventMock): msg = json.dumps(self.event_data) resp = Client().post( reverse("pinax_stripe_webhook"), - six.u(msg), + msg, content_type="application/json" ) self.assertEqual(resp.status_code, 200) @@ -105,7 +100,7 @@ def test_webhook_associated_with_stripe_account(self, StripeEventMock): msg = json.dumps(connect_event_data) resp = Client().post( reverse("pinax_stripe_webhook"), - six.u(msg), + msg, content_type="application/json" ) self.assertEqual(resp.status_code, 200) @@ -121,7 +116,7 @@ def test_webhook_duplicate_event(self): msg = json.dumps(data) resp = Client().post( reverse("pinax_stripe_webhook"), - six.u(msg), + msg, content_type="application/json" ) self.assertEqual(resp.status_code, 200) diff --git a/pinax/stripe/utils.py b/pinax/stripe/utils.py index 648681f38..dbaeb6da8 100644 --- a/pinax/stripe/utils.py +++ b/pinax/stripe/utils.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import datetime import decimal diff --git a/pinax/stripe/webhooks.py b/pinax/stripe/webhooks.py index 0950b1d38..d7a6fe335 100644 --- a/pinax/stripe/webhooks.py +++ b/pinax/stripe/webhooks.py @@ -5,14 +5,13 @@ from django.dispatch import Signal import stripe -from six import with_metaclass from . import models from .conf import settings from .utils import obfuscate_secret_key -class WebhookRegistry(object): +class WebhookRegistry: def __init__(self): self._registry = {} @@ -60,7 +59,7 @@ def __new__(cls, clsname, bases, attrs): return newclass -class Webhook(with_metaclass(Registerable, object)): +class Webhook(metaclass=Registerable): name = None From d8c2359ec9f001d22d0b4583fdd3aebd142f17aa Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:27:30 -0600 Subject: [PATCH 14/49] Lint --- pinax/stripe/tests/urls.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pinax/stripe/tests/urls.py b/pinax/stripe/tests/urls.py index 0651829d6..38ad589e8 100644 --- a/pinax/stripe/tests/urls.py +++ b/pinax/stripe/tests/urls.py @@ -11,6 +11,5 @@ def __call__(self): urlpatterns += [ path("the/app/$", FakeViewForUrl, name="the_app"), path("accounts/signup/$", FakeViewForUrl, name="signup"), - path("password/reset/confirm//$", FakeViewForUrl, - name="password_reset"), + path("password/reset/confirm//$", FakeViewForUrl, name="password_reset"), ] From 1a1d989fbec881cb86e6bf720556e58da1848b0b Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:29:30 -0600 Subject: [PATCH 15/49] Bump version of action --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 062bb815d..c3f71399e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,7 +17,7 @@ jobs: django: [2.2.*, 3.2.*] steps: - - uses: pinax/testing@v3 + - uses: pinax/testing@v4 with: python: ${{ matrix.python }} django: ${{ matrix.django }} From ce660ff8751c5e5093558b365872a0528b9832b9 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:31:08 -0600 Subject: [PATCH 16/49] Bump version of action --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c3f71399e..9cbf88363 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,7 +17,7 @@ jobs: django: [2.2.*, 3.2.*] steps: - - uses: pinax/testing@v4 + - uses: pinax/testing@v5 with: python: ${{ matrix.python }} django: ${{ matrix.django }} From 41fc933e446dbc1e5d5fa58077f16eef0b6b168b Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:31:43 -0600 Subject: [PATCH 17/49] Fix imports --- pinax/stripe/tests/test_webhooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index a96d11188..3f78e4a0a 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -1,9 +1,9 @@ import json from django.dispatch import Signal -from django.urls import reverse from django.test import TestCase from django.test.client import Client +from django.urls import reverse import stripe from mock import patch From 0c64eea4c694b8ee34757d4d322fa568ebb94fd7 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:33:09 -0600 Subject: [PATCH 18/49] Install self before tests --- test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test.sh b/test.sh index 9039e7585..e47ca313a 100755 --- a/test.sh +++ b/test.sh @@ -1,6 +1,8 @@ #!/bin/bash set -euo pipefail +pip install . + DJANGO_SETTINGS_MODULE=pinax.stripe.tests.settings python -m pytest --cov --cov-report=term-missing:skip-covered pinax From 699ff95d5a52584703fe68c192831c68866b1d19 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 13:34:36 -0600 Subject: [PATCH 19/49] Add pytest-cov --- requirements.testing.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.testing.txt b/requirements.testing.txt index b3715019e..1a706448d 100644 --- a/requirements.testing.txt +++ b/requirements.testing.txt @@ -1,5 +1,6 @@ mock pytest +pytest-cov pytest-django coverage codecov From 20810fd62ffa52ed3246802c98408c48b110ebb3 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 18:02:04 -0600 Subject: [PATCH 20/49] Drop jsonfield --- makemigrations.py | 1 - .../migrations/0018_auto_20211125_1756.py | 23 +++++++++++++++++++ pinax/stripe/models.py | 6 ++--- pinax/stripe/tests/settings.py | 1 - pinax/stripe/tests/test_webhooks.py | 2 +- setup.cfg | 3 +-- test.sh | 2 -- 7 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 pinax/stripe/migrations/0018_auto_20211125_1756.py diff --git a/makemigrations.py b/makemigrations.py index 4c4460786..a762a33c2 100644 --- a/makemigrations.py +++ b/makemigrations.py @@ -26,7 +26,6 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.sites", - "jsonfield", "pinax.stripe", ], SITE_ID=1, diff --git a/pinax/stripe/migrations/0018_auto_20211125_1756.py b/pinax/stripe/migrations/0018_auto_20211125_1756.py new file mode 100644 index 000000000..8ab80b26e --- /dev/null +++ b/pinax/stripe/migrations/0018_auto_20211125_1756.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.9 on 2021-11-25 23:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0017_auto_20200815_1037'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='validated_message', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='event', + name='webhook_message', + field=models.JSONField(), + ), + ] diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index 8ab64bf20..d9b0be71a 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -1,8 +1,6 @@ from django.db import models from django.utils import timezone -from jsonfield.fields import JSONField - class StripeObject(models.Model): @@ -19,8 +17,8 @@ class Event(StripeObject): livemode = models.BooleanField(default=False) customer_id = models.CharField(max_length=200, blank=True) account_id = models.CharField(max_length=200, blank=True) - webhook_message = JSONField() - validated_message = JSONField(null=True, blank=True) + webhook_message = models.JSONField() + validated_message = models.JSONField(null=True, blank=True) valid = models.BooleanField(null=True, blank=True) processed = models.BooleanField(default=False) pending_webhooks = models.PositiveIntegerField(default=0) diff --git a/pinax/stripe/tests/settings.py b/pinax/stripe/tests/settings.py index bf0faa023..fde8b21e3 100644 --- a/pinax/stripe/tests/settings.py +++ b/pinax/stripe/tests/settings.py @@ -27,7 +27,6 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.sites", - "jsonfield", "pinax.stripe", ] SITE_ID = 1 diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index 3f78e4a0a..272db2c98 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -112,7 +112,7 @@ def test_webhook_associated_with_stripe_account(self, StripeEventMock): def test_webhook_duplicate_event(self): data = {"id": 123} - Event.objects.create(stripe_id=123, livemode=True) + Event.objects.create(stripe_id=123, livemode=True, webhook_message={}) msg = json.dumps(data) resp = Client().post( reverse("pinax_stripe_webhook"), diff --git a/setup.cfg b/setup.cfg index f765a274e..4c01072c5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [isort] multi_line_output=3 known_django=django -known_third_party=stripe,six,mock,appconf,jsonfield +known_third_party=stripe,mock,appconf sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER skip_glob=*/pinax/stripe/migrations/* @@ -39,7 +39,6 @@ package_dir = packages = find: install_requires = django-appconf>=1.0.1 - jsonfield>=1.0.3 stripe>=2.0 django>=2.2 pytz>=2021.3 diff --git a/test.sh b/test.sh index e47ca313a..9039e7585 100755 --- a/test.sh +++ b/test.sh @@ -1,8 +1,6 @@ #!/bin/bash set -euo pipefail -pip install . - DJANGO_SETTINGS_MODULE=pinax.stripe.tests.settings python -m pytest --cov --cov-report=term-missing:skip-covered pinax From 9b4686fd5652cd3f3e28d6519300e40946e7350c Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 18:02:24 -0600 Subject: [PATCH 21/49] Bump action --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9cbf88363..b2bac3cc8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,7 +17,7 @@ jobs: django: [2.2.*, 3.2.*] steps: - - uses: pinax/testing@v5 + - uses: pinax/testing@v6 with: python: ${{ matrix.python }} django: ${{ matrix.django }} From c51d68c6cfc1466a95da8822bd0c1c2a4f020480 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 18:05:20 -0600 Subject: [PATCH 22/49] Drop Django 2.2 support -- end of life is April 2022 anyway --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b2bac3cc8..ec35764e9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: strategy: matrix: python: [3.6, 3.7, 3.8, 3.9, "3.10"] - django: [2.2.*, 3.2.*] + django: [3.2.*] steps: - uses: pinax/testing@v6 From dcea09329a3e1051f1e469228679311fe6c64f5c Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 18:07:07 -0600 Subject: [PATCH 23/49] Drop Django 2.2 support -- end of life is April 2022 anyway --- setup.cfg | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4c01072c5..5f96e80ca 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,6 @@ classifiers = Programming Language :: Python Programming Language :: Python :: 3 Framework :: Django - Framework :: Django :: 2.2 Framework :: Django :: 3.2 Topic :: Software Development :: Libraries :: Python Modules @@ -40,9 +39,8 @@ packages = find: install_requires = django-appconf>=1.0.1 stripe>=2.0 - django>=2.2 + django>=3.2 pytz>=2021.3 - django-ipware>=2.1.0 zip_safe = False [options.packages.find] From 79f781e3d06b968fb187df7746421e5b601043af Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 21:41:31 -0600 Subject: [PATCH 24/49] More updates --- pinax/stripe/tests/settings.py | 31 +------------------------------ pinax/stripe/tests/urls.py | 6 +++--- 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/pinax/stripe/tests/settings.py b/pinax/stripe/tests/settings.py index fde8b21e3..b8cf37d6a 100644 --- a/pinax/stripe/tests/settings.py +++ b/pinax/stripe/tests/settings.py @@ -1,10 +1,6 @@ import os -import django - -DEBUG = True USE_TZ = True -TIME_ZONE = "UTC" DATABASES = { "default": { "ENGINE": os.environ.get("PINAX_STRIPE_DATABASE_ENGINE", "django.db.backends.sqlite3"), @@ -13,20 +9,10 @@ "USER": os.environ.get("PINAX_STRIPE_DATABASE_USER", ""), } } -MIDDLEWARE = [ # from 2.0 onwards, only MIDDLEWARE is used - "django.contrib.sessions.middleware.SessionMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", -] -if django.VERSION < (1, 10): - MIDDLEWARE_CLASSES = MIDDLEWARE ROOT_URLCONF = "pinax.stripe.tests.urls" INSTALLED_APPS = [ - "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.sites", "pinax.stripe", ] SITE_ID = 1 @@ -34,21 +20,6 @@ PINAX_STRIPE_SECRET_KEY = "sk_test_01234567890123456789abcd" TEMPLATES = [{ "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [ - "pinax/stripe/tests/templates" - ], - "APP_DIRS": True, - "OPTIONS": { - "debug": True, - "context_processors": [ - "django.contrib.auth.context_processors.auth", - "django.template.context_processors.debug", - "django.template.context_processors.i18n", - "django.template.context_processors.media", - "django.template.context_processors.static", - "django.template.context_processors.tz", - "django.template.context_processors.request", - ], - }, }] SECRET_KEY = "pinax-stripe-secret-key" +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/pinax/stripe/tests/urls.py b/pinax/stripe/tests/urls.py index 38ad589e8..376c53d24 100644 --- a/pinax/stripe/tests/urls.py +++ b/pinax/stripe/tests/urls.py @@ -9,7 +9,7 @@ def __call__(self): urlpatterns += [ - path("the/app/$", FakeViewForUrl, name="the_app"), - path("accounts/signup/$", FakeViewForUrl, name="signup"), - path("password/reset/confirm//$", FakeViewForUrl, name="password_reset"), + path("the/app/", FakeViewForUrl, name="the_app"), + path("accounts/signup/", FakeViewForUrl, name="signup"), + path("password/reset/confirm//", FakeViewForUrl, name="password_reset"), ] From 3662b74f9157f6b1309639f7d60fa96a8e6b81ed Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 22:15:13 -0600 Subject: [PATCH 25/49] Add test coverage --- pinax/stripe/admin.py | 5 ++--- pinax/stripe/tests/settings.py | 7 +++++++ pinax/stripe/tests/test_models.py | 5 +++++ pinax/stripe/tests/urls.py | 2 ++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pinax/stripe/admin.py b/pinax/stripe/admin.py index 8b0536ffc..be2f55645 100644 --- a/pinax/stripe/admin.py +++ b/pinax/stripe/admin.py @@ -1,5 +1,4 @@ from django.contrib import admin -from django.utils.encoding import force_text from django.utils.translation import gettext_lazy as _ from .models import Event, EventProcessingException @@ -15,8 +14,8 @@ def change_view(self, request, object_id, form_url="", extra_context=None): opts = self.model._meta extra_context = extra_context or {} - extra_context["title"] = _("View %s" % force_text(opts.verbose_name)) - return super(ModelAdmin, self).change_view( + extra_context["title"] = _(f"View {opts.verbose_name}") + return super().change_view( request, object_id, form_url, extra_context=extra_context, ) diff --git a/pinax/stripe/tests/settings.py b/pinax/stripe/tests/settings.py index b8cf37d6a..223545cd7 100644 --- a/pinax/stripe/tests/settings.py +++ b/pinax/stripe/tests/settings.py @@ -10,9 +10,16 @@ } } ROOT_URLCONF = "pinax.stripe.tests.urls" +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.messages.middleware.MessageMiddleware" +] INSTALLED_APPS = [ + "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", "pinax.stripe", ] SITE_ID = 1 diff --git a/pinax/stripe/tests/test_models.py b/pinax/stripe/tests/test_models.py index 75f27a03b..7b36d2a42 100644 --- a/pinax/stripe/tests/test_models.py +++ b/pinax/stripe/tests/test_models.py @@ -27,3 +27,8 @@ def test_event_str_and_repr(self): repr(e), f"Event(pk=None, kind='customer.deleted', customer='{e.customer_id}', valid=None, created_at={created_at_iso}, stripe_id='evt_X')" ) + + def test_validated_message(self): + created_at = datetime.datetime.utcnow() + e = Event(kind="customer.deleted", webhook_message={}, validated_message={"foo": "bar"}, created_at=created_at) + self.assertEqual(e.message, e.validated_message) diff --git a/pinax/stripe/tests/urls.py b/pinax/stripe/tests/urls.py index 376c53d24..c68bcad88 100644 --- a/pinax/stripe/tests/urls.py +++ b/pinax/stripe/tests/urls.py @@ -1,3 +1,4 @@ +from django.contrib import admin from django.urls import path from ..urls import urlpatterns @@ -9,6 +10,7 @@ def __call__(self): urlpatterns += [ + path("admin/", admin.site.urls), path("the/app/", FakeViewForUrl, name="the_app"), path("accounts/signup/", FakeViewForUrl, name="signup"), path("password/reset/confirm//", FakeViewForUrl, name="password_reset"), From 8dd7bbc495cebfb99c001ffcde7e9a3e786c1b8b Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 22:21:42 -0600 Subject: [PATCH 26/49] Add more tests and drop unused code --- pinax/stripe/tests/test_admin.py | 81 ++++++++++++++++++++++++++++++ pinax/stripe/tests/test_signals.py | 8 +++ pinax/stripe/tests/test_utils.py | 9 +++- pinax/stripe/utils.py | 8 --- 4 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 pinax/stripe/tests/test_admin.py create mode 100644 pinax/stripe/tests/test_signals.py diff --git a/pinax/stripe/tests/test_admin.py b/pinax/stripe/tests/test_admin.py new file mode 100644 index 000000000..28f92c46e --- /dev/null +++ b/pinax/stripe/tests/test_admin.py @@ -0,0 +1,81 @@ +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase + +from ..admin import EventAdmin, EventProcessingExceptionAdmin +from ..models import Event, EventProcessingException + + +class TestEventProcessingExceptionAdmin(TestCase): + + def setUp(self): + self.factory = RequestFactory() + + def test_no_add_permission(self): + instance = EventProcessingExceptionAdmin(EventProcessingException, admin.site) + self.assertFalse(instance.has_add_permission(None)) + + def test_no_change_permission(self): + request = self.factory.post('/admin/') + instance = EventProcessingExceptionAdmin(EventProcessingException, admin.site) + self.assertFalse(instance.has_change_permission(request)) + + def test_has_change_permission(self): + request = self.factory.get("/admin/") + instance = EventProcessingExceptionAdmin(EventProcessingException, admin.site) + self.assertTrue(instance.has_change_permission(request)) + + def test_change_view_title(self): + request = self.factory.get("/admin/") + request.user = get_user_model().objects.create_user( + username="staff", + email="staff@staff.com", + is_staff=True, + is_superuser=True + ) + event = Event.objects.create(kind="foo", webhook_message={}, stripe_id="foo") + error = EventProcessingException.objects.create(event=event, data={}, message="foo") + instance = EventProcessingExceptionAdmin(EventProcessingException, admin.site) + response = instance.change_view(request, str(error.pk)) + self.assertEqual( + response.context_data["title"], + "View event processing exception" + ) + + +class TestEventAdmin(TestCase): + + def setUp(self): + self.factory = RequestFactory() + + def test_no_add_permission(self): + instance = EventAdmin(Event, admin.site) + self.assertFalse(instance.has_add_permission(None)) + + def test_no_change_permission(self): + factory = RequestFactory() + request = factory.post('/admin/') + instance = EventAdmin(Event, admin.site) + self.assertFalse(instance.has_change_permission(request)) + + def test_has_change_permission(self): + factory = RequestFactory() + request = factory.get("/admin/") + instance = EventAdmin(Event, admin.site) + self.assertTrue(instance.has_change_permission(request)) + + def test_change_view_title(self): + request = self.factory.get("/admin/") + request.user = get_user_model().objects.create_user( + username="staff", + email="staff@staff.com", + is_staff=True, + is_superuser=True + ) + event = Event.objects.create(kind="foo", webhook_message={}, stripe_id="foo") + instance = EventAdmin(Event, admin.site) + response = instance.change_view(request, str(event.pk)) + self.assertEqual( + response.context_data["title"], + "View event" + ) diff --git a/pinax/stripe/tests/test_signals.py b/pinax/stripe/tests/test_signals.py new file mode 100644 index 000000000..29b902cee --- /dev/null +++ b/pinax/stripe/tests/test_signals.py @@ -0,0 +1,8 @@ +from django.test import TestCase + +from ..signals import WEBHOOK_SIGNALS + + +class TestSignals(TestCase): + def test_signals(self): + self.assertEqual(len(WEBHOOK_SIGNALS.keys()), 67) diff --git a/pinax/stripe/tests/test_utils.py b/pinax/stripe/tests/test_utils.py index fd21f3a47..1ad0090cb 100644 --- a/pinax/stripe/tests/test_utils.py +++ b/pinax/stripe/tests/test_utils.py @@ -7,7 +7,8 @@ from ..utils import ( convert_amount_for_api, convert_amount_for_db, - convert_tstamp + convert_tstamp, + obfuscate_secret_key ) @@ -76,3 +77,9 @@ def test_convert_amount_for_api_none_currency(self): expected = 999 actual = convert_amount_for_api(decimal.Decimal("9.99"), currency=None) self.assertEqual(expected, actual) + + +class OtherUtilTests(TestCase): + def test_obfuscate_secret_key(self): + val = obfuscate_secret_key("foobar") + self.assertEqual(val, "********************obar") diff --git a/pinax/stripe/utils.py b/pinax/stripe/utils.py index dbaeb6da8..4f50c3cd6 100644 --- a/pinax/stripe/utils.py +++ b/pinax/stripe/utils.py @@ -40,14 +40,6 @@ def convert_amount_for_api(amount, currency="usd"): return int(amount * 100) if currency.lower() not in ZERO_DECIMAL_CURRENCIES else int(amount) -def update_with_defaults(obj, defaults, created): - if not created: - for key in defaults: - setattr(obj, key, defaults[key]) - obj.save() - return obj - - CURRENCY_SYMBOLS = { "aud": "\u0024", "cad": "\u0024", From 0b105e6d4e14c942733421a192462eb21df90e87 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 23:45:14 -0600 Subject: [PATCH 27/49] More test coverage --- pinax/stripe/tests/test_views.py | 30 ++++++++++ pinax/stripe/tests/test_webhooks.py | 91 ++++++++++++++++++++++++++++- pinax/stripe/views.py | 2 +- pinax/stripe/webhooks.py | 11 ++-- requirements.testing.txt | 1 - setup.cfg | 2 +- 6 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 pinax/stripe/tests/test_views.py diff --git a/pinax/stripe/tests/test_views.py b/pinax/stripe/tests/test_views.py new file mode 100644 index 000000000..dab101c02 --- /dev/null +++ b/pinax/stripe/tests/test_views.py @@ -0,0 +1,30 @@ +from unittest.mock import patch + +from django.test import RequestFactory, TestCase + +from ..models import Event +from ..views import Webhook +from . import PLAN_CREATED_TEST_DATA + + +class WebhookViewTest(TestCase): + def setUp(self): + self.factory = RequestFactory() + + @patch("pinax.stripe.views.registry") + def test_send_webhook(self, mock_registry): + request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json") + response = Webhook.as_view()(request) + self.assertEqual(response.status_code, 200) + self.assertTrue(Event.objects.filter(stripe_id=PLAN_CREATED_TEST_DATA["id"]).exists()) + self.assertTrue( + mock_registry.get.return_value.return_value.process.called + ) + + @patch("pinax.stripe.views.registry") + def test_send_webhook_no_handler(self, mock_registry): + mock_registry.get.return_value = None + request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json") + response = Webhook.as_view()(request) + self.assertEqual(response.status_code, 200) + self.assertTrue(Event.objects.filter(stripe_id=PLAN_CREATED_TEST_DATA["id"]).exists()) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index 272db2c98..b2e79cdad 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -1,4 +1,5 @@ import json +from unittest.mock import patch from django.dispatch import Signal from django.test import TestCase @@ -6,10 +7,14 @@ from django.urls import reverse import stripe -from mock import patch from ..models import Event, EventProcessingException -from ..webhooks import AccountExternalAccountCreatedWebhook, Webhook, registry +from ..webhooks import ( + AccountApplicationDeauthorizeWebhook, + AccountExternalAccountCreatedWebhook, + Webhook, + registry +) class WebhookRegistryTest(TestCase): @@ -156,6 +161,22 @@ def test_process_exception_is_logged(self, ProcessWebhookMock, ValidateMock): AccountExternalAccountCreatedWebhook(event).process() self.assertTrue(EventProcessingException.objects.filter(event=event).exists()) + @patch("pinax.stripe.webhooks.Webhook.validate") + def test_process_already_processed(self, ValidateMock): + event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=True) + hook = registry.get(event.kind) + hook(event).process() + self.assertFalse(ValidateMock.called) + + @patch("pinax.stripe.webhooks.Webhook.validate") + @patch("pinax.stripe.webhooks.Webhook.process_webhook") + def test_process_not_valid(self, ProcessWebhookMock, ValidateMock): + event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=False, processed=False) + hook = registry.get(event.kind) + hook(event).process() + self.assertTrue(ValidateMock.called) + self.assertFalse(ProcessWebhookMock.called) + @patch("pinax.stripe.webhooks.Webhook.validate") @patch("pinax.stripe.webhooks.Webhook.process_webhook") def test_process_exception_is_logged_non_stripeerror(self, ProcessWebhookMock, ValidateMock): @@ -171,3 +192,69 @@ def test_process_return_none(self, ValidateMock): # note: we choose an event type for which we do no processing event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=False) self.assertIsNone(AccountExternalAccountCreatedWebhook(event).process()) + + @patch("stripe.Event.retrieve") + def test_process_deauthorize(self, RetrieveMock): + data = {"data": {"object": {"id": "evt_001"}}, + "account": "acct_bb"} + event = Event.objects.create( + kind=AccountApplicationDeauthorizeWebhook.name, + webhook_message=data, + ) + RetrieveMock.side_effect = stripe.error.PermissionError( + "The provided key 'sk_test_********************abcd' does not have access to account 'acct_aa' (or that account does not exist). Application access may have been revoked.") + AccountApplicationDeauthorizeWebhook(event).process() + self.assertTrue(event.valid) + self.assertTrue(event.processed) + + @patch("stripe.Event.retrieve") + def test_process_deauthorize_fake_response(self, RetrieveMock): + data = {"data": {"object": {"id": "evt_001"}}, + "account": "acct_bb"} + event = Event.objects.create( + kind=AccountApplicationDeauthorizeWebhook.name, + webhook_message=data, + ) + RetrieveMock.side_effect = stripe.error.PermissionError( + "The provided key 'sk_test_********************ABCD' does not have access to account 'acc_aa' (or that account does not exist). Application access may have been revoked.") + with self.assertRaises(stripe.error.PermissionError): + AccountApplicationDeauthorizeWebhook(event).process() + + @patch("stripe.Event.retrieve") + def test_process_deauthorize_with_delete_account(self, RetrieveMock): + data = {"data": {"object": {"id": "evt_002"}}, + "account": "acct_bb"} + event = Event.objects.create( + kind=AccountApplicationDeauthorizeWebhook.name, + webhook_message=data, + ) + RetrieveMock.side_effect = stripe.error.PermissionError( + "The provided key 'sk_test_********************abcd' does not have access to account 'acct_bb' (or that account does not exist). Application access may have been revoked.") + AccountApplicationDeauthorizeWebhook(event).process() + self.assertTrue(event.valid) + self.assertTrue(event.processed) + + @patch("stripe.Event.retrieve") + def test_process_deauthorize_without_account(self, RetrieveMock): + data = {"data": {"object": {"id": "evt_001"}}} + event = Event.objects.create( + kind=AccountApplicationDeauthorizeWebhook.name, + webhook_message=data, + ) + RetrieveMock.return_value.to_dict.return_value = data + AccountApplicationDeauthorizeWebhook(event).process() + self.assertTrue(event.valid) + self.assertTrue(event.processed) + + @patch("stripe.Event.retrieve") + def test_process_deauthorize_without_account_exception(self, RetrieveMock): + data = {"data": {"object": {"id": "evt_001"}}} + event = Event.objects.create( + kind=AccountApplicationDeauthorizeWebhook.name, + webhook_message=data, + ) + RetrieveMock.side_effect = stripe.error.PermissionError() + RetrieveMock.return_value.to_dict.return_value = data + AccountApplicationDeauthorizeWebhook(event).process() + self.assertTrue(event.valid) + self.assertTrue(event.processed) diff --git a/pinax/stripe/views.py b/pinax/stripe/views.py index 491fe939c..936f2a3da 100644 --- a/pinax/stripe/views.py +++ b/pinax/stripe/views.py @@ -30,7 +30,7 @@ def add_event(self, data): @method_decorator(csrf_exempt) def dispatch(self, *args, **kwargs): - return super(Webhook, self).dispatch(*args, **kwargs) + return super().dispatch(*args, **kwargs) def post(self, request, *args, **kwargs): data = json.loads(smart_str(self.request.body)) diff --git a/pinax/stripe/webhooks.py b/pinax/stripe/webhooks.py index d7a6fe335..45847bbfc 100644 --- a/pinax/stripe/webhooks.py +++ b/pinax/stripe/webhooks.py @@ -25,11 +25,8 @@ def register(self, webhook): def keys(self): return self._registry.keys() - def get(self, name, default=None): - try: - return self[name]["webhook"] - except KeyError: - return default + def get(self, name): + return self[name]["webhook"] def get_signal(self, name, default=None): try: @@ -159,10 +156,10 @@ def validate(self): account anymore). """ try: - super(AccountApplicationDeauthorizeWebhook, self).validate() + super().validate() except stripe.error.PermissionError as exc: if self.stripe_account: - if not(self.stripe_account in str(exc) and obfuscate_secret_key(settings.PINAX_STRIPE_SECRET_KEY) in str(exc)): + if self.stripe_account not in str(exc) and obfuscate_secret_key(settings.PINAX_STRIPE_SECRET_KEY) not in str(exc): raise exc self.event.valid = True self.event.validated_message = self.event.webhook_message diff --git a/requirements.testing.txt b/requirements.testing.txt index 1a706448d..848e89230 100644 --- a/requirements.testing.txt +++ b/requirements.testing.txt @@ -1,4 +1,3 @@ -mock pytest pytest-cov pytest-django diff --git a/setup.cfg b/setup.cfg index 5f96e80ca..292e8af59 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [isort] multi_line_output=3 known_django=django -known_third_party=stripe,mock,appconf +known_third_party=stripe,appconf sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER skip_glob=*/pinax/stripe/migrations/* From 8cfb9c8543feb5c72ae583cecf1d9b1ca2dc3070 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Thu, 25 Nov 2021 23:49:56 -0600 Subject: [PATCH 28/49] Updates docs --- docs/index.md | 11 - docs/reference/actions.md | 467 ------------------ docs/reference/commands.md | 22 - docs/reference/forms.md | 1 - docs/reference/hooksets.md | 1 - docs/reference/managers.md | 1 - docs/reference/middleware.md | 7 - docs/reference/mixins.md | 1 - docs/reference/templates.md | 47 -- docs/user-guide/connect.md | 259 ---------- docs/user-guide/ecommerce.md | 20 - docs/user-guide/getting-started.md | 184 ------- .../images/stripe-account-panel.png | Bin 76187 -> 0 bytes .../images/stripe-create-plan-modal.png | Bin 50346 -> 0 bytes docs/user-guide/images/stripe-create-plan.png | Bin 38473 -> 0 bytes docs/user-guide/images/stripe-menu.png | Bin 29089 -> 0 bytes docs/user-guide/images/stripe-settings.png | Bin 62524 -> 0 bytes docs/user-guide/saas.md | 44 -- docs/user-guide/settings.md | 128 ----- docs/user-guide/upgrading.md | 16 - mkdocs.yml | 16 +- 21 files changed, 1 insertion(+), 1224 deletions(-) delete mode 100644 docs/reference/actions.md delete mode 100644 docs/reference/commands.md delete mode 100644 docs/reference/forms.md delete mode 100644 docs/reference/hooksets.md delete mode 100644 docs/reference/managers.md delete mode 100644 docs/reference/middleware.md delete mode 100644 docs/reference/mixins.md delete mode 100644 docs/reference/templates.md delete mode 100644 docs/user-guide/connect.md delete mode 100644 docs/user-guide/ecommerce.md delete mode 100644 docs/user-guide/getting-started.md delete mode 100644 docs/user-guide/images/stripe-account-panel.png delete mode 100644 docs/user-guide/images/stripe-create-plan-modal.png delete mode 100644 docs/user-guide/images/stripe-create-plan.png delete mode 100644 docs/user-guide/images/stripe-menu.png delete mode 100644 docs/user-guide/images/stripe-settings.png delete mode 100644 docs/user-guide/saas.md delete mode 100644 docs/user-guide/settings.md delete mode 100644 docs/user-guide/upgrading.md diff --git a/docs/index.md b/docs/index.md index 276f89e94..75f845347 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,17 +7,6 @@ As a reusable Django app, `pinax-stripe` provides the ecosystem with a well tested, documented, and proven Stripe integration story for any site that needs payments. -## Sections - -This documentation is broken up into three main sections. First, the -[User Guide](user-guide/getting-started.md) is designed to provide a conceptual -level introduction along with just enough details to get you going on your -project. As you need to dive deeper you, you'll want to check out the -[Reference](reference/actions.md) docs for details on all the code you'll find -in this library. Finally, the [About](about/history.md) section will provide -details for the curious on topics like this project's history, our release -notes, and this project's license text. - ## Finding Help The primary place to find a helpful hand in our [Slack](http://slack.pinaxproject.com/) diff --git a/docs/reference/actions.md b/docs/reference/actions.md deleted file mode 100644 index 27a3d92d4..000000000 --- a/docs/reference/actions.md +++ /dev/null @@ -1,467 +0,0 @@ -# Actions - -## Charges - -#### pinax.stripe.actions.charges.calculate_refund_amount - -Calculates the refund amount given a charge and optional amount. - -Args: - -- charge: a `pinax.stripe.models.Charge` object. -- amount: optionally, the `decimal.Decimal` amount you wish to refund. - -#### pinax.stripe.actions.charges.capture - -Capture the payment of an existing, uncaptured, charge. - -Args: - -- charge: a `pinax.stripe.models.Charge` object. -- amount: the `decimal.Decimal` amount of the charge to capture. - -#### pinax.stripe.actions.charges.create - -Creates a charge for the given customer. - -Args: - -- amount: should be a `decimal.Decimal` amount. -- customer: the Stripe id of the customer to charge. -- source: the Stripe id of the source belonging to the customer. Defaults to `None`. -- currency: the currency with which to charge the amount in. Defaults to `"usd"`. -- description: a description of the charge. Defaults to `None`. -- send_receipt: send a receipt upon successful charge. Defaults to - `PINAX_STRIPE_SEND_EMAIL_RECEIPTS`. -- capture: immediately capture the charge instead of doing a pre-authorization. - Defaults to `True`. - -Returns: `pinax.stripe.models.Charge` object. - -#### pinax.stripe.actions.charges.sync_charges_for_customer - -Populate database with all the charges for a customer. - -Args: - -- customer: a `pinax.stripe.models.Customer` object - -#### pinax.stripe.actions.charges.sync_charge_from_stripe_data - -Create or update the charge represented by the data from a Stripe API query. - -Args: - -- data: the data representing a charge object in the Stripe API - -Returns: `pinax.stripe.models.Charge` object - -## Customers - -#### pinax.stripe.actions.customers.can_charge - -Can the given customer create a charge - -Args: - -- customer: a `pinax.stripe.models.Customer` object - -#### pinax.stripe.actions.customers.create - -Creates a Stripe customer - -Args: - -- user: a `user` object. -- card: optionally, the `token` for a new card. -- plan: a plan to subscribe the user to. Defaults to - `settings.PINAX_STRIPE_DEFAULT_PLAN`. -- charge_immediately: whether or not the user should be immediately - charged for the subscription. Defaults to `True`. -- quantity: the quantity of the subscription. Defaults to `1`. - -Returns: `pinax.stripe.models.Customer` object that was created - -#### pinax.stripe.actions.customers.get_customer_for_user - -Get a customer object for a given user - -Args: - -- user: a `user` object - -Returns: `pinax.stripe.models.Customer` object or `None` if it doesn't exist - -#### pinax.stripe.actions.customers.purge - -Deletes the Stripe customer data and purges the linking of the transaction -data to the Django user. - -Args: - -- customer: the `pinax.stripe.models.Customer` object to purge. - -#### pinax.stripe.actions.customers.link_customer - -Links a customer referenced in a webhook event message to the event object - -Args: - -- event: the `pinax.stripe.models.Event` object to link - -#### pinax.stripe.actions.customers.set_default_source - -Sets the default payment source for a customer - -Args: - -- customer: a `pinax.stripe.models.Customer` object -- source: the Stripe ID of the payment source - -#### pinax.stripe.actions.customers.sync_customer - -Synchronizes a local Customer object with details from the Stripe API - -Args: - -- customer: a `pinax.stripe.models.Customer` object -- cu: optionally, data from the Stripe API representing the customer - -## Events - -#### pinax.stripe.actions.events.add_event - -Adds and processes an event from a received webhook - -Args: - -- stripe_id: the stripe id of the event. -- kind: the label of the event. -- livemode: `True` or `False` if the webhook was sent from livemode or not. -- message: the data of the webhook. -- api_version: the version of the Stripe API used. -- request_id: the id of the request that initiated the webhook. -- pending_webhooks: the number of pending webhooks. Defaults to `0`. - -#### pinax.stripe.actions.events.dupe_event_exists - -Checks if a duplicate event exists - -Args: - -- stripe_id: the Stripe ID of the event to check. - -Returns: `True`, if the event already exists, otherwise, `False`. - -## Exceptions - -#### pinax.stripe.actions.exceptions.log_exception - -Log an exception that was captured as a result of processing events - -Args: - -- data: the data to log about the exception -- exception: the exception object itself -- event: optionally, the event object from which the exception occurred - -## Invoices - -#### pinax.stripe.actions.invoices.create - -Creates a Stripe invoice - -Args: - -- customer: the `pinax.stripe.models.Customer` to create the invoice for. - -Returns: the data from the Stripe API that represents the invoice object that - was created - -#### pinax.stripe.actions.invoices.create_and_pay - -Creates and and immediately pays an invoice for a customer - -Args: - -- customer: the `pinax.stripe.models.Customer` to create the invoice for. - -Returns: `True`, if invoice was created, `False` if there was an error. - -#### pinax.stripe.actions.invoices.pay - -Triggers an invoice to be paid - -Args: - -- invoice: the `pinax.stripe.models.Invoice` object to have paid -- send_receipt: if `True`, send the receipt as a result of paying. Defaults to `True`. - -Returns: `True` if the invoice was paid, `False` if it was unable to be paid. - -#### pinax.stripe.actions.invoices.sync_invoice_from_stripe_data - -Synchronizes a local invoice with data from the Stripe API - -Args: - -- stripe_invoice: data that represents the invoice from the Stripe API -- send_receipt: if `True`, send the receipt as a result of paying. Defaults - to `settings.PINAX_STRIPE_SEND_EMAIL_RECEIPTS`. - -Returns: the `pinax.stripe.models.Invoice` that was created or updated - -#### pinax.stripe.actions.invoices.sync_invoices_for_customer - -Synchronizes all invoices for a customer - -Args: - -- customer: the `pinax.stripe.models.Customer` for whom to synchronize all invoices - -#### pinax.stripe.actions.invoices.sync_invoice_items - -Synchronizes all invoice line items for a particular invoice - -This assumes line items from a Stripe invoice.lines property and not through -the invoicesitems resource calls. At least according to the documentation -the data for an invoice item is slightly different between the two calls. - -For example, going through the invoiceitems resource you do not get a "type" -field on the object. - -Args: - -- invoice: the `pinax.stripe.models.Invoice` object to synchronize -- items: the data from the Stripe API representing the line items - -## Plans - -#### pinax.stripe.actions.plans.sync_plans - -Synchronizes all plans from the Stripe API - -## Refunds - -#### pinax.stripe.actions.refunds.create - -Creates a refund for a particular charge - -Args: - -- charge: the `pinax.stripe.models.Charge` against which to create the refund -- amount: how much should the refund be, defaults to `None`, in which case - the full amount of the charge will be refunded - -## Sources - -#### pinax.stripe.actions.sources.create_card - -Creates a new card for a customer - -Args: - -- customer: the `pinax.stripe.models.Customer` to create the card for -- token: the token created from Stripe.js - -#### pinax.stripe.actions.sources.delete_card - -Deletes a card from a customer - -Args: - -- customer: the `pinax.stripe.models.Customer` to delete the card from -- source: the Stripe ID of the payment source to delete - -#### pinax.stripe.actions.sources.delete_card_object - -Deletes the local `pinax.stripe.models.Customer` object. - -Args: - -- source: the Stripe ID of the card - -#### pinax.stripe.actions.sources.sync_card - -Synchronizes the data for a card locally for a given customer - -Args: - -- customer: the `pinax.stripe.models.Customer` to create or update a card for -- source: data reprenting the card from the Stripe API - -#### pinax.stripe.actions.sources.sync_bitcoin - -Synchronizes the data for a Bitcoin receiver locally for a given customer - -Args: - -- customer: the `pinax.stripe.models.Customer` to create or update a Bitcoin - receiver for -- source: data reprenting the Bitcoin receiver from the Stripe API - -#### pinax.stripe.actions.sources.sync_payment_source_from_stripe_data - -Synchronizes the data for a payment source locally for a given customer - -Args: - -- customer: the `pinax.stripe.models.Customer` to create or update a Bitcoin - receiver for -- source: data reprenting the payment source from the Stripe API - -#### pinax.stripe.actions.sources.update_card - -Updates a card for a given customer - -Args: - -- customer: the `pinax.stripe.models.Customer` for whom to update the card -- source: the Stripe ID of the card to update -- name: optionally, a name to give the card -- exp_month: optionally, the expiration month for the card -- exp_year: optionally, the expiration year for the card - -## Subscriptions - -#### pinax.stripe.actions.subscriptions.cancel - -Cancels a subscription - -Args: - -- subscription: the `pinax.stripe.models.Subscription` to cancel -- at_period_end: True, to cancel at the end, otherwise immediately cancel. - Defaults to `True` - -#### pinax.stripe.actions.subscriptions.create - -Creates a subscription for the given customer - -Args: - -- customer: the `pinax.stripe.models.Customer` to create the subscription for -- plan: the plan to subscribe to -- quantity: if provided, the number to subscribe to -- trial_days: if provided, the number of days to trial before starting -- token: if provided, a token from Stripe.js that will be used as the - payment source for the subscription and set as the default - source for the customer, otherwise the current default source - will be used -- coupon: if provided, a coupon to apply towards the subscription -- tax_percent: if provided, add percentage as tax - -Returns: the `pinax.stripe.models.Subscription` object that was created - -#### pinax.stripe.actions.subscriptions.has_active_subscription - -Checks if the given customer has an active subscription - -Args: - -- customer: the `pinax.stripe.models.Subscription` to check - -Returns: `True`, if there is an active subscription, otherwise `False` - -#### pinax.stripe.actions.subscriptions.is_period_current - -Tests if the provided `pinax.stripe.models.Subscription` object for the current period - -Args: - -- subscription: a `pinax.stripe.models.Subscription` object to test - -Returns: `True`, if provided subscription periods end is beyond `timezone.now`, - otherwise `False`. - -#### pinax.stripe.actions.subscriptions.is_status_current - -Tests if the provided subscription object has a status that means current - -Args: - -- subscription: a `pinax.stripe.models.Subscription` object to test - -Returns: `bool` - -#### pinax.stripe.actions.subscriptions.is_valid - -Tests if the provided subscription object is valid - -Args: - -- subscription: a `pinax.stripe.models.Subscription` object to test - -Returns: `bool` - -#### pinax.stripe.actions.subscriptions.retrieve - -Retrieve a subscription object from Stripe's API - -Stripe throws an exception if a subscription has been deleted that we are -attempting to sync. In this case we want to just silently ignore that -exception but pass on any other. - -Args: - -- customer: the `pinax.stripe.models.Customer` who's subscription you are - trying to retrieve -- sub_id: the Stripe ID of the subscription you are fetching - -Returns: the data for a subscription object from the Stripe API - -#### pinax.stripe.actions.subscriptions.sync_subscription_from_stripe_data - -Synchronizes data from the Stripe API for a subscription - -Args: - -- customer: the `pinax.stripe.models.Customer` who's subscription you are - syncronizing -- subscription: data from the Stripe API representing a subscription - -Returns: the `pinax.stripe.models.Subscription` object created or updated - -#### pinax.stripe.actions.subscriptions.update - -Updates a subscription - -Args: - -- subscription: the `pinax.stripe.models.Subscription` to update -- plan: optionally, the plan to change the subscription to -- quantity: optionally, the quantiy of the subscription to change -- prorate: optionally, if the subscription should be prorated or not. Defaults - to `True` -- coupon: optionally, a coupon to apply to the subscription -- charge_immediately: optionally, whether or not to charge immediately. - Defaults to `False` - -## Transfers - -#### pinax.stripe.actions.transfers.during - -Return a queryset of `pinax.stripe.models.Transfer` objects for the provided -year and month. - -Args: - -- year: 4-digit year -- month: month as a integer, 1=January through 12=December - -#### pinax.stripe.actions.transfers.sync_transfer - -Synchronizes a transfer from the Stripe API - -Args: - -- transfer: data from Stripe API representing transfer -- event: the `pinax.stripe.models.Event` associated with the transfer - -#### pinax.stripe.actions.transfers.update_status - -Updates the status of a `pinax.stripe.models.Transfer` object from Stripe API - -Args: - -- transfer: a `pinax.stripe.models.Transfer` object to update diff --git a/docs/reference/commands.md b/docs/reference/commands.md deleted file mode 100644 index 12d09d0e1..000000000 --- a/docs/reference/commands.md +++ /dev/null @@ -1,22 +0,0 @@ -# Commands - -#### pinax.stripe.management.commands.init_customers - -Create `pinax.stripe.models.Customer` objects for existing users that do not -have one. - -#### pinax.stripe.management.commands.sync_customers - -Synchronizes customer data from the Stripe API. - -Utilizes the following actions: - -- `pinax.stripe.actions.customers.sync_customer` -- `pinax.stripe.actions.invoices.sync_invoices_for_customer` -- `pinax.stripe.actions.charges.sync_charges_for_customer` - -#### pinax.stripe.management.commands.sync_plans - -Make sure your Stripe account has the plans. - -Utilizes `pinax.stripe.actions.plans.sync_plans`. diff --git a/docs/reference/forms.md b/docs/reference/forms.md deleted file mode 100644 index 130d855f5..000000000 --- a/docs/reference/forms.md +++ /dev/null @@ -1 +0,0 @@ -# Forms diff --git a/docs/reference/hooksets.md b/docs/reference/hooksets.md deleted file mode 100644 index 2c01e9933..000000000 --- a/docs/reference/hooksets.md +++ /dev/null @@ -1 +0,0 @@ -# HookSets diff --git a/docs/reference/managers.md b/docs/reference/managers.md deleted file mode 100644 index e98593f45..000000000 --- a/docs/reference/managers.md +++ /dev/null @@ -1 +0,0 @@ -# Managers diff --git a/docs/reference/middleware.md b/docs/reference/middleware.md deleted file mode 100644 index f62702728..000000000 --- a/docs/reference/middleware.md +++ /dev/null @@ -1,7 +0,0 @@ -# Middleware - -Add `"pinax.stripe.middleware.ActiveSubscriptionMiddleware"` to the middleware settings if you need to limit access to -urls for only those users with an active subscription. - -Settings that should be setup for use of this middleware can be found in -[the SaaS documentation](../user-guide/saas.md). diff --git a/docs/reference/mixins.md b/docs/reference/mixins.md deleted file mode 100644 index e2b01b356..000000000 --- a/docs/reference/mixins.md +++ /dev/null @@ -1 +0,0 @@ -# Mixins diff --git a/docs/reference/templates.md b/docs/reference/templates.md deleted file mode 100644 index d51706790..000000000 --- a/docs/reference/templates.md +++ /dev/null @@ -1,47 +0,0 @@ -# Templates - -Default templates are provided by the `pinax-templates` app in the -[stripe](https://github.com/pinax/pinax-templates/tree/master/pinax/templates/templates/pinax/stripe) -section of that project. - -Reference pinax-templates -[installation instructions](https://github.com/pinax/pinax-templates/blob/master/README.md#installation) -to include these templates in your project. - -## Customizing Templates - -Override the default `pinax-templates` templates by copying them into your project -subdirectory `pinax/stripe/` on the template path and modifying as needed. - -For example if your project doesn't use Bootstrap, copy the desired templates -then remove Bootstrap and Font Awesome class names from your copies. -Remove class references like `class="btn btn-success"` and `class="icon icon-pencil"` as well as -`bootstrap` from the `{% load i18n bootstrap %}` statement. -Since `bootstrap` template tags and filters are no longer loaded, you'll also need to update -`{{ form|bootstrap }}` to `{{ form }}` since the "bootstrap" filter is no longer available. - -### `base.html` - -### `invoice_list.html` - -### `paymentmethod_create.html` - -### `paymentmethod_delete.html` - -### `paymentmethod_list.html` - -### `paymentmethod_update.html` - -### `subscription_create.html` - -### `subscription_delete.html` - -### `subscription_form.html` - -### `subscription_list.html` - -### `subscription_update.html` - -### `_invoice_table.html` - -### `_stripe_js.html` diff --git a/docs/user-guide/connect.md b/docs/user-guide/connect.md deleted file mode 100644 index 1a58dd2c8..000000000 --- a/docs/user-guide/connect.md +++ /dev/null @@ -1,259 +0,0 @@ -# Using Stripe Connect - -[Stripe Connect](https://stripe.com/connect) allows you to perform charges on behalf of your -users and then payout to their bank accounts. - -There are several ways to integrate Connect and these result in the creation of different -[account types](https://stripe.com/connect/account-types). Before you begin your Connect -integration, it is crucial you identify which strategy makes sense for your project as which -you choose has great implications in terms of development effort and how much of your users' -experience you can customize. - -This project allows use of any account type. - -!!! tip "Using Connect requires you to receive webhooks" - - Regardless of which integration you use you will need to enable webhooks so that you can - immediately know when an Account has changed. It may also be necessary for Standard and - Express integrations in order to detect when an Account has been created. - -The minimum required Stripe API version for using the Connect integration with -this project is [version 2017-05-26](https://stripe.com/docs/upgrades#2017-05-25). - -## Standard and Express Accounts - -Users go through an OAuth-like flow hosted by Stripe and set up their own Stripe account. -Stripe will send an event via webhook that will create the account instance in your database. - -You can then create a credit card charge on behalf of a standard account by specifying -the `destination_account` parameter: - -```python -from pinax.stripe.models import Account -from pinax.stripe.actions.charges import create - -account = Account.objects.get(pk=123) -charge = create(5.00, customer, destination_account=account.stripe_id) -``` - -As a result doing this, the charge will be deposited into the specified Account and -paid out to the user via their configured payout settings. - - -## Custom Accounts - -Custom accounts are created, updated and transacted with fully via Stripe's APIs. This -gives you full control over the user experience but places a high developmental burden -on your project. - -You must collect information from your users to setup their Accounts. To this end, this -library includes forms that will help you create accounts and keep them verified. - -### Verification - -When you create a custom Connect account, you can initially supply the minimum details -and immediately be able to transfer funds to the account. After a certain amount has -been transferred, Stripe will request further verification for an account and at this -point you need to ask your user to supply that information. One of the main advantages -of going the Standard or Express routes is that this verification dialogue happens -between your customer and Stripe. - -### Forms - -To create a Custom account, you must capture your users' banking information and supply it -to Stripe. The information you must capture varies by country. Be sure to read Stripe's -[documentation on required info](https://stripe.com/docs/connect/required-verification-information) -before proceeding. - -This library includes two forms intended to ease the process of collecting the right information -from your users when both creating and updating Custom accounts. - -#### Creating a Custom Account - -To create an Account for the currently logged in user, you can use the `InitialCustomAccountForm` -along with a `FormView`, as below. Assuming the user enters valid data, this form -will create a custom Account that you can immediately begin processing charges for and paying out -to. - -```python -from django.views.generic.edit import FormView -from pinax.stripe.forms import InitialCustomAccountForm - - -class CreateCustomAccountView(FormView): - """Prompt a user to enter their bank account details.""" - - form_class = InitialCustomAccountForm - template_name = '' - - def get_form_kwargs(self): - form_kwargs = super( - CreateCustomAccountView, self - ).get_form_kwargs() - initial = form_kwargs.pop('initial', {}) - form_kwargs['request'] = self.request - form_kwargs['country'] = 'US' - return form_kwargs - - def form_valid(self, form): - try: - form.save() - except: - if form.errors: - # means we've converted the exception into errors on the - # form so we just redisplay the form in this case - return self.form_invalid(form) - else: - # some untranslatable error occurred, log it and - # inform user you're looking into it - pass - else: - # success - pass - # redirect to success url - return super(self, CreateCustomAccountView).form_valid(form) - -``` - -#### Updating a Custom Account with Further Verification Information - -After a Custom account has had a certain amount of charges created or funds paid out -Stripe will request additional verification info. They will set a due date after which -the ability to create charges for and pay out to this account may be restricted. - -You will need to detect the webhook for `account.updated` and based on several fields, -determine whether or not you need to initiate an information collection process for -your user. For example: - -```python -from pinax.stripe.models import Account -from pinax.stripe.signals import WEBHOOK_SIGNALS - - -@receiver(WEBHOOK_SIGNALS["account.updated"]) -@receiver_switch -def stripe_account_updated(sender, event, **kwargs): - account = Account.objects.get( - stripe_id=event.validated_message['data']['object']['id'] - ) - # if this is not a custom account, it's probably our platform - # account or an express or standard account, so do nothing - if not account.type == "custom": - return - if account.verification_due_by and account.verification_fields_needed: - # then Stripe is asking us for some info! - # notify the user about this, flag their account so when they login - # they can see they need to enter further info - pass - -``` - -When the user next accesses your website, you will want to be able to request -them to provide further information if they wish to continue receiving payments -and possibly payouts. - -This library includes the `AdditionalCustomAccountForm` in order to make it easy -to dynamically request the right extra information from the user. Using a `FormView` -as with the previous example, you simply need to initialize the form with a keyword -argument `account`, which should be the Account instance you need to collect -further information for. This form will automatically parse `Account.verification_fields_needed` -and build the fields dynamically. - - -```python -from django.views.generic.edit import FormView -from pinax.stripe.forms import AdditionalCustomAccountForm - - -class UpdateCustomAccountView(FormView): - """Prompt a user to enter further info to keep their account verified.""" - - form_class = AdditionalCustomAccountForm - template_name = '' - - def get_form_kwargs(self, *args, **kwargs): - form_kwargs = super( - UpdateCustomAccountView, self - ).get_form_kwargs( - *args, **kwargs - ) - initial = form_kwargs.pop('initial', {}) - form_kwargs['account'] = - return form_kwargs - - def form_valid(self, form): - try: - form.save() - except: - if form.errors: - # means we've converted the exception into errors on the - # form so we just redisplay the form in this case - return self.form_invalid(form) - else: - # some untranslatable error occurred, log it and - # inform user you're looking into it - pass - else: - # success - pass - # redirect to success url - return super(self, UpdateCustomAccountView).form_valid(form) - -``` - -#### Manually paying out a Custom account - -You may decide to keep your users' payout schedules simple and on a rolling basis, but -using Custom accounts frees you up to fully control this aspect of your product. - -When you have a user with a Custom account in good standing, you can create a payout for -the user as below. For the sake of this example, we'll assume you're using the `destination_account` -parametre when creating the charges, such that the payment balance is automatically being -deposited into the Custom account's balance. - -```python -from pinax.stripe.models import Account - -account = Account.objects.get(pk=) - -# we choose the first external account the user has configured -external_account = stripe_account.external_accounts.data[0] - -external_transfer = transfers.create( - 5.00, - 'USD', - external_account.id, - "A payout to a bank account!", - stripe_account=account.stripe_id, # this tells Stripe to transfer from the balance of the Custom account -) -assert external_transfer.status in ('paid', 'pending') - -``` - -In most cases, the transfer (to an external account, these are commonly referred to as `payouts`) will be -initially in a `pending` state. After several days, this will shift to `paid` and your user should see the -amount on their bank account statement. - -#### Create a Connected Customer - -The action `actions.customer.create` accepts a stripe\_account parameter that will automatically -populate a `UserAccount` entry to maintain the relationship between your user and the Stripe account. -Note that in the context of stripe Connect a user is allowed to have several customers, one per account. -This is why the M2M through model `UserAccount` is preferred over `Customer.user` to maintain this -relationships. - -```python -customer = pinax.stripe.actions.customers.create(user, stripe_account=account) -UserAccount.filter(user=user, account=account, customer=customer).exists() ->>> True -``` - -### Retrieve a Connected Customer - -```python -customer = pinax.stripe.actions.customers.get_customer_for_user(user, stripe_account=account) - -# Under the hood, the M2M through model will be used to filter the relevant customer among all candidates - -customer = user.customers.get(user_account__account=stripe_account) -``` diff --git a/docs/user-guide/ecommerce.md b/docs/user-guide/ecommerce.md deleted file mode 100644 index 1e6b3f310..000000000 --- a/docs/user-guide/ecommerce.md +++ /dev/null @@ -1,20 +0,0 @@ -# eCommerce - -One very rudimentary and small aspect of eCommerce is making one off charges. -There is a lot to write about in using `pinax-stripe` for eCommerce, but at -the most basic level making a one off charge can be done with the following -bit of Python code in your site: - -```python -import decimal - -from pinax.stripe.actions import charges - - -charges.create( - amount=decimal.Decimal("5.66"), - customer=request.user.customer.stripe_id -) -``` - -This will create a charge on the customer's default payment source for $5.66. diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md deleted file mode 100644 index 547d820fe..000000000 --- a/docs/user-guide/getting-started.md +++ /dev/null @@ -1,184 +0,0 @@ -# Getting Started - -Adding Stripe integration to your Django project can be done in 3 painless -steps, less if you use the Pinax starter project. - -!!! tip "Pinax Starter Project for Stripe" - - If you choose this route, then you can skip the rest of the steps in this - guide. After running `pip install pinax-cli` just run - `pinax start stripe ` and follow the instructions in the - README of the project that is created. - - -## Installation - -To install simply run: - - pip install pinax-stripe - - -## Configuration - -### Settings - -There are only three required settings (four if setting up subscriptions) you -need to configure: - -* Installed Apps (`INSTALLED_APPS`) -* Stripe Keys (`PINAX_STRIPE_PUBLIC_KEY` and `PINAX_STRIPE_SECRET_KEY`) -* Default Plan (`PINAX_STRIPE_DEFAULT_PLAN`) - -See the [settings and configuration](settings.md) docs for more of what's -available to customize your integration. - -#### Installed Apps - -```python -# settings.py -INSTALLED_APPS = ( - ... - "django.contrib.sites", - ... - "pinax.stripe", -) -``` - -#### Set `SITE_ID` for the `Sites` framework - -```python -# settings.py -SITE_ID = 1 -``` - -#### Creating the `pinax-stripe` database tables - -`pinax-stripe` stores a cache of some Stripe data locally, so you need to run the included migrations to set up the new tables. Just run: - - ./manage.py migrate - -#### Stripe Keys - -Your Stripe keys are what authorize the app to integrate with your Stripe -account. You can find your keys the Stripe account panel (see screenshots): - -![](images/stripe-menu.png) - -![](images/stripe-account-panel.png) - -It's a good idea not to commit your production keys to your source repository -as a way of limiting access to who can access your Stripe account. One way of -doing this is setting environment variables where you deploy your code: - -```python -# settings.py -PINAX_STRIPE_PUBLIC_KEY = os.environ.get("STRIPE_PUBLIC_KEY", "your test public key") -PINAX_STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "your test secret key") -``` - -This will use the environment variables `STRIPE_PUBLIC_KEY` and -`STRIPE_SECRET_KEY` if they have been set. Otherwise what you set in the second -parameter will be used. - -#### Default Plan - -If you are using `pinax-stripe` for something like a Software-as-a-Service -site with subscriptions, then you will want to also set the Stripe ID for a -`PINAX_STRIPE_DEFAULT_PLAN` setting and install middleware. See the -[SaaS Guide](../user-guide/saas.md) for more details about working with -subscriptions. - -### Urls and Views - -If you want to use the [default views](../reference/views.md) that ship with -`pinax-stripe` you can simply hook up the urls: - -```python -# urls.py -url(r"^payments/", include("pinax.stripe.urls")), -``` - -However you may only want to hook up some of them or customize some and hook up -each url individually. Please see the [urls](../reference/urls.md) docs for more -details. - -## Syncing Data - -The data in `pinax-stripe` is a cache of the data you have in your Stripe -account. The one exception to this is the `pinax.stripe.models.Customer` model -that links a Stripe Customer to a user in your site. - -!!! note - - The reason for this exception is because of the need to link the Stripe - data to users in your application. This is done through a one to one - relationship (a `Customer` can only belong to a single `User` and a `User` - can only have a single `Customer` reference). - -### Syncing Plans - -If you are using subscriptions you'll want to setup your plans in your Stripe -account: - -![](images/stripe-create-plan.png) - -![](images/stripe-create-plan-modal.png) - -and then run: - - ./manage.py sync_plans - - -### Initializing Customers - -If you already have users in your site and are adding payments and/or -subscription capabilities and want to create a customer for every user in your -site, you'll want to do two things: - -First setup, handle new users being created in your site either in a sign up -view, a signal receiver, etc., to run: - -```python -from pinax.stripe.actions import customers -customers.create(user=new_user) -``` - -Then, to update your Stripe account after your initial deploy of a site with -existing users: - - ./manage.py init_customers - -!!! note "Note" - - This is not required and you may choose to only create customers - for users that actually become customers in the event you have a mix of users - and customers on your site. - - -### Syncing Customer Data - -In the event, you need to update the local cache of data for your customers, -you can run: - - ./manage.py sync_customers - - -## Testing Webhooks Locally - -Since the Stripe integration is driven largely by webhooks you'll need to -configure a port forwarder to give you a public URL to route requests to -your local `runserver`. - -[ngrok](https://ngrok.com/) has been a great tool that is easy to get going, -but you can use whatever you want. Here is how to use `ngrok`: - - ngrok http 8000 # assuming you are running runserver with the default 8000 port - -Copy and paste the url that ngrok outputs that it's mapping to your local -machine, into your webhook settings in the Stripe dashboard account settings. -Make sure the webhook URL appended to the end: - - http://.ngrok.io/payments/webhook/ - -Now when you do activities locally like subscribe, change payment methods, etc., -the webhooks will flow back to your machine. diff --git a/docs/user-guide/images/stripe-account-panel.png b/docs/user-guide/images/stripe-account-panel.png deleted file mode 100644 index 8ef98a174e37038f8c659fd1348f774a630b8b60..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 76187 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 LzGk|HRp5UBL+9rp diff --git a/docs/user-guide/images/stripe-create-plan-modal.png b/docs/user-guide/images/stripe-create-plan-modal.png deleted file mode 100644 index bba55cafc29d84a98e789a8fb856042abe02dc87..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50346 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 N1sP@Of=Bvq{tvPVEFb^? diff --git a/docs/user-guide/images/stripe-create-plan.png b/docs/user-guide/images/stripe-create-plan.png deleted file mode 100644 index d77248a1544f79bccd48b494d5918a938bde1e65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38473 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 diff --git a/docs/user-guide/images/stripe-menu.png b/docs/user-guide/images/stripe-menu.png deleted file mode 100644 index 2bca34b49d13322d808a515a68217985d8940174..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29089 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 Wr`hP?=#Q_f78jNgDt)Er`+oo@ub!a* diff --git a/docs/user-guide/images/stripe-settings.png b/docs/user-guide/images/stripe-settings.png deleted file mode 100644 index 150e70afd86f96a06c9a914ff2a07d82d3b3a141..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 62524 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 j-<b3W7PiuxdhBn diff --git a/docs/user-guide/saas.md b/docs/user-guide/saas.md deleted file mode 100644 index eb00a5946..000000000 --- a/docs/user-guide/saas.md +++ /dev/null @@ -1,44 +0,0 @@ -# Software as a Service - -SaaS projects require minimal setup to manage subscriptions for your software. -You will need to configure settings, middleware and a few settings in the Stripe.com management screens. - -## Settings - -The following settings marked as **Required** will need to exist in your settings file. - -### PINAX_STRIPE_DEFAULT_PLAN - -**Required** - -`PINAX_STRIPE_DEFAULT_PLAN` - -Sets a default plan and is used if you have a scenario where you want to auto-subscribe new users to a plan upon signup. - -### PINAX_STRIPE_SUBSCRIPTION_REQUIRED_REDIRECT - -**Required** - -`PINAX_STRIPE_SUBSCRIPTION_REQUIRED_REDIRECT` - -The URL of where to redirect requests to that are not from a user with an active subscription when the `pinax.stripe.middleware.ActiveSubscriptionMiddleware` is active. - -### PINAX_STRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS - -`PINAX_STRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS` - -A list of any URLs exempt from requiring an active subscription. The middleware in `pinax.stripe.middleware.ActiveSubscriptionMiddleware` will allow access to these URLs. - - -## Stripe Settings - -![](images/stripe-settings.png) - -Settings for subscriptions will need to be configured for your service's subscription within Stripe's dashboard. -Set your preference for what happens with failed payments and the cancellation. When a card fails, Stripe will send a webhook to update the customer's status. -If the customer's subscription has been cancelled, the middleware will redirect the user to the predefined url notifying them of a problem -with their account. - -## Middleware - -Add `"pinax.stripe.middleware.ActiveSubscriptionMiddleware"` to the middleware settings. \ No newline at end of file diff --git a/docs/user-guide/settings.md b/docs/user-guide/settings.md deleted file mode 100644 index acaa9bb05..000000000 --- a/docs/user-guide/settings.md +++ /dev/null @@ -1,128 +0,0 @@ -# Settings & Configuration - -## Settings - -### PINAX_STRIPE_PUBLIC_KEY - -**Required** - -This is the Stripe "publishable" key. You can find it in your Stripe account's -[Account Settings panel](#stripe-account-settings-panel). - - -### PINAX_STRIPE_SECRET_KEY - -**Required** - -This is the Stripe "secret" key. You can find it in your Stripe account's -[Account Settings panel](#stripe-account-settings-panel). - - -### PINAX_STRIPE_API_VERSION - -Defaults to `"2015-10-16"` - -This is the API version to use for API calls and webhook processing. - - -### PINAX_STRIPE_INVOICE_FROM_EMAIL - -Defaults to `"billing@example.com"` - -This is the **from** address of the email notifications containing invoices. - - -### PINAX_STRIPE_DEFAULT_PLAN - -Defaults to `None` - -Sets a default plan and is used if you have a scenario where you want to -auto-subscribe new users to a plan upon signup. - - -### PINAX_STRIPE_HOOKSET - -Defaults to `"pinax.stripe.hooks.DefaultHookSet"` - -Should be a string that is a dotted-notation path to a class that implements -[hookset](#hooksets) methods as outlined below. - - -### PINAX_STRIPE_SEND_EMAIL_RECEIPTS - -Defaults to `True` - -Tells `pinax-stripe` to send out email receipts for successful charges. - - -### PINAX_STRIPE_SUBSCRIPTION_REQUIRED_EXCEPTION_URLS - -Defaults to `[]` - -A list of URLs to exempt from requiring an active subscription if the -`pinax.stripe.middleware.ActiveSubscriptionMiddleware` is installed. - - -### PINAX_STRIPE_SUBSCRIPTION_REQUIRED_REDIRECT - -Defaults to `None` - -The URL of where to redirect requests to that are not from a user with an -active subscription if the `pinax.stripe.middleware.ActiveSubscriptionMiddleware` -is installed. - - -### PINAX_STRIPE_SUBSCRIPTION_TAX_PERCENT - -Defaults to `None` - -If you wish to charge tax on a subscription, set this value to an integer -specifying the percentage of tax required (i.e. 10% would be '10'). This is -used by `pinax.stripe.views.SubscriptionCreateView` - - -## Stripe Account Settings Panel - -![](images/stripe-account-panel.png) - - -## HookSets - -A HookSet is a design pattern that allows the site developer to override -callables to customize behavior. There is some overlap with Signals but they -are different in that these are called directly and executed only once per -call rather than going through a dispatch mechanism where there is an -unknown number of receivers. - -There are currently three methods on the `DefaultHookSet` than you can -override. You do this by inheriting from the default and implementing the -methods you care to change. - -```python -# mysite/hooks.py -from pinax.stripe.hooks import DefaultHookSet - -class HookSet(DefaultHookSet): - - def adjust_subscription_quantity(self, customer, plan, quantity): - """ - Given a customer, plan, and quantity, when calling Customer.subscribe - you have the opportunity to override the quantity that was specified. - """ - return quantity - - def trial_period(self, user, plan): - """ - Given a user and plan, return an end date for a trial period, or None - for no trial period. - """ - return None - - def send_receipt(self, charge): - pass -``` - -```python -# settings.py -PINAX_STRIPE_HOOKSET = "mysite.hooks.HookSet" -``` diff --git a/docs/user-guide/upgrading.md b/docs/user-guide/upgrading.md deleted file mode 100644 index 9f95efd1b..000000000 --- a/docs/user-guide/upgrading.md +++ /dev/null @@ -1,16 +0,0 @@ -# Upgrading from Django Stripe Payments - -There has been a tremendous amount of change since this package was called -`django-stripe-payments`. A lot of work and thought has been done to consider -the upgrade path and make it as easy as possible. In terms of the data -migration it should be mostly automatic. - -The only data that needs to migrate is the user to customer linkage and that is -done in the [0002_auto_20151205_1451.py](https://github.com/pinax/pinax-stripe/blob/master/pinax/stripe/migrations/0002_auto_20151205_1451.py) -data migration. - -This only copies over the customer links. To pull in all the other data you -should run `manage.py sync_plans` and then `manage.py sync_customers`. - -That should be it. If you run into any issues upgrading or otherwise, please -[report an issue](https://github.com/pinax/pinax-stripe/issues/new). diff --git a/mkdocs.yml b/mkdocs.yml index fb00179eb..99b3143b5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,24 +1,10 @@ site_name: Pinax Stripe pages: - Home: index.md -- User Guide: - - Getting Started: user-guide/getting-started.md - - Settings & Configuration: user-guide/settings.md - - Software as a Service: user-guide/saas.md - - eCommerce: user-guide/ecommerce.md - - Stripe Connect: user-guide/connect.md - - Upgrading from DSP: user-guide/upgrading.md - Reference: - - Actions: reference/actions.md - - Management Commands: reference/commands.md - - Templates: reference/templates.md - Settings: reference/settings.md - - Forms: reference/forms.md - - HookSets: reference/hooksets.md - - Managers: reference/managers.md - - Middleware: reference/middleware.md - - Mixins: reference/mixins.md - Signals: reference/signals.md + - Template Tags: reference/templatetags.md - URLs: reference/urls.md - Utilities: reference/utils.md - Views: reference/views.md From 771652d705cd84c1913f64ab0d625800d30e8fc3 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 11:24:49 -0600 Subject: [PATCH 29/49] Change package name --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 292e8af59..3ef1a8b37 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,7 @@ DJANGO_SETTINGS_MODULE = pinax.stripe.tests.settings addopts = --reuse-db -ra --nomigrations [metadata] -name = pinax-stripe +name = pinax-stripe-light version = 5.0.0 author = Pinax Team author_email = team@pinaxproject.com From 210389eec367925caee2d3768d99545999dcf552 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 11:34:22 -0600 Subject: [PATCH 30/49] Fix missing import --- pinax/stripe/tests/test_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pinax/stripe/tests/test_models.py b/pinax/stripe/tests/test_models.py index bb85501ec..b3abbc4e2 100644 --- a/pinax/stripe/tests/test_models.py +++ b/pinax/stripe/tests/test_models.py @@ -1,6 +1,7 @@ import datetime from django.test import TestCase +from django.utils import timezone from ..models import Event, EventProcessingException From 78ef672eda0f2d95f624c22ad1de1953f7055462 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 11:37:58 -0600 Subject: [PATCH 31/49] Update package ref --- pinax/stripe/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pinax/stripe/__init__.py b/pinax/stripe/__init__.py index 05275cb70..95d488a83 100644 --- a/pinax/stripe/__init__.py +++ b/pinax/stripe/__init__.py @@ -1,3 +1,3 @@ import pkg_resources -__version__ = pkg_resources.get_distribution("pinax-stripe").version +__version__ = pkg_resources.get_distribution("pinax-stripe-light").version From 1d0c8f6b3fc5e57f13746fed1c118b0022f6965a Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 14:07:13 -0600 Subject: [PATCH 32/49] Collapse migrations --- ...120_1239.py => 0015_auto_20211126_1406.py} | 25 ++++++++++++++++--- .../migrations/0016_remove_event_request.py | 17 ------------- .../migrations/0017_auto_20200815_1037.py | 18 ------------- .../migrations/0018_auto_20211125_1756.py | 23 ----------------- 4 files changed, 22 insertions(+), 61 deletions(-) rename pinax/stripe/migrations/{0015_auto_20190120_1239.py => 0015_auto_20211126_1406.py} (86%) delete mode 100644 pinax/stripe/migrations/0016_remove_event_request.py delete mode 100644 pinax/stripe/migrations/0017_auto_20200815_1037.py delete mode 100644 pinax/stripe/migrations/0018_auto_20211125_1756.py diff --git a/pinax/stripe/migrations/0015_auto_20190120_1239.py b/pinax/stripe/migrations/0015_auto_20211126_1406.py similarity index 86% rename from pinax/stripe/migrations/0015_auto_20190120_1239.py rename to pinax/stripe/migrations/0015_auto_20211126_1406.py index ff9670958..dbe4f14fd 100644 --- a/pinax/stripe/migrations/0015_auto_20190120_1239.py +++ b/pinax/stripe/migrations/0015_auto_20211126_1406.py @@ -1,4 +1,4 @@ -# Generated by Django 2.1.5 on 2019-01-20 18:39 +# Generated by Django 3.2.9 on 2021-11-26 20:06 from django.db import migrations, models @@ -75,7 +75,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='plan', - unique_together=set(), + unique_together=None, ), migrations.RemoveField( model_name='plan', @@ -103,7 +103,7 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name='useraccount', - unique_together=set(), + unique_together=None, ), migrations.RemoveField( model_name='useraccount', @@ -121,6 +121,10 @@ class Migration(migrations.Migration): model_name='event', name='customer', ), + migrations.RemoveField( + model_name='event', + name='request', + ), migrations.RemoveField( model_name='event', name='stripe_account', @@ -135,6 +139,21 @@ class Migration(migrations.Migration): name='customer_id', field=models.CharField(blank=True, max_length=200), ), + migrations.AlterField( + model_name='event', + name='valid', + field=models.BooleanField(blank=True, null=True), + ), + migrations.AlterField( + model_name='event', + name='validated_message', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='event', + name='webhook_message', + field=models.JSONField(), + ), migrations.DeleteModel( name='Account', ), diff --git a/pinax/stripe/migrations/0016_remove_event_request.py b/pinax/stripe/migrations/0016_remove_event_request.py deleted file mode 100644 index 8be09ee88..000000000 --- a/pinax/stripe/migrations/0016_remove_event_request.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 2.1.5 on 2019-04-22 02:03 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0015_auto_20190120_1239'), - ] - - operations = [ - migrations.RemoveField( - model_name='event', - name='request', - ), - ] diff --git a/pinax/stripe/migrations/0017_auto_20200815_1037.py b/pinax/stripe/migrations/0017_auto_20200815_1037.py deleted file mode 100644 index aad95dbf6..000000000 --- a/pinax/stripe/migrations/0017_auto_20200815_1037.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.1 on 2020-08-15 15:37 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0016_remove_event_request'), - ] - - operations = [ - migrations.AlterField( - model_name='event', - name='valid', - field=models.BooleanField(blank=True, null=True), - ), - ] diff --git a/pinax/stripe/migrations/0018_auto_20211125_1756.py b/pinax/stripe/migrations/0018_auto_20211125_1756.py deleted file mode 100644 index 8ab80b26e..000000000 --- a/pinax/stripe/migrations/0018_auto_20211125_1756.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.2.9 on 2021-11-25 23:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0017_auto_20200815_1037'), - ] - - operations = [ - migrations.AlterField( - model_name='event', - name='validated_message', - field=models.JSONField(blank=True, null=True), - ), - migrations.AlterField( - model_name='event', - name='webhook_message', - field=models.JSONField(), - ), - ] From 880acce4b75f213841167d7cf27cf9d2ed249abb Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 14:17:04 -0600 Subject: [PATCH 33/49] Reset migrations --- pinax/stripe/migrations/0001_initial.py | 280 +---------------- .../migrations/0002_auto_20151205_1451.py | 30 -- .../migrations/0002_auto_20211126_1416.py | 23 ++ .../0003_make_cvc_check_blankable.py | 19 -- pinax/stripe/migrations/0004_plan_metadata.py | 22 -- .../migrations/0005_auto_20161006_1445.py | 20 -- pinax/stripe/migrations/0006_coupon.py | 40 --- .../migrations/0007_auto_20170108_1202.py | 21 -- .../migrations/0008_auto_20170509_1736.py | 25 -- .../migrations/0009_auto_20170825_1841.py | 21 -- pinax/stripe/migrations/0010_connect.py | 283 ------------------ .../migrations/0011_auto_20171121_1648.py | 24 -- .../migrations/0011_auto_20171123_2016.py | 57 ---- .../stripe/migrations/0013_charge_outcome.py | 21 -- .../migrations/0014_auto_20180413_1959.py | 18 -- .../stripe/migrations/0014_blank_with_null.py | 151 ---------- .../migrations/0015_auto_20211126_1406.py | 196 ------------ 17 files changed, 37 insertions(+), 1214 deletions(-) delete mode 100644 pinax/stripe/migrations/0002_auto_20151205_1451.py create mode 100644 pinax/stripe/migrations/0002_auto_20211126_1416.py delete mode 100644 pinax/stripe/migrations/0003_make_cvc_check_blankable.py delete mode 100644 pinax/stripe/migrations/0004_plan_metadata.py delete mode 100644 pinax/stripe/migrations/0005_auto_20161006_1445.py delete mode 100644 pinax/stripe/migrations/0006_coupon.py delete mode 100644 pinax/stripe/migrations/0007_auto_20170108_1202.py delete mode 100644 pinax/stripe/migrations/0008_auto_20170509_1736.py delete mode 100644 pinax/stripe/migrations/0009_auto_20170825_1841.py delete mode 100644 pinax/stripe/migrations/0010_connect.py delete mode 100644 pinax/stripe/migrations/0011_auto_20171121_1648.py delete mode 100644 pinax/stripe/migrations/0011_auto_20171123_2016.py delete mode 100644 pinax/stripe/migrations/0013_charge_outcome.py delete mode 100644 pinax/stripe/migrations/0014_auto_20180413_1959.py delete mode 100644 pinax/stripe/migrations/0014_blank_with_null.py delete mode 100644 pinax/stripe/migrations/0015_auto_20211126_1406.py diff --git a/pinax/stripe/migrations/0001_initial.py b/pinax/stripe/migrations/0001_initial.py index a8133ad64..cd57831c7 100644 --- a/pinax/stripe/migrations/0001_initial.py +++ b/pinax/stripe/migrations/0001_initial.py @@ -1,133 +1,34 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals +# Generated by Django 3.2.9 on 2021-11-26 20:16 -from decimal import Decimal - -import django.utils.timezone -from django.conf import settings from django.db import migrations, models - -import jsonfield.fields +import django.db.models.deletion +import django.utils.timezone class Migration(migrations.Migration): + initial = True + dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ - migrations.CreateModel( - name='BitcoinReceiver', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('active', models.BooleanField(default=False)), - ('amount', models.DecimalField(decimal_places=2, max_digits=9)), - ('amount_received', models.DecimalField(decimal_places=2, max_digits=9, default=Decimal('0'))), - ('bitcoin_amount', models.PositiveIntegerField()), - ('bitcoin_amount_received', models.PositiveIntegerField(default=0)), - ('bitcoin_uri', models.TextField(blank=True)), - ('currency', models.CharField(max_length=10, default='usd')), - ('description', models.TextField(blank=True)), - ('email', models.TextField(blank=True)), - ('filled', models.BooleanField(default=False)), - ('inbound_address', models.TextField(blank=True)), - ('payment', models.TextField(blank=True)), - ('refund_address', models.TextField(blank=True)), - ('uncaptured_funds', models.BooleanField(default=False)), - ('used_for_payment', models.BooleanField(default=False)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Card', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('name', models.TextField(blank=True)), - ('address_line_1', models.TextField(blank=True)), - ('address_line_1_check', models.CharField(max_length=15)), - ('address_line_2', models.TextField(blank=True)), - ('address_city', models.TextField(blank=True)), - ('address_state', models.TextField(blank=True)), - ('address_country', models.TextField(blank=True)), - ('address_zip', models.TextField(blank=True)), - ('address_zip_check', models.CharField(max_length=15)), - ('brand', models.TextField(blank=True)), - ('country', models.CharField(max_length=2)), - ('cvc_check', models.CharField(max_length=15)), - ('dynamic_last4', models.CharField(blank=True, max_length=4)), - ('tokenization_method', models.CharField(blank=True, max_length=15)), - ('exp_month', models.IntegerField()), - ('exp_year', models.IntegerField()), - ('funding', models.CharField(max_length=15)), - ('last4', models.CharField(blank=True, max_length=4)), - ('fingerprint', models.TextField()), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Charge', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('source', models.CharField(max_length=100)), - ('currency', models.CharField(max_length=10, default='usd')), - ('amount', models.DecimalField(null=True, decimal_places=2, max_digits=9)), - ('amount_refunded', models.DecimalField(null=True, decimal_places=2, max_digits=9)), - ('description', models.TextField(blank=True)), - ('paid', models.NullBooleanField()), - ('disputed', models.NullBooleanField()), - ('refunded', models.NullBooleanField()), - ('captured', models.NullBooleanField()), - ('receipt_sent', models.BooleanField(default=False)), - ('charge_created', models.DateTimeField(null=True, blank=True)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Customer', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('account_balance', models.DecimalField(null=True, decimal_places=2, max_digits=9)), - ('currency', models.CharField(blank=True, max_length=10, default='usd')), - ('delinquent', models.BooleanField(default=False)), - ('default_source', models.TextField(blank=True)), - ('date_purged', models.DateTimeField(null=True, editable=False)), - ('user', models.OneToOneField(null=True, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), - ], - options={ - 'abstract': False, - }, - ), migrations.CreateModel( name='Event', fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stripe_id', models.CharField(max_length=191, unique=True)), ('created_at', models.DateTimeField(default=django.utils.timezone.now)), ('kind', models.CharField(max_length=250)), ('livemode', models.BooleanField(default=False)), - ('webhook_message', jsonfield.fields.JSONField()), - ('validated_message', jsonfield.fields.JSONField(null=True)), - ('valid', models.NullBooleanField()), + ('customer_id', models.CharField(blank=True, max_length=200)), + ('account_id', models.CharField(blank=True, max_length=200)), + ('webhook_message', models.TextField()), + ('validated_message', models.TextField(blank=True, null=True)), + ('valid', models.BooleanField(blank=True, null=True)), ('processed', models.BooleanField(default=False)), - ('request', models.CharField(blank=True, max_length=100)), ('pending_webhooks', models.PositiveIntegerField(default=0)), ('api_version', models.CharField(blank=True, max_length=100)), - ('customer', models.ForeignKey(null=True, to='pinax_stripe.Customer', on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -136,165 +37,12 @@ class Migration(migrations.Migration): migrations.CreateModel( name='EventProcessingException', fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('data', models.TextField()), ('message', models.CharField(max_length=500)), ('traceback', models.TextField()), ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('event', models.ForeignKey(null=True, to='pinax_stripe.Event', on_delete=models.CASCADE)), - ], - ), - migrations.CreateModel( - name='Invoice', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('amount_due', models.DecimalField(decimal_places=2, max_digits=9)), - ('attempted', models.NullBooleanField()), - ('attempt_count', models.PositiveIntegerField(null=True)), - ('statement_descriptor', models.TextField(blank=True)), - ('currency', models.CharField(max_length=10, default='usd')), - ('closed', models.BooleanField(default=False)), - ('description', models.TextField(blank=True)), - ('paid', models.BooleanField(default=False)), - ('receipt_number', models.TextField(blank=True)), - ('period_end', models.DateTimeField()), - ('period_start', models.DateTimeField()), - ('subtotal', models.DecimalField(decimal_places=2, max_digits=9)), - ('total', models.DecimalField(decimal_places=2, max_digits=9)), - ('date', models.DateTimeField()), - ('webhooks_delivered_at', models.DateTimeField(null=True)), - ('charge', models.ForeignKey(null=True, related_name='invoices', to='pinax_stripe.Charge', on_delete=models.CASCADE)), - ('customer', models.ForeignKey(related_name='invoices', to='pinax_stripe.Customer', on_delete=models.CASCADE)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='InvoiceItem', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(max_length=255)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('amount', models.DecimalField(decimal_places=2, max_digits=9)), - ('currency', models.CharField(max_length=10, default='usd')), - ('kind', models.CharField(blank=True, max_length=25)), - ('period_start', models.DateTimeField()), - ('period_end', models.DateTimeField()), - ('proration', models.BooleanField(default=False)), - ('line_type', models.CharField(max_length=50)), - ('description', models.CharField(blank=True, max_length=200)), - ('quantity', models.IntegerField(null=True)), - ('invoice', models.ForeignKey(related_name='items', to='pinax_stripe.Invoice', on_delete=models.CASCADE)), - ], - ), - migrations.CreateModel( - name='Plan', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('amount', models.DecimalField(decimal_places=2, max_digits=9)), - ('currency', models.CharField(max_length=15)), - ('interval', models.CharField(max_length=15)), - ('interval_count', models.IntegerField()), - ('name', models.CharField(max_length=150)), - ('statement_descriptor', models.TextField(blank=True)), - ('trial_period_days', models.IntegerField(null=True)), + ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.event')), ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Subscription', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('application_fee_percent', models.DecimalField(null=True, decimal_places=2, max_digits=3, default=None)), - ('cancel_at_period_end', models.BooleanField(default=False)), - ('canceled_at', models.DateTimeField(null=True, blank=True)), - ('current_period_end', models.DateTimeField(null=True, blank=True)), - ('current_period_start', models.DateTimeField(null=True, blank=True)), - ('ended_at', models.DateTimeField(null=True, blank=True)), - ('quantity', models.IntegerField()), - ('start', models.DateTimeField()), - ('status', models.CharField(max_length=25)), - ('trial_end', models.DateTimeField(null=True, blank=True)), - ('trial_start', models.DateTimeField(null=True, blank=True)), - ('customer', models.ForeignKey(to='pinax_stripe.Customer', on_delete=models.CASCADE)), - ('plan', models.ForeignKey(to='pinax_stripe.Plan', on_delete=models.CASCADE)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='Transfer', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('stripe_id', models.CharField(unique=True, max_length=191)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('amount', models.DecimalField(decimal_places=2, max_digits=9)), - ('currency', models.CharField(max_length=25, default='usd')), - ('status', models.CharField(max_length=25)), - ('date', models.DateTimeField()), - ('description', models.TextField(null=True, blank=True)), - ('event', models.ForeignKey(related_name='transfers', to='pinax_stripe.Event', on_delete=models.CASCADE)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='TransferChargeFee', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('amount', models.DecimalField(decimal_places=2, max_digits=9)), - ('currency', models.CharField(max_length=10, default='usd')), - ('application', models.TextField(null=True, blank=True)), - ('description', models.TextField(null=True, blank=True)), - ('kind', models.CharField(max_length=150)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('transfer', models.ForeignKey(related_name='charge_fee_details', to='pinax_stripe.Transfer', on_delete=models.CASCADE)), - ], - ), - migrations.AddField( - model_name='invoiceitem', - name='plan', - field=models.ForeignKey(null=True, to='pinax_stripe.Plan', on_delete=models.CASCADE), - ), - migrations.AddField( - model_name='invoiceitem', - name='subscription', - field=models.ForeignKey(null=True, to='pinax_stripe.Subscription', on_delete=models.CASCADE), - ), - migrations.AddField( - model_name='invoice', - name='subscription', - field=models.ForeignKey(null=True, to='pinax_stripe.Subscription', on_delete=models.CASCADE), - ), - migrations.AddField( - model_name='charge', - name='customer', - field=models.ForeignKey(related_name='charges', to='pinax_stripe.Customer', on_delete=models.CASCADE), - ), - migrations.AddField( - model_name='charge', - name='invoice', - field=models.ForeignKey(null=True, related_name='charges', to='pinax_stripe.Invoice', on_delete=models.CASCADE), - ), - migrations.AddField( - model_name='card', - name='customer', - field=models.ForeignKey(to='pinax_stripe.Customer', on_delete=models.CASCADE), - ), - migrations.AddField( - model_name='bitcoinreceiver', - name='customer', - field=models.ForeignKey(to='pinax_stripe.Customer', on_delete=models.CASCADE), ), ] diff --git a/pinax/stripe/migrations/0002_auto_20151205_1451.py b/pinax/stripe/migrations/0002_auto_20151205_1451.py deleted file mode 100644 index 65a79c2ef..000000000 --- a/pinax/stripe/migrations/0002_auto_20151205_1451.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.conf import settings -from django.db import connection, migrations, models - - -def migrate_customers(apps, schema_editor): - cursor = connection.cursor() - if "payments_customer" in connection.introspection.table_names(): - cursor.execute("SELECT user_id, stripe_id, date_purged FROM payments_customer") - Customer = apps.get_model("pinax_stripe", "Customer") - for row in cursor.fetchall(): - Customer.objects.create( - user_id=row[0], - stripe_id=row[1], - date_purged=row[2] - ) - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('pinax_stripe', '0001_initial'), - ] - - operations = [ - migrations.RunPython(migrate_customers) - ] diff --git a/pinax/stripe/migrations/0002_auto_20211126_1416.py b/pinax/stripe/migrations/0002_auto_20211126_1416.py new file mode 100644 index 000000000..06593e096 --- /dev/null +++ b/pinax/stripe/migrations/0002_auto_20211126_1416.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.9 on 2021-11-26 20:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='validated_message', + field=models.JSONField(blank=True, null=True), + ), + migrations.AlterField( + model_name='event', + name='webhook_message', + field=models.JSONField(), + ), + ] diff --git a/pinax/stripe/migrations/0003_make_cvc_check_blankable.py b/pinax/stripe/migrations/0003_make_cvc_check_blankable.py deleted file mode 100644 index fe425236f..000000000 --- a/pinax/stripe/migrations/0003_make_cvc_check_blankable.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0002_auto_20151205_1451'), - ] - - operations = [ - migrations.AlterField( - model_name='card', - name='cvc_check', - field=models.CharField(blank=True, max_length=15), - ), - ] diff --git a/pinax/stripe/migrations/0004_plan_metadata.py b/pinax/stripe/migrations/0004_plan_metadata.py deleted file mode 100644 index 356adbf32..000000000 --- a/pinax/stripe/migrations/0004_plan_metadata.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.1 on 2016-10-03 16:33 -from __future__ import unicode_literals - -from django.db import migrations - -import jsonfield.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0003_make_cvc_check_blankable'), - ] - - operations = [ - migrations.AddField( - model_name='plan', - name='metadata', - field=jsonfield.fields.JSONField(null=True), - ), - ] diff --git a/pinax/stripe/migrations/0005_auto_20161006_1445.py b/pinax/stripe/migrations/0005_auto_20161006_1445.py deleted file mode 100644 index 35945a96c..000000000 --- a/pinax/stripe/migrations/0005_auto_20161006_1445.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10 on 2016-10-06 08:45 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0004_plan_metadata'), - ] - - operations = [ - migrations.AlterField( - model_name='card', - name='country', - field=models.CharField(blank=True, max_length=2), - ), - ] diff --git a/pinax/stripe/migrations/0006_coupon.py b/pinax/stripe/migrations/0006_coupon.py deleted file mode 100644 index ae94af754..000000000 --- a/pinax/stripe/migrations/0006_coupon.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.10 on 2016-12-20 03:16 -from __future__ import unicode_literals - -import django.utils.timezone -from django.db import migrations, models - -import jsonfield.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0005_auto_20161006_1445'), - ] - - operations = [ - migrations.CreateModel( - name='Coupon', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('stripe_id', models.CharField(max_length=191, unique=True)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('amount_off', models.DecimalField(decimal_places=2, max_digits=9, null=True)), - ('currency', models.CharField(default='usd', max_length=10)), - ('duration', models.CharField(default='once', max_length=10)), - ('duration_in_months', models.PositiveIntegerField(null=True)), - ('livemode', models.BooleanField(default=False)), - ('max_redemptions', models.PositiveIntegerField(null=True)), - ('metadata', jsonfield.fields.JSONField(null=True)), - ('percent_off', models.PositiveIntegerField(null=True)), - ('redeem_by', models.DateTimeField(null=True)), - ('times_redeemed', models.PositiveIntegerField(null=True)), - ('valid', models.BooleanField(default=False)), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/pinax/stripe/migrations/0007_auto_20170108_1202.py b/pinax/stripe/migrations/0007_auto_20170108_1202.py deleted file mode 100644 index 5859fb49b..000000000 --- a/pinax/stripe/migrations/0007_auto_20170108_1202.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9.8 on 2017-01-08 18:02 -from __future__ import unicode_literals - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0006_coupon'), - ] - - operations = [ - migrations.AlterField( - model_name='charge', - name='customer', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='charges', to='pinax_stripe.Customer'), - ), - ] diff --git a/pinax/stripe/migrations/0008_auto_20170509_1736.py b/pinax/stripe/migrations/0008_auto_20170509_1736.py deleted file mode 100644 index e2dfecfeb..000000000 --- a/pinax/stripe/migrations/0008_auto_20170509_1736.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10 on 2017-05-09 17:36 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0007_auto_20170108_1202'), - ] - - operations = [ - migrations.AddField( - model_name='invoice', - name='tax', - field=models.DecimalField(decimal_places=2, max_digits=9, null=True), - ), - migrations.AddField( - model_name='invoice', - name='tax_percent', - field=models.DecimalField(decimal_places=2, max_digits=9, null=True), - ), - ] diff --git a/pinax/stripe/migrations/0009_auto_20170825_1841.py b/pinax/stripe/migrations/0009_auto_20170825_1841.py deleted file mode 100644 index 0c8f8bcc2..000000000 --- a/pinax/stripe/migrations/0009_auto_20170825_1841.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11 on 2017-08-25 18:41 -from __future__ import unicode_literals - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0008_auto_20170509_1736'), - ] - - operations = [ - migrations.AlterField( - model_name='transfer', - name='event', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transfers', to='pinax_stripe.Event'), - ), - ] diff --git a/pinax/stripe/migrations/0010_connect.py b/pinax/stripe/migrations/0010_connect.py deleted file mode 100644 index 603b47ea0..000000000 --- a/pinax/stripe/migrations/0010_connect.py +++ /dev/null @@ -1,283 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.10.2 on 2017-11-16 01:12 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import django.utils.timezone -import jsonfield.fields - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('pinax_stripe', '0009_auto_20170825_1841'), - ] - - operations = [ - migrations.CreateModel( - name='Account', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('stripe_id', models.CharField(max_length=191, unique=True)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('business_name', models.TextField(blank=True, null=True)), - ('business_url', models.TextField(blank=True, null=True)), - ('charges_enabled', models.BooleanField(default=False)), - ('country', models.CharField(max_length=2)), - ('debit_negative_balances', models.BooleanField(default=False)), - ('decline_charge_on_avs_failure', models.BooleanField(default=False)), - ('decline_charge_on_cvc_failure', models.BooleanField(default=False)), - ('default_currency', models.CharField(max_length=3)), - ('details_submitted', models.BooleanField(default=False)), - ('display_name', models.TextField()), - ('email', models.TextField(blank=True, null=True)), - ('legal_entity_address_city', models.TextField(blank=True, null=True)), - ('legal_entity_address_country', models.TextField(blank=True, null=True)), - ('legal_entity_address_line1', models.TextField(blank=True, null=True)), - ('legal_entity_address_line2', models.TextField(blank=True, null=True)), - ('legal_entity_address_postal_code', models.TextField(blank=True, null=True)), - ('legal_entity_address_state', models.TextField(blank=True, null=True)), - ('legal_entity_dob', models.DateField(null=True)), - ('legal_entity_first_name', models.TextField(blank=True, null=True)), - ('legal_entity_gender', models.TextField(blank=True, null=True)), - ('legal_entity_last_name', models.TextField(blank=True, null=True)), - ('legal_entity_maiden_name', models.TextField(blank=True, null=True)), - ('legal_entity_personal_id_number_provided', models.BooleanField(default=False)), - ('legal_entity_phone_number', models.TextField(blank=True, null=True)), - ('legal_entity_ssn_last_4_provided', models.BooleanField(default=False)), - ('legal_entity_type', models.TextField(blank=True, null=True)), - ('legal_entity_verification_details', models.TextField(blank=True, null=True)), - ('legal_entity_verification_details_code', models.TextField(blank=True, null=True)), - ('legal_entity_verification_document', models.TextField(blank=True, null=True)), - ('legal_entity_verification_status', models.TextField(blank=True, null=True)), - ('type', models.TextField(blank=True, null=True)), - ('metadata', jsonfield.fields.JSONField(blank=True, null=True)), - ('stripe_publishable_key', models.CharField(blank=True, max_length=100, null=True)), - ('product_description', models.TextField(blank=True, null=True)), - ('statement_descriptor', models.TextField(blank=True, null=True)), - ('support_email', models.TextField(blank=True, null=True)), - ('support_phone', models.TextField(blank=True, null=True)), - ('timezone', models.TextField(blank=True, null=True)), - ('tos_acceptance_date', models.DateField(null=True)), - ('tos_acceptance_ip', models.TextField(blank=True, null=True)), - ('tos_acceptance_user_agent', models.TextField(blank=True, null=True)), - ('transfer_schedule_delay_days', models.PositiveSmallIntegerField(null=True)), - ('transfer_schedule_interval', models.TextField(blank=True, null=True)), - ('transfer_schedule_monthly_anchor', models.PositiveSmallIntegerField(null=True)), - ('transfer_schedule_weekly_anchor', models.TextField(blank=True, null=True)), - ('transfer_statement_descriptor', models.TextField(blank=True, null=True)), - ('transfers_enabled', models.BooleanField(default=False)), - ('verification_disabled_reason', models.TextField(blank=True, null=True)), - ('verification_due_by', models.DateTimeField(blank=True, null=True)), - ('verification_timestamp', models.DateTimeField(blank=True, null=True)), - ('verification_fields_needed', jsonfield.fields.JSONField(blank=True, null=True)), - ('authorized', models.BooleanField(default=True)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stripe_accounts', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='BankAccount', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('stripe_id', models.CharField(max_length=191, unique=True)), - ('created_at', models.DateTimeField(default=django.utils.timezone.now)), - ('account_holder_name', models.TextField()), - ('account_holder_type', models.TextField()), - ('bank_name', models.TextField(blank=True, null=True)), - ('country', models.TextField()), - ('currency', models.TextField()), - ('default_for_currency', models.BooleanField(default=False)), - ('fingerprint', models.TextField()), - ('last4', models.CharField(max_length=4)), - ('metadata', jsonfield.fields.JSONField(blank=True, null=True)), - ('routing_number', models.TextField()), - ('status', models.TextField()), - ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bank_accounts', to='pinax_stripe.Account')), - ], - options={ - 'abstract': False, - }, - ), - migrations.CreateModel( - name='UserAccount', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_accounts', related_query_name='user_account', to='pinax_stripe.Account')), - ], - ), - migrations.AddField( - model_name='charge', - name='available', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='charge', - name='available_on', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='charge', - name='fee', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AddField( - model_name='charge', - name='fee_currency', - field=models.CharField(blank=True, max_length=10, null=True), - ), - migrations.AddField( - model_name='charge', - name='transfer_group', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='amount_reversed', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AddField( - model_name='transfer', - name='application_fee', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AddField( - model_name='transfer', - name='created', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='destination', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='destination_payment', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='failure_code', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='failure_message', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='livemode', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='transfer', - name='metadata', - field=jsonfield.fields.JSONField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='method', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='reversed', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='transfer', - name='source_transaction', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='source_type', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='statement_descriptor', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='transfer_group', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='transfer', - name='type', - field=models.TextField(blank=True, null=True), - ), - migrations.AlterField( - model_name='coupon', - name='metadata', - field=jsonfield.fields.JSONField(blank=True, null=True), - ), - migrations.AlterField( - model_name='customer', - name='account_balance', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AlterField( - model_name='customer', - name='user', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='event', - name='validated_message', - field=jsonfield.fields.JSONField(blank=True, null=True), - ), - migrations.AlterField( - model_name='plan', - name='metadata', - field=jsonfield.fields.JSONField(blank=True, null=True), - ), - migrations.AddField( - model_name='useraccount', - name='customer', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_accounts', related_query_name='user_account', to='pinax_stripe.Customer'), - ), - migrations.AddField( - model_name='useraccount', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_accounts', related_query_name='user_account', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='customer', - name='stripe_account', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), - ), - migrations.AddField( - model_name='customer', - name='users', - field=models.ManyToManyField(related_name='customers', related_query_name='customers', through='pinax_stripe.UserAccount', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='event', - name='stripe_account', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), - ), - migrations.AddField( - model_name='plan', - name='stripe_account', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), - ), - migrations.AddField( - model_name='transfer', - name='stripe_account', - field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Account'), - ), - migrations.AlterUniqueTogether( - name='useraccount', - unique_together=set([('user', 'account')]), - ), - ] diff --git a/pinax/stripe/migrations/0011_auto_20171121_1648.py b/pinax/stripe/migrations/0011_auto_20171121_1648.py deleted file mode 100644 index 5782b3ed6..000000000 --- a/pinax/stripe/migrations/0011_auto_20171121_1648.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-11-21 16:48 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0010_connect'), - ] - - operations = [ - migrations.AlterField( - model_name='plan', - name='stripe_id', - field=models.CharField(max_length=191), - ), - migrations.AlterUniqueTogether( - name='plan', - unique_together=set([('stripe_id', 'stripe_account')]), - ), - ] diff --git a/pinax/stripe/migrations/0011_auto_20171123_2016.py b/pinax/stripe/migrations/0011_auto_20171123_2016.py deleted file mode 100644 index 4f3ed3707..000000000 --- a/pinax/stripe/migrations/0011_auto_20171123_2016.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-11-23 20:16 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0011_auto_20171121_1648'), - ] - - operations = [ - migrations.RenameField( - model_name='account', - old_name='transfer_schedule_weekly_anchor', - new_name='payout_schedule_weekly_anchor', - ), - migrations.RenameField( - model_name='account', - old_name='transfer_statement_descriptor', - new_name='payout_statement_descriptor', - ), - migrations.RenameField( - model_name='account', - old_name='transfers_enabled', - new_name='payouts_enabled', - ), - migrations.RemoveField( - model_name='account', - name='transfer_schedule_delay_days', - ), - migrations.RemoveField( - model_name='account', - name='transfer_schedule_interval', - ), - migrations.RemoveField( - model_name='account', - name='transfer_schedule_monthly_anchor', - ), - migrations.AddField( - model_name='account', - name='payout_schedule_delay_days', - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='account', - name='payout_schedule_interval', - field=models.CharField(blank=True, choices=[('Manual', 'manual'), ('Daily', 'daily'), ('Weekly', 'weekly'), ('Monthly', 'monthly')], max_length=7, null=True), - ), - migrations.AddField( - model_name='account', - name='payout_schedule_monthly_anchor', - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - ] diff --git a/pinax/stripe/migrations/0013_charge_outcome.py b/pinax/stripe/migrations/0013_charge_outcome.py deleted file mode 100644 index bcb9183e8..000000000 --- a/pinax/stripe/migrations/0013_charge_outcome.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-11-22 15:40 -from __future__ import unicode_literals - -from django.db import migrations -import jsonfield.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0014_blank_with_null'), - ] - - operations = [ - migrations.AddField( - model_name='charge', - name='outcome', - field=jsonfield.fields.JSONField(blank=True, null=True), - ), - ] diff --git a/pinax/stripe/migrations/0014_auto_20180413_1959.py b/pinax/stripe/migrations/0014_auto_20180413_1959.py deleted file mode 100644 index 23eab7849..000000000 --- a/pinax/stripe/migrations/0014_auto_20180413_1959.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 2.0.4 on 2018-04-14 00:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0013_charge_outcome'), - ] - - operations = [ - migrations.AlterField( - model_name='account', - name='display_name', - field=models.TextField(blank=True, null=True), - ), - ] diff --git a/pinax/stripe/migrations/0014_blank_with_null.py b/pinax/stripe/migrations/0014_blank_with_null.py deleted file mode 100644 index 6673432aa..000000000 --- a/pinax/stripe/migrations/0014_blank_with_null.py +++ /dev/null @@ -1,151 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.6 on 2017-11-24 16:30 -from __future__ import unicode_literals - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0011_auto_20171123_2016'), - ] - - operations = [ - migrations.AlterField( - model_name='account', - name='legal_entity_dob', - field=models.DateField(blank=True, null=True), - ), - migrations.AlterField( - model_name='account', - name='tos_acceptance_date', - field=models.DateField(blank=True, null=True), - ), - migrations.AlterField( - model_name='charge', - name='amount', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AlterField( - model_name='charge', - name='amount_refunded', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AlterField( - model_name='charge', - name='customer', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='charges', to='pinax_stripe.Customer'), - ), - migrations.AlterField( - model_name='charge', - name='invoice', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='charges', to='pinax_stripe.Invoice'), - ), - migrations.AlterField( - model_name='charge', - name='source', - field=models.CharField(blank=True, max_length=100), - ), - migrations.AlterField( - model_name='coupon', - name='amount_off', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AlterField( - model_name='coupon', - name='duration_in_months', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='coupon', - name='max_redemptions', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='coupon', - name='percent_off', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='coupon', - name='redeem_by', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='coupon', - name='times_redeemed', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='customer', - name='date_purged', - field=models.DateTimeField(blank=True, editable=False, null=True), - ), - migrations.AlterField( - model_name='event', - name='customer', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Customer'), - ), - migrations.AlterField( - model_name='eventprocessingexception', - name='event', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Event'), - ), - migrations.AlterField( - model_name='invoice', - name='attempt_count', - field=models.PositiveIntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='invoice', - name='charge', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='pinax_stripe.Charge'), - ), - migrations.AlterField( - model_name='invoice', - name='subscription', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Subscription'), - ), - migrations.AlterField( - model_name='invoice', - name='tax', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AlterField( - model_name='invoice', - name='tax_percent', - field=models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True), - ), - migrations.AlterField( - model_name='invoice', - name='webhooks_delivered_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='invoiceitem', - name='plan', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Plan'), - ), - migrations.AlterField( - model_name='invoiceitem', - name='quantity', - field=models.IntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='invoiceitem', - name='subscription', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pinax_stripe.Subscription'), - ), - migrations.AlterField( - model_name='plan', - name='trial_period_days', - field=models.IntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='subscription', - name='application_fee_percent', - field=models.DecimalField(blank=True, decimal_places=2, default=None, max_digits=3, null=True), - ), - ] diff --git a/pinax/stripe/migrations/0015_auto_20211126_1406.py b/pinax/stripe/migrations/0015_auto_20211126_1406.py deleted file mode 100644 index dbe4f14fd..000000000 --- a/pinax/stripe/migrations/0015_auto_20211126_1406.py +++ /dev/null @@ -1,196 +0,0 @@ -# Generated by Django 3.2.9 on 2021-11-26 20:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pinax_stripe', '0014_auto_20180413_1959'), - ] - - operations = [ - migrations.RemoveField( - model_name='account', - name='user', - ), - migrations.RemoveField( - model_name='bankaccount', - name='account', - ), - migrations.RemoveField( - model_name='bitcoinreceiver', - name='customer', - ), - migrations.RemoveField( - model_name='card', - name='customer', - ), - migrations.RemoveField( - model_name='charge', - name='customer', - ), - migrations.RemoveField( - model_name='charge', - name='invoice', - ), - migrations.DeleteModel( - name='Coupon', - ), - migrations.RemoveField( - model_name='customer', - name='stripe_account', - ), - migrations.RemoveField( - model_name='customer', - name='user', - ), - migrations.RemoveField( - model_name='customer', - name='users', - ), - migrations.RemoveField( - model_name='invoice', - name='charge', - ), - migrations.RemoveField( - model_name='invoice', - name='customer', - ), - migrations.RemoveField( - model_name='invoice', - name='subscription', - ), - migrations.RemoveField( - model_name='invoiceitem', - name='invoice', - ), - migrations.RemoveField( - model_name='invoiceitem', - name='plan', - ), - migrations.RemoveField( - model_name='invoiceitem', - name='subscription', - ), - migrations.AlterUniqueTogether( - name='plan', - unique_together=None, - ), - migrations.RemoveField( - model_name='plan', - name='stripe_account', - ), - migrations.RemoveField( - model_name='subscription', - name='customer', - ), - migrations.RemoveField( - model_name='subscription', - name='plan', - ), - migrations.RemoveField( - model_name='transfer', - name='event', - ), - migrations.RemoveField( - model_name='transfer', - name='stripe_account', - ), - migrations.RemoveField( - model_name='transferchargefee', - name='transfer', - ), - migrations.AlterUniqueTogether( - name='useraccount', - unique_together=None, - ), - migrations.RemoveField( - model_name='useraccount', - name='account', - ), - migrations.RemoveField( - model_name='useraccount', - name='customer', - ), - migrations.RemoveField( - model_name='useraccount', - name='user', - ), - migrations.RemoveField( - model_name='event', - name='customer', - ), - migrations.RemoveField( - model_name='event', - name='request', - ), - migrations.RemoveField( - model_name='event', - name='stripe_account', - ), - migrations.AddField( - model_name='event', - name='account_id', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AddField( - model_name='event', - name='customer_id', - field=models.CharField(blank=True, max_length=200), - ), - migrations.AlterField( - model_name='event', - name='valid', - field=models.BooleanField(blank=True, null=True), - ), - migrations.AlterField( - model_name='event', - name='validated_message', - field=models.JSONField(blank=True, null=True), - ), - migrations.AlterField( - model_name='event', - name='webhook_message', - field=models.JSONField(), - ), - migrations.DeleteModel( - name='Account', - ), - migrations.DeleteModel( - name='BankAccount', - ), - migrations.DeleteModel( - name='BitcoinReceiver', - ), - migrations.DeleteModel( - name='Card', - ), - migrations.DeleteModel( - name='Charge', - ), - migrations.DeleteModel( - name='Customer', - ), - migrations.DeleteModel( - name='Invoice', - ), - migrations.DeleteModel( - name='InvoiceItem', - ), - migrations.DeleteModel( - name='Plan', - ), - migrations.DeleteModel( - name='Subscription', - ), - migrations.DeleteModel( - name='Transfer', - ), - migrations.DeleteModel( - name='TransferChargeFee', - ), - migrations.DeleteModel( - name='UserAccount', - ), - ] From 408c3f305fad63af748f45e9165ce789a9c942a6 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 22:50:28 -0600 Subject: [PATCH 34/49] Set app info Closes #643 --- pinax/stripe/conf.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pinax/stripe/conf.py b/pinax/stripe/conf.py index 24aca6eb7..338c4c4ef 100644 --- a/pinax/stripe/conf.py +++ b/pinax/stripe/conf.py @@ -3,6 +3,8 @@ import stripe from appconf import AppConf +from pinax.stripe import __version__ + class PinaxStripeAppConf(AppConf): @@ -21,3 +23,11 @@ def configure_api_version(self, value): def configure_secret_key(self, value): stripe.api_key = value return value + + def configure(self): + stripe.set_app_info( + name="Pinax Stripe Light", + version=__version__, + url="https://github.com/pinax/pinax-stripe-light" + ) + return super().configure() From 9c18816d24613871b442935511a35c5bd6cdd9cc Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 23:33:17 -0600 Subject: [PATCH 35/49] Fix configure --- pinax/stripe/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pinax/stripe/conf.py b/pinax/stripe/conf.py index 338c4c4ef..cf0da6c13 100644 --- a/pinax/stripe/conf.py +++ b/pinax/stripe/conf.py @@ -30,4 +30,4 @@ def configure(self): version=__version__, url="https://github.com/pinax/pinax-stripe-light" ) - return super().configure() + return self.configured_data From 1398496c3cd27e3c8892280fad5635a3c1455af5 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 23:33:43 -0600 Subject: [PATCH 36/49] Add test to validate inheritance override in registry --- pinax/stripe/tests/test_webhooks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index b2e79cdad..a9b3a1063 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -10,6 +10,7 @@ from ..models import Event, EventProcessingException from ..webhooks import ( + AccountUpdatedWebhook, AccountApplicationDeauthorizeWebhook, AccountExternalAccountCreatedWebhook, Webhook, @@ -17,6 +18,10 @@ ) +class NewAccountUpdatedWebhook(AccountUpdatedWebhook): + pass + + class WebhookRegistryTest(TestCase): def test_get_signal(self): @@ -26,6 +31,10 @@ def test_get_signal(self): def test_get_signal_keyerror(self): self.assertIsNone(registry.get_signal("not a webhook")) + def test_inherited_hook(self): + webhook = registry.get("account.updated") + self.assertIs(webhook, NewAccountUpdatedWebhook) + class WebhookTests(TestCase): From 52d6d26cd9013d687bbe0a4e544855fde9a888e6 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Fri, 26 Nov 2021 23:46:32 -0600 Subject: [PATCH 37/49] Break up webhooks --- pinax/stripe/tests/test_admin.py | 4 +- pinax/stripe/tests/test_webhooks.py | 24 +-- pinax/stripe/webhooks/__init__.py | 4 + pinax/stripe/webhooks/base.py | 94 +++++++++++ .../{webhooks.py => webhooks/generated.py} | 158 +----------------- pinax/stripe/webhooks/overrides.py | 29 ++++ pinax/stripe/webhooks/registry.py | 38 +++++ 7 files changed, 180 insertions(+), 171 deletions(-) create mode 100644 pinax/stripe/webhooks/__init__.py create mode 100644 pinax/stripe/webhooks/base.py rename pinax/stripe/{webhooks.py => webhooks/generated.py} (71%) create mode 100644 pinax/stripe/webhooks/overrides.py create mode 100644 pinax/stripe/webhooks/registry.py diff --git a/pinax/stripe/tests/test_admin.py b/pinax/stripe/tests/test_admin.py index 28f92c46e..bdc92cd6c 100644 --- a/pinax/stripe/tests/test_admin.py +++ b/pinax/stripe/tests/test_admin.py @@ -16,7 +16,7 @@ def test_no_add_permission(self): self.assertFalse(instance.has_add_permission(None)) def test_no_change_permission(self): - request = self.factory.post('/admin/') + request = self.factory.post("/admin/") instance = EventProcessingExceptionAdmin(EventProcessingException, admin.site) self.assertFalse(instance.has_change_permission(request)) @@ -54,7 +54,7 @@ def test_no_add_permission(self): def test_no_change_permission(self): factory = RequestFactory() - request = factory.post('/admin/') + request = factory.post("/admin/") instance = EventAdmin(Event, admin.site) self.assertFalse(instance.has_change_permission(request)) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index a9b3a1063..e07555993 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -10,9 +10,9 @@ from ..models import Event, EventProcessingException from ..webhooks import ( - AccountUpdatedWebhook, - AccountApplicationDeauthorizeWebhook, AccountExternalAccountCreatedWebhook, + AccountUpdatedWebhook, + CustomAccountApplicationDeauthorizeWebhook, Webhook, registry ) @@ -207,12 +207,12 @@ def test_process_deauthorize(self, RetrieveMock): data = {"data": {"object": {"id": "evt_001"}}, "account": "acct_bb"} event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizeWebhook.name, webhook_message=data, ) RetrieveMock.side_effect = stripe.error.PermissionError( "The provided key 'sk_test_********************abcd' does not have access to account 'acct_aa' (or that account does not exist). Application access may have been revoked.") - AccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizeWebhook(event).process() self.assertTrue(event.valid) self.assertTrue(event.processed) @@ -221,25 +221,25 @@ def test_process_deauthorize_fake_response(self, RetrieveMock): data = {"data": {"object": {"id": "evt_001"}}, "account": "acct_bb"} event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizeWebhook.name, webhook_message=data, ) RetrieveMock.side_effect = stripe.error.PermissionError( "The provided key 'sk_test_********************ABCD' does not have access to account 'acc_aa' (or that account does not exist). Application access may have been revoked.") with self.assertRaises(stripe.error.PermissionError): - AccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizeWebhook(event).process() @patch("stripe.Event.retrieve") def test_process_deauthorize_with_delete_account(self, RetrieveMock): data = {"data": {"object": {"id": "evt_002"}}, "account": "acct_bb"} event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizeWebhook.name, webhook_message=data, ) RetrieveMock.side_effect = stripe.error.PermissionError( "The provided key 'sk_test_********************abcd' does not have access to account 'acct_bb' (or that account does not exist). Application access may have been revoked.") - AccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizeWebhook(event).process() self.assertTrue(event.valid) self.assertTrue(event.processed) @@ -247,11 +247,11 @@ def test_process_deauthorize_with_delete_account(self, RetrieveMock): def test_process_deauthorize_without_account(self, RetrieveMock): data = {"data": {"object": {"id": "evt_001"}}} event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizeWebhook.name, webhook_message=data, ) RetrieveMock.return_value.to_dict.return_value = data - AccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizeWebhook(event).process() self.assertTrue(event.valid) self.assertTrue(event.processed) @@ -259,11 +259,11 @@ def test_process_deauthorize_without_account(self, RetrieveMock): def test_process_deauthorize_without_account_exception(self, RetrieveMock): data = {"data": {"object": {"id": "evt_001"}}} event = Event.objects.create( - kind=AccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizeWebhook.name, webhook_message=data, ) RetrieveMock.side_effect = stripe.error.PermissionError() RetrieveMock.return_value.to_dict.return_value = data - AccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizeWebhook(event).process() self.assertTrue(event.valid) self.assertTrue(event.processed) diff --git a/pinax/stripe/webhooks/__init__.py b/pinax/stripe/webhooks/__init__.py new file mode 100644 index 000000000..ba840f04c --- /dev/null +++ b/pinax/stripe/webhooks/__init__.py @@ -0,0 +1,4 @@ +from .base import Webhook # noqa +from .generated import * # noqa +from .overrides import CustomAccountApplicationDeauthorizeWebhook # noqa +from .registry import registry # noqa diff --git a/pinax/stripe/webhooks/base.py b/pinax/stripe/webhooks/base.py new file mode 100644 index 000000000..adaaf7d9e --- /dev/null +++ b/pinax/stripe/webhooks/base.py @@ -0,0 +1,94 @@ +import json +import sys +import traceback + +import stripe + +from .. import models +from .registry import registry + + +class Registerable(type): + def __new__(cls, clsname, bases, attrs): + newclass = super(Registerable, cls).__new__(cls, clsname, bases, attrs) + if getattr(newclass, "name", None) is not None: + registry.register(newclass) + return newclass + + +class Webhook(metaclass=Registerable): + + name = None + + def __init__(self, event): + if event.kind != self.name: + raise Exception("The Webhook handler ({}) received the wrong type of Event ({})".format(self.name, event.kind)) + self.event = event + self.stripe_account = None + + def validate(self): + """ + Validate incoming events. + + We fetch the event data to ensure it is legit. + For Connect accounts we must fetch the event using the `stripe_account` + parameter. + """ + self.stripe_account = self.event.webhook_message.get("account", None) + evt = stripe.Event.retrieve( + self.event.stripe_id, + stripe_account=self.stripe_account + ) + self.event.validated_message = json.loads( + json.dumps( + evt.to_dict(), + sort_keys=True, + ) + ) + self.event.valid = self.is_event_valid(self.event.webhook_message["data"], self.event.validated_message["data"]) + self.event.save() + + @staticmethod + def is_event_valid(webhook_message_data, validated_message_data): + """ + Notice "data" may contain a "previous_attributes" section + """ + return "object" in webhook_message_data and "object" in validated_message_data and \ + webhook_message_data["object"] == validated_message_data["object"] + + def send_signal(self): + signal = registry.get_signal(self.name) + if signal: + return signal.send(sender=self.__class__, event=self.event) + + def log_exception(self, data, exception): + info = sys.exc_info() + info_formatted = "".join(traceback.format_exception(*info)) if info[1] is not None else "" + models.EventProcessingException.objects.create( + event=self.event, + data=data or "", + message=str(exception), + traceback=info_formatted + ) + + def process(self): + if self.event.processed: + return + self.validate() + if not self.event.valid: + return + + try: + self.process_webhook() + self.send_signal() + self.event.processed = True + self.event.save() + except Exception as e: + data = None + if isinstance(e, stripe.error.StripeError): + data = e.http_body + self.log_exception(data=data, exception=e) + raise e + + def process_webhook(self): + return diff --git a/pinax/stripe/webhooks.py b/pinax/stripe/webhooks/generated.py similarity index 71% rename from pinax/stripe/webhooks.py rename to pinax/stripe/webhooks/generated.py index 45847bbfc..841e3cf4b 100644 --- a/pinax/stripe/webhooks.py +++ b/pinax/stripe/webhooks/generated.py @@ -1,137 +1,4 @@ -import json -import sys -import traceback - -from django.dispatch import Signal - -import stripe - -from . import models -from .conf import settings -from .utils import obfuscate_secret_key - - -class WebhookRegistry: - - def __init__(self): - self._registry = {} - - def register(self, webhook): - self._registry[webhook.name] = { - "webhook": webhook, - "signal": Signal() - } - - def keys(self): - return self._registry.keys() - - def get(self, name): - return self[name]["webhook"] - - def get_signal(self, name, default=None): - try: - return self[name]["signal"] - except KeyError: - return default - - def signals(self): - return { - key: self.get_signal(key) - for key in self.keys() - } - - def __getitem__(self, name): - return self._registry[name] - - -registry = WebhookRegistry() -del WebhookRegistry - - -class Registerable(type): - def __new__(cls, clsname, bases, attrs): - newclass = super(Registerable, cls).__new__(cls, clsname, bases, attrs) - if getattr(newclass, "name", None) is not None: - registry.register(newclass) - return newclass - - -class Webhook(metaclass=Registerable): - - name = None - - def __init__(self, event): - if event.kind != self.name: - raise Exception("The Webhook handler ({}) received the wrong type of Event ({})".format(self.name, event.kind)) - self.event = event - self.stripe_account = None - - def validate(self): - """ - Validate incoming events. - - We fetch the event data to ensure it is legit. - For Connect accounts we must fetch the event using the `stripe_account` - parameter. - """ - self.stripe_account = self.event.webhook_message.get("account", None) - evt = stripe.Event.retrieve( - self.event.stripe_id, - stripe_account=self.stripe_account - ) - self.event.validated_message = json.loads( - json.dumps( - evt.to_dict(), - sort_keys=True, - ) - ) - self.event.valid = self.is_event_valid(self.event.webhook_message["data"], self.event.validated_message["data"]) - self.event.save() - - @staticmethod - def is_event_valid(webhook_message_data, validated_message_data): - """ - Notice "data" may contain a "previous_attributes" section - """ - return "object" in webhook_message_data and "object" in validated_message_data and \ - webhook_message_data["object"] == validated_message_data["object"] - - def send_signal(self): - signal = registry.get_signal(self.name) - if signal: - return signal.send(sender=self.__class__, event=self.event) - - def log_exception(self, data, exception): - info = sys.exc_info() - info_formatted = "".join(traceback.format_exception(*info)) if info[1] is not None else "" - models.EventProcessingException.objects.create( - event=self.event, - data=data or "", - message=str(exception), - traceback=info_formatted - ) - - def process(self): - if self.event.processed: - return - self.validate() - if not self.event.valid: - return - - try: - self.process_webhook() - self.send_signal() - self.event.processed = True - self.event.save() - except Exception as e: - data = None - if isinstance(e, stripe.error.StripeError): - data = e.http_body - self.log_exception(data=data, exception=e) - raise e - - def process_webhook(self): - return +from .base import Webhook class AccountUpdatedWebhook(Webhook): @@ -143,29 +10,6 @@ class AccountApplicationDeauthorizeWebhook(Webhook): name = "account.application.deauthorized" description = "Occurs whenever a user deauthorizes an application. Sent to the related application only." - def validate(self): - """ - Specialized validation of incoming events. - - When this event is for a connected account we should not be able to - fetch the event anymore (since we have been disconnected). - But there might be multiple connections (e.g. for Dev/Prod). - - Therefore we try to retrieve the event, and handle a - PermissionError exception to be expected (since we cannot access the - account anymore). - """ - try: - super().validate() - except stripe.error.PermissionError as exc: - if self.stripe_account: - if self.stripe_account not in str(exc) and obfuscate_secret_key(settings.PINAX_STRIPE_SECRET_KEY) not in str(exc): - raise exc - self.event.valid = True - self.event.validated_message = self.event.webhook_message - - -# @@@ with signals not sure we need all these class AccountExternalAccountCreatedWebhook(Webhook): name = "account.external_account.created" diff --git a/pinax/stripe/webhooks/overrides.py b/pinax/stripe/webhooks/overrides.py new file mode 100644 index 000000000..126f31f75 --- /dev/null +++ b/pinax/stripe/webhooks/overrides.py @@ -0,0 +1,29 @@ +import stripe + +from ..conf import settings +from ..utils import obfuscate_secret_key +from .generated import AccountApplicationDeauthorizeWebhook + + +class CustomAccountApplicationDeauthorizeWebhook(AccountApplicationDeauthorizeWebhook): + + def validate(self): + """ + Specialized validation of incoming events. + + When this event is for a connected account we should not be able to + fetch the event anymore (since we have been disconnected). + But there might be multiple connections (e.g. for Dev/Prod). + + Therefore we try to retrieve the event, and handle a + PermissionError exception to be expected (since we cannot access the + account anymore). + """ + try: + super().validate() + except stripe.error.PermissionError as exc: + if self.stripe_account: + if self.stripe_account not in str(exc) and obfuscate_secret_key(settings.PINAX_STRIPE_SECRET_KEY) not in str(exc): + raise exc + self.event.valid = True + self.event.validated_message = self.event.webhook_message diff --git a/pinax/stripe/webhooks/registry.py b/pinax/stripe/webhooks/registry.py new file mode 100644 index 000000000..9a855746f --- /dev/null +++ b/pinax/stripe/webhooks/registry.py @@ -0,0 +1,38 @@ +from django.dispatch import Signal + + +class WebhookRegistry: + + def __init__(self): + self._registry = {} + + def register(self, webhook): + self._registry[webhook.name] = { + "webhook": webhook, + "signal": Signal() + } + + def keys(self): + return self._registry.keys() + + def get(self, name): + return self[name]["webhook"] + + def get_signal(self, name, default=None): + try: + return self[name]["signal"] + except KeyError: + return default + + def signals(self): + return { + key: self.get_signal(key) + for key in self.keys() + } + + def __getitem__(self, name): + return self._registry[name] + + +registry = WebhookRegistry() +del WebhookRegistry From 232463e7acc971367479b70a232a341057ecf6fc Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 00:08:03 -0600 Subject: [PATCH 38/49] Add generated webhooks --- pinax/stripe/tests/test_signals.py | 2 +- pinax/stripe/tests/test_webhooks.py | 22 +- pinax/stripe/webhooks/__init__.py | 2 +- pinax/stripe/webhooks/generated.py | 648 +++++++++++++++++++++++++--- pinax/stripe/webhooks/overrides.py | 4 +- 5 files changed, 614 insertions(+), 64 deletions(-) diff --git a/pinax/stripe/tests/test_signals.py b/pinax/stripe/tests/test_signals.py index 29b902cee..45cc18058 100644 --- a/pinax/stripe/tests/test_signals.py +++ b/pinax/stripe/tests/test_signals.py @@ -5,4 +5,4 @@ class TestSignals(TestCase): def test_signals(self): - self.assertEqual(len(WEBHOOK_SIGNALS.keys()), 67) + self.assertGreater(len(WEBHOOK_SIGNALS.keys()), 100) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index e07555993..6aefcd260 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -12,7 +12,7 @@ from ..webhooks import ( AccountExternalAccountCreatedWebhook, AccountUpdatedWebhook, - CustomAccountApplicationDeauthorizeWebhook, + CustomAccountApplicationDeauthorizedWebhook, Webhook, registry ) @@ -207,12 +207,12 @@ def test_process_deauthorize(self, RetrieveMock): data = {"data": {"object": {"id": "evt_001"}}, "account": "acct_bb"} event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizedWebhook.name, webhook_message=data, ) RetrieveMock.side_effect = stripe.error.PermissionError( "The provided key 'sk_test_********************abcd' does not have access to account 'acct_aa' (or that account does not exist). Application access may have been revoked.") - CustomAccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizedWebhook(event).process() self.assertTrue(event.valid) self.assertTrue(event.processed) @@ -221,25 +221,25 @@ def test_process_deauthorize_fake_response(self, RetrieveMock): data = {"data": {"object": {"id": "evt_001"}}, "account": "acct_bb"} event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizedWebhook.name, webhook_message=data, ) RetrieveMock.side_effect = stripe.error.PermissionError( "The provided key 'sk_test_********************ABCD' does not have access to account 'acc_aa' (or that account does not exist). Application access may have been revoked.") with self.assertRaises(stripe.error.PermissionError): - CustomAccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizedWebhook(event).process() @patch("stripe.Event.retrieve") def test_process_deauthorize_with_delete_account(self, RetrieveMock): data = {"data": {"object": {"id": "evt_002"}}, "account": "acct_bb"} event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizedWebhook.name, webhook_message=data, ) RetrieveMock.side_effect = stripe.error.PermissionError( "The provided key 'sk_test_********************abcd' does not have access to account 'acct_bb' (or that account does not exist). Application access may have been revoked.") - CustomAccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizedWebhook(event).process() self.assertTrue(event.valid) self.assertTrue(event.processed) @@ -247,11 +247,11 @@ def test_process_deauthorize_with_delete_account(self, RetrieveMock): def test_process_deauthorize_without_account(self, RetrieveMock): data = {"data": {"object": {"id": "evt_001"}}} event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizedWebhook.name, webhook_message=data, ) RetrieveMock.return_value.to_dict.return_value = data - CustomAccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizedWebhook(event).process() self.assertTrue(event.valid) self.assertTrue(event.processed) @@ -259,11 +259,11 @@ def test_process_deauthorize_without_account(self, RetrieveMock): def test_process_deauthorize_without_account_exception(self, RetrieveMock): data = {"data": {"object": {"id": "evt_001"}}} event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizeWebhook.name, + kind=CustomAccountApplicationDeauthorizedWebhook.name, webhook_message=data, ) RetrieveMock.side_effect = stripe.error.PermissionError() RetrieveMock.return_value.to_dict.return_value = data - CustomAccountApplicationDeauthorizeWebhook(event).process() + CustomAccountApplicationDeauthorizedWebhook(event).process() self.assertTrue(event.valid) self.assertTrue(event.processed) diff --git a/pinax/stripe/webhooks/__init__.py b/pinax/stripe/webhooks/__init__.py index ba840f04c..ade5718a6 100644 --- a/pinax/stripe/webhooks/__init__.py +++ b/pinax/stripe/webhooks/__init__.py @@ -1,4 +1,4 @@ from .base import Webhook # noqa from .generated import * # noqa -from .overrides import CustomAccountApplicationDeauthorizeWebhook # noqa +from .overrides import CustomAccountApplicationDeauthorizedWebhook # noqa from .registry import registry # noqa diff --git a/pinax/stripe/webhooks/generated.py b/pinax/stripe/webhooks/generated.py index 841e3cf4b..0b5f44ec7 100644 --- a/pinax/stripe/webhooks/generated.py +++ b/pinax/stripe/webhooks/generated.py @@ -6,7 +6,12 @@ class AccountUpdatedWebhook(Webhook): description = "Occurs whenever an account status or property has changed." -class AccountApplicationDeauthorizeWebhook(Webhook): +class AccountApplicationAuthorizedWebhook(Webhook): + name = "account.application.authorized" + description = "Occurs whenever a user authorizes an application. Sent to the related application only." + + +class AccountApplicationDeauthorizedWebhook(Webhook): name = "account.application.deauthorized" description = "Occurs whenever a user deauthorizes an application. Sent to the related application only." @@ -33,7 +38,7 @@ class ApplicationFeeCreatedWebhook(Webhook): class ApplicationFeeRefundedWebhook(Webhook): name = "application_fee.refunded" - description = "Occurs whenever an application fee is refunded, whether from refunding a charge or from refunding the application fee directly, including partial refunds." + description = "Occurs whenever an application fee is refunded, whether from refunding a charge or from refunding the application fee directly. This includes partial refunds." class ApplicationFeeRefundUpdatedWebhook(Webhook): @@ -43,27 +48,22 @@ class ApplicationFeeRefundUpdatedWebhook(Webhook): class BalanceAvailableWebhook(Webhook): name = "balance.available" - description = "Occurs whenever your Stripe balance has been updated (e.g. when a charge collected is available to be paid out). By default, Stripe will automatically transfer any funds in your balance to your bank account on a daily basis." - - -class BitcoinReceiverCreatedWebhook(Webhook): - name = "bitcoin.receiver.created" - description = "Occurs whenever a receiver has been created." + description = "Occurs whenever your Stripe balance has been updated (e.g., when a charge is available to be paid out). By default, Stripe automatically transfers funds in your balance to your bank account on a daily basis." -class BitcoinReceiverFilledWebhook(Webhook): - name = "bitcoin.receiver.filled" - description = "Occurs whenever a receiver is filled (that is, when it has received enough bitcoin to process a payment of the same amount)." +class BillingPortalConfigurationCreatedWebhook(Webhook): + name = "billing_portal.configuration.created" + description = "Occurs whenever a portal configuration is created." -class BitcoinReceiverUpdatedWebhook(Webhook): - name = "bitcoin.receiver.updated" - description = "Occurs whenever a receiver is updated." +class BillingPortalConfigurationUpdatedWebhook(Webhook): + name = "billing_portal.configuration.updated" + description = "Occurs whenever a portal configuration is updated." -class BitcoinReceiverTransactionCreatedWebhook(Webhook): - name = "bitcoin.receiver.transaction.created" - description = "Occurs whenever bitcoin is pushed to a receiver." +class CapabilityUpdatedWebhook(Webhook): + name = "capability.updated" + description = "Occurs whenever a capability has new requirements or a new status." class ChargeCapturedWebhook(Webhook): @@ -71,11 +71,21 @@ class ChargeCapturedWebhook(Webhook): description = "Occurs whenever a previously uncaptured charge is captured." +class ChargeExpiredWebhook(Webhook): + name = "charge.expired" + description = "Occurs whenever an uncaptured charge expires." + + class ChargeFailedWebhook(Webhook): name = "charge.failed" description = "Occurs whenever a failed charge attempt occurs." +class ChargePendingWebhook(Webhook): + name = "charge.pending" + description = "Occurs whenever a pending charge is created." + + class ChargeRefundedWebhook(Webhook): name = "charge.refunded" description = "Occurs whenever a charge is refunded, including partial refunds." @@ -93,17 +103,17 @@ class ChargeUpdatedWebhook(Webhook): class ChargeDisputeClosedWebhook(Webhook): name = "charge.dispute.closed" - description = "Occurs when the dispute is resolved and the dispute status changes to won or lost." + description = "Occurs when a dispute is closed and the dispute status changes to lost, warning_closed, or won." class ChargeDisputeCreatedWebhook(Webhook): name = "charge.dispute.created" - description = "Occurs whenever a customer disputes a charge with their bank (chargeback)." + description = "Occurs whenever a customer disputes a charge with their bank." class ChargeDisputeFundsReinstatedWebhook(Webhook): name = "charge.dispute.funds_reinstated" - description = "Occurs when funds are reinstated to your account after a dispute is won." + description = "Occurs when funds are reinstated to your account after a dispute is closed. This includes partially refunded payments." class ChargeDisputeFundsWithdrawnWebhook(Webhook): @@ -116,6 +126,31 @@ class ChargeDisputeUpdatedWebhook(Webhook): description = "Occurs when the dispute is updated (usually with evidence)." +class ChargeRefundUpdatedWebhook(Webhook): + name = "charge.refund.updated" + description = "Occurs whenever a refund is updated, on selected payment methods." + + +class CheckoutSessionAsyncPaymentFailedWebhook(Webhook): + name = "checkout.session.async_payment_failed" + description = "Occurs when a payment intent using a delayed payment method fails." + + +class CheckoutSessionAsyncPaymentSucceededWebhook(Webhook): + name = "checkout.session.async_payment_succeeded" + description = "Occurs when a payment intent using a delayed payment method finally succeeds." + + +class CheckoutSessionCompletedWebhook(Webhook): + name = "checkout.session.completed" + description = "Occurs when a Checkout Session has been successfully completed." + + +class CheckoutSessionExpiredWebhook(Webhook): + name = "checkout.session.expired" + description = "Occurs when a Checkout Session is expired." + + class CouponCreatedWebhook(Webhook): name = "coupon.created" description = "Occurs whenever a coupon is created." @@ -131,6 +166,21 @@ class CouponUpdatedWebhook(Webhook): description = "Occurs whenever a coupon is updated." +class CreditNoteCreatedWebhook(Webhook): + name = "credit_note.created" + description = "Occurs whenever a credit note is created." + + +class CreditNoteUpdatedWebhook(Webhook): + name = "credit_note.updated" + description = "Occurs whenever a credit note is updated." + + +class CreditNoteVoidedWebhook(Webhook): + name = "credit_note.voided" + description = "Occurs whenever a credit note is voided." + + class CustomerCreatedWebhook(Webhook): name = "customer.created" description = "Occurs whenever a new customer is created." @@ -153,7 +203,7 @@ class CustomerDiscountCreatedWebhook(Webhook): class CustomerDiscountDeletedWebhook(Webhook): name = "customer.discount.deleted" - description = "Occurs whenever a customer's discount is removed." + description = "Occurs whenever a coupon is removed from a customer." class CustomerDiscountUpdatedWebhook(Webhook): @@ -163,7 +213,7 @@ class CustomerDiscountUpdatedWebhook(Webhook): class CustomerSourceCreatedWebhook(Webhook): name = "customer.source.created" - description = "Occurs whenever a new source is created for the customer." + description = "Occurs whenever a new source is created for a customer." class CustomerSourceDeletedWebhook(Webhook): @@ -171,6 +221,11 @@ class CustomerSourceDeletedWebhook(Webhook): description = "Occurs whenever a source is removed from a customer." +class CustomerSourceExpiringWebhook(Webhook): + name = "customer.source.expiring" + description = "Occurs whenever a card or source will expire at the end of the month." + + class CustomerSourceUpdatedWebhook(Webhook): name = "customer.source.updated" description = "Occurs whenever a source's details are changed." @@ -178,59 +233,239 @@ class CustomerSourceUpdatedWebhook(Webhook): class CustomerSubscriptionCreatedWebhook(Webhook): name = "customer.subscription.created" - description = "Occurs whenever a customer with no subscription is signed up for a plan." + description = "Occurs whenever a customer is signed up for a new plan." class CustomerSubscriptionDeletedWebhook(Webhook): name = "customer.subscription.deleted" - description = "Occurs whenever a customer ends their subscription." + description = "Occurs whenever a customer's subscription ends." + + +class CustomerSubscriptionPendingUpdateAppliedWebhook(Webhook): + name = "customer.subscription.pending_update_applied" + description = "Occurs whenever a customer's subscription's pending update is applied, and the subscription is updated." + + +class CustomerSubscriptionPendingUpdateExpiredWebhook(Webhook): + name = "customer.subscription.pending_update_expired" + description = "Occurs whenever a customer's subscription's pending update expires before the related invoice is paid." class CustomerSubscriptionTrialWillEndWebhook(Webhook): name = "customer.subscription.trial_will_end" - description = "Occurs three days before the trial period of a subscription is scheduled to end." + description = "Occurs three days before a subscription's trial period is scheduled to end, or when a trial is ended immediately (using trial_end=now)." class CustomerSubscriptionUpdatedWebhook(Webhook): name = "customer.subscription.updated" - description = "Occurs whenever a subscription changes. Examples would include switching from one plan to another, or switching status from trial to active." + description = "Occurs whenever a subscription changes (e.g., switching from one plan to another, or changing the status from trial to active)." + + +class CustomerTaxIdCreatedWebhook(Webhook): + name = "customer.tax_id.created" + description = "Occurs whenever a tax ID is created for a customer." + + +class CustomerTaxIdDeletedWebhook(Webhook): + name = "customer.tax_id.deleted" + description = "Occurs whenever a tax ID is deleted from a customer." + + +class CustomerTaxIdUpdatedWebhook(Webhook): + name = "customer.tax_id.updated" + description = "Occurs whenever a customer's tax ID is updated." + + +class FileCreatedWebhook(Webhook): + name = "file.created" + description = "Occurs whenever a new Stripe-generated file is available for your account." + + +class IdentityVerificationSessionCanceledWebhook(Webhook): + name = "identity.verification_session.canceled" + description = "Occurs whenever a VerificationSession is canceled" + + +class IdentityVerificationSessionCreatedWebhook(Webhook): + name = "identity.verification_session.created" + description = "Occurs whenever a VerificationSession is created" + + +class IdentityVerificationSessionProcessingWebhook(Webhook): + name = "identity.verification_session.processing" + description = "Occurs whenever a VerificationSession transitions to processing" + + +class IdentityVerificationSessionRedactedWebhook(Webhook): + name = "identity.verification_session.redacted" + description = "Occurs whenever a VerificationSession is redacted." + + +class IdentityVerificationSessionRequiresInputWebhook(Webhook): + name = "identity.verification_session.requires_input" + description = "Occurs whenever a VerificationSession transitions to require user input" + + +class IdentityVerificationSessionVerifiedWebhook(Webhook): + name = "identity.verification_session.verified" + description = "Occurs whenever a VerificationSession transitions to verified" class InvoiceCreatedWebhook(Webhook): name = "invoice.created" - description = "Occurs whenever a new invoice is created. If you are using webhooks, Stripe will wait one hour after they have all succeeded to attempt to pay the invoice; the only exception here is on the first invoice, which gets created and paid immediately when you subscribe a customer to a plan. If your webhooks do not all respond successfully, Stripe will continue retrying the webhooks every hour and will not attempt to pay the invoice. After 3 days, Stripe will attempt to pay the invoice regardless of whether or not your webhooks have succeeded. See how to respond to a webhook." + description = "Occurs whenever a new invoice is created. To learn how webhooks can be used with this event, and how they can affect it, see Using Webhooks with Subscriptions." + + +class InvoiceDeletedWebhook(Webhook): + name = "invoice.deleted" + description = "Occurs whenever a draft invoice is deleted." + + +class InvoiceFinalizationFailedWebhook(Webhook): + name = "invoice.finalization_failed" + description = "Occurs whenever a draft invoice cannot be finalized. See the invoice’s last finalization error for details." + + +class InvoiceFinalizedWebhook(Webhook): + name = "invoice.finalized" + description = "Occurs whenever a draft invoice is finalized and updated to be an open invoice." + + +class InvoiceMarkedUncollectibleWebhook(Webhook): + name = "invoice.marked_uncollectible" + description = "Occurs whenever an invoice is marked uncollectible." + + +class InvoicePaidWebhook(Webhook): + name = "invoice.paid" + description = "Occurs whenever an invoice payment attempt succeeds or an invoice is marked as paid out-of-band." + + +class InvoicePaymentActionRequiredWebhook(Webhook): + name = "invoice.payment_action_required" + description = "Occurs whenever an invoice payment attempt requires further user action to complete." class InvoicePaymentFailedWebhook(Webhook): name = "invoice.payment_failed" - description = "Occurs whenever an invoice attempts to be paid, and the payment fails. This can occur either due to a declined payment, or because the customer has no active card. A particular case of note is that if a customer with no active card reaches the end of its free trial, an invoice.payment_failed notification will occur." + description = "Occurs whenever an invoice payment attempt fails, due either to a declined payment or to the lack of a stored payment method." class InvoicePaymentSucceededWebhook(Webhook): name = "invoice.payment_succeeded" - description = "Occurs whenever an invoice attempts to be paid, and the payment succeeds." + description = "Occurs whenever an invoice payment attempt succeeds." + + +class InvoiceSentWebhook(Webhook): + name = "invoice.sent" + description = "Occurs whenever an invoice email is sent out." + + +class InvoiceUpcomingWebhook(Webhook): + name = "invoice.upcoming" + description = "Occurs X number of days before a subscription is scheduled to create an invoice that is automatically charged—where X is determined by your subscriptions settings. Note: The received Invoice object will not have an invoice ID." class InvoiceUpdatedWebhook(Webhook): name = "invoice.updated" - description = "Occurs whenever an invoice changes (for example, the amount could change)." + description = "Occurs whenever an invoice changes (e.g., the invoice amount)." + +class InvoiceVoidedWebhook(Webhook): + name = "invoice.voided" + description = "Occurs whenever an invoice is voided." -class InvoiceItemCreatedWebhook(Webhook): + +class InvoiceitemCreatedWebhook(Webhook): name = "invoiceitem.created" description = "Occurs whenever an invoice item is created." -class InvoiceItemDeletedWebhook(Webhook): +class InvoiceitemDeletedWebhook(Webhook): name = "invoiceitem.deleted" description = "Occurs whenever an invoice item is deleted." -class InvoiceItemUpdatedWebhook(Webhook): +class InvoiceitemUpdatedWebhook(Webhook): name = "invoiceitem.updated" description = "Occurs whenever an invoice item is updated." +class IssuingAuthorizationCreatedWebhook(Webhook): + name = "issuing_authorization.created" + description = "Occurs whenever an authorization is created." + + +class IssuingAuthorizationRequestWebhook(Webhook): + name = "issuing_authorization.request" + description = "Represents a synchronous request for authorization, see Using your integration to handle authorization requests." + + +class IssuingAuthorizationUpdatedWebhook(Webhook): + name = "issuing_authorization.updated" + description = "Occurs whenever an authorization is updated." + + +class IssuingCardCreatedWebhook(Webhook): + name = "issuing_card.created" + description = "Occurs whenever a card is created." + + +class IssuingCardUpdatedWebhook(Webhook): + name = "issuing_card.updated" + description = "Occurs whenever a card is updated." + + +class IssuingCardholderCreatedWebhook(Webhook): + name = "issuing_cardholder.created" + description = "Occurs whenever a cardholder is created." + + +class IssuingCardholderUpdatedWebhook(Webhook): + name = "issuing_cardholder.updated" + description = "Occurs whenever a cardholder is updated." + + +class IssuingDisputeClosedWebhook(Webhook): + name = "issuing_dispute.closed" + description = "Occurs whenever a dispute is won, lost or expired." + + +class IssuingDisputeCreatedWebhook(Webhook): + name = "issuing_dispute.created" + description = "Occurs whenever a dispute is created." + + +class IssuingDisputeFundsReinstatedWebhook(Webhook): + name = "issuing_dispute.funds_reinstated" + description = "Occurs whenever funds are reinstated to your account for an Issuing dispute." + + +class IssuingDisputeSubmittedWebhook(Webhook): + name = "issuing_dispute.submitted" + description = "Occurs whenever a dispute is submitted." + + +class IssuingDisputeUpdatedWebhook(Webhook): + name = "issuing_dispute.updated" + description = "Occurs whenever a dispute is updated." + + +class IssuingTransactionCreatedWebhook(Webhook): + name = "issuing_transaction.created" + description = "Occurs whenever an issuing transaction is created." + + +class IssuingTransactionUpdatedWebhook(Webhook): + name = "issuing_transaction.updated" + description = "Occurs whenever an issuing transaction is updated." + + +class MandateUpdatedWebhook(Webhook): + name = "mandate.updated" + description = "Occurs whenever a Mandate is updated." + + class OrderCreatedWebhook(Webhook): name = "order.created" description = "Occurs whenever an order is created." @@ -238,12 +473,12 @@ class OrderCreatedWebhook(Webhook): class OrderPaymentFailedWebhook(Webhook): name = "order.payment_failed" - description = "Occurs whenever payment is attempted on an order, and the payment fails." + description = "Occurs whenever an order payment attempt fails." class OrderPaymentSucceededWebhook(Webhook): name = "order.payment_succeeded" - description = "Occurs whenever payment is attempted on an order, and the payment succeeds." + description = "Occurs whenever an order payment attempt succeeds." class OrderUpdatedWebhook(Webhook): @@ -251,9 +486,104 @@ class OrderUpdatedWebhook(Webhook): description = "Occurs whenever an order is updated." -class PaymentCreatedWebhook(Webhook): - name = "payment.created" - description = "A payment has been received by a Connect account via Transfer from the platform account." +class OrderReturnCreatedWebhook(Webhook): + name = "order_return.created" + description = "Occurs whenever an order return is created." + + +class PaymentIntentAmountCapturableUpdatedWebhook(Webhook): + name = "payment_intent.amount_capturable_updated" + description = "Occurs when a PaymentIntent has funds to be captured. Check the amount_capturable property on the PaymentIntent to determine the amount that can be captured. You may capture the PaymentIntent with an amount_to_capture value up to the specified amount. Learn more about capturing PaymentIntents." + + +class PaymentIntentCanceledWebhook(Webhook): + name = "payment_intent.canceled" + description = "Occurs when a PaymentIntent is canceled." + + +class PaymentIntentCreatedWebhook(Webhook): + name = "payment_intent.created" + description = "Occurs when a new PaymentIntent is created." + + +class PaymentIntentPaymentFailedWebhook(Webhook): + name = "payment_intent.payment_failed" + description = "Occurs when a PaymentIntent has failed the attempt to create a payment method or a payment." + + +class PaymentIntentProcessingWebhook(Webhook): + name = "payment_intent.processing" + description = "Occurs when a PaymentIntent has started processing." + + +class PaymentIntentRequiresActionWebhook(Webhook): + name = "payment_intent.requires_action" + description = "Occurs when a PaymentIntent transitions to requires_action state" + + +class PaymentIntentSucceededWebhook(Webhook): + name = "payment_intent.succeeded" + description = "Occurs when a PaymentIntent has successfully completed payment." + + +class PaymentMethodAttachedWebhook(Webhook): + name = "payment_method.attached" + description = "Occurs whenever a new payment method is attached to a customer." + + +class PaymentMethodAutomaticallyUpdatedWebhook(Webhook): + name = "payment_method.automatically_updated" + description = "Occurs whenever a payment method's details are automatically updated by the network." + + +class PaymentMethodDetachedWebhook(Webhook): + name = "payment_method.detached" + description = "Occurs whenever a payment method is detached from a customer." + + +class PaymentMethodUpdatedWebhook(Webhook): + name = "payment_method.updated" + description = "Occurs whenever a payment method is updated via the PaymentMethod update API." + + +class PayoutCanceledWebhook(Webhook): + name = "payout.canceled" + description = "Occurs whenever a payout is canceled." + + +class PayoutCreatedWebhook(Webhook): + name = "payout.created" + description = "Occurs whenever a payout is created." + + +class PayoutFailedWebhook(Webhook): + name = "payout.failed" + description = "Occurs whenever a payout attempt fails." + + +class PayoutPaidWebhook(Webhook): + name = "payout.paid" + description = "Occurs whenever a payout is expected to be available in the destination account. If the payout fails, a payout.failed notification is also sent, at a later time." + + +class PayoutUpdatedWebhook(Webhook): + name = "payout.updated" + description = "Occurs whenever a payout is updated." + + +class PersonCreatedWebhook(Webhook): + name = "person.created" + description = "Occurs whenever a person associated with an account is created." + + +class PersonDeletedWebhook(Webhook): + name = "person.deleted" + description = "Occurs whenever a person associated with an account is deleted." + + +class PersonUpdatedWebhook(Webhook): + name = "person.updated" + description = "Occurs whenever a person associated with an account is updated." class PlanCreatedWebhook(Webhook): @@ -271,16 +601,76 @@ class PlanUpdatedWebhook(Webhook): description = "Occurs whenever a plan is updated." +class PriceCreatedWebhook(Webhook): + name = "price.created" + description = "Occurs whenever a price is created." + + +class PriceDeletedWebhook(Webhook): + name = "price.deleted" + description = "Occurs whenever a price is deleted." + + +class PriceUpdatedWebhook(Webhook): + name = "price.updated" + description = "Occurs whenever a price is updated." + + class ProductCreatedWebhook(Webhook): name = "product.created" description = "Occurs whenever a product is created." +class ProductDeletedWebhook(Webhook): + name = "product.deleted" + description = "Occurs whenever a product is deleted." + + class ProductUpdatedWebhook(Webhook): name = "product.updated" description = "Occurs whenever a product is updated." +class PromotionCodeCreatedWebhook(Webhook): + name = "promotion_code.created" + description = "Occurs whenever a promotion code is created." + + +class PromotionCodeUpdatedWebhook(Webhook): + name = "promotion_code.updated" + description = "Occurs whenever a promotion code is updated." + + +class QuoteAcceptedWebhook(Webhook): + name = "quote.accepted" + description = "Occurs whenever a quote is accepted." + + +class QuoteCanceledWebhook(Webhook): + name = "quote.canceled" + description = "Occurs whenever a quote is canceled." + + +class QuoteCreatedWebhook(Webhook): + name = "quote.created" + description = "Occurs whenever a quote is created." + + +class QuoteFinalizedWebhook(Webhook): + name = "quote.finalized" + description = "Occurs whenever a quote is finalized." + + +class RadarEarlyFraudWarningCreatedWebhook(Webhook): + name = "radar.early_fraud_warning.created" + description = "Occurs whenever an early fraud warning is created." + + +class RadarEarlyFraudWarningUpdatedWebhook(Webhook): + name = "radar.early_fraud_warning.updated" + description = "Occurs whenever an early fraud warning is updated." + + class RecipientCreatedWebhook(Webhook): name = "recipient.created" description = "Occurs whenever a recipient is created." @@ -296,29 +686,194 @@ class RecipientUpdatedWebhook(Webhook): description = "Occurs whenever a recipient is updated." -class SKUCreatedWebhook(Webhook): +class ReportingReportRunFailedWebhook(Webhook): + name = "reporting.report_run.failed" + description = "Occurs whenever a requested ReportRun failed to complete." + + +class ReportingReportRunSucceededWebhook(Webhook): + name = "reporting.report_run.succeeded" + description = "Occurs whenever a requested ReportRun completed succesfully." + + +class ReportingReportTypeUpdatedWebhook(Webhook): + name = "reporting.report_type.updated" + description = "Occurs whenever a ReportType is updated (typically to indicate that a new day's data has come available)." + + +class ReviewClosedWebhook(Webhook): + name = "review.closed" + description = "Occurs whenever a review is closed. The review's reason field indicates why: approved, disputed, refunded, or refunded_as_fraud." + + +class ReviewOpenedWebhook(Webhook): + name = "review.opened" + description = "Occurs whenever a review is opened." + + +class SetupIntentCanceledWebhook(Webhook): + name = "setup_intent.canceled" + description = "Occurs when a SetupIntent is canceled." + + +class SetupIntentCreatedWebhook(Webhook): + name = "setup_intent.created" + description = "Occurs when a new SetupIntent is created." + + +class SetupIntentRequiresActionWebhook(Webhook): + name = "setup_intent.requires_action" + description = "Occurs when a SetupIntent is in requires_action state." + + +class SetupIntentSetupFailedWebhook(Webhook): + name = "setup_intent.setup_failed" + description = "Occurs when a SetupIntent has failed the attempt to setup a payment method." + + +class SetupIntentSucceededWebhook(Webhook): + name = "setup_intent.succeeded" + description = "Occurs when an SetupIntent has successfully setup a payment method." + + +class SigmaScheduledQueryRunCreatedWebhook(Webhook): + name = "sigma.scheduled_query_run.created" + description = "Occurs whenever a Sigma scheduled query run finishes." + + +class SkuCreatedWebhook(Webhook): name = "sku.created" description = "Occurs whenever a SKU is created." -class SKUUpdatedWebhook(Webhook): +class SkuDeletedWebhook(Webhook): + name = "sku.deleted" + description = "Occurs whenever a SKU is deleted." + + +class SkuUpdatedWebhook(Webhook): name = "sku.updated" description = "Occurs whenever a SKU is updated." +class SourceCanceledWebhook(Webhook): + name = "source.canceled" + description = "Occurs whenever a source is canceled." + + +class SourceChargeableWebhook(Webhook): + name = "source.chargeable" + description = "Occurs whenever a source transitions to chargeable." + + +class SourceFailedWebhook(Webhook): + name = "source.failed" + description = "Occurs whenever a source fails." + + +class SourceMandateNotificationWebhook(Webhook): + name = "source.mandate_notification" + description = "Occurs whenever a source mandate notification method is set to manual." + + +class SourceRefundAttributesRequiredWebhook(Webhook): + name = "source.refund_attributes_required" + description = "Occurs whenever the refund attributes are required on a receiver source to process a refund or a mispayment." + + +class SourceTransactionCreatedWebhook(Webhook): + name = "source.transaction.created" + description = "Occurs whenever a source transaction is created." + + +class SourceTransactionUpdatedWebhook(Webhook): + name = "source.transaction.updated" + description = "Occurs whenever a source transaction is updated." + + +class SubscriptionScheduleAbortedWebhook(Webhook): + name = "subscription_schedule.aborted" + description = "Occurs whenever a subscription schedule is canceled due to the underlying subscription being canceled because of delinquency." + + +class SubscriptionScheduleCanceledWebhook(Webhook): + name = "subscription_schedule.canceled" + description = "Occurs whenever a subscription schedule is canceled." + + +class SubscriptionScheduleCompletedWebhook(Webhook): + name = "subscription_schedule.completed" + description = "Occurs whenever a new subscription schedule is completed." + + +class SubscriptionScheduleCreatedWebhook(Webhook): + name = "subscription_schedule.created" + description = "Occurs whenever a new subscription schedule is created." + + +class SubscriptionScheduleExpiringWebhook(Webhook): + name = "subscription_schedule.expiring" + description = "Occurs 7 days before a subscription schedule will expire." + + +class SubscriptionScheduleReleasedWebhook(Webhook): + name = "subscription_schedule.released" + description = "Occurs whenever a new subscription schedule is released." + + +class SubscriptionScheduleUpdatedWebhook(Webhook): + name = "subscription_schedule.updated" + description = "Occurs whenever a subscription schedule is updated." + + +class TaxRateCreatedWebhook(Webhook): + name = "tax_rate.created" + description = "Occurs whenever a new tax rate is created." + + +class TaxRateUpdatedWebhook(Webhook): + name = "tax_rate.updated" + description = "Occurs whenever a tax rate is updated." + + +class TopupCanceledWebhook(Webhook): + name = "topup.canceled" + description = "Occurs whenever a top-up is canceled." + + +class TopupCreatedWebhook(Webhook): + name = "topup.created" + description = "Occurs whenever a top-up is created." + + +class TopupFailedWebhook(Webhook): + name = "topup.failed" + description = "Occurs whenever a top-up fails." + + +class TopupReversedWebhook(Webhook): + name = "topup.reversed" + description = "Occurs whenever a top-up is reversed." + + +class TopupSucceededWebhook(Webhook): + name = "topup.succeeded" + description = "Occurs whenever a top-up succeeds." + + class TransferCreatedWebhook(Webhook): name = "transfer.created" - description = "Occurs whenever a new transfer is created." + description = "Occurs whenever a transfer is created." class TransferFailedWebhook(Webhook): name = "transfer.failed" - description = "Occurs whenever Stripe attempts to send a transfer and that transfer fails." + description = "Occurs whenever a transfer failed." class TransferPaidWebhook(Webhook): name = "transfer.paid" - description = "Occurs whenever a sent transfer is expected to be available in the destination bank account. If the transfer failed, a transfer.failed webhook will additionally be sent at a later time. Note to Connect users: this event is only created for transfers from your connected Stripe accounts to their bank accounts, not for transfers to the connected accounts themselves." + description = "Occurs after a transfer is paid. For Instant Payouts, the event will typically be sent within 30 minutes." class TransferReversedWebhook(Webhook): @@ -328,9 +883,4 @@ class TransferReversedWebhook(Webhook): class TransferUpdatedWebhook(Webhook): name = "transfer.updated" - description = "Occurs whenever the description or metadata of a transfer is updated." - - -class PingWebhook(Webhook): - name = "ping" - description = "May be sent by Stripe at any time to see if a provided webhook URL is working." + description = "Occurs whenever a transfer's description or metadata is updated." diff --git a/pinax/stripe/webhooks/overrides.py b/pinax/stripe/webhooks/overrides.py index 126f31f75..8f9b100cb 100644 --- a/pinax/stripe/webhooks/overrides.py +++ b/pinax/stripe/webhooks/overrides.py @@ -2,10 +2,10 @@ from ..conf import settings from ..utils import obfuscate_secret_key -from .generated import AccountApplicationDeauthorizeWebhook +from .generated import AccountApplicationDeauthorizedWebhook -class CustomAccountApplicationDeauthorizeWebhook(AccountApplicationDeauthorizeWebhook): +class CustomAccountApplicationDeauthorizedWebhook(AccountApplicationDeauthorizedWebhook): def validate(self): """ From d9bdaf77e2b9834efd3539ec6ce599804a84b5dc Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 00:08:15 -0600 Subject: [PATCH 39/49] Add script to generate webhooks --- update_webhooks.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 update_webhooks.py diff --git a/update_webhooks.py b/update_webhooks.py new file mode 100644 index 000000000..5749ab8ae --- /dev/null +++ b/update_webhooks.py @@ -0,0 +1,41 @@ +import requests + + +URL = "https://stripe.com/docs/api/curl/sections?all_sections=1&version=2019-02-19&cacheControlVersion=4" +response = requests.get(URL) + +data = response.json() + +event_types = data["event_types"]["data"]["event_types"] + +class_template = """class {class_name}Webhook(Webhook): + name = "{name}" + description = "{description}" + + +""" + +header = """from .base import Webhook + + +""" + +with open("pinax/stripe/webhooks/generated.py", "wb") as fp: + fp.write(header.encode("utf-8")) + for index, event_type in enumerate(event_types): + name = event_type["type"] + description = event_type["description"].replace('"', "'") + class_name = name.replace(".", " ").replace("_", " ").title().replace(" ", "") + + code = class_template.format( + class_name=class_name, + name=name, + description=description + ) + if index + 1 == len(event_types): + code = f"{code.strip()}\n" + + fp.write(code.encode("utf-8")) + + print(f"{name} added...") + fp.close() From bca0b591c1c8dccb176e444d32ac8ba164c49e78 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 00:22:53 -0600 Subject: [PATCH 40/49] Update to latest version of API --- pinax/stripe/webhooks/generated.py | 1 + update_webhooks.py | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pinax/stripe/webhooks/generated.py b/pinax/stripe/webhooks/generated.py index 0b5f44ec7..7b6942877 100644 --- a/pinax/stripe/webhooks/generated.py +++ b/pinax/stripe/webhooks/generated.py @@ -1,3 +1,4 @@ +# Stripe API Version: 2020-08-27 from .base import Webhook diff --git a/update_webhooks.py b/update_webhooks.py index 5749ab8ae..92a50d73e 100644 --- a/update_webhooks.py +++ b/update_webhooks.py @@ -1,11 +1,12 @@ import requests -URL = "https://stripe.com/docs/api/curl/sections?all_sections=1&version=2019-02-19&cacheControlVersion=4" +URL = "https://stripe.com/docs/api/curl/sections?all_sections=1&version=2020-08-27&cacheControlVersion=4" response = requests.get(URL) data = response.json() +version = data["event_types"]["data"]["version"] event_types = data["event_types"]["data"]["event_types"] class_template = """class {class_name}Webhook(Webhook): @@ -15,11 +16,13 @@ """ -header = """from .base import Webhook +header = f"""# Stripe API Version: {version} +from .base import Webhook """ +print(f"Creating {len(event_types)} classes...") with open("pinax/stripe/webhooks/generated.py", "wb") as fp: fp.write(header.encode("utf-8")) for index, event_type in enumerate(event_types): From 0d6da79324e62fd488ed936c8d6a3ba4edbb8807 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 01:21:38 -0600 Subject: [PATCH 41/49] Use signature verification to validate webhooks --- pinax/stripe/admin.py | 4 +- pinax/stripe/conf.py | 5 +- .../migrations/0003_auto_20211127_0119.py | 26 ++++ pinax/stripe/models.py | 11 +- pinax/stripe/tests/settings.py | 1 + pinax/stripe/tests/test_admin.py | 4 +- pinax/stripe/tests/test_models.py | 13 +- pinax/stripe/tests/test_views.py | 45 ++++++- pinax/stripe/tests/test_webhooks.py | 118 ++++-------------- pinax/stripe/views.py | 23 ++-- pinax/stripe/webhooks/__init__.py | 1 - pinax/stripe/webhooks/base.py | 34 ----- pinax/stripe/webhooks/overrides.py | 29 ----- 13 files changed, 119 insertions(+), 195 deletions(-) create mode 100644 pinax/stripe/migrations/0003_auto_20211127_0119.py delete mode 100644 pinax/stripe/webhooks/overrides.py diff --git a/pinax/stripe/admin.py b/pinax/stripe/admin.py index be2f55645..ea34e7b3c 100644 --- a/pinax/stripe/admin.py +++ b/pinax/stripe/admin.py @@ -46,7 +46,6 @@ class EventAdmin(ModelAdmin): "stripe_id", "kind", "livemode", - "valid", "processed", "created_at", "account_id", @@ -55,13 +54,12 @@ class EventAdmin(ModelAdmin): list_filter = [ "kind", "created_at", - "valid", "processed" ] search_fields = [ "stripe_id", "customer_id", - "validated_message", + "message", "account_id", ] diff --git a/pinax/stripe/conf.py b/pinax/stripe/conf.py index cf0da6c13..395a597c9 100644 --- a/pinax/stripe/conf.py +++ b/pinax/stripe/conf.py @@ -10,11 +10,12 @@ class PinaxStripeAppConf(AppConf): PUBLIC_KEY = None SECRET_KEY = None - API_VERSION = "2019-03-14" + API_VERSION = "2020-08-27" + ENDPOINT_SECRET = None class Meta: prefix = "pinax_stripe" - required = ["PUBLIC_KEY", "SECRET_KEY", "API_VERSION"] + required = ["PUBLIC_KEY", "SECRET_KEY", "API_VERSION", "ENDPOINT_SECRET"] def configure_api_version(self, value): stripe.api_version = value diff --git a/pinax/stripe/migrations/0003_auto_20211127_0119.py b/pinax/stripe/migrations/0003_auto_20211127_0119.py new file mode 100644 index 000000000..502a66057 --- /dev/null +++ b/pinax/stripe/migrations/0003_auto_20211127_0119.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.9 on 2021-11-27 07:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0002_auto_20211126_1416'), + ] + + operations = [ + migrations.RenameField( + model_name='event', + old_name='webhook_message', + new_name='message', + ), + migrations.RemoveField( + model_name='event', + name='valid', + ), + migrations.RemoveField( + model_name='event', + name='validated_message', + ), + ] diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index d9b0be71a..d4a6047ea 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -17,26 +17,19 @@ class Event(StripeObject): livemode = models.BooleanField(default=False) customer_id = models.CharField(max_length=200, blank=True) account_id = models.CharField(max_length=200, blank=True) - webhook_message = models.JSONField() - validated_message = models.JSONField(null=True, blank=True) - valid = models.BooleanField(null=True, blank=True) + message = models.JSONField() processed = models.BooleanField(default=False) pending_webhooks = models.PositiveIntegerField(default=0) api_version = models.CharField(max_length=100, blank=True) - @property - def message(self): - return self.validated_message - def __str__(self): return "{} - {}".format(self.kind, self.stripe_id) def __repr__(self): - return "Event(pk={!r}, kind={!r}, customer={!r}, valid={!r}, created_at={!s}, stripe_id={!r})".format( + return "Event(pk={!r}, kind={!r}, customer={!r}, created_at={!s}, stripe_id={!r})".format( self.pk, self.kind, self.customer_id, - self.valid, self.created_at.replace(microsecond=0).isoformat(), self.stripe_id, ) diff --git a/pinax/stripe/tests/settings.py b/pinax/stripe/tests/settings.py index 223545cd7..ca9c09447 100644 --- a/pinax/stripe/tests/settings.py +++ b/pinax/stripe/tests/settings.py @@ -25,6 +25,7 @@ SITE_ID = 1 PINAX_STRIPE_PUBLIC_KEY = "" PINAX_STRIPE_SECRET_KEY = "sk_test_01234567890123456789abcd" +PINAX_STRIPE_ENDPOINT_SECRET = "foo" TEMPLATES = [{ "BACKEND": "django.template.backends.django.DjangoTemplates", }] diff --git a/pinax/stripe/tests/test_admin.py b/pinax/stripe/tests/test_admin.py index bdc92cd6c..2d962bc33 100644 --- a/pinax/stripe/tests/test_admin.py +++ b/pinax/stripe/tests/test_admin.py @@ -33,7 +33,7 @@ def test_change_view_title(self): is_staff=True, is_superuser=True ) - event = Event.objects.create(kind="foo", webhook_message={}, stripe_id="foo") + event = Event.objects.create(kind="foo", message={}, stripe_id="foo") error = EventProcessingException.objects.create(event=event, data={}, message="foo") instance = EventProcessingExceptionAdmin(EventProcessingException, admin.site) response = instance.change_view(request, str(error.pk)) @@ -72,7 +72,7 @@ def test_change_view_title(self): is_staff=True, is_superuser=True ) - event = Event.objects.create(kind="foo", webhook_message={}, stripe_id="foo") + event = Event.objects.create(kind="foo", message={}, stripe_id="foo") instance = EventAdmin(Event, admin.site) response = instance.change_view(request, str(event.pk)) self.assertEqual( diff --git a/pinax/stripe/tests/test_models.py b/pinax/stripe/tests/test_models.py index b3abbc4e2..ccfdc42fa 100644 --- a/pinax/stripe/tests/test_models.py +++ b/pinax/stripe/tests/test_models.py @@ -1,5 +1,3 @@ -import datetime - from django.test import TestCase from django.utils import timezone @@ -15,21 +13,16 @@ def test_event_processing_exception_str(self): def test_event_str_and_repr(self): created_at = timezone.now() created_at_iso = created_at.replace(microsecond=0).isoformat() - e = Event(kind="customer.deleted", webhook_message={}, created_at=created_at) + e = Event(kind="customer.deleted", message={}, created_at=created_at) self.assertTrue("customer.deleted" in str(e)) self.assertEqual( repr(e), - f"Event(pk=None, kind='customer.deleted', customer='', valid=None, created_at={created_at_iso}, stripe_id='')" + f"Event(pk=None, kind='customer.deleted', customer='', created_at={created_at_iso}, stripe_id='')" ) e.stripe_id = "evt_X" e.customer_id = "cus_YYY" self.assertEqual( repr(e), - f"Event(pk=None, kind='customer.deleted', customer='{e.customer_id}', valid=None, created_at={created_at_iso}, stripe_id='evt_X')" + f"Event(pk=None, kind='customer.deleted', customer='{e.customer_id}', created_at={created_at_iso}, stripe_id='evt_X')" ) - - def test_validated_message(self): - created_at = datetime.datetime.utcnow() - e = Event(kind="customer.deleted", webhook_message={}, validated_message={"foo": "bar"}, created_at=created_at) - self.assertEqual(e.message, e.validated_message) diff --git a/pinax/stripe/tests/test_views.py b/pinax/stripe/tests/test_views.py index dab101c02..7d3fe5988 100644 --- a/pinax/stripe/tests/test_views.py +++ b/pinax/stripe/tests/test_views.py @@ -2,6 +2,8 @@ from django.test import RequestFactory, TestCase +import stripe + from ..models import Event from ..views import Webhook from . import PLAN_CREATED_TEST_DATA @@ -11,9 +13,11 @@ class WebhookViewTest(TestCase): def setUp(self): self.factory = RequestFactory() + @patch("pinax.stripe.views.stripe.Webhook.construct_event") @patch("pinax.stripe.views.registry") - def test_send_webhook(self, mock_registry): - request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json") + def test_send_webhook(self, mock_registry, mock_event): + mock_event.return_value.to_dict_recursive.return_value = PLAN_CREATED_TEST_DATA + request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json", HTTP_STRIPE_SIGNATURE="foo") response = Webhook.as_view()(request) self.assertEqual(response.status_code, 200) self.assertTrue(Event.objects.filter(stripe_id=PLAN_CREATED_TEST_DATA["id"]).exists()) @@ -21,10 +25,43 @@ def test_send_webhook(self, mock_registry): mock_registry.get.return_value.return_value.process.called ) + @patch("pinax.stripe.views.stripe.Webhook.construct_event") + @patch("pinax.stripe.views.registry") + def test_send_webhook_dupe(self, mock_registry, mock_event): + Event.objects.create(stripe_id=PLAN_CREATED_TEST_DATA["id"], message=PLAN_CREATED_TEST_DATA) + mock_event.return_value.to_dict_recursive.return_value = PLAN_CREATED_TEST_DATA + mock_event.return_value.id = PLAN_CREATED_TEST_DATA["id"] + request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json", HTTP_STRIPE_SIGNATURE="foo") + response = Webhook.as_view()(request) + self.assertEqual(response.status_code, 200) + self.assertFalse( + mock_registry.get.return_value.return_value.process.called + ) + + @patch("pinax.stripe.views.stripe.Webhook.construct_event") @patch("pinax.stripe.views.registry") - def test_send_webhook_no_handler(self, mock_registry): + def test_send_webhook_no_handler(self, mock_registry, mock_event): mock_registry.get.return_value = None - request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json") + mock_event.return_value.to_dict_recursive.return_value = PLAN_CREATED_TEST_DATA + request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json", HTTP_STRIPE_SIGNATURE="foo") response = Webhook.as_view()(request) self.assertEqual(response.status_code, 200) self.assertTrue(Event.objects.filter(stripe_id=PLAN_CREATED_TEST_DATA["id"]).exists()) + + @patch("pinax.stripe.views.stripe.Webhook.construct_event") + @patch("pinax.stripe.views.registry") + def test_send_webhook_value_error(self, mock_registry, mock_event): + mock_registry.get.return_value = None + mock_event.side_effect = ValueError + request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json", HTTP_STRIPE_SIGNATURE="foo") + response = Webhook.as_view()(request) + self.assertEqual(response.status_code, 400) + + @patch("pinax.stripe.views.stripe.Webhook.construct_event") + @patch("pinax.stripe.views.registry") + def test_send_webhook_stripe_error(self, mock_registry, mock_event): + mock_registry.get.return_value = None + mock_event.side_effect = stripe.error.SignatureVerificationError("foo", "sig") + request = self.factory.post("/webhook", data=PLAN_CREATED_TEST_DATA, content_type="application/json", HTTP_STRIPE_SIGNATURE="foo") + response = Webhook.as_view()(request) + self.assertEqual(response.status_code, 400) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index 6aefcd260..493d41430 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -12,7 +12,6 @@ from ..webhooks import ( AccountExternalAccountCreatedWebhook, AccountUpdatedWebhook, - CustomAccountApplicationDeauthorizedWebhook, Webhook, registry ) @@ -92,30 +91,36 @@ def test_webhook_init(self): webhook = Webhook(event) self.assertIsNone(webhook.name) + @patch("stripe.Webhook.construct_event") @patch("stripe.Event.retrieve") @patch("stripe.Transfer.retrieve") - def test_webhook_with_transfer_event(self, TransferMock, StripeEventMock): + def test_webhook_with_transfer_event(self, TransferMock, StripeEventMock, MockEvent): + MockEvent.return_value.to_dict_recursive.return_value = self.event_data.copy() StripeEventMock.return_value.to_dict.return_value = self.event_data TransferMock.return_value = self.event_data["data"]["object"] msg = json.dumps(self.event_data) resp = Client().post( reverse("pinax_stripe_webhook"), msg, - content_type="application/json" + content_type="application/json", + HTTP_STRIPE_SIGNATURE="foo" ) self.assertEqual(resp.status_code, 200) self.assertTrue(Event.objects.filter(kind="transfer.created").exists()) + @patch("stripe.Webhook.construct_event") @patch("stripe.Event.retrieve") - def test_webhook_associated_with_stripe_account(self, StripeEventMock): + def test_webhook_associated_with_stripe_account(self, StripeEventMock, MockEvent): connect_event_data = self.event_data.copy() connect_event_data["account"] = "acc_XXX" + MockEvent.return_value.to_dict_recursive.return_value = connect_event_data StripeEventMock.return_value.to_dict.return_value = connect_event_data msg = json.dumps(connect_event_data) resp = Client().post( reverse("pinax_stripe_webhook"), msg, - content_type="application/json" + content_type="application/json", + HTTP_STRIPE_SIGNATURE="foo" ) self.assertEqual(resp.status_code, 200) self.assertTrue(Event.objects.filter(kind="transfer.created").exists()) @@ -124,14 +129,17 @@ def test_webhook_associated_with_stripe_account(self, StripeEventMock): "acc_XXX" ) - def test_webhook_duplicate_event(self): + @patch("stripe.Webhook.construct_event") + def test_webhook_duplicate_event(self, MockEvent): + MockEvent.return_value.to_dict_recursive.return_value = self.event_data.copy() data = {"id": 123} - Event.objects.create(stripe_id=123, livemode=True, webhook_message={}) + Event.objects.create(stripe_id=123, livemode=True, message={}) msg = json.dumps(data) resp = Client().post( reverse("pinax_stripe_webhook"), msg, - content_type="application/json" + content_type="application/json", + HTTP_STRIPE_SIGNATURE="foo" ) self.assertEqual(resp.status_code, 200) self.assertEqual(Event.objects.filter(stripe_id="123").count(), 1) @@ -160,110 +168,32 @@ def signal_handler(sender, *args, **kwargs): webhook.name = "mismatch name" # Not sure how this ever happens due to the registry webhook.send_signal() - @patch("pinax.stripe.webhooks.Webhook.validate") @patch("pinax.stripe.webhooks.Webhook.process_webhook") - def test_process_exception_is_logged(self, ProcessWebhookMock, ValidateMock): + def test_process_exception_is_logged(self, ProcessWebhookMock): # note: we choose an event type for which we do no processing - event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=False) + event = Event.objects.create(kind="account.external_account.created", message={}, processed=False) ProcessWebhookMock.side_effect = stripe.error.StripeError("Message", "error") with self.assertRaises(stripe.error.StripeError): AccountExternalAccountCreatedWebhook(event).process() self.assertTrue(EventProcessingException.objects.filter(event=event).exists()) - @patch("pinax.stripe.webhooks.Webhook.validate") - def test_process_already_processed(self, ValidateMock): - event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=True) - hook = registry.get(event.kind) - hook(event).process() - self.assertFalse(ValidateMock.called) - - @patch("pinax.stripe.webhooks.Webhook.validate") @patch("pinax.stripe.webhooks.Webhook.process_webhook") - def test_process_not_valid(self, ProcessWebhookMock, ValidateMock): - event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=False, processed=False) + def test_process_already_processed(self, ProcessWebhookMock): + event = Event.objects.create(kind="account.external_account.created", message={}, processed=True) hook = registry.get(event.kind) hook(event).process() - self.assertTrue(ValidateMock.called) self.assertFalse(ProcessWebhookMock.called) - @patch("pinax.stripe.webhooks.Webhook.validate") @patch("pinax.stripe.webhooks.Webhook.process_webhook") - def test_process_exception_is_logged_non_stripeerror(self, ProcessWebhookMock, ValidateMock): + def test_process_exception_is_logged_non_stripeerror(self, ProcessWebhookMock): # note: we choose an event type for which we do no processing - event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=False) + event = Event.objects.create(kind="account.external_account.created", message={}, processed=False) ProcessWebhookMock.side_effect = Exception("generic exception") with self.assertRaises(Exception): AccountExternalAccountCreatedWebhook(event).process() self.assertTrue(EventProcessingException.objects.filter(event=event).exists()) - @patch("pinax.stripe.webhooks.Webhook.validate") - def test_process_return_none(self, ValidateMock): + def test_process_return_none(self): # note: we choose an event type for which we do no processing - event = Event.objects.create(kind="account.external_account.created", webhook_message={}, valid=True, processed=False) + event = Event.objects.create(kind="account.external_account.created", message={}, processed=False) self.assertIsNone(AccountExternalAccountCreatedWebhook(event).process()) - - @patch("stripe.Event.retrieve") - def test_process_deauthorize(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_001"}}, - "account": "acct_bb"} - event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizedWebhook.name, - webhook_message=data, - ) - RetrieveMock.side_effect = stripe.error.PermissionError( - "The provided key 'sk_test_********************abcd' does not have access to account 'acct_aa' (or that account does not exist). Application access may have been revoked.") - CustomAccountApplicationDeauthorizedWebhook(event).process() - self.assertTrue(event.valid) - self.assertTrue(event.processed) - - @patch("stripe.Event.retrieve") - def test_process_deauthorize_fake_response(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_001"}}, - "account": "acct_bb"} - event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizedWebhook.name, - webhook_message=data, - ) - RetrieveMock.side_effect = stripe.error.PermissionError( - "The provided key 'sk_test_********************ABCD' does not have access to account 'acc_aa' (or that account does not exist). Application access may have been revoked.") - with self.assertRaises(stripe.error.PermissionError): - CustomAccountApplicationDeauthorizedWebhook(event).process() - - @patch("stripe.Event.retrieve") - def test_process_deauthorize_with_delete_account(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_002"}}, - "account": "acct_bb"} - event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizedWebhook.name, - webhook_message=data, - ) - RetrieveMock.side_effect = stripe.error.PermissionError( - "The provided key 'sk_test_********************abcd' does not have access to account 'acct_bb' (or that account does not exist). Application access may have been revoked.") - CustomAccountApplicationDeauthorizedWebhook(event).process() - self.assertTrue(event.valid) - self.assertTrue(event.processed) - - @patch("stripe.Event.retrieve") - def test_process_deauthorize_without_account(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_001"}}} - event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizedWebhook.name, - webhook_message=data, - ) - RetrieveMock.return_value.to_dict.return_value = data - CustomAccountApplicationDeauthorizedWebhook(event).process() - self.assertTrue(event.valid) - self.assertTrue(event.processed) - - @patch("stripe.Event.retrieve") - def test_process_deauthorize_without_account_exception(self, RetrieveMock): - data = {"data": {"object": {"id": "evt_001"}}} - event = Event.objects.create( - kind=CustomAccountApplicationDeauthorizedWebhook.name, - webhook_message=data, - ) - RetrieveMock.side_effect = stripe.error.PermissionError() - RetrieveMock.return_value.to_dict.return_value = data - CustomAccountApplicationDeauthorizedWebhook(event).process() - self.assertTrue(event.valid) - self.assertTrue(event.processed) diff --git a/pinax/stripe/views.py b/pinax/stripe/views.py index 936f2a3da..e6838bd16 100644 --- a/pinax/stripe/views.py +++ b/pinax/stripe/views.py @@ -1,11 +1,11 @@ -import json - +from django.conf import settings from django.http import HttpResponse from django.utils.decorators import method_decorator -from django.utils.encoding import smart_str from django.views.decorators.csrf import csrf_exempt from django.views.generic import View +import stripe + from .models import Event from .webhooks import registry @@ -19,7 +19,7 @@ def add_event(self, data): stripe_id=data["id"], kind=kind, livemode=data["livemode"], - webhook_message=data, + message=data, api_version=data["api_version"], pending_webhooks=data["pending_webhooks"] ) @@ -33,7 +33,16 @@ def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) def post(self, request, *args, **kwargs): - data = json.loads(smart_str(self.request.body)) - if not Event.objects.filter(stripe_id=data["id"]).exists(): - self.add_event(data) + signature = self.request.META["HTTP_STRIPE_SIGNATURE"] + payload = self.request.body + event = None + try: + event = stripe.Webhook.construct_event(payload, signature, settings.PINAX_STRIPE_ENDPOINT_SECRET) + except ValueError: + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError: + return HttpResponse(status=400) + + if not Event.objects.filter(stripe_id=event.id).exists(): + self.add_event(event.to_dict_recursive()) return HttpResponse() diff --git a/pinax/stripe/webhooks/__init__.py b/pinax/stripe/webhooks/__init__.py index ade5718a6..ea578cf6b 100644 --- a/pinax/stripe/webhooks/__init__.py +++ b/pinax/stripe/webhooks/__init__.py @@ -1,4 +1,3 @@ from .base import Webhook # noqa from .generated import * # noqa -from .overrides import CustomAccountApplicationDeauthorizedWebhook # noqa from .registry import registry # noqa diff --git a/pinax/stripe/webhooks/base.py b/pinax/stripe/webhooks/base.py index adaaf7d9e..6c863c2a3 100644 --- a/pinax/stripe/webhooks/base.py +++ b/pinax/stripe/webhooks/base.py @@ -1,4 +1,3 @@ -import json import sys import traceback @@ -26,36 +25,6 @@ def __init__(self, event): self.event = event self.stripe_account = None - def validate(self): - """ - Validate incoming events. - - We fetch the event data to ensure it is legit. - For Connect accounts we must fetch the event using the `stripe_account` - parameter. - """ - self.stripe_account = self.event.webhook_message.get("account", None) - evt = stripe.Event.retrieve( - self.event.stripe_id, - stripe_account=self.stripe_account - ) - self.event.validated_message = json.loads( - json.dumps( - evt.to_dict(), - sort_keys=True, - ) - ) - self.event.valid = self.is_event_valid(self.event.webhook_message["data"], self.event.validated_message["data"]) - self.event.save() - - @staticmethod - def is_event_valid(webhook_message_data, validated_message_data): - """ - Notice "data" may contain a "previous_attributes" section - """ - return "object" in webhook_message_data and "object" in validated_message_data and \ - webhook_message_data["object"] == validated_message_data["object"] - def send_signal(self): signal = registry.get_signal(self.name) if signal: @@ -74,9 +43,6 @@ def log_exception(self, data, exception): def process(self): if self.event.processed: return - self.validate() - if not self.event.valid: - return try: self.process_webhook() diff --git a/pinax/stripe/webhooks/overrides.py b/pinax/stripe/webhooks/overrides.py deleted file mode 100644 index 8f9b100cb..000000000 --- a/pinax/stripe/webhooks/overrides.py +++ /dev/null @@ -1,29 +0,0 @@ -import stripe - -from ..conf import settings -from ..utils import obfuscate_secret_key -from .generated import AccountApplicationDeauthorizedWebhook - - -class CustomAccountApplicationDeauthorizedWebhook(AccountApplicationDeauthorizedWebhook): - - def validate(self): - """ - Specialized validation of incoming events. - - When this event is for a connected account we should not be able to - fetch the event anymore (since we have been disconnected). - But there might be multiple connections (e.g. for Dev/Prod). - - Therefore we try to retrieve the event, and handle a - PermissionError exception to be expected (since we cannot access the - account anymore). - """ - try: - super().validate() - except stripe.error.PermissionError as exc: - if self.stripe_account: - if self.stripe_account not in str(exc) and obfuscate_secret_key(settings.PINAX_STRIPE_SECRET_KEY) not in str(exc): - raise exc - self.event.valid = True - self.event.validated_message = self.event.webhook_message From 70339227d7b59a9bc528db61e9bafae6b050bdba Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 01:26:44 -0600 Subject: [PATCH 42/49] Add unregister #411 --- pinax/stripe/tests/test_webhooks.py | 4 ++++ pinax/stripe/webhooks/registry.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/pinax/stripe/tests/test_webhooks.py b/pinax/stripe/tests/test_webhooks.py index 493d41430..fd31654a7 100644 --- a/pinax/stripe/tests/test_webhooks.py +++ b/pinax/stripe/tests/test_webhooks.py @@ -150,6 +150,10 @@ def test_webhook_event_mismatch(self): with self.assertRaises(Exception): WH(event) + def test_registry_unregister(self): + registry.unregister("account.updated") + self.assertFalse("account.updated" in registry._registry) + @patch("django.dispatch.Signal.send") def test_send_signal(self, SignalSendMock): event = Event(kind="account.application.deauthorized") diff --git a/pinax/stripe/webhooks/registry.py b/pinax/stripe/webhooks/registry.py index 9a855746f..5e955fbf3 100644 --- a/pinax/stripe/webhooks/registry.py +++ b/pinax/stripe/webhooks/registry.py @@ -12,6 +12,9 @@ def register(self, webhook): "signal": Signal() } + def unregister(self, name): + del self._registry[name] + def keys(self): return self._registry.keys() From 1c06b7aa2ce69ee1dc5bba899ac6ed655c3c3666 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 01:28:45 -0600 Subject: [PATCH 43/49] Isort --- update_webhooks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/update_webhooks.py b/update_webhooks.py index 92a50d73e..10338cb80 100644 --- a/update_webhooks.py +++ b/update_webhooks.py @@ -1,6 +1,5 @@ import requests - URL = "https://stripe.com/docs/api/curl/sections?all_sections=1&version=2020-08-27&cacheControlVersion=4" response = requests.get(URL) From 891fc4d0e1c494c390978bd12cc008296a699ad3 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 01:32:46 -0600 Subject: [PATCH 44/49] Update badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c3f754eb..92386e18a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![](https://img.shields.io/badge/license-MIT-blue.svg)](https://pypi.python.org/pypi/pinax-stripe/) [![Codecov](https://img.shields.io/codecov/c/github/pinax/pinax-stripe.svg)](https://codecov.io/gh/pinax/pinax-stripe) -[![CircleCI](https://circleci.com/gh/pinax/pinax-stripe.svg?style=svg)](https://circleci.com/gh/pinax/pinax-stripe) +[![Build](https://github.com/pinax/pinax-images/actions/workflows/ci.yaml/badge.svg)](https://github.com/pinax/pinax-stripe/actions) ![](https://img.shields.io/github/contributors/pinax/pinax-stripe.svg) ![](https://img.shields.io/github/issues-pr/pinax/pinax-stripe.svg) ![](https://img.shields.io/github/issues-pr-closed/pinax/pinax-stripe.svg) From 33415e71d970a4d33492dda4a049f9a51d46a9d0 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 19:38:02 -0600 Subject: [PATCH 45/49] Update docs --- .github/workflows/ci.yaml | 15 +++ MANIFEST.in | 2 - README.md | 82 +++-------- check-migrations.sh | 6 + docs/about/history.md | 10 ++ docs/about/release-notes.md | 9 ++ docs/index.md | 6 +- docs/reference/urls.md | 4 +- docs/reference/webhooks.md | 261 +++++++++++++++++++++++++----------- tox.ini | 47 ------- update_webhooks.py | 2 +- 11 files changed, 248 insertions(+), 196 deletions(-) delete mode 100644 MANIFEST.in create mode 100755 check-migrations.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ec35764e9..e9662d7fc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,6 +8,21 @@ jobs: steps: - uses: pinax/linting@v2 + check-migrations: + name: Check Migrations + runs-on: ubuntu-latest + shell: bash + steps: + - uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: 3.9 + + - run: pip install . + - run: ./check-migrations.sh + test: name: Testing runs-on: ubuntu-latest diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 5d718f90d..000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include README.rst -recursive-include pinax/stripe/templates * diff --git a/README.md b/README.md index 92386e18a..ded6f3832 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,23 @@ ![](http://pinaxproject.com/pinax-design/patches/pinax-stripe.svg) -# Pinax Stripe +# Pinax Stripe (Light) -[![](https://img.shields.io/pypi/v/pinax-stripe.svg)](https://pypi.python.org/pypi/pinax-stripe/) -[![](https://img.shields.io/badge/license-MIT-blue.svg)](https://pypi.python.org/pypi/pinax-stripe/) +[![](https://img.shields.io/pypi/v/pinax-stripe-light.svg)](https://pypi.python.org/pypi/pinax-stripe-light/) +[![](https://img.shields.io/badge/license-MIT-blue.svg)](https://pypi.python.org/pypi/pinax-stripe-light/) -[![Codecov](https://img.shields.io/codecov/c/github/pinax/pinax-stripe.svg)](https://codecov.io/gh/pinax/pinax-stripe) -[![Build](https://github.com/pinax/pinax-images/actions/workflows/ci.yaml/badge.svg)](https://github.com/pinax/pinax-stripe/actions) -![](https://img.shields.io/github/contributors/pinax/pinax-stripe.svg) -![](https://img.shields.io/github/issues-pr/pinax/pinax-stripe.svg) -![](https://img.shields.io/github/issues-pr-closed/pinax/pinax-stripe.svg) +[![Codecov](https://img.shields.io/codecov/c/github/pinax/pinax-stripe-light.svg)](https://codecov.io/gh/pinax/pinax-stripe-light) +[![Build](https://github.com/pinax/pinax-stripe-light/actions/workflows/ci.yaml/badge.svg)](https://github.com/pinax/pinax-stripe-light/actions) +![](https://img.shields.io/github/contributors/pinax/pinax-stripe-light.svg) +![](https://img.shields.io/github/issues-pr/pinax/pinax-stripe-light.svg) +![](https://img.shields.io/github/issues-pr-closed/pinax/pinax-stripe-light.svg) [![](http://slack.pinaxproject.com/badge.svg)](http://slack.pinaxproject.com/) This app was formerly called `django-stripe-payments` and has been renamed to -avoid namespace collisions and to have more consistency with Pinax. +avoid namespace collisions and to have more consistency with Pinax. It has once +[more been renamed](https://github.com/pinax/pinax-stripe-light/discussions/644) +to `pinax-stripe-light` (package name, though it retains the `pinax.stripe.*` +Python namespace). ## Pinax @@ -24,62 +27,11 @@ This collection can be found at http://pinaxproject.com. This app was developed as part of the Pinax ecosystem but is just a Django app and can be used independently of other Pinax apps. -## pinax-stripe +## pinax-stripe-light -`pinax-stripe` is a payments Django app for Stripe. - -This app allows you to process one off charges as well as signup users for -recurring subscriptions managed by Stripe. - -To bootstrap your project, we recommend you start with: -https://pinax-stripe.readthedocs.org/en/latest/user-guide/getting-started/ - -## Development - -`pinax-stripe` supports a variety of Python and Django versions. It's best if you test each one of these before committing. Our [Travis CI Integration](https://travis-ci.org/pinax/pinax-stripe) will test these when you push but knowing before you commit prevents from having to do a lot of extra commits to get the build to pass. - -### Environment Setup - -In order to easily test on all these Pythons and run the exact same thing that Travis CI will execute you'll want to setup [pyenv](https://github.com/yyuu/pyenv) and install the Python versions outlined in [tox.ini](tox.ini). - -If you are on the Mac, it's recommended you use [brew](http://brew.sh/). After installing `brew` run: - -``` -$ brew install pyenv pyenv-virtualenv pyenv-virtualenvwrapper -``` - -Then: - -``` -$ CFLAGS="-I$(xcrun --show-sdk-path)/usr/include -I$(brew --prefix openssl)/include" \ -LDFLAGS="-L$(brew --prefix openssl)/lib" \ -pyenv install 2.7.14 3.4.7 3.5.4 3.6.3 - -$ pyenv virtualenv 2.7.14 -$ pyenv virtualenv 3.4.7 -$ pyenv virtualenv 3.5.4 -$ pyenv virtualenv 3.6.3 -$ pyenv global 2.7.14 3.4.7 3.5.4 3.6.3 - -$ pip install detox -``` - -To run test suite: - -Make sure you are NOT inside a `virtualenv` and then: - -``` -$ detox -``` - -This will execute the testing matrix in parallel as defined in the `tox.ini`. - - -## Documentation - -The `pinax-stripe` documentation is available at http://pinax-stripe.readthedocs.org/en/latest/. -The Pinax documentation is available at http://pinaxproject.com/pinax/. -We recently did a Pinax Hangout on pinax-stripe, you can read the recap blog post and find the video [here](http://blog.pinaxproject.com/2016/01/27/recap-january-pinax-hangout/). +`pinax-stripe-light` is a Django app for integrating Stripe webhooks into your +project. It also includes from lightweight utilities like template tags to make +working with Stripe a bit easier. ## Contribute @@ -88,8 +40,6 @@ See [this blog post](http://blog.pinaxproject.com/2016/02/26/recap-february-pina In case of any questions we recommend you [join our Pinax Slack team](http://slack.pinaxproject.com) and ping us there instead of creating an issue on GitHub. Creating issues on GitHub is of course also valid but we are usually able to help you faster if you ping us in Slack. -We also highly recommend reading our [Open Source and Self-Care blog post](http://blog.pinaxproject.com/2016/01/19/open-source-and-self-care/). - ## Code of Conduct diff --git a/check-migrations.sh b/check-migrations.sh new file mode 100755 index 000000000..329a0eb12 --- /dev/null +++ b/check-migrations.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail + +DJANGO_SETTINGS_MODULE=pinax.stripe.tests.settings + +django-admin makemigrations --check -v3 --dry-run --noinput pinax_stripe diff --git a/docs/about/history.md b/docs/about/history.md index 3fe7df681..1691b8a09 100644 --- a/docs/about/history.md +++ b/docs/about/history.md @@ -17,3 +17,13 @@ After nearly 200 commits, 13 merged pull requests, and 45 closed issues, `pinax-stripe` was to publish on **December 5, 2015**. Though it's a rename, we are kept the same semantic versioning from `django-stripe-payments` making this release the `3.0.0` release. + +On **November 27, 2021**, after years of use in many different sites, it was decided +to narrow the scope of the package to the parts that actually were getting used. +The package adopted a new name, `pinax-stripe-light` in case someone wants to pick +up the maintainence on the original larger vision for the project. + +Back in **2013** it was [hard forked](https://github.com/dj-stripe/dj-stripe/commit/6fe7b7970f8282e2f5606468f5ac5bc5e226458f) [without attribution](https://github.com/dj-stripe/dj-stripe/commit/6fe7b7970f8282e2f5606468f5ac5bc5e226458f#diff-c693279643b8cd5d248172d9c22cb7cf4ed163a3c98c8a3f69c2717edd3eacb7) (violating the license of this +project) producing dj-stripe. Despite this violation, we recommend considering this +package if you need a fuller package to integrate with Stripe. It has commercial +support and is well-maintained at the time of writing this. diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index 9f2f1dbf5..42bf3b400 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -1,5 +1,14 @@ # Release Notes +## 5.0.0 - 2021-11-27 - pinax-stripe-light + +* Renamed package to `pinax-stripe-light` +* Dropped most models and all actions, retaining only templatetags and the webhook integration pieces +* Added a script to generate webhook handlers +* Added webhook verification using signature header +* Updated packaging and CI + + ## 4.4.0 - 2018-08-04 * Pin `python-stripe` to `>2.0` after the merge of [PR 574](https://github.com/pinax/pinax-stripe/pull/574) which fixed compatibility. [PR 581](https://github.com/pinax/pinax-stripe/pull/581) diff --git a/docs/index.md b/docs/index.md index 75f845347..1c2aca7ad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,9 @@ -# Pinax Stripe Documentation +# Pinax Stripe (Light) Documentation [Pinax](http://pinaxproject.com/pinax/) is an open source ecosystem of reusable Django apps, themes, and starter project templates. -As a reusable Django app, `pinax-stripe` provides the ecosystem with +As a reusable Django app, `pinax-stripe-light` provides the ecosystem with a well tested, documented, and proven Stripe integration story for any site that needs payments. @@ -15,4 +15,4 @@ need anything at all. If you think you encountering a bug either in the code, or in the docs (after all if something is not clear in the docs, then it should be considered a -bug in the documentation, most of the time), then please [file an issue](http://github.com/pinax/pinax-stripe/issues/) with the project. +bug in the documentation, most of the time), then please [file an issue](http://github.com/pinax/pinax-stripe-light/issues/) with the project or [ask a question](http://github.com/pinax/pinax-stripe-light/discussions/). diff --git a/docs/reference/urls.md b/docs/reference/urls.md index 47aed7adc..be572e21e 100644 --- a/docs/reference/urls.md +++ b/docs/reference/urls.md @@ -2,9 +2,9 @@ Default URLs are provided for basic management of subscriptions, payment methods and payment history. -``` +```python # urls.py -url(r"^payments/", include("payments.urls")), +url(r"^payments/", include("pinax.stripe.urls")), ``` You many want to customize urls or override them according to the needs of your application. diff --git a/docs/reference/webhooks.md b/docs/reference/webhooks.md index a589e3664..e13728e1b 100644 --- a/docs/reference/webhooks.md +++ b/docs/reference/webhooks.md @@ -19,8 +19,8 @@ From there click on add endpoint button and add the full url: ![](images/webhooks-add-url.png) -`pinax-stripe` ships with a webhook view and all the code necessary to process -and store events sent to your webhook. If you install the `pinax-stripe` urls +`pinax-stripe-light` ships with a webhook view and all the code necessary to process +and store events sent to your webhook. If you install the `pinax-stripe-light` urls like so: ```python @@ -34,17 +34,15 @@ pictured above is: ## Security -Since this is a wide open URL we do not want to record and react to any data -sent our way. Therefore, we actually record the data that is sent, but then -before processing it, we validate it against the Stripe API. If it validates -as untampered data, then we continue the processing. +Security is handled through signature verification of the webhook. Stripe sends +a header that is passed along with the data and a shared secret to a function in +the stripe library to verify the payload. It is only recorded and processed if +it passes verification. -If validation fails, then `Event.valid` will be set to `False` enabling at -least some data to try and hunt down any malicious activity. ## Signals -`pinax-stripe` handles certain events in the webhook processing that are +`pinax-stripe-light` handles certain events in the webhook processing that are important for certain operations like syncing data or deleting cards. Every event, though, has a corresponding signal that is sent, so you can hook into these events in your project. See [the signals reference](signals.md) for @@ -52,69 +50,182 @@ details on how to wire those up. ## Events -* `account.updated` - Occurs whenever an account status or property has changed. -* `account.application.deauthorized` - Occurs whenever a user deauthorizes an application. Sent to the related application only. -* `account.external_account.created` - Occurs whenever an external account is created. -* `account.external_account.deleted` - Occurs whenever an external account is deleted. -* `account.external_account.updated` - Occurs whenever an external account is updated. -* `application_fee.created` - Occurs whenever an application fee is created on a charge. -* `application_fee.refunded` - Occurs whenever an application fee is refunded, whether from refunding a charge or from refunding the application fee directly, including partial refunds. -* `application_fee.refund.updated` - Occurs whenever an application fee refund is updated. -* `balance.available` - Occurs whenever your Stripe balance has been updated (e.g. when a charge collected is available to be paid out). By default, Stripe will automatically transfer any funds in your balance to your bank account on a daily basis. -* `bitcoin.receiver.created` - Occurs whenever a receiver has been created. -* `bitcoin.receiver.filled` - Occurs whenever a receiver is filled (that is, when it has received enough bitcoin to process a payment of the same amount). -* `bitcoin.receiver.updated` - Occurs whenever a receiver is updated. -* `bitcoin.receiver.transaction.created` - Occurs whenever bitcoin is pushed to a receiver. -* `charge.captured` - Occurs whenever a previously uncaptured charge is captured. -* `charge.failed` - Occurs whenever a failed charge attempt occurs. -* `charge.refunded` - Occurs whenever a charge is refunded, including partial refunds. -* `charge.succeeded` - Occurs whenever a new charge is created and is successful. -* `charge.updated` - Occurs whenever a charge description or metadata is updated. -* `charge.dispute.closed` - Occurs when the dispute is resolved and the dispute status changes to won or lost. -* `charge.dispute.created` - Occurs whenever a customer disputes a charge with their bank (chargeback). -* `charge.dispute.funds_reinstated` - Occurs when funds are reinstated to your account after a dispute is won. -* `charge.dispute.funds_withdrawn` - Occurs when funds are removed from your account due to a dispute. -* `charge.dispute.updated` - Occurs when the dispute is updated (usually with evidence). -* `coupon.created` - Occurs whenever a coupon is created. -* `coupon.deleted` - Occurs whenever a coupon is deleted. -* `coupon.updated` - Occurs whenever a coupon is updated. -* `customer.created` - Occurs whenever a new customer is created. -* `customer.deleted` - Occurs whenever a customer is deleted. -* `customer.updated` - Occurs whenever any property of a customer changes. -* `customer.discount.created` - Occurs whenever a coupon is attached to a customer. -* `customer.discount.deleted` - Occurs whenever a customer's discount is removed. -* `customer.discount.updated` - Occurs whenever a customer is switched from one coupon to another. -* `customer.source.created` - Occurs whenever a new source is created for the customer. -* `customer.source.deleted` - Occurs whenever a source is removed from a customer. -* `customer.source.updated` - Occurs whenever a source's details are changed. -* `customer.subscription.created` - Occurs whenever a customer with no subscription is signed up for a plan. -* `customer.subscription.deleted` - Occurs whenever a customer ends their subscription. -* `customer.subscription.trial_will_end` - Occurs three days before the trial period of a subscription is scheduled to end. -* `customer.subscription.updated` - Occurs whenever a subscription changes. Examples would include switching from one plan to another, or switching status from trial to active. -* `invoice.created` - Occurs whenever a new invoice is created. If you are using webhooks, Stripe will wait one hour after they have all succeeded to attempt to pay the invoice; the only exception here is on the first invoice, which gets created and paid immediately when you subscribe a customer to a plan. If your webhooks do not all respond successfully, Stripe will continue retrying the webhooks every hour and will not attempt to pay the invoice. After 3 days, Stripe will attempt to pay the invoice regardless of whether or not your webhooks have succeeded. See how to respond to a webhook. -* `invoice.payment_failed` - Occurs whenever an invoice attempts to be paid, and the payment fails. This can occur either due to a declined payment, or because the customer has no active card. A particular case of note is that if a customer with no active card reaches the end of its free trial, an invoice.payment_failed notification will occur. -* `invoice.payment_succeeded` - Occurs whenever an invoice attempts to be paid, and the payment succeeds. -* `invoice.updated` - Occurs whenever an invoice changes (for example, the amount could change). -* `invoiceitem.created` - Occurs whenever an invoice item is created. -* `invoiceitem.deleted` - Occurs whenever an invoice item is deleted. -* `invoiceitem.updated` - Occurs whenever an invoice item is updated. -* `order.created` - Occurs whenever an order is created. -* `order.payment_failed` - Occurs whenever payment is attempted on an order, and the payment fails. -* `order.payment_succeeded` - Occurs whenever payment is attempted on an order, and the payment succeeds. -* `order.updated` - Occurs whenever an order is updated. -* `plan.created` - Occurs whenever a plan is created. -* `plan.deleted` - Occurs whenever a plan is deleted. -* `plan.updated` - Occurs whenever a plan is updated. -* `product.created` - Occurs whenever a product is created. -* `product.updated` - Occurs whenever a product is updated. -* `recipient.created` - Occurs whenever a recipient is created. -* `recipient.deleted` - Occurs whenever a recipient is deleted. -* `recipient.updated` - Occurs whenever a recipient is updated. -* `sku.created` - Occurs whenever a SKU is created. -* `sku.updated` - Occurs whenever a SKU is updated. -* `transfer.created` - Occurs whenever a new transfer is created. -* `transfer.failed` - Occurs whenever Stripe attempts to send a transfer and that transfer fails. -* `transfer.paid` - Occurs whenever a sent transfer is expected to be available in the destination bank account. If the transfer failed, a transfer.failed webhook will additionally be sent at a later time. Note to Connect users: this event is only created for transfers from your connected Stripe accounts to their bank accounts, not for transfers to the connected accounts themselves. -* `transfer.reversed` - Occurs whenever a transfer is reversed, including partial reversals. -* `transfer.updated` - Occurs whenever the description or metadata of a transfer is updated. -* `ping` - May be sent by Stripe at any time to see if a provided webhook URL is working. +These classes are found in `pinax.stripe.webhooks.*`: + +* `AccountUpdatedWebhook` - `account.updated` - Occurs whenever an account status or property has changed. +* `AccountApplicationAuthorizedWebhook` - `account.application.authorized` - Occurs whenever a user authorizes an application. Sent to the related application only. +* `AccountApplicationDeauthorizedWebhook` - `account.application.deauthorized` - Occurs whenever a user deauthorizes an application. Sent to the related application only. +* `AccountExternalAccountCreatedWebhook` - `account.external_account.created` - Occurs whenever an external account is created. +* `AccountExternalAccountDeletedWebhook` - `account.external_account.deleted` - Occurs whenever an external account is deleted. +* `AccountExternalAccountUpdatedWebhook` - `account.external_account.updated` - Occurs whenever an external account is updated. +* `ApplicationFeeCreatedWebhook` - `application_fee.created` - Occurs whenever an application fee is created on a charge. +* `ApplicationFeeRefundedWebhook` - `application_fee.refunded` - Occurs whenever an application fee is refunded, whether from refunding a charge or from refunding the application fee directly. This includes partial refunds. +* `ApplicationFeeRefundUpdatedWebhook` - `application_fee.refund.updated` - Occurs whenever an application fee refund is updated. +* `BalanceAvailableWebhook` - `balance.available` - Occurs whenever your Stripe balance has been updated (e.g., when a charge is available to be paid out). By default, Stripe automatically transfers funds in your balance to your bank account on a daily basis. +* `BillingPortalConfigurationCreatedWebhook` - `billing_portal.configuration.created` - Occurs whenever a portal configuration is created. +* `BillingPortalConfigurationUpdatedWebhook` - `billing_portal.configuration.updated` - Occurs whenever a portal configuration is updated. +* `CapabilityUpdatedWebhook` - `capability.updated` - Occurs whenever a capability has new requirements or a new status. +* `ChargeCapturedWebhook` - `charge.captured` - Occurs whenever a previously uncaptured charge is captured. +* `ChargeExpiredWebhook` - `charge.expired` - Occurs whenever an uncaptured charge expires. +* `ChargeFailedWebhook` - `charge.failed` - Occurs whenever a failed charge attempt occurs. +* `ChargePendingWebhook` - `charge.pending` - Occurs whenever a pending charge is created. +* `ChargeRefundedWebhook` - `charge.refunded` - Occurs whenever a charge is refunded, including partial refunds. +* `ChargeSucceededWebhook` - `charge.succeeded` - Occurs whenever a new charge is created and is successful. +* `ChargeUpdatedWebhook` - `charge.updated` - Occurs whenever a charge description or metadata is updated. +* `ChargeDisputeClosedWebhook` - `charge.dispute.closed` - Occurs when a dispute is closed and the dispute status changes to lost, warning_closed, or won. +* `ChargeDisputeCreatedWebhook` - `charge.dispute.created` - Occurs whenever a customer disputes a charge with their bank. +* `ChargeDisputeFundsReinstatedWebhook` - `charge.dispute.funds_reinstated` - Occurs when funds are reinstated to your account after a dispute is closed. This includes partially refunded payments. +* `ChargeDisputeFundsWithdrawnWebhook` - `charge.dispute.funds_withdrawn` - Occurs when funds are removed from your account due to a dispute. +* `ChargeDisputeUpdatedWebhook` - `charge.dispute.updated` - Occurs when the dispute is updated (usually with evidence). +* `ChargeRefundUpdatedWebhook` - `charge.refund.updated` - Occurs whenever a refund is updated, on selected payment methods. +* `CheckoutSessionAsyncPaymentFailedWebhook` - `checkout.session.async_payment_failed` - Occurs when a payment intent using a delayed payment method fails. +* `CheckoutSessionAsyncPaymentSucceededWebhook` - `checkout.session.async_payment_succeeded` - Occurs when a payment intent using a delayed payment method finally succeeds. +* `CheckoutSessionCompletedWebhook` - `checkout.session.completed` - Occurs when a Checkout Session has been successfully completed. +* `CheckoutSessionExpiredWebhook` - `checkout.session.expired` - Occurs when a Checkout Session is expired. +* `CouponCreatedWebhook` - `coupon.created` - Occurs whenever a coupon is created. +* `CouponDeletedWebhook` - `coupon.deleted` - Occurs whenever a coupon is deleted. +* `CouponUpdatedWebhook` - `coupon.updated` - Occurs whenever a coupon is updated. +* `CreditNoteCreatedWebhook` - `credit_note.created` - Occurs whenever a credit note is created. +* `CreditNoteUpdatedWebhook` - `credit_note.updated` - Occurs whenever a credit note is updated. +* `CreditNoteVoidedWebhook` - `credit_note.voided` - Occurs whenever a credit note is voided. +* `CustomerCreatedWebhook` - `customer.created` - Occurs whenever a new customer is created. +* `CustomerDeletedWebhook` - `customer.deleted` - Occurs whenever a customer is deleted. +* `CustomerUpdatedWebhook` - `customer.updated` - Occurs whenever any property of a customer changes. +* `CustomerDiscountCreatedWebhook` - `customer.discount.created` - Occurs whenever a coupon is attached to a customer. +* `CustomerDiscountDeletedWebhook` - `customer.discount.deleted` - Occurs whenever a coupon is removed from a customer. +* `CustomerDiscountUpdatedWebhook` - `customer.discount.updated` - Occurs whenever a customer is switched from one coupon to another. +* `CustomerSourceCreatedWebhook` - `customer.source.created` - Occurs whenever a new source is created for a customer. +* `CustomerSourceDeletedWebhook` - `customer.source.deleted` - Occurs whenever a source is removed from a customer. +* `CustomerSourceExpiringWebhook` - `customer.source.expiring` - Occurs whenever a card or source will expire at the end of the month. +* `CustomerSourceUpdatedWebhook` - `customer.source.updated` - Occurs whenever a source's details are changed. +* `CustomerSubscriptionCreatedWebhook` - `customer.subscription.created` - Occurs whenever a customer is signed up for a new plan. +* `CustomerSubscriptionDeletedWebhook` - `customer.subscription.deleted` - Occurs whenever a customer's subscription ends. +* `CustomerSubscriptionPendingUpdateAppliedWebhook` - `customer.subscription.pending_update_applied` - Occurs whenever a customer's subscription's pending update is applied, and the subscription is updated. +* `CustomerSubscriptionPendingUpdateExpiredWebhook` - `customer.subscription.pending_update_expired` - Occurs whenever a customer's subscription's pending update expires before the related invoice is paid. +* `CustomerSubscriptionTrialWillEndWebhook` - `customer.subscription.trial_will_end` - Occurs three days before a subscription's trial period is scheduled to end, or when a trial is ended immediately (using trial_end=now). +* `CustomerSubscriptionUpdatedWebhook` - `customer.subscription.updated` - Occurs whenever a subscription changes (e.g., switching from one plan to another, or changing the status from trial to active). +* `CustomerTaxIdCreatedWebhook` - `customer.tax_id.created` - Occurs whenever a tax ID is created for a customer. +* `CustomerTaxIdDeletedWebhook` - `customer.tax_id.deleted` - Occurs whenever a tax ID is deleted from a customer. +* `CustomerTaxIdUpdatedWebhook` - `customer.tax_id.updated` - Occurs whenever a customer's tax ID is updated. +* `FileCreatedWebhook` - `file.created` - Occurs whenever a new Stripe-generated file is available for your account. +* `IdentityVerificationSessionCanceledWebhook` - `identity.verification_session.canceled` - Occurs whenever a VerificationSession is canceled +* `IdentityVerificationSessionCreatedWebhook` - `identity.verification_session.created` - Occurs whenever a VerificationSession is created +* `IdentityVerificationSessionProcessingWebhook` - `identity.verification_session.processing` - Occurs whenever a VerificationSession transitions to processing +* `IdentityVerificationSessionRedactedWebhook` - `identity.verification_session.redacted` - Occurs whenever a VerificationSession is redacted. +* `IdentityVerificationSessionRequiresInputWebhook` - `identity.verification_session.requires_input` - Occurs whenever a VerificationSession transitions to require user input +* `IdentityVerificationSessionVerifiedWebhook` - `identity.verification_session.verified` - Occurs whenever a VerificationSession transitions to verified +* `InvoiceCreatedWebhook` - `invoice.created` - Occurs whenever a new invoice is created. To learn how webhooks can be used with this event, and how they can affect it, see Using Webhooks with Subscriptions. +* `InvoiceDeletedWebhook` - `invoice.deleted` - Occurs whenever a draft invoice is deleted. +* `InvoiceFinalizationFailedWebhook` - `invoice.finalization_failed` - Occurs whenever a draft invoice cannot be finalized. See the invoice’s last finalization error for details. +* `InvoiceFinalizedWebhook` - `invoice.finalized` - Occurs whenever a draft invoice is finalized and updated to be an open invoice. +* `InvoiceMarkedUncollectibleWebhook` - `invoice.marked_uncollectible` - Occurs whenever an invoice is marked uncollectible. +* `InvoicePaidWebhook` - `invoice.paid` - Occurs whenever an invoice payment attempt succeeds or an invoice is marked as paid out-of-band. +* `InvoicePaymentActionRequiredWebhook` - `invoice.payment_action_required` - Occurs whenever an invoice payment attempt requires further user action to complete. +* `InvoicePaymentFailedWebhook` - `invoice.payment_failed` - Occurs whenever an invoice payment attempt fails, due either to a declined payment or to the lack of a stored payment method. +* `InvoicePaymentSucceededWebhook` - `invoice.payment_succeeded` - Occurs whenever an invoice payment attempt succeeds. +* `InvoiceSentWebhook` - `invoice.sent` - Occurs whenever an invoice email is sent out. +* `InvoiceUpcomingWebhook` - `invoice.upcoming` - Occurs X number of days before a subscription is scheduled to create an invoice that is automatically charged—where X is determined by your subscriptions settings. Note: The received Invoice object will not have an invoice ID. +* `InvoiceUpdatedWebhook` - `invoice.updated` - Occurs whenever an invoice changes (e.g., the invoice amount). +* `InvoiceVoidedWebhook` - `invoice.voided` - Occurs whenever an invoice is voided. +* `InvoiceitemCreatedWebhook` - `invoiceitem.created` - Occurs whenever an invoice item is created. +* `InvoiceitemDeletedWebhook` - `invoiceitem.deleted` - Occurs whenever an invoice item is deleted. +* `InvoiceitemUpdatedWebhook` - `invoiceitem.updated` - Occurs whenever an invoice item is updated. +* `IssuingAuthorizationCreatedWebhook` - `issuing_authorization.created` - Occurs whenever an authorization is created. +* `IssuingAuthorizationRequestWebhook` - `issuing_authorization.request` - Represents a synchronous request for authorization, see Using your integration to handle authorization requests. +* `IssuingAuthorizationUpdatedWebhook` - `issuing_authorization.updated` - Occurs whenever an authorization is updated. +* `IssuingCardCreatedWebhook` - `issuing_card.created` - Occurs whenever a card is created. +* `IssuingCardUpdatedWebhook` - `issuing_card.updated` - Occurs whenever a card is updated. +* `IssuingCardholderCreatedWebhook` - `issuing_cardholder.created` - Occurs whenever a cardholder is created. +* `IssuingCardholderUpdatedWebhook` - `issuing_cardholder.updated` - Occurs whenever a cardholder is updated. +* `IssuingDisputeClosedWebhook` - `issuing_dispute.closed` - Occurs whenever a dispute is won, lost or expired. +* `IssuingDisputeCreatedWebhook` - `issuing_dispute.created` - Occurs whenever a dispute is created. +* `IssuingDisputeFundsReinstatedWebhook` - `issuing_dispute.funds_reinstated` - Occurs whenever funds are reinstated to your account for an Issuing dispute. +* `IssuingDisputeSubmittedWebhook` - `issuing_dispute.submitted` - Occurs whenever a dispute is submitted. +* `IssuingDisputeUpdatedWebhook` - `issuing_dispute.updated` - Occurs whenever a dispute is updated. +* `IssuingTransactionCreatedWebhook` - `issuing_transaction.created` - Occurs whenever an issuing transaction is created. +* `IssuingTransactionUpdatedWebhook` - `issuing_transaction.updated` - Occurs whenever an issuing transaction is updated. +* `MandateUpdatedWebhook` - `mandate.updated` - Occurs whenever a Mandate is updated. +* `OrderCreatedWebhook` - `order.created` - Occurs whenever an order is created. +* `OrderPaymentFailedWebhook` - `order.payment_failed` - Occurs whenever an order payment attempt fails. +* `OrderPaymentSucceededWebhook` - `order.payment_succeeded` - Occurs whenever an order payment attempt succeeds. +* `OrderUpdatedWebhook` - `order.updated` - Occurs whenever an order is updated. +* `OrderReturnCreatedWebhook` - `order_return.created` - Occurs whenever an order return is created. +* `PaymentIntentAmountCapturableUpdatedWebhook` - `payment_intent.amount_capturable_updated` - Occurs when a PaymentIntent has funds to be captured. Check the amount_capturable property on the PaymentIntent to determine the amount that can be captured. You may capture the PaymentIntent with an amount_to_capture value up to the specified amount. Learn more about capturing PaymentIntents. +* `PaymentIntentCanceledWebhook` - `payment_intent.canceled` - Occurs when a PaymentIntent is canceled. +* `PaymentIntentCreatedWebhook` - `payment_intent.created` - Occurs when a new PaymentIntent is created. +* `PaymentIntentPaymentFailedWebhook` - `payment_intent.payment_failed` - Occurs when a PaymentIntent has failed the attempt to create a payment method or a payment. +* `PaymentIntentProcessingWebhook` - `payment_intent.processing` - Occurs when a PaymentIntent has started processing. +* `PaymentIntentRequiresActionWebhook` - `payment_intent.requires_action` - Occurs when a PaymentIntent transitions to requires_action state +* `PaymentIntentSucceededWebhook` - `payment_intent.succeeded` - Occurs when a PaymentIntent has successfully completed payment. +* `PaymentMethodAttachedWebhook` - `payment_method.attached` - Occurs whenever a new payment method is attached to a customer. +* `PaymentMethodAutomaticallyUpdatedWebhook` - `payment_method.automatically_updated` - Occurs whenever a payment method's details are automatically updated by the network. +* `PaymentMethodDetachedWebhook` - `payment_method.detached` - Occurs whenever a payment method is detached from a customer. +* `PaymentMethodUpdatedWebhook` - `payment_method.updated` - Occurs whenever a payment method is updated via the PaymentMethod update API. +* `PayoutCanceledWebhook` - `payout.canceled` - Occurs whenever a payout is canceled. +* `PayoutCreatedWebhook` - `payout.created` - Occurs whenever a payout is created. +* `PayoutFailedWebhook` - `payout.failed` - Occurs whenever a payout attempt fails. +* `PayoutPaidWebhook` - `payout.paid` - Occurs whenever a payout is expected to be available in the destination account. If the payout fails, a payout.failed notification is also sent, at a later time. +* `PayoutUpdatedWebhook` - `payout.updated` - Occurs whenever a payout is updated. +* `PersonCreatedWebhook` - `person.created` - Occurs whenever a person associated with an account is created. +* `PersonDeletedWebhook` - `person.deleted` - Occurs whenever a person associated with an account is deleted. +* `PersonUpdatedWebhook` - `person.updated` - Occurs whenever a person associated with an account is updated. +* `PlanCreatedWebhook` - `plan.created` - Occurs whenever a plan is created. +* `PlanDeletedWebhook` - `plan.deleted` - Occurs whenever a plan is deleted. +* `PlanUpdatedWebhook` - `plan.updated` - Occurs whenever a plan is updated. +* `PriceCreatedWebhook` - `price.created` - Occurs whenever a price is created. +* `PriceDeletedWebhook` - `price.deleted` - Occurs whenever a price is deleted. +* `PriceUpdatedWebhook` - `price.updated` - Occurs whenever a price is updated. +* `ProductCreatedWebhook` - `product.created` - Occurs whenever a product is created. +* `ProductDeletedWebhook` - `product.deleted` - Occurs whenever a product is deleted. +* `ProductUpdatedWebhook` - `product.updated` - Occurs whenever a product is updated. +* `PromotionCodeCreatedWebhook` - `promotion_code.created` - Occurs whenever a promotion code is created. +* `PromotionCodeUpdatedWebhook` - `promotion_code.updated` - Occurs whenever a promotion code is updated. +* `QuoteAcceptedWebhook` - `quote.accepted` - Occurs whenever a quote is accepted. +* `QuoteCanceledWebhook` - `quote.canceled` - Occurs whenever a quote is canceled. +* `QuoteCreatedWebhook` - `quote.created` - Occurs whenever a quote is created. +* `QuoteFinalizedWebhook` - `quote.finalized` - Occurs whenever a quote is finalized. +* `RadarEarlyFraudWarningCreatedWebhook` - `radar.early_fraud_warning.created` - Occurs whenever an early fraud warning is created. +* `RadarEarlyFraudWarningUpdatedWebhook` - `radar.early_fraud_warning.updated` - Occurs whenever an early fraud warning is updated. +* `RecipientCreatedWebhook` - `recipient.created` - Occurs whenever a recipient is created. +* `RecipientDeletedWebhook` - `recipient.deleted` - Occurs whenever a recipient is deleted. +* `RecipientUpdatedWebhook` - `recipient.updated` - Occurs whenever a recipient is updated. +* `ReportingReportRunFailedWebhook` - `reporting.report_run.failed` - Occurs whenever a requested ReportRun failed to complete. +* `ReportingReportRunSucceededWebhook` - `reporting.report_run.succeeded` - Occurs whenever a requested ReportRun completed succesfully. +* `ReportingReportTypeUpdatedWebhook` - `reporting.report_type.updated` - Occurs whenever a ReportType is updated (typically to indicate that a new day's data has come available). +* `ReviewClosedWebhook` - `review.closed` - Occurs whenever a review is closed. The review's reason field indicates why: approved, disputed, refunded, or refunded_as_fraud. +* `ReviewOpenedWebhook` - `review.opened` - Occurs whenever a review is opened. +* `SetupIntentCanceledWebhook` - `setup_intent.canceled` - Occurs when a SetupIntent is canceled. +* `SetupIntentCreatedWebhook` - `setup_intent.created` - Occurs when a new SetupIntent is created. +* `SetupIntentRequiresActionWebhook` - `setup_intent.requires_action` - Occurs when a SetupIntent is in requires_action state. +* `SetupIntentSetupFailedWebhook` - `setup_intent.setup_failed` - Occurs when a SetupIntent has failed the attempt to setup a payment method. +* `SetupIntentSucceededWebhook` - `setup_intent.succeeded` - Occurs when an SetupIntent has successfully setup a payment method. +* `SigmaScheduledQueryRunCreatedWebhook` - `sigma.scheduled_query_run.created` - Occurs whenever a Sigma scheduled query run finishes. +* `SkuCreatedWebhook` - `sku.created` - Occurs whenever a SKU is created. +* `SkuDeletedWebhook` - `sku.deleted` - Occurs whenever a SKU is deleted. +* `SkuUpdatedWebhook` - `sku.updated` - Occurs whenever a SKU is updated. +* `SourceCanceledWebhook` - `source.canceled` - Occurs whenever a source is canceled. +* `SourceChargeableWebhook` - `source.chargeable` - Occurs whenever a source transitions to chargeable. +* `SourceFailedWebhook` - `source.failed` - Occurs whenever a source fails. +* `SourceMandateNotificationWebhook` - `source.mandate_notification` - Occurs whenever a source mandate notification method is set to manual. +* `SourceRefundAttributesRequiredWebhook` - `source.refund_attributes_required` - Occurs whenever the refund attributes are required on a receiver source to process a refund or a mispayment. +* `SourceTransactionCreatedWebhook` - `source.transaction.created` - Occurs whenever a source transaction is created. +* `SourceTransactionUpdatedWebhook` - `source.transaction.updated` - Occurs whenever a source transaction is updated. +* `SubscriptionScheduleAbortedWebhook` - `subscription_schedule.aborted` - Occurs whenever a subscription schedule is canceled due to the underlying subscription being canceled because of delinquency. +* `SubscriptionScheduleCanceledWebhook` - `subscription_schedule.canceled` - Occurs whenever a subscription schedule is canceled. +* `SubscriptionScheduleCompletedWebhook` - `subscription_schedule.completed` - Occurs whenever a new subscription schedule is completed. +* `SubscriptionScheduleCreatedWebhook` - `subscription_schedule.created` - Occurs whenever a new subscription schedule is created. +* `SubscriptionScheduleExpiringWebhook` - `subscription_schedule.expiring` - Occurs 7 days before a subscription schedule will expire. +* `SubscriptionScheduleReleasedWebhook` - `subscription_schedule.released` - Occurs whenever a new subscription schedule is released. +* `SubscriptionScheduleUpdatedWebhook` - `subscription_schedule.updated` - Occurs whenever a subscription schedule is updated. +* `TaxRateCreatedWebhook` - `tax_rate.created` - Occurs whenever a new tax rate is created. +* `TaxRateUpdatedWebhook` - `tax_rate.updated` - Occurs whenever a tax rate is updated. +* `TopupCanceledWebhook` - `topup.canceled` - Occurs whenever a top-up is canceled. +* `TopupCreatedWebhook` - `topup.created` - Occurs whenever a top-up is created. +* `TopupFailedWebhook` - `topup.failed` - Occurs whenever a top-up fails. +* `TopupReversedWebhook` - `topup.reversed` - Occurs whenever a top-up is reversed. +* `TopupSucceededWebhook` - `topup.succeeded` - Occurs whenever a top-up succeeds. +* `TransferCreatedWebhook` - `transfer.created` - Occurs whenever a transfer is created. +* `TransferFailedWebhook` - `transfer.failed` - Occurs whenever a transfer failed. +* `TransferPaidWebhook` - `transfer.paid` - Occurs after a transfer is paid. For Instant Payouts, the event will typically be sent within 30 minutes. +* `TransferReversedWebhook` - `transfer.reversed` - Occurs whenever a transfer is reversed, including partial reversals. +* `TransferUpdatedWebhook` - `transfer.updated` - Occurs whenever a transfer's description or metadata is updated. diff --git a/tox.ini b/tox.ini index 25ebd4830..c84904461 100644 --- a/tox.ini +++ b/tox.ini @@ -14,50 +14,3 @@ data_file = .coverage [coverage:report] omit = pinax/stripe/conf.py,pinax/stripe/tests/*,pinax/stripe/migrations/* show_missing = True - -[tox] -envlist = - checkqa - py35-dj{22} - py36-dj{22,3} - py37-dj{22,3} - py38-dj{22,3} - -[testenv] -extras = testing -passenv = - CI CIRCLECI CIRCLE_* - PINAX_STRIPE_DATABASE_ENGINE - PINAX_STRIPE_DATABASE_HOST - PINAX_STRIPE_DATABASE_NAME - PINAX_STRIPE_DATABASE_USER -deps = - pytest - pytest-django - coverage: pytest-cov - dj22: Django>=2.2,<3 - dj3: Django>=3.0,<3.1 - master: https://github.com/django/django/tarball/master - postgres: psycopg2-binary -usedevelop = True -setenv = - DJANGO_SETTINGS_MODULE=pinax.stripe.tests.settings - coverage: _STRIPE_PYTEST_ARGS=--cov --cov-report=term-missing:skip-covered - postgres: PINAX_STRIPE_DATABASE_ENGINE={env:PINAX_STRIPE_DATABASE_ENGINE:django.db.backends.postgresql_psycopg2} -commands = - python -m pytest {env:_STRIPE_PYTEST_ARGS:} {posargs} - -[testenv:checkqa] -commands = - flake8 pinax -deps = - flake8 == 3.7.9 - flake8-isort == 3.0.0 - flake8-quotes == 3.0.0 - -[testenv:check_migrated] -setenv = - DJANGO_SETTINGS_MODULE=pinax.stripe.tests.settings -passenv = -commands = - django-admin makemigrations --check -v3 --dry-run --noinput pinax_stripe diff --git a/update_webhooks.py b/update_webhooks.py index 10338cb80..1cae75a9e 100644 --- a/update_webhooks.py +++ b/update_webhooks.py @@ -39,5 +39,5 @@ fp.write(code.encode("utf-8")) - print(f"{name} added...") + print(f"* `{class_name}Webhook` - `{name}` - {description}") fp.close() From 57954555cdb4aa5a3ee66506e74f55a088e1aa5f Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 19:40:38 -0600 Subject: [PATCH 46/49] Fix ci --- .github/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e9662d7fc..05b7cabe2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,6 @@ jobs: check-migrations: name: Check Migrations runs-on: ubuntu-latest - shell: bash steps: - uses: actions/checkout@v2 From 4ff489e2884d6263c4165679c5155261e76a5cf6 Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 19:42:19 -0600 Subject: [PATCH 47/49] Fix script --- check-migrations.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/check-migrations.sh b/check-migrations.sh index 329a0eb12..e5d6dfcd9 100755 --- a/check-migrations.sh +++ b/check-migrations.sh @@ -1,6 +1,6 @@ #!/bin/bash set -euo pipefail -DJANGO_SETTINGS_MODULE=pinax.stripe.tests.settings +export DJANGO_SETTINGS_MODULE=pinax.stripe.tests.settings django-admin makemigrations --check -v3 --dry-run --noinput pinax_stripe From e6548d96756915905838d395309c7f152b044e7d Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 19:46:14 -0600 Subject: [PATCH 48/49] Update history --- docs/about/history.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/about/history.md b/docs/about/history.md index 1691b8a09..8792b3974 100644 --- a/docs/about/history.md +++ b/docs/about/history.md @@ -23,7 +23,8 @@ to narrow the scope of the package to the parts that actually were getting used. The package adopted a new name, `pinax-stripe-light` in case someone wants to pick up the maintainence on the original larger vision for the project. -Back in **2013** it was [hard forked](https://github.com/dj-stripe/dj-stripe/commit/6fe7b7970f8282e2f5606468f5ac5bc5e226458f) [without attribution](https://github.com/dj-stripe/dj-stripe/commit/6fe7b7970f8282e2f5606468f5ac5bc5e226458f#diff-c693279643b8cd5d248172d9c22cb7cf4ed163a3c98c8a3f69c2717edd3eacb7) (violating the license of this -project) producing dj-stripe. Despite this violation, we recommend considering this +Years ago, it was [hard forked](https://github.com/dj-stripe/dj-stripe/commit/6fe7b7970f8282e2f5606468f5ac5bc5e226458f), violating terms of our license [due to leaving out the attribution](https://github.com/dj-stripe/dj-stripe/commit/6fe7b7970f8282e2f5606468f5ac5bc5e226458f#diff-c693279643b8cd5d248172d9c22cb7cf4ed163a3c98c8a3f69c2717edd3eacb7) producing [dj-stripe](https://github.com/dj-stripe/dj-stripe/). + +Despite this violation, we recommend considering this package if you need a fuller package to integrate with Stripe. It has commercial support and is well-maintained at the time of writing this. From af2723d908070a5dd416f0570cf47b8c34acf69a Mon Sep 17 00:00:00 2001 From: Patrick Altman Date: Sat, 27 Nov 2021 19:51:15 -0600 Subject: [PATCH 49/49] Update settings for checking migrations --- pinax/stripe/tests/settings.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pinax/stripe/tests/settings.py b/pinax/stripe/tests/settings.py index ca9c09447..c8c139c5e 100644 --- a/pinax/stripe/tests/settings.py +++ b/pinax/stripe/tests/settings.py @@ -12,7 +12,8 @@ ROOT_URLCONF = "pinax.stripe.tests.urls" MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", - "django.contrib.messages.middleware.MessageMiddleware" + "django.contrib.messages.middleware.MessageMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", ] INSTALLED_APPS = [ "django.contrib.admin", @@ -28,6 +29,13 @@ PINAX_STRIPE_ENDPOINT_SECRET = "foo" TEMPLATES = [{ "BACKEND": "django.template.backends.django.DjangoTemplates", + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.request", + ] + } }] SECRET_KEY = "pinax-stripe-secret-key" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"