# Record a cash payment taken on site

> A draft invoice cannot take a payment — issue it first, then record the cash with propose_record_payment and paid_on_site: true, dated the day you were actually paid. Use mark_invoice_paid only for paper-only reconciliation where no payment row will ever arrive from elsewhere, because it writes its own synthetic payment and will double-count against an imported one.

URL: https://biloh.com.au/docs/how-to/record-a-cash-payment-taken-on-site
Category: How-to guides | Audience: operator | Updated: 2026-08-28

## The situation

Some jobs get paid the old way. You print the invoice, take it to the counter, and walk out with cash. Nothing was emailed and nothing was clicked, but the money is real and the books need to know.

## A draft invoice cannot take a payment

This is the step people trip on. `propose_record_payment` requires the invoice to be `sent`, `partially_paid`, `overdue` or `disputed`. A `draft` invoice is rejected — a draft has not been issued, so there is nothing yet for a payment to settle.

Issue it first through the normal chain (`propose_send_invoice` → `confirm_pending_operation` → `approve_send_invoice`). The invoice transitions to `sent` and the audit trail is intact.

**Check the recipients array before you approve.** You are issuing a document the client already holds on paper; a duplicate email may be unnecessary or confusing. If the resolved recipient is an internal address, the send transitions state without reaching the client, which is what you want here. If it resolves to the client, decide deliberately whether a second copy in their inbox helps.

## Record the cash

```
propose_record_payment(
  invoice_id,
  amount_cents,
  payment_date: "YYYY-MM-DD",   // the day you were actually paid
  payment_method: "cash",
  paid_on_site: true,
  notes: "..."
)
approve_record_payment(operation_id, confirmed: true)
```

`paid_on_site: true` marks it as cash collected at the job rather than a banked receipt, which keeps it distinguishable during reconciliation.

Two optional guards worth using: `expected_invoice_total_cents` asserts against the invoice total and fails loudly on a stale figure, and `external_reference` gives you idempotency — re-recording with the same reference returns the existing payment instead of creating a duplicate.

The propose leg returns its reasoning before anything is written: `will_transition_to`, `outstanding_before_cents`, `allocation_cents`, `overpayment_overflow_cents`, and the list of `gate_checks_passed`. Read it. An overpayment does not fail — the excess mints a client credit.

## Date it when the money moved, not when you typed it

Finance Engine Principle 3: real-world financial event dates are entered explicitly and never default to today. Cash taken yesterday and entered this morning is dated **yesterday**. `payment_date` is required for exactly this reason — there is no default to fall back on.

Get this wrong and the payment lands in the wrong reporting period, which surfaces later as a reconciliation difference nobody can explain.

## `record_payment` or `mark_invoice_paid`?

Both end with the invoice reading `paid`. They are not interchangeable.

| | `record_payment` (propose + approve) | `mark_invoice_paid` |
|---|---|---|
| Use for | Money you actually received | Paper-only reconciliation with no separate payment record |
| Creates | A real payment row + allocation | A **synthetic** reconciling payment row |
| Handles | Partial payments, overpayment → credit | Full settlement only |
| Emits | `payment.recorded` + `invoice.paid` | `invoice.paid` |

`record_payment` is the canonical path — use it by default, including for cash.

The trap with `mark_invoice_paid` is double-counting. It writes its own synthetic payment. If the same receipt is later imported from an external ledger, you now hold two payments against one invoice. Reserve it for receipts that will genuinely never arrive from anywhere else: a hand-reconciled cheque, a payment predating the system.

## Verify

Re-read with `get_invoice` and assert two things, not one:

- `status: "paid"`
- `outstanding_cents: 0`

Status alone can be misleading if allocations went sideways. The outstanding figure is refund-aware and credit-aware, so it is the number that tells the truth.

Any linked contractor payables cascade asynchronously once the invoice is paid.

## Related

- [Invoice send: gates and guards](/docs/reference/invoice-send-gates-and-guards)
- [Triage a draft invoice queue after a billing cutover](/docs/how-to/triage-invoices-after-a-billing-cutover)
