…il on total drift
QBO Automated Sales Tax rates an invoice by its ShipAddr. The push never sent
one, so QBO fell back to the company address (Vancouver, 8.9%) and re-rated a
Winlock job (8.0%): INV-00177-2 came out $15,125 for a $15,000 milestone and
left a phantom $125 balance (fixed by hand in QBO 2026-08-29).
- wa-tax.ts: export parseLocationText; add qbShipAddrFor() — street + WA zip
from the project location, else the client's structured address, else null
(no ShipAddr, the old behavior). Partial/prefixed addresses are never sent.
- quickbooks.ts: createQBMilestoneInvoice accepts shipAddr and sends ShipAddr.
- quickbooks-payments.ts / progress-billing.ts: pass the address; the QBO
grand total must match the milestone within one cent or the QBO invoice is
deleted and the push fails (was a console.warn on the milestone path, and
unchecked on the progress-billing path).
- tests/qb-ship-addr.test.ts: parser/fallback/zip-boundary cases.
Codex-reviewed, two rounds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019zwfh13xTH7W9qeqRrd7Sm
Why
QBO Automated Sales Tax rates an invoice by its
ShipAddr. The ProBuild→QBO milestone push never sent one, so QBO used the company address (Vancouver, 8.9%) and re-rated the Berg ADU job (Winlock, 8.0%): INV-00177-2 became $15,125 for a $15,000 milestone, leaving a phantom $125 balance. (That invoice was corrected by hand in QBO on 2026-08-29; this PR stops it recurring on the next non-Vancouver job.)What
qbShipAddrFor()inwa-tax.ts: street + WA zip from the project location (reusingparseLocationText), else the client's structured address, elsenull→ noShipAddr(previous behavior). Partial or project-name-prefixed addresses are never sent, since a wrong jurisdiction is worse than the company default. WA zip range 98001–99403.createQBMilestoneInvoicetakesshipAddrand sends it.pushMilestoneToQuickBooks,stageProgressBillingToQuickBooksCore) now require QBO's grand total to match the milestone within one cent; otherwise the QBO invoice is deleted and the push fails with a message pointing at the job-site address / tax rate. Previously aconsole.warnon the first path and no check on the second.Verification
tsc --noEmitclean;npm run test:unit566/566 (newtests/qb-ship-addr.test.ts, 7 cases).Line1, unsafe parser reuse, missing total invariant on the staging path) all addressed; round-2 items (tolerance, zip bounds) addressed. Not added: push-path serialization / QBO-mismatch integration tests (no QBO mock harness in the unit tier;tscenforces the wiring).Note
CLAUDE.mdstill says "lib/quickbooks.tssends pre-tax lines and lets QBO compute its own tax" — that's stale (the push has sentTxnTaxDetailsince the tax-line work); worth a one-line doc fix separately.🤖 Generated with Claude Code
https://claude.ai/code/session_019zwfh13xTH7W9qeqRrd7Sm