Multi-currency in Django ecommerce project

Project localization also concerns ecommerce platforms, which requires them to allow purchases in the local currency of the customer. In order to achieve that, developers need to integrate multi-currency functionality into all parts of the platform, that operates with money values: stock records, baskets, orders, discounts, analytics. Since there are multiple moving parts affected by this, so we decided to review it in the details in this post.

First of all, Django-Oscar implements currency through the char field in the StockRecord model and it's basically used for currency indication. But when we face with the multi-currency pricing, we'll need a bit more advanced functionality — currency selection and conversion accordingly using conversion rates, provided by the trusted source. To get started with, we'll review couple useful Django packages that could simplify our project implementation.

Django-Money

Django-Money wraps around Django models and fields around py-moneyed package with money and currency classes implementation.

Django-Money has MoneyField, which stores both money value and the currency within the single model field.

class StockRecord(AbstractStockRecord):
    price_excl_tax = MoneyField(max_digits=12, decimal_places=2, default_currency='USD')

It also has exchange rate support and integrates openexchangerates.org and fixer.io.

Django-currencies

Django-currencies is a package by Panos Laganakos implements currency conversion — it stores both currency details and rate within the single model. The model contains a factor field — divide fraction of the given currency and default one, which does not allow to have exchange rates between multiple currencies.

Package has also bundled integration with openexchangerates.org, Yahoo Finance and Currency ISO services, which allows to fetch automatically exchange rates from those ones and use for conversion.

Solution

Django-oscar defines currency per basket and order models and enforces the same currency for all basket/order lines. Thus, combined model field integration would not be quite optimal for the Django-Oscar and bring sort of duplication and also requires to replace all decimal model fields with the combined money fields and rewrite logic, e.g. `total_incl_tax`, `total_excl_tax`, `shipping_incl_tax`, `shipping_excl_tax` would produce 8 fields in the model.

Since the biggest complexity is in the conversion functionality integration into the Django-Oscar, but not the conversion itself, we could implement it on our own.

First of all, let's define default currency and list of currencies in the settings:

OSCAR_DEFAULT_CURRENCY = 'EUR'

OSCAR_CURRENCIES = ('CZK', 'EUR', 'HRK', 'HUF', 'RON', 'USD')

Then, let's add simple model to store exchange rates:

# apps/partner/models.py
from django.db import models


class ExchangeRate(models.Model):
    base_currency = models.CharField(max_length=255)
    currency = models.CharField(max_length=255)
    value = models.DecimalField(max_digits=20, decimal_places=6)
    date_created = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return '%s to %s' % (self.base_currency, self.currency)

As a source with the exchange rates, I chose https://exchangeratesapi.io/ — free service, developed by Madis Väin, which is not only free, but open-source (https://github.com/exchangeratesapi/exchangeratesapi) and built using Python.

Fetching exchange rates is done by Django management command and simple function:

# apps/partner/management/commands/fetch_exchange_rates.py
from django.conf import settings
from django.core.management.base import BaseCommand

from apps.partner import utils
from apps.partner.models import ExchangeRate


class Command(BaseCommand):
    def handle(self, *args, **options):
        for base_currency in settings.OSCAR_CURRENCIES:
            currencies = list(settings.OSCAR_CURRENCIES).copy()
            currencies.remove(base_currency)
            rates = utils.fetch_exchange_rates(base_currency, currencies=','.join(currencies))
            for rate_code, rate_value in rates['rates'].items():
                ExchangeRate.objects.create(base_currency=base_currency, currency=rate_code, value=rate_value)
                self.stdout.write('Fetched rate %s to %s' % (base_currency, rate_code))
# apps/partner/utils.py
from decimal import Decimal as D, ROUND_HALF_UP

import requests

EXCHANGERATES_API_URL = 'https://api.exchangeratesapi.io/latest?base=%s&symbols=%s'


def fetch_exchange_rates(base_currency, currencies):
    url = EXCHANGERATES_API_URL % (base_currency, currencies)
    r = requests.get(url)
    if r.status_code == 200:
        return r.json()

For better performance, we can cache exchange rate to avoid unnecessary database calls:

# apps/partner/utils.py
from decimal import Decimal as D, ROUND_HALF_UP

from django.core.cache import cache

from .models import ExchangeRate


