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 |
dest_amount |
Target amount in |
dest_unit |
Currency unit of the target amount (defaults to ‘usd’) |
orig_account |
Source account (Funds, Income, Expenses, etc.) |
orig_organization |
Source |
orig_amount |
Source amount in |
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 manage.py 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)
Accounts
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, amount=None, descr=None, created_at=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 provider:Receivable
Example:
2014/09/10 subscribe to open-space plan xia:Payable $179.99 cowork:Receivable
Online charge sucessful
- Charge.payment_successful(receipt_info=None)
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 subscriber:Liability ; 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 broker for provider broker:Expenses processor_fee processor:Backlog yyyy/mm/dd cha_***** broker fee paid by provider provider:Expenses broker_fee_amount broker:Backlog yyyy/mm/dd cha_***** distribution to broker broker:Funds broker_fee_amount processor:Funds yyyy/mm/dd sub_***** distribution to provider (backlog accounting) provider:Receivable plan_amount provider:Backlog yyyy/mm/dd cha_***** distribution to provider provider:Funds distribute_amount processor:Funds
Example:
2014/09/10 Charge ch_ABC123 on credit card of xia stripe:Funds $179.99 xia:Liability 2014/09/10 Keep a balanced ledger xia:Liability $179.99 xia:Payable 2014/09/10 Charge ch_ABC123 broker fee to cowork cowork:Expenses $17.99 broker:Backlog 2014/09/10 Charge ch_ABC123 distribution due to cowork broker:Funds $17.99 stripe:Funds 2014/09/10 Charge ch_ABC123 processor fee for open-space cowork:Expenses $5.22 stripe:Backlog 2014/09/10 Charge ch_ABC123 distribution for open-space cowork:Receivable $179.99 cowork:Backlog 2014/09/10 Charge ch_ABC123 distribution for open-space cowork:Funds $156.78 stripe:Funds
Offline Payment
- TransactionManager.offline_payment(subscription, amount, payment_event_id=None, descr=None, created_at=None, user=None)
For an offline payment, we will record a sequence of
Transaction
as if we went throughpayment_successful
andwithdraw_funds
while bypassing the processor.Thus an offline payment is recorded as follow:
; Record the off-line payment yyyy/mm/dd check_***** charge event provider:Funds amount subscriber:Liability ; 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 funds to the provider yyyy/mm/dd sub_***** distribution to provider (backlog accounting) provider:Receivable amount provider:Backlog yyyy/mm/dd check_***** mark the amount as offline payment provider:Offline amount provider:Funds
Example:
2014/09/10 Check received off-line cowork:Funds $179.99 xia:Liability 2014/09/10 Keep a balanced ledger xia:Liability $179.99 xia:Payable 2014/09/10 backlog accounting cowork:Receivable $179.99 cowork:Backlog 2014/09/10 mark payment as processed off-line cowork:Offline $179.99 cowork:Funds
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 subscriber:Refunded yyyy/mm/dd cha_*****_*** refund of processor fee processor:Refund processor_fee processor:Funds yyyy/mm/dd cha_*****_*** refund of broker fee processor:Refund broker_fee broker:Funds yyyy/mm/dd cha_*****_*** cancel distribution processor:Refund distribute_amount provider:Funds
Refund
is replaced byChargeback
if the refund was initiated by a chargeback event.Example:
2014/09/10 Charge ch_ABC123 refund for subscribe to open-space plan cowork:Refund $179.99 xia:Refunded 2014/09/10 Charge ch_ABC123 refund processor fee stripe:Refund $5.22 stripe:Funds 2014/09/10 Charge ch_ABC123 refund broker fee stripe:Refund $17.99 broker:Funds 2014/09/10 Charge ch_ABC123 cancel distribution stripe:Refund $156.78 cowork:Funds
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
provider:Funds
Withdrawal
- 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 provider:Funds ; With StripeConnect there are no processor fees anymore ; for Payouts. yyyy/mm/dd processor fee paid by provider processor:Funds processor_fee provider:Funds
Example:
2014/09/10 withdraw from cowork stripe:Withdraw $174.52 cowork:Funds
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 subscriber:Payable
Example:
2014/09/10 past due for period 2014/09/10 to 2014/10/10 xia:Liability $179.99 xia:Payable
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 aReceivable
(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 provider:Income yyyy/mm/dd sub_***** When service is invoiced after period ends provider:Receivable period_amount provider:Income
Example:
2014/09/10 recognized income for period 2014/09/10 to 2014/10/10 cowork:Backlog $179.99 cowork:Income
Write off
- Organization.create_cancel_transactions(subscription, amount, dest_unit, descr=None, created_at=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 subscriber:Payable yyyy/mm/dd sub_***** write off liability provider:Writeoff liability_amount subscriber:Liability yyyy/mm/dd sub_***** write off receivable subscriber:Canceled liability_amount provider:Receivable
Example:
2014/09/10 balance ledger xia:Liability $179.99 xia:Payable 2014/09/10 write off liability cowork:Writeoff $179.99 xia:Liability 2014/09/10 write off receivable xia:Canceled $179.99 cowork:Receivable
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 aCharge
is created for payment of a balance due by a subcriber:yyyy/mm/dd sub_***** description subscriber:Settled amount provider:Settled
Example:
2014/09/10 balance due xia:Settled $179.99 cowork:Settled
Charges
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.