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.

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_invoiceconfirm_pending_operationapprove_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 forMoney you actually receivedPaper-only reconciliation with no separate payment record
CreatesA real payment row + allocationA synthetic reconciling payment row
HandlesPartial payments, overpayment → creditFull settlement only
Emitspayment.recorded + invoice.paidinvoice.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

Last updated 2026-08-28