django-oscar / django-oscar-accounts

Managed accounts for Django (with or without django-oscar)
BSD 3-Clause "New" or "Revised" License
212 stars 119 forks source link

=========================== Managed accounts for Django

A 'managed account' is an allocation of money that can be debited and credited. This package provides managed account functionality for use with the e-commerce framework Oscar_.

.. _Oscar: https://github.com/django-oscar/django-oscar

Accounts can be used to implement a variety of interesting components, including:

Basically anything that involves tracking the movement of funds within a closed system.

This package uses double-entry bookkeeping_ where every transaction is recorded twice (once for the source and once for the destination). This ensures the books always balance and there is full audit trail of all transactional activity.

If your project manages money, you should be using a library like this. Your finance people will thank you.

.. image:: https://github.com/django-oscar/django-oscar-accounts/workflows/Tests/badge.svg

.. image:: http://codecov.io/github/django-oscar/django-oscar-accounts/coverage.svg?branch=master :alt: Coverage :target: http://codecov.io/github/django-oscar/django-oscar-accounts?branch=master

.. image:: https://requires.io/github/django-oscar/django-oscar-accounts/requirements.svg?branch=master :target: https://requires.io/github/django-oscar/django-oscar-accounts/requirements/?branch=master :alt: Requirements Status

.. image:: https://img.shields.io/pypi/v/django-oscar-accounts.svg :target: https://pypi.python.org/pypi/django-oscar-accounts/

.. _double-entry bookkeeping: http://en.wikipedia.org/wiki/Double-entry_bookkeeping_system

Features

Screenshots

.. image:: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-accounts.thumb.png :alt: Dashboard account list :target: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-accounts.png

.. image:: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-form.thumb.png :alt: Create new account :target: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-form.png

.. image:: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-transfers.thumb.png :alt: Dashboard transfer list :target: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-transfers.png

.. image:: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-detail.thumb.png :alt: Dashboard account detail :target: https://github.com/tangentlabs/django-oscar-accounts/raw/master/screenshots/dashboard-detail.png

Installation

Install using pip:

.. code-block:: bash

pip install django-oscar-accounts

and add oscar_accounts.apps.AccountsConfig and oscar_accounts.dashboard.apps.AccountsDashboardConfig to INSTALLED_APPS. Running manage.py migrate oscar_accounts will create the appropriate database tables. To create initial some core accounts and account-types use manage.py oscar_accounts_init. The names of these accounts can be controlled using settings (see below).

In order to make the accounts accessible via the Oscar dashboard you need to append it to your OSCAR_DASHBOARD_NAVIGATION

.. code-block:: python

from oscar.defaults import *

OSCAR_DASHBOARD_NAVIGATION.append(
    {
        'label': 'Accounts',
        'icon': 'fas fa-globe',
        'children': [
            {
                'label': 'Accounts',
                'url_name': 'accounts_dashboard:accounts-list',
            },
            {
                'label': 'Transfers',
                'url_name': 'accounts_dashboard:transfers-list',
            },
            {
                'label': 'Deferred income report',
                'url_name': 'accounts_dashboard:report-deferred-income',
            },
            {
                'label': 'Profit/loss report',
                'url_name': 'accounts_dashboard:report-profit-loss',
            },
        ]
    })

Furthermore you need to add the url-pattern to your urls.py

.. code-block:: python

from django.apps import apps

# ...

urlpatterns = [
    ...
    path('dashboard/accounts/', apps.get_app_config('accounts_dashboard').urls),
]

You should also set-up a cronjob that calls::

./manage.py close_expired_accounts

to close any expired accounts and transfer their funds to the 'expired' account.

API

Create account instances using the manager:

.. code-block:: python

from decimal import Decimal
import datetime

from django.contrib.auth import get_user_model

from oscar_accounts import models

User = get_user_model()

anonymous_account = models.Account.objects.create()

barry = User.objects.get(username="barry")
user_account = models.Account.objects.create(primary_user=barry)

no_credit_limit_account = models.Account.objects.create(credit_limit=None)
credit_limit_account = models.Account.objects.create(credit_limit=Decimal('1000.00'))

today = datetime.date.today()
next_week = today + datetime.timedelta(days=7)
date_limited_account = models.Account.objects.create(
    start_date=today, end_date=next_week)

Transfer funds using the facade:

.. code-block:: python

from oscar_accounts import facade

staff_member = User.objects.get(username="staff")
trans = facade.transfer(source=no_credit_limit_account,
                        destination=user_account,
                        amount=Decimal('10.00'),
                        user=staff_member)

Reverse transfers:

.. code-block:: python

facade.reverse(trans, user=staff_member,
               description="Just an example")

If the proposed transfer is invalid, an exception will be raised. All exceptions are subclasses of oscar_accounts.exceptions.AccountException. Your client code should look for exceptions of this type and handle them appropriately.

Client code should only use the oscar_accounts.models.Budget class and the two functions from oscar_accounts.facade - nothing else should be required.

Error handling

Note that the transfer operation is wrapped in its own database transaction to ensure that only complete transfers are written out. When using Django's transaction middleware, you need to be careful. If you have an unhandled exception, then account transfers will still be committed even though nothing else will be. To handle this, you need to make sure that, if an exception occurs during your post-payment code, then you roll-back any transfers.

Here's a toy example:

.. code-block:: python

from oscar_accounts import facade

def submit(self, order_total):
    # Take payment first
    transfer = facade.transfer(self.get_user_account(),
                               self.get_merchant_account(),
                               order_total)
    # Create order models
    try:
        self.place_order()
    except Exception, e:
        # Something went wrong placing the order.  Roll-back the previous
        # transfer
        facade.reverse(transfer)

In this situation, you'll end up with two transfers being created but no order. While this isn't ideal, it's the best way of handling exceptions that occur during order placement.

Multi-transfer payments

Projects will often allow users to have multiple accounts and pay for an order using more than one. This will involve several transfers and needs some careful handling in your application code.

It normally makes sense to write your own wrapper around the accounts API to encapsulate your business logic and error handling. Here's an example:

.. code-block:: python

from decimal import Decimal as D
from oscar_accounts import models, exceptions, facade

def redeem(order_number, user, amount):
    # Get user's non-empty accounts ordered with the first to expire first
    accounts = models.Account.active.filter(
        user=user, balance__gt=0).order_by('end_date')

    # Build up a list of potential transfers that cover the requested amount
    transfers = []
    amount_to_allocate = amount
    for account in accounts:
        to_transfer = min(account.balance, amount_to_allocate)
        transfers.append((account, to_transfer))
        amount_to_allocate -= to_transfer
        if amount_to_allocate == D('0.00'):
            break
    if amount_to_allocate > D('0.00'):
        raise exceptions.InsufficientFunds()

    # Execute transfers to some 'Sales' account
    destination = models.Account.objects.get(name="Sales")
    completed_transfers = []
    try:
        for account, amount in transfers:
            transfer = facade.transfer(
                account, destination, amount, user=user,
                description="Order %s" % order_number)
            completed_transfers.append(transfer)
    except exceptions.AccountException, transfer_exc:
        # Something went wrong with one of the transfers (possibly a race condition).
        # We try and roll back all completed ones to get us back to a clean state.
        try:
            for transfer in completed_transfers:
                facade.reverse(transfer)
        except Exception, reverse_exc:
            # Uh oh: No man's land.  We could be left with a partial
            # redemption. This will require an admin to intervene.  Make
            # sure your logger mails admins on error.
            logger.error("Order %s, transfers failed (%s) and reverse failed (%s)",
                         order_number, transfer_exc, reverse_exc)
            logger.exception(reverse_exc)

        # Raise an exception so that your client code can inform the user appropriately.
        raise RedemptionFailed()
    else:
        # All transfers completed ok
        return completed_transfers

As you can see, there is some careful handling of the scenario where not all transfers can be executed.

If you using Oscar then ensure that you create an OrderSource instance for every transfer (rather than aggregating them all into one). This will provide better audit information. Here's some example code:

.. code-block:: python

try:
    transfers = api.redeem(order_number, user, total_incl_tax)
except Exception:
    # Inform user of failed payment
else:
    for transfer in transfers:
        source_type, __ = SourceType.objects.get_or_create(name="Accounts")
        source = Source(
            source_type=source_type,
            amount_allocated=transfer.amount,
            amount_debited=transfer.amount,
            reference=transfer.reference)
        self.add_payment_source(source)

Core accounts and account types

A post-syncdb signal will create the common structure for account types and accounts. Some names can be controlled with settings, as indicated in parentheses.

Example transactions

Consider the following accounts and account types:

Note that all accounts start with a balance of 0 and the sum of all balances will always be zero.

A customer purchases a £50 giftcard

A customer pays for a £30 order using their £50 giftcard

The customer's giftcard expires with £20 still on it

The customer phones up to complain and a staff member creates a new giftcard for £20

Settings

There are settings to control the naming and initial unpaid and deferred income account types:

Contributing

Fork repo, set-up virtualenv and run::

make install

Run tests with::

pytest