Billing — Stripe, QuickBooks, and the RTOpacks Billing Engine¶
Status: Canonical billing reference. Supersedes finance-reference.md, qb-config.md, stripe-config.md, and briefs/finance-spec.md (all removed in Phase 5 consolidation, session 59).
Last verified: 2026-04-15 (session 59)
Built by: Alex (Claude Code), sessions 49–58
Companion docs: architecture/data-architecture.md, architecture/database.md, ops/standing-rules.md, workers/inventory.md
Read this in full before touching any billing code. Billing straddles four moving parts — the RTOpacks billing engine, Stripe, QuickBooks, and the reconciliation cron — and each has gotchas that will bite silently. Skimming for an answer is the failure mode that burned three sessions' worth of debugging time before this doc was written.
0. What this doc owns¶
- The billing data model. 10 tables in ops-db, plus the columns on
orgsandrto_clientsthat participate in billing. - The commercial model. What we sell, pricing, revenue types, GST, entitlements, dunning, grandfathering, refund policy.
- The Stripe integration. Subscription lifecycle, checkout, webhook, state machine, vendor gotchas.
- The QuickBooks integration. OAuth rolling token semantics, entity caching, push verification sequence, reconciliation worker, AU sandbox quirks.
- Operational runbooks. Reconnect QB, retry a stuck invoice, reset test data, quarterly BAS posture, production cutover checklist.
- Vendor volatility notes. Both Stripe and QB are live-moving APIs. The gotchas we've hit and the monitoring canaries that surface new breaks.
What this doc does NOT own:
- The worker chain pattern and failure modes → worker-patterns.md
- SQL conventions (ISO timestamps, ON CONFLICT writer contract) → ../architecture/database.md
- The QB OAuth re-auth procedure is summarised here but the authoritative operational runbook is ../ops/standing-rules.md. When they disagree, standing-rules wins.
1. Commercial model¶
1.1 What we sell¶
| Product | Type | Price (incl. GST) | Seats |
|---|---|---|---|
| RTOpacks Essential | Subscription | $399/mo | 1 |
| RTOpacks Pro | Subscription | $699/mo | 5 |
| RTOpacks Enterprise | Subscription | Custom | 100 |
| Additional Seat | Subscription add-on | $35/mo | +1 |
1.2 Not built yet¶
Single-course purchase, course bundle, SCORM storefront, InstaLearn library expansion, marketplace revenue share, AI credits top-up. Schema hooks exist (billing_purchases, revenue_type) but no UI or payment flow. Picked up under FINANCE-03 / FINANCE-04.
1.3 Revenue types (immutable)¶
Every financial event carries exactly one of:
- subscription — recurring plan payment
- one_off_purchase — single course, bundle, SCORM
- platform_fee — InstaLearn marketplace cut
- marketplace_fee — RTO-to-RTO content sales cut
New revenue streams get new types. Never reuse. Existing types are load-bearing for the ledger.
1.4 GST¶
- Flat 10% on all products (AU only at this stage)
- Calculated in-house:
ex_gst = total / 1.1,gst = total - ex_gst - Never use Stripe's automatic tax product. We compute, Stripe passes through.
- AU fiscal quarters: Q1 Jul–Sep, Q2 Oct–Dec, Q3 Jan–Mar, Q4 Apr–Jun.
getCurrentQuarter()inbilling.tshandles this — do not assume calendar quarters.
1.5 Policy decisions¶
Signup/trial: new orgs start on free tier. No automated trial on paid plans.
Cancellation: cancel-at-period-end. Access continues until period ends, then drops to free. No data deletion triggered by billing events. Long-term storage limits for cancelled orgs are deferred until production sizes are known.
Dunning: Stripe Smart Retries handle card failures (4 attempts over 14 days).
| Retry count | billing_dunning.status |
RTO experience |
|---|---|---|
| 0 | ok |
Normal |
| 1 | warning |
Orange banner — "Payment failed. Update card. Retrying in X days." |
| 3+ | restricted |
Red banner — read-only, AI features disabled |
| Sub deleted | cancelled |
Drops to free |
Payment success resets dunning to ok and restores access immediately.
Grandfathering: no automatic policy. Per-org price overrides via admin (billing_subscriptions.price_override_*). expires_at = NULL = indefinite. Admin note required on any override.
Refunds: manual by Tim in the Stripe dashboard. Refund webhooks flow through and write negative amounts to billing_ledger. No automated refund logic in RTOpacks.
Billing contact: orgs.billing_email overrides the owner's auth email. Surfaced as an editable field in the workspace billing UI.
2. Data model (ops-db)¶
10 billing tables live on ops-db. Participating columns on orgs and rto_clients are called out inline.
orgs (workspace DB, not ops-db)
├── billing_tier (free/essential/pro/enterprise)
├── billing_status (active/past_due/cancelled)
├── billing_email
├── billing_parent_id (FK → orgs.id, NULL = standalone) [not yet active]
├── billing_group_id (pooled seat/MRR aggregation) [not yet active]
└── jurisdiction (AU default, drives tax + QB routing) [not yet active]
billing_customers
├── org_id → orgs.id
├── stripe_customer_id (test_cust_* in Phase 1, real cus_* in Phase 2+)
├── qb_customer_id (populated on first QB push, re-linked on orphan detection)
├── billing_email
├── is_test (reset target flag)
├── stripe_payment_method_id
├── rto_code (indexed bridge to rto_clients and the admin client card)
├── card_brand (e.g. "visa", written by handleSubscribe + pm.attached webhook)
└── card_last4 (e.g. "4242")
billing_plans — 5 rows seeded (free, essential, pro, enterprise, seat_addon)
├── plan_tier
├── amount_ex_gst, gst_amount, amount_total (cents AUD)
├── base_seats
├── stripe_price_id (populated when Stripe Products are created)
└── superseded_by (price versioning)
billing_plan_entitlements — 32 rows seeded
├── plan_id → billing_plans.id
├── feature: corpus/calendar/radar/landscape/kn/studio/api_access/seat_limit
└── value: 'true'/'false' or numeric string
billing_subscriptions
├── org_id → billing_customers.org_id
├── plan_id → billing_plans.id
├── plan_tier (denormalised — always update both when changing plans)
├── status: active/trialing/past_due/cancelled/unpaid/paused
├── base_seats + purchased_seats = total seat limit
├── current_period_start/end
├── payment_method_token
├── cancelled_at, cancellation_reason
└── price_override_active/amount/expires_at/note
billing_invoices
├── org_id
├── invoice_number (RTP-0001, RTP-0002...) via billing_invoice_sequence
├── amount_ex_gst, gst_amount, amount_total (cents)
├── stripe_invoice_id, stripe_charge_id, stripe_invoice_url, stripe_fee_cents
├── qb_invoice_id, qb_payment_id, qb_payment_status
├── qb_sync_status: pending/synced/failed/payment_pending/abandoned
├── qb_sync_error, qb_synced_at
└── revenue_type
billing_ledger — append-only event log
├── event_type: subscription_payment/plan_upgraded/plan_downgraded/
│ subscription_cancelled/seat_added/qb_sync_failed/
│ qb_sync_recovered/qb_sync_abandoned/stripe_event_*
├── revenue_type
├── amount_ex_gst, gst_amount, amount_total (negative for refunds)
├── stripe_event_id (for webhook idempotency)
└── description (plain English, QB-ready)
gst_accrual
├── quarter: 2026-Q4 (AU fiscal)
├── gst_collected, gst_refunded, gst_net
├── invoice_count
└── remitted, remitted_at (set when BAS is lodged)
billing_dunning
├── org_id
├── status: ok/warning/restricted/cancelled
├── retry_count (1 = warning, 3+ = restricted)
└── first_failure_at, last_retry_at, resolved_at
billing_purchases — reserved for FINANCE-04 one-off purchases
billing_invoice_sequence — AUTOINCREMENT for RTP-XXXX numbering
rto_clients (not ops-db — workspace card surface)
├── rto_code (PK, 5-digit ASQA code)
├── is_client (flipped to 1 by admin convert or invoice.payment_succeeded)
├── is_test (reset-decovert target flag)
├── client_status (active/suspended/null)
├── plan
└── client_since, stripe_customer_id, stripe_subscription_id
2.1 Writer-contract notes¶
- All timestamps are ISO 8601 strings. See
database.mdConvention 1 — the SQLite lex gotcha has bitten four times. Billing writers must build timestamp filter cutoffs in JS as explicit ISO literals, never viadatetime('now', '-N minutes'). billing_invoice_sequenceis global and monotonic. Numbers never reuse, even after a test-data purge. Post-purge invoices continue from the last allocated number.- No cross-database foreign keys (Convention 4). The
rto_codebridge frombilling_customers→rto_clientsis logical, not enforced. - Always update both
plan_idandplan_tieronbilling_subscriptionswhen changing plans.plan_tieris the denormalised read path;plan_idis the source of truth via JOIN. - No cascade deletes.
test-resetdoes manual DELETE in order (dunning → ledger → invoices → subscriptions → customers → update orgs). If you add new billing tables, add them to the reset sequence.
2.2 D1 account gotcha¶
ops-db lives on account f95d453 (RTOpacks), not e5a9830 (UCCO Foundation). wrangler d1 execute ops-db --remote run from the project root hits the wrong account. Always cd apps/admin first — its wrangler.jsonc pins the correct account.
3. API surface (internal-api)¶
All billing endpoints live on rtopacks-internal-api at internal-api.rtopacks.com.au.
3.1 Subscription lifecycle (session auth)¶
POST /billing/subscribe { org_id, plan_id, payment_method_token }
POST /billing/cancel { org_id, reason? }
POST /billing/upgrade { org_id, new_plan_id }
POST /billing/downgrade { org_id, new_plan_id }
POST /billing/add-seat { org_id }
3.2 Read endpoints (session auth)¶
GET /billing/subscription ?org_id= → plan, sub, entitlements, dunning, invoices
GET /billing/entitlements ?org_id=&feature= → { granted, reason }
GET /billing/gate ?org_id= → seat limit + dunning check
3.3 Admin endpoints (caller.is_super required)¶
GET /billing/admin/overview → MRR, active subs, GST, unsynced QB count
GET /billing/admin/plans → all plans + entitlements
GET /billing/admin/gst → all quarters
GET /billing/admin/invoices ?limit=&qb_status=
PATCH /billing/plans/:id { is_active: 0|1 }
POST /billing/entitlements { entitlements: [{ plan_id, feature, value }] }
PATCH /billing/gst/:quarter marks quarter remitted
POST /billing/qb-retry { invoice_id }
POST /billing/qb-reconcile-now
GET /billing/qb-connect → 302 to Intuit OAuth
GET /billing/qb-callback → Intuit callback (CF Access bypass)
POST /billing/reset-test → nuke is_test=1 rows + decovert flagged orgs
GET /billing/admin/org/:rto_code → full billing picture for admin client card
3.4 Test endpoints (admin or ENVIRONMENT=test)¶
POST /billing/test-payment { org_id, plan_id, test_card }
4111111111111111 = success, 1111111111111111 = failure
POST /billing/test-reset { org_id } → wipes all billing state for org
3.5 Error codes¶
BILLING_NO_SUB no active subscription
BILLING_PLAN_NOT_FOUND plan doesn't exist or inactive
BILLING_ALREADY_SUBSCRIBED 409, use upgrade/downgrade
BILLING_WRONG_DIRECTION upgrade called with lower price or vice versa
BILLING_SEAT_NOT_ELIGIBLE free/essential can't add seats
BILLING_BAD_REQUEST missing required fields
4. Stripe integration¶
4.1 Account + secrets¶
- Account: RTOpacks production Stripe account.
- API version:
2026-03-25.dahlia(codename "Dahlia"; prior was "Alf"). Pinned inworkers/internal-api/src/lib/stripe.ts. - Secrets:
STRIPE_SECRET_KEYonrtopacks-internal-api. Publishable key used byapps/workspaceclient-side code. No Stripe keys are committed.STRIPE_WEBHOOK_SECRETrequired for Phase 2 webhook endpoint. - No Stripe secrets on
qb-reconcile— reconciliation never talks to Stripe.
4.2 Subscribe flow (webhook-authoritative)¶
RTO clicks Subscribe in workspace
→ /settings/billing renders Stripe Elements CardElement
→ user enters card, confirmCardSetup() with SetupIntent → payment_method_id
→ POST /api/billing/subscribe (workspace proxy, forwards X-RTP-Session)
→ POST /billing/subscribe (internal-api handleSubscribe)
→ billing_customers lookup (stripe_customer_id, rto_code)
→ attachPaymentMethod + setDefaultPaymentMethod
→ stripeCreateSubscription (charges card immediately)
→ billing_subscriptions INSERT (status from Stripe sub)
→ getPaymentMethodDetails → { brand, last4 }
→ UPDATE billing_customers SET stripe_payment_method_id, card_brand,
card_last4, rto_code = COALESCE(rto_code, orgs.rto_code)
→ UPDATE orgs SET billing_tier, billing_status='active'
→ billing_invoices INSERT (status='open', stripe_invoice_id=latest_invoice)
→ billing_ledger INSERT (subscription_created)
→ return to workspace
Stripe processes the charge → fires invoice.payment_succeeded webhook
→ POST /billing/webhook (internal-api, CF Access bypass)
→ verifyWebhookSignature (HMAC-SHA256 + replay check)
→ idempotency check via stripe_event_id in billing_ledger
→ handleInvoicePaymentSucceeded:
→ find local invoice by stripe_invoice_id
→ UPDATE billing_invoices SET status='paid', stripe_charge_id,
stripe_invoice_url
→ gst_accrual UPSERT (skipped for is_test=1)
→ billing_dunning UPDATE status='ok' (reset retry_count)
→ billing_ledger INSERT (stripe_payment_succeeded)
→ ctx.waitUntil(pushInvoiceToQB(local.id, env))
→ BILLING-CONVERT-01 auto-convert (skipped for is_test=1):
→ SELECT rto_code FROM billing_customers WHERE org_id = ?
→ if rto_code present:
UPSERT rto_clients (is_client=1, is_test=0, client_status='active',
client_since = COALESCE(client_since, now()))
→ if rto_code null: log warning, payment still succeeds, convert skipped
4.3 Webhook events we handle¶
invoice.payment_succeeded— the canonical paid signal; triggers the full paid-flow above.invoice.payment_failed— incrementbilling_dunning.retry_count, surface the banner state.customer.subscription.updated— plan changes, status transitions.customer.subscription.deleted— drops org to free, resets dunning tocancelled.payment_method.attached— keepsbilling_customers.card_brand/card_last4fresh on card updates.
Idempotency: every webhook handler checks billing_ledger for the incoming stripe_event_id before writing. Stripe retries aggressively; double-writes are a silent bug class.
4.4 Vendor volatility — Stripe¶
Stripe is actively refactoring. It is NOT stable plumbing. Treat every integration as a live moving target.
Concrete breaks during FINANCE-02 (session 49):
- current_period_start / current_period_end moved from the subscription top-level to sub.items.data[0].current_period_*. Code now reads from items[0] with top-level as fallback. If Stripe moves it again, subscribe crashes with "Invalid time value".
- latest_invoice returns string | object | null depending on expansion. D1 bindings require coercion: typeof x === "string" ? x : null.
- inv.charge, inv.subscription, inv.amount_paid in webhook payloads are string | undefined. Any undefined passed to D1 → D1_TYPE_ERROR. Coerce everything before the bind call.
- Stripe Link + US postal code auto-inject into CardElement. For AU cards: hidePostalCode: true, disableLink: true.
- Test-mode customer emails do not deliver to non-verified addresses. Don't chase "missing" invoice emails in test mode.
- The invoice PDF is Stripe-templated. It looks generic until Dashboard → Branding sets logo, brand colour, public business name.
Ongoing risk: any API version bump can silently relocate fields, rename properties, or change webhook payload shape. When picking up billing work, check the API version string in stripe.ts and be prepared for relocations.
Monitoring canary: the webhook handler. If customer.subscription.updated or invoice.payment_succeeded start 500'ing after a quiet period, suspect vendor-side schema change before assuming our bug.
4.5 Stripe fees (no automated posting)¶
Stripe returns fee amount on balance_transaction.fee. Store stripe_fee_cents on billing_invoices from the balance transaction. Do NOT post per-transaction fees to QB. Kevin receives a monthly Stripe tax invoice and posts a single aggregate expense to a "Stripe Fees" expense account (Expenses → Bank Charges detail type, created once at Phase 2 setup).
Rationale: per-transaction posting creates QB noise and doesn't match how Stripe invoices. Kevin reconciles monthly against Stripe's own tax invoice.
4.6 International card block¶
At Phase 2 Stripe setup, enable a Radar rule to block non-AU cards:
Location: Stripe Dashboard → Radar → Rules.
4.7 Chargeback evidence retention¶
RTOpacks has natural chargeback defences. Evidence retained per subscription:
| Evidence | Source |
|---|---|
| ToS acceptance timestamp | Auth flow (ops-db) |
| Subscription confirmation email | Resend send log |
| Usage logs | Session data (workspace DB) |
| Invoice PDF | Stripe-hosted |
Kevin does not action chargebacks. Tim retrieves evidence on dispute.
5. QuickBooks integration¶
5.1 Intuit app¶
| Field | Value |
|---|---|
| App name | RTOpacks QuickBooks Integration |
| App ID | 68b9483e-f6fa-44df-ada1-41aea3f58e68 |
| Environment | Development (sandbox) — production not yet approved |
| Scope | com.intuit.quickbooks.accounting |
| Redirect URI (prod) | https://internal-api.rtopacks.com.au/billing/qb-callback |
| Redirect URI (local) | http://localhost:8787/api/integrations/quickbooks/callback |
| Sandbox company ID | 9341456854400409 |
5.2 Secrets¶
Stored as Wrangler secrets on both rtopacks-internal-api and qb-reconcile:
QB_CLIENT_ID
QB_CLIENT_SECRET
QB_COMPANY_ID 9341456854400409 (sandbox)
QB_REFRESH_TOKEN generated via /billing/qb-connect on 2026-04-11
5.3 Token lifecycle — rolling, not fixed¶
This is the most-mistaken thing in the billing system. Read it carefully. It supersedes any older doc that said "100-day expiry."
| Token | Expiry | Handled by |
|---|---|---|
| Access token | 1 hour | Auto-refreshed, cached in KV (qb-access-token, TTL 55 min) |
| Refresh token | Rolling 100 days | Stored in KV (qb-refresh-token) + Wrangler secret fallback |
The refresh token rolls. Every time we use it to mint a new access token, Intuit returns a new refresh token AND resets the 100-day clock. As long as the system makes any QB API call within 100 days, the chain renews itself forever. In active production, this is set-and-forget.
Critical clarification: the 100-day clock resets on Intuit's server side on every successful refresh call — regardless of whether Intuit returns a new refresh_token string or the same one. The HTTP call is what matters, not the string rotation. Do not add "did the string change?" logic.
Manual re-auth is only required when: 1. The system goes 100+ days completely idle (no subs, no payments, no reconciliation). Effectively never in production — see §5.4 heartbeat. 2. Intuit portal settings change (scopes, redirect URI, environment switch). Rare deliberate action. 3. Sandbox-only: Intuit resets/rebuilds the sandbox company or invalidates auth bindings out-of-cycle. This happened repeatedly during active development in sessions 49–50 and is the main reason the token chain died in sandbox.
5.4 Heartbeat (QB-HEARTBEAT-01)¶
qb-reconcile runs daily at 20:00 UTC (06:00 AEST). Every invocation runs two things in order:
-
Heartbeat.
refreshAndStoreQBToken()unconditionally exchanges the refresh token for a new access token, then writes BOTH the new access token (qb-access-token, 55 min TTL) AND the returned refresh token (qb-refresh-token, no TTL) back to SESSION_KV. This is the critical step that keeps the rolling window alive regardless of whether there are invoices to sync that day. -
Reconciliation. If the heartbeat succeeded, query
billing_invoicesfor rows withqb_sync_status IN ('failed', 'pending', 'payment_pending')from the last 7 days and retry the full push pipeline. Three statuses: failed— full retry from step 1payment_pending— payment-only retry (invoice already exists in QB)pending— full push- Abandons after 3 consecutive failures (
qb_sync_abandoned).
If the heartbeat fails, the reconciliation loop is skipped — no point trying to push without a valid token. Next day's cron retries.
Why this exists: before QB-HEARTBEAT-01 (session 51), the cron only reconciled invoices. An idle day left the QB token untouched and the 100-day clock ticking toward death silently. The heartbeat makes the refresh unconditional so an idle billing system (no paying clients, no failed pushes) stays alive indefinitely.
Manual trigger for verification:
Expected: {"status":"ok","synced":N,"failed":0,"abandoned":0,"heartbeat":true}.
wrangler cron trigger was removed in wrangler 4.x, so the /_test HTTP endpoint is the canonical manual-exercise path.
5.5 QB push — verification sequence¶
Every push follows 8 verified steps:
- Token — get/refresh access token
- Accounts — resolve and verify cached entity IDs (re-query on 404)
- Customer — find or create, update with current org + NRT data
- Invoice POST — create invoice in QB
- Invoice GET — verify amounts match what we sent
- Payment POST — create payment linked to invoice
- Invoice GET — confirm balance = 0 (paid)
- Write synced — only now mark
qb_sync_status = 'synced'
On failure at any step: set qb_sync_status = 'failed' or 'payment_pending', write to billing_ledger, reconciliation worker retries.
5.6 Customer object — what works¶
{
"DisplayName": "Trading name",
"CompanyName": "Legal name",
"GivenName": "CEO first name",
"FamilyName": "CEO last name",
"PrimaryEmailAddr": { "Address": "email" },
"PrimaryPhone": { "FreeFormNumber": "phone" },
"WebAddr": { "URI": "website" },
"BillAddr": {
"Line1": "street",
"City": "suburb",
"CountrySubDivisionCode": "state code",
"PostalCode": "postcode",
"Country": "Australia"
},
"PrimaryTaxIdentifier": "ABN as 11 digits no spaces",
"PaymentMethodRef": { "value": "queried ID" },
"SalesTermRef": { "value": "queried ID" },
"DefaultTaxCodeRef": { "value": "queried ID" },
"Notes": "RTO: code — Legal: name — ABN: formatted — ACN: number — entity type — Auto-managed by RTOpacks.",
"PreferredDeliveryMethod": "None"
}
Customer data source priority: (1) orgs table for org name/email/phone/address. (2) rtos table (rtopacks-db NRT data) for legal_name, trading_name, ABN, ACN, CEO details, website, entity_type. (3) Fallback: "Org {orgId}". NRT lookup uses rto_code column (not code).
Orphan handling: when billing is reset locally but the customer exists in QB (qb_customer_id is null), the push searches QB first by "Org {orgId}" (old default), then by the new DisplayName, then re-links. QB customers cannot be deleted, only deactivated — always find + update.
5.7 Invoice object¶
{
"CustomerRef": { "value": "qb_customer_id" },
"DocNumber": "RTP-XXXX",
"TxnDate": "YYYY-MM-DD",
"DueDate": "YYYY-MM-DD",
"GlobalTaxCalculation": "TaxExcluded",
"SalesTermRef": { "value": "due_on_receipt_id" },
"Line": [{
"DetailType": "SalesItemLineDetail",
"Amount": 362.73,
"Description": "RTOpacks Essential — April 2026",
"SalesItemLineDetail": {
"ItemRef": { "value": "services_item_id" },
"TaxCodeRef": { "value": "gst_tax_code_id" },
"UnitPrice": 362.73,
"Qty": 1
}
}],
"PrivateNote": "Auto-generated by RTOpacks billing engine. Invoice RTP-XXXX. Do not edit manually."
}
5.8 Payment object¶
{
"CustomerRef": { "value": "qb_customer_id" },
"TotalAmt": 399.00,
"TxnDate": "YYYY-MM-DD",
"PaymentMethodRef": { "value": "credit_card_method_id" },
"PaymentRefNum": "RTP-XXXX",
"PrivateNote": "Stripe charge {charge.id} | {brand} {last4} | {date} AEST | RTOpacks ref: RTP-XXXX",
"Line": [{
"Amount": 399.00,
"LinkedTxn": [{ "TxnId": "qb_invoice_id", "TxnType": "Invoice" }]
}]
}
PaymentRefNum = RTP-XXXX. PrivateNote carries the Stripe charge ID, brand, last4, and AEST date once Stripe is live.
5.9 Plain English description format (ledger + QB)¶
Examples:
- "RTOpacks Essential — monthly subscription, 1 seat, April 2026"
- "RTOpacks Pro — monthly subscription, 5 seats, April 2026"
- "Additional seat — RTOpacks Essential, April 2026"
Never use internal IDs, SKUs, price IDs, or system references in any customer-facing or QB-facing text. Kevin reads the ledger and the QB invoice. Both must be intelligible without a lookup.
5.10 GST handling (critical)¶
| Approach | Result |
|---|---|
| Two lines (subtotal + GST line) | Rejected: "Make sure all your transactions have a GST rate" |
TaxCodeRef: { value: "TAX" } |
Rejected: tax code "TAX" doesn't exist |
GlobalTaxCalculation: "NotApplicable" + two lines |
Works but GST doesn't post to BAS Payable |
GlobalTaxCalculation: "TaxExcluded" + queried TaxCodeRef |
Correct. QB calculates GST, posts to BAS Payable |
The correct approach: single service line, GlobalTaxCalculation: "TaxExcluded", amount is ex-GST, TaxCodeRef set to the queried GST tax code ID. QB calculates 10% GST automatically and posts to BAS Liabilities Payable.
5.11 Required QB entity IDs (queried once, cached in KV)¶
| KV Key | What | Query |
|---|---|---|
qb-taxcode-gst-id |
GST tax code ID | SELECT * FROM TaxCode WHERE Name = 'GST' |
qb-item-services-id |
Services item ID | SELECT * FROM Item WHERE Name = 'RTOpacks Services', then Services, then create |
qb-paymentmethod-cc-id |
Credit Card method ID | SELECT * FROM PaymentMethod WHERE Name = 'Credit Card' |
qb-term-due-on-receipt |
Due on receipt term ID | SELECT * FROM Term WHERE Name = 'Due on receipt' |
qb-account-income-id |
Income account ID | SELECT * FROM Account WHERE AccountType = 'Income' MAXRESULTS 1 |
qb-access-token |
OAuth access token | Auto-refreshed, 55 min TTL |
qb-refresh-token |
OAuth refresh token (KV fallback) | Set by OAuth callback + heartbeat |
Item resolution order: RTOpacks Services → Services → create RTOpacks Services. Never pick up random sandbox items like "Employee Celebration" — a generic Type = 'Service' query will match them.
5.12 QB field gotchas (proven in debugging)¶
- ABN uses
PrimaryTaxIdentifier— 11 digits no spaces.TaxIdentifieris rejected as "unsupported property" even though docs imply it's valid.BusinessNumberis accepted but the value is not stored (returns null on read). QB UI masks the ABN asXXXX8872535. - Customer updates require
SyncToken. Every QB entity has aSyncTokenthat increments on each update. GET the current record, extractSyncToken, include it in the update POST. Without it: concurrency error. PaymentMethodRefandSalesTermRefrequire IDs, not names. These are dropdowns in QB. Query the API for the ID and pass{ "value": "3" }, not{ "value": "Credit Card" }.- Customer create is not idempotent. Returns "Duplicate Name Exists Error" (code 6240) on re-run. Must query by DisplayName first and re-link.
- QB customers cannot be deleted, only deactivated. After a billing reset, the push must find + re-link existing customers, not try to create new ones.
5.13 Vendor volatility — QuickBooks¶
QB Online is a work in progress, particularly on the AU side. Treat it as a live, evolving API.
AU-specific quirks:
- No GST account by default. AU sandbox ships without GST enabled. Tax codes are empty until GST is manually enabled in QB UI: Settings → Accounts and Settings → Advanced → GST. qb-push.ts has a GST line fallback for when taxCodeId is null, but this produces a "not applicable" line rather than using the native code. Re-enable GST in the UI whenever sandbox is reset.
- ABN lives on PrimaryTaxIdentifier, not TaxIdentifier. Docs are wrong.
- The sandbox /reset endpoint does NOT exist. Returns "Unsupported Operation". Sandbox reset is manual via the Intuit portal only.
Not AU-specific but cost us time:
- Customer create "Duplicate Name Exists" (6240) instead of idempotent behaviour — see §5.12.
- Generic Type = 'Service' item queries match sandbox noise.
- Token expiry is a standing ops concern — see §5.3 + standing-rules.
Ongoing risk: when picking up billing work, verify field names with a direct curl before trusting the docs, particularly for tax or identity fields. Assume the field may have been renamed or disabled-by-default in AU before assuming our code is wrong.
6. Workspace + admin surfaces¶
6.1 Workspace billing UI (L4)¶
Billing overview: current plan name, seat count (used / total), next billing date + amount (incl. GST), status with visual treatment, CTAs based on status.
Plan selection: all tiers with pricing (incl. GST), current plan indicated, upgrade incentive calculator when additional seats are purchased, upgrade flows immediately (Stripe proration), downgrade takes effect next cycle.
Invoice history: invoice number, period, amount, status, PDF download (new tab). At least 12 months visible.
Payment method: last 4 digits + expiry + card brand. "Update card" via Stripe.js — card never touches RTOpacks.
Billing contact: current billing email, edit in place.
Status visual treatments:
| Status | Treatment |
|---|---|
| Active | No banner |
| Warning (dunning) | Orange banner, persistent, dismissible per session only |
| Restricted (dunning) | Red banner, non-dismissible, CTA to update card |
| Cancelled | Informational — "Your account is on the free plan" |
| Trial | Informational with trial end date |
6.2 What GET /billing/subscription exposes to the workspace¶
{
"plan_tier": "essential",
"status": "active",
"dunning_status": "ok",
"base_seats": 1,
"purchased_seats": 0,
"seat_limit": 1,
"active_members": 1,
"current_period_end": "2026-05-11",
"amount_next_ex_gst": 36273,
"amount_next_total": 39900,
"cancel_at_period_end": false,
"payment_method": { "last4": "4242", "brand": "visa", "exp_month": 12, "exp_year": 2027 },
"billing_email": null
}
All amounts in cents AUD. UI divides by 100 for display.
6.3 Admin surface¶
Admin needs to:
- Monitor MRR, churn, failed payments, GST accrual
- Look up any org's billing status (client card at /billing/admin/org/:rto_code)
- Apply price overrides (grandfathering, promos, comps)
- Manually set plan tier (Enterprise, support cases)
- Trigger QB sync and view sync errors
- Reconnect QuickBooks when the sandbox dies
Admin does NOT: - Process payments (RTO-initiated from workspace) - Issue refunds (Tim in Stripe dashboard directly) - Manage Stripe products/prices
6.4 Entitlement model¶
Entitlements are enforced by RTOpacks, not Stripe. The system checks orgs.billing_tier and billing_plan_entitlements for feature access.
Seat enforcement:
seat_limit = base_seats + purchased_seats
if active_members >= seat_limit → reject L4A token issuance (402)
Server-side at token issuance. No client-side feature flags.
EntitlementGate fails open. The <EntitlementGate feature="radar"> component catches network errors and renders content. This is intentional — a billing API outage shouldn't lock paying RTOs out.
7. Operational runbooks¶
7.1 Reconnect QuickBooks (sandbox rebuild, portal config change)¶
The authoritative runbook is ../ops/standing-rules.md. Summary:
- admin → Finance → QuickBooks tab → amber "Reconnect QuickBooks" button (top, next to the stats cards).
- Browser bounces via CF Access → admin worker → service binding to
internal-api /billing/qb-connect.handleQBConnectwrites CSRF state to KV and returns 302 to Intuit. - Sign in to Intuit, pick the company, accept scopes.
- Intuit redirects to
/billing/qb-callback(CF Access bypass list). Callback writes freshqb-access-token(55 min TTL) andqb-refresh-token(no TTL) to SESSION_KV. - Callback returns a plain-text page showing the new refresh token + company ID. Informational only — the KV write already happened. Ignore the "store as QB_REFRESH_TOKEN" wording; it's stale from when the flow was manual.
- Go back to Finance → QuickBooks and click Retry on any invoices stuck at
failedwith[step1_token] QB token unavailable.
Belt-and-braces (optional but recommended): rotate the Wrangler secret after reconnect so the fallback path is fresh.
cd workers/internal-api && npx wrangler secret put QB_REFRESH_TOKEN
cd ../qb-reconcile && npx wrangler secret put QB_REFRESH_TOKEN
Paste the token into stdin when prompted — never into chat, commit, or file. getOrRefreshToken prefers KV first, so this is insurance.
7.2 Debug a stuck invoice¶
- Check
qb_sync_status.billing_invoices.qb_sync_statustells you the phase:pending,payment_pending,failed,synced,abandoned. - Read
qb_sync_error. Concrete error from the last failed push step.[step1_token] QB token unavailable→ token problem → reconnect.[step3_customer] Duplicate Name Exists→ orphan customer → the find-and-relink path didn't match — check the DisplayName. - Check
billing_ledger. Filterevent_type = 'qb_sync_failed'for the full failure history on that invoice. - Retry manually.
POST /billing/qb-retry { invoice_id }pushes a single invoice through the full pipeline. Or click Retry in admin → Finance → QuickBooks. - Force full reconciliation.
POST /billing/qb-reconcile-nowruns the reconcile loop synchronously from a different path than the cron.
7.3 Reset test data¶
POST /billing/reset-test wipes all is_test=1 billing rows and decoverts flagged rto_clients. The reset snapshots rto_codes from three sources before wiping:
rto_clients.is_test = 1— convert-only orgs (flagged by Test Tools convert)billing_customers.is_test = 1— setup-intent ranbilling_subscriptions.is_test = 1joined to billing_customers — full sub flow
Union of all three is decoverted:
UPDATE rto_clients
SET is_client = 0, is_test = 0, client_status = NULL, plan = NULL
WHERE rto_code IN (...) AND is_client = 1
Response: { orgs_decoverted, decoverted_rto_codes }. NRT data (legal_name, ABN, contacts, scope) is never touched — only billing/client state.
Critical: real-path conversions from invoice.payment_succeeded write is_test=0, so reset never touches them. A real paying client survives every reset. To manually undo a real-path conversion (e.g. a test run that accidentally hit a real rto_code), use the Decovert RTO button in admin Finance → Test Tools → POST /api/admin/organisations/[code] with action=decovert.
7.4 Quarterly BAS posture¶
gst_accrual is the single source of truth for BAS. Query by quarter:
When BAS is lodged: PATCH /billing/gst/:quarter sets remitted = 1, remitted_at = now(). The column is idempotent — re-lodgement just bumps remitted_at.
7.5 Production cutover checklist (Stripe + QB live keys)¶
- Intuit app review submitted and approved (2–4 weeks)
-
QB_ENVIRONMENTsecret changed fromsandboxtoproductionon internal-api + qb-reconcile - All KV cache entries cleared (entity IDs differ between sandbox and production)
-
QB_COMPANY_IDupdated to production company ID - New
QB_REFRESH_TOKENgenerated via OAuth flow against production - Kevin confirms chart of accounts structure matches sandbox (income account, GST account)
- Kevin confirms "RTOpacks Services" item, "Credit Card" payment method, "Due on receipt" term exist
- Test invoice pushed and verified in production QB
- Sandbox test data purged (FINANCE-01 purge script)
- Stripe Products and Prices created for each tier in AUD
- Stripe webhook endpoint registered in Stripe dashboard,
STRIPE_WEBHOOK_SECRETstored - ABN and ACN confirmed with Kevin, entered in Stripe
- RTOpacks logo + brand colour uploaded to Stripe
- Invoice template configured and previewed — Tim approves layout
- GST registration current
- Radar rule active: block non-AU cards
- Test mode end-to-end: subscribe → pay → invoice → QB push verified
- Dunning flow tested in Stripe test mode
8. File map¶
workers/internal-api/src/
billing.ts — all billing endpoint handlers
lib/stripe.ts — Stripe client, API version pin
lib/qb-push.ts — QB token management, push pipeline
index.ts — route wiring (search for "Billing" block)
workers/qb-reconcile/
src/index.ts — daily heartbeat + reconciliation
wrangler.jsonc — cron: 0 20 * * * UTC (6am AEST)
apps/admin/app/
finance/page.tsx — admin billing panel (6 tabs)
api/admin/billing/[...path]/route.ts — proxy to internal-api
components/sidebar.tsx — Finance nav
apps/workspace/app/
(workspace)/settings/billing/page.tsx — RTO billing page
(workspace)/components/entitlement-gate.tsx
(workspace)/components/dunning-banner.tsx
api/billing/[...path]/route.ts — proxy to internal-api
scripts/migrations/
2026-04-11-finance-01a-billing-schema.sql
2026-04-11-finance-01b-plans-schema.sql
2026-04-11-finance-01c-entitlements-seed.sql
2026-04-12-billing-card-01-customer-columns.sql
2026-04-12-billing-card-01-rto-clients-is-test.sql
9. Deferred decisions¶
| Decision | Reason deferred | Owner |
|---|---|---|
| Free tier feature set | Not yet scoped | Tim |
| Storage limits for cancelled orgs | Need production data size baseline | Tim + Alex |
| Credit billing (AI compute) | Cost basis unknown until Studio runs in production | Tim |
| One-off course purchases | FINANCE-04 — shape understood, not built | Tim + Alex |
| Annual pricing option | Not prioritised | Tim |
| Tax handling outside AU | Not relevant yet; schema has orgs.jurisdiction reserved |
Tim + Kevin |
| Marketplace revenue share | Significant scope expansion — future | Tim |
10. Related docs¶
architecture/database.md— SQL conventions that all billing writers must respect (timestamp lex gotcha, writer contract, bulk-delete safety)architecture/data-architecture.md— which database owns which tableops/standing-rules.md— the authoritative QB re-auth runbook + rolling-token semanticsworkers/inventory.md— canonical inventory forrtopacks-internal-apiandqb-reconcileworker-patterns.md— chain-dispatch and worker failure modes (billing uses HTTP + webhooks, not the queue chain, but read this before adding any async billing worker)