EXCHANGE_RATE_CACHE_KEY = '%s-%s_rate'


def convert_currency(from_currency, to_currency, convertible_value):
    cache_key = EXCHANGE_RATE_CACHE_KEY % (from_currency, to_currency)
    value = cache.get(cache_key, None)
    if value:
        return value

    rate = ExchangeRate.objects.filter(base_currency=from_currency, currency=to_currency).last()
    value = D(rate.value * convertible_value).quantize(D('0.01'), ROUND_HALF_UP)
    cache.set(cache_key, value)
    return value

As already mentioned at the beginning, StockRecord model has char field, which could store any string value. Since we explicitly define list of available currencies in the settings, we'd override stock record form in the dashboard and allow to select currency from the list.

# apps/dashboard/catalogue/forms.py
from django import forms
from django.conf import settings

from oscar.apps.dashboard.catalogue.forms import StockRecordForm as CoreStockRecordForm


CURRENCY_CHOICES = [(c,) * 2 for c in settings.OSCAR_CURRENCIES]


class StockRecordForm(CoreStockRecordForm):
    price_currency = forms.ChoiceField(choices=CURRENCY_CHOICES)

Currency selection

First of all, we need to allow customers to select currency of the basket and order and which they will be eventually charged in.

Originally, Basket model has currency property, which indicates currency of its first line. However, if basket is empty and has no lines — it will fall back to default currency.

Thus, in order to make selected currency persistent, we could store it both in the request session and basket model. If we about to store it only in the request session, we'd need to customize several templates, which uses basket.currency to format prices. From the other hand, if we store it only in the basket it won't be persistent — we would need to select currency each time new basket created.

# apps/basket/models.py
from django.db import models

from oscar.apps.basket.abstract_models import AbstractBasket
from oscar.core.utils import get_default_currency


class Basket(AbstractBasket):
    # Since original Oscar basket model already had `currency` property, we
    # couldn't just replace it with the model field with the same name.
    # On the contrary, we overridden a property and implemented property
    # setter, which stores value in the `_currency` field in order to
    # avoid collision.
    _currency = models.CharField(max_length=3, default=get_default_currency)

    @property
    def currency(self):
        return self._currency

    @currency.setter
    def currency(self, value):
        self._currency = value

    def change_currency(self, currency):
        self.currency = currency
        self.save()

        for line in self.lines.all():
            stock_info = self.get_stock_info(line.product, options=None)
            line.price_currency = currency
            line.price_excl_tax = stock_info.price.excl_tax
            line.price_incl_tax = stock_info.price.incl_tax
            line.save()

        self.reset_offer_applications()

Within the undermentioned simple view we allow to switch currency and redirect back to the original page.

# apps/basket/views.py
from django.http import HttpResponseRedirect
from django.views.generic import FormView

from oscar.core.loading import get_class


BasketCurrencyForm = get_class('basket.forms', 'BasketCurrencyForm')


class BasketCurrencyUpdateView(FormView):
    form_class = BasketCurrencyForm

    def form_valid(self, form):
        currency = form.cleaned_data['currency']
        self.request.session['currency'] = currency
        self.request.basket.change_currency(currency)
        return HttpResponseRedirect(self.get_success_url())

    def get_success_url(self):
        return self.request.META.get('HTTP_REFERER', '/')

Currency selection in the top left navbar

In order to keep selected currency persevering, we'll need to extract it from the request session and set during basket creation. Since we need to access request, it'd be appropriate to customize BasketMiddleware and override basket creation there.

# apps/basket/middleware.py
from django.conf import settings
from django.contrib import messages
from django.utils.translation import gettext_lazy as _

from oscar.apps.basket.middleware import BasketMiddleware as CoreBasketMiddleware
from oscar.core.loading import get_model

Basket = get_model('basket', 'basket')


