Cash flow forecast
A cash flow forecast answers one question: will there be money in the account on the day it is needed. That is not the same question as whether the business is profitable, and the two have different answers far more often than anyone expects.
The reason is timing. You pay wages on Friday and materials in 30 days, and the customer pays you in 52. Every dollar of growth widens that gap before it closes it, so a business can grow its way into insolvency while every job on the board is profitable. Profit is an opinion about a period. Cash is a fact about a date.
Ask these eight questions first
These are the inputs the forecast needs. Ask all eight at once, and pull rather than ask wherever an accounting system is connected.
1. What is the actual bank balance today, and is the reconciliation current?
2. Do you have an overdraft or a facility? What is the limit?
3. Who owes you money, how much, and when is it due?
4. For each of your biggest customers: when do they actually pay, against
when the invoice is due?
5. What do you owe, and when is it due?
6. What goes out on a schedule? Wages and how often, rent, vehicles, loans
7. What large one offs are coming in the next quarter? Tax, insurance
renewal, equipment
8. What new work will you invoice, and how long before that money arrives?
Question 4 is load bearing and it is the whole difference between a forecast and a wish. Every hand built forecast books money on the due date, because that is the date printed on the invoice. Get the real figure per customer from the ledger: payment date minus due date, taken as a median over the last year or two. Do not average across customers, because the biggest debtor is usually the slowest and averaging hides exactly that.
Question 1 means the bank balance, not the ledger balance. They differ by whatever has not cleared, and the bank figure is the one that decides whether a payment bounces. If the reconciliation is behind, say so in the output, because a forecast starting from an unreconciled balance is wrong from week one.
Question 7 is where the surprises are. Sales tax is the largest single payment most small businesses make, and it sits in the account looking like cash until it leaves. Annual insurances and licences are forgotten eleven months out of twelve.
Question 2 matters because zero is not the real cliff. Forecast headroom against the limit, not against nothing.
Build it
python scripts/cash_forecast.py forecast.json
python scripts/cash_forecast.py forecast.json --stress=late30
Thirteen weeks by default, which is the standard horizon: far enough to act, near enough to be worth trusting. The script places every receipt and payment in the week it will actually land and tells you the low point, the week it goes negative, and how much you would need to cover it.
Method and the input format in references/building-the-forecast.md. A worked example is at assets/forecast.example.json.
When it goes to a lender or a board rather than staying on your own screen, put it in assets/forecast-pack-template.md and render it:
node scripts/render_pdf.mjs forecast.md --brand=brand.json
Needs Node 18 or newer and Chrome or Edge, and nothing installed. See references/rendering-the-pdf.md.
The one thing that makes a forecast real
Schedule receipts on when the customer will actually pay, not when the invoice is due.
This is the entire difference between a forecast and a wish. Every hand built forecast is optimistic in exactly this way: it books the money on the due date, because that is the date written on the invoice. The customer who has paid at 40 days for three years will pay at 40 days again.
Get the real number per customer from the ledger. It is a different figure for each one, and averaging them loses the thing you need, which is that the biggest debtor is often the slowest.
xero_list_invoices type ACCREC, with payment dates
quickbooks_get_aged_receivables
The same applies to the outgoing side, but with less benefit: pay your suppliers on time, and forecast them on their due dates.
Stress it
Run --stress=late30 before showing a forecast to anyone. Every receipt lands 30 days later than assumed.
A forecast that survives that is worth showing a bank. A forecast that does not has just told you the most important thing about the business, which is how much slack there actually is, and it is usually much less than the owner believes. In the worked example the business never goes negative on plan and goes $43,700 short in week 5 under stress.
A lender is pricing the chance you are wrong. Showing them the stressed case first, with what you would do about it, is a materially stronger position than being asked for it.
Getting the data in
Do not retype anything that already exists in the accounting system.
| Xero | QuickBooks | |
|---|---|---|
| Outstanding invoices | xero_list_invoices type ACCREC | quickbooks_get_aged_receivables |
| Bills to pay | xero_list_invoices type ACCPAY | quickbooks_get_aged_payables |
| Actual bank position | xero_get_bank_summary | quickbooks_get_balance_sheet |
| Recurring patterns | xero_get_profit_and_loss by month | quickbooks_get_profit_and_loss |
Per system detail, including the traps, in references/xero.md and references/quickbooks.md.
Use the outstanding amount, never the invoice total. An invoice part paid down to $200 remaining is not a $5,000 receipt. Xero’s AmountDue and QuickBooks’ aged receivables report are already net of payments applied. Rebuilding the ageing by hand from invoice dates is how a forecast acquires money that does not exist.
Start from the actual bank balance, not the ledger balance. They differ by whatever has not cleared. The bank balance is the one that determines whether a payment bounces.
What goes in it
| Line | Commonly wrong |
|---|---|
| Receipts from existing invoices | Booked on the due date rather than the real pay date |
| Receipts from work not yet invoiced | Left out entirely, or wildly optimistic |
| Wages | Fortnightly pay runs land 26 times a year, so two months have three |
| Sales tax and GST | Left out. It is the largest single payment most small businesses make |
| Payroll tax and income tax instalments | Left out until the notice arrives |
| Loan and lease repayments | Principal is cash out and never appears in the profit and loss |
| Capital purchases | The whole amount leaves in one week, not over the depreciation life |
| The owner’s drawings | Left out, then paid anyway |
| Annual insurances and licences | One large week a year, forgotten eleven months out of twelve |
Two structural traps worth stating plainly:
Sales tax is not your money. It sits in the account looking like cash and then leaves in one payment. A business that spends it has borrowed from the tax authority at very unfavourable terms. Forecast the payment date explicitly.
Loan principal and capital purchases never touch the profit and loss. A business can be profitable every month and still run dry paying for a van. Only the cash flow shows it.
Rules
- Weekly, not monthly, for the first quarter. A monthly forecast hides the fact that wages land on the 6th and the money arrives on the 25th. The whole risk lives inside the month
- Roll it forward every week. Add a week, update actuals, compare. A forecast built once was a spreadsheet exercise. The comparison between forecast and actual is where the learning is
- Track the forecast error. After four weeks you will know whether you are systematically optimistic, and by how much. Almost everyone is
- Show the low point, not the closing balance. Ending the quarter at $86,000 is irrelevant if week 9 is minus $4,000. The low point is the number that matters
- Forecast against the overdraft limit, not against zero. Zero is not where the problem starts
- One forecast, one owner, one update day. Two versions in circulation is the same as none
What not to do
- Do not forecast revenue and call it cash. The two are separated by your terms and your customers’ habits
- Do not include money from work you have not won. Put it in a separate line, clearly labelled, kept conservative. This is the line an optimist inflates and a lender discounts first
- Do not use the average days to pay across all customers. The distribution matters more than the mean, because one large slow payer dominates the outcome
- Do not build it in a way only you can update. A forecast that is not updated because the person who built it is busy is not a forecast
- Do not hide the bad weeks. The value of the forecast is entirely in seeing them early, while there is still time to invoice faster, delay a purchase, or ask for terms
When the answer is bad
A forecast showing a shortfall is a forecast doing its job. Six weeks of warning is the difference between a conversation and a crisis. In rough order of what to try:
- Invoice faster. Most small businesses invoice weekly or on completion. Invoicing on the day, or progressively, moves every receipt forward at no cost
- Chase what is already owed. The cheapest money available. Verify first that the invoice is genuinely unpaid, with no unallocated credit or unapplied payment against it, then work a consistent ladder: a reminder at day 1, a direct one at day 7, a phone call at day 14, a final notice at day 30. The call at day 14 is the step that actually collects
- Ask for deposits. A 30% deposit on new work changes the shape of the whole forecast
- Move a discretionary payment. A capital purchase delayed by one month is often the entire gap
- Talk to suppliers before you are late, not after. Terms are usually available to someone who asks in advance and never to someone who has already missed
- Talk to the bank early, with the forecast in hand. An overdraft arranged six weeks out is a normal facility. The same request the day it is needed is a problem
Reference files
Everything the skill tells your AI to read, exactly as it ships in the zip.
references/building-the-forecast.md 6.1 KB
# Building the forecast
## The input file
The script takes one JSON file. Every section is optional except `start` and `opening_balance`.
```json
{
"name": "Northside Electrical",
"start": "2026-03-02",
"opening_balance": 84200,
"overdraft_limit": 50000,
"receivables": [
{"customer": "Harding Group", "amount": 46200, "due": "2026-03-14", "days_late": 12}
],
"payables": [
{"supplier": "CableCo", "amount": 21400, "due": "2026-03-20"}
],
"recurring": [
{"name": "Wages", "amount": 31800, "every": "fortnight",
"first": "2026-03-06", "direction": "out"},
{"name": "Maintenance contracts", "amount": 22000, "every": "month",
"first": "2026-03-10", "direction": "in"}
],
"one_off": [
{"name": "Sales tax payment", "amount": 38400, "date": "2026-04-28", "direction": "out"}
],
"new_sales": {
"weekly_amount": 24000,
"collected_after_days": 35,
"starts": "2026-03-09"
}
}
```
| Field | Notes |
|---|---|
| `start` | Anchored back to the Monday of that week |
| `opening_balance` | The **bank** balance, not the ledger balance |
| `overdraft_limit` | Optional. Reports headroom against the limit rather than against zero |
| `days_late` | Days past due this customer actually pays. The most important field in the file |
| `every` | `week`, `fortnight`, `month` or `quarter` |
| `direction` | `in` or `out`. Defaults to `out` |
| `new_sales` | Work not yet won or not yet invoiced. Keep it conservative |
A receivable already overdue at the start of the forecast lands in week 1 rather than disappearing, because it has not been paid and the money is still expected.
## days_late is the whole game
Every hand built forecast is optimistic in the same way: it books money on the invoice due date, because that is the date printed on the invoice.
Get the real figure per customer, from the ledger, by comparing payment dates to due dates over the last year or two. Then:
| What the history says | `days_late` |
|---|---|
| Pays on or before due | 0 |
| Consistently 10 to 14 days over | 12 |
| Two payment runs a month, 15th and 30th | Use the gap to the next run, not an average |
| Erratic | Use their worst normal case, not their average |
| New customer, no history | Use your overall average plus a week |
**Do not average across customers.** The distribution matters more than the mean, because one large slow payer dominates the outcome and averaging hides exactly that.
## Horizon
| Horizon | Use |
|---|---|
| 13 weeks | The default. Standard for lenders, and the working horizon for an owner |
| 26 weeks | A capital decision, or a seasonal business crossing its quiet period |
| 52 weeks | A funding conversation. Accuracy past six months is low, and everyone knows it |
```
python scripts/cash_forecast.py forecast.json --weeks=26
```
Weekly buckets for the whole horizon. Monthly buckets hide the thing you are looking for: wages land on the 6th and the customer pays on the 25th, and the entire risk lives inside that month.
## Reading the output
```
Wk Week ending In Out Net Closing
9 2026-05-03 24,000 75,100 -51,100 38,100 <- low point
THE ANSWER
Low point 38,100 in week 9, ending 2026-05-03
Never goes negative. Tightest headroom 88,100 in week 9
```
**The low point is the number, not the closing balance.** Finishing the quarter at $86,900 is irrelevant if week 9 dips below zero. The script marks the low point, the first negative week, and the shortfall you would need to cover it.
The "biggest weeks out" section exists to show you which payments cause the dip, because the fix is usually moving one of them rather than finding new money.
## Stressing it
```
python scripts/cash_forecast.py forecast.json --stress=late30
```
Every receipt, including new sales, lands 30 days later. Run this before showing a forecast to anyone.
In the worked example, the plan case never goes negative and has $88,100 of headroom. Stressed, it goes negative in week 5 and is $43,700 short. Both statements are true about the same business, and only one of them tells you how much slack there really is.
Other stresses worth running by editing the file rather than by flag:
- **The largest customer does not pay at all.** Delete that line
- **New sales halve.** Halve `weekly_amount`
- **A large job is delayed by a month.** Move its receivable
## The weekly rhythm
The forecast is worth roughly nothing as a one off document and a great deal as a habit.
Once a week, same day:
1. Update the opening balance to the actual bank balance
2. Refresh receivables and payables from the accounting system
3. Add one week to the far end
4. **Compare last week's forecast to what actually happened**
Step 4 is the one people skip and the one that matters. After a month you will know whether you are systematically optimistic and by how much, and you can correct for it. Almost everyone is optimistic, usually on `days_late` and on new sales.
## Forecast error
Keep a single line per week:
```
Week ending Forecast closing Actual closing Error
2026-03-08 47,500 41,900 -5,600
2026-03-15 68,100 59,400 -8,700
```
A consistent negative error means an assumption is wrong, not that you were unlucky. Usually `days_late` is too low, or `new_sales` is too high, or something recurring is missing. Two months of this makes the forecast trustworthy in a way no amount of care at build time can.
## For a lender
They will want:
- **13 weeks, weekly.** Sometimes 26
- **The stressed case, and your response to it.** Offering this before being asked is a materially stronger position
- **The assumptions stated**, particularly `days_late` and new sales
- **Consistency with the filed accounts.** A forecast whose run rate contradicts the last profit and loss will be asked about
- **The low point, named.** They are lending against the gap, so do not make them find it
Render it as a document rather than pasting a terminal table into an email:
```
node scripts/render_pdf.mjs forecast.md --brand=brand.json
```
See [rendering-the-pdf.md](rendering-the-pdf.md). Right align every number column, using a colon at the end of that column's delimiter row.
references/quickbooks.md 3.7 KB
# Pulling the data from QuickBooks
```
quickbooks_get_aged_receivables the authoritative ageing. Start here
quickbooks_get_aged_payables bills to pay
quickbooks_get_balance_sheet the cash position
quickbooks_get_cash_flow historical cash movement
quickbooks_get_profit_and_loss by month, to find the recurring shape
quickbooks_list_invoices the detail behind a line
quickbooks_list_payments payment dates, for deriving days_late
quickbooks_list_customers terms per customer
```
## Money in
**Use `quickbooks_get_aged_receivables`.** It is already correct for part payments, credits and unapplied amounts. Rebuilding the ageing from invoice dates is both slower and wrong.
Use `quickbooks_list_invoices` only for the detail behind a line you need to place precisely: the due date, the customer, the specific invoice number.
**Watch for unapplied payments.** A payment received and not applied to an invoice leaves that invoice looking unpaid, and a forecast will book the money twice: once as an expected receipt and once as cash already in the bank. `quickbooks_get_customer_balance` disagreeing with the sum of that customer's open invoices is how you detect it.
## Deriving days_late
QuickBooks does not report it, so build it:
1. Pull paid invoices for the last 12 to 24 months
2. Pull `quickbooks_list_payments` for the dates
3. Payment date minus due date, per invoice
4. Median per customer
Use the median rather than the mean, so one disputed invoice paid at 120 days does not distort a customer who is otherwise reliable.
**QuickBooks meters API reads, and the free tier has a hard monthly ceiling.** Pull the aged receivables report once and work from it. Do not loop per customer, and do not re-pull the history every time you roll the forecast forward: derive `days_late` once a quarter and keep it in the input file.
## Money out
`quickbooks_get_aged_payables` for entered bills, forecast on their due dates.
Add by hand everything that never becomes a bill:
- Payroll, including employer taxes and contributions
- Sales tax remittance. Check the filing frequency and the actual due dates
- Estimated income tax payments, which are quarterly in many jurisdictions
- Loan and lease principal
- Owner distributions
- Direct debits and card payments that are categorised straight from the bank feed
The last one is the usual gap, and the way to find it is to read a bank statement rather than the payables list.
## The opening balance
`quickbooks_get_balance_sheet` gives the book cash position. Where the bank feed is reconciled these agree; where it is not, use the bank's own figure. The forecast has to start from the money that is actually there.
## Finding the recurring lines
`quickbooks_get_profit_and_loss` with monthly columns for the last 12 months. Read across each row: a similar figure every month is a recurring line, and a figure appearing once or twice is an annual item that will otherwise be forgotten.
`quickbooks_get_cash_flow` is useful as a cross check on the shape, though it is a historical statement rather than a forecast and is organised by accounting category rather than by week.
## Multi currency
If the business invoices in more than one currency, the forecast has to be in one. Convert at a conservative rate and say which rate was used, because a forecast that silently moves with the exchange rate cannot be compared to last week's.
## Rate and cost notes
- Reads are metered. Three or four report calls per forecast update is right. Hundreds of per invoice calls is not
- Do not poll to see whether a payment has arrived. Check when you next roll the forecast forward
- `quickbooks_get_aged_receivables` once beats `quickbooks_get_customer_balance` per customer, always
references/rendering-the-pdf.md 5.4 KB
# Rendering the document
`scripts/render_pdf.mjs` turns the markdown into a PDF with a cover page, a contents list, running headers, page numbers and tables that do not split a row across a page.
```
node scripts/render_pdf.mjs document.md
node scripts/render_pdf.mjs document.md --out=final.pdf --brand=brand.json
node scripts/render_pdf.mjs document.md --html
```
Needs Node 18 or newer and Chrome or Edge installed. There is no `npm install`: the markdown parser and the browser client are both inside the script.
`--html` stops after writing the intermediate HTML instead of rendering. Use it whenever the pagination is wrong: open that file in a browser and press print, and you see exactly what the renderer sees. Far faster than rendering a PDF to find out where a page broke.
## The cover page
Frontmatter at the very top of the file drives it. Leave it out and there is no cover, which is right for an internal draft and wrong for anything sent outside the business.
```
---
title: Cash flow forecast
subtitle: 13 weeks from 2 March 2026
client: Northside Electrical
author: Dana Reyes
date: March 2026
confidential: true
---
```
Keys used: `title`, `subtitle`, `client`, `author`, `company`, `date`, `version`, `confidential`. Anything else is parsed and ignored, which makes frontmatter a safe place for notes to yourself. Without a `title` there is no cover at all.
Only `key: value` on single lines. No nesting, no lists, no multi line values.
## Markers
| Marker | Effect |
|---|---|
| `<!-- toc -->` | A contents list built from the `##` and `###` headings |
| `<!-- pagebreak -->` | Forces a new page. `\newpage` on its own line is the same |
Every other HTML comment is stripped, so author notes in a template never reach the PDF.
## The brand file
One JSON file sets colours, fonts, logo, page size and margins. Copy `assets/brand.example.json`, change the values, and every document rendered with it matches.
The logo path inside the brand file resolves relative to the brand file, not to wherever you are standing, so a brand folder moves as one piece.
**Do not edit `assets/document.css` to change a colour.** Colours and fonts are injected from the brand file as CSS variables. Editing the stylesheet is how one customer's document ends up with another customer's blue. If one document must differ, pass `--css=just-this-one.css`, which is appended after the main stylesheet so you only write the difference.
## Supported markdown
A deliberate subset: headings, paragraphs, bold, italic, strikethrough, code spans and fenced blocks, ordered and unordered lists with one level of nesting, task list checkboxes, blockquotes, horizontal rules, links, images and pipe tables.
Anything outside the subset falls through as a paragraph rather than failing, so a document never silently loses text.
**Not supported:** reference style links, inline HTML (escaped on purpose, so a document assembled partly from a customer's own words cannot inject markup), footnotes, nesting past one level, hard line breaks from trailing spaces, bare URL autolinks, and syntax highlighting in code fences.
## Table alignment, which matters most here
The delimiter row sets it, and a financial document lives or dies on this:
```
| Line | FY26 | FY27 |
|---------------|----------:|----------:|
| Revenue | 3,400,000 | 4,100,000 |
| Gross profit | 1,054,000 | 1,435,000 |
```
A colon at the end of a column's delimiter right aligns it. Right aligned cells also get tabular figures, so the digits line up in a column even in a proportional font. **Right align every column of numbers.** A column of currency that is left aligned reads as amateur from across a room.
## When it comes out wrong
| Problem | Cause |
|---|---|
| "No Chrome or Edge found" | The error lists every path it tried. Add yours to the `BROWSERS` array at the top of the script |
| Images are blank boxes | Image paths resolve relative to the markdown file, logo paths relative to the brand file. A very large image can also exceed the 15 second readiness ceiling: shrink it |
| Mysterious blank page | A pagebreak immediately before a heading that already starts a page, or a trailing pagebreak at the end of the document |
| The margins doubled | An `@page` rule was added to the stylesheet. Chrome applies it on top of the print call's margins. Remove it and set margins in the brand file |
| Everything on one enormous page | Something has a fixed height larger than the page, usually a pasted element with an inline style |
| A table row split across a page | A single row taller than a page cannot avoid breaking. Shorten the cell. A long table splitting is correct, and the header repeats automatically |
| Numbers do not line up | The column is not right aligned |
| Text where markup should be | The parser is a subset. See above |
Exit codes: `0` rendered, `1` usage or input error, `2` the browser failed to start or the render failed. On `2` the intermediate HTML is deliberately left on disk and its path is printed. Open it.
## Before sending
- **Read the PDF, not the markdown.** The PDF is the artefact, and reading the source and assuming is how a broken table reaches a bank
- **Check the last page.** A single orphaned line on a final page is the most common flaw in a generated document, and the fix is usually cutting two sentences rather than changing the CSS
- **Name the file the way the recipient will file it.** `northside-electrical-cashflow-2026-03.pdf`, not `document.pdf` or `final_v3_FINAL.pdf`
references/xero.md 3.7 KB
# Pulling the data from Xero
```
xero_list_invoices type ACCREC for money in, ACCPAY for money out
xero_list_payments payment dates, which is how days_late is derived
xero_list_credit_notes allocated and unallocated
xero_get_bank_summary the opening balance
xero_get_profit_and_loss by month, to find the recurring shape
xero_get_contacts payment terms per contact
```
## Money in
Pull authorised `ACCREC` invoices. Two rules:
**Use `AmountDue`, never `Total`.** `AmountDue` is already net of payments applied. An invoice part paid down to $200 remaining shows a `Total` of $5,000, and a forecast built on totals has money in it that does not exist.
**Age by `DueDate`, not `Date`.** The due date is what matters, and terms vary by contact. `xero_get_contacts` carries the payment terms per contact, so a customer on 45 day terms is not late at day 40 and should not be forecast as though they were.
Then check credit notes. An unallocated credit note reduces what that customer will actually pay, and a forecast that misses it books money that will be netted off.
## Deriving days_late
This is the field that makes the forecast real, and Xero does not hand it to you directly. Build it:
1. Pull `ACCREC` invoices with status `PAID` for the last 12 to 24 months
2. Pull `xero_list_payments` for the payment dates
3. For each invoice, take payment date minus due date
4. Take the median per customer, not the mean. One disputed invoice paid at 120 days should not move the number
Use the median as `days_late` for that customer. For customers with fewer than about five paid invoices, use your overall median plus a week.
**Do the arithmetic on one pulled list, not with a call per customer.** Xero rate limits per tenant, and per customer calls will exhaust the limit on a business of any size.
## Money out
`ACCPAY` invoices are bills to pay. Forecast them on their due dates. Do not model yourself paying late, even if you sometimes do, because a forecast that assumes you will stretch suppliers is a forecast that has hidden the problem rather than shown it.
What is **not** in `ACCPAY` and has to be added by hand:
- Wages and the on costs that ride with them
- Sales tax and GST payments
- Loan and lease repayments, the principal especially
- Owner drawings
- Anything paid by direct debit that never gets entered as a bill
That last category is the usual gap. Look down a bank statement, not the payables ledger, to find them.
## The opening balance
`xero_get_bank_summary` gives the balance per bank account.
Use the **bank** figure, not the ledger figure. They differ by whatever has not cleared, and the bank figure is the one that determines whether a payment bounces. If the reconciliation is behind, the difference can be material, and a forecast starting from an unreconciled balance is wrong from week one.
Get the reconciliation current before building the forecast: every unreconciled transaction is a difference between the number you are starting from and the money that is actually there.
## Finding the recurring lines
`xero_get_profit_and_loss` with monthly periods for the last 12 months. Read down each account row: anything with a similar figure every month is a recurring line for the forecast, and anything appearing once or twice a year is the kind of item people forget.
Insurance renewals, licence fees, annual software, tax instalments and the accountant's bill all live in that second group. They are easy to miss eleven months of the year and they are exactly the ones that cause a surprise.
## Rate limits
Xero rate limits per tenant. Pull each list once and do the joining and filtering locally rather than making a call per invoice or per customer. A forecast needs three or four calls in total, and a naive implementation needs hundreds.
scripts/cash_forecast.py 11.5 KB
#!/usr/bin/env python3
"""
Build a 13 week rolling cash flow forecast and find the week it goes negative.
python cash_forecast.py forecast.json
python cash_forecast.py forecast.json --weeks=26 --json
python cash_forecast.py forecast.json --stress=late30
The question this answers is not "will we be profitable". It is "do we have the
money to pay people on the 15th". Those have different answers more often than
anyone expects, and the gap is widest when a business is growing.
Receivables are scheduled on WHEN THEY WILL ACTUALLY PAY, not when they are due.
That is the single difference between a forecast and a wish. Give each customer a
days_late figure taken from their own history, and the forecast stops being
optimistic in the way every hand built one is.
Input format, all JSON. Dates are ISO, yyyy-mm-dd:
{
"name": "Northside Electrical",
"start": "2026-03-02",
"opening_balance": 84200,
"overdraft_limit": 50000,
"receivables": [
{"customer": "Harding Group", "amount": 46200, "due": "2026-03-14",
"days_late": 12},
{"customer": "Bellhaven", "amount": 18900, "due": "2026-03-28"}
],
"payables": [
{"supplier": "CableCo", "amount": 21400, "due": "2026-03-20"}
],
"recurring": [
{"name": "Wages", "amount": 31800, "every": "fortnight",
"first": "2026-03-06", "direction": "out"},
{"name": "Rent", "amount": 3500, "every": "month",
"first": "2026-03-01", "direction": "out"},
{"name": "Maintenance contracts", "amount": 22000, "every": "month",
"first": "2026-03-10", "direction": "in"}
],
"one_off": [
{"name": "Sales tax payment", "amount": 38400, "date": "2026-04-28",
"direction": "out"},
{"name": "New van deposit", "amount": 12000, "date": "2026-04-07",
"direction": "out"}
],
"new_sales": {
"weekly_amount": 24000,
"collected_after_days": 35,
"starts": "2026-03-09"
}
}
Field notes:
days_late days past due this customer actually pays. Take it from the
ledger, per customer, not as one average. Omit it and the
forecast assumes they pay exactly on the due date, which is
the assumption that makes every hand built forecast wrong
every "week", "fortnight", "month" or "quarter"
direction "in" or "out". Defaults to "out" for payables and recurring
new_sales work not yet won or not yet invoiced. Keep this conservative.
It is the line an optimist inflates and a lender discounts
overdraft_limit optional. If set, the report shows headroom against it rather
than against zero, because zero is not the real cliff
--stress=late30 every receivable pays 30 days later than assumed. Run it.
A forecast that survives this is a forecast worth showing a bank
"""
import json
import sys
from datetime import date, datetime, timedelta
WEEK = timedelta(days=7)
def die(msg):
print(f"error: {msg}", file=sys.stderr)
sys.exit(1)
def parse_date(value, field):
try:
return datetime.strptime(str(value), "%Y-%m-%d").date()
except (ValueError, TypeError):
die(f"{field}: expected a yyyy-mm-dd date, got {value!r}")
def add_months(d, n):
"""Same day next month, clamped to the end of a short month."""
month = d.month - 1 + n
year = d.year + month // 12
month = month % 12 + 1
for day in range(d.day, 27, -1):
try:
return date(year, month, day)
except ValueError:
continue
return date(year, month, min(d.day, 28))
def occurrences(first, every, horizon_end):
"""Every date this recurring item falls on, up to the end of the horizon."""
out = []
cursor = first
guard = 0
while cursor <= horizon_end and guard < 500:
out.append(cursor)
guard += 1
if every == "week":
cursor = cursor + WEEK
elif every == "fortnight":
cursor = cursor + 2 * WEEK
elif every == "month":
cursor = add_months(cursor, 1)
elif every == "quarter":
cursor = add_months(cursor, 3)
else:
die(f'unknown "every" value: {every!r}. Use week, fortnight, month or quarter.')
return out
def build(spec, weeks, stress_days=0):
start = parse_date(spec.get("start"), "start")
# Anchor the forecast to the Monday of the starting week.
start -= timedelta(days=start.weekday())
ends = [start + WEEK * (i + 1) - timedelta(days=1) for i in range(weeks)]
horizon_end = ends[-1]
buckets = [{"week": i + 1, "start": start + WEEK * i, "end": ends[i],
"inflow": 0.0, "outflow": 0.0, "lines_in": [], "lines_out": []}
for i in range(weeks)]
def place(when, amount, label, direction):
if when < start:
# Already overdue at the start of the forecast. It has not been paid,
# so it lands in week 1 rather than vanishing.
idx = 0
elif when > horizon_end:
return
else:
idx = (when - start).days // 7
b = buckets[idx]
if direction == "in":
b["inflow"] += amount
b["lines_in"].append((label, amount))
else:
b["outflow"] += amount
b["lines_out"].append((label, amount))
for r in spec.get("receivables", []):
due = parse_date(r.get("due"), "receivables.due")
late = int(r.get("days_late", 0)) + stress_days
place(due + timedelta(days=late), float(r["amount"]),
r.get("customer", "receivable"), "in")
for p in spec.get("payables", []):
due = parse_date(p.get("due"), "payables.due")
place(due, float(p["amount"]), p.get("supplier", "payable"),
p.get("direction", "out"))
for item in spec.get("recurring", []):
first = parse_date(item.get("first"), "recurring.first")
direction = item.get("direction", "out")
for when in occurrences(first, item.get("every", "month"), horizon_end):
place(when, float(item["amount"]), item.get("name", "recurring"), direction)
for item in spec.get("one_off", []):
place(parse_date(item.get("date"), "one_off.date"), float(item["amount"]),
item.get("name", "one off"), item.get("direction", "out"))
ns = spec.get("new_sales")
if ns:
amount = float(ns.get("weekly_amount", 0))
lag = int(ns.get("collected_after_days", 30)) + stress_days
first = parse_date(ns.get("starts", spec.get("start")), "new_sales.starts")
if amount:
cursor = first
while cursor <= horizon_end:
place(cursor + timedelta(days=lag), amount, "New sales", "in")
cursor += WEEK
balance = float(spec.get("opening_balance", 0))
for b in buckets:
b["opening"] = balance
b["net"] = b["inflow"] - b["outflow"]
balance += b["net"]
b["closing"] = balance
return buckets
def analyse(buckets, spec):
limit = spec.get("overdraft_limit")
floor = -float(limit) if limit else 0.0
lowest = min(buckets, key=lambda b: b["closing"])
breach = next((b for b in buckets if b["closing"] < floor), None)
negative = next((b for b in buckets if b["closing"] < 0), None)
return {"lowest": lowest, "breach": breach, "negative": negative, "floor": floor,
"limit": limit}
def report(spec, buckets, a, weeks, stress_days):
name = spec.get("name", "Cash flow forecast")
title = f"{name} {weeks} week forecast from {buckets[0]['start']}"
if stress_days:
title += f" [STRESSED: every receipt {stress_days} days later]"
print(f"\n{title}")
print("=" * min(len(title), 96))
print(f"\n {'Wk':>3} {'Week ending':<13}{'In':>13}{'Out':>13}{'Net':>13}{'Closing':>14}")
for b in buckets:
marker = ""
if b["closing"] < 0:
marker = " NEGATIVE"
elif a["limit"] and b["closing"] < a["floor"]:
marker = " OVER LIMIT"
elif b is a["lowest"]:
marker = " <- low point"
print(f" {b['week']:>3} {str(b['end']):<13}{b['inflow']:>13,.0f}"
f"{b['outflow']:>13,.0f}{b['net']:>13,.0f}{b['closing']:>14,.0f}{marker}")
print("\nTHE ANSWER")
low = a["lowest"]
print(f" Low point {low['closing']:>14,.0f} in week {low['week']}, "
f"ending {low['end']}")
if a["negative"]:
n = a["negative"]
print(f" GOES NEGATIVE week {n['week']}, ending {n['end']}, "
f"at {n['closing']:,.0f}")
print(f" Shortfall to cover {-low['closing']:,.0f}")
elif a["limit"] and a["breach"]:
br = a["breach"]
print(f" BREACHES OVERDRAFT week {br['week']}, ending {br['end']}")
else:
headroom = low["closing"] - a["floor"]
print(f" Never goes negative. Tightest headroom {headroom:,.0f} in week {low['week']}")
total_in = sum(b["inflow"] for b in buckets)
total_out = sum(b["outflow"] for b in buckets)
print(f"\n Total in {total_in:>14,.0f}")
print(f" Total out {total_out:>14,.0f}")
print(f" Net movement {total_in - total_out:>14,.0f}")
print(f" Opening {buckets[0]['opening']:>14,.0f}")
print(f" Closing {buckets[-1]['closing']:>14,.0f}")
print("\nBIGGEST WEEKS OUT")
for b in sorted(buckets, key=lambda x: -x["outflow"])[:3]:
lines = ", ".join(f"{n} {v:,.0f}" for n, v in
sorted(b["lines_out"], key=lambda t: -t[1])[:3])
print(f" Week {b['week']:>2} ending {b['end']} {b['outflow']:>11,.0f} {lines}")
if not stress_days:
print("\n Now run it again with --stress=late30. A forecast that survives")
print(" every customer paying 30 days later is one worth showing a bank.")
print()
def main():
args = sys.argv[1:]
paths = [a for a in args if not a.startswith("--")]
if not paths:
print("Usage: python cash_forecast.py <forecast.json> [--weeks=13] "
"[--stress=late30] [--json]")
sys.exit(1)
weeks = 13
for a in args:
if a.startswith("--weeks="):
weeks = int(a.split("=", 1)[1])
stress = 0
for a in args:
if a.startswith("--stress="):
token = a.split("=", 1)[1]
if not token.startswith("late") or not token[4:].isdigit():
die('--stress expects the form late30')
stress = int(token[4:])
if weeks < 1 or weeks > 104:
die("--weeks must be between 1 and 104")
try:
with open(paths[0], encoding="utf-8") as fh:
spec = json.load(fh)
except FileNotFoundError:
die(f"no such file: {paths[0]}")
except json.JSONDecodeError as e:
die(f"{paths[0]} is not valid JSON: {e}")
buckets = build(spec, weeks, stress)
a = analyse(buckets, spec)
if "--json" in args:
print(json.dumps({
"weeks": [{k: (str(v) if isinstance(v, date) else v)
for k, v in b.items() if k not in ("lines_in", "lines_out")}
for b in buckets],
"low_point": {"week": a["lowest"]["week"],
"closing": a["lowest"]["closing"],
"ending": str(a["lowest"]["end"])},
"goes_negative_week": a["negative"]["week"] if a["negative"] else None,
}, indent=2))
else:
report(spec, buckets, a, weeks, stress)
if __name__ == "__main__":
main()
scripts/render_pdf.mjs 20.4 KB
/**
* Render a markdown document to a branded, print-quality PDF.
*
* node render_pdf.mjs plan.md
* node render_pdf.mjs plan.md --out=business-plan.pdf --brand=brand.json
* node render_pdf.mjs plan.md --html keep the intermediate HTML and stop
*
* Needs Node 18+ and Chrome or Edge installed. No npm install: the markdown parser
* and the CDP client below are the whole dependency list.
*
* Why Chrome and not a PDF library: a real business document needs a cover page,
* running headers and footers, page numbers, controlled page breaks and tables that
* do not split a row across a page. Chrome's print engine does all of that from CSS
* you can read, and the output is the same shape a designer would expect. A PDF
* library would mean drawing every box by hand.
*
* The intermediate HTML is written NEXT TO the source markdown, not in a temp
* directory, so relative image paths in the markdown resolve the way the author
* wrote them. It is deleted afterwards unless --html is passed.
*
* Frontmatter (optional, must be the very first thing in the file) drives the cover:
*
* ---
* title: Business plan
* subtitle: FY26 to FY28
* client: Northside Electrical
* author: Dana Reyes
* date: March 2026
* confidential: true
* ---
*
* Body markers:
* <!-- toc --> replaced by a contents list built from the h2 and h3 headings
* <!-- pagebreak --> forces a new page. \newpage on its own line does the same
*
* Exit codes: 0 rendered, 1 usage or input error, 2 browser or render failure.
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, extname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const srcArg = argv.find((a) => !a.startsWith("--"));
const flag = (name) => argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
const htmlOnly = argv.includes("--html");
if (!srcArg) {
console.error("Usage: node render_pdf.mjs <document.md> [--out=file.pdf] [--brand=brand.json] [--css=extra.css] [--html]");
process.exit(1);
}
const srcPath = resolve(srcArg);
if (!existsSync(srcPath)) {
console.error(`No such file: ${srcPath}`);
process.exit(1);
}
const srcDir = dirname(srcPath);
const outPath = resolve(flag("out") ?? join(srcDir, `${basename(srcPath, extname(srcPath))}.pdf`));
const BROWSERS = [
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
];
const DEFAULT_BRAND = {
name: "",
accent: "#0B6EE8",
ink: "#12161C",
muted: "#5A6472",
rule: "#DCE1E8",
headingFont: "Georgia, 'Times New Roman', serif",
bodyFont: "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
monoFont: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
logo: null,
footerNote: "",
pageSize: "A4",
margin: { top: "22mm", bottom: "20mm", left: "18mm", right: "18mm" },
};
/* ------------------------------------------------------------------ markdown */
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
/** Inline spans. Code first, so a backtick span is never re-parsed for emphasis. */
function inline(text) {
const code = [];
let s = text.replace(/`([^`]+)`/g, (_, c) => `\u0000${code.push(`<code>${esc(c)}</code>`) - 1}\u0000`);
// Author notes are not content. Code spans were lifted out above, so a comment
// deliberately being SHOWN inside backticks survives this.
s = s.replace(/<!--[\s\S]*?-->/g, "");
s = esc(s);
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g,
(_, alt, src, title) => `<img src="${src}" alt="${alt}"${title ? ` title="${title}"` : ""}>`);
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>');
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
s = s.replace(/(^|[^*])\*([^*]+)\*/g, "$1<em>$2</em>");
s = s.replace(/~~([^~]+)~~/g, "<del>$1</del>");
s = s.replace(/\u0000(\d+)\u0000/g, (_, i) => code[Number(i)]);
return s;
}
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
/** Pull `key: value` frontmatter if the file opens with a --- fence. */
function frontmatter(raw) {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!m) return { meta: {}, body: raw };
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
if (kv) meta[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
}
return { meta, body: raw.slice(m[0].length) };
}
/**
* Block parser. A deliberate subset: headings, paragraphs, fenced code, pipe tables,
* ordered and unordered lists with one level of nesting, blockquotes, rules, images.
* Anything outside the subset passes through as a paragraph rather than failing, so a
* document never silently loses text.
*/
function blocks(md) {
const lines = md.replace(/\r\n/g, "\n").split("\n");
const out = [];
const headings = [];
let i = 0;
/** A sublist belongs INSIDE the <li> it hangs off, not after it. */
const nest = (item, sub) => item.replace(/<\/li>$/, `<ul>${sub.join("")}</ul></li>`);
const listItem = (text) => {
const task = text.match(/^\[([ xX])\]\s+(.*)$/);
if (!task) return `<li>${inline(text)}</li>`;
const done = task[1].toLowerCase() === "x";
return `<li class="task"><span class="box${done ? " on" : ""}"></span>${inline(task[2])}</li>`;
};
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
if (/^(<!--\s*pagebreak\s*-->|\\newpage)\s*$/i.test(line.trim())) {
out.push('<div class="pagebreak"></div>'); i++; continue;
}
if (/^<!--\s*toc\s*-->$/i.test(line.trim())) { out.push("\u0001TOC\u0001"); i++; continue; }
// An author note, possibly spanning several lines. Templates are full of these
// and none of them belong in the rendered document.
if (line.trimStart().startsWith("<!--")) {
while (i < lines.length && !lines[i].includes("-->")) i++;
i++;
continue;
}
const h = line.match(/^(#{1,6})\s+(.*)$/);
if (h) {
const level = h[1].length;
const text = h[2].trim();
const id = slug(text);
if (level === 2 || level === 3) headings.push({ level, text, id });
out.push(`<h${level} id="${id}">${inline(text)}</h${level}>`);
i++; continue;
}
const fence = line.match(/^```\s*([\w-]*)\s*$/);
if (fence) {
const buf = [];
i++;
while (i < lines.length && !/^```\s*$/.test(lines[i])) buf.push(lines[i++]);
i++;
out.push(`<pre class="code"><code>${esc(buf.join("\n"))}</code></pre>`);
continue;
}
if (/^(\*\*\*|---|___)\s*$/.test(line.trim())) { out.push("<hr>"); i++; continue; }
// Pipe table. Needs the delimiter row, otherwise it is just text with pipes in it.
if (line.includes("|") && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[i + 1] ?? "")) {
const cells = (r) => r.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
const head = cells(line);
const align = cells(lines[i + 1]).map((d) =>
d.startsWith(":") && d.endsWith(":") ? "center" : d.endsWith(":") ? "right" : "left");
i += 2;
const body = [];
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) body.push(cells(lines[i++]));
const th = head.map((c, n) => `<th style="text-align:${align[n] ?? "left"}">${inline(c)}</th>`).join("");
const tr = body.map((r) =>
`<tr>${r.map((c, n) => `<td style="text-align:${align[n] ?? "left"}">${inline(c)}</td>`).join("")}</tr>`).join("");
out.push(`<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`);
continue;
}
if (/^\s*>/.test(line)) {
const buf = [];
while (i < lines.length && /^\s*>/.test(lines[i])) buf.push(lines[i++].replace(/^\s*>\s?/, ""));
out.push(`<blockquote>${blocks(buf.join("\n")).html}</blockquote>`);
continue;
}
const bullet = line.match(/^(\s*)([-*+])\s+(.*)$/);
const number = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/);
if (bullet || number) {
const ordered = Boolean(number);
const tag = ordered ? "ol" : "ul";
const start = ordered ? Number(number[2]) : 1;
const items = [];
let nested = null;
while (i < lines.length) {
const b = lines[i].match(/^(\s*)([-*+])\s+(.*)$/);
const n = lines[i].match(/^(\s*)(\d+)[.)]\s+(.*)$/);
const m = b || n;
if (!m) {
// A plain indented line continues the item it follows.
if (items.length && /^\s{2,}\S/.test(lines[i])) { items[items.length - 1] += ` ${inline(lines[i].trim())}`; i++; continue; }
break;
}
if (Boolean(n) !== ordered && m[1].length === 0) break;
if (m[1].length >= 2) {
nested ??= [];
nested.push(listItem(m[3]));
i++; continue;
}
if (nested) { items[items.length - 1] = nest(items[items.length - 1], nested); nested = null; }
items.push(listItem(m[3]));
i++;
}
if (nested && items.length) items[items.length - 1] = nest(items[items.length - 1], nested);
out.push(`<${tag}${ordered && start !== 1 ? ` start="${start}"` : ""}>${items.join("")}</${tag}>`);
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*>|\s*[-*+]\s|\s*\d+[.)]\s)/.test(lines[i])) {
para.push(lines[i++]);
}
const joined = para.join(" ").trim();
if (!joined) { i++; continue; }
// A paragraph that is nothing but an image gets to be a figure, not a text line.
const lone = joined.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/);
out.push(lone
? `<figure><img src="${lone[2]}" alt="${lone[1]}">${lone[1] ? `<figcaption>${inline(lone[1])}</figcaption>` : ""}</figure>`
: `<p>${inline(joined)}</p>`);
}
return { html: out.join("\n"), headings };
}
/* ---------------------------------------------------------------- assembling */
function coverPage(meta, brand) {
if (!meta.title) return "";
const logo = brand.logo ? `<img class="cover-logo" src="${brand.logo}" alt="">` : "";
const rows = [
meta.client && ["Prepared for", meta.client],
meta.author && ["Prepared by", meta.author],
(meta.company || brand.name) && ["Company", meta.company || brand.name],
meta.date && ["Date", meta.date],
meta.version && ["Version", meta.version],
].filter(Boolean);
return `<section class="cover">
${logo}
<div class="cover-body">
<h1 class="cover-title">${inline(meta.title)}</h1>
${meta.subtitle ? `<p class="cover-subtitle">${inline(meta.subtitle)}</p>` : ""}
${rows.length ? `<dl class="cover-meta">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${inline(v)}</dd>`).join("")}</dl>` : ""}
${String(meta.confidential).toLowerCase() === "true" ? '<p class="cover-confidential">Commercial in confidence</p>' : ""}
</div>
</section>
<div class="pagebreak"></div>`;
}
/**
* A contents list, without page numbers. Page numbers would need a second render to
* find out what page each heading landed on, and a contents list that is confidently
* wrong is worse than one that is honestly a list of sections.
*/
function toc(headings) {
if (!headings.length) return "";
const items = headings.map((h) =>
`<li class="toc-h${h.level}"><a href="#${h.id}">${inline(h.text)}</a></li>`).join("");
return `<nav class="toc"><h2 class="toc-title">Contents</h2><ol>${items}</ol></nav>`;
}
async function loadBrand() {
const path = flag("brand");
if (!path) return DEFAULT_BRAND;
let parsed;
try {
parsed = JSON.parse(await readFile(resolve(path), "utf8"));
} catch (e) {
console.error(`Could not read brand file ${path}: ${e.message}`);
process.exit(1);
}
const brand = { ...DEFAULT_BRAND, ...parsed, margin: { ...DEFAULT_BRAND.margin, ...(parsed.margin ?? {}) } };
// A logo path in the brand file is relative to the brand file, not the cwd.
if (brand.logo && !/^(https?:|data:)/.test(brand.logo)) {
brand.logo = pathToFileURL(resolve(dirname(resolve(path)), brand.logo)).href;
}
return brand;
}
async function readCss(brand) {
const bundled = join(HERE, "..", "assets", "document.css");
let css = existsSync(bundled) ? await readFile(bundled, "utf8") : "";
if (!css) console.warn("assets/document.css not found next to the script. Rendering with browser defaults.");
const extra = flag("css");
if (extra) css += `\n${await readFile(resolve(extra), "utf8")}`;
const vars = `:root{
--accent:${brand.accent};--ink:${brand.ink};--muted:${brand.muted};--rule:${brand.rule};
--font-heading:${brand.headingFont};--font-body:${brand.bodyFont};--font-mono:${brand.monoFont};
}`;
return `${vars}\n${css}`;
}
/* ------------------------------------------------------------------- browser */
class Cdp {
#ws; #id = 0; #pending = new Map();
static async connect(url) {
const c = new Cdp();
c.#ws = new WebSocket(url);
await new Promise((res, rej) => {
c.#ws.onopen = res;
c.#ws.onerror = () => rej(new Error("CDP websocket failed to open"));
});
c.#ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
const p = c.#pending.get(msg.id);
if (!p) return; // an event, not a reply
c.#pending.delete(msg.id);
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
};
return c;
}
send(method, params = {}, sessionId) {
const id = ++this.#id;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
this.#ws.send(JSON.stringify({ id, method, params, sessionId }));
});
}
close() { try { this.#ws.close(); } catch { /* already gone */ } }
}
async function launch(port) {
const exe = BROWSERS.find((p) => existsSync(p));
if (!exe) throw new Error(`No Chrome or Edge found. Looked in:\n ${BROWSERS.join("\n ")}`);
const profile = await mkdtemp(join(tmpdir(), "render-pdf-"));
const proc = spawn(exe, [
"--headless=new", `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
"--no-first-run", "--no-default-browser-check", "--disable-extensions",
"--force-color-profile=srgb", "--allow-file-access-from-files", "about:blank",
]);
proc.on("error", (e) => { console.error("Failed to launch browser:", e.message); process.exit(2); });
const deadline = Date.now() + 20000;
let wsUrl;
while (Date.now() < deadline && !wsUrl) {
try {
const res = await fetch(`http://127.0.0.1:${port}/json/version`);
if (res.ok) wsUrl = (await res.json()).webSocketDebuggerUrl;
} catch { /* not up yet */ }
if (!wsUrl) await new Promise((r) => setTimeout(r, 120));
}
if (!wsUrl) throw new Error(`DevTools never came up on :${port}`);
const cdp = await Cdp.connect(wsUrl);
return {
cdp,
async dispose() { cdp.close(); proc.kill(); await rm(profile, { recursive: true, force: true }).catch(() => {}); },
};
}
const PAGE_SIZES = { // inches, because printToPDF takes inches
A4: [8.27, 11.69],
Letter: [8.5, 11],
Legal: [8.5, 14],
A5: [5.83, 8.27],
};
const mmToIn = (v) => {
const n = parseFloat(v);
if (/mm$/i.test(v)) return n / 25.4;
if (/cm$/i.test(v)) return n / 2.54;
if (/in$/i.test(v)) return n;
if (/pt$/i.test(v)) return n / 72;
return n / 25.4; // bare numbers are millimetres
};
/* ---------------------------------------------------------------------- main */
const raw = await readFile(srcPath, "utf8");
const { meta, body } = frontmatter(raw);
const brand = await loadBrand();
const parsed = blocks(body);
const html = parsed.html.replace("\u0001TOC\u0001", toc(parsed.headings));
const css = await readCss(brand);
const docTitle = meta.title || basename(srcPath, extname(srcPath));
const page = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${esc(docTitle)}</title>
<style>${css}</style></head>
<body class="doc">
${coverPage(meta, brand)}
<main>${html}</main>
</body></html>`;
const htmlPath = join(srcDir, `.${basename(srcPath, extname(srcPath))}.render.html`);
await writeFile(htmlPath, page, "utf8");
if (htmlOnly) {
console.log(`HTML written to ${htmlPath}`);
console.log("Open it in a browser and use Print to preview pagination before rendering the PDF.");
process.exit(0);
}
const [pw, ph] = PAGE_SIZES[brand.pageSize] ?? PAGE_SIZES.A4;
// The header and footer are separate mini-documents with no access to the page CSS,
// so their styling is inline and their font sizes are absolute. This is a Chrome rule,
// not a choice.
const chrome = (content) =>
`<div style="width:100%;font-size:8px;font-family:${brand.bodyFont.replace(/"/g, "'")};color:${brand.muted};
padding:0 ${brand.margin.left} 0 ${brand.margin.right};display:flex;justify-content:space-between;">${content}</div>`;
let browser;
try {
browser = await launch(9339);
const { cdp } = browser;
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Runtime.enable", {}, sessionId);
await cdp.send("Page.navigate", { url: pathToFileURL(htmlPath).href }, sessionId);
// The Cdp client above drops events on the floor, so readiness is polled rather than
// awaited. Poll for the real condition: the document is complete AND every image has
// finished, because a half decoded image prints as a blank box with no error anywhere.
// `complete` goes true when an image finishes loading OR fails. Waiting for
// naturalWidth as well would hang the full 15s on every broken path, which is the
// opposite of useful: a missing logo should be reported instantly, not waited on.
const ready = `document.readyState === "complete" &&
Array.from(document.images).every((i) => i.complete)`;
const deadline = Date.now() + 15000;
for (;;) {
const { result } = await cdp.send("Runtime.evaluate", { expression: ready, returnByValue: true }, sessionId);
if (result.value === true) break;
if (Date.now() > deadline) { console.warn("Page never settled after 15s. Printing it as it stands."); break; }
await new Promise((r) => setTimeout(r, 100));
}
// Name anything that failed to load. A blank box in a PDF with no warning anywhere
// is the single most common way a branded document goes out looking broken.
const { result: broken } = await cdp.send("Runtime.evaluate", {
expression: `JSON.stringify(Array.from(document.images)
.filter((i) => i.naturalWidth === 0 && i.getAttribute("src"))
.map((i) => i.getAttribute("src")))`,
returnByValue: true,
}, sessionId);
for (const src of JSON.parse(broken.value || "[]")) {
console.warn(`Image did not load, it will be blank in the PDF: ${decodeURI(src)}`);
}
// Fonts lay out after load and shift the pagination if printed too early.
await cdp.send("Runtime.evaluate", { expression: "document.fonts.ready", awaitPromise: true }, sessionId).catch(() => {});
const { data } = await cdp.send("Page.printToPDF", {
printBackground: true,
preferCSSPageSize: false,
paperWidth: pw,
paperHeight: ph,
marginTop: mmToIn(brand.margin.top),
marginBottom: mmToIn(brand.margin.bottom),
marginLeft: mmToIn(brand.margin.left),
marginRight: mmToIn(brand.margin.right),
displayHeaderFooter: true,
headerTemplate: chrome(`<span>${esc(brand.name || "")}</span><span>${esc(docTitle)}</span>`),
footerTemplate: chrome(
`<span>${esc(brand.footerNote || "")}</span><span class="pageNumber"></span>`),
}, sessionId);
await writeFile(outPath, Buffer.from(data, "base64"));
console.log(`Wrote ${outPath}`);
if (parsed.headings.length) console.log(`${parsed.headings.length} headings, cover ${meta.title ? "on" : "off"}`);
} catch (e) {
console.error(`Render failed: ${e.message}`);
console.error(`The intermediate HTML is at ${htmlPath}. Open it in a browser to see what the page actually looks like.`);
process.exitCode = 2;
} finally {
await browser?.dispose();
if (!process.exitCode) await rm(htmlPath, { force: true }).catch(() => {});
}
assets/brand.example.json 621 B
{
"name": "Northside Electrical",
"accent": "#0B6EE8",
"ink": "#12161C",
"muted": "#5A6472",
"rule": "#DCE1E8",
"headingFont": "Georgia, 'Times New Roman', serif",
"bodyFont": "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif",
"monoFont": "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
"_logo": "a path relative to THIS file, e.g. logo.png. Rename to \"logo\" to use it",
"logo": null,
"footerNote": "Northside Electrical Pty Ltd | Commercial in confidence",
"pageSize": "A4",
"margin": { "top": "22mm", "bottom": "20mm", "left": "18mm", "right": "18mm" }
}
assets/document.css 5.7 KB
/*
* Print stylesheet for render_pdf.mjs.
*
* The script injects a :root block above this file with --accent, --ink, --muted,
* --rule and the three font stacks, taken from the brand file. Everything here is
* expressed in those variables, so rebranding a document means editing brand.json
* and not this file.
*
* Page size and margins are set by the script through printToPDF, not by @page.
* Chrome applies printToPDF margins and @page margins to the same box, and having
* both set is the usual cause of a document whose margins mysteriously double.
*/
* { box-sizing: border-box; }
body.doc {
margin: 0;
font-family: var(--font-body);
font-size: 10.5pt;
line-height: 1.55;
color: var(--ink);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* ------------------------------------------------------------------- cover */
.cover {
display: flex;
flex-direction: column;
justify-content: center;
/* Fills the printable box. 297mm less the 22mm and 20mm the script sets. */
min-height: 250mm;
}
.cover-logo { max-height: 18mm; max-width: 70mm; margin-bottom: 16mm; }
.cover-title {
font-family: var(--font-heading);
font-size: 34pt;
line-height: 1.12;
font-weight: 700;
margin: 0 0 4mm;
color: var(--ink);
}
.cover-subtitle {
font-size: 13pt;
color: var(--muted);
margin: 0 0 14mm;
font-weight: 400;
}
.cover-body::before {
content: "";
display: block;
width: 28mm;
height: 3pt;
background: var(--accent);
margin-bottom: 10mm;
}
.cover-meta {
display: grid;
grid-template-columns: 34mm 1fr;
gap: 2mm 6mm;
margin: 0;
font-size: 10pt;
}
.cover-meta dt { color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; font-size: 8pt; padding-top: 1pt; }
.cover-meta dd { margin: 0; font-weight: 600; }
.cover-confidential {
margin-top: 16mm;
font-size: 8pt;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
/* --------------------------------------------------------------- headings */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
color: var(--ink);
line-height: 1.25;
/* Never leave a heading alone at the foot of a page. */
break-after: avoid-page;
page-break-after: avoid;
break-inside: avoid-page;
}
main h1 { font-size: 22pt; margin: 0 0 6mm; }
h2 {
font-size: 15pt;
margin: 10mm 0 3mm;
padding-bottom: 2mm;
border-bottom: 0.7pt solid var(--rule);
}
h3 { font-size: 12pt; margin: 7mm 0 2mm; }
h4 { font-size: 10.5pt; margin: 5mm 0 1.5mm; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); }
main > h2:first-child, main > h1:first-child { margin-top: 0; }
/* ------------------------------------------------------------------- text */
p { margin: 0 0 3.2mm; orphans: 3; widows: 3; }
a { color: var(--accent); text-decoration: none; }
strong { font-weight: 650; }
ul, ol { margin: 0 0 3.2mm; padding-left: 6mm; }
li { margin-bottom: 1.4mm; break-inside: avoid; }
li > ul, li > ol { margin-top: 1.4mm; }
li.task { list-style: none; margin-left: -5mm; display: flex; gap: 2.5mm; align-items: baseline; }
li.task .box {
flex: 0 0 auto;
width: 3mm; height: 3mm;
border: 0.7pt solid var(--muted);
border-radius: 0.6mm;
}
li.task .box.on { background: var(--accent); border-color: var(--accent); }
blockquote {
margin: 0 0 3.2mm;
padding: 1mm 0 1mm 5mm;
border-left: 2pt solid var(--accent);
color: var(--muted);
break-inside: avoid;
}
blockquote p:last-child { margin-bottom: 0; }
hr { border: 0; border-top: 0.7pt solid var(--rule); margin: 7mm 0; }
code {
font-family: var(--font-mono);
font-size: 0.88em;
background: #F3F5F8;
padding: 0.3mm 1mm;
border-radius: 0.8mm;
}
pre.code {
font-family: var(--font-mono);
font-size: 8.5pt;
line-height: 1.45;
background: #F3F5F8;
border: 0.7pt solid var(--rule);
border-radius: 1.5mm;
padding: 3mm 4mm;
margin: 0 0 3.2mm;
white-space: pre-wrap; /* a long line wraps rather than being cut off the page */
word-break: break-word;
break-inside: avoid;
}
pre.code code { background: none; padding: 0; font-size: inherit; }
/* ----------------------------------------------------------------- tables */
table {
width: 100%;
border-collapse: collapse;
margin: 0 0 4mm;
font-size: 9.5pt;
break-inside: auto;
}
thead { display: table-header-group; } /* repeat the header on every page */
tr { break-inside: avoid; page-break-inside: avoid; }
th {
text-align: left;
font-weight: 650;
font-size: 8pt;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
border-bottom: 1pt solid var(--ink);
padding: 2mm 2.5mm;
}
td { padding: 2mm 2.5mm; border-bottom: 0.5pt solid var(--rule); vertical-align: top; }
tbody tr:nth-child(even) { background: #FAFBFC; }
/* Right align a column of numbers by putting :--- in the markdown delimiter row. */
td[style*="right"], th[style*="right"] { font-variant-numeric: tabular-nums; }
/* ---------------------------------------------------------------- figures */
figure { margin: 0 0 4mm; break-inside: avoid; text-align: center; }
img { max-width: 100%; }
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }
/* -------------------------------------------------------------- contents */
.toc { break-after: page; page-break-after: always; }
.toc-title { border: 0; margin-top: 0; }
.toc ol { list-style: none; padding: 0; }
.toc li { margin-bottom: 2mm; }
.toc a { color: var(--ink); }
.toc-h3 { padding-left: 6mm; font-size: 9.5pt; color: var(--muted); }
.toc-h3 a { color: var(--muted); }
/* ----------------------------------------------------------------- breaks */
.pagebreak { break-after: page; page-break-after: always; height: 0; }
/* Put class="keep" on a heading to start its section on a fresh page. */
.keep { break-before: page; page-break-before: always; }
assets/forecast-pack-template.md 3.4 KB
---
title: Cash flow forecast
subtitle: <13 weeks from 2 March 2026>
company: <Business name>
client: <Prepared for: the bank, the board, or delete this line>
author: <Who built it>
date: <Month Year>
confidential: true
---
<!-- Render to PDF with the script that ships alongside this template:
node scripts/render_pdf.mjs forecast.md --brand=brand.json
Build the numbers first with scripts/cash_forecast.py, then paste the weekly
table in below. Run the stressed case too and put it in section 4. A lender is
pricing the chance you are wrong, so offering the stressed case before being
asked is a materially stronger position than being asked for it. -->
## 1. Summary
| | |
|---|---:|
| Opening bank balance | <$> |
| Low point | **<$>** |
| Low point falls in | <week, ending date> |
| Closing balance at week 13 | <$> |
| Overdraft limit | <$> |
| Tightest headroom against the limit | <$> |
<One paragraph: what the forecast shows, in plain language. If it goes negative,
say so here in the first sentence rather than leaving it to be discovered in the
table. A reader who finds bad news themselves trusts the rest of the document less.>
## 2. Assumptions
<The most read page in the pack. Every number in section 3 traces back to something
here. State which figures are measured and which are assumed.>
| Assumption | Value | Basis |
|---|---:|---|
| Days late, by customer | <see below> | <Median of payment date minus due date, last 12 months> |
| New sales per week | <$> | <Measured, or assumed. Say which> |
| Collection lag on new sales | <days> | <> |
| Wages | <$ per fortnight> | <> |
| Sales tax payment | <$ on date> | <> |
**Days to pay, by customer.** <Taken from the ledger, not from the stated terms. The
distribution matters more than the average, because one large slow payer dominates
the outcome.>
| Customer | Terms | Actually pays | Share of receivables |
|---|---|---:|---:|
| <> | <> | <+12 days> | <%> |
| <> | | | |
## 3. The forecast
<Paste the weekly table from cash_forecast.py. Right align every number column
using a colon at the end of that column's delimiter row.>
| Wk | Week ending | In | Out | Net | Closing |
|---|---|---:|---:|---:|---:|
| 1 | | | | | |
| 2 | | | | | |
| 3 | | | | | |
## 4. Stressed case
<Every receipt 30 days later than assumed. Run it with --stress=late30.>
| | Plan | Stressed |
|---|---:|---:|
| Low point | <$> | <$> |
| Week it falls | <> | <> |
| Goes negative | <No> | <Week 5> |
| Shortfall to cover | <nil> | <$> |
**What we would do.** <Name the actions in order, with the amount each releases.
Invoice faster, chase what is owed, take deposits, delay a discretionary payment,
talk to suppliers, talk to the bank. A stressed case with no response is only half
the answer.>
## 5. The biggest weeks out
<Which payments cause the dip. The fix is usually moving one of them, not finding
new money.>
| Week ending | Total out | The three largest |
|---|---:|---|
| <> | <$> | <> |
## 6. How this is maintained
| | |
|---|---|
| Updated | <Weekly, on a named day> |
| Owner | <One name> |
| Source of receivables | <Xero / QuickBooks, pulled not retyped> |
| Forecast error tracked | <Yes, from week X> |
<A forecast built once was a spreadsheet exercise. The comparison between forecast
and actual, week after week, is what makes the next one trustworthy.>
| Week ending | Forecast closing | Actual closing | Error |
|---|---:|---:|---:|
| <> | | | |
| <> | | | |
assets/forecast.example.json 1.4 KB
{
"name": "Northside Electrical",
"start": "2026-03-02",
"opening_balance": 84200,
"overdraft_limit": 50000,
"receivables": [
{"customer": "Harding Group", "amount": 46200, "due": "2026-03-14", "days_late": 12},
{"customer": "Bellhaven", "amount": 18900, "due": "2026-03-28"},
{"customer": "Corrigan Facilities", "amount": 31500, "due": "2026-02-20", "days_late": 25},
{"customer": "Maple Ridge", "amount": 9800, "due": "2026-04-11", "days_late": 6}
],
"payables": [
{"supplier": "CableCo", "amount": 21400, "due": "2026-03-20"},
{"supplier": "Switchgear Supply", "amount": 14600, "due": "2026-04-03"}
],
"recurring": [
{"name": "Wages", "amount": 31800, "every": "fortnight", "first": "2026-03-06", "direction": "out"},
{"name": "Rent", "amount": 3500, "every": "month", "first": "2026-03-01", "direction": "out"},
{"name": "Vehicles and fuel", "amount": 1400, "every": "week", "first": "2026-03-03", "direction": "out"},
{"name": "Maintenance contracts", "amount": 22000, "every": "month", "first": "2026-03-10", "direction": "in"}
],
"one_off": [
{"name": "Sales tax payment", "amount": 38400, "date": "2026-04-28", "direction": "out"},
{"name": "New van deposit", "amount": 12000, "date": "2026-04-07", "direction": "out"}
],
"new_sales": {"weekly_amount": 24000, "collected_after_days": 35, "starts": "2026-03-09"}
}