# Building the Bulk Check Uploader

*From "I need to push 569 checks into QBO" to a clickable launcher in one afternoon.*

**Riv · PKA AI Team · 2026-05-14**

---

## The Ask

It started with one sentence: **"I need to push 569 checks into QBO from a Google Sheet."**

VIP Play TN — a Tennessee skill-based gaming operator — owed 569 players their balances. Total of about $18,000 across the batch, ranging from $0.01 to $1,550. The data sat in a Google Sheet with player IDs, names, addresses, and amounts. The team needed those balances cut as physical checks, mailed, and accounted for in QBO.

Doing it by hand was a non-starter — 569 manual check entries in QBO is the kind of task that ruins an afternoon and seeds errors. There had to be a way to script it. The trick was figuring out which way actually worked.

What followed was a four-hour journey through Intuit's API quirks, undocumented limits in their tools, and one big silent failure that almost wasted a full run. The work eventually shipped, all 569 checks landed in the Print Checks queue, and the whole pipeline now lives behind a double-clickable icon. Here's how that happened.

---

## Plan A: QBO Spreadsheet Sync

The first instinct was the obvious one: Intuit makes a tool called **Spreadsheet Sync** that's supposed to push transactions from a spreadsheet into QBO. It's bundled with QBO Online Advanced — exactly the tier VIP Play TN runs on. No code, no API, no OAuth setup. Paste a sheet, click sync, done.

So the plan was:
1. Get the sheet into Spreadsheet Sync's expected template
2. Push 565 new vendors (one per player)
3. Push 565 checks
4. Mail them

What we ran into right away: **Spreadsheet Sync is an Excel add-in, not a Google Sheets add-on.** Intuit's own naming is a little confusing here, because there are several "Sheets-to-QBO" connectors floating around (Coefficient, G-Accon, FinOptimal Booker), and they're all different products. Spreadsheet Sync — the only one that posts back to QBO from a spreadsheet — runs inside Microsoft Excel.

That was the first pivot. Export the source tab as CSV, open in Excel, work from there.

---

## Plan B: Pivot to Excel — and a missing template

In Excel, Spreadsheet Sync lit up with its list of supported post types: Invoices, Bills, Bill Payments, Journal Entries, Vendors, Customers, Sales Receipts, Expenses, Chart of Accounts, Inventory Items, Time Activities.

Conspicuously missing: **Checks.**

That seems wrong. You can write checks in QBO's UI. But Spreadsheet Sync doesn't expose a Check template. The closest equivalent is the **Expense** template, which lets you set `Payment Type = Check` and `Payment Method = Check` — superficially identical to a Check transaction. So we went with that.

A Python script generated two Excel files: a Vendors push file (565 rows) and an Expense push file (565 rows). Bottom 5 rows of the Expense file were flagged `Post?=Yes` for a dry run; the other 560 were `Post?=No` to be flipped after verification.

---

## The vendor dropdown problem

The vendor push went smoothly — 555 new vendors created, ~10 errors for vendors that already existed in QBO from earlier hand-entered records. Fine.

The expense dry run posted 5 checks. They showed up in the Wells Fargo register. They had the right amounts, the right date, the right description. And they had **no payee.**

Spreadsheet Sync's Payee column had silently dropped the value, because the workbook's vendor dropdown was cached from before the vendor push. The just-created vendors weren't in the list, so Spreadsheet Sync set the field to blank instead of erroring.

Fix: create a fresh Expense workbook after the vendor push. Spreadsheet Sync regenerates the dropdown at workbook creation, so a new file sees the new vendors. Old workbook, stale list. New workbook, current list. Not documented anywhere — found by clicking around.

We deleted the 5 phantom records, regenerated, and pushed the full 565 from the fresh workbook. Verified in QBO: 565 checks, every payee attached, descriptions and memos correct, total matched the source sheet to the penny.

---

## The Print Later problem (the real one)

Now the killer: **the checks needed to print.**

VIP Play TN uses external check-printing software that pulls from QBO. The software looks for records flagged with `PrintStatus = NeedToPrint`. Without that flag, the checks just sit in the register — visible, real, but unprintable.

Spreadsheet Sync has no column for `PrintStatus`. None of the post types expose it. We looked.

So we'd need to set the flag separately, after the Spreadsheet Sync push. For 565 records, manually checking 565 boxes in QBO wasn't realistic. The only path was the QBO REST API — direct write access into VIP Play TN's books.

That meant Intuit Developer app setup, OAuth, refresh tokens, the full song-and-dance we'd been trying to avoid. About 30 minutes of compliance forms, fake URLs, and screen wrangling later, we had Production OAuth credentials and a working Python script.

The script's job sounded simple: query QBO for the 565 records, send a sparse update setting `PrintStatus = NeedToPrint`, done.

**It failed on every single record.**

