diff --git a/settings.py b/settings.py index 3fed9a0b..fd32e76d 100644 --- a/settings.py +++ b/settings.py @@ -167,6 +167,10 @@ 'task': 'silver.tasks.generate_pdfs', 'schedule': datetime.timedelta(seconds=5) }, + 'retry-transactions': { + 'task': 'silver.tasks.retry_transactions', + 'schedule': datetime.timedelta(hours=1) + } } LOCK_MANAGER_CONNECTION = {'host': 'localhost', 'port': 6379, 'db': 1} diff --git a/silver/admin.py b/silver/admin.py index c18c14b9..17425327 100644 --- a/silver/admin.py +++ b/silver/admin.py @@ -893,11 +893,11 @@ def _model_name(self): class TransactionForm(forms.ModelForm): class Meta: model = Transaction - fields = ['proforma', 'invoice', 'amount', 'currency', 'state', + fields = ['proforma', 'invoice', 'amount', 'currency', 'state', 'retried_transaction', 'payment_method', 'uuid', 'valid_until', 'last_access', 'data', 'fail_code', 'cancel_code', 'refund_code'] - readonly_fields = ['state', 'uuid', 'last_access'] + readonly_fields = ['state', 'uuid', 'last_access', 'retried_transaction'] create_only_fields = ['amount', 'currency', 'proforma', 'invoice', 'payment_method', 'valid_until'] @@ -942,10 +942,10 @@ class TransactionAdmin(ModelAdmin): list_display = ('uuid', 'related_invoice', 'related_proforma', 'amount', 'state', 'created_at', 'updated_at', 'get_customer', 'get_pay_url', 'get_payment_method', - 'get_is_recurring') + 'get_is_recurring', 'get_is_retrial') list_filter = ('payment_method__customer', 'state', 'payment_method__payment_processor') - actions = ['execute', 'process', 'cancel', 'settle', 'fail'] + actions = ['execute', 'process', 'cancel', 'settle', 'fail', 'retry'] ordering = ['-created_at'] def get_queryset(self, request): @@ -971,11 +971,19 @@ def get_customer(self, obj): return u'%s' % (link, obj.payment_method.customer) get_customer.allow_tags = True get_customer.short_description = 'Customer' + get_customer.admin_order_field = 'payment_method__customer' def get_is_recurring(self, obj): return obj.payment_method.verified get_is_recurring.boolean = True get_is_recurring.short_description = 'Recurring' + get_is_recurring.admin_order_field = 'verified' + + def get_is_retrial(self, obj): + return bool(obj.retried_transaction) + get_is_retrial.boolean = True + get_is_retrial.short_description = 'Retrial' + get_is_retrial.admin_order_field = 'retried_transaction' def get_payment_method(self, obj): link = urlresolvers.reverse("admin:silver_paymentmethod_change", @@ -983,6 +991,7 @@ def get_payment_method(self, obj): return u'%s' % (link, obj.payment_method) get_payment_method.allow_tags = True get_payment_method.short_description = 'Payment Method' + get_payment_method.admin_order_field = 'payment_method' def perform_action(self, request, queryset, action, display_verb=None): failed_count = 0 @@ -998,7 +1007,7 @@ def perform_action(self, request, queryset, action, display_verb=None): try: method(transaction) transaction.save() - except TransitionNotAllowed: + except (TransitionNotAllowed, ValidationError): failed_count += 1 settled_count = transactions_count - failed_count @@ -1098,16 +1107,21 @@ def fail(self, request, queryset): self.perform_action(request, queryset, 'fail', 'failed') fail.short_description = 'Fail the selected transactions' + def retry(self, request, queryset): + self.perform_action(request, queryset, 'retry', 'retried') + retry.short_description = 'Retry the selected transactions' + def related_invoice(self, obj): return obj.invoice.admin_change_url if obj.invoice else None related_invoice.allow_tags = True related_invoice.short_description = 'Invoice' + related_invoice.admin_order_field = 'invoice' def related_proforma(self, obj): return obj.proforma.admin_change_url if obj.proforma else None related_proforma.allow_tags = True related_proforma.short_description = 'Proforma' - + related_proforma.admin_order_field = 'proforma' class PaymentMethodAdmin(ModelAdmin): list_display = ('customer', 'payment_processor', 'added_at', 'verified', diff --git a/silver/models/billing_entities/provider.py b/silver/models/billing_entities/provider.py index b6dc1b0f..be8d1400 100644 --- a/silver/models/billing_entities/provider.py +++ b/silver/models/billing_entities/provider.py @@ -25,6 +25,7 @@ from django.utils.translation import ugettext_lazy as _ from silver.models.billing_entities.base import BaseBillingEntity +from silver.retry_patterns import RetryPatterns PAYMENT_DUE_DAYS = getattr(settings, 'SILVER_DEFAULT_DUE_DAYS', 5) @@ -106,6 +107,10 @@ class Meta: "date will appear to be the end of the cycle billing duration." ) + transaction_maximum_automatic_retries = models.PositiveIntegerField(default=5) + transaction_retry_pattern = models.CharField(choices=RetryPatterns.as_choices(), max_length=16, + default=RetryPatterns.as_choices()[0][0]) + def __init__(self, *args, **kwargs): super(Provider, self).__init__(*args, **kwargs) company_field = self._meta.get_field("company") diff --git a/silver/models/transactions/transaction.py b/silver/models/transactions/transaction.py index 38fbff44..f01c9e25 100644 --- a/silver/models/transactions/transaction.py +++ b/silver/models/transactions/transaction.py @@ -16,14 +16,14 @@ import uuid import logging - +from datetime import timedelta from decimal import Decimal from annoying.fields import JSONField from annoying.functions import get_object_or_None from django_fsm import FSMField, post_transition, transition -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.core.validators import MinValueValidator from django.db import models, transaction from django.db.models import Q @@ -35,6 +35,7 @@ from silver.models import Invoice, Proforma from silver.models.transactions.codes import FAIL_CODES, REFUND_CODES, CANCEL_CODES +from silver.retry_patterns import RetryPatterns from silver.utils.international import currencies from silver.utils.models import AutoDateTimeField @@ -46,6 +47,7 @@ class Transaction(models.Model): _provider = None + uuid = models.UUIDField(default=uuid.uuid4) amount = models.DecimalField( decimal_places=2, max_digits=12, validators=[MinValueValidator(Decimal('0.00'))] @@ -88,13 +90,35 @@ def as_choices(cls): related_name='invoice_transactions') payment_method = models.ForeignKey('PaymentMethod') - uuid = models.UUIDField(default=uuid.uuid4) + valid_until = models.DateTimeField(null=True, blank=True) last_access = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(default=timezone.now) updated_at = AutoDateTimeField(default=timezone.now) + class RetryTypes: + Automatic = 'automatic' + Staff = 'staff' + Customer = 'customer' + PaymentProcessor = 'payment_processor' + + @classmethod + def as_list(cls): + return [getattr(cls, state) for state in vars(cls).keys() if + state[0].isupper()] + + @classmethod + def as_choices(cls): + return ( + (state, _(state.capitalize())) for state in cls.as_list() + ) + + retrial_type = models.CharField(choices=RetryTypes.as_choices(), max_length=16, + null=True, blank=True) + retried_transaction = models.OneToOneField('self', related_name='retried_by', + null=True, blank=True) + fail_code = models.CharField( choices=[(code, code) for code in FAIL_CODES.keys()], max_length=32, null=True, blank=True @@ -147,6 +171,77 @@ def refund(self, refund_code='default', refund_reason='Unknown refund reason'): self.refund_code = refund_code logger.error(str(refund_reason)) + @property + def automatic_retries_count(self): + if not self.retried_transaction: + return 0 + + if self.retrial_type == self.RetryTypes.Automatic: + return self.retried_transaction.automatic_retries_count + 1 + + return self.retried_transaction.automatic_retries_count + + @property + def last_automatic_retried_transaction(self): + transaction = self + + while transaction: + if transaction.retrial_type == self.RetryTypes.Automatic: + return transaction.retried_transaction + + transaction = transaction.retried_transaction + + @property + def next_retry_datetime(self): + pattern_method = RetryPatterns.get_pattern_method(self.provider.transaction_retry_pattern) + days_gap = pattern_method(self.automatic_retries_count) + + return self.created_at + timedelta(days=days_gap) + + @property + def can_be_retried(self): + if not self.state == self.States.Failed: + return False + + try: + self.retried_by + except ObjectDoesNotExist: + pass + else: + return False + + if self.document.state != self.document.STATES.ISSUED: + return False + + return True + + @property + def should_be_automatically_retried(self): + if not self.can_be_retried: + return False + + automatic_retries_count = self.automatic_retries_count + if automatic_retries_count >= self.provider.transaction_maximum_automatic_retries: + return False + + if timezone.now() < self.next_retry_datetime: + return False + + return True + + def retry(self, payment_method=None, retrial_type=RetryTypes.Automatic): + if not self.can_be_retried: + raise ValidationError('The transaction cannot be retried.') + + payment_method = payment_method or self.payment_method + + if not payment_method.verified or payment_method.canceled: + raise ValidationError({'payment_method': 'The payment method is not recurring.'}) + + return Transaction.objects.create(amount=self.amount, currency=self.currency, + retried_transaction=self, retrial_type=retrial_type, + document=self.document, payment_method=payment_method) + @transaction.atomic() def save(self, *args, **kwargs): previous_instance = get_object_or_None(Transaction, pk=self.pk) if self.pk else None @@ -221,7 +316,7 @@ def clean(self): raise ValidationError(message) if self.amount: if self.amount > self.document.amount_to_be_charged_in_transaction_currency: - message = "Amount is greater than the amount that should be charged in order " \ + message = "Amount is greater than what should be charged in order " \ "to pay the billing document." raise ValidationError(message) else: diff --git a/silver/retry_patterns.py b/silver/retry_patterns.py new file mode 100644 index 00000000..c846c750 --- /dev/null +++ b/silver/retry_patterns.py @@ -0,0 +1,37 @@ +def daily(count): + return 1 + + +def exponential(count): + return 2 ** count + + +def fibonacci(count): + previous = current = 1 + + for i in range(2, count): + previous, current = current, previous + current + + return current + + +class RetryPatterns: + patterns = { + 'daily': daily, + 'exponential': exponential, + 'fibonacci': fibonacci + } + + @classmethod + def as_list(cls): + return cls.patterns.keys() + + @classmethod + def as_choices(cls): + return list( + (pattern, pattern.capitalize()) for pattern in cls.patterns + ) + + @classmethod + def get_pattern_method(cls, name): + return cls.patterns.get(name) diff --git a/silver/tasks.py b/silver/tasks.py index 0be12633..fc773987 100644 --- a/silver/tasks.py +++ b/silver/tasks.py @@ -14,21 +14,25 @@ from __future__ import absolute_import +import logging from itertools import chain from celery import group, shared_task from celery_once import QueueOnce -from redis.exceptions import LockError from django.conf import settings +from django.db.models import Q from django.utils import timezone from silver.documents_generator import DocumentsGenerator -from silver.models import Invoice, Proforma, Transaction, BillingDocumentBase +from silver.models import Invoice, Proforma, Transaction, PaymentMethod, BillingDocumentBase from silver.payment_processors.mixins import PaymentProcessorTypes from silver.vendors.redis_server import redis +logger = logging.getLogger(__name__) + + PDF_GENERATION_TIME_LIMIT = getattr(settings, 'PDF_GENERATION_TIME_LIMIT', 60) # default 60s @@ -121,3 +125,25 @@ def execute_transactions(transaction_ids=None): executable_transactions = executable_transactions.filter(pk__in=transaction_ids) group(execute_transaction.s(transaction.id) for transaction in executable_transactions)() + + + +@shared_task() +def retry_transactions(): + for transaction in Transaction.objects.filter(Q(invoice__state=Invoice.STATES.ISSUED) | + Q(proforma__state=Proforma.STATES.ISSUED), + state=Transaction.States.Failed, + retried_by=None): + if not transaction.should_be_retried: + continue + + for payment_method in PaymentMethod.objects.filter(customer=transaction.customer, + verified=True, canceled=False): + try: + transaction.retry(payment_method=payment_method) + break + except Exception: + logger.exception('[Tasks][Transaction]: %s', { + 'detail': 'There was an error while retrying the transaction.', + 'transaction_id': transaction.id + }) diff --git a/silver/tests/api/test_transactions.py b/silver/tests/api/test_transactions.py index b44c4d5c..07881e34 100644 --- a/silver/tests/api/test_transactions.py +++ b/silver/tests/api/test_transactions.py @@ -373,7 +373,7 @@ def test_add_transaction_with_amount_greater_than_what_should_be_charged(self): response = self.client.post(url, format='json', data=data) expected_data = { - 'non_field_errors': ["Amount is greater than the amount that should be charged in " + 'non_field_errors': ["Amount is greater than what should be charged in " "order to pay the billing document."] } self.assertEqual(response.data, expected_data) diff --git a/silver/tests/integration/test_documents_transactions.py b/silver/tests/integration/test_documents_transactions.py index 3acb7fa9..d9ed6efd 100644 --- a/silver/tests/integration/test_documents_transactions.py +++ b/silver/tests/integration/test_documents_transactions.py @@ -132,7 +132,7 @@ def test_no_transaction_creation_for_issued_documents_case3(self): mock_execute = MagicMock() with patch.multiple(TriggeredProcessor, execute_transaction=mock_execute): expected_exception = ValidationError - expected_message = "Amount is greater than the amount that should be " \ + expected_message = "Amount is greater than what should be " \ "charged in order to pay the billing document." try: TransactionFactory.create(invoice=invoice, diff --git a/silver/tests/unit/test_transactions.py b/silver/tests/unit/test_transactions.py new file mode 100644 index 00000000..2cae4803 --- /dev/null +++ b/silver/tests/unit/test_transactions.py @@ -0,0 +1,208 @@ +import pytest +from datetime import timedelta +from django.core.exceptions import ValidationError +from django.utils import timezone +from mock import patch + +from silver.models import Transaction, Invoice +from silver.retry_patterns import RetryPatterns +from silver.tests.factories import TransactionFactory, PaymentMethodFactory, InvoiceFactory, \ + DocumentEntryFactory, ProviderFactory + + +@pytest.mark.django_db +def test_retry_transaction(): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method) + + assert transaction.retry() + + +@pytest.mark.django_db +def test_retry_already_retried_transaction(): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method) + + TransactionFactory.create(retried_transaction=transaction) + + with pytest.raises(ValidationError) as exception_info: + transaction.retry() + assert str(exception_info.value) == "[u'The transaction cannot be retried.']" + + +@pytest.mark.django_db +def test_retry_transaction_with_canceled_payment_method(): + payment_method = PaymentMethodFactory.create(verified=True, canceled=True) + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method) + + with pytest.raises(ValidationError) as exception_info: + transaction.retry() + assert str(exception_info.value) == "{'payment_method': " \ + "[u'The payment method is not recurring.']}" + + +@pytest.mark.django_db +def test_retry_transaction_of_paid_billing_document(): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + + invoice = InvoiceFactory.create(customer=payment_method.customer, state=Invoice.STATES.ISSUED) + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method, + invoice=invoice) + + invoice.pay() + + with pytest.raises(ValidationError) as exception_info: + transaction.retry() + assert str(exception_info.value) == "[u'The transaction cannot be retried.']" + + +@pytest.mark.django_db +def test_retry_transaction_of_canceled_billing_document(): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + + invoice = InvoiceFactory.create(customer=payment_method.customer, state=Invoice.STATES.ISSUED) + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method, + invoice=invoice) + + invoice.cancel() + + with pytest.raises(ValidationError) as exception_info: + transaction.retry() + assert str(exception_info.value) == "[u'The transaction cannot be retried.']" + + +@pytest.mark.django_db +def test_retry_transaction_with_amount_greater_than_remaining_payable_amount(): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + + entry = DocumentEntryFactory(quantity=1, unit_price=200) + invoice = InvoiceFactory.create(customer=payment_method.customer, state=Invoice.STATES.ISSUED, + invoice_entries=[entry]) + + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method, + invoice=invoice, amount=150) + + TransactionFactory.create(state=Transaction.States.Settled, + payment_method=payment_method, + invoice=invoice, amount=100) + + with pytest.raises(ValidationError) as exception_info: + transaction.retry() + assert str(exception_info.value) == "{'__all__': [u'Amount is greater than what should be " \ + "charged in order to pay the billing document.']}" + + +@pytest.mark.parametrize('state', [Transaction.States.Initial, + Transaction.States.Pending, + Transaction.States.Refunded, + Transaction.States.Settled]) +@pytest.mark.django_db +def test_retry_non_failed_transaction(state): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + + transaction = TransactionFactory.create(state=state, payment_method=payment_method) + + with pytest.raises(ValidationError) as exception_info: + transaction.retry() + assert str(exception_info.value) == "[u'The transaction cannot be retried.']" + + +@pytest.mark.django_db +def test_automatic_retries_count(): + transaction = TransactionFactory.create(state=Transaction.States.Failed) + assert transaction.automatic_retries_count == 0 + + automatic_retrial = TransactionFactory.create(state=Transaction.States.Failed, + retried_transaction=transaction, + retrial_type=Transaction.RetryTypes.Automatic) + + assert automatic_retrial.automatic_retries_count == 1 + + staff_retrial = TransactionFactory.create(state=Transaction.States.Failed, + retried_transaction=automatic_retrial, + retrial_type=Transaction.RetryTypes.Staff) + + assert staff_retrial.automatic_retries_count == 1 + + last_automatic_retrial = TransactionFactory.create( + state=Transaction.States.Failed, + retried_transaction=staff_retrial, + retrial_type=Transaction.RetryTypes.Automatic + ) + + assert last_automatic_retrial.automatic_retries_count == 2 + + +@pytest.mark.django_db +def test_next_retry_datetime_daily(monkeypatch): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + + provider = ProviderFactory.create(transaction_retry_pattern='daily') + + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method) + + monkeypatch.setattr('silver.models.Transaction.provider', provider) + monkeypatch.setattr('silver.models.Transaction.automatic_retries_count', 3) + + assert transaction.next_retry_datetime == transaction.created_at + timedelta(days=1) + + +@pytest.mark.django_db +def test_next_retry_datetime_exponential(monkeypatch): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + + provider = ProviderFactory.create(transaction_retry_pattern='exponential') + + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method) + + monkeypatch.setattr('silver.models.Transaction.provider', provider) + monkeypatch.setattr('silver.models.Transaction.automatic_retries_count', 3) + + # 2 ** 3 = 8 + assert transaction.next_retry_datetime == transaction.created_at + timedelta(days=8) + + +@pytest.mark.django_db +def test_next_retry_datetime_fibonacci(monkeypatch): + payment_method = PaymentMethodFactory.create(verified=True, canceled=False) + + provider = ProviderFactory.create(transaction_retry_pattern='fibonacci') + + transaction = TransactionFactory.create(state=Transaction.States.Failed, + payment_method=payment_method) + + monkeypatch.setattr('silver.models.Transaction.provider', provider) + monkeypatch.setattr('silver.models.Transaction.automatic_retries_count', 3) + + # the 3rd number of the fibonacci series is 2 + assert transaction.next_retry_datetime == transaction.created_at + timedelta(days=2) + + +@pytest.mark.django_db +def test_should_be_automatically_retried(monkeypatch): + transaction = TransactionFactory.create(created_at=timezone.now() - timedelta(days=1)) + + monkeypatch.setattr('silver.models.Transaction.can_be_retried', True) + monkeypatch.setattr('silver.models.Transaction.automatic_retries_count', + transaction.provider.transaction_maximum_automatic_retries) + assert not transaction.should_be_automatically_retried + + monkeypatch.setattr('silver.models.Transaction.automatic_retries_count', 0) + monkeypatch.setattr('silver.models.Transaction.next_retry_datetime', + timezone.now() + timedelta(days=1)) + assert not transaction.should_be_automatically_retried + + monkeypatch.setattr('silver.models.Transaction.next_retry_datetime', timezone.now()) + monkeypatch.setattr('silver.models.Transaction.can_be_retried', False) + assert not transaction.should_be_automatically_retried + + monkeypatch.setattr('silver.models.Transaction.can_be_retried', True) + assert transaction.should_be_automatically_retried