From 6bd1ef16042739ae9cf6e8793475fe743884377e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Dlouh=C3=BD?= Date: Fri, 14 Feb 2020 19:42:09 +0100 Subject: [PATCH 1/5] add common subscription methods --- payments/core.py | 16 +++++++++ payments/models.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/payments/core.py b/payments/core.py index ff33d2aca..434281c6c 100644 --- a/payments/core.py +++ b/payments/core.py @@ -119,6 +119,22 @@ def get_return_url(self, payment, extra_data=None): return url + "?" + qs return url + def autocomplete_with_subscription(self, payment): + """ + Complete the payment with subscription + Used by providers, that use server initiated subscription workflow + + Throws RedirectNeeded if there is problem with the payment that needs to be solved by user + """ + raise NotImplementedError() + + def cancel_subscription(self, subscription): + """ + Cancel subscription + Used by providers, that use provider initiated subscription workflow + """ + raise NotImplementedError() + def capture(self, payment, amount=None): raise NotImplementedError() diff --git a/payments/models.py b/payments/models.py index 98c9a0684..966c6eccb 100644 --- a/payments/models.py +++ b/payments/models.py @@ -1,5 +1,7 @@ +import enum import json from typing import Iterable +from typing import Optional from typing import Union from uuid import uuid4 @@ -36,6 +38,60 @@ def __setattr__(self, key, value): self._payment.extra_data = json.dumps(data) +class BaseSubscription(models.Model): + token = models.CharField( + _("subscription token/id"), + help_text=_("Token/id used to identify subscription by provider"), + max_length=255, + default=None, + null=True, + blank=True, + ) + payment_provider = models.CharField( + _('payment provider'), + help_text=_('Provider variant, that will be used for payment renewal'), + max_length=255, + default=None, + null=True, + blank=True, + ) + + class TimeUnit(enum.Enum): + year = "year" + month = "month" + day = "day" + + def get_token(self) -> str: + return self.token + + def set_recurrence(self, token: str, **kwargs): + """ + Sets token and other values associated with subscription recurrence + Kwargs can contain provider-specific values + """ + self.token = token + + def get_period(self) -> int: + raise NotImplementedError() + + def get_unit(self) -> TimeUnit: + raise NotImplementedError() + + def cancel(self): + """ + Cancel the subscription by provider + Used by providers, that use provider initiated subscription workflow + Implementer is responsible for cancelling the subscription model + + Raises PaymentError if the cancellation didn't pass through + """ + provider = provider_factory(self.variant) + provider.cancel_subscription(self) + + class Meta: + abstract = True + + class BasePayment(models.Model): """ Represents a single transaction. Each instance has one or more PaymentItem. @@ -144,6 +200,33 @@ def get_success_url(self) -> str: def get_process_url(self) -> str: return reverse("process_payment", kwargs={"token": self.token}) + def get_payment_url(self) -> str: + """ + Get the url the view that handles the payment (payment_details() in documentation) + For now used only by PayU provider to redirect users back to CVV2 form + """ + raise NotImplementedError() + + def get_subscription(self) -> Optional[BaseSubscription]: + """ + Returns subscription object associated with this payment + or None if the payment is not recurring + """ + return None + + def is_recurring(self) -> bool: + return self.get_subscription() is not None + + def autocomplete_with_subscription(self): + """ + Complete the payment with subscription + Used by providers, that use server initiated subscription workflow + + Throws RedirectNeeded if there is problem with the payment that needs to be solved by user + """ + provider = provider_factory(self.variant) + provider.autocomplete_with_subscription(self) + def capture(self, amount=None): if self.status != PaymentStatus.PREAUTH: raise ValueError("Only pre-authorized payments can be captured.") From 6de5e33cf8b20404d30e9d27459a00b97c4e7e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Dlouh=C3=BD?= Date: Wed, 27 Oct 2021 16:36:34 +0200 Subject: [PATCH 2/5] add subscribtion support PayPal (not finished) --- payments/paypal/__init__.py | 103 +++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/payments/paypal/__init__.py b/payments/paypal/__init__.py index 8a220c6ff..870a9b327 100644 --- a/payments/paypal/__init__.py +++ b/payments/paypal/__init__.py @@ -63,17 +63,20 @@ class PaypalProvider(BasicProvider): """ def __init__( - self, client_id, secret, endpoint="https://api.sandbox.paypal.com", capture=True + self, client_id, secret, endpoint="https://api.sandbox.paypal.com", capture=True, **kwargs ): self.secret = secret self.client_id = client_id self.endpoint = endpoint self.oauth2_url = self.endpoint + "/v1/oauth2/token" self.payments_url = self.endpoint + "/v1/payments/payment" + self.subscriptions_url = self.endpoint + "/v1/billing/subscriptions" + self.plans_url = self.endpoint + "/v1/billing/plans" self.payment_execute_url = self.payments_url + "/%(id)s/execute/" self.payment_refund_url = ( self.endpoint + "/v1/payments/capture/{captureId}/refund" ) + self.plan_id = kwargs.get('plan_id', None) super().__init__(capture=capture) def set_response_data(self, payment, response, is_auth=False): @@ -113,13 +116,15 @@ def post(self, payment, *args, **kwargs): } if "data" in kwargs: kwargs["data"] = json.dumps(kwargs["data"]) - response = requests.post(*args, **kwargs) + http_method = kwargs.pop('http_method', requests.post) + response = http_method(*args, **kwargs) try: data = response.json() except ValueError: data = {} if 400 <= response.status_code <= 500: - self.set_error_data(payment, data) + if payment: + self.set_error_data(payment, data) logger.debug(data) message = "Paypal error" if response.status_code == 400: @@ -131,12 +136,17 @@ def post(self, payment, *args, **kwargs): message = error_data.get("message", message) else: logger.warning(message, extra={"status_code": response.status_code}) - payment.change_status(PaymentStatus.ERROR, message) + if payment: + payment.change_status(PaymentStatus.ERROR, message) raise PaymentError(message) else: - self.set_response_data(payment, data) + if payment: + self.set_response_data(payment, data) return data + def get(self, payment, *args, **kwargs): + return self.post(payment, http_method=requests.get, *args, **kwargs) + def get_last_response(self, payment, is_auth=False): extra_data = json.loads(payment.extra_data or "{}") if is_auth: @@ -144,8 +154,11 @@ def get_last_response(self, payment, is_auth=False): return extra_data.get("response", {}) def get_access_token(self, payment): - last_auth_response = self.get_last_response(payment, is_auth=True) - created = payment.created + if payment: + last_auth_response = self.get_last_response(payment, is_auth=True) + created = payment.created + else: + last_auth_response = {} now = timezone.now() if ( "access_token" in last_auth_response @@ -167,7 +180,8 @@ def get_access_token(self, payment): response.raise_for_status() data = response.json() last_auth_response.update(data) - self.set_response_data(payment, last_auth_response, is_auth=True) + if payment: + self.set_response_data(payment, last_auth_response, is_auth=True) return "{} {}".format(data["token_type"], data["access_token"]) def get_transactions_items(self, payment): @@ -209,10 +223,13 @@ def get_transactions_data(self, payment): } return data - def get_product_data(self, payment, extra_data=None): + def get_redirect_urls(self, payment): return_url = self.get_return_url(payment) + return {"return_url": return_url, "cancel_url": return_url} + + def get_product_data(self, payment, extra_data=None): data = self.get_transactions_data(payment) - data["redirect_urls"] = {"return_url": return_url, "cancel_url": return_url} + data["redirect_urls"] = self.get_redirect_urls(payment) data["payer"] = {"payment_method": "paypal"} return data @@ -222,10 +239,15 @@ def get_form(self, payment, data=None): links = self._get_links(payment) redirect_to = links.get("approval_url") if not redirect_to: - payment_data = self.create_payment(payment) + if payment.is_recurring(): + payment_data = self.create_subscription(payment) + links = self._get_links(payment) + redirect_to = links["approve"] + else: + payment_data = self.create_payment(payment) + links = self._get_links(payment) + redirect_to = links["approval_url"] payment.transaction_id = payment_data["id"] - links = self._get_links(payment) - redirect_to = links["approval_url"] payment.change_status(PaymentStatus.WAITING) raise RedirectNeeded(redirect_to["href"]) @@ -234,6 +256,8 @@ def process_data(self, payment, request): failure_url = payment.get_failure_url() if "token" not in request.GET: return HttpResponseForbidden("FAILED") + if payment.is_recurring(): + self.process_subscription_data(payment, request) payer_id = request.GET.get("PayerID") if not payer_id: if payment.status != PaymentStatus.CONFIRMED: @@ -254,6 +278,54 @@ def process_data(self, payment, request): payment.change_status(PaymentStatus.PREAUTH) return redirect(success_url) + def process_subscription_data(self, payment, request): + success_url = payment.get_success_url() + failure_url = payment.get_failure_url() + subscription_id = request.GET.get("subscription_id") + if not subscription_id: + if payment.status != PaymentStatus.CONFIRMED: + payment.change_status(PaymentStatus.REJECTED) + return redirect(failure_url) + else: + return redirect(success_url) + try: + subscription_data = self.get_subscription(payment) + except PaymentError: + return redirect(failure_url) + if subscription_data['status'] == 'ACTIVE': + payment.captured_amount = payment.total + payment.change_status(PaymentStatus.CONFIRMED) + return redirect(success_url) + payment.change_status(PaymentStatus.REJECTED) + return redirect(failure_url) + + def create_subscription(self, payment, extra_data=None): + redirect_urls = self.get_redirect_urls(payment) + if callable(self.plan_id): + plan_id = self.plan_id(payment) + else: + plan_id = self.plan_id + plan_data = { + "plan_id": plan_id, + "application_context": { + 'shipping_preference': 'NO_SHIPPING', + "user_action": "SUBSCRIBE_NOW", + "payment_method": { + "payer_selected": "PAYPAL", + "payee_preferred": "IMMEDIATE_PAYMENT_REQUIRED" + }, + **redirect_urls, + } + } + payment_data = self.post(payment, self.subscriptions_url, data=plan_data) + subscription = payment.get_subscription() + subscription.set_recurrence(payment_data['id']) + return payment_data + + def cancel_subscription(self, subscription): + subscription_id = subscription.get_token() + self.post(None, f"{self.subscriptions_url}/{subscription_id}/cancel") + def create_payment(self, payment, extra_data=None): product_data = self.get_product_data(payment, extra_data) payment = self.post(payment, self.payments_url, data=product_data) @@ -265,6 +337,11 @@ def execute_payment(self, payment, payer_id): execute_url = links["execute"]["href"] return self.post(payment, execute_url, data=post) + def get_subscription(self, payment): + links = self._get_links(payment) + execute_url = links["edit"]["href"] + return self.get(payment, execute_url) + def get_amount_data(self, payment, amount=None): return { "currency": payment.currency, From 98aa87bb10fa5f7090b3900beac87f933db84c47 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 Oct 2021 14:59:58 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- payments/models.py | 4 ++-- payments/paypal/__init__.py | 21 +++++++++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/payments/models.py b/payments/models.py index 966c6eccb..3330ff2f8 100644 --- a/payments/models.py +++ b/payments/models.py @@ -48,8 +48,8 @@ class BaseSubscription(models.Model): blank=True, ) payment_provider = models.CharField( - _('payment provider'), - help_text=_('Provider variant, that will be used for payment renewal'), + _("payment provider"), + help_text=_("Provider variant, that will be used for payment renewal"), max_length=255, default=None, null=True, diff --git a/payments/paypal/__init__.py b/payments/paypal/__init__.py index 870a9b327..6e94b4896 100644 --- a/payments/paypal/__init__.py +++ b/payments/paypal/__init__.py @@ -63,7 +63,12 @@ class PaypalProvider(BasicProvider): """ def __init__( - self, client_id, secret, endpoint="https://api.sandbox.paypal.com", capture=True, **kwargs + self, + client_id, + secret, + endpoint="https://api.sandbox.paypal.com", + capture=True, + **kwargs, ): self.secret = secret self.client_id = client_id @@ -76,7 +81,7 @@ def __init__( self.payment_refund_url = ( self.endpoint + "/v1/payments/capture/{captureId}/refund" ) - self.plan_id = kwargs.get('plan_id', None) + self.plan_id = kwargs.get("plan_id", None) super().__init__(capture=capture) def set_response_data(self, payment, response, is_auth=False): @@ -116,7 +121,7 @@ def post(self, payment, *args, **kwargs): } if "data" in kwargs: kwargs["data"] = json.dumps(kwargs["data"]) - http_method = kwargs.pop('http_method', requests.post) + http_method = kwargs.pop("http_method", requests.post) response = http_method(*args, **kwargs) try: data = response.json() @@ -292,7 +297,7 @@ def process_subscription_data(self, payment, request): subscription_data = self.get_subscription(payment) except PaymentError: return redirect(failure_url) - if subscription_data['status'] == 'ACTIVE': + if subscription_data["status"] == "ACTIVE": payment.captured_amount = payment.total payment.change_status(PaymentStatus.CONFIRMED) return redirect(success_url) @@ -308,18 +313,18 @@ def create_subscription(self, payment, extra_data=None): plan_data = { "plan_id": plan_id, "application_context": { - 'shipping_preference': 'NO_SHIPPING', + "shipping_preference": "NO_SHIPPING", "user_action": "SUBSCRIBE_NOW", "payment_method": { "payer_selected": "PAYPAL", - "payee_preferred": "IMMEDIATE_PAYMENT_REQUIRED" + "payee_preferred": "IMMEDIATE_PAYMENT_REQUIRED", }, **redirect_urls, - } + }, } payment_data = self.post(payment, self.subscriptions_url, data=plan_data) subscription = payment.get_subscription() - subscription.set_recurrence(payment_data['id']) + subscription.set_recurrence(payment_data["id"]) return payment_data def cancel_subscription(self, subscription): From 285a83627f63b55afabdbb28a7a0fcc1469ed576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Dlouh=C3=BD?= Date: Wed, 27 Oct 2021 17:31:12 +0200 Subject: [PATCH 4/5] override plan price --- payments/paypal/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/payments/paypal/__init__.py b/payments/paypal/__init__.py index 6e94b4896..cc5bfde9b 100644 --- a/payments/paypal/__init__.py +++ b/payments/paypal/__init__.py @@ -312,6 +312,22 @@ def create_subscription(self, payment, extra_data=None): plan_id = self.plan_id plan_data = { "plan_id": plan_id, + "plan": { # Override plan values + "billing_cycles": [{ + "sequence": 1, + # TODO: This doesn't work: + # "frequency": { + # "interval_unit": payment.get_subscription().get_unit(), + # "interval_count": payment.get_subscription().get_period(), + # }, + "pricing_scheme": { + "fixed_price": { + "currency_code": payment.currency, + "value": str(payment.total.quantize(CENTS, rounding=ROUND_HALF_UP)), + }, + }, + }], + }, "application_context": { "shipping_preference": "NO_SHIPPING", "user_action": "SUBSCRIBE_NOW", From b8360f630bcf6743ea6eb7212c44078190dd1a5a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 27 Oct 2021 15:32:39 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- payments/paypal/__init__.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/payments/paypal/__init__.py b/payments/paypal/__init__.py index cc5bfde9b..76536803a 100644 --- a/payments/paypal/__init__.py +++ b/payments/paypal/__init__.py @@ -313,20 +313,26 @@ def create_subscription(self, payment, extra_data=None): plan_data = { "plan_id": plan_id, "plan": { # Override plan values - "billing_cycles": [{ - "sequence": 1, - # TODO: This doesn't work: - # "frequency": { - # "interval_unit": payment.get_subscription().get_unit(), - # "interval_count": payment.get_subscription().get_period(), - # }, - "pricing_scheme": { - "fixed_price": { - "currency_code": payment.currency, - "value": str(payment.total.quantize(CENTS, rounding=ROUND_HALF_UP)), + "billing_cycles": [ + { + "sequence": 1, + # TODO: This doesn't work: + # "frequency": { + # "interval_unit": payment.get_subscription().get_unit(), + # "interval_count": payment.get_subscription().get_period(), + # }, + "pricing_scheme": { + "fixed_price": { + "currency_code": payment.currency, + "value": str( + payment.total.quantize( + CENTS, rounding=ROUND_HALF_UP + ) + ), + }, }, - }, - }], + } + ], }, "application_context": { "shipping_preference": "NO_SHIPPING",