Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(WIP) PayPal subscribtions #274

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions payments/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment on lines +122 to +129
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what this method should do.

What does autocomplete mean in this context?


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()

Expand Down
83 changes: 83 additions & 0 deletions payments/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import enum
import json
from typing import Iterable
from typing import Optional
from typing import Union
from uuid import uuid4

Expand Down Expand Up @@ -36,6 +38,60 @@ def __setattr__(self, key, value):
self._payment.extra_data = json.dumps(data)


class BaseSubscription(models.Model):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand correctly, applications need to provide a subclass of this model, correct?

Can you add a brief docstring mentioning this?

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"
Comment on lines +59 to +62
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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()
Comment on lines +74 to +75
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what this is supposed to return either.


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.
Expand Down Expand Up @@ -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.")
Expand Down
130 changes: 117 additions & 13 deletions payments/paypal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -131,21 +141,29 @@ 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:
return extra_data.get("auth_response", {})
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
Expand All @@ -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):
Expand Down Expand Up @@ -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

Expand All @@ -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"])

Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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,
Expand Down