General Ledger Design
1. Overview
This document specifies how EMS records the financial effect of what it sells. Each business transaction — a sale, a payment, a fee, a refund — posts to the ledger when it happens, using the amounts actually charged. Postings belong to the books of the company they concern, so the ledger holds several companies' books and any one company’s books can be projected on their own. Posted transactions are consolidated into journals for export to external accounting software.
2. Status
Implementation status: proposed, not implemented.
Related architecture: Financial Management Architecture
Amount definitions: Order Line Item Amounts
Decision history: design-journal/2026-09/financial-ledger-posting-model.adoc
3. Purpose
3.1. Business Goals
-
Post at the transaction - A sale is in the ledger when it is made, a payment when money is received
-
Actual amounts - Every posting uses the sales price, discount and fee actually recorded, never a recalculation
-
Each company’s books - One ledger holds several companies; each company’s projection is a complete, balanced set of books
-
Simple destinations - Operators say where money was received; which accounts that means is configuration
-
Traceability - Every posting links back to the order, line item or payment it came from
-
Delta tracking - Corrections after a journal are posted as deltas, never as silent edits
-
Journal consolidation - Transactions are consolidated per company for export
-
Steady state balance - All accounts net to zero once everything is exported
3.2. Design Rationale
Orders are operational records and change for business reasons at any time. The ledger is the financial record, so it is kept separate from them:
-
Orders are operational - Lines are added and removed, prices corrected, orders cancelled
-
Ledger records are financial - They keep an audit trail of what was recorded and when
-
Journals lock transactions - Once exported, a change is tracked as a delta
The shapes below were chosen over these alternatives:
| Rejected alternative | Why it loses |
|---|---|
Post only when an order is paid |
The ledger would never hold what is owed. Unpaid sales, including entries whose riders raced, would be invisible to the books, and the timing of revenue would depend on when the customer paid rather than when the sale was made. |
Derive every account from a payment processor held on the order |
An order can be paid in parts, paid twice, or started online and completed at a desk, so one order-level processor cannot say where money was received. Deriving the income account from how the customer paid would also make revenue depend on the payment method, which is not a property of the sale. |
Treat revenue as the amount net of fees |
Revenue is |
Mark journaled transactions by changing their type to |
With sales and payments both posted, the original type is lost, so unwinding a journal cannot restore it. A journaled transaction instead references the journal that includes it. |
Let operators choose a ledger account when taking a payment |
Operators at a desk know whether money went into a cash box, through a card machine or through online payment. The accounts behind each are configuration. |
4. Amounts
Every posting uses the amounts persisted on the order line items and payments. Each is an actual, recorded at the time of the transaction by the channel that made it:
| Amount | Actual |
|---|---|
|
The sales price the selling channel recorded for the line |
|
The discount value the selling channel applied to the line |
|
|
|
The charge the destination’s provider made for collecting the payment, as charged |
Nothing is recomputed from the current price list or from a fee formula when posting. Where a provider charges a fee per payment rather than per line, the fee is apportioned across the payment’s lines as described in Fee attribution, so the line fees sum exactly to the charge.
For a fully paid line, the ledger therefore records:
income = gross
discount allowed = discount
fee expense = fee
money received = gross - discount - fee (into the destination's receipt account)
5. Posting Triggers
Each business transaction posts when it happens:
| Business transaction | When it posts | Transaction type |
|---|---|---|
Sale |
When the order is placed — an online order when its storefront order is created, a desk sale when it is recorded. An order still being assembled during registration posts nothing. |
|
Correction to a sale — a line added or removed, a price or discount changed, the order cancelled |
When made. Changes the sale’s |
|
Payment |
When money is received into a destination |
|
Fee that becomes known after the payment |
When it becomes known, for example on a card-machine settlement statement |
|
Refund of money |
When money is returned |
|
Manual correction not arising from an order |
When made |
|
Journal |
When created |
|
A discount granted after the sale — including a refund recorded against an unpaid order to make an entry free — is a correction to the sale, not a refund. No money moves.
6. Database Schema
6.1. Entity Relationships
An order has one ORDER transaction per set of books it affects, and one PAYMENT transaction per payment
per set of books. See Books and Tenancy.
6.2. Relationship Ownership
| Relationship | Owner | Rationale |
|---|---|---|
GlTransaction → Order |
GlTransaction (FK on gl_transaction) |
Financial record references operational source |
GlTransaction → Payment |
GlTransaction (FK on gl_transaction) |
Financial record references operational source |
GlTransaction → Journal |
GlTransaction (FK on gl_transaction, self-referencing) |
A source transaction records the journal that includes it |
GlRecord → OrderLineItem |
GlRecord (FK on gl_record) |
Financial record references operational source |
6.3. Table: gl_transaction (Modified)
CREATE TABLE gl_transaction (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
-- Transaction details
transaction_date DATE NOT NULL,
created_date_time TIMESTAMP NOT NULL,
description VARCHAR(50),
-- Type discriminator
transaction_type VARCHAR(2) NOT NULL, -- 'OR', 'PM', 'JN', 'AD', 'RF'
-- Relationships
order_id BIGINT, -- FK to sales_order (nullable)
payment_id BIGINT, -- FK to the payment record (nullable)
journal_id BIGINT, -- FK to gl_transaction; set when journaled
organisation_id BIGINT NOT NULL, -- the books this transaction belongs to
CONSTRAINT fk_gl_transaction_order
FOREIGN KEY (order_id) REFERENCES sales_order(id),
CONSTRAINT fk_gl_transaction_journal
FOREIGN KEY (journal_id) REFERENCES gl_transaction(id),
CONSTRAINT fk_gl_transaction_organisation
FOREIGN KEY (organisation_id) REFERENCES organisation(id)
);
6.4. Table: gl_record (Modified)
CREATE TABLE gl_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
-- Amount (positive = debit, negative = credit)
amount DECIMAL(21,2) NOT NULL,
posted_date DATE NOT NULL,
-- Delta indicator
is_delta BOOLEAN DEFAULT FALSE,
-- Relationships
account_id BIGINT NOT NULL,
transaction_id BIGINT NOT NULL,
order_line_item_id BIGINT, -- FK to order_line_item (nullable)
CONSTRAINT fk_gl_record_account
FOREIGN KEY (account_id) REFERENCES gl_account(id),
CONSTRAINT fk_gl_record_transaction
FOREIGN KEY (transaction_id) REFERENCES gl_transaction(id),
CONSTRAINT fk_gl_record_line_item
FOREIGN KEY (order_line_item_id) REFERENCES order_line_item(id)
);
6.5. Enumerations
6.5.1. GlTransactionType
| Value | Code | Meaning |
|---|---|---|
|
|
A sale, posted when the order is placed |
|
|
Money received into a destination, and any fee charged for it |
|
|
A consolidated export entry |
|
|
A manual correction not arising from an order |
|
|
Money returned to a customer |
Stored as varchar(2) via a converter. A transaction’s type never changes; being journaled is recorded by
journal_id.
7. Payment Destinations
A payment records where the money was received. Operators choose a destination from three types; they never choose an account.
| Type | What it is | Typical receipt account |
|---|---|---|
Cash box |
A named cash container used at a desk |
Cash on hand |
Card machine |
A specific card terminal |
Card settlement clearing |
Online payment |
A payment gateway merchant account |
Gateway balance |
Each destination is configuration. It belongs to one organisation — the company that owns the money it receives — and maps to accounts in that organisation’s books:
-
receiptAccount(required) - where money received through this destination is held -
feeAccount(optional) - where fees charged by this destination’s provider are expensed; empty for a cash box
A payment carries its destination, the amount received, when it was received (UTC), the provider’s or operator’s reference, and the fee once known. An order may have any number of payments, through any destinations.
8. Account Resolution
Accounts are resolved from what each posting is about, never from how a different posting was made:
| Posting | Account | Resolved from |
|---|---|---|
Sale |
Income |
The selling organisation’s configuration |
Sale |
Discount allowed |
The selling organisation’s configuration |
Sale, payment |
Receivable |
The selling organisation’s configuration |
Payment |
Receipt |
The payment’s destination |
Payment |
Fee |
The payment’s destination |
Payment in excess of what is owed |
Customer credit |
The selling organisation’s configuration |
9. Posting Rules
9.1. Sale
Posted as an ORDER transaction in the seller’s books when the order is placed.
| Record | Amount | Per | Sign |
|---|---|---|---|
Income |
|
Line item |
Credit (negative) |
Discount allowed |
|
Line item |
Debit (positive) |
Receivable |
Sum of |
Order (consolidated) |
Debit (positive) |
9.2. Payment
Posted as a PAYMENT transaction when money is received.
| Record | Amount | Per | Sign |
|---|---|---|---|
Receipt |
Amount received less |
Payment (consolidated) |
Debit (positive) |
Fee |
|
Line item |
Debit (positive) |
Receivable |
Amount received, up to what the order still owes |
Order (consolidated) |
Credit (negative) |
Customer credit |
Any amount received beyond what the order owes |
Payment |
Credit (negative) |
Where the fee is not known when the payment is received, the payment posts with a zero fee and the fee posts
later as a further PAYMENT transaction on the same payment: debit fee, credit receipt. Either way the
receipt account ends with gross - discount - fee.
Customer credit remains until it is resolved — refunded, or applied to another order.
9.3. Refund
Posted as a REFUND transaction when money is returned: debit income for the amount refunded against the line,
credit the receipt account of the destination the money was returned from. A fee the provider retains is not
reversed.
9.5. Example: A Sale and Its Payment
An online order with two entries. Entry A sells at R200.00 with no discount; entry B sells at R200.00 with a R20.00 discount. The customer pays R380.00 online, and the gateway charges R18.35, apportioned as R9.66 to A and R8.69 to B.
Sale (ORDER, when the order is placed):
| Account | Description | Debit | Credit |
|---|---|---|---|
Event Income |
Entry A |
R200.00 |
|
Event Income |
Entry B |
R200.00 |
|
Discount Allowed |
Entry B |
R20.00 |
|
Receivable |
Order (consolidated) |
R380.00 |
|
Totals |
R400.00 |
R400.00 |
Payment (PAYMENT, when the money is received):
| Account | Description | Debit | Credit |
|---|---|---|---|
Gateway Balance |
Received less fee |
R361.65 |
|
Payment Fees |
Entry A |
R9.66 |
|
Payment Fees |
Entry B |
R8.69 |
|
Receivable |
Order (consolidated) |
R380.00 |
|
Totals |
R380.00 |
R380.00 |
The gateway balance holds R361.65: gross R400.00, less discount R20.00, less fee R18.35. Between the two postings the receivable nets to zero, and had the customer never paid, the R380.00 would still be in the books as owed.
10. Books and Tenancy
The ledger holds the books of more than one company. A company’s books are the transactions whose
organisation is that company.
-
Every transaction belongs to exactly one company’s books and references only that company’s accounts.
-
Every transaction balances, so any one company’s transactions, taken alone, balance. That projection is a complete set of books for that company.
-
The seller’s books carry the sale. The destination owner’s books carry the money received. When the seller owns the destination, a payment is one transaction. When a different company owns it — a cash box or card machine operated by one company on another’s behalf — the payment posts one transaction in each company’s books, each balanced, both referencing the same payment.
An organisation’s accounts are not currently scoped to that organisation in the schema; rule 1 requires it.
11. Delta Handling
11.1. When Delta Records Are Created
A correction to a posted transaction is applied in place until the transaction is journaled, and as delta records afterwards:
A delta record links to the affected line item where there is one, and is included by the next journal.
An order cancelled before its sale is journaled — a duplicate order, for example — therefore leaves no trace in the exported books. One cancelled after its sale is journaled posts a reversing delta.
11.2. Example: Price Correction After Journal
-
An entry sold at R200.00 is posted and journaled on 15 January
-
On 20 January the entry is found to belong to a category priced at R180.00, and the sale is corrected
-
Delta records are created on the sale’s
ORDERtransaction:
| Account | Description | Debit | Credit |
|---|---|---|---|
Event Income |
Price correction delta |
R20.00 |
|
Receivable |
Price correction delta |
R20.00 |
If the entry had already been paid, the R20.00 credit leaves the order over-paid. The over-payment is resolved like any other customer credit.
12. Journal Management
12.1. Journal Creation
A journal consolidates one company’s transactions that no journal yet includes. Multiple optional filter parameters allow flexible consolidation:
12.1.1. Filter Parameters
| Parameter | Type | Description |
|---|---|---|
organisationId |
Long (required) |
The company whose books are journaled |
toDate |
LocalDate (required) |
Include transactions up to and including this date |
fromDate |
LocalDate (optional) |
Include transactions from this date (if null, includes all prior) |
registrationSystemId |
Long (optional) |
Include only transactions for orders from this registration system |
paymentDestinationId |
Long (optional) |
Include only payment and refund transactions through this destination |
description |
String (optional) |
Journal description |
Creating a journal:
-
Selects the company’s
ORDER,PAYMENT,REFUNDandADJUSTMENTtransactions matching the filters whosejournal_idis empty, including delta records added since they were last journaled. -
Creates a
JOURNALtransaction in that company’s books. -
Creates one consolidated record per account, summing the selected records.
-
Sets
journal_idon each selected transaction. -
Stores the filter criteria with the journal.
13. API Design
13.1. Endpoints
| Method | Endpoint | Description |
|---|---|---|
POST |
|
Create journal for date range |
GET |
|
List journals (paginated) |
GET |
|
Get journal details |
GET |
|
Get journal GL records |
DELETE |
|
Delete journal (unwind) |
13.2. Request/Response Examples
13.2.1. Create Journal
# Minimal request - the company's books up to a date
curl -X POST http://localhost:8080/api/gl/journals \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"organisationId": 1,
"toDate": "2026-01-31",
"description": "January 2026 Journal"
}'
# Full request - with optional filters
curl -X POST http://localhost:8080/api/gl/journals \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"organisationId": 1,
"fromDate": "2026-01-01",
"toDate": "2026-01-31",
"registrationSystemId": 5,
"paymentDestinationId": 2,
"description": "January 2026 - Online Payments Only"
}'
Response (minimal request; a month of sales, all paid online):
{
"id": 123,
"transactionDate": "2026-02-01",
"transactionType": "JOURNAL",
"description": "January 2026 Journal",
"filters": {
"organisationId": 1,
"toDate": "2026-01-31"
},
"records": [
{
"accountName": "Event Income",
"accountType": "INCOME",
"amount": -15000.00
},
{
"accountName": "Discount Allowed",
"accountType": "INCOME",
"amount": 500.00
},
{
"accountName": "Gateway Balance",
"accountType": "ASSET",
"amount": 13915.00
},
{
"accountName": "Payment Fees",
"accountType": "EXPENSE",
"amount": 585.00
}
],
"summary": {
"totalDebits": 15000.00,
"totalCredits": 15000.00,
"transactionCount": 84
}
}
The receivable nets to zero because every sale in the period was paid, so no record is returned for it.
14. Steady State Behavior
When all transactions have been exported:
-
Every source transaction references a journal
-
Each company’s journals hold its consolidated totals
-
Any later correction creates delta records
-
The next journal carries only the new transactions and the deltas
Net Zero Goal: When all income and assets have been transferred to the external accounting system, each company’s books within this system show net zero in all accounts.
15. VAT
VAT is not applied in this design: no company whose books it holds is registered for VAT. Fees are posted as charged, VAT inclusive, because that is the amount that leaves the receipt account and it is not reclaimable.
The design keeps VAT addable without changing when or what posts:
-
Amounts are actuals. Each is the amount charged, so its VAT component can be derived at the rate in force on the transaction date.
-
Accounts are resolved by configuration. Output VAT on sales and input VAT on fees become further accounts resolved the same way, not new posting triggers.
-
A sale posts when it is made. That is the tax point for VAT on sales, so the posting date is already the one VAT reporting needs.
-
Registration is per company. Each company’s books are separate, so one company can be VAT registered while another is not, and VAT treatment is resolved per company.
16. Scope Boundaries
This specification does not yet cover:
-
The accounts that pair a payment posted in two companies' books, which company bears a fee in that case, and how the amount due between them is settled.
-
Scoping the chart of accounts to an organisation.
-
The granularity at which a seller’s income account is configured — per organisation, event or product.
-
Where a direct bank transfer fits among the three destination types.
-
Movements from a destination to a bank account — gateway payouts, card-machine settlements, cash banked — and the fees they carry.
-
Filtering a journal by event.
-
Field-level schema for the payment and destination records.
17. Migration Requirements
17.1. Add Columns to gl_transaction
ALTER TABLE gl_transaction
ADD COLUMN transaction_type VARCHAR(2) NOT NULL DEFAULT 'OR',
ADD COLUMN order_id BIGINT,
ADD COLUMN payment_id BIGINT,
ADD COLUMN journal_id BIGINT;
ALTER TABLE gl_transaction
ADD CONSTRAINT fk_gl_transaction_order
FOREIGN KEY (order_id) REFERENCES sales_order(id);
ALTER TABLE gl_transaction
ADD CONSTRAINT fk_gl_transaction_journal
FOREIGN KEY (journal_id) REFERENCES gl_transaction(id);
The foreign key on payment_id is added with the payment table.
18. Testing Strategy
18.1. Unit Tests
-
Sale posting: income per line at gross, discount per line, receivable at the sum of net
-
Payment posting: receipt at amount less fee, apportioned fee per line, receivable, customer credit on over-payment
-
Late fee posting leaves the receipt account at
gross - discount - fee -
Double-entry balance for every transaction type
-
Each company’s projection balances when a payment posts in two companies' books
-
Delta record creation before and after journaling
-
Journal consolidation calculations and unwind