Constructing the income statement
Revenue, cost of revenue, opex, EBITDA, net income, all from your ledger.
By Solomon Ajayi · Free to read, no signup
Every accounting period (monthly, quarterly), your CFO needs an income statement: a summary of revenue, costs, and profit over the period. The good news: it's literally a SQL query over your journal lines, grouped by account type, filtered by posting date. The bad news: most fintechs have a chart of accounts that's grown organically, with revenue scattered across 40 accounts and expenses across 80. This lesson uses a tightly-curated CoA to show how a clean income statement falls out of well-named accounts. We post one revenue + one COGS + one opex entry so the structure is visible.
The income statement answers one question: did you make money over a period of time. It is not a balance, it is a flow, summed between two dates. Revenue at the top, then the costs that directly produced it (cost of revenue), then the operating costs that keep the business running, and what falls out the bottom is net income. The whole report is a grouping of your income and expense accounts over a date range, nothing more exotic than that.
Two ideas in this lesson make the report honest. The first is ordering: revenue minus cost of revenue gives gross profit, and that 80 percent gross margin tells you something different from net margin. The second is the matching principle. The salary entry accrues an expense into this period even though the cash leaves next period, because the expense belongs to the period that earned the revenue, not the period the cash happens to move. That is why the salary line debits an expense and credits a payable rather than the bank.
What makes this a one-line query instead of a three-day archaeology project is account naming. Revenue grouped by source, expenses grouped by function, and a flat account type that distinguishes income from expense. The accounts in this lesson are deliberately curated so the report falls out cleanly. The discipline is resisting the urge to mint a fresh account for every nuance, because every stray account is another row the report has to find and classify.
Worked example, step by step
Revenue line: ₦2M of transaction fees this period
Aggregate of many smaller revenue entries posted throughout the period. We show one summary entry for clarity.
| Account | Debit | Credit |
|---|---|---|
| Bank Account (1200) | ₦2,000,000.00 | |
| Revenue: Transaction Fees (4000) | ₦2,000,000.00 |
Bank Account UP ₦2,000,000 (debit). Revenue: Transaction Fees UP ₦2,000,000 (credit). This account will roll into the 'Revenue: Transaction Fees' line of the income statement.
COGS: ₦400K in processor costs this period
Direct cost of producing the revenue (interchange, scheme fees, etc.). Sits on the COGS line of the income statement, between revenue and gross profit.
| Account | Debit | Credit |
|---|---|---|
| COGS: Processor Costs (5000) | ₦400,000.00 | |
| Bank Account (1200) | ₦400,000.00 |
Bank Account DOWN ₦400,000 (credit, you paid the processors). COGS: Processor Costs UP ₦400,000 (debit, expense). Gross profit for the period = ₦2,000,000 - ₦400,000 = ₦1,600,000 (an 80% gross margin, typical for fintech).
Opex: ₦500K accrued salaries (recognised even if not yet paid)
Salaries earned this period but paid next period. Under accrual accounting, the expense lands in THIS period, that's the matching principle. The accrued liability sits on the balance sheet until cash actually goes out.
| Account | Debit | Credit |
|---|---|---|
| Opex: Salaries (5100) | ₦500,000.00 | |
| Accrued Salaries Payable (2400) | ₦500,000.00 |
Opex: Salaries UP ₦500,000 (debit, expense). Accrued Salaries Payable UP ₦500,000 (credit, liability). Period net income = ₦1,600,000 - ₦500,000 = ₦1,100,000 (rough, we're missing marketing, but you see the shape).
Takeaway
The income statement is a SQL query: `SELECT account_code, SUM(credit - debit) FROM journal_line WHERE posted_at BETWEEN X AND Y AND account_type IN ('income','expense') GROUP BY account_code, account_type ORDER BY ...`. The DISCIPLINE that makes this work is account naming: revenue accounts grouped by SOURCE (transaction fees, interest, FX spread), expense accounts grouped by FUNCTION (COGS, Opex-Salaries, Opex-Marketing, Opex-Tech). Resist the urge to add a new account for every nuance, a clean CoA is the foundation of clean reporting. Your accountant will thank you; your auditor will move faster; your CFO can answer 'how much did we earn last quarter' in 10 seconds, not 3 days.
The code behind it
One grouped query turns the general ledger into a full income statement for a period.
-- Income statement for a period, straight from the ledger.
-- Convention: for income/expense, balance = SUM(credit - debit),
-- so revenue lands positive and expenses land negative. We just add them up.
WITH category AS (
SELECT
CASE
WHEN a.code LIKE '4%' THEN 'revenue' -- 4000 Transaction Fees, 4100 Interest
WHEN a.code LIKE '50%' THEN 'cogs' -- 5000 Processor Costs
WHEN a.code LIKE '51%' OR a.code LIKE '52%' THEN 'opex' -- 5100 Salaries, 5200 Marketing
END AS category,
SUM(jl.credit - jl.debit) AS amount -- naira kobo as integer minor units
FROM journal_line jl
JOIN account a ON a.code = jl.account_code
WHERE a.type IN ('income', 'expense')
AND jl.posted_at >= DATE '2026-06-01' -- period start (inclusive)
AND jl.posted_at < DATE '2026-07-01' -- next period start (exclusive)
GROUP BY 1
)
SELECT
SUM(amount) FILTER (WHERE category = 'revenue') AS revenue,
SUM(amount) FILTER (WHERE category IN ('revenue','cogs')) AS gross_profit,
SUM(amount) FILTER (WHERE category IN ('revenue','cogs','opex')) AS operating_income,
SUM(amount) AS net_income
FROM category;
-- revenue 2,000,000 | gross_profit 1,600,000 | operating_income & net_income 1,100,000Practice this on a real ledger
Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.