Source code for debits.paypal.views

import traceback
from decimal import Decimal
import datetime
import requests
from django.utils import timezone
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt

from debits.debits_base.processors import PaymentCallback, PAYMENT_PROCESSOR_PAYPAL
from debits.debits_base.base import logger
from debits.debits_base.models import BaseTransaction, SimpleTransaction, SubscriptionTransaction, AutomaticPayment, \
    SubscriptionPurchase
from debits.debits_base.base import Period
from django.conf import settings


# https://www.angelleye.com/paypal-recurring-payments-reference-transactions-and-preapproved-payments/

# https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNIntro/
# https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNandPDTVariables/

# Examples of IPN for recurring payments:
# https://www.angelleye.com/paypal-recurring-payments-ipn-samples/
# https://gist.github.com/thenbrent/3037967

# How to test IPN:
# https://developer.paypal.com/docs/classic/ipn/integration-guide/IPNTesting/

# https://developer.paypal.com/docs/classic/products/recurring-payments/
# says that recurring payments are available for PayPal Payments Standard

# https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECRecurringPayments/
# for a general introduction into recurring payments in PayPal

# This is a trouble: https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECRecurringPayments/
# "For recurring payments with the Express Checkout API, PayPal does not allow certain updates, such as billing amount, within 3 days of the scheduled billing date."

# Recurring payments cannot be created for buyers in Germany or China. In this case, you can use reference transactions as an alternate solution:
# https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECReferenceTxns/


# # Internal
# def parse_date(string):
#     # Not tread safe!!
#     # old_locale = locale.getlocale(locale.LC_TIME)
#     # locale.setlocale(locale.LC_TIME, "C")
#     ret = datetime.datetime.strptime(string, '%H:%M:%S %b %d, %Y %Z')
#     # locale.setlocale(locale.LC_TIME, old_locale)
#     return ret


# Internal.
from debits.paypal.models import PayPalAPI, PayPalProcessorInfo

MONTHS = [
    'Jan', 'Feb', 'Mar', 'Apr',
    'May', 'Jun', 'Jul', 'Aug',
    'Sep', 'Oct', 'Nov', 'Dec',
]


