Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
26 changes: 20 additions & 6 deletions silver/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']

Expand Down Expand Up @@ -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):
Expand All @@ -971,18 +971,27 @@ def get_customer(self, obj):
return u'<a href="%s">%s</a>' % (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",
args=[obj.payment_method.pk])
return u'<a href="%s">%s</a>' % (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
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions silver/models/billing_entities/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
103 changes: 99 additions & 4 deletions silver/models/transactions/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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'))]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions silver/retry_patterns.py
Original file line number Diff line number Diff line change
@@ -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)
30 changes: 28 additions & 2 deletions silver/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
})
2 changes: 1 addition & 1 deletion silver/tests/api/test_transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion silver/tests/integration/test_documents_transactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading