Changeset View
Changeset View
Standalone View
Standalone View
looper/clock.py
| import abc | import abc | ||||
| import datetime | import datetime | ||||
| import logging | import logging | ||||
| import typing | from typing import Callable, Mapping, Optional | ||||
| from django.conf import settings | from django.conf import settings | ||||
| import django.db.models | import django.db.models | ||||
| import django.db.transaction | import django.db.transaction | ||||
| import django.utils.timezone | import django.utils.timezone | ||||
| from . import admin_log, exceptions, models, signals | from looper import admin_log, exceptions, models, signals | ||||
| log = logging.getLogger(__name__) | log = logging.getLogger(__name__) | ||||
| RENEWABLE_SUBSCRIPTION_STATUSES = {'active'} | RENEWABLE_SUBSCRIPTION_STATUSES = {'active'} | ||||
| CANCELLABLE_SUBSCRIPTION_STATUSES = {'pending-cancellation'} | CANCELLABLE_SUBSCRIPTION_STATUSES = {'pending-cancellation'} | ||||
| CollectionFunc = typing.Callable[[models.Subscription, typing.Optional[models.Order]], bool] | CollectionFunc = Callable[[models.Subscription, Optional[models.Order]], bool] | ||||
| class AbstractClock(metaclass=abc.ABCMeta): | class AbstractClock(metaclass=abc.ABCMeta): | ||||
| """Interface for time-based subscription updates. | """Interface for time-based subscription updates. | ||||
| NOTE(Sybren): this interface is provisionary and will be extended/changed. | NOTE(Sybren): this interface is provisionary and will be extended/changed. | ||||
| """ | """ | ||||
| log = log.getChild('AbstractClock') | log = log.getChild('AbstractClock') | ||||
| def tick(self) -> None: | def tick(self) -> None: | ||||
| """Main entry point for running the clock.""" | """Main entry point for running the clock.""" | ||||
| for subscription in self.queryset_renewal(): | for subscription in self.queryset_renewal(): | ||||
| self.renew_subscription(subscription) | self.renew_subscription(subscription) | ||||
| for subscription in self.queryset_cancel(): | for subscription in self.queryset_cancel(): | ||||
| self.cancel_subscription(subscription) | self.cancel_subscription(subscription) | ||||
| def now(self) -> datetime.datetime: | def now(self) -> datetime.datetime: | ||||
| """Return what is considered to be 'now'. | """Return what is considered to be 'now'. | ||||
| This can be any timestamp. Subclasses can override this method for | This can be any timestamp. Subclasses can override this method for | ||||
| simulating events rather than executing them. | simulating events rather than executing them. | ||||
| """ | """ | ||||
| return django.utils.timezone.now() | return django.utils.timezone.now() | ||||
| def queryset_renewal(self) -> django.db.models.QuerySet: | def queryset_renewal(self) -> 'django.db.models.QuerySet[models.Subscription]': | ||||
| """Return a QuerySet of all subscriptions that need renewal.""" | """Return a QuerySet of all subscriptions that need renewal.""" | ||||
| my_log = self.log.getChild('queryset_renewal') | my_log = self.log.getChild('queryset_renewal') | ||||
| now = self.now() | now = self.now() | ||||
| from django.db.models import F | from django.db.models import F | ||||
| qs = models.Subscription.objects \ | |||||
| .filter(status__in=RENEWABLE_SUBSCRIPTION_STATUSES, next_payment__lt=now) \ | qs = ( | ||||
| .exclude(order__retry_after__gt=now) \ | models.Subscription.objects.filter( | ||||
| .exclude(collection_method='managed', next_payment__gt=now) \ | status__in=RENEWABLE_SUBSCRIPTION_STATUSES, next_payment__lt=now | ||||
| ) | |||||
| .exclude(order__retry_after__gt=now) | |||||
| .exclude(collection_method='managed', next_payment__gt=now) | |||||
| .exclude(collection_method='managed', last_notification__gt=F('next_payment')) | .exclude(collection_method='managed', last_notification__gt=F('next_payment')) | ||||
| my_log.info('Found %d subscriptions with status in %s and next_payment < %s', | ) | ||||
| len(qs), RENEWABLE_SUBSCRIPTION_STATUSES, now) | my_log.info( | ||||
| 'Found %d subscriptions with status in %s and next_payment < %s', | |||||
| len(qs), | |||||
| RENEWABLE_SUBSCRIPTION_STATUSES, | |||||
| now, | |||||
| ) | |||||
| return qs | return qs | ||||
| def queryset_cancel(self) -> django.db.models.QuerySet: | def queryset_cancel(self) -> 'django.db.models.QuerySet[models.Subscription]': | ||||
| """Return a QuerySet of all subscriptions that need cancellation.""" | """Return a QuerySet of all subscriptions that need cancellation.""" | ||||
| my_log = self.log.getChild('queryset_cancel') | my_log = self.log.getChild('queryset_cancel') | ||||
| now = self.now() | now = self.now() | ||||
| qs = models.Subscription.objects \ | qs = models.Subscription.objects.filter( | ||||
| .filter(status__in=CANCELLABLE_SUBSCRIPTION_STATUSES, next_payment__lt=now) | status__in=CANCELLABLE_SUBSCRIPTION_STATUSES, next_payment__lt=now | ||||
| my_log.info('Found %d subscriptions with status in %s and next_payment < %s', | ) | ||||
| len(qs), CANCELLABLE_SUBSCRIPTION_STATUSES, now) | my_log.info( | ||||
| 'Found %d subscriptions with status in %s and next_payment < %s', | |||||
| len(qs), | |||||
| CANCELLABLE_SUBSCRIPTION_STATUSES, | |||||
| now, | |||||
| ) | |||||
| return qs | return qs | ||||
| def renew_subscription(self, subscription: models.Subscription) -> None: | def renew_subscription(self, subscription: models.Subscription) -> None: | ||||
| my_log = self.log.getChild('renew_subscription') | my_log = self.log.getChild('renew_subscription') | ||||
| try: | try: | ||||
| order = self.order_to_renew(subscription) | order = self.order_to_renew(subscription) | ||||
| except Exception as ex: | except Exception as ex: | ||||
| self.on_order_creation_failed(subscription, ex) | self.on_order_creation_failed(subscription, ex) | ||||
| return | return | ||||
| # Deal with the collection depending on the collection method. | # Deal with the collection depending on the collection method. | ||||
| collection_method = order.collection_method if order else subscription.collection_method | collection_method = order.collection_method if order else subscription.collection_method | ||||
| collection_funcs: typing.Mapping[str, CollectionFunc] = { | collection_funcs: Mapping[str, CollectionFunc] = { | ||||
| 'automatic': self.collect_automatic, | 'automatic': self.collect_automatic, | ||||
| 'manual': self.collect_manual, | 'manual': self.collect_manual, | ||||
| 'managed': self.collect_managed, | 'managed': self.collect_managed, | ||||
| } | } | ||||
| try: | try: | ||||
| collection_func = collection_funcs[collection_method] | collection_func = collection_funcs[collection_method] | ||||
| except KeyError: | except KeyError: | ||||
| self.log.error("No known way to handle collection method %r for subscription %r" | self.log.error( | ||||
| "No known way to handle collection method %r for subscription %r" | |||||
| " order %r, handling as 'managed'", | " order %r, handling as 'managed'", | ||||
| collection_method, subscription.pk, order) | collection_method, | ||||
| subscription.pk, | |||||
| order, | |||||
| ) | |||||
| collection_func = collection_funcs['managed'] | collection_func = collection_funcs['managed'] | ||||
| try: | try: | ||||
| collection_ok = collection_func(subscription, order) | collection_ok = collection_func(subscription, order) | ||||
| except Exception as ex: | except Exception as ex: | ||||
| self.on_order_charge_failed(subscription, order, ex) | self.on_order_charge_failed(subscription, order, ex) | ||||
| return | return | ||||
| if not collection_ok: | if not collection_ok: | ||||
| return | return | ||||
| subscription.bump_next_payment() | subscription.bump_next_payment() | ||||
| my_log.info('Renewal of subscription %d successful', subscription.pk) | my_log.info('Renewal of subscription %d successful', subscription.pk) | ||||
| self.on_subscription_renewal_ok(subscription) | self.on_subscription_renewal_ok(subscription) | ||||
| def order_to_renew(self, subscription) -> typing.Optional[models.Order]: | def order_to_renew(self, subscription: models.Subscription) -> Optional[models.Order]: | ||||
| """Determine which order to use for renewals. | """Determine which order to use for renewals. | ||||
| This can be an existing order (when retrying), a new one (when not | This can be an existing order (when retrying), a new one (when not | ||||
| retrying), or None (for managed subscriptions). | retrying), or None (for managed subscriptions). | ||||
| """ | """ | ||||
| my_log = self.log.getChild('order_to_renew') | my_log = self.log.getChild('order_to_renew') | ||||
| # Figure out whether this is a fresh renewal or the re-try of a previously failed one. | # Figure out whether this is a fresh renewal or the re-try of a previously failed one. | ||||
| order = subscription.latest_order() | order = subscription.latest_order() | ||||
| is_retry = order and order.status == 'soft-failed' | is_retry = order and order.status == 'soft-failed' | ||||
| if is_retry: | if is_retry: | ||||
| my_log.info('Retrying renewal of subscription %d with status %r and ' | my_log.info( | ||||
| 'collection method %r', | 'Retrying renewal of subscription %d with status %r and collection method %r', | ||||
| subscription.pk, subscription.status, subscription.collection_method) | subscription.pk, | ||||
| subscription.status, | |||||
| subscription.collection_method, | |||||
| ) | |||||
| return order | return order | ||||
| my_log.info('Renewing subscription %d with status %r and collection method %r', | my_log.info( | ||||
| subscription.pk, subscription.status, subscription.collection_method) | 'Renewing subscription %d with status %r and collection method %r', | ||||
| subscription.pk, | |||||
| subscription.status, | |||||
| subscription.collection_method, | |||||
| ) | |||||
| subscription.intervals_elapsed += 1 | subscription.intervals_elapsed += 1 | ||||
| if subscription.collection_method == 'managed': | if subscription.collection_method == 'managed': | ||||
| # Don't bother creating a renewal order for managed subscriptions. | # Don't bother creating a renewal order for managed subscriptions. | ||||
| return None | return None | ||||
| return self.create_renewal_order(subscription) | return self.create_renewal_order(subscription) | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def cancel_subscription(self, subscription: models.Subscription) -> None: | def cancel_subscription(self, subscription: models.Subscription) -> None: | ||||
| """Cancel a pending-cancellation or on-hold-for-too-long subscription.""" | """Cancel a pending-cancellation or on-hold-for-too-long subscription.""" | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def create_renewal_order(self, subscription: models.Subscription) -> models.Order: | def create_renewal_order(self, subscription: models.Subscription) -> models.Order: | ||||
| """Create a new order for the subscription renewal.""" | """Create a new order for the subscription renewal.""" | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def collect_automatic(self, subscription: models.Subscription, | def collect_automatic( | ||||
| order: typing.Optional[models.Order]) -> bool: | self, subscription: models.Subscription, order: Optional[models.Order] | ||||
| ) -> bool: | |||||
| """Create a Transaction and try to charge it. | """Create a Transaction and try to charge it. | ||||
| :return: True when the charge was succesful, False otherwise. | :return: True when the charge was succesful, False otherwise. | ||||
| """ | """ | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def collect_manual(self, subscription: models.Subscription, | def collect_manual( | ||||
| order: typing.Optional[models.Order]) -> bool: | self, subscription: models.Subscription, order: Optional[models.Order] | ||||
| ) -> bool: | |||||
| """Create a Transaction but don't charge it yet. | """Create a Transaction but don't charge it yet. | ||||
| :return: True when the creation was succesful, False otherwise. | :return: True when the creation was succesful, False otherwise. | ||||
| """ | """ | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def collect_managed(self, subscription: models.Subscription, | def collect_managed( | ||||
| order: typing.Optional[models.Order]) -> bool: | self, subscription: models.Subscription, order: Optional[models.Order] | ||||
| ) -> bool: | |||||
| """Handle managed subscriptions surpassing their next_payment date. | """Handle managed subscriptions surpassing their next_payment date. | ||||
| This can send a `managed_subscription_notification` signal. | This can send a `managed_subscription_notification` signal. | ||||
| :return: always False, as managed collections never actually charge the account. | :return: always False, as managed collections never actually charge the account. | ||||
| """ | """ | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def on_order_creation_failed(self, subscription: models.Subscription, ex: Exception) -> None: | def on_order_creation_failed(self, subscription: models.Subscription, ex: Exception) -> None: | ||||
| """Called when creating the renewal order failed.""" | """Called when creating the renewal order failed.""" | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def on_order_charge_failed(self, subscription: models.Subscription, | def on_order_charge_failed( | ||||
| order: typing.Optional[models.Order], ex: Exception) -> None: | self, subscription: models.Subscription, order: Optional[models.Order], ex: Exception | ||||
| ) -> None: | |||||
| """Called when charging the renewal order failed. | """Called when charging the renewal order failed. | ||||
| This covers unexpected errors, and not GatewayErrors. These are | This covers unexpected errors, and not GatewayErrors. These are | ||||
| handled in `collect_automatic()` itself. | handled in `collect_automatic()` itself. | ||||
| """ | """ | ||||
| @abc.abstractmethod | @abc.abstractmethod | ||||
| def on_subscription_renewal_ok(self, subscription: models.Subscription) -> None: | def on_subscription_renewal_ok(self, subscription: models.Subscription) -> None: | ||||
| """Called after a succesful (probably automatic) renewal of the subscription.""" | """Called after a succesful (probably automatic) renewal of the subscription.""" | ||||
| class SimulatingClock(AbstractClock): | class SimulatingClock(AbstractClock): | ||||
| log = log.getChild('SimulatingClock') | log = log.getChild('SimulatingClock') | ||||
| def tick(self) -> None: | def tick(self) -> None: | ||||
| self.log.info('Performing simulated tick') | self.log.info('Performing simulated tick') | ||||
| super().tick() | super().tick() | ||||
| self.log.info('Simulated tick done') | self.log.info('Simulated tick done') | ||||
| def cancel_subscription(self, subscription: models.Subscription) -> None: | def cancel_subscription(self, subscription: models.Subscription) -> None: | ||||
| """Cancel a pending-cancellation or on-hold-for-too-long subscription.""" | """Cancel a pending-cancellation or on-hold-for-too-long subscription.""" | ||||
| self.log.info('Simulating cancellation of subscription %d with status %r', | self.log.info( | ||||
| subscription.pk, subscription.status) | 'Simulating cancellation of subscription %d with status %r', | ||||
| subscription.pk, | |||||
| subscription.status, | |||||
| ) | |||||
| def create_renewal_order(self, subscription: models.Subscription) -> models.Order: | def create_renewal_order(self, subscription: models.Subscription) -> models.Order: | ||||
| """Create a new order for the subscription renewal.""" | """Create a new order for the subscription renewal.""" | ||||
| self.log.info('Creating fake renewal order for subscription %d', subscription.pk) | self.log.info('Creating fake renewal order for subscription %d', subscription.pk) | ||||
| order = subscription.generate_order(save=False) | order = subscription.generate_order(save=False) | ||||
| return order | return order | ||||
| def collect_automatic(self, subscription: models.Subscription, | def collect_automatic( | ||||
| order: typing.Optional[models.Order]) -> bool: | self, subscription: models.Subscription, order: Optional[models.Order] | ||||
| ) -> bool: | |||||
| """Create an in-memory Transaction. | """Create an in-memory Transaction. | ||||
| :return: always True. | :return: always True. | ||||
| """ | """ | ||||
| self.log.info('Simulating renewal order charge for subscription %d', subscription.pk) | self.log.info('Simulating renewal order charge for subscription %d', subscription.pk) | ||||
| assert order is not None, 'Automatically-renewing subscriptions should always have an order' | assert order is not None, 'Automatically-renewing subscriptions should always have an order' | ||||
| order.collection_attempts += 1 | order.collection_attempts += 1 | ||||
| order.generate_transaction(save=False) | order.generate_transaction(save=False) | ||||
| return True | return True | ||||
| def collect_manual(self, subscription: models.Subscription, | def collect_manual( | ||||
| order: typing.Optional[models.Order]) -> bool: | self, subscription: models.Subscription, order: Optional[models.Order] | ||||
| ) -> bool: | |||||
| """Create an in-memory Transaction. | """Create an in-memory Transaction. | ||||
| :return: always False as manual collections never actually charge the account. | :return: always False as manual collections never actually charge the account. | ||||
| """ | """ | ||||
| self.log.info('Simulating renewal order creation for subscription %d', | self.log.info('Simulating renewal order creation for subscription %d', subscription.pk) | ||||
| subscription.pk) | |||||
| return False | return False | ||||
| def collect_managed(self, subscription: models.Subscription, | def collect_managed( | ||||
| order: typing.Optional[models.Order]) -> bool: | self, subscription: models.Subscription, order: Optional[models.Order] | ||||
| ) -> bool: | |||||
| """Handle managed subscriptions surpassing their next_payment date. | """Handle managed subscriptions surpassing their next_payment date. | ||||
| This can send a `managed_subscription_notification` signal. | This can send a `managed_subscription_notification` signal. | ||||
| :return: always False, as managed collections never actually charge the account. | :return: always False, as managed collections never actually charge the account. | ||||
| """ | """ | ||||
| self.log.info('Simulating managed subscription notification for subscription %d', | self.log.info( | ||||
| subscription.pk) | 'Simulating managed subscription notification for subscription %d', subscription.pk | ||||
| ) | |||||
| return False | return False | ||||
| def on_order_creation_failed(self, subscription: models.Subscription, ex: Exception) -> None: | def on_order_creation_failed(self, subscription: models.Subscription, ex: Exception) -> None: | ||||
| """Called when creating the renewal order failed.""" | """Called when creating the renewal order failed.""" | ||||
| self.log.info('Simulated error creating renewal order for subscription %d', subscription.pk) | self.log.info('Simulated error creating renewal order for subscription %d', subscription.pk) | ||||
| def on_order_charge_failed(self, subscription: models.Subscription, | def on_order_charge_failed( | ||||
| order: typing.Optional[models.Order], ex: Exception) -> None: | self, subscription: models.Subscription, order: Optional[models.Order], ex: Exception | ||||
| ) -> None: | |||||
| """Called when charging the renewal order failed.""" | """Called when charging the renewal order failed.""" | ||||
| self.log.info('Simulated error charging renewal order %s for subscription %d', | self.log.info( | ||||
| order.pk if order else '-no order-', subscription.pk) | 'Simulated error charging renewal order %s for subscription %d', | ||||
| order.pk if order else '-no order-', | |||||
| subscription.pk, | |||||
| ) | |||||
| def on_subscription_renewal_ok(self, subscription: models.Subscription) -> None: | def on_subscription_renewal_ok(self, subscription: models.Subscription) -> None: | ||||
| pass | pass | ||||
| class Clock(AbstractClock): | class Clock(AbstractClock): | ||||
| log = log.getChild('Clock') | log = log.getChild('Clock') | ||||
| @django.db.transaction.atomic() | @django.db.transaction.atomic() | ||||
| def renew_subscription(self, subscription: models.Subscription) -> None: | def renew_subscription(self, subscription: models.Subscription) -> None: | ||||
| super().renew_subscription(subscription) | super().renew_subscription(subscription) | ||||
| subscription.save() | subscription.save() | ||||
| @django.db.transaction.atomic() | @django.db.transaction.atomic() | ||||
| def cancel_subscription(self, subscription: models.Subscription) -> None: | def cancel_subscription(self, subscription: models.Subscription) -> None: | ||||
| """Cancel a pending-cancellation or on-hold-for-too-long subscription.""" | """Cancel a pending-cancellation or on-hold-for-too-long subscription.""" | ||||
| my_log = self.log.getChild('cancel_subscription') | my_log = self.log.getChild('cancel_subscription') | ||||
| old_status = subscription.status | old_status = subscription.status | ||||
| my_log.info('Cancelling subscription %d with status %r', | my_log.info('Cancelling subscription %d with status %r', subscription.pk, old_status) | ||||
| subscription.pk, old_status) | |||||
| subscription.status = 'cancelled' | subscription.status = 'cancelled' | ||||
| subscription.save(update_fields={'status'}) | subscription.save(update_fields={'status'}) | ||||
| admin_log.attach_log_entry(subscription, | admin_log.attach_log_entry( | ||||
| subscription, | |||||
| f'Subscription period ended, transitioning from ' | f'Subscription period ended, transitioning from ' | ||||
| f'{old_status!r} to {subscription.status!r}') | f'{old_status!r} to {subscription.status!r}', | ||||
| ) | |||||
| def create_renewal_order(self, subscription: models.Subscription) -> models.Order: | def create_renewal_order(self, subscription: models.Subscription) -> models.Order: | ||||
| """Create a new order for the subscription renewal.""" | """Create a new order for the subscription renewal.""" | ||||
| my_log = self.log.getChild('create_renewal_order') | my_log = self.log.getChild('create_renewal_order') | ||||
| my_log.info('Creating renewal order for subscription %d', subscription.pk) | my_log.info('Creating renewal order for subscription %d', subscription.pk) | ||||
| order = subscription.generate_order() | order = subscription.generate_order() | ||||
| admin_log.attach_log_entry(subscription, f'Created order {order.pk} for renewal') | admin_log.attach_log_entry(subscription, f'Created order {order.pk} for renewal') | ||||
| return order | return order | ||||
| def collect_automatic(self, subscription: models.Subscription, | def collect_automatic( | ||||
| order: typing.Optional[models.Order]) -> bool: | self, subscription: models.Subscription, order: Optional[models.Order] | ||||
| ) -> bool: | |||||
| """Create a transaction and try to charge it.""" | """Create a transaction and try to charge it.""" | ||||
| my_log = self.log.getChild('collect_automatic') | my_log = self.log.getChild('collect_automatic') | ||||
| assert order is not None, 'Automatic collection always has an order.' | assert order is not None, 'Automatic collection always has an order.' | ||||
| trans: models.Transaction = order.generate_transaction() | trans: models.Transaction = order.generate_transaction() | ||||
| my_log.info('Charging transaction %d for subscription %d and order %d', | my_log.info( | ||||
| trans.pk, order.subscription.pk, order.pk) | 'Charging transaction %d for subscription %d and order %d', | ||||
| trans.pk, | |||||
| order.subscription.pk, | |||||
| order.pk, | |||||
| ) | |||||
| order.collection_attempts += 1 | order.collection_attempts += 1 | ||||
| charge_ok = trans.charge(customer_ip_address=None) | charge_ok = trans.charge(customer_ip_address=None) | ||||
| if not charge_ok: | if not charge_ok: | ||||
| self.on_collect_automatic_failed(order, trans) | self.on_collect_automatic_failed(order, trans) | ||||
| else: | else: | ||||
| order.retry_after = None | order.retry_after = None | ||||
| signals.automatic_payment_succesful.send(sender=order, transaction=trans) | signals.automatic_payment_succesful.send(sender=order, transaction=trans) | ||||
| order.save(update_fields={'collection_attempts', 'status', 'retry_after'}) | order.save(update_fields={'collection_attempts', 'status', 'retry_after'}) | ||||
| return charge_ok | return charge_ok | ||||
| def collect_manual(self, subscription: models.Subscription, | def collect_manual( | ||||
| order: typing.Optional[models.Order]) -> bool: | self, subscription: models.Subscription, order: Optional[models.Order] | ||||
| ) -> bool: | |||||
| """Just deactivate the subscription. | """Just deactivate the subscription. | ||||
| :return: Always False, because we don't collect the payment in this function. | :return: Always False, because we don't collect the payment in this function. | ||||
| """ | """ | ||||
| my_log = self.log.getChild('collect_manual') | my_log = self.log.getChild('collect_manual') | ||||
| assert order is not None, 'Manually-collected subscriptions should always have an order' | assert order is not None, 'Manually-collected subscriptions should always have an order' | ||||
| my_log.info('Placing subscription %d with order %d on-hold ' | my_log.info( | ||||
| 'for manual renewal', order.pk, subscription.pk) | 'Placing subscription %d with order %d on-hold for manual renewal', | ||||
| order.pk, | |||||
| subscription.pk, | |||||
| ) | |||||
| if not subscription.may_transition_to('on-hold'): | if not subscription.may_transition_to('on-hold'): | ||||
| my_log.warning("Unable to transition subscription %d from %r to 'on-hold' " | my_log.warning( | ||||
| "Unable to transition subscription %d from %r to 'on-hold' " | |||||
| "for manual renewal with order %d.", | "for manual renewal with order %d.", | ||||
| subscription.pk, subscription.status, order.pk) | subscription.pk, | ||||
| subscription.status, | |||||
| order.pk, | |||||
| ) | |||||
| admin_log.attach_log_entry( | admin_log.attach_log_entry( | ||||
| subscription, | subscription, | ||||
| f"Unable to transition subscription from {subscription.status} to 'on-hold' for " | f"Unable to transition subscription from {subscription.status} to 'on-hold' for " | ||||
| f"manual renewal with order {order.pk}") | f"manual renewal with order {order.pk}", | ||||
| ) | |||||
| return False | return False | ||||
| subscription.status = 'on-hold' | subscription.status = 'on-hold' | ||||
| subscription.save(update_fields={'status'}) | subscription.save(update_fields={'status'}) | ||||
| admin_log.attach_log_entry(subscription, f"Transitioned subscription to 'on-hold' " | admin_log.attach_log_entry( | ||||
| f"awaiting manual payment of order {order.pk}") | subscription, | ||||
| f"Transitioned subscription to 'on-hold' " | |||||
| f"awaiting manual payment of order {order.pk}", | |||||
| ) | |||||
| return False | return False | ||||
| def collect_managed(self, subs: models.Subscription, | def collect_managed(self, subs: models.Subscription, order: Optional[models.Order]) -> bool: | ||||
| order: typing.Optional[models.Order]) -> bool: | |||||
| """Handle managed subscriptions surpassing their next_payment date. | """Handle managed subscriptions surpassing their next_payment date. | ||||
| This can send a `managed_subscription_notification` signal. | This can send a `managed_subscription_notification` signal. | ||||
| """ | """ | ||||
| my_log = self.log.getChild('collect_managed') | my_log = self.log.getChild('collect_managed') | ||||
| if not subs.notification_required: | if not subs.notification_required: | ||||
| my_log.info("Not notifying for 'managed' subscription %d", subs.pk) | my_log.info("Not notifying for 'managed' subscription %d", subs.pk) | ||||
| return False | return False | ||||
| my_log.info('Notifying manager that managed subscription pk=%r surpassed its next_payment', | my_log.info( | ||||
| subs.pk) | 'Notifying manager that managed subscription pk=%r surpassed its next_payment', subs.pk | ||||
| ) | |||||
| subs.last_notification = self.now() | subs.last_notification = self.now() | ||||
| subs.save(update_fields={'last_notification'}) | subs.save(update_fields={'last_notification'}) | ||||
| signals.managed_subscription_notification.send(sender=subs) | signals.managed_subscription_notification.send(sender=subs) | ||||
| return False | return False | ||||
| def on_order_creation_failed(self, subscription: models.Subscription, ex: Exception) -> None: | def on_order_creation_failed(self, subscription: models.Subscription, ex: Exception) -> None: | ||||
| """Called when creating the renewal order failed.""" | """Called when creating the renewal order failed.""" | ||||
| my_log = self.log.getChild('on_order_creation_failed') | my_log = self.log.getChild('on_order_creation_failed') | ||||
| if isinstance(ex, exceptions.GatewayError): | if isinstance(ex, exceptions.GatewayError): | ||||
| prefix = 'Error' | prefix = 'Error' | ||||
| logger = my_log.error | logger = my_log.error | ||||
| else: | else: | ||||
| prefix = 'Unexpected error' | prefix = 'Unexpected error' | ||||
| logger = my_log.exception | logger = my_log.exception | ||||
| logger(f'{prefix} creating renewal order for subscription %d: %s', subscription.pk, ex) | logger(f'{prefix} creating renewal order for subscription %d: %s', subscription.pk, ex) | ||||
| admin_log.attach_log_entry(subscription, f'{prefix} creating renewal order: {ex}') | admin_log.attach_log_entry(subscription, f'{prefix} creating renewal order: {ex}') | ||||
| self._deactivate_subscription(subscription) | self._deactivate_subscription(subscription) | ||||
| def on_order_charge_failed(self, subscription: models.Subscription, | def on_order_charge_failed( | ||||
| order: typing.Optional[models.Order], ex: Exception) -> None: | self, subscription: models.Subscription, order: Optional[models.Order], ex: Exception | ||||
| ) -> None: | |||||
| """Called when charging the renewal order failed.""" | """Called when charging the renewal order failed.""" | ||||
| my_log = self.log.getChild('on_order_charge_failed') | my_log = self.log.getChild('on_order_charge_failed') | ||||
| my_log.exception('Unexpected error charging renewal order %s for subscription %d: %s', | my_log.exception( | ||||
| order.pk if order else '-no order-', subscription.pk, ex) | 'Unexpected error charging renewal order %s for subscription %d: %s', | ||||
| order.pk if order else '-no order-', | |||||
| subscription.pk, | |||||
| ex, | |||||
| ) | |||||
| # Don't deactivate the subscription here; this could very well be an issue on our | # Don't deactivate the subscription here; this could very well be an issue on our | ||||
| # end (bad Braintree credentials, network connection gone down, etc.), so we | # end (bad Braintree credentials, network connection gone down, etc.), so we | ||||
| # shouldn't punish our customers for this. | # shouldn't punish our customers for this. | ||||
| if order: | if order: | ||||
| admin_log.attach_log_entry(order, f'Unexpected error charging order: {ex}') | admin_log.attach_log_entry(order, f'Unexpected error charging order: {ex}') | ||||
| else: | else: | ||||
| admin_log.attach_log_entry(subscription, f'Unexpected error charging order: {ex}') | admin_log.attach_log_entry(subscription, f'Unexpected error charging order: {ex}') | ||||
| def on_collect_automatic_failed(self, | def on_collect_automatic_failed(self, order: models.Order, trans: models.Transaction) -> None: | ||||
| order: models.Order, | |||||
| trans: models.Transaction) -> None: | |||||
| """Update the order and transaction for the failure.""" | """Update the order and transaction for the failure.""" | ||||
| max_attempts: int = settings.LOOPER_CLOCK_MAX_AUTO_ATTEMPTS | max_attempts: int = settings.LOOPER_CLOCK_MAX_AUTO_ATTEMPTS | ||||
| # This is logged at info level, because it is actually expected to happen frequently. | # This is logged at info level, because it is actually expected to happen frequently. | ||||
| my_log = self.log.getChild('on_collect_automatic_failed') | my_log = self.log.getChild('on_collect_automatic_failed') | ||||
| msg = f'Error charging transaction {trans.pk} for renewal order {order.pk} for ' \ | msg = ( | ||||
| f'subscription {order.subscription.pk}, attempt {order.collection_attempts} ' \ | f'Error charging transaction {trans.pk} for renewal order {order.pk} for ' | ||||
| f'subscription {order.subscription.pk}, attempt {order.collection_attempts} ' | |||||
| f'of {max_attempts}: {trans.failure_message}' | f'of {max_attempts}: {trans.failure_message}' | ||||
| ) | |||||
| my_log.info(msg) | my_log.info(msg) | ||||
| if order.collection_attempts >= max_attempts: | if order.collection_attempts >= max_attempts: | ||||
| admin_log.attach_log_entry( | admin_log.attach_log_entry( | ||||
| order, f'{msg}\nDeactivating subscription and failing order.') | order, f'{msg}\nDeactivating subscription and failing order.' | ||||
| ) | |||||
| self._deactivate_subscription(order.subscription) | self._deactivate_subscription(order.subscription) | ||||
| order.status = 'failed' | order.status = 'failed' | ||||
| signals.automatic_payment_failed.send(sender=order, transaction=trans) | signals.automatic_payment_failed.send(sender=order, transaction=trans) | ||||
| else: | else: | ||||
| admin_log.attach_log_entry( | admin_log.attach_log_entry( | ||||
| order, f'{msg}\nKeeping subscription status {order.subscription.status!r} ' | order, | ||||
| f'and retrying later') | f'{msg}\nKeeping subscription status {order.subscription.status!r} ' | ||||
| f'and retrying later', | |||||
| ) | |||||
| now = django.utils.timezone.now() | now = django.utils.timezone.now() | ||||
| order.retry_after = now + settings.LOOPER_ORDER_RETRY_AFTER | order.retry_after = now + settings.LOOPER_ORDER_RETRY_AFTER | ||||
| order.status = 'soft-failed' | order.status = 'soft-failed' | ||||
| signals.automatic_payment_soft_failed.send(sender=order, transaction=trans) | signals.automatic_payment_soft_failed.send(sender=order, transaction=trans) | ||||
| def _deactivate_subscription(self, subscription: models.Subscription) -> None: | def _deactivate_subscription(self, subscription: models.Subscription) -> None: | ||||
| """Set a subscription to 'on-hold' after a failure. | """Set a subscription to 'on-hold' after a failure. | ||||
| This is a no-op when the subscription status is not 'active'. | This is a no-op when the subscription status is not 'active'. | ||||
| """ | """ | ||||
| if subscription.status != 'active': | if subscription.status != 'active': | ||||
| return | return | ||||
| subscription.status = 'on-hold' | subscription.status = 'on-hold' | ||||
| my_log = self.log.getChild('_deactivate_subscription') | my_log = self.log.getChild('_deactivate_subscription') | ||||
| my_log.info('Setting subscription %d to %r due to renewal failure.', | my_log.info( | ||||
| subscription.pk, subscription.status) | 'Setting subscription %d to %r due to renewal failure.', | ||||
| subscription.pk, | |||||
| subscription.status, | |||||
| ) | |||||
| subscription.save(update_fields={'status'}) | subscription.save(update_fields={'status'}) | ||||
| def on_subscription_renewal_ok(self, subscription: models.Subscription) -> None: | def on_subscription_renewal_ok(self, subscription: models.Subscription) -> None: | ||||
| """Called after a succesful (probably automatic) renewal of the subscription.""" | """Called after a succesful (probably automatic) renewal of the subscription.""" | ||||
| admin_log.attach_log_entry( | admin_log.attach_log_entry( | ||||
| subscription, | subscription, | ||||
| f"{subscription.collection_method.title()} renewal successful. Updated " | f"{subscription.collection_method.title()} renewal successful. Updated " | ||||
| f"next payment to {subscription.next_payment}") | f"next payment to {subscription.next_payment}", | ||||
| ) | |||||