# Based on https://github.com/spookylukey/django-paypal/blob/master/paypal/standard/forms.py
[docs]def parse_date(value): """Internal.""" value = value.strip() # needed? time_part, month_part, day_part, year_part, zone_part = value.split() month_part = month_part.strip(".") day_part = day_part.strip(",") month = MONTHS.index(month_part) + 1 day = int(day_part) year = int(year_part) hour, minute, second = map(int, time_part.split(":")) dt = datetime(year, month, day, hour, minute, second) if zone_part in ["PDT", "PST"]: # PST/PDT is 'US/Pacific' dt = timezone.pytz.timezone('US/Pacific').localize( dt, is_dst=zone_part == 'PDT') if not settings.USE_TZ: dt = timezone.make_naive(dt, timezone=timezone.utc) return dt
# FIXME: Refund fails for coupon or gift certificates, because they support only full refunds
[docs]@method_decorator(csrf_exempt, name='dispatch') class PayPalIPN(PaymentCallback, View): """This class processes all kinds of PayPal IPNs. All its methods are considered internal.""" # See https://developer.paypal.com/docs/classic/express-checkout/integration-guide/ECRecurringPayments/ # for all kinds of IPN for recurring payments.
[docs] def post(self, request): try: self.do_post(request) except KeyError as e: logger.warning("PayPal IPN var %s is missing" % e) except: import traceback traceback.print_exc() return HttpResponse('', content_type="text/plain")
[docs] def do_post(self, request): # 'payment_date', 'time_created' unused if request.POST['receiver_email'] == settings.PAYPAL_EMAIL: self.do_do_post(request.POST, request) else: logger.warning("Wrong PayPal email")
[docs] def do_do_post(self, POST, request): debug = settings.PAYPAL_DEBUG url = 'https://www.sandbox.paypal.com' if debug else 'https://www.paypal.com' r = requests.post(url + '/cgi-bin/webscr', 'cmd=_notify-validate&' + request.body.decode( POST.get('charset') or request.content_params['charset']), headers={ 'content-type': request.content_type}) # message must use the same encoding as the original if r.text == 'VERIFIED': self.verified_post(POST, request) else: logger.warning("PayPal verification not passed")
[docs] def verified_post(self, POST, request): # print('custom', POST['custom']) # Don't print sensitive data # As of 4 May 2020 in PayPal there is not `custom` in unsubscription notification transaction_id = BaseTransaction.pk_from_custom(POST['custom']) if 'custom' in POST else None self.on_transaction_complete(POST, transaction_id)
[docs] def on_transaction_complete(self, POST, transaction_id): # Crazy: Recurring payment and subscription payments are not the same. # 'recurring_payment_id' and 'subscr_id' are equivalent: https://thereforei.am/2012/07/03/cancelling-subscriptions-created-with-paypal-standard-via-the-express-checkout-api/ type_dispatch = { 'web_accept': self.accept_regular_payment, 'cart': self.accept_regular_payment, 'express_checkout': self.accept_regular_payment, 'recurring_payment': self.accept_recurring_payment, 'subscr_payment': self.accept_subscription_payment, 'recurring_payment_profile_created': self.accept_recurring_signup, 'subscr_signup': self.accept_subscription_signup, 'recurring_payment_profile_cancel': self.accept_recurring_canceled, 'recurring_payment_suspended': self.accept_recurring_canceled, 'subscr_cancel': self.accept_recurring_canceled } if 'payment_status' in POST and POST['payment_status'] == 'Refunded': self.accept_refund(POST, transaction_id) else: type_dispatch[POST['txn_type']](POST, transaction_id)
[docs] def accept_refund(self, POST, transaction_id): self.do_appect_refund(POST, transaction_id)
[docs] def do_appect_refund(self, POST, transaction_id): try: transaction = BaseTransaction.objects.get(pk=transaction_id) except BaseTransaction.DoesNotExist: traceback.print_exc() return if POST['mc_currency'] == transaction.purchase.item.currency: transaction.payment.refund_payment() else: logger.warning("Wrong refund currency.")
[docs] def accept_regular_payment(self, POST, transaction_id): if POST['payment_status'] == 'Completed': self.do_accept_regular_payment(POST, transaction_id)
[docs] def do_accept_regular_payment(self, POST, transaction_id): POST = POST.dict() # for POST.get() below self.do_do_accept_regular_payment(POST, transaction_id)
[docs] def do_do_accept_regular_payment(self, POST, transaction_id): try: transaction = SimpleTransaction.objects.get(pk=transaction_id) except BaseTransaction.DoesNotExist: traceback.print_exc() return if Decimal(POST['mc_gross']) == transaction.purchase.item.price and \ Decimal(POST['shipping']) == transaction.purchase.shipping and \ Decimal(POST['tax']) == transaction.purchase.tax and \ POST['mc_currency'] == transaction.purchase.item.currency: if self.auto_refund(transaction, transaction.purchase.simplepurchase.prolongpurchase.prolonged, POST): return HttpResponse('') payment = transaction.on_accept_regular_payment(POST['payer_email']) self.on_payment(payment) else: logger.warning("Wrong amount or currency")
[docs] def accept_recurring_payment(self, POST, transaction_id): if POST['payment_status'] != 'Completed': return self.do_accept_recurring_payment(POST, transaction_id)
[docs] def do_accept_recurring_payment(self, POST, transaction_id): # transaction = BaseTransaction.objects.select_for_update().get(pk=transaction_id) # only inside transaction try: transaction = SubscriptionTransaction.objects.get(pk=transaction_id) except BaseTransaction.DoesNotExist: traceback.print_exc() return if Decimal(POST['amount_per_cycle']) == transaction.purchase.item.price + transaction.purchase.item.shipping + transaction.purchase.item.tax and \ POST['payment_cycle'] in self.pp_payment_cycles(transaction.purchase.item): self.do_do_accept_subscription_or_recurring_payment(transaction, transaction.purchase.item, POST, POST['recurring_payment_id']) else: logger.warning("Wrong recurring payment data")
[docs] def accept_subscription_payment(self, POST, transaction_id): if POST['payment_status'] != 'Completed': return self.do_accept_subscription_payment(POST, transaction_id)
[docs] def do_do_accept_subscription_or_recurring_payment(self, transaction, purchase, POST, ref): if self.auto_refund(transaction, purchase, POST): return HttpResponse('') purchase.subscriptionpurchase.activate_subscription(ref, POST['payer_email'], PAYMENT_PROCESSOR_PAYPAL) # This is already done in activate_subscription(): payment = AutomaticPayment.objects.create(transaction=transaction, email=POST['payer_email'], subscription_reference=ref, processor_id=PAYMENT_PROCESSOR_PAYPAL) purchase.payment = payment self.do_subscription_or_recurring_payment(purchase.subscriptionpurchase) # calls save() self.on_payment(transaction.payment.automaticpayment)
[docs] def do_accept_subscription_payment(self, POST, transaction_id): # transaction = BaseTransaction.objects.select_for_update().get(pk=transaction_id) # only inside transaction try: transaction = SubscriptionTransaction.objects.get(pk=transaction_id) except BaseTransaction.DoesNotExist: traceback.print_exc() return purchase = transaction.purchase if Decimal(POST['mc_gross']) == purchase.item.price + purchase.shipping + purchase.tax and \ POST['mc_currency'] == purchase.item.currency: self.do_do_accept_subscription_or_recurring_payment(transaction, purchase, POST, POST['subscr_id']) else: logger.warning("Wrong subscription payment data")
[docs] def do_subscription_or_recurring_payment(self, purchase): # transaction.processor = PaymentProcessor.objects.get(pk=PAYMENT_PROCESSOR_PAYPAL) purchase.trial = False date = purchase.due_payment_date if purchase.item.subscriptionitem.payment_period.count > 0: # hack to eliminate infinite loop while date <= datetime.date.today(): date = self.advance_item_date(date, purchase) purchase.due_payment_date = date purchase.save()
[docs] def advance_item_date(self, date, purchase): date = PayPalProcessorInfo.offset_date(date, purchase.item.subscriptionitem.payment_period) purchase.set_payment_date(date) purchase.reminders_sent = 0 return date
[docs] def do_subscription_or_recurring_created(self, transaction, POST, ref): purchase = transaction.purchase.subscriptionpurchase purchase.activate_subscription(ref, POST['payer_email'], PAYMENT_PROCESSOR_PAYPAL) # transaction.processor = PaymentProcessor.objects.get(pk=PAYMENT_PROCESSOR_PAYPAL) SubscriptionPurchase.objects.filter(pk=purchase.pk).update(trial=False) purchase.upgrade_subscription() self.on_subscription_created(POST, purchase)
[docs] def accept_subscription_signup(self, POST, transaction_id): self.do_accept_subscription_signup(POST, transaction_id)
[docs] def do_accept_subscription_signup(self, POST, transaction_id): try: transaction = SubscriptionTransaction.objects.get(pk=transaction_id) except BaseTransaction.DoesNotExist: traceback.print_exc() return purchase = transaction.purchase.subscriptionpurchase m = { Period.UNIT_DAYS: 'D', Period.UNIT_WEEKS: 'W', Period.UNIT_MONTHS: 'M', Period.UNIT_YEARS: 'Y', } period1_right = (purchase.item.subscriptionitem.trial_period.count == 0 and 'period1' not in POST) or \ (purchase.item.subscriptionitem.trial_period.count != 0 and 'period1' in POST and \ POST['period1'] == str(purchase.item.subscriptionitem.trial_period.count)+' '+m[purchase.item.subscriptionitem.trial_period.unit]) if period1_right and 'period2' not in POST and \ Decimal(POST['amount3']) == purchase.item.price and \ POST['period3'] == str(purchase.item.subscriptionitem.payment_period.count)+' '+m[purchase.item.subscriptionitem.payment_period.unit] and \ POST['mc_currency'] == purchase.item.currency: self.do_subscription_or_recurring_created(transaction, POST, POST['subscr_id']) else: logger.warning("Wrong subscription signup data")
[docs] def accept_recurring_signup(self, POST, transaction_id): try: transaction = SubscriptionTransaction.objects.get(pk=transaction_id) except BaseTransaction.DoesNotExist: traceback.print_exc() return if 'period1' not in POST and 'period2' not in POST and \ Decimal(POST['mc_amount3']) == transaction.purchase.item.price + transaction.purchase.shipping + transaction.purchase.tax and \ POST['mc_currency'] == transaction.purchase.item.currency and \ POST['period3'] in self.pp_payment_cycles(transaction): self.do_subscription_or_recurring_created(transaction, POST, POST['recurring_payment_id']) else: logger.warning("Wrong recurring signup data")
[docs] def accept_recurring_canceled(self, POST, subscription_reference): self.do_accept_recurring_canceled(POST, subscription_reference)
[docs] def do_accept_recurring_canceled(self, POST, subscription_reference): # try: # transaction = SubscriptionTransaction.objects.get(pk=transaction_id) # except BaseTransaction.DoesNotExist: # traceback.print_exc() # # return # transaction.purchase.subscriptionpurchase.cancel_subscription() # self.on_subscription_canceled(POST, transaction.purchase) subscription_reference = getattr(POST, 'recurring_payment_id', POST['subscr_id']) subscriptionpurchase = SubscriptionPurchase.objects.get(subscription_reference=subscription_reference) subscriptionpurchase.cancel_subscription() self.on_subscription_canceled(POST, subscriptionpurchase.purchase)
[docs] def auto_refund(self, transaction, purchase, POST): # "purchase" is SubscriptionItem if self.should_auto_refund(): api = PayPalAPI() # FIXME: Wrong for American Express card: https://www.paypal.com/us/selfhelp/article/How-do-I-issue-a-full-or-partial-refund-FAQ780 amount = (transaction.purchase.item.price - Decimal(0.30)).quantize(Decimal('1.00')) api.refund(POST['txn_id'], str(amount)) return True return False
[docs] def should_auto_refund(self): return False
# Ugh, PayPal
[docs] def pp_payment_cycles(self, purchase): first_tmpl = { Period.UNIT_DAYS: 'every %d Days', Period.UNIT_WEEKS: 'every %d Weeks', Period.UNIT_MONTHS: 'every %d Months', Period.UNIT_YEARS: 'every %d Years', }[purchase.item.subscriptionitem.payment_period.unit] first = first_tmpl % purchase.item.subscriptionitem.payment_period.count if purchase.item.subscriptionitem.payment_period.count == 1: second = { Period.UNIT_DAYS: 'Daily', Period.UNIT_WEEKS: 'Weekly', Period.UNIT_MONTHS: 'Monthly', Period.UNIT_YEARS: 'Yearly', }[purchase.item.subscriptionitem.payment_period.unit] return (first, second) else: return (first,)