class BasketMiddleware(CoreBasketMiddleware):
    def get_basket(self, request):
        if request._basket_cache is not None:
            return request._basket_cache

        num_baskets_merged = 0
        manager = Basket.open
        cookie_key = self.get_cookie_key(request)
        cookie_basket = self.get_cookie_basket(cookie_key, request, manager)

        if hasattr(request, 'user') and request.user.is_authenticated:
            try:
                basket, created = manager.get_or_create(owner=request.user)
                # We need to set user-selected currency, stored in the session in
                # order to avoid basket created in default currency, so that
                # user will have to change it again. Thus, we just want to make
                # selected currency persistent for the all user baskets too.
                if created:
                    basket.currency = request.session.get('currency', settings.OSCAR_DEFAULT_CURRENCY)
                    basket.save()
            except Basket.MultipleObjectsReturned:
                old_baskets = list(manager.filter(owner=request.user))
                basket = old_baskets[0]
                for other_basket in old_baskets[1:]:
                    self.merge_baskets(basket, other_basket)
                    num_baskets_merged += 1

            basket.owner = request.user

            if cookie_basket:
                self.merge_baskets(basket, cookie_basket)
                num_baskets_merged += 1
                request.cookies_to_delete.append(cookie_key)

        elif cookie_basket:
            basket = cookie_basket
        else:
            basket = Basket()

        request._basket_cache = basket

        if num_baskets_merged > 0:
            messages.add_message(request, messages.WARNING,
                                 _("We have merged a basket from a previous session. Its contents "
                                   "might have changed."))

        return basket

Finally, we need to respect selected currency in the partner pricing strategy and if stock record currency differs from selected one — convert to it.

# apps/partner/strategy.py
from decimal import Decimal as D

from django.conf import settings

from oscar.apps.partner.strategy import Default as CoreDefault
from oscar.core.loading import get_class

from apps.partner.utils import convert_currency


FixedPrice = get_class('partner.prices', 'FixedPrice')


class Default(CoreDefault):
    """
    Partner strategy, that converts prices from stockrecord currency
    to user-selected currency.
    """

    def get_currency(self):
        currency = self.request.session.get('currency', None)
        currency = currency or settings.OSCAR_DEFAULT_CURRENCY
        return currency

    def convert_currency(self, stockrecord, prices):
        currency = self.get_currency()
        price_excl_tax = convert_currency(stockrecord.price_currency, currency, prices.excl_tax)
        return FixedPrice(excl_tax=price_excl_tax, currency=currency, tax=D(0))

    def pricing_policy(self, product, stockrecord):
        prices = super().pricing_policy(product, stockrecord)
        if stockrecord:
            currency = self.get_currency()
            if currency == stockrecord.price_currency:
                return prices

            return self.convert_currency(stockrecord, prices)
        return prices

    def parent_pricing_policy(self, product, stockrecord):
        prices = super().parent_pricing_policy(product, stockrecord)
        if stockrecord:
            currency = self.get_currency()
            if currency == stockrecord.price_currency:
                return prices

            return self.convert_currency(stockrecord, prices)
        return prices


class Selector(object):
    def strategy(self, request=None, user=None, **kwargs):
        return Default(request)

Shipping

Shipping cost is summed up to the basket total in the order calculator, thus it is absolutely necessary to lead it to the same currency as basket.

In the Oscar, shipping methods, which implement shipping costs calculation method, does not define currency at all.

As an example, we took Oscar's FixedPrice class and defined currency per it, using OSCAR_DEFAULT_CURRENCY setting. Hereafter, we implemented shipping costs conversion to the basket currency.

# apps/shipping/methods.py
from decimal import Decimal as D

from django.conf import settings

from oscar.apps.shipping.methods import FixedPrice as OriginalFixedPrice
from oscar.core import prices

from apps.partner.utils import convert_currency


class FixedPrice(OriginalFixedPrice):
    charge_incl_tax = D(10)
    charge_excl_tax = D(10)
    currency = settings.OSCAR_DEFAULT_CURRENCY

    def __init__(self, charge_excl_tax=None, charge_incl_tax=None, currency=None):
        super().__init__(charge_excl_tax=charge_excl_tax, charge_incl_tax=charge_incl_tax)
        if currency:
            self.currency = currency

    def calculate(self, basket):
        if self.currency == basket.currency:
            return super().calculate(basket)

        # If basket currency differs from shipping method's currency,
        # we convert shipping costs to user-selected currency.
        charge_excl_tax = convert_currency(self.currency, basket.currency, self.charge_excl_tax)
        charge_incl_tax = convert_currency(self.currency, basket.currency, self.charge_incl_tax)
        return prices.Price(
            currency=self.currency,
            excl_tax=charge_excl_tax,
            incl_tax=charge_incl_tax)

Analytics

Within each placed order we record product and user analytic records and the latter has total_spent field, which used to sum up user's orders totals.

