[Django]-How to add few payment methods to django-oscar

8👍

Docdata was a very good inspiration for my implementation.

Multiple payment methods, means you will first have to list all methods somewhere (for easy enable/disable or something else) and then decide what methods are applicable to given user (depending on currency, basket size, location, etc.) All this can be handled by extending the PaymentMethodView.

settings.py

from oscar import get_core_apps as get_oscar_apps

...
INSTALLED_APPS = [
    ...
] + get_oscar_apps([
    'checkout',
]) + [
    'cashondelivery', # https://github.com/ashishnitinpatil/django-oscar-cash-on-delivery
    'custom_payment', # my local app for custom payment gateway
]

OSCAR_PAYMENT_METHODS = (
    ('cod', _('Cash on delivery')),
    ('custom_payment', _('Credit / Debit card')),
)
...

checkout/forms.py

from django import forms
from django.conf import settings
from django.utils.translation import ugettext_lazy as _


class PaymentMethodForm(forms.Form):
    """
    Extra form for the custom payment method.
    """
    payment_method = forms.ChoiceField(
        label=_("Select a payment method"),
        choices=settings.OSCAR_PAYMENT_METHODS,
        widget=forms.RadioSelect()
    )


def get_payment_method_display(payment_method):
    return dict(settings.OSCAR_PAYMENT_METHODS).get(payment_method)

checkout/views.py

from django.utils import six
from django.conf import settings
from django.shortcuts import redirect
from django.views.generic import FormView
from django.utils.translation import ugettext as _
from django.core.urlresolvers import reverse, reverse_lazy

from . import forms
from custom_payment import facade
from cashondelivery import gateway as cod_gateway

from oscar.apps.checkout import exceptions
from oscar.core.loading import get_model, get_class


Source = get_model("payment", "Source")
SourceType = get_model("payment", "SourceType")
RedirectRequired = get_class("payment.exceptions", "RedirectRequired")
UnableToPlaceOrder = get_class('order.exceptions', 'UnableToPlaceOrder')
OscarPaymentMethodView = get_class("checkout.views", "PaymentMethodView")
OscarPaymentDetailsView = get_class("checkout.views", "PaymentDetailsView")
OscarShippingMethodView = get_class("checkout.views", "ShippingMethodView")

# Sample pre-condition
class CheckCountryPreCondition(object):
    """DRY class for check country in session pre_condition"""

    def get_pre_conditions(self, request):
        if 'check_country_in_session' not in self.pre_conditions:
            return self.pre_conditions + ['check_country_in_session']
        return super().get_pre_conditions(request)

    def check_country_in_session(self, request):
        if request.session.get('country', None) is None:
            raise exceptions.FailedPreCondition(
                    url=reverse('checkout:shipping-address'),
                )


# Inspired by https://github.com/django-oscar/django-oscar-docdata/blob/master/sandbox/apps/checkout/views.py
class PaymentMethodView(CheckCountryPreCondition, OscarPaymentMethodView, FormView):
    """
    View for a user to choose which payment method(s) they want to use.

    This would include setting allocations if payment is to be split
    between multiple sources. It's not the place for entering sensitive details
    like bankcard numbers though - that belongs on the payment details view.
    """
    template_name = "checkout/payment_method.html"
    step = 'payment-method'
    form_class = forms.PaymentMethodForm
    success_url = reverse_lazy('checkout:payment-details')

    pre_conditions = [
        'check_basket_is_not_empty',
        'check_basket_is_valid',
        'check_user_email_is_captured',
        'check_shipping_data_is_captured',
        'check_payment_data_is_captured',
    ]
    skip_conditions = ['skip_unless_payment_is_required']

    def get(self, request, *args, **kwargs):
        # if only single payment method, store that
        # and then follow default (redirect to preview)
        # else show payment method choice form
        if len(settings.OSCAR_PAYMENT_METHODS) == 1:
            self.checkout_session.pay_by(settings.OSCAR_PAYMENT_METHODS[0][0])
            return redirect(self.get_success_url())
        else:
            return FormView.get(self, request, *args, **kwargs)

    def get_success_url(self, *args, **kwargs):
        # Redirect to the correct payments page as per the method (different methods may have different views &/or additional views)
        return reverse_lazy('checkout:preview')

    def get_initial(self):
        return {
            'payment_method': self.checkout_session.payment_method(),
        }

    def form_valid(self, form):
        # Store payment method in the CheckoutSessionMixin.checkout_session (a CheckoutSessionData object)
        self.checkout_session.pay_by(form.cleaned_data['payment_method'])
        return super().form_valid(form)

