Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

non final capture + return amount + more documentation #148

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
9 changes: 6 additions & 3 deletions payments/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ def get_base_url():
"""
Returns host url according to project settings. Protocol is chosen by
checking PAYMENT_USES_SSL variable.
If PAYMENT_HOST is not specified, gets domain from Sites.
Otherwise checks if it's callable and returns it's result. If it's not a
If PAYMENT_HOST is not specified, gets domain from Sites.
Otherwise checks if it's callable and returns it's result. If it's not a
callable treats it as domain.
"""
protocol = 'https' if PAYMENT_USES_SSL else 'http'
Expand Down Expand Up @@ -92,13 +92,16 @@ def get_return_url(self, payment, extra_data=None):
return url + '?' + qs
return url

def capture(self, payment, amount=None):
def capture(self, payment, amount=None, final=True):
''' Capture a fraction of the total amount of a payment. Return amount captured or None '''
raise NotImplementedError()

def release(self, payment):
''' Annilates captured payment '''
raise NotImplementedError()

def refund(self, payment, amount=None):
''' Refund payment, return amount which was refunded or None '''
raise NotImplementedError()


Expand Down
2 changes: 1 addition & 1 deletion payments/cybersource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def charge(self, payment, data):
self._set_proper_payment_status_from_reason_code(
payment, response.reasonCode)

def capture(self, payment, amount=None):
def capture(self, payment, amount=None, final=True):
if amount is None:
amount = payment.total
params = self._prepare_capture(payment, amount=amount)
Expand Down
2 changes: 1 addition & 1 deletion payments/dummy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def process_data(self, payment, request):
return HttpResponseRedirect(payment.get_success_url())
return HttpResponseRedirect(payment.get_failure_url())

def capture(self, payment, amount=None):
def capture(self, payment, amount=None, final=True):
payment.change_status(PaymentStatus.CONFIRMED)
return amount

Expand Down
43 changes: 29 additions & 14 deletions payments/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import unicode_literals
import json
from uuid import uuid4
import logging

from django.conf import settings
from django.core.urlresolvers import reverse
Expand All @@ -10,6 +11,8 @@
from .core import provider_factory
from . import FraudStatus, PaymentStatus

# Get an instance of a logger
logger = logging.getLogger(__name__)

class PaymentAttributeProxy(object):

Expand Down Expand Up @@ -82,11 +85,16 @@ def change_status(self, status, message=''):
'''
Updates the Payment status and sends the status_changed signal.
'''
from .signals import status_changed
self.status = status
self.message = message
self.save()
status_changed.send(sender=type(self), instance=self)
if self.status != status:
from .signals import status_changed
self.status = status
self.message = message
self.save()
for receiver, result in status_changed.send_robust(sender=type(self), instance=self):
if isinstance(result, Exception):
logger.critical(result)
else:
self.save()

def change_fraud_status(self, status, message='', commit=True):
available_statuses = [choice[0] for choice in FraudStatus.CHOICES]
Expand Down Expand Up @@ -133,17 +141,21 @@ def get_success_url(self):
def get_process_url(self):
return reverse('process_payment', kwargs={'token': self.token})

def capture(self, amount=None):
def capture(self, amount=None, final=True):
''' Capture a fraction of the total amount of a payment. Return amount captured or None '''
if self.status != PaymentStatus.PREAUTH:
raise ValueError(
'Only pre-authorized payments can be captured.')
provider = provider_factory(self.variant)
amount = provider.capture(self, amount)
amount = provider.capture(self, amount, final)
if amount:
self.captured_amount = amount
self.change_status(PaymentStatus.CONFIRMED)
self.captured_amount += amount
if final:
self.change_status(PaymentStatus.CONFIRMED)
return amount

def release(self):
''' Annilates captured payment '''
if self.status != PaymentStatus.PREAUTH:
raise ValueError(
'Only pre-authorized payments can be released.')
Expand All @@ -152,19 +164,22 @@ def release(self):
self.change_status(PaymentStatus.REFUNDED)

def refund(self, amount=None):
''' Refund payment, return amount which was refunded or None '''
if self.status != PaymentStatus.CONFIRMED:
raise ValueError(
'Only charged payments can be refunded.')
if amount:
if amount > self.captured_amount:
raise ValueError(
'Refund amount can not be greater then captured amount')
provider = provider_factory(self.variant)
amount = provider.refund(self, amount)
provider = provider_factory(self.variant)
amount = provider.refund(self, amount)
if amount:
self.captured_amount -= amount
if self.captured_amount == 0 and self.status != PaymentStatus.REFUNDED:
self.change_status(PaymentStatus.REFUNDED)
self.save()
if self.captured_amount == 0 and self.status != PaymentStatus.REFUNDED:
self.change_status(PaymentStatus.REFUNDED)
self.save()
return amount