Since user is able to place orders in different currencies it might bring discrepancy into the statistical data.

Therefore, it will make more sense to record user analytics in default currency, which requires to perform conversion in order_placed signal receiver.

# apps/analytics/receivers.py
import logging

from django.conf import settings
from django.db import IntegrityError
from django.db.models import F
from django.dispatch import receiver

from oscar.apps.analytics.receivers import _record_products_in_order, receive_order_placed
from oscar.apps.order.signals import order_placed
from oscar.core.loading import get_class

from apps.partner.utils import convert_currency

UserRecord = get_class('analytics.models', 'UserRecord')
logger = logging.getLogger('oscar.analytics')


# Disconnect original Oscar signal receiver in order to avoid collision.
order_placed.disconnect(receive_order_placed)


def _record_user_order(user, order):
    try:
        # Since all orders are stored in user-selected currency, for
        # consistency we need to record user analytics in default currency.
        # User might select different currency for different orders,
        # also in order to aggregate analytics we'll need to have data in
        # the same currency.
        record = UserRecord.objects.filter(user=user)
        if order.currency == settings.OSCAR_DEFAULT_CURRENCY:
            total_spent = order.total_incl_tax
        else:
            total_spent = convert_currency(order.currency, settings.OSCAR_DEFAULT_CURRENCY, order.total_incl_tax)

        affected = record.update(
            num_orders=F('num_orders') + 1,
            num_order_lines=F('num_order_lines') + order.num_lines,
            num_order_items=F('num_order_items') + order.num_items,
            total_spent=F('total_spent') + total_spent,
            date_last_order=order.date_placed)
        if not affected:
            UserRecord.objects.create(
                user=user, num_orders=1, num_order_lines=order.num_lines,
                num_order_items=order.num_items,
                total_spent=total_spent,
                date_last_order=order.date_placed)
    except IntegrityError:      # pragma: no cover
        logger.error(
            "IntegrityError in analytics when recording a user order.")


@receiver(order_placed)
def analytics_receive_order_placed(sender, order, user, **kwargs):
    if kwargs.get('raw', False):
        return
    _record_products_in_order(order)
    if user and user.is_authenticated:
        _record_user_order(user, order)

Discounts

Since we already allowing partners to sell products in desired currency (per stock record), it also makes sense to provide ability to set currency in the platform discounts.

E.g., if vendor sells the product for 5000 Kč (Czech koruna), it would be much more convenient to give an opportunity for the vendor to provide discount for 300 Kč, not €11.70, which would save from the necessity to calculate prices in the default platform currency.

Every discount offer has condition and benefit, which implies we need add currency field to those models and rework condition and benefit application, which should convert its value into the basket currency.

# apps/offer/models.py
from oscar.apps.offer.abstract_models import AbstractBenefit, AbstractCondition
from core.mixins import CurrencyMixin


class Benefit(CurrencyMixin, AbstractBenefit):
    pass


class Condition(CurrencyMixin, AbstractCondition):
    pass

Following the previous example, if we set absolute discount benefit for 300 Kč value for the particular discount and basket has Euro currency, we'll need automatically subtract €11.70 from the order total. The same rule applies to other benefit classes with absolute discount value.

The example below also includes customization of theFixedPriceBenefit, ShippingAbsoluteDiscountBenefitand ShippingFixedPriceBenefit could be customized using the same approach.

# apps/offer/benefits.py
from decimal import Decimal as D

from oscar.apps.offer.benefits import (
    AbsoluteDiscountBenefit, FixedPriceBenefit)
from oscar.apps.offer.benefits import apply_discount
from oscar.apps.offer.conditions import CoverageCondition, ValueCondition
from oscar.apps.offer.results import ZERO_DISCOUNT, BasketDiscount
from oscar.core.loading import get_class
from oscar.templatetags.currency_filters import currency
from apps.partner.utils import convert_currency

range_anchor = get_class('offer.utils', 'range_anchor')


