Transaction ledger

Transactions are recorded in an append-only double-entry book keeping ledger using the following Transaction Model:

Name Description
created_at Date of creation
descr Free-form text description (optional)
event_id Tie-in to other models or third-party systems (optional)
dest_account Target account (Funds, Income, Expenses, etc.)
dest_organization Target Organization
dest_amount Target amount in dest_unit
dest_unit Currency unit of the target amount (defaults to ‘usd’)
orig_account Source account (Funds, Income, Expenses, etc.)
orig_organization Source Organization
orig_amount Source amount in orig_unit
orig_unit Currency unit of the source amount (defaults to ‘usd’)

A Transaction records the movement of an amount from an source to a target.

All transactions can be expored in ledger-cli format using the export command:

python ledger export

In a minimal cash flow accounting system, orig_account and dest_account are optional, or rather each Organization only has one account (Funds) because we only keep track of the actual transfer of funds.

In a more complex system, like here, we want to keep track of cash assets, revenue and expenses separately because those numbers are meaningful to understand the business. The balance sheet we want to generate at the end of each accounting period will dictate the number of accounts each Organization has as well as the movements recorded in the double-entry ledger.

In an online subscription business, there are two chain of events that trigger Transaction to be recorded: the subscription pipeline itself and the charge pipeline.

subscription pipeline:

  • place a subscription order from a Cart
  • period start
  • period end

charge pipeline:

  • charge sucessful
  • refund or chargeback (optional)
  • refund or chargeback expiration (cannot be disputed afterwards)


The balance sheet we are working out of leads to 11 accounts, 9 directly derived from above then 2 more (Withdraw and Writeoff) to balance the books.

  • Backlog
    Cash received by a provider that was received in advance of earning it.
  • Chargeback
    Cash taken back out of a provider funds by the platform on a dispute.
  • Canceled
    Receivables are written off
  • Expenses
    Fees paid by provider to a processor to settle a credit card payment.
  • Funds
    Cash amount currently held on the platform by a provider.
  • Income
    Taxable income on a provider for service provided and invoiced.
  • Liability
    Balance due by a subscriber.
  • Payable
    Order of a subscription to a plan as recorded by a subscriber.
  • Offline
    Record an offline payment to a provider (ex: paper check).
  • Receivable
    Order of a subscription to a plan as recorded by a provider.
  • Refund
    Cash willingly transfered out of a provider funds held on the platform.
  • Refunded
    Cash transfered back to a subscriber credit card.
  • Withdraw
    Cash that was taken out of the platform by a provider.
  • Writeoff
    Payables that cannot and will not be collected by a provider

Place a subscription order from a Cart

TransactionManager.new_subscription_order(subscription, nb_natural_periods, prorated_amount=0, created_at=None, descr=None, discount_percent=0, descr_suffix=None)

Each time a subscriber places an order through the /billing/:organization/cart/ page, a Transaction is recorded as follow:

yyyy/mm/dd sub_***** description
    subscriber:Payable                       amount


2014/09/10 subscribe to open-space plan
    xia:Payable                             $179.99

At first, nb_periods, the number of period paid in advance, is stored in the Transaction.orig_amount. The Transaction is created in TransactionManager.new_subscription_order, then only later saved when TransactionManager.record_order is called through Organization.execute_order. record_order will replace orig_amount by the correct amount in the expected currency.

Charge sucessful


When a charge through the payment processor is sucessful, a unique Transaction records the charge through the processor. The amount of the charge is then redistributed to the providers (minus processor fee):

; Record the charge

yyyy/mm/dd cha_***** charge event
    processor:Funds                          charge_amount

; Compensate for atomicity of charge record (when necessary)

yyyy/mm/dd sub_***** invoiced-item event
    subscriber:Liability           min(invoiced_item_amount,
    subscriber:Payable                      balance_payable)

; Distribute processor fee and funds to the provider

yyyy/mm/dd cha_***** processor fee paid by provider
    provider:Expenses                        processor_fee

yyyy/mm/dd cha_***** broker fee paid by provider
    provider:Expenses                        broker_fee_amount

yyyy/mm/dd cha_***** distribution to broker
    broker:Funds                             broker_fee_amount

yyyy/mm/dd sub_***** distribution to provider (backlog accounting)
    provider:Receivable                      plan_amount

yyyy/mm/dd cha_***** distribution to provider
    provider:Funds                           distribute_amount


2014/09/10 Charge ch_ABC123 on credit card of xia
    stripe:Funds                           $179.99

2014/09/10 Keep a balanced ledger
    xia:Liability                          $179.99

2014/09/10 Charge ch_ABC123 broker fee to cowork
    cowork:Expenses                        $17.99

2014/09/10 Charge ch_ABC123 distribution due to cowork
    broker:Funds                           $17.99

2014/09/10 Charge ch_ABC123 processor fee for open-space
    cowork:Expenses                         $5.22

2014/09/10 Charge ch_ABC123 distribution for open-space
    cowork:Receivable                     $179.99

