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