← All changelog entriesFinQMoney OutShipped

Bills: enter, match, approve and post what you owe

The first screen of Money Out. Bills are checked against the purchase order and what actually arrived, flagged if they look like a duplicate, and only reach the ledger once someone with authority has said yes.

YYash, Engineer, Navio Labs
Shipped
PR open, Oct 3 to Oct 11
8 days
Lines changed, tests included
~4,900
Specs passing at merge
1,720
Review findings fixed before merge
10 of 10

FinQ is our $9-a-month bookkeeping app for solo consultants. Until now most of the work went into Money In: invoices, payments and aging. Bills is the first screen of Money Out, and the one the rest of it depends on. The pay run, AP aging and the vendor ledger all assume the bills underneath them are real, matched and approved.

What shipped

  • Enter a bill by hand or from a purchase order. With an order, its lines, quantities and accounts carry over. You only type what’s on the vendor’s paper: their bill number, the date and the tax.
  • Three-way match. Each line compares what was ordered, what was received and what is being billed, and says in plain English what any difference means.
  • Approval when it matters. A bill needs a human when it’s over the vendor’s threshold, or when the match is off by more than the tolerance.
  • Duplicate guard. “This looks like a bill you already have” appears before you can pay it twice.
  • Posting only after approval. A bill hits the ledger once it’s approved, or once it’s clear it doesn’t need approval. Then it shows up in the pay run.
The Bills list in FinQ, grouped into drafts, waiting on approval, to pay and paid.
The Bills list: drafts, bills waiting on approval, bills to pay and paid bills in one place.
The new bill form with a vendor picker and an optional purchase order field.
Starting a bill. Pick the vendor and, optionally, the purchase order it bills against.
A Bay Packaging Co bill waiting on approval, with line items, a match table and a possible-duplicate warning.
One bill waiting on approval: five lines from a purchase order, one short shipment, one price drift inside tolerance, and a possible duplicate flagged on the right.

The rules, as built

These are the product decisions behind the screen. Most came straight out of the copy in the design mock. One line, “Rate is $0.06 over the order. $36.00 difference, inside tolerance”, told us there is a tolerance, that it’s measured in money and not only percent, and that $36 should pass.

RuleHow it works
Match toleranceThe smaller of $50 or 2% by default. Applied to the bill’s total over-billing, not line by line.
Drift inside tolerancePosts, with the difference going to a purchase price variance account.
Drift outside toleranceWaits for an owner or admin, even if approvals are off for that vendor.
Approval thresholdBills over the vendor’s threshold ($5,000 in the demo) need an owner or admin. Below it, entering and paying is one step.
Self-approvalYou can’t approve a bill you submitted if anyone else on the account could. A sole owner still can.
Account pickerBill lines can only post to expense or non-cash asset accounts. Bank, card, receivables, input tax and liabilities are refused and hidden.
Sent backReturns to draft with a required note. Nothing was posted, so nothing needs undoing.
Abandoned after send-backVoided, not deleted, so the approval history stays on record.
Foreign currencyCan be drafted, but posting refuses it with a clear message until exchange rates are designed.

The screen explains its own rules, because most solo consultants don’t know what a three-way match is and shouldn’t have to. Every match row has a “What it means” sentence, such as “Short-shipped 500, billed for what arrived, 500 still on order.” The approval panel says why it’s asking: “An approval chain of one person is theatre.”

The same bill seen by the person who entered it, showing a waiting message instead of approve buttons.
The same bill seen by Tom, who entered it. It’s over the threshold, so he sees “Waiting on an owner or admin” instead of the buttons.

Under the hood

The Bill, BillLine and BillApproval models already existed from earlier groundwork, so this was mostly behaviour, split into small services that each do one job:

  • Bills::ThreeWayMatch works out ordered, received and billed per order line. It only counts bills submitted before this one, so two bills against the same order don’t double-count each other.
  • Bills::DuplicateCheck finds the same vendor with a total in a small window, or the same bill number written differently.
  • Bills::ApprovalState decides whether a bill needs a human, and which one.
  • Bills::Submit, Approve, SendBack and Post handle the lifecycle.

FinQ’s ledger is append-only and double-entry, so posting is one balanced entry: debit each line’s expense or asset account, debit input tax credit where tax is recoverable, credit Accounts Payable. Nothing touches the books before approval.

Bills was the top of a stack of Money Out pull requests: vendors, orders and receipts, AP aging, recurring spend, fixed assets, then Bills. Each was reviewable on its own, at the cost of rebasing every low-level fix up through the stack.

What went wrong on the way

The spec was in the wrong place. The Money Out issues had their bodies shifted by one, so each issue’s spec sat in the next issue’s description. Work started against the wrong requirements before anyone noticed. Checking the issue against the mock is now the first step of every Money Out task.

Tests passed, the books were wrong. An early browser QA pass posted test bills against 1000 Business checking, a bank account. The entry balanced and every test passed, because nothing said a bill line couldn’t go to a bank account. That’s where the account-picker rule above came from. A balanced entry isn’t the same as a correct one.

Duplicates are fuzzier than a unique index. Vendors write INV-001 on one bill and inv 1 on the reminder, or BPC-2026-0042 and BPC20260042. The follow-up normalises bill numbers by dropping case and separators and trimming leading zeros from each run of digits, with the SQL and Ruby versions sharing one definition. Uniqueness in the database stays on the raw number. Normalisation only feeds the warning, so two genuinely different bills, like INV-001 and INV-1 from different years, can still be saved.

Anyone could approve their own bill. The first version let the submitter approve. That’s right for a one-person business and wrong with two admins, so the follow-up added the self-approval rule.

The bill awaiting approval in dark mode.
Everything ships in light and dark. Checking dark mode is part of the definition of done.

How it was checked

  • Full suite at 1,720 passing specs when Bills merged.
  • A code review pass before merge came back with 10 findings. All 10 were fixed before merging.
  • Browser QA as different people (a member and an owner), in light and dark mode, and at phone width. Both the bank-account posting and the self-approval gap were found this way, not by reading code.
  • Built with Claude Code as a pair. Yash wrote the issues, made the product calls, reviewed every change and did the browser QA.

Known gaps

  • Phone layout. The wide line-item and match tables scroll sideways inside their cards. It works, but it isn’t pretty.
  • Bills from captured documents. Creating a bill straight from a photo or PDF isn’t wired up yet.
  • Voiding a posted bill. Not supported yet. On an append-only ledger it needs a proper reversing entry, not a delete.
  • Foreign currency. Drafts only, until exchange rates are designed.
The bill at phone width with the duplicate warning at the top.
At phone width the duplicate warning moves to the top, so it’s the first thing you see.
A draft bill with no purchase order, where the match card explains there is nothing to match.
A draft with no purchase order. The match card says there’s nothing to match instead of showing an empty table.

What we’re carrying forward

  • Copy is a spec. When the mock’s text says something specific, the system has to make it true.
  • Block what’s definitely wrong, warn on what’s probably wrong. Posting to a bank account is blocked. A likely duplicate is a warning you can override.
  • QA as real people. Click through as each role, in both themes and at phone width, before calling it done.
Take the week-one bet