Cash Flow Kills More SMBs Than Bad Products
A CB Insights analysis of startup post-mortems found that 38% of failed businesses cited running out of cash as a primary reason for shutting down. Not bad products. Not weak marketing. Cash. The invoice was sent, the revenue was booked, but the money wasn't in the bank when payroll hit.
Most SMBs manage cash flow in spreadsheets—manually pulling bank balances, eyeballing receivables aging, and hoping that next month's collections cover next month's obligations. The problem isn't laziness; it's that traditional accounting software shows you where your cash was, not where it's going.
Odoo 19's Accounting module includes a full cash flow forecasting engine that most implementations never configure. It pulls from open invoices, scheduled payments, recurring bills, and bank balances to project your liquidity position forward in time. This guide walks you through every step: how to set up the cash flow statement, configure forecast reports, project receivables and payables, integrate live bank balances, run scenario planning, and configure automated alerts before your cash runs dry.
Setting Up the Cash Flow Statement in Odoo 19
Before you can forecast, you need a clean cash flow statement. Odoo 19 generates this automatically from your chart of accounts—but only if your accounts are tagged correctly. A miscategorized account means entire cash movements disappear from the report.
Account Tagging for Cash Flow Classification
Odoo classifies cash movements into three IAS 7 categories: Operating, Investing, and Financing. Each account in your chart of accounts needs the correct tag:
| Cash Flow Category | Account Tag | Example Accounts | Common Mistakes |
|---|---|---|---|
| Operating Activities | operating | Revenue, COGS, Payroll, Rent, Utilities | Forgetting to tag prepaid expense accounts |
| Investing Activities | investing | Fixed Assets, Equipment Purchases, Securities | Tagging asset depreciation as investing (it's non-cash) |
| Financing Activities | financing | Loan Proceeds, Loan Repayments, Equity Injections | Missing interest payments (they're operating, not financing) |
| Cash & Equivalents | cash | Bank Accounts, Petty Cash, Money Market | Not including short-term deposit accounts |
Configuring Account Tags via Python
# Run in Odoo shell: python odoo-bin shell -d mydb
env = self.env
# Map account code prefixes to cash flow tags
tag_map = {{
'1000': 'cash', # Bank & cash accounts
'1100': 'operating', # Receivables
'1200': 'operating', # Inventory
'1500': 'investing', # Fixed assets
'2000': 'operating', # Payables
'2500': 'financing', # Long-term loans
'3000': 'financing', # Equity
'4000': 'operating', # Revenue
'5000': 'operating', # Expenses
}}
for prefix, tag_name in tag_map.items():
tag = env['account.account.tag'].search(
[('name', '=', tag_name),
('applicability', '=', 'accounts')],
limit=1,
)
accounts = env['account.account'].search(
[('code', '=like', f'{{prefix}}%')]
)
if tag and accounts:
accounts.write({{'tag_ids': [(4, tag.id)]}})
print(f"Tagged {{len(accounts)}} accounts "
f"({{prefix}}*) as {{tag_name}}")
env.cr.commit()After tagging, navigate to Accounting → Reporting → Cash Flow Statement and generate the report for a completed month. If the Net Increase in Cash line doesn't match the actual change in your bank balance for that period, you have mistagged accounts. Fix them before building any forecasts—a forecast built on a broken cash flow statement is worse than no forecast at all.
Configuring the Cash Flow Forecast Report in Odoo 19
Odoo 19's forecast report aggregates data from multiple sources to project your cash position forward. The default configuration shows a 30-day forecast, but most SMBs need 90 days minimum to make useful decisions. Here's how to configure it properly.
Enabling the Forecast Module
<!-- Navigate to: Accounting → Configuration → Settings -->
<!-- Or enable programmatically: -->
<odoo>
<record id="config_enable_forecast"
model="res.config.settings">
<field name="group_cash_flow_forecast"
eval="True" />
<field name="cash_forecast_days">90</field>
</record>
</odoo>Building a Custom Forecast Report
The built-in report is a starting point. For operational decision-making, you need a forecast that breaks down inflows and outflows by category and shows weekly buckets:
from odoo import api, fields, models
from datetime import timedelta
class CashFlowForecast(models.TransientModel):
_name = "cash.flow.forecast"
_description = "Cash Flow Forecast Report"
date_from = fields.Date(
default=fields.Date.today,
)
date_to = fields.Date(
default=lambda self: fields.Date.today()
+ timedelta(days=90),
)
journal_ids = fields.Many2many(
"account.journal",
string="Bank Journals",
domain=[("type", "=", "bank")],
)
include_draft = fields.Boolean(
string="Include Draft Invoices",
default=False,
help="Include unposted invoices in projections. "
"Useful for sales pipeline visibility.",
)
@api.model
def _get_opening_balance(self, journals, date):
"""Sum of all bank journal balances as of date."""
balances = []
for journal in journals:
account = journal.default_account_id
balance = self.env["account.move.line"].read_group(
domain=[
("account_id", "=", account.id),
("date", "<=", date),
("parent_state", "=", "posted"),
],
fields=["balance:sum"],
groupby=[],
)
balances.append(
balance[0]["balance"] if balance else 0.0
)
return sum(balances)
def _get_projected_inflows(self, date_from, date_to):
"""Receivables due in the forecast window."""
domain = [
("move_id.move_type", "in",
["out_invoice", "out_refund"]),
("date_maturity", ">=", date_from),
("date_maturity", "<=", date_to),
("amount_residual", ">", 0),
]
if not self.include_draft:
domain.append(
("parent_state", "=", "posted")
)
return self.env["account.move.line"].search(domain)
def _get_projected_outflows(self, date_from, date_to):
"""Payables due in the forecast window."""
domain = [
("move_id.move_type", "in",
["in_invoice", "in_refund"]),
("date_maturity", ">=", date_from),
("date_maturity", "<=", date_to),
("amount_residual", ">", 0),
]
if not self.include_draft:
domain.append(
("parent_state", "=", "posted")
)
return self.env["account.move.line"].search(domain)Including draft invoices in your forecast gives you pipeline visibility but inflates projected inflows. We recommend running two forecasts side by side: one with posted invoices only (your conservative baseline) and one including drafts (your optimistic scenario). If the gap between them is large, your cash position depends heavily on deals that haven't closed yet—and that's a risk you need to manage, not ignore.
AR/AP Projections and Bank Balance Integration
A cash flow forecast is only as accurate as the data feeding it. The three pillars are: receivables timing (when customers actually pay, not when invoices are due), payables scheduling (when you've committed to pay vendors), and live bank balances (your actual starting point, not the ledger balance).
Adjusting for Real Payment Behavior
The biggest mistake in cash flow forecasting is trusting the due date on invoices. If your payment terms say Net 30 but your average customer pays in 47 days, your forecast is wrong by 17 days on every receivable. Odoo 19 lets you compute a historical payment delay per customer:
from odoo import api, fields, models
class ResPartner(models.Model):
_inherit = "res.partner"
x_avg_payment_delay = fields.Float(
string="Avg Payment Delay (Days)",
compute="_compute_avg_payment_delay",
store=True,
help="Average days between invoice due date "
"and actual payment over the last 12 months.",
)
@api.depends("invoice_ids.payment_state",
"invoice_ids.invoice_date_due")
def _compute_avg_payment_delay(self):
for partner in self:
paid_invoices = partner.invoice_ids.filtered(
lambda m: (
m.move_type == "out_invoice"
and m.payment_state == "paid"
and m.invoice_date_due
)
)
if not paid_invoices:
partner.x_avg_payment_delay = 0.0
continue
delays = []
for inv in paid_invoices[-50:]: # Last 50
payments = inv._get_reconciled_payments()
if payments:
pay_date = max(p.date for p in payments)
delta = (pay_date - inv.invoice_date_due).days
delays.append(max(delta, 0))
partner.x_avg_payment_delay = (
sum(delays) / len(delays) if delays else 0.0
)Connecting Live Bank Balances via OFX/CAMT
Your forecast's starting balance must be your actual bank balance, not the accounting ledger balance. These differ because of in-transit payments, unreconciled bank fees, and timing differences. Odoo 19 supports automatic bank synchronization through:
- Odoo Online Bank Sync — Direct API integration with 15,000+ banks via Plaid/Yodlee. One-click setup in
Accounting → Configuration → Online Synchronization. - CAMT.053 Import — European standard (ISO 20022). Upload XML files from your bank portal. Supports multi-currency and batch statements.
- OFX Import — North American standard. Works with most US and Canadian banks. Imported via
Accounting → Bank Statements → Import. - CSV Fallback — For banks that only export CSV. Map columns in Odoo's import wizard. Error-prone but universally available.
def _get_live_bank_balance(self, journal):
"""
Return the last reconciled bank statement
balance, which is closer to the real bank
balance than the GL balance.
"""
last_statement = self.env[
"account.bank.statement"
].search(
[("journal_id", "=", journal.id)],
order="date desc, id desc",
limit=1,
)
if last_statement:
return last_statement.balance_end_real
# Fallback to GL balance
return journal.default_account_id.current_balanceA forecast anchored on a bank balance that's 3 weeks stale is useless. Configure Odoo's bank sync to run daily and make reconciliation a morning task for your finance team. The 10 minutes it takes to reconcile each morning buys you a forecast that's accurate to yesterday—not last month.
Scenario Planning and Automated Low-Cash Alerts
A single forecast number is a guess. Three scenarios—best case, expected case, worst case—give you a range of outcomes you can actually plan around. Odoo 19 doesn't ship with a built-in scenario engine, but it takes fewer than 100 lines to build one.
Building a Scenario Model
from odoo import fields, models
class CashFlowScenario(models.Model):
_name = "cash.flow.scenario"
_description = "Cash Flow Forecast Scenario"
name = fields.Char(required=True)
scenario_type = fields.Selection(
[("best", "Best Case"),
("expected", "Expected"),
("worst", "Worst Case")],
required=True,
)
# Adjustment factors (1.0 = no change)
inflow_factor = fields.Float(
string="Inflow Multiplier",
default=1.0,
help="1.0 = 100% of projected inflows collected. "
"0.85 = assume 15% of receivables are delayed "
"beyond the forecast window.",
)
outflow_factor = fields.Float(
string="Outflow Multiplier",
default=1.0,
help="1.0 = all payables paid on schedule. "
"1.10 = assume 10% cost overrun.",
)
delay_days = fields.Integer(
string="Collection Delay (Days)",
default=0,
help="Shift all receivable due dates forward "
"by this many days.",
)
extra_outflow = fields.Float(
string="One-Time Extra Outflow",
help="E.g. an equipment purchase or tax payment "
"expected in this period.",
)Automated Low-Cash Alerts
The whole point of forecasting is early warning. If your forecast shows you'll drop below a minimum cash threshold in the next 30 days, you need to know today—not when the bank rejects a payroll ACH. Here's how to wire up automated alerts:
from odoo import api, fields, models
from datetime import timedelta
import logging
_logger = logging.getLogger(__name__)
class CashFlowAlert(models.Model):
_name = "cash.flow.alert"
_description = "Low Cash Alert Configuration"
name = fields.Char(required=True)
threshold = fields.Float(
string="Minimum Cash Threshold",
required=True,
help="Alert when projected balance falls below.",
)
forecast_days = fields.Integer(
default=30,
help="How many days ahead to check.",
)
notify_user_ids = fields.Many2many(
"res.users",
string="Notify",
)
@api.model
def _cron_check_cash_alerts(self):
"""Called daily by ir.cron scheduled action."""
alerts = self.search([])
for alert in alerts:
forecast = self.env["cash.flow.forecast"].create(
{{
"date_from": fields.Date.today(),
"date_to": (
fields.Date.today()
+ timedelta(days=alert.forecast_days)
),
}}
)
# Check weekly buckets for threshold breach
current = fields.Date.today()
end = current + timedelta(days=alert.forecast_days)
balance = forecast._get_opening_balance(
forecast.journal_ids, current
)
while current <= end:
week_end = current + timedelta(days=7)
inflows = sum(
forecast._get_projected_inflows(
current, week_end
).mapped("amount_residual")
)
outflows = sum(
forecast._get_projected_outflows(
current, week_end
).mapped("amount_residual")
)
balance += inflows - outflows
if balance < alert.threshold:
self._send_low_cash_notification(
alert, current, balance
)
break
current = week_end
def _send_low_cash_notification(
self, alert, breach_date, projected_balance
):
"""Send Odoo Discuss notification to alert users."""
msg = (
f"⚠️ Cash flow alert: Projected balance "
f"drops to {{projected_balance:,.2f}} by "
f"{{breach_date}}, below your "
f"{{alert.threshold:,.2f}} threshold."
)
for user in alert.notify_user_ids:
user.partner_id.message_post(
body=msg,
message_type="notification",
subtype_xmlid="mail.mt_note",
)
_logger.warning(
"Low cash alert '%s': projected %s by %s",
alert.name, projected_balance, breach_date,
)Registering the Cron Job
<odoo>
<record id="cron_cash_flow_alert"
model="ir.cron">
<field name="name">Cash Flow: Low Balance Alert</field>
<field name="model_id"
ref="model_cash_flow_alert"/>
<field name="state">code</field>
<field name="code">model._cron_check_cash_alerts()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</odoo>Your minimum cash threshold should be at least 2x your monthly payroll. Payroll is your least negotiable obligation—you can delay vendor payments, renegotiate lease terms, or pause discretionary spending, but you can't miss payroll without losing your team. Two months of payroll as a threshold gives you enough runway to take corrective action before the situation becomes a crisis.
3 Cash Flow Forecasting Mistakes That Destroy Liquidity Predictions
Forecasting from the GL Balance Instead of the Bank Balance
Your general ledger says you have $340,000 in cash. Your bank says $285,000. The $55,000 difference is in-transit ACH payments, uncleared checks, and bank fees that haven't been recorded yet. If your forecast starts from the GL balance, every single projection is inflated by $55,000. You think you're safe for another month. In reality, you're 3 weeks from a cash crunch.
Always anchor your forecast to the last reconciled bank statement balance, not the GL balance. Configure daily bank synchronization and reconcile before running your forecast. The balance_end_real field on account.bank.statement is your source of truth.
Ignoring Recurring Expenses That Aren't in the AP Ledger
Your forecast pulls from open payables—invoices you've received and entered into Odoo. But your largest expenses don't start as vendor bills. Payroll, tax payments, loan installments, insurance premiums, and SaaS subscriptions often come as direct debits or scheduled transfers. They're not in your AP aging report. Your forecast shows healthy outflows. Then payroll, quarterly taxes, and the annual insurance premium all hit in the same week and your balance drops by $180,000 that the forecast never predicted.
Use Odoo's recurring journal entries (Accounting → Miscellaneous → Recurring Entries) to create scheduled placeholders for every non-invoice cash outflow: payroll, taxes, loan payments, insurance, and subscriptions. These entries appear in the forecast's outflow projections even though they don't originate from a vendor bill.
Using a Single Forecast Without Scenario Ranges
A forecast that says "you'll have $210,000 on April 15" gives a false sense of precision. That number assumes every customer pays on time, no unexpected expenses arise, and your sales pipeline converts at the historical rate. None of these assumptions will be exactly right. When the CFO trusts a single number and it's wrong by 30%, the organization loses confidence in the entire forecasting process.
Always present three scenarios: worst case (85% inflow collection, 110% outflows, +10 day collection delay), expected case (95% inflow, 100% outflows, historical delay), and best case (100% inflow, 95% outflows, no delay). Decisions should be made against the worst case. Budgets should be set against the expected case. Bonuses should be tied to the best case.
What Real-Time Cash Flow Forecasting Saves Your Business
Cash flow forecasting isn't a finance department exercise. It's a survival tool with measurable returns:
Automated alerts catch liquidity shortfalls 3-4 weeks before they hit, giving you time to accelerate collections, delay discretionary spending, or arrange a credit line while terms are favorable.
Companies with accurate cash flow forecasts reduce reliance on expensive short-term credit facilities. A $50,000 line of credit at 12% APR costs $500/month in interest that proper forecasting eliminates.
When you know your cash position with confidence, you can take early payment discounts (typically 2/10 Net 30). On $500K in annual payables, that's $10,000 in savings from paying 20 days early.
For a $5M revenue SMB, the combination of reduced emergency borrowing, captured early payment discounts, and avoided late payment penalties typically saves $35,000-$60,000 annually—from a tool that takes two days to configure and 10 minutes a day to maintain.
Optimization Metadata
Complete guide to Odoo 19 cash flow forecasting. Set up cash flow statements, configure AR/AP projections, integrate bank balances, run scenario planning, and automate low-cash alerts.
1. "Setting Up the Cash Flow Statement in Odoo 19"
2. "Configuring the Cash Flow Forecast Report in Odoo 19"
3. "3 Cash Flow Forecasting Mistakes That Destroy Liquidity Predictions"