class PaymentDetailsView(CheckCountryPreCondition, OscarPaymentDetailsView):

    def handle_payment(self, order_number, order_total, **kwargs):
        method = self.checkout_session.payment_method()
        if method == 'cod':
            return self.handle_cod_payment(order_number, order_total, **kwargs)
        elif method == 'custom_payment':
            return self.handle_custom_payment_payment(order_number, order_total, **kwargs)
        else:
            raise PaymentError(_('Bad payment method in handle_payment!'))

    def handle_cod_payment(self, order_number, total, **kwargs):
        reference = cod_gateway.create_transaction(order_number, total)
        source_type, is_created = SourceType.objects.get_or_create(
            name='Cash on delivery')
        source = Source(
            source_type=source_type,
            currency=total.currency,
            amount_allocated=total.incl_tax,
            amount_debited=total.incl_tax
        )
        self.add_payment_source(source)
        self.add_payment_event('awaiting-delivery', total.incl_tax, reference=reference)

    def handle_custom_payment_payment(self, order_number, total, **kwargs):
        submission = self.build_submission(order_number=order_number, **kwargs)

        # Save required payment gateway data
        # also validates that we have all we need
        custom_payment_payment_data = facade.get_gateway_url_and_parameters(
            submission, self.request.build_absolute_uri, live=settings.custom_payment['LIVE'])

        # Any raised exceptions are handled by the PaymentDetail.submit() code.
        custom_payment_order = facade.create_order(
            order_number=order_number,
            amount=total,
            payment_data=custom_payment_payment_data
        )
        # record payment data to session to double verify things on success / failure
        self.set_custom_payment_payment_data(custom_payment_payment_data, custom_payment_order)

        source = Source(
            source_type=facade.get_source_type(),
            currency=total.currency,
            amount_allocated=total.incl_tax,   # amount_* field depends on type of transaction.
            reference=custom_payment_order.id
        )
        self.add_payment_source(source)

        # Also record payment event.
        # This will be visible in the Dashboard
        self.add_payment_event('pre-auth', total.incl_tax, reference=custom_payment_order.id)

        # Regardless of whether the order is paid, write it in the database before redirecting.
        # Oscar actually skips this when redirecting the user to the payment provider.
        self._save_order(order_number, submission)

        # Redirect the user to the payment provider's gateway.
        raise RedirectRequired(facade.get_payment_url())

    def _save_order(self, order_number, submission):
        # Finalize the order that PaymentDetailsView.submit() started
        # If all is ok with payment, try and place order
        logger.info("Order #%s: payment started, placing order", order_number)

        try:
            # Call OrderPlacementMixin.handle_order_placement()
            return self.handle_order_placement(
                order_number, submission['user'], submission['basket'],
                submission['shipping_address'], submission['shipping_method'],
                submission['shipping_charge'], submission['billing_address'],
                submission['order_total'], **(submission['order_kwargs'])
            )
        except UnableToPlaceOrder as e:
            # It's possible that something will go wrong while trying to
            # actually place an order.  Not a good situation to be in as a
            # payment transaction may already have taken place, but needs
            # to be handled gracefully.
            logger.error("Order #%s: unable to place order - %s", order_number, e, exc_info=True)
            msg = six.text_type(e)
            self.restore_frozen_basket()
            return self.render_to_response(self.get_context_data(error=msg))

    # Can't update CheckoutSessionMixin nicely without causing trouble,
    # hence dumping the overridden / new methods from that class here
    def check_payment_data_is_captured(self, request):
        # We don't collect payment data by default so we don't have anything to
        # validate here. If your shop requires forms to be submitted on the
        # payment details page, then override this method to check that the
        # relevant data is available. Often just enforcing that the preview
        # view is only accessible from a POST request is sufficient.
        if not self.checkout_session.payment_method():
            raise FailedPreCondition(
                url=reverse('checkout:payment-method'),
                message=_("Please select a payment method for your order!")
            )

    def set_custom_payment_payment_data(self, payment_data, custom_payment_order):
        self.request.session['custom_payment'] = payment_data
        self.request.session['custom_payment_order_id'] = custom_payment_order.id
        self.request.session['ongoing_online_payment'] = True
        self.request.session.pop('custom_payment_error', None)

    def get_context_data(self, **kwargs):
        ctx = super(PaymentDetailsView, self).get_context_data(**kwargs)
        ctx.update({'payment_method': self.checkout_session.payment_method()})
        return ctx

    def send_confirmation_message(self, order, *args, **kwargs):
        # In case of custom_payment, delay sending the order confirmation till payment success!
        if not self.checkout_session.payment_method() == 'custom_payment':
            super(PaymentDetailsView, self).send_confirmation_message(order, *args, **kwargs)

You may ignore the details for custom_payment since that is something specific for my project / payment gateway, you would likely have different requirements for that. I have still left a chunk of the things in it so that you may think of the things accordingly.

PS – Please note I’m using Python3.6, so a few things (like super) might not work as mentioned here, in previous Python versions (specially 2.7)

Update

My templates/checkout/payment_method.html:

{% extends "checkout/checkout.html" %}
{% load i18n %}

{% block checkout_title %}{{ form.payment_method.label }}{% endblock %}

{% block checkout_nav %}
    {% include 'checkout/nav.html' with step=3 %}
{% endblock %}

{% block content %}
    {% if error %}
        <div class="alert alert-error">
            {{ error }}
        </div>
    {% endif %}

    <form action="" method="post">
        {% csrf_token %}

        {% if form.payment_method.errors %}{{ form.payment_method.errors }}{% endif %}
        <div class="radio">
            <label for="id_payment_method_0">
                <input id="id_payment_method_0" name="payment_method" type="radio" value="cod" required="">{% trans "Cash on delivery" %}
            </label>
        </div>
        <div class="radio">
            <label for="id_payment_method_1">
                <input id="id_payment_method_1" name="payment_method" type="radio" value="custom_payment" required="">{% trans "Credit / Debit Card" %}
            </label>
        </div>
        <p><button type="submit" class="btn btn-large btn-primary">{% trans "Continue to confirmation" %}</button>

    </form>
{% endblock %}

Leave a comment