Journals
A journal entry is a voucher: a header plus balanced lines. This page covers the manual entries staff write, how they are approved, and how a wrong one is corrected.
Rules: FIN-031 (no edits to posted entries — LAW), FIN-032 (maker-checker), ACC-030 (approval limits).
1. Lifecycle
stateDiagram-v2
[*] --> pending: written on the website or the phone — always pending
pending --> approved: approve_journal_entries (someone else)
pending --> rejected: approve_journal_entries, reason required
approved --> reversed: post_journal_reversal
rejected --> [*]
There is no path from the browser or the phone to approved. Editing a row to set the status is
refused: "Vouchers are approved through approve_journal_entries, not by editing the row
(FIN-032)."
1a. Writing a voucher on the phone
Finance staff write a voucher from the app: More → Finance → New entry (also on Home).
The phone calls one database function, create_manual_journal, which writes the header and
the lines in one transaction (FIN-032).
The phone (create_manual_journal) |
The website (POST /finance/journals) |
|
|---|---|---|
| Permission | finance.create |
finance.create |
| Status | always pending, the caller as maker |
always pending |
| Types | journal (JV), payment (PV), receipt (RV), contra (CT) — prefixes from fin_voucher_prefix |
one manual journal (JV); receipts, payments and contras have their own dialogs |
| Date | not in the future, not in a locked or closed period | not in a locked or closed period |
| Narration | required | optional |
| Lines | 2 to 50; each an active account that is not a group; a debit or a credit; paise | 2 or more; active, not a group |
| Balance | exact, to the paisa | within ₹0.01 |
| Foreign currency | refused — enter it on the website | per-line currency and rate (FIN-034) |
| Sent twice | the same voucher within ten minutes returns the first | no guard |
| Audit | finance.voucher.create, with the door |
finance.voucher.create |
A payment must credit a cash or bank account (an account under Liquid Assets, or the cash or
bank account in finance settings); a receipt must debit one; a contra touches only those. Every
type is stored as manual_journal, so the manual-journal
approval limit applies to all four
(ACC-030).
The account picker searches the chart by code or name and offers only accounts that can take an
entry. A party ledger is found by its party code (BP-0012, CU-000123, SU-0005), because its
name carries it (PTY-004).
A bill or photo is attached after the voucher is saved: drive-upload (kind
voucher_attachment, finance.edit), then a JournalAttachment row. Without finance.edit
the voucher is saved without a bill.
On the phone a voucher also opens with its lines and files; a person who may decide it
approves or rejects it there (approve_journal_entries), and a posted hand-made voucher can be
reversed with a reason (post_journal_reversal, pending). Vouchers posted by a booking, a
receipt or a supplier record are reversed on the website, which also updates that record.
Not built on the phone: foreign-currency lines, recurring templates, a party reference on a
line other than the party's own ledger, bulk approval. The website's create route has not been
moved onto create_manual_journal; it still writes with two inserts, kept pending by the
guards.
2. Approving
approve_journal_entries(ids, decision, reason) — finance.journals.approve; more than one
id also needs finance.journals.bulk_approve; rejecting needs finance.journals.reject or
.approve, plus a reason.
Before a voucher is approved the database checks:
- the approver did not create it (unless they hold
finance.journals.approve_own, which only the super admin does); - the lines balance — at least two lines, positive total debit, debit and credit within ₹0.01;
- the entry date is in an open accounting period;
- for a voucher a person wrote — a journal, a contra, an on-account receipt, or a reversal
on custom lines — the amount is within the approver's
approval limit —
manual_journal, ₹25,000 for a Finance Manager, above thatfinance.approvals.high_value; - a reversal is never approved before the voucher it reverses, and never by the person who made the voucher it reverses (ACC-020).
Rows that fail come back in skipped with a reason; the rest are approved. Originals are
ordered ahead of their reversals on approve, and the reverse on reject.
When one voucher is approved or rejected on the website and the database skips it, the
approver is told why (src/lib/finance/journalSkipMessage.ts; the phone uses the same words):
| Skip reason | What the approver sees |
|---|---|
unbalanced |
"The voucher's debits and credits don't match — it can't be approved until it's corrected." |
above_approval_limit |
The limit message with the amounts in rupees, e.g. "Manual journal / adjustment of ₹30,000 is above the ₹25,000 limit for you (Finance Manager): it needs CEO / GM" |
original_not_approved |
The voucher reverses one that is not approved yet; approve the original first. |
maker_checker_self_approval |
You cannot approve (or reject) your own journal entry; ask a different approver. |
period_locked |
The voucher is dated in a locked accounting period. |
not_found |
The voucher was not found. |
refused |
The database's own message. |
already_approved, already_rejected |
"This journal has already been processed by another user." |
Only a voucher someone else already decided says "already processed".
Not approvals.approve
Journal approval used to be gated on the generic approvals.approve, which sales and
operations managers hold. It is now finance.journals.approve, and ops and sales
managers were revoked from it.
3. Reversing
post_journal_reversal is the only way to reverse. The old
POST /finance/journals/:id/reverse behaviour — a free-standing inverse voucher — is gone.
What it guarantees now:
- Every reversal names the voucher it reverses.
reversalOfEntryIdis mandatory; a reversal without one is refused. - A reversal follows its original. Reversing a pending voucher produces a pending reversal, which cannot be approved before the original. Rejecting a voucher rejects its pending reversals.
- Reversals can never exceed the voucher. Checked under an advisory lock, so partial reversals are allowed and repeated cancellation reversals cannot stack up past the original amount.
- A reversal of a reversal is refused, and a rejected voucher cannot be reversed.
- It posts approved only for a caller who may approve and did not make the original. Otherwise it posts pending and waits for someone else.
Corrections after the reversal are new entries — for supplier transactions,
correct_supplier_transaction (finance.supplier_transactions.correct, reason required,
audited).
4. Opening balances are vouchers
set_account_opening_balance(accountId, amount, reason, asOf, note) posts an OB- voucher
pending, so a second person approves it, with the equity side on account code 3000.
Restating a balance reverses the previous voucher first (or rejects it, if it was still
pending). Account.openingBalance is a cache, not the source — it cannot be typed in from
the browser, and finance_opening_balance_drift() reports accounts where the cache and the
voucher disagree (AUD-010).
This also fixed a double count: reports used to add Account.openingBalance on top of
the opening-balance voucher.
5. System vouchers
Most vouchers are not written by hand. They are posted by the function that performs the business event, in the same transaction, so there is no operation without its entry:
| Event | Posted by |
|---|---|
| A verified receipt | verify_payment |
| An approved refund | approve_refund |
| A B2B cancellation decision | post_decision_journal — only the person who just decided it, once per reference |
| An airline cancellation filing | file_airline_cancellation |
post_decision_journal refuses any reference type outside the three B2B cancellation kinds:
"Voucher type X cannot be posted without approval; post it pending instead."
5a. The register
Finance → Journal lists every voucher one server page at a time, newest first
(GET /finance/journals?paged=1), with a search over the voucher number, memo and source, a
status filter (waiting for approval, approved, rejected) and a Voucher date range on
entryDate — Indian calendar days, both ends included. The paging bar under the register gives
rows per page (25, 50 or 100), "1–25 of N vouchers" and first, previous, next and last page.
It used to show the newest 500 vouchers with nothing to say older ones existed
(PLT-050,
UX-030).
5b. The Transactions feed
Finance → Transactions is one list of every receipt, invoice, ledger entry and journal voucher,
built and paged by the server (GET /finance/transactions, finance_transactions_page). A
receipt opens its voucher (the payment's newest payment journal); a ledger entry opens its
journalEntryId; a voucher is listed on its own only when neither a receipt nor a ledger entry
already shows it, so nothing is counted twice. The search (reference, booking number, memo, a
voucher's line narrations, receipt number, method, source), type, status, a Date range
(Indian days, both ends included) and the order (date, amount, type, status) run in SQL. The
paging bar under the feed gives rows per page (25, 50 or 100), "1–25 of N transactions" and
first, previous, next and last page.
It used to merge the registers in the browser from the newest 500 vouchers, so an older voucher was in no feed. A status filter now keeps only rows with that status — a ledger entry has none, so it is not listed under Pending; it used to pass every status filter (PLT-050, UX-030).
6. Routes
| Route | Method | Permission |
|---|---|---|
/finance/journals |
GET | finance.view |
/finance/journals |
POST | finance.create — always lands pending |
create_manual_journal (RPC, the phone) |
— | finance.create — always lands pending |
/finance/journals/pending |
GET | finance.view |
/finance/journals/:id |
GET | finance.view |
/finance/journals/:id/approve, /reject |
POST | finance.journals.approve / .reject |
/finance/journals/approve-bulk |
POST | finance.journals.bulk_approve |
/finance/journals/:id/reverse |
POST | finance.journals.reverse or finance.create |
The journal list (unfiltered), the approval queue and one voucher are each one request to
the database (finance_screen, finance_journal_screen), which checks finance.view itself
(PRF-010). A filtered list (?status=, ?accountId=, …) reads
as before.
7. Where to look
| Concern | Path |
|---|---|
| Guards, reversal rules, decision journals | supabase/migrations/20260919100000_finance_journal_controls.sql |
| Opening balances | supabase/migrations/20260919100600_finance_opening_balances_reports.sql |
| Approval limits | supabase/migrations/20260919100700_finance_approval_limits.sql |
| Supplier corrections | supabase/migrations/20260919100400_finance_supplier_tds_controls.sql |
| A voucher from the phone | supabase/migrations/20261001160000_a_journal_from_any_door.sql |
| Tests | supabase/tests/finance_controls.sql, supabase/tests/a_journal_from_any_door.sql |