pledge

Using the models

from decimal import Decimal
from django_pledge.models import Account, Entry, Transfer

Create accounts #

bank = Account.objects.create(name="bank", currency="EUR")
alice = Account.objects.create(
    name="alice", currency="EUR", allow_negative_balance=False, metadata={"user": 42}
)

alice.id        # AccountId('pla_01K9...')

Leave id unset. The database picks it.

Move money #

bank.transfer(alice, Decimal("100.00"))

# the same thing, spelled out
Transfer.objects.create(from_account=bank, to_account=alice, amount=Decimal("100.00"))

Add your own data with metadata, and the real date with event_at:

t = bank.transfer(alice, Decimal("12.50"), metadata={"order": 7})
t.id            # TransferId('plt_01K9...')
t.created_at    # filled in
Use Decimal

Amounts must be Decimal or int. A float raises ValidationError.

Many at once #

bulk_create books a list of transfers in one call. All or nothing.

Transfer.objects.bulk_create(
    [
        Transfer(from_account=alice, to_account=seller, amount=Decimal("9.00")),
        Transfer(from_account=alice, to_account=revenue, amount=Decimal("1.00")),
    ],
    metadata={"order": 7},   # for every transfer that doesn't set its own
)

It returns the same instances, filled in, in order.

Safe retries #

Make the transfer id first and store it on your own model. Then retrying is safe: the same id is booked once.

from django_pledge import TransferId

order.transfer_id = TransferId.new()
order.save()

alice.transfer(shop, order.amount, id=order.transfer_id)   # safe to run again

Tip: give your model a field with a default, and every new row gets an id for free:

from django_pledge import TransferIdField

class Order(models.Model):
    transfer_id = TransferIdField(default=TransferId.new, unique=True, editable=False)

See safe retries for the rules.

Read balances and history #

alice.refresh_from_db()
alice.balance                     # Decimal('97.50')
alice.is_closed                   # False

alice.history()                   # entries, oldest first, with running balances
alice.transfers()                 # transfers in and out, in order
alice.balance_at(some_datetime)   # balance at that moment

Transfer.objects.filter(metadata__order=7)
Entry.objects.filter(account=alice).select_related("transfer")

Balances keep the scale of the transfers, so an empty BTC account shows Decimal('0E-8'). DjangoJSONEncoder writes that as "0E-8". django_pledge.json.LedgerJSONEncoder writes "0.00000000" instead. The metadata fields use it, and you can pass it to your own responses:

from django_pledge.json import LedgerJSONEncoder

JsonResponse({"balance": alice.balance}, encoder=LedgerJSONEncoder)

A statement for a customer:

for entry in alice.history().select_related("transfer"):
    print(entry.created_at, entry.amount, entry.account_current_balance)

Close an account #

from django.db import transaction

with transaction.atomic():
    alice.refresh_from_db()
    if alice.balance > 0:
        alice.transfer(bank, alice.balance)
    alice.close()

close() needs a zero balance. If money lands in between, it raises AccountNotEmpty and the whole block rolls back.

Handle errors #

Ledger errors are raised as django_pledge.LedgerError subclasses:

import django_pledge

try:
    alice.transfer(shop, amount)
except django_pledge.NegativeBalanceNotAllowed as e:
    e.account_id  # the account without enough money
except django_pledge.LedgerError as e:
    e.code, e.message, e.detail

Each error code has a class: AccountClosed, AccountNotEmpty, CurrencyMismatch, TransferConflict and so on. The ids and values involved are typed attributes, like e.account_id, e.transfer_id or e.balance. The full list is in the reference.

Every write runs in its own savepoint, so a refused transfer doesn’t break your surrounding transaction.

What you can’t do #

Ledger rows can’t be changed or deleted. These raise ReadOnlyError:

  • save() on an existing row, delete()
  • QuerySet.update(), QuerySet.delete(), bulk_update()
  • get_or_create(), update_or_create()

To undo a transfer, book one the other way.

Tests #

Check the books at the end of a test:

Account.objects.assert_verified()   # raises if anything is off