Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
9.0 KiB
PRD: QBO Closed-Loop Sync — Slice 1
- Status: Draft
- Owner: Robert Isaacs
- Created: 2026-06-11
- Design:
./design.md(architecture authority; this PRD details slice 1) - Branch: feature branches off
release/qbo-online-integration
1. Problem statement & user value
Phase 1 shipped a one-way, operator-driven invoice push to QuickBooks Online. The moment a client pays an invoice in QBO, Alga is wrong: AR status, the client portal, and collections all show unpaid until someone reconciles by hand. There is also no automation (an operator must remember to create export batches), no per-invoice answer to "is this in QuickBooks?", and a dead connection is discovered only when an export fails.
Slice 1 closes the loop for the highest-value flows: a scheduled sync engine pulls QBO payments into Alga as real AR records, pushes newly finalized invoices out automatically, detects drift on exported invoices, surfaces sync state on every invoice, and warns billing admins before the connection breaks.
2. Goals (slice 1)
- A per-tenant×realm sync cycle runs every ~15 minutes unattended, on Temporal
or pg-boss via the
IJobRunnerabstraction, identically on hosted and appliance. - Payments recorded in QBO (including edits and deletions) are applied to Alga
invoices within one cycle:
invoice_paymentsrow,paymenttransaction, status flip topaid/partially_applied. - Finalizing an invoice auto-enqueues its export; the cycle delivers it through
the existing batch pipeline (auto-batches,
origin: 'scheduled'). - Exported invoices changed or voided in QBO are flagged as drift with re-export / accept resolutions.
- Every invoice shows a sync badge (
Not synced | Queued | Synced | Drift | Error | Voided) with a deep link into QBO; the QBO settings page shows cycle health and pending work. - Refresh-token expiry and auth failures notify billing admins (14/7/2-day countdown; immediate on failure).
- Sync exceptions land in the existing workflow-task inbox, deduplicated to one open task per entity+type.
3. Non-goals (slice 1)
- Credit memo export, credit reshape, voids (slice 2 — outlined in §9).
- Customer mapping UI, reconciliation wizard, go-live cutoff (slice 3).
- Outward Stripe-payment push, class/department tracking, multi-realm UX, Intuit webhooks (slice 4).
- Importing QBO-originated documents;
RefundReceipthandling (surface only). - Monitoring/metrics beyond the cycle stats already in the design.
4. Personas & primary flows
- MSP billing admin: connects QBO (phase 1), leaves auto-sync on; finalized invoices appear in QBO within a cycle; when clients pay, Alga flips to paid by itself. Checks the settings health panel when something looks off; works sync exceptions from the task inbox.
- MSP bookkeeper (in QBO): records, edits, or deletes payments in QBO as the book of record; Alga follows without being told.
- Operator (existing flow): can still create manual export batches; they ride the same pipeline and satisfy any queued auto-export ops.
5. Functional scope (slice 1 detail)
5.1 Sync engine
Per design.md §Sync engine: accounting_sync_cycles (cursor + run history),
accounting_sync_operations (outbound queue), new adapter capabilities
supportsChangePolling/fetchChanges(since) (QBO: CDC) and
supportsPaymentRecording (declared now, used in slice 4). Cycle order: token
health → inbound (customers, payments, invoice drift) → outbound drain →
cursor advance (inbound success only; outbound failures retry with capped
backoff, then become exceptions). 5-minute cursor overlap; all appliers
idempotent against the mapping ledger. Scheduling registered on connect /
deregistered on disconnect / re-registered at startup; singleton-keyed per
tenant×realm. "Sync now" triggers an immediate cycle.
5.2 Inbound payments
Per-allocation application from Payment.Line[].LinkedTxn via a shared
recordExternalPayment service refactored out of
PaymentService.recordPaymentFromWebhook (Stripe behavior unchanged).
Idempotency/echo-suppression via payment mapping rows
(alga_entity_type: 'invoice_payment'). Payment edits reverse-and-reapply;
deletions write payment_reversal and recompute status. Unmapped invoice,
currency mismatch → exceptions. Unapplied amounts → cycle stats only.
Status flip uses a computeBalanceDue helper that matches current behavior
(slice 2 swaps its internals for the credit reshape).
5.3 Invoice drift detection
Deliver snapshots exported total alongside the existing sync token in mapping
metadata. Inbound invoice changes with a moved sync token compare totals /
void state / doc number; material drift sets sync_status: 'drift' and files
an exception with both versions and two actions: re-export (sparse update QBO
to Alga's truth) or accept (refresh snapshot). Balance-only movement is not
drift. Unmapped QBO invoices are counted, not imported.
5.4 Auto-export on finalize
finalizeInvoice enqueues export_invoice when a realm is connected and the
tenant's auto-sync setting is on (default off until slice 1 is validated live,
then on). The cycle groups pending ops into one scheduled batch through the
existing validate→transform→deliver pipeline; validation failures become
inbox exceptions. Manual batches mark matching ops done.
5.5 Exceptions & notifications
New system task definitions (folding in the unwired qbo_mapping_error):
accounting_sync_drift, accounting_sync_unmapped_payment,
accounting_sync_export_error, accounting_connection_expired. One open task
per entity+type; cycles update rather than duplicate. Notifications (internal +
email, to users with billing_settings update): connection expired/auth
failure immediately; token expiry at 14/7/2 days; per-cycle summary only when
new exceptions appeared.
5.6 UI
- Invoice sync badge (list + detail;
InvoiceTaxSourceBadgepattern) fed by mapping ledger + ops queue; tooltip with QBO doc number, last-synced time, environment-aware deep link; detail actions Sync now / View in QuickBooks. - Settings health panel in
QboIntegrationSettings: last cycle result, next run, pending/exception/drift counts (deep-linked to inbox), token expiry countdown, Sync Now, auto-sync toggle.
6. Data model & API notes
- New tables
accounting_sync_cycles,accounting_sync_operations(columns indesign.md); Citus-distributed ontenantlike theaccounting_export_*tables. tenant_external_entity_mappingsgains payment mappings (no schema change; newalga_entity_typevalue) and the exported-total metadata snapshot.accounting_export_batchesgains anorigindiscriminator (manual | scheduled).- Tenant settings: auto-sync flag (+ slice-4 placeholders deferred).
- QBO API surface: ChangeDataCapture endpoint added to
QboClientService; no other new external calls in slice 1.
7. Risks & open questions
scheduleRecurringJobcoerces intervals to 24h — use the short-interval path or fix the coercion (see SCRATCHPAD gotchas).- Payment reverse-and-reapply must be transactional per payment; partial application crashes mid-cycle must not double-apply (idempotency tests cover this).
- Refactoring
recordPaymentFromWebhooktouches the live Stripe path — regression risk is mitigated by keeping its tests green and behavior byte-identical. - Open: exact deep-link URL format for QBO sandbox vs production (resolve during implementation; environment is already known per connection).
8. Acceptance criteria / definition of done
- All slice-1 features in
features.jsonimplemented; automated tests intests.json(modeautomated) green; existing Stripe payment and Xero suites unaffected. - Live Intuit-sandbox smoke (mode
live-smokeintests.json) executed and passing: connect → finalize → auto-export within a cycle → pay in QBO → Alga flips paid → edit payment → amounts follow → delete payment → reversal → drift (edit invoice total in QBO) → exception with working re-export → token alert fires when expiry forced. - Auto-sync default flipped on only after the smoke passes.
9. Later slices (planned separately)
Each later slice has its own full plan:
../2026-06-11-qbo-phase2-slice2-credits-voids/,
../2026-06-11-qbo-phase2-slice3-onboarding/,
../2026-06-11-qbo-phase2-slice4-polish/.
- Slice 2 — Credits & voids: credit reshape (immutable totals + backfill,
invoice_type, CM- numbering, balance-due read-site audit incl. Stripe payment-link amount), CreditMemo export,apply_creditzero-dollar Payment linkage,voidInvoiceaction + QBO void propagation, delete-blocking. - Slice 3 — Onboarding: customer mapping tab (
getQboCustomers), first-connect reconciliation wizard (customers → historical invoice matching without export → go-live cutoff). - Slice 4 — Polish: Stripe payments pushed to QBO (deposit account
picker via
getQboAccounts), class/department tracking via mapping metadata + tenant defaults, multi-realm UX, optional Intuit webhooks for hosted latency.