Source code for debits.debits_base.models

import abc
import hmac
import datetime

import html2text
from django.apps import apps
from django.urls import reverse
from django.db import models
from django.db.models import F
import django.db
from django.db import transaction
from django.core.exceptions import ObjectDoesNotExist
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _
from composite_field import CompositeField
from django.conf import settings

from debits.debits_base.base import logger, Period, period_to_delta


[docs]class ModelRef(CompositeField): """Reference to a Django model""" app_label = models.CharField(_('Django app with the model'), max_length=100) """Django app with the model.""" model = models.CharField(_('Python model class name'), max_length=100) """The model class name."""
# The following function does not work as a method, because # CompositeField is replaced with composite_field.base.CompositeField.Proxy:
[docs]def model_from_ref(model_ref): """Retrieves a model from `ModelRef`. Args: model_ref: A `ModelRef` field instance. Returns: A Django model class. """ return apps.get_model(model_ref.app_label, model_ref.model)
[docs]class PaymentProcessor(models.Model): """Payment processor (such as PayPal, DalPay, etc.)""" name = models.CharField(_('The name of the company'), max_length=255) """The name of the payment processing company or service.""" url = models.URLField(max_length=255) """The site of the payment processor.""" klass = ModelRef() """The Django model which handles API for payments and similar stuff.""" def __str__(self): return self.name
[docs]class Product(models.Model): name = models.CharField(_('Product name'), max_length=255) """Product name.""" def __str__(self): return self.name
[docs]class BaseTransaction(models.Model): """A redirect (or other query) to the payment processor. It may be paid or (not yet) paid.""" # class Meta: # abstract = True processor = models.ForeignKey(PaymentProcessor, on_delete=models.CASCADE) """Payment processor.""" creation_date = models.DateTimeField(auto_now_add=True) """Date of the redirect.""" purchase = models.ForeignKey('Purchase', related_name='transactions', null=False, on_delete=models.CASCADE) """The stuff sold by this transaction.""" def __repr__(self): return "<BaseTransaction: %s>" % (("pk=%d" % self.pk) if self.pk else "no pk")
[docs] @staticmethod def custom_from_pk(pk): """Secret code of a transaction. Secret can be known only to one who created a BaseTransaction. This prevents third parties to make fake IPNs from a payment processor. Args: pk: the serial primary key (of :class:`BaseTransaction`) used to calculate the secret transaction code. Returns: A secret string.""" secret = hmac.new(settings.SECRET_KEY.encode(), ('payid ' + str(pk)).encode()).hexdigest() return settings.PAYMENTS_REALM + ' ' + str(pk) + ' ' + secret
[docs] @staticmethod def pk_from_custom(custom): """Restore the :class:`BaseTransaction` primary key from the secret "custom". Raises :class:`BaseTransaction.DoesNotExist` if the custom is wrong. Args: custom: A secret string. Returns: The primary key for :class:`BaseTransaction`.""" r = custom.split(' ', 2) if len(r) != 3 or r[0] != settings.PAYMENTS_REALM: raise BaseTransaction.DoesNotExist try: pk = int(r[1]) secret = hmac.new(settings.SECRET_KEY.encode(), ('payid ' + str(pk)).encode()).hexdigest() if r[2] != secret: raise BaseTransaction.DoesNotExist return pk except ValueError: raise BaseTransaction.DoesNotExist
[docs] @abc.abstractmethod def invoice_id(self): """Invoice ID. Used internally to prevent more than one payment for the same transaction.""" pass
[docs] def invoiced_purchase(self): """Internal.""" return self.purchase.old_subscription or self.purchase
[docs] @abc.abstractmethod def subinvoice(self): """Subinvoice ID. Used internally to prevent more than one payment for the same transaction.""" pass
[docs]class SimpleTransaction(BaseTransaction): """A one-time (non-recurring) transaction."""
[docs] def subinvoice(self): return 1
[docs] def invoice_id(self): return settings.PAYMENTS_REALM + ' p-%d' % (self.purchase.pk,)
# Make transaction atomic to be sure that simpleitem.save() and advance_parent() do together
[docs] @transaction.atomic def on_accept_regular_payment(self, email): """Handles confirmation of a (non-recurring) payment.""" payment = SimplePayment.objects.create(transaction=self, email=email) self.purchase.status = SimplePaymentStatus.PAID self.purchase.payment = payment self.purchase.upgrade_subscription() self.purchase.save() try: self.advance_parent(self.purchase.simplepurchase.prolongpurchase, payment) except AttributeError: pass return payment
[docs] @transaction.atomic def advance_parent(self, prolongpurchase, payment): """Advances the parent transaction on receive of a "prolong" payment. Args: prolongpurchase: :class:`ProlongPurchase`. `prolongitem.period` contains the number of days to advance the parent (:class:`SubscriptionItem`) item. The parent transaction is advanced this number of days. """ parent_purchase = SubscriptionPurchase.objects.select_for_update().get( pk=prolongpurchase.prolonged_id) # must be inside transaction # parent.email = transaction.email base_date = max(datetime.date.today(), parent_purchase.due_payment_date) klass = model_from_ref(payment.transaction.processor.klass) # prolongpurchase.payment is None, so use payment instead parent_purchase.set_payment_date(klass.offset_date(base_date, prolongpurchase.period)) parent_purchase.save()
[docs]class SubscriptionTransaction(BaseTransaction): """A transaction for a subscription service."""
[docs] def subinvoice(self): return self.invoiced_purchase().subscriptionpurchase.subinvoice
[docs] def invoice_id(self): if self.purchase.old_subscription: return settings.PAYMENTS_REALM + ' %d-%d-u' % (self.purchase.pk, self.subinvoice()) else: return settings.PAYMENTS_REALM + ' %d-%d' % (self.purchase.pk, self.subinvoice())
[docs]class Item(models.Model): """Anything sold or rent. Apps using this package should create their product records manually. Then you create an instance of a subclass of this class before allowing the user to make a transaction. In a future we may provide an interface for registering new products. """ product = models.ForeignKey('Product', null=True, on_delete=models.CASCADE) """The sold product.""" product_qty = models.IntegerField(default=1) """Quantity of the sold product (often 1).""" currency = models.CharField(max_length=3, default='USD') """The currency for which this is sold.""" price = models.DecimalField(max_digits=10, decimal_places=2) """Price of the item. For recurring payment it is the amount of one payment.""" def __repr__(self): return "<Item pk=%d, %s>" % (self.pk, self.product.name) def __str__(self): return self.product.name
[docs] @abc.abstractmethod def is_subscription(self): pass
"""Is this a recurring (or one-time) payment."""
[docs]class SimplePaymentStatus(object): NOT_PAID = 1 PAID = 2 REFUNDED = 3
[docs]class SimpleItem(Item): """Non-subscription item. To sell a non-subscription item, create a subclass of this model, describing your sold good."""
[docs] def is_subscription(self): return False
[docs]class SubscriptionItem(Item): """Subscription (recurring) item. To sell a subscription item, create a subclass of this model, describing your sold service.""" grace_period = Period(unit=Period.UNIT_DAYS, count=20) """How much :attr:`payment_deadline` is above :attr:`due_payment_data`.""" payment_period = Period(unit=Period.UNIT_MONTHS, count=1) """How often to pay (for automatic recurring payments).""" trial_period = Period(unit=Period.UNIT_MONTHS, count=0) """Trial period. It may be zero."""
[docs] def is_subscription(self): return True
[docs]class Purchase(models.Model): item = models.ForeignKey('Item', null=False, on_delete=models.CASCADE) parent = models.ForeignKey('AggregatePurchase', null=True, on_delete=models.SET_NULL, related_name='childs') """This purchase is a part of a composite purchase.""" creation_date = models.DateTimeField(auto_now_add=True) """Date of item creation.""" payment = models.OneToOneField('Payment', null=True, on_delete=models.CASCADE) """Payment accomplished for this item or `None`.""" blocked = models.BooleanField(default=False) """A hacker or misbehavior detected.""" gratis = models.BooleanField(default=False) """Provide a product or service for free.""" shipping = models.DecimalField(max_digits=10, decimal_places=2, default=0) """Price of shipping. Remain zero if doubt.""" tax = models.DecimalField(max_digits=10, decimal_places=2, default=0) """Tax for the sale. Remain zero if doubt.""" # code = models.CharField(max_length=255) # TODO reminders_sent = models.SmallIntegerField(default=0, db_index=True) """Email (or SMS, etc.) payment reminders sent state. * 0 - no reminder sent * 1 - before due payment sent * 2 - at due payment sent * 3 - day before deadline sent TODO: Move to :class:`SubscriptionPurchase`?""" old_subscription = models.ForeignKey('Purchase', null=True, related_name='new_subscription', on_delete=models.CASCADE) """We remove old_subscription (if not `None`) automatically when new subscription is created. The new payment may be either one-time (:class:`SimpleItem` (usually :class:`ProlongPurchase`)) or subscription (:class:`SubscriptionItem`).""" def __repr__(self): return "<Purchase pk=%d, %s>" % (self.pk, self.item.product.name) @property def is_aggregate(self): return False
[docs] @transaction.atomic def upgrade_subscription(self): """Internal. It cancels the old subscription (if any). It can be called from both subscription IPN and payment IPN, so prepare to handle it two times.""" if self.old_subscription: self.do_upgrade_subscription()
[docs] def do_upgrade_subscription(self): """Internal. TODO: Remove ALL old subscriptions as in payment_system2.""" try: self.old_subscription.subscriptionpurchase.force_cancel(is_upgrade=True) except CannotCancelSubscription: pass # self.on_upgrade_subscription(transaction, item.old_subscription) # TODO: Needed? Purchase.objects.filter(pk=self.pk).update(old_subscription=None)
# TODO: Move to Payment class?
[docs] def send_rendered_email(self, template_name, subject, data): """Internal.""" email = None try: email = self.payment.email # Item.objects.filter(pk=self.pk).update(email=email) except AttributeError: # no .payment return if email is not None: html = render_to_string(template_name, data, request=None, using=None) text = html2text.html2text(html) send_mail(subject, text, settings.FROM_EMAIL, [email], html_message=html)
[docs]class SimplePurchase(Purchase): status = models.SmallIntegerField(_('Payment status'), default=SimplePaymentStatus.NOT_PAID) # SimplePaymentStatus @property def paid(self): """It was paid by the user (and not refunded).""" cur = self while True: if cur._paid: return True cur = cur.parent.only('status', 'parent') if not cur: return False @property def _paid(self): """Internal.""" return self.status == SimplePaymentStatus.PAID
[docs] def is_paid(self): return (self.paid or self.gratis) and not self.blocked
"""If to consider the item paid (or gratis) but not blocked."""
[docs]class SubscriptionPurchase(Purchase): due_payment_date = models.DateField(default=datetime.date.today, db_index=True) """The reference payment date.""" payment_deadline = models.DateField(null=True, db_index=True) # may include "grace period" """The dealine payment date. After it is reached, the item is considered inactive.""" trial = models.BooleanField(default=False, db_index=True) """Now in trial period.""" # https://bitbucket.org/arcamens/django-payments/wiki/Invoice%20IDs subinvoice = models.PositiveIntegerField(default=1) # no need for index, as it is used only at PayPal side """Internal.""" subscription_reference = models.CharField(max_length=255, null=True, db_index=True) """As `recurring_payment_id` in PayPal. TODO: Avangate has it for every product, but PayPal for transaction as a whole.""" processor = models.ForeignKey(PaymentProcessor, on_delete=models.CASCADE, null=True) """Payment processor for a subscription payment.""" email = models.EmailField(null=True) """User's email. DalPay requires to notify the customer 10 days before every payment.""" def __init__(self, *args, **kwargs): try: settings.PROLONG_PAYMENT_VIEW except AttributeError: raise Exception("Missing PROLONG_PAYMENT_VIEW in settings.") super().__init__(*args, **kwargs) @property def subscribed(self): """Is in automatic (not manual) recurring mode.""" return bool(self.subscription_reference)
[docs] def is_active(self): """Is the item active (paid on time and not blocked). Usually you should use quick_is_active() instead because that is faster.""" prior = self.payment_deadline is not None and \ datetime.date.today() <= self.payment_deadline return (prior or self.gratis) and not self.blocked
[docs] @staticmethod def quick_is_active(item_id): """Is the item with given PK active (paid on time and not blocked). Usually you should use quick_is_active() instead because that is faster.""" item = SubscriptionItem.objects.filter(pk=item_id).\ only('payment_deadline', 'gratis', 'blocked').get() return item.is_active()
[docs] def set_payment_date(self, date): """Sets both :attr:`due_payment_date` and :attr:`payment_deadline`.""" self.due_payment_date = date # klass = model_from_ref(self.payment.transaction.processor.klass) # self.payment_deadline = klass.offset_date(self.due_payment_date, self.grace_period) self.payment_deadline = self.due_payment_date + period_to_delta(self.item.subscriptionitem.grace_period)
[docs] def start_trial(self): """Start trial period. This should be called after setting non-zero :attr:`trial_period`.""" if self.item.subscriptionitem.trial_period.count != 0: self.trial = True # klass = model_from_ref(self.payment.transaction.processor.klass) # not yet defined # self.set_payment_date(klass.offset_date(datetime.date.today(), self.trial_period)) self.set_payment_date(datetime.date.today() + period_to_delta(self.item.subscriptionitem.trial_period))
# TODO: The same as in do_upgrade_subscription() #@shared_task # PayPal tormoz, so run in a separate thread # TODO: celery (with `TypeError: force_cancel() missing 1 required positional argument: 'self'`)
[docs] def force_cancel(self, is_upgrade=False): """Cancels the :attr:`transaction`.""" if self.subscription_reference: klass = model_from_ref(self.processor.klass) api = klass().api() try: api.cancel_agreement(self.subscription_reference, is_upgrade=is_upgrade) # may raise an exception except CannotCancelSubscription: logger.warn("Cannot cancel subscription " + self.subscription_reference) # fallback SubscriptionPurchase.objects.filter(pk=self.pk).update( payment=None, processor=None, subscription_reference=None, subinvoice=F('subinvoice') + 1) raise # transaction.cancel_subscription() # runs in the callback else: # SubscriptionItem.objects.filter(payment=self.pk).update(payment=None, subinvoice=F('subinvoice') + 1) # called in cancel_subscription() pass
[docs] @django.db.transaction.atomic def activate_subscription(self, ref, email, processor): """Internal. "Competes" with :meth:`on_accept_regular_payment`.""" SubscriptionPurchase.objects.filter(pk=self.pk).update(subscription_reference=ref, email=email, processor=processor)
[docs] def cancel_subscription(self): """Called when we detect that the subscription was canceled.""" # atomic operation SubscriptionPurchase.objects.filter(pk=self.pk).update( payment=None, subscription_reference=None, processor=None, subinvoice=F('subinvoice') + 1) if not self.old_subscription: # don't send this email on plan upgrade self.cancel_subscription_email()
[docs] def cancel_subscription_email(self): """Internal. Sends cancel subscription email.""" url = settings.PAYMENTS_HOST + reverse(settings.PROLONG_PAYMENT_VIEW, args=[self.pk]) days_before = (self.due_payment_date - datetime.date.today()).days self.send_rendered_email('debits/email/subscription-canceled.html', _("Service subscription canceled"), {'self': self, 'product': self.item.product.name, 'url': url, 'days_before': days_before})
[docs] @staticmethod def send_reminders(): """Send all email reminders.""" SubscriptionPurchase.send_regular_reminders() SubscriptionPurchase.send_trial_reminders()
[docs] @staticmethod def send_regular_reminders(): """Internal.""" # start with the last SubscriptionPurchase.send_regular_before_due_reminders() SubscriptionPurchase.send_regular_due_reminders() SubscriptionPurchase.send_regular_deadline_reminders()
[docs] @staticmethod def send_regular_before_due_reminders(): """Internal.""" days_before = settings.PAYMENTS_DAYS_BEFORE_DUE_REMIND reminder_date = datetime.date.today() + datetime.timedelta(days=days_before) q = SubscriptionPurchase.objects.filter(reminders_sent__lt=3, due_payment_date__lte=reminder_date, trial=False) for purchase in q: Item.objects.filter(pk=purchase.pk).update(reminders_sent=3) url = reverse(settings.PROLONG_PAYMENT_VIEW, args=[purchase.pk]) purchase.send_rendered_email('debits/email/before-due-remind.html', _("You need to pay for %s") % purchase.product.name, {'transaction': purchase, 'product': purchase.product.name, 'url': url, 'days_before': days_before})
[docs] @staticmethod def send_regular_due_reminders(): """Internal.""" reminder_date = datetime.date.today() q = SubscriptionPurchase.objects.filter(reminders_sent__lt=2, due_payment_date__lte=reminder_date, trial=False) for purchase in q: Item.objects.filter(pk=purchase.pk).update(reminders_sent=2) url = reverse(settings.PROLONG_PAYMENT_VIEW, args=[purchase.pk]) purchase.send_rendered_email('debits/email/due-remind.html', _("You need to pay for %s") % purchase.product.name, {'transaction': purchase, 'product': purchase.product.name, 'url': url})
[docs] @staticmethod def send_regular_deadline_reminders(): """Internal.""" reminder_date = datetime.date.today() q = SubscriptionPurchase.objects.filter(reminders_sent__lt=1, payment_deadline__lte=reminder_date, trial=False) for purchase in q: Item.objects.filter(pk=purchase.pk).update(reminders_sent=1) url = reverse(settings.PROLONG_PAYMENT_VIEW, args=[purchase.pk]) purchase.send_rendered_email('debits/email/deadline-remind.html', _("You need to pay for %s") % purchase.product.name, {'transaction': purchase, 'product': purchase.product.name, 'url': url})
[docs] @staticmethod def send_trial_reminders(): """Internal.""" # start with the last SubscriptionPurchase.send_trial_before_due_reminders() SubscriptionPurchase.send_trial_due_reminders() SubscriptionPurchase.send_trial_deadline_reminders()
[docs] @staticmethod def send_trial_before_due_reminders(): """Internal.""" days_before = settings.PAYMENTS_DAYS_BEFORE_TRIAL_END_REMIND reminder_date = datetime.date.today() + datetime.timedelta(days=days_before) q = SubscriptionPurchase.objects.filter(reminders_sent__lt=3, due_payment_date__lte=reminder_date, trial=True) for purchase in q: Item.objects.filter(pk=purchase.pk).update(reminders_sent=3) url = reverse(settings.PROLONG_PAYMENT_VIEW, args=[purchase.pk]) purchase.send_rendered_email('debits/email/before-due-remind.html', _("You need to pay for %s") % purchase.product.name, {'transaction': purchase, 'product': purchase.product.name, 'url': url, 'days_before': days_before})
[docs] @staticmethod def send_trial_due_reminders(): """Internal.""" reminder_date = datetime.date.today() q = SubscriptionPurchase.objects.filter(reminders_sent__lt=2, due_payment_date__lte=reminder_date, trial=True) for purchase in q: Item.objects.filter(pk=purchase.pk).update(reminders_sent=2) url = reverse(settings.PROLONG_PAYMENT_VIEW, args=[purchase.pk]) purchase.send_rendered_email('debits/email/due-remind.html', _("You need to pay for %s") % purchase.product.name, {'transaction': purchase, 'product': purchase.product.name, 'url': url})
[docs] @staticmethod def send_trial_deadline_reminders(): """Internal.""" reminder_date = datetime.date.today() q = SubscriptionPurchase.objects.filter(reminders_sent__lt=1, payment_deadline__lte=reminder_date, trial=True) for purchase in q: Item.objects.filter(pk=purchase.pk).update(reminders_sent=1) url = reverse(settings.PROLONG_PAYMENT_VIEW, args=[purchase.pk]) purchase.send_rendered_email('debits/email/deadline-remind.html', _("You need to pay for %s") % purchase.product.name, {'transaction': purchase, 'product': purchase.product.name, 'url': url})
# TODO # def get_email(self): # try: # # We get the first email, as normally we have no more than one non-canceled transaction # t = self.transactions.filter(subscription__canceled=False)[0] # payment = AutomaticPayment.objects.filter(transaction=t).order_by('-id')[0] # return payment.email # except IndexError: # no object # return None
[docs]class ProlongPurchase(SimplePurchase): """Prolong :attr:`prolonged` item. This is meant to be a one-time payment which prolongs a manual subscription item.""" prolonged = models.ForeignKey('SubscriptionPurchase', related_name='child', parent_link=False, on_delete=models.CASCADE) """Which subscription item to prolong.""" period = Period(unit=Period.UNIT_MONTHS, count=0) """The amount of days (or weeks, months, etc.) how much to prolong."""
[docs] def refund_payment(self): """Handle payment refund. For :class:`ProlongPurchase` we subtract the prolong days back from the :attr:`parent` item.""" prolong2 = self.period prolong2.count *= -1 klass = model_from_ref(self.payment.transaction.processor.klass) self.prolonged.set_payment_date(klass.offset_date(self.prolonged.due_payment_date, prolong2)) self.prolonged.save()
[docs]class Payment(models.Model): """Base class describing a particular payment. It generated by our IPN handler.""" payment_time = models.DateTimeField(_('Payment time'), auto_now_add=True) transaction = models.OneToOneField('BaseTransaction', on_delete=models.CASCADE) """The transaction we accepted.""" email = models.EmailField(null=True) """User's email. DalPay requires to notify the customer 10 days before every payment."""
[docs] def refund_payment(self): """Handles payment refund.""" # Controversial decision to reset payment=None on refund # try: # SimplePayment.objects.filter(pk=self.pk).update(payment=None, status=SimplePaymentStatus.REFUNDED) # except ObjectDoesNotExist: # Payment.objects.filter(pk=self.pk).update(payment=None) try: SimplePurchase.objects.filter(pk=self.purchase.pk).update(status=SimplePaymentStatus.REFUNDED) except ObjectDoesNotExist: pass try: self.transaction.purchase.simplepurchase.prolongpurchase.refund_payment() except (SimplePurchase.DoesNotExist, ProlongPurchase.DoesNotExist): pass
[docs]class SimplePayment(Payment): """Non-recurring payment.""" pass
[docs]class AutomaticPayment(Payment): """Automatic (recurring) payment.""" processor = models.ForeignKey(PaymentProcessor, on_delete=models.CASCADE) """Payment processor.""" subscription_reference = models.CharField(max_length=255, null=True) """As `recurring_payment_id` in PayPal. TODO: Avangate has it for every product, but PayPal for transaction as a whole."""
# A transaction should have a code that identifies it. # code = models.CharField(max_length=255)
[docs]class AggregateItem(SimpleItem): """Several payments in one. TODO: Not tested!"""
[docs] def calc(self): """Update price to be the sum of all children.""" price = 0.0 for child in self.childs.all(): price += child.price self.price = price self.save()
[docs]class AggregatePurchase(SimplePurchase): """Several payments in one. TODO: Not tested!"""
[docs] def calc(self): """Update shipping and tax to be the sum of all children. TODO: Also tax.""" self.item.simpleitem.aggregateitem.calc() shipping = 0.0 tax = 0.0 for child in self.childs.all(): shipping += child.shipping tax += child.tax self.shipping = shipping self.tax = tax self.save()
@property def is_aggregate(self): return True
[docs]class CannotCancelSubscription(Exception): """Canceling subscription failed.""" pass
[docs]class CannotRefund(Exception): """Refunding payment failed.""" pass