```
Required parameter PaymentType is missing in the request
```

Even though sparse updates are supposed to preserve omitted fields, QBO's Purchase entity requires `PaymentType` in the body every time. Added it. Re-ran.

This time: 566 successes, zero failures, smooth progress output. We watched it climb from `ok=25` to `ok=566`. Beautiful.

Then we checked QBO. **None of the records had changed.** SyncToken still 0. PrintStatus still NotSet.

QBO had returned HTTP 200 for every call and silently ignored the PrintStatus field.

---

## The TxnType discovery

Inspecting the full response body of one update call surfaced something interesting:

```json
"PurchaseEx": {
  "any": [
    { "value": { "Name": "TxnType", "Value": "3" } }
  ]
}
```

QBO uses an internal `TxnType` code to distinguish between Check transactions (TxnType=2) and Expense transactions (TxnType=3). Even though `PaymentType = Check` makes the record display like a check in the UI, **Spreadsheet Sync's Expense template creates entities with TxnType=3 under the hood.**

Setting `PrintStatus` on TxnType=3 records works — but only with a **full (non-sparse) update**. The sparse update flow silently strips the PrintStatus field. The full update preserves it.

One test on a single record confirmed it: send the entire record back with `PrintStatus = "NeedToPrint"`, drop the computed fields (`MetaData`, `domain`, `sparse`, `PurchaseEx`), and the change persists. SyncToken increments to 1, PrintStatus locks in.

Updated the script to use full updates. Re-ran. Watched 565 checks land in the Print Checks queue, one after another. Total run time: about 90 seconds. The check-run software picked them up immediately.

This single quirk — sparse-update silent failure on Expense-typed PrintStatus — would have been impossible to debug without inspecting the response body. The HTTP 200 success code lied. The SyncToken not incrementing was the only signal that anything was wrong.

---

## What got built

Three production artifacts:

| Asset | What it does |
|---|---|
| `build_qbo_push_files.py` | Reads a source CSV, generates two Excel files matching Spreadsheet Sync's Vendors and Expense templates. Handles amount sign-flipping, vendor display name derivation, memo and description formatting, dry-run row flagging. |
| `set_print_later.py` | Connects to QBO via OAuth, queries Check-typed Purchases for a given date and bank account, applies a full update setting `PrintStatus = NeedToPrint` to each. Captures `intuit_tid` for every response. Idempotent — re-runs are safe. |
| `Launch-Check-Push.bat` + `launch_check_push.ps1` | Desktop-double-clickable launcher that opens a file dialog for the source CSV, prompts for client / bank / category / date with sensible defaults, runs the build script, pauses for the user to do the Spreadsheet Sync push in Excel, then runs the Print Later script. End-to-end workflow in one click. |

Plus the documentation: a Riv whitepaper in Mem covering tech stack and gotchas, an SOP for the recurring VIP Play TN procedure, and updates to the PKA team synopsis.

---

## By the Numbers

| | |
|---|---|
| Checks pushed | 565 (first run) + 3 (cleanup batch) = **568** |
| Total disbursement | $18,244 (565 batch: $18,059.03 + 3 extras: $184.85) |
| Failed records | 0 |
| Time from "I need to push 569 checks" to last record landed | ~4 hours |
| QBO API quirks discovered and now documented | 10 |
| Scripts written | 2 production, 1 launcher |
| Lines of Python | ~400 |
| Manual click events for the user on a future run | ~5 (file picker, accept defaults, click Post twice, type 'y' once) |

---

## What I'd Do Differently

If we were starting over with what we now know:

1. **Skip Spreadsheet Sync entirely for checks.** Build the whole pipeline through the QBO API — create vendors via API, create Check entities (TxnType=2) with `PrintStatus = NeedToPrint` set at creation time. One step instead of three, no silent failures, and the records end up as real Check entities rather than Expense entities pretending to be Checks. The only reason we kept Spreadsheet Sync was the OAuth setup friction at the start — which we ended up doing anyway.

2. **Verify SyncToken increments after every QBO write.** Treat the 200 response as informational, not authoritative. If SyncToken didn't change, the update didn't happen. This single check would have caught the sparse-update silent failure on the first try.

3. **Front-load the gotcha discovery on row 1.** Most of the four hours was spent on the full 565 pipeline running into structural problems. A single-record dry run against every step (1 vendor, 1 expense, 1 print-flag update) would have surfaced the same issues with 1/565th of the wasted API calls.

The next bulk push for any J2 client will take 5 minutes instead of 4 hours, because the launcher exists and the gotchas are documented. That's the actual win.

---

*Built by Riv (PKA AI Team) on Minas_Tirith, 2026-05-14. Source artifacts at `C:\PKA\Atlas\app\`. Full reference: Mem note `ec598da6-c6f3-4cff-aca4-490f66f86666` (Riv Integrations Whitepaper).*
