← Building a solo company with AI agents

Part 9 of 40 · 7 min read

Invoice to cash: a payment is a fact, not a reassuring message

Experimental · Work in progress · Internal R&D

Move from a commercial commitment to invoicing, partial payment, duplicate receipt handling and an explainable outstanding balance.

Arunank Sharan · Kuzushi Labs ·

In this article

“Payment has been sent” is useful customer communication. It is not yet a reconciled receipt. A founder following up manually has to compare the message, invoice, bank or payment-provider record, partial payments and any dispute. This sub-loop is about organising that evidence and prompting the right next action.

In the current prototype, a payment amount can be recorded against an opportunity and an l2c.invoice.paid event emitted. The financial event bus can consume that event in a local test. Full invoice objects, partial settlement, live bank ingestion and collections scheduling are still extension work.

Keep invoice status and payment status separate

An invoice is a demand for payment under an agreement. A payment is a movement recorded by a provider or bank. An allocation says how much of that payment settles a particular invoice. Conflating the three makes partial payments and refunds surprisingly difficult.

For an example S$6,000 invoice with S$2,000 received:

Invoice face amount       S$6,000
Confirmed allocations     S$2,000
Outstanding balance       S$4,000
Collection state          PARTIALLY_PAID
Customer message          "Remaining transfer is scheduled"

The message is retained as context; it does not reduce the outstanding balance. The amounts are illustrative. The public example uses integer minor units for exact arithmetic and treats currency as part of every allocation.

The proposed reconciliation record

{
  "invoice_id": "invoice-017",
  "payment_id": "provider-payment-042",
  "allocation_minor": 200000,
  "currency": "SGD",
  "evidence_ref": "statement-entry-042",
  "match_basis": ["invoice_reference", "currency", "account"],
  "review_state": "CONFIRMED"
}

A partial match can be prepared automatically without being committed automatically. If the amount matches but the account does not, the operator needs a review. A payment received without an invoice reference can remain unallocated instead of being forced onto the nearest amount.

The current record_payment helper illustrates event wiring, but it writes a simplified paid status and does not enforce these allocation rules. I would introduce the invoice, payment and allocation records before adding a real bank connector.

Collections should react to fresh facts

Before sending a reminder, reload the invoice and payment evidence. A scheduled reminder created yesterday may be wrong after this morning's transfer. The action should bind to the current outstanding amount, channel permission, dispute state and last-contact time.

A concrete reminder policy can be expressed as a small decision table:

Current stateProposed next step
PaidCancel pending reminder
Partially paidPrepare a reminder for the remaining amount
DisputedRoute the disputed item for review
Promise to pay still within its dateWait; retain the promise as context
Overdue with no recent contactDraft a reminder under the agreed schedule
Payment outcome unknownReconcile before asserting non-payment

These are workflow design choices, not a universal collections policy. The founder chooses the customer relationship and commercial exceptions; the system keeps the facts and scheduled work consistent.

Handle duplicate and late events

One provider payment can arrive through a webhook and later appear in a statement import. Deduplication must recognise the same economic event across those sources. Deduplicating only the webhook delivery ID prevents repeated delivery handling, but not a second accounting entry from another source.

The durable operation should preserve the provider payment identity, allocation identity and journal reference. A correction should create an explicit adjustment or reversal rather than edit away the earlier record. If a charge is reversed later, the customer balance changes through a recorded new event.

The current financial bus is invoked explicitly in tests. Adding an automatic consumer requires event deduplication, transaction boundaries and reconciliation; it is not just a subscription declaration.

What the founder gets back

The useful daily view is short: overdue amount, expected receipts, ambiguous matches, disputed invoices and the messages awaiting approval. It should be possible to click an amount and see the evidence behind it.

I would initially measure unmatched-payment age, duplicate allocations, reminders sent after settlement and operator time per resolved exception. “The agent sent 50 reminders” is activity. “Every outstanding invoice has a correct next step” is the operational result this sub-loop is intended to produce.

Execute settlement in exact units

The reference rule accepts confirmed receipt identities, integer amounts and a currency. It deduplicates identical receipts and rejects changed reuse. That makes a partial payment calculation testable without pretending a customer message is a bank record.

from company_lab.business import outstanding, Review

receipts = [("payment-42", 200000, "SGD")]
assert outstanding(600000, "SGD", receipts) == 400000
assert outstanding(600000, "SGD", receipts * 2) == 400000
try:
    outstanding(600000, "SGD", receipts + [("payment-42", 210000, "SGD")])
except Review:
    pass  # same provider identity with a different amount needs investigation

This simple function covers one invoice and one currency. The full settlement model needs a payment account identity, allocation records, reversals, unallocated amounts and a transaction around balance changes. For a payment that covers three invoices, deduplicating by invoice alone would be wrong. The economic receipt exists once; three allocations refer to it.

Separate cash, fees and customer credit

Suppose a provider reports a gross customer payment of S$2,000 and deducts a fee before depositing cash into the company's bank account. The invoice allocation should use the gross amount paid by the customer, while provider fees and cash settlement appear in their own records. Allocating only the net bank deposit would incorrectly leave the customer short.

Likewise, a credit note can reduce the amount due without being a cash refund. A refund can reverse part of a prior payment. Keep these as linked operations rather than updating a single paid_amount field until it happens to match the current balance.

RecordExample identityMeaning
Customer paymentProvider account + payment IDGross payment confirmed by that provider
Invoice allocationPayment ID + invoice ID + allocation sequencePortion assigned to the invoice
Provider settlementBalance transaction or payout IDCash movement after fees and timing differences
Credit or reversalSeparate adjustment identityAn explicit change to the earlier obligation

Provider evidence creates one receipt, allocations update outstanding balance, and reminders use the current balance.

Build the connector around verified source events

The payment webhook receiver first validates the provider signature against the raw request body. It then durably records the event and returns promptly. A background worker resolves the connected account, retrieves any required current provider object and maps the event into the settlement model. Stripe documents duplicate and out-of-order delivery, so arrival order must not be treated as the payment lifecycle. Stripe webhooks.

The agent's role is narrower: explain ambiguous matches, prepare a collections message and identify missing evidence. It should not choose which bank entry to treat as settled merely because the customer's language sounds confident. Account and currency checks belong in the deterministic reconciliation path.

For statement imports, preserve file hash, row identity and source account. A row number alone is unstable if the file is regenerated. Use the provider's transaction identity where available and a reviewed fallback matching policy where it is not.

Make the reminder race reproducible

At 08:00, a scheduler drafts a reminder for S$4,000. At 08:03, a settlement worker allocates the remaining payment. At 08:05, the send worker wakes. It reloads the balance and cancels the reminder. The draft remains in history with a reason, rather than disappearing mysteriously.

Now change the scenario: the payment is only S$1,000. The old S$4,000 reminder is stale; the system prepares a revised S$3,000 message and applies the relevant approval policy. Reusing the old approved text would be a customer-facing error even though a reminder is still appropriate.

The operator view should group unmatched receipts, overdue invoices, disputed balances and uncertain provider outcomes separately. A single “collections pending” count hides which actions are safe. The implementation exercise is to process the same receipt through webhook and statement fixtures, then prove that the customer's balance changes once and the reminder queue reflects the new amount.