diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f7255b4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,52 @@ +sudo: false +language: python +python: + - "3.4" + - "3.5" + - "3.6" +env: + - DJANGO="1.11" + - DJANGO="2.0" + - DJANGO="2.1" + - DJANGO="master" +matrix: + include: + - python: "2.7" + env: DJANGO="1.11" + - python: "3.7" + sudo: required + dist: xenial + env: DJANGO="2.0" + - python: "3.7" + sudo: required + dist: xenial + env: DJANGO="2.1" + - python: "3.7" + sudo: required + dist: xenial + env: DJANGO="master" + allow_failures: + - python: "3.5" + env: DJANGO="2.1" + - python: "3.5" + env: DJANGO="master" + - python: "3.6" + env: DJANGO="2.1" + - python: "3.6" + env: DJANGO="master" + - python: "3.7" + sudo: required + dist: xenial + env: DJANGO="2.1" + - python: "3.7" + env: DJANGO="master" + sudo: required + dist: xenial + exclude: + - python: "3.4" + env: DJANGO="2.1" + - python: "3.4" + env: DJANGO="master" +after_success: codecov +install: pip install tox-travis codecov +script: tox diff --git a/README.md b/README.md new file mode 100644 index 0000000..72abce2 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Razorpay for django-payments + +[![PyPi Release](https://img.shields.io/pypi/v/django-payments-razorpay.svg)](https://pypi.org/project/django-payments-razorpay/) +![python](https://img.shields.io/pypi/pyversions/django-payments-razorpay.svg) +[![Build Status](https://travis-ci.org/NyanKiyoshi/django-payment-razorpay.svg?branch=master)](https://travis-ci.org/NyanKiyoshi/django-payments-razorpay) +[![codecov](https://codecov.io/gh/NyanKiyoshi/django-payment-razorpay/branch/master/graph/badge.svg)](https://codecov.io/gh/NyanKiyoshi/django-payments-razorpay) + +**WARNING:** only the paisa (INR) currency is supported by Razorpay as of now. + +## Installation +Add `django-payments-razorpay` to your project requirements +and/ or run the installation with: +```shell +pip install django-payments-razorpay +``` + + +## Provider parameters +First of all, to create your API credentials, you need to go in your Razorpay account settings, +then in the API Keys section ([direct link](https://dashboard.razorpay.com/#/app/keys)). + +| Key | Required | Type | Description | +| ------------ | ------- | --------- | ----------- | +| `public_key` | Yes | `string` | Your Razorpay **key id** | +| `secret_key` | Yes | `string` | Your Razorpay **secret key id** | +| `image` | No | `string` | An absolute or relative link to your store logo | +| `name` | No | `string` | Your store name | +| `prefill` | No | `boolean` | Pre-fill the email and customer's full name if set to `True` (disabled by default) | + + +## Example configuration + +In your `settings.py` file, you can add the following keys or append the data to them: + +```python +PAYMENT_VARIANTS = { + 'razorpay': ('django_payments_razorpay.RazorPayProvider', { + 'public_key': 'RAZORPAY_PUBLIC_KEY', + 'secret_key': 'RAZORPAY_SECRET_KEY'})} +``` + +Note: if you are using **Saleor**, you may want to add Razorpay to the checkout payment choices: + +```python +CHECKOUT_PAYMENT_CHOICES = [ + ('razorpay', 'RazorPay')] +``` + + +## Notes +1. Razorpay automatically capture the whole payment amount; +2. In test mode, you can use `4111 1111 1111 1111` (or any other valid credit card numbers) +with any future expiry date and CVV to pay orders. diff --git a/django_payments_razorpay/__init__.py b/django_payments_razorpay/__init__.py new file mode 100644 index 0000000..f574967 --- /dev/null +++ b/django_payments_razorpay/__init__.py @@ -0,0 +1,54 @@ +import json +from decimal import Decimal + +from payments import PaymentStatus, RedirectNeeded +from payments.core import BasicProvider +from .forms import ModalPaymentForm +import razorpay +import razorpay.errors + + +class RazorPayProvider(BasicProvider): + + form_class = ModalPaymentForm + + def __init__( + self, + public_key, secret_key, + image='', name='', prefill=False, **kwargs): + + self.secret_key = secret_key + self.public_key = public_key + self.image = image + self.name = name + self.prefill = prefill + self.razorpay_client = razorpay.Client(auth=(public_key, secret_key)) + + super(RazorPayProvider, self).__init__(**kwargs) + + def get_form(self, payment, data=None): + if payment.status == PaymentStatus.WAITING: + payment.change_status(PaymentStatus.INPUT) + + form = self.form_class( + data=data, payment=payment, provider=self) + + if form.is_valid(): + raise RedirectNeeded(payment.get_success_url()) + return form + + def charge(self, transaction_id, payment): + amount = int(payment.total * 100) + charge = self.razorpay_client.payment.capture(transaction_id, amount) + return charge + + def refund(self, payment, amount=None): + amount = int((amount or payment.captured_amount) * 100) + try: + refund = self.razorpay_client.payment.refund( + payment.transaction_id, amount) + except razorpay.errors.BadRequestError as exc: + raise ValueError(str(exc)) + refunded_amount = Decimal(refund['amount']) / 100 + payment.attrs.refund = json.dumps(refund) + return refunded_amount diff --git a/django_payments_razorpay/forms.py b/django_payments_razorpay/forms.py new file mode 100644 index 0000000..59440ec --- /dev/null +++ b/django_payments_razorpay/forms.py @@ -0,0 +1,42 @@ +import json +from decimal import Decimal + +from django import forms +from django.utils.translation import ugettext as _ +from payments import PaymentStatus +from payments.forms import PaymentForm + +from .widgets import RazorPayCheckoutWidget + + +class ModalPaymentForm(PaymentForm): + razorpay_payment_id = forms.CharField( + required=True, widget=forms.HiddenInput) + + def __init__(self, *args, **kwargs): + super(ModalPaymentForm, self).__init__( + hidden_inputs=False, autosubmit=True, *args, **kwargs) + + widget = RazorPayCheckoutWidget( + provider=self.provider, payment=self.payment) + self.fields['razorpay'] = forms.CharField( + widget=widget, required=False) + self.transaction_id = None + + def clean(self): + data = super(ModalPaymentForm, self).clean() + + if self.payment.transaction_id: + msg = _('This payment has already been processed.') + self._errors['__all__'] = self.error_class([msg]) + else: + self.transaction_id = data['razorpay_payment_id'] + + charge = self.provider.charge(self.transaction_id, self.payment) + captured_amount = Decimal(charge['amount']) / 100 + + self.payment.attrs.capture = json.dumps(charge) + self.payment.captured_amount = captured_amount + self.payment.transaction_id = self.transaction_id + self.payment.change_status(PaymentStatus.CONFIRMED) + return data diff --git a/django_payments_razorpay/widgets.py b/django_payments_razorpay/widgets.py new file mode 100644 index 0000000..7292873 --- /dev/null +++ b/django_payments_razorpay/widgets.py @@ -0,0 +1,39 @@ +from django.forms.utils import flatatt +from django.forms.widgets import HiddenInput +from django.utils.html import format_html +from django.utils.translation import ugettext_lazy as _ + +CHECKOUT_SCRIPT_URL = 'https://checkout.razorpay.com/v1/checkout.js' + + +class RazorPayCheckoutWidget(HiddenInput): + def __init__(self, provider, payment, *args, **kwargs): + override_attrs = kwargs.get('attrs', None) + base_attrs = kwargs['attrs'] = { + 'src': CHECKOUT_SCRIPT_URL, + 'data-key': provider.public_key, + 'data-buttontext': _('Pay now with Razorpay'), + 'data-image': provider.image, + 'data-name': provider.name, + 'data-description': payment.description or _('Total payment'), + 'data-amount': int(payment.total * 100), + 'data-currency': payment.currency + } + + if provider.prefill: + customer_name = '%s %s' % ( + payment.billing_last_name, + payment.billing_first_name) + base_attrs.update({ + 'data-prefill.name': customer_name, + 'data-prefill.email': payment.billing_email + }) + + if override_attrs: + base_attrs.update(override_attrs) + super(RazorPayCheckoutWidget, self).__init__(*args, **kwargs) + + def render(self, *args, **kwargs): + attrs = kwargs.setdefault('attrs', {}) + attrs.update(self.attrs) + return format_html('', flatatt(attrs)) diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..222243c --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python + +from os.path import isfile +from setuptools import setup + +REQUIREMENTS = ['django-payments>=0.12.3', 'razorpay>=1.1.1'] +TEST_REQUIREMENTS = ['pytest', 'mock'] + + +if isfile('README.md'): + with open('README.md') as fp: + long_description = fp.read() +else: + long_description = '' + + +setup( + name='django-payments-razorpay', + author='NyanKiyoshi', + author_email='hello@vanille.bid', + url='https://github.com/NyanKiyoshi/django-payments-razorpay/', + description='Razorpay provider for django-payments.', + long_description=long_description, + long_description_content_type='text/markdown', + version='0.1.1', + packages=['django_payments_razorpay'], + include_package_data=True, + classifiers=[ + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Framework :: Django', + 'Topic :: Software Development :: Libraries :: Application Frameworks', + 'Topic :: Software Development :: Libraries :: Python Modules'], + install_requires=REQUIREMENTS, + tests_require=TEST_REQUIREMENTS, + zip_safe=False) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..6dc39b6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,5 @@ +import django +import os + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') +django.setup() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..af8fed8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,107 @@ +from decimal import Decimal + +import mock +import pytest +from django_payments_razorpay import RazorPayProvider +from payments import PaymentStatus +from payments.models import BasePayment + +TRANSACTION_ID = 'pay_7IZD7aJ2kkmOjk' + +CLIENT_ID = 'abc123' +SECRET = '123abc' +PAYMENT_TOKEN = '5a4dae68-2715-4b1e-8bb2-2c2dbe9255f6' +VARIANT = 'razorpay' +EMAIL = 'hello@example.com' +FIRST_NAME = 'John' +LAST_NAME = 'Doe' +TOTAL = Decimal(220) +TOTAL_INT = 22000 + + +@pytest.fixture +def payment(): + payment_data = { + 'description': 'payment', + 'currency': 'USD', + 'delivery': Decimal(10), + 'status': PaymentStatus.WAITING, + 'tax': Decimal(10), + 'token': PAYMENT_TOKEN, + 'total': TOTAL, + 'captured_amount': Decimal(0), + 'variant': VARIANT, + 'transaction_id': None, + 'message': '', + 'billing_first_name': FIRST_NAME, + 'billing_last_name': LAST_NAME, + 'billing_email': EMAIL} + _payment = BasePayment(**payment_data) + _payment.id = 1 + _payment.save = mock.MagicMock() + _payment.get_success_url = mock.MagicMock( + new_callable=lambda: 'https://success') + return _payment + + +@pytest.fixture +def valid_capture_data(): + return { + "id": TRANSACTION_ID, + "entity": "payment", + "amount": TOTAL_INT, + "currency": "INR", + "status": "captured", + "order_id": None, + "invoice_id": None, + "international": False, + "method": "wallet", + "amount_refunded": 0, + "refund_status": None, + "captured": True, + "description": "Purchase Description", + "wallet": "freecharge", + "email": "a@b.com", + "contact": "91xxxxxxxx", + "notes": { + "merchant_order_id": "order id" + }, + "error_code": None, + "error_description": None, + "created_at": 1400826750 + } + + +@pytest.fixture +def valid_full_refund_data(): + return { + "id": "rfnd_5UXHCzSiC02RBz", + "entity": "refund", + "amount": TOTAL_INT, + "currency": "INR", + "payment_id": "pay_5UWttxtCjkrldV", + "notes": {}, + "created_at": 1462887226 + } + + +@pytest.fixture +def valid_partial_refund_data(valid_full_refund_data): + valid_full_refund_data = valid_full_refund_data.copy() + valid_full_refund_data['amount'] = 2000 + return valid_full_refund_data + + +@pytest.fixture +def provider(valid_capture_data, valid_full_refund_data): + _provider = RazorPayProvider(CLIENT_ID, SECRET) + mocked_payment = _provider.razorpay_client.payment = mock.MagicMock() + mocked_payment.capture.return_value = valid_capture_data + mocked_payment.refund.return_value = valid_full_refund_data + return _provider + + +@pytest.fixture +def valid_payment_form_data(): + return { + 'razorpay_payment_id': TRANSACTION_ID} diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..74870a1 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +import os + +PROJECT_ROOT = os.path.normpath( + os.path.join(os.path.dirname(__file__), '..', 'django_payments_razorpay')) + +SECRET_KEY = 'secret' +PAYMENT_HOST = 'example.com' + +INSTALLED_APPS = ['payments', 'django.contrib.sites'] + +PAYMENT_VARIANTS = { + 'razorpay': ('django_payments_razorpay.RazorPayProvider', { + 'public_key': 'RAZORPAY_PUBLIC_KEY', + 'secret_key': 'RAZORPAY_SECRET_KEY'})} diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 0000000..0fac558 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,35 @@ +import pytest +from django_payments_razorpay import ModalPaymentForm +from payments import PaymentStatus +from tests.conftest import TOTAL, TOTAL_INT, TRANSACTION_ID + + +def test_modal_payment_form_valid_data( + provider, payment, valid_payment_form_data): + form = ModalPaymentForm( + provider=provider, payment=payment, data=valid_payment_form_data) + assert form.is_valid() + provider.razorpay_client.payment.capture.assert_called_once_with( + TRANSACTION_ID, TOTAL_INT) + assert payment.captured_amount == TOTAL + assert payment.transaction_id == TRANSACTION_ID + assert payment.status == PaymentStatus.CONFIRMED + + +def test_modal_payment_form_already_processed( + provider, payment, valid_payment_form_data): + payment.transaction_id = TRANSACTION_ID + form = ModalPaymentForm( + provider=provider, payment=payment, data=valid_payment_form_data) + assert not form.is_valid() + provider.razorpay_client.payment.capture.assert_not_called() + + +def test_modal_payment_form_invalid_data( + provider, payment, valid_payment_form_data): + form = ModalPaymentForm( + provider=provider, payment=payment, data={}) + + with pytest.raises(KeyError, message='razorpay_payment_id'): + form.is_valid() + provider.razorpay_client.payment.capture.assert_not_called() diff --git a/tests/test_provider.py b/tests/test_provider.py new file mode 100644 index 0000000..a1a46de --- /dev/null +++ b/tests/test_provider.py @@ -0,0 +1,90 @@ +from decimal import Decimal + +import mock +import pytest +import razorpay.errors +from django_payments_razorpay import RazorPayProvider +from payments import PaymentStatus, RedirectNeeded +from payments.core import provider_factory +from tests.conftest import CLIENT_ID, SECRET, TOTAL, TRANSACTION_ID + + +def test_provider_factory(): + assert isinstance(provider_factory('razorpay'), RazorPayProvider) + + +def test_authentication(provider): + assert provider.razorpay_client.auth == (CLIENT_ID, SECRET) + + +@mock.patch( + 'django_payments_razorpay.forms.RazorPayCheckoutWidget', create=True) +def test_get_form(mocked_razor_checkout, provider, payment): + form = provider.get_form(payment) + mocked_razor_checkout.assert_called_once_with( + provider=provider, payment=payment) + assert 'razorpay' in form.fields + assert form.fields['razorpay'].widget == mocked_razor_checkout.return_value + + +def test_get_form_invalid_data(provider, payment): + with pytest.raises(KeyError, message='razorpay_payment_id'): + provider.get_form(payment, data={}) + + assert payment.captured_amount == 0 + assert payment.transaction_id is None + + +def test_get_form_valid_data(valid_payment_form_data, provider, payment): + with pytest.raises(RedirectNeeded, message='https://success'): + provider.get_form(payment, data=valid_payment_form_data) + + assert payment.save.call_count != 0 + assert payment.status == PaymentStatus.CONFIRMED + assert payment.captured_amount == payment.total + assert payment.transaction_id == TRANSACTION_ID + + +@mock.patch('payments.models.provider_factory', create=True) +@pytest.mark.parametrize( + 'partial_refund,expected_status', ( + (False, PaymentStatus.REFUNDED), + (True, PaymentStatus.CONFIRMED) + )) +def test_refund( + mocked_provider_factory, + partial_refund, expected_status, + valid_partial_refund_data, provider, payment): + + mocked_provider_factory.return_value = provider + provider.refund = mock.MagicMock(wraps=provider.refund) + + if partial_refund: + refund_amount = Decimal(20) + expected_captured_amount = Decimal(200) + provider.razorpay_client.payment.refund.return_value = ( + valid_partial_refund_data) + else: + refund_amount = TOTAL + expected_captured_amount = 0 + + payment.captured_amount = payment.total + payment.status = PaymentStatus.CONFIRMED + payment.refund(amount=refund_amount) + + mocked_provider_factory.assert_called_once_with('razorpay') + provider.refund.assert_called_once_with(payment, refund_amount) + assert payment.captured_amount == expected_captured_amount + assert payment.status == expected_status + + +def test_refund_invalid_data(provider, payment): + def _raise_fake_error(*args, **kwargs): + raise razorpay.errors.BadRequestError('hello world') + payment.captured_amount = payment.total + provider.razorpay_client.payment.refund.side_effect = _raise_fake_error + + with pytest.raises(ValueError, message='hello world'): + provider.refund(payment, Decimal(2220)) + + assert payment.captured_amount == payment.total diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 0000000..3cec29f --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,37 @@ +from django_payments_razorpay.widgets import RazorPayCheckoutWidget + + +def test_checkout_widget_attrs_overriding(provider, payment): + base_attrs = RazorPayCheckoutWidget(provider, payment).attrs + overridden_attrs = RazorPayCheckoutWidget( + provider, payment, attrs={'data-currency': 'INR'}).attrs + + base_attrs['data-currency'] = 'INR' + assert base_attrs == overridden_attrs + + +def test_checkout_widget_render_without_prefill(provider, payment): + widget = RazorPayCheckoutWidget(provider, payment) + assert widget.render() == ( + '') + + +def test_checkout_widget_render_with_prefill(provider, payment): + provider.prefill = True + widget = RazorPayCheckoutWidget(provider, payment) + assert widget.render() == ( + '') diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..638b9e5 --- /dev/null +++ b/tox.ini @@ -0,0 +1,31 @@ +[tox] +envlist = py27-django111, py{34,35,36,37}-django{111,20,21,_master} + +[testenv] +usedevelop=True +deps= + coverage + django111: django>=1.11a1,<1.12 + django20: Django>=2.0a1,<2.1 + django21: Django>=2.1,<2.2 + django_master: https://github.com/django/django/archive/master.tar.gz + mock + pytest + pytest-cov +commands=pytest --cov --cov-report= + +[travis] +python = + 2.7: py27 + 3.4: py34 + 3.5: py35 + 3.6: py36 + 3.7: py37 +unignore_outcomes = True + +[travis:env] +DJANGO = + 1.11: django111 + 2.0: django2.0 + 2.1: django2.1 + master: django_master