class CurrencyAwareAbsoluteDiscountBenefit(AbsoluteDiscountBenefit):
    class Meta(AbsoluteDiscountBenefit.Meta):
        proxy = True

    @property
    def name(self):
        return self._description % {
            'value': currency(self.value, self.currency),
            'range': self.range.name.lower()}

    @property
    def description(self):
        return self._description % {
            'value': currency(self.value, self.currency),
            'range': range_anchor(self.range)}

    def apply(self,  basket, condition, offer, **kwargs):
        if basket.currency != self.currency:
            discount_amount = kwargs.get('discount_amount', self.value)
            kwargs['discount_amount'] = convert_currency(self.currency, basket.currency, discount_amount)
        return super().apply(basket, condition, offer, **kwargs)


class CurrencyAwareFixedPriceBenefit(FixedPriceBenefit):
    class Meta(FixedPriceBenefit.Meta):
        proxy = True

    def get_benefit_value(self, basket):
        if self.currency != basket.currency:
            return convert_currency(self.currency, basket.currency, self.value)
        return self.value

    @property
    def name(self):
        return self._description % {
            'amount': currency(self.value, self.currency)}

    def apply(self, basket, condition, offer, **kwargs):  # noqa (too complex (10))
        if isinstance(condition, ValueCondition):
            return ZERO_DISCOUNT

        benefit_value = self.get_benefit_value(basket)
        line_tuples = self.get_applicable_lines(offer, basket, range=condition.range)
        if not line_tuples:
            return ZERO_DISCOUNT

        # Determine the lines to consume
        num_permitted = int(condition.value)
        num_affected = 0
        value_affected = D('0.00')
        covered_lines = []
        for price, line in line_tuples:
            if isinstance(condition, CoverageCondition):
                quantity_affected = 1
            else:
                quantity_affected = min(
                    line.quantity_without_offer_discount(offer),
                    num_permitted - num_affected)
            num_affected += quantity_affected
            value_affected += quantity_affected * price
            covered_lines.append((price, line, quantity_affected))
            if num_affected >= num_permitted:
                break

        discount = max(value_affected - benefit_value, D('0.00'))
        if not discount:
            return ZERO_DISCOUNT

        discount_applied = D('0.00')
        last_line = covered_lines[-1][1]
        for price, line, quantity in covered_lines:
            if line == last_line:
                line_discount = discount - discount_applied
            else:
                line_discount = self.round(
                    discount * (price * quantity) / value_affected)
            apply_discount(line, line_discount, quantity, offer)
            discount_applied += line_discount
        return BasketDiscount(discount)

ValueCondition implies that basket needs to have total amount of the products greater or equal to the condition value. Following the same approach, we convert condition value to the basket currency before making comparison.

# apps/offer/conditions.py
from decimal import Decimal as D

from django.utils.translation import gettext_lazy as _

from oscar.apps.offer.conditions import ValueCondition
from oscar.core.loading import get_classes
from oscar.templatetags.currency_filters import currency

from apps.partner.utils import convert_currency

range_anchor, unit_price = get_classes('offer.utils', ['range_anchor', 'unit_price'])


class CurrencyAwareValueCondition(ValueCondition):
    class Meta(ValueCondition.Meta):
        proxy = True

    @property
    def name(self):
        return self._description % {
            'amount': currency(self.value, self.currency),
            'range': str(self.range).lower()}

    @property
    def description(self):
        return self._description % {
            'amount': currency(self.value, self.currency),
            'range': range_anchor(self.range)}

    def get_condition_value(self, basket):
        if self.currency != basket.currency:
            return convert_currency(self.currency, basket.currency, self.value)
        return self.value

    def is_satisfied(self, offer, basket):
        value_of_matches = D('0.00')
        for line in basket.all_lines():
            if (self.can_apply_condition(line)
                    and line.quantity_without_offer_discount(offer) > 0):
                price = unit_price(offer, line)
                value_of_matches += price * int(
                    line.quantity_without_offer_discount(offer)
                )

            if value_of_matches >= self.get_condition_value(basket):
                return True
        return False

    def is_partially_satisfied(self, offer, basket):
        value_of_matches = self._get_value_of_matches(offer, basket)
        return D('0.00') < value_of_matches < self.get_condition_value(basket)

    def get_upsell_message(self, offer, basket):
        value_of_matches = self._get_value_of_matches(offer, basket)
        condition_value = self.get_condition_value(basket)
        return _('Spend %(value)s more from %(range)s') % {
            'value': currency(condition_value - value_of_matches, self.currency),
            'range': self.range}

All project code available in the Github repository — https://github.com/metaclassco/django_oscar_multicurrency.