@property
def attrs(self):
Expand Down
4 changes: 2 additions & 2 deletions payments/paypal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,13 +252,13 @@ def get_amount_data(self, payment, amount=None):
'total': str(amount.quantize(
CENTS, rounding=ROUND_HALF_UP))}

def capture(self, payment, amount=None):
def capture(self, payment, amount=None, final=True):
if amount is None:
amount = payment.total
amount_data = self.get_amount_data(payment, amount)
capture_data = {
'amount': amount_data,
'is_final_capture': True
'is_final_capture': final
}
links = self._get_links(payment)
url = links['capture']['href']
Expand Down
2 changes: 1 addition & 1 deletion payments/stripe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def get_form(self, payment, data=None):
raise RedirectNeeded(payment.get_success_url())
return form

def capture(self, payment, amount=None):
def capture(self, payment, amount=None, final=True):
amount = int((amount or payment.total) * 100)
charge = stripe.Charge.retrieve(payment.transaction_id)
try:
Expand Down
2 changes: 1 addition & 1 deletion payments/stripe/test_stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def change_fraud_status(self, status, message='', commit=True):
self.fraud_status = status
self.fraud_message = message

def capture(self, amount=None):
def capture(self, amount=None, final=True):
amount = amount or self.total
self.captured_amount = amount
self.change_status(PaymentStatus.CONFIRMED)
Expand Down
32 changes: 29 additions & 3 deletions payments/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from unittest import TestCase
from mock import patch, NonCallableMock

from django.dispatch import Signal

from payments import core
from .forms import CreditCardPaymentFormWithName, PaymentForm
from .models import BasePayment
Expand Down Expand Up @@ -42,14 +44,38 @@ def test_capture_with_wrong_status(self):
payment = BasePayment(variant='default', status=PaymentStatus.WAITING)
self.assertRaises(ValueError, payment.capture)

@patch('payments.signals.status_changed', new_callable=Signal)
def test_robust_signals(self, mocked_signal):
with patch.object(BasePayment, 'save') as mocked_save_method:
mocked_save_method.return_value = None
def rogue_handler(sender, instance, **kwargs):
raise Exception("Here be dragons")
def benign_handler(sender, instance, **kwargs):
pass
class UnrelatedClass(object):
pass
def unrelated_handler(sender, instance, **kwargs):
raise Exception("Should not be called")
mocked_signal.connect(rogue_handler, sender=BasePayment)
mocked_signal.connect(benign_handler, sender=BasePayment)
mocked_signal.connect(unrelated_handler, sender=UnrelatedClass)
payment = BasePayment(variant='default', status=PaymentStatus.PREAUTH)
# python < 3.4 has no asserLogs
if hasattr(self, "assertLogs"):
with self.assertLogs("payments.models", "CRITICAL") as logs:
payment.change_status(PaymentStatus.WAITING, "fooo")
self.assertEqual(logs.output, ['CRITICAL:payments.models:Here be dragons'])

@patch('payments.dummy.DummyProvider.capture')
def test_capture_preauth_successfully(self, mocked_capture_method):
amount = Decimal('20')
with patch.object(BasePayment, 'save') as mocked_save_method:
mocked_save_method.return_value = None
mocked_capture_method.return_value = amount

payment = BasePayment(variant='default', status=PaymentStatus.PREAUTH)
captured_amount = Decimal('0')
payment = BasePayment(variant='default', captured_amount=captured_amount,
status=PaymentStatus.PREAUTH)
payment.capture(amount)

self.assertEqual(payment.status, PaymentStatus.CONFIRMED)
Expand All @@ -63,7 +89,7 @@ def test_capture_preauth_without_amount(self, mocked_capture_method):
mocked_save_method.return_value = None
mocked_capture_method.return_value = amount

captured_amount = Decimal('100')
captured_amount = Decimal('0')
status = PaymentStatus.PREAUTH
payment = BasePayment(variant='default', status=status,
captured_amount=captured_amount)
Expand Down Expand Up @@ -110,7 +136,7 @@ def test_refund_without_amount(self, mocked_refund_method):
payment.refund(refund_amount)
self.assertEqual(payment.status, status)
self.assertEqual(payment.captured_amount, captured_amount)
self.assertEqual(mocked_refund_method.call_count, 0)
self.assertEqual(mocked_refund_method.call_count, 1)

@patch('payments.dummy.DummyProvider.refund')
def test_refund_partial_success(self, mocked_refund_method):
Expand Down