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

  1. Post at the transaction - A sale is in the ledger when it is made, a payment when money is received

  2. Actual amounts - Every posting uses the sales price, discount and fee actually recorded, never a recalculation

  3. Each company’s books - One ledger holds several companies; each company’s projection is a complete, balanced set of books

  4. Simple destinations - Operators say where money was received; which accounts that means is configuration

  5. Traceability - Every posting links back to the order, line item or payment it came from

  6. Delta tracking - Corrections after a journal are posted as deltas, never as silent edits

  7. Journal consolidation - Transactions are consolidated per company for export

  8. 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 gross - discount. The fee is a separate expense. See Order Line Item Amounts.

Mark journaled transactions by changing their type to JOURNAL

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

gross

The sales price the selling channel recorded for the line

discount

The discount value the selling channel applied to the line

net

gross - discount — the revenue for the line

fee

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.

ORDER

Correction to a sale — a line added or removed, a price or discount changed, the order cancelled

When made. Changes the sale’s ORDER transaction: in place before it is journaled, as delta records after.

ORDER

Payment

When money is received into a destination

PAYMENT

Fee that becomes known after the payment

When it becomes known, for example on a card-machine settlement statement

PAYMENT

Refund of money

When money is returned

REFUND

Manual correction not arising from an order

When made

ADJUSTMENT

Journal

When created

JOURNAL

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

gl-entities

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

ORDER

OR

A sale, posted when the order is placed

PAYMENT

PM

Money received into a destination, and any fee charged for it

JOURNAL

JN

A consolidated export entry

ADJUSTMENT

AD

A manual correction not arising from an order

REFUND

RF

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.

6.5.2. GlAccountType

public enum GlAccountType {
    BANK,               // Bank account
    ASSET,              // Asset account
    LIABILITY,          // Liability account
    ACCOUNT_PAYABLE,    // Accounts payable
    ACCOUNT_RECEIVABLE, // Accounts receivable
    INCOME,             // Income account
    EXPENSE             // Expense account
}

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

gross

Line item

Credit (negative)

Discount allowed

discount, where non-zero

Line item

Debit (positive)

Receivable

Sum of net

Order (consolidated)

Debit (positive)

9.2. Payment

Posted as a PAYMENT transaction when money is received.

Record Amount Per Sign

Receipt

Amount received less fee

Payment (consolidated)

Debit (positive)

Fee

fee, apportioned to the lines the payment settles

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.4. Double-entry validation

Every transaction balances: total debits equal total credits.

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.

  1. Every transaction belongs to exactly one company’s books and references only that company’s accounts.

  2. Every transaction balances, so any one company’s transactions, taken alone, balance. That projection is a complete set of books for that company.

  3. 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:

delta-decision

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

  1. An entry sold at R200.00 is posted and journaled on 15 January

  2. On 20 January the entry is found to belong to a category priced at R180.00, and the sale is corrected

  3. Delta records are created on the sale’s ORDER transaction:

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:

  1. Selects the company’s ORDER, PAYMENT, REFUND and ADJUSTMENT transactions matching the filters whose journal_id is empty, including delta records added since they were last journaled.

  2. Creates a JOURNAL transaction in that company’s books.

  3. Creates one consolidated record per account, summing the selected records.

  4. Sets journal_id on each selected transaction.

  5. Stores the filter criteria with the journal.

12.2. Journal Deletion (Unwind)

Deleting a journal clears journal_id on every transaction that references it, making them eligible for a future journal, then deletes the journal transaction. Delta records stay with their source transactions. Only a journal that has not been exported may be deleted.

13. API Design

13.1. Endpoints

Method Endpoint Description

POST

/api/gl/journals

Create journal for date range

GET

/api/gl/journals

List journals (paginated)

GET

/api/gl/journals/{id}

Get journal details

GET

/api/gl/journals/{id}/records

Get journal GL records

DELETE

/api/gl/journals/{id}

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.

13.2.2. Delete Journal

curl -X DELETE http://localhost:8080/api/gl/journals/123 \
  -H "Authorization: Bearer $TOKEN"

14. Steady State Behavior

When all transactions have been exported:

  1. Every source transaction references a journal

  2. Each company’s journals hold its consolidated totals

  3. Any later correction creates delta records

  4. 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.

17.2. Add Columns to gl_record

ALTER TABLE gl_record
    ADD COLUMN is_delta BOOLEAN DEFAULT FALSE,
    ADD COLUMN order_line_item_id BIGINT;

ALTER TABLE gl_record
    ADD CONSTRAINT fk_gl_record_line_item
    FOREIGN KEY (order_line_item_id) REFERENCES order_line_item(id);

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

18.2. Integration Tests

  • Order placed → ORDER transaction; order still being assembled → nothing posted

  • Payment received → PAYMENT transaction

  • Order cancelled before and after journaling

  • Journal creation and deletion

  • Multi-order, multi-payment journal consolidation