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..3330ff2f8 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.") diff --git a/payments/paypal/__init__.py b/payments/paypal/__init__.py index 8a220c6ff..76536803a 100644 --- a/payments/paypal/__init__.py +++ b/payments/paypal/__init__.py @@ -63,17 +63,25 @@ 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 +121,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 +141,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 +159,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 +185,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 +228,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 +244,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 +261,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 +283,76 @@ 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, + "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", + "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 +364,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,