2014/09/10 Charge ch_ABC123 distribution for open-space
    cowork:Funds                          $156.78

Refund and Chargeback

Refunds are initiated by the provider while chargebacks are initated by the subscriber. In either case, they represent a loss of income while the service was provided.

ChargeItem.create_refund_transactions(refunded_amount, charge_available, charge_processor_fee, charge_broker_fee, corrected_available, corrected_processor_fee, corrected_broker_fee, created_at=None, refund_type=None)

Each ChargeItem can be partially refunded:

yyyy/mm/dd cha_*****_*** refund to subscriber
    provider:Refund                          refunded_amount

yyyy/mm/dd cha_*****_*** refund of processor fee
    processor:Refund                         processor_fee

yyyy/mm/dd cha_*****_*** refund of broker fee
    processor:Refund                         broker_fee

yyyy/mm/dd cha_*****_*** cancel distribution
    processor:Refund                         distribute_amount

Refund is replaced by Chargeback if the refund was initiated by a chargeback event.


2014/09/10 Charge ch_ABC123 refund for subscribe to open-space plan
    cowork:Refund                            $179.99

2014/09/10 Charge ch_ABC123 refund processor fee
    stripe:Refund                              $5.22

2014/09/10 Charge ch_ABC123 refund broker fee
    stripe:Refund                             $17.99

2014/09/10 Charge ch_ABC123 cancel distribution
    stripe:Refund                            $156.78

Stripe allows you to issue a refund at any time up to 90 days after the charge while for most transactions, subscribers have 120 days from the sale or when they discovered a problem with the product to dispute a charge.

The provider will incur an extra fee on the chargeback that we record as such:

yyyy/mm/dd chargeback fee
    processor:Funds                          chargeback_fee


Organization.create_withdraw_transactions(event_id, amount, unit, descr, created_at=None, dry_run=False)

Withdraw funds from the site into the provider’s bank account.

We record one straightforward Transaction for the withdrawal and an additional one in case there is a processor transfer fee:

yyyy/mm/dd po_***** withdrawal to provider bank account
    processor:Withdraw                       amount

; With StripeConnect there are no processor fees anymore
; for Payouts.
yyyy/mm/dd processor fee paid by provider
    processor:Funds                          processor_fee


2014/09/10 withdraw from cowork
    stripe:Withdraw                          $174.52

new_subscription_order and payment_successful generates a seemingly complex set of Transaction. Now we see how the following events build on the previously recorded transactions to implement deferred revenue accounting.

The following events create “accounting” transactions. No actual funds is transfered between the organizations.

Period started

TransactionManager.create_period_started(subscription, created_at=None)

When a period starts and we have a payable balance for a subscription, we transfer it to a Liability account, recorded as follow:

yyyy/mm/dd sub_***** description
    subscriber:Liability                     period_amount


2014/09/10 past due for period 2014/09/10 to 2014/10/10
    xia:Liability                             $179.99

Period ended

TransactionManager.create_income_recognized(subscription, amount=0, starts_at=None, ends_at=None, descr=None, event_id=None, dry_run=False)

When a period ends and we either have a Backlog (payment was made before the period starts) or a Receivable (invoice is submitted after the period ends). Either way we must recognize income for that period since the subscription was serviced:

yyyy/mm/dd sub_***** When payment was made at begining of period
    provider:Backlog                   period_amount

yyyy/mm/dd sub_***** When service is invoiced after period ends
    provider:Receivable                period_amount


2014/09/10 recognized income for period 2014/09/10 to 2014/10/10
    cowork:Backlog                         $179.99

Write off

Organization.create_cancel_transactions(at_time=None, user=None)

Sometimes, a provider will give up and assume receivables cannot be recovered from a subscriber. At that point the receivables are written off.:

yyyy/mm/dd sub_***** balance ledger
    subscriber:Liability                       payable_amount

yyyy/mm/dd sub_***** write off liability
    provider:Writeoff                          liability_amount

yyyy/mm/dd sub_***** write off receivable
    subscriber:Canceled                        liability_amount


2014/09/10 balance ledger
    xia:Liability                             $179.99

2014/09/10 write off liability
    cowork:Writeoff                           $179.99

2014/09/10 write off receivable
    xia:Canceled                              $179.99

Settled account

TransactionManager.new_subscription_statement(subscription, created_at=None, descr_pat=None, balance_now=None)

Since the ordering system is tightly coupled to the Transaction ledger, we create special “none” transaction that are referenced when a Charge is created for payment of a balance due by a subcriber:

yyyy/mm/dd sub_***** description
    subscriber:Settled                        amount


2014/09/10 balance due
    xia:Settled                             $179.99


Charges are recorded in a table separate from the ledger. They undergo their own state diagram as follows.


ChargeItem records every line item for a Charge. The recorded relationships between Charge, ChargeItem and Transaction.event_id is critical to easily record refunds, chargeback disputes and reverted chargebacks in an append-only double-entry bookkeeping system.