Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
149 lines
7.3 KiB
Markdown
149 lines
7.3 KiB
Markdown
# PRD: QBO Closed-Loop Sync — Slice 2: Credits & Voids
|
||
|
||
- **Status:** Draft
|
||
- **Owner:** Robert Isaacs
|
||
- **Created:** 2026-06-11
|
||
- **Design:** `../2026-06-11-qbo-phase2-closed-loop/design.md`
|
||
- **Depends on:** Slice 1 (sync engine, ops queue, exceptions framework, `computeBalanceDue` helper)
|
||
|
||
## 1. Problem statement & user value
|
||
|
||
MSPs issue credits constantly — SLA penalties, billing disputes, true-downs —
|
||
and QBO rejects negative-total invoices, so today credits are the records most
|
||
likely to force manual double-entry. Worse, Alga's credit application mutates
|
||
the posted invoice's `total_amount` (`creditActions.ts:910`), a semantic no
|
||
accounting system can sync against. Separately, Alga can only hard-delete
|
||
invoices; once documents live in QBO, deletion without a void leaves the books
|
||
disagreeing and destroys the cross-system audit trail.
|
||
|
||
This slice fixes the credit semantics while the credit system is barely used,
|
||
then ships credit memo export with application linkage and real void support —
|
||
the remaining document flows a bookkeeper expects from an accounting
|
||
integration.
|
||
|
||
## 2. Goals
|
||
|
||
- Posted invoice totals are immutable; credit application moves the derived
|
||
balance due. Historical data backfilled losslessly.
|
||
- Credit notes are first-class: explicit `invoice_type`, their own `CM-`
|
||
number sequence, exported to QBO as `CreditMemo`s through the existing
|
||
pipeline.
|
||
- Applying credit to an exported invoice reconciles in QBO (zero-dollar
|
||
`Payment` linking CreditMemo → Invoice), so both systems agree on balances.
|
||
- A `voidInvoice` action voids (not deletes) finalized invoices and credit
|
||
notes, propagating the void to QBO; hard delete is blocked for exported
|
||
documents.
|
||
|
||
## 3. Non-goals
|
||
|
||
- Importing QBO-originated CreditMemos (exceptions only, per design).
|
||
- Prepayment export of any kind (prepayments are excluded from CreditMemo
|
||
export with a validation message; mapping them to QBO unapplied payments is
|
||
future work, not scheduled in any slice).
|
||
- Un-applying credit / application reversal sync (Alga-side unapply, if used,
|
||
surfaces as an exception rather than auto-reconciling in QBO).
|
||
- Refund receipts / cash refunds.
|
||
- Changes to the credit pool ledger internals (`credit_tracking` FIFO,
|
||
expirations, reconciliation job).
|
||
|
||
## 4. Personas & primary flows
|
||
|
||
- **MSP billing admin:** issues a credit note (negative invoice) for a service
|
||
dispute; on finalize it appears in QBO as a CreditMemo within a cycle.
|
||
Applies it to an open invoice in Alga; the QBO invoice balance drops to
|
||
match. Voids a mis-issued invoice with a reason; QBO shows it voided.
|
||
- **MSP bookkeeper (in QBO):** sees credits and voids arrive as proper
|
||
documents — CreditMemos and voided invoices — not mystery edits.
|
||
- **Client (portal):** sees balance due (after credits), and pays exactly
|
||
that via the Stripe link.
|
||
|
||
## 5. Functional scope
|
||
|
||
### 5.1 Credit reshape (lands first, own PR)
|
||
|
||
- `applyCreditToInvoice` stops decrementing `invoices.total_amount`; it only
|
||
increments `credit_applied` (and the existing transaction/allocation/pool
|
||
writes are unchanged).
|
||
- `computeBalanceDue` (introduced in slice 1) becomes the real derivation:
|
||
`total_amount − credit_applied − payments`. All amount-due read sites switch
|
||
to it. Known sites to audit (enumerate findings in SCRATCHPAD as the audit
|
||
runs): MSP invoice list/detail, client portal invoice list/detail/pay page,
|
||
Stripe payment-link amount (`getOrCreateInvoicePaymentLinkUrl`), overdue
|
||
detection, invoice PDF/email templates that render an amount due, accounting
|
||
export selectors that reason about settledness.
|
||
- Backfill migration: `total_amount += credit_applied` where
|
||
`credit_applied > 0` (down migration reverses). Lossless because the
|
||
historical mutation preserved both operands.
|
||
- `invoice_type` column: `standard | credit_note | prepayment`; backfilled
|
||
from `is_prepayment` and negative totals; `is_prepayment` retained but
|
||
derived. Finalizing a negative-total invoice sets `credit_note`.
|
||
- Credit notes draw document numbers from their own sequence with a `CM-`
|
||
prefix, configurable alongside the existing invoice numbering settings.
|
||
|
||
### 5.2 CreditMemo export
|
||
|
||
- Finalizing a credit note (auto-sync on) enqueues `export_credit_memo`;
|
||
manual batches may include credit notes through the same selector (the
|
||
preview already carries a `credit_memo` flag).
|
||
- Adapter transform sign-flips lines into a QBO `CreditMemo`, reusing
|
||
item/tax mappings and the tax-delegation behavior; mapping ledger rows use
|
||
`external_entity_type: 'CreditMemo'` with sync-token + total snapshot
|
||
(drift baseline).
|
||
- Prepayment invoices are excluded from export with a clear validation
|
||
message.
|
||
- Slice 1's drift detection extends to CreditMemos (CDC already polls them).
|
||
|
||
### 5.3 Credit application linkage
|
||
|
||
- `applyCreditToInvoice` enqueues `apply_credit` keyed to the
|
||
`credit_allocations` row when the tenant has a connected realm.
|
||
- The cycle processes `apply_credit` only when both the credit note and the
|
||
target invoice are mapped; otherwise the op stays pending with a reason
|
||
(it drains naturally once exports complete).
|
||
- Execution creates a zero-dollar QBO `Payment` whose lines link the
|
||
CreditMemo and Invoice with the applied amount; a mapping row keyed to the
|
||
allocation provides idempotency and echo suppression.
|
||
|
||
### 5.4 Voids
|
||
|
||
- `voidInvoice` action (EE-independent core billing change; reason required;
|
||
`billing_settings`-equivalent invoice permission): status → `cancelled`,
|
||
writes an `invoice_cancelled` transaction, auto-reverses credit
|
||
applications (pool/balance restoration + reversal transactions). Blocked
|
||
while payments exist. Applies to invoices and unapplied credit notes;
|
||
applied credit notes must be unwound first.
|
||
- Invoice detail UI: Void action with confirmation dialog + reason field.
|
||
- `hardDeleteInvoice` blocked when an external mapping exists; the error
|
||
directs to void.
|
||
- Outbound `void_invoice` op calls QBO's void operation; mapping status →
|
||
`voided`; badge (slice 1) renders it.
|
||
|
||
## 6. Data model & API notes
|
||
|
||
- Migrations: `invoices.invoice_type` (+ backfill), `total_amount` backfill,
|
||
credit-note number sequence settings.
|
||
- New ops on `accounting_sync_operations`: `export_credit_memo`,
|
||
`apply_credit`, `void_invoice` (enum values exist from slice 1's table).
|
||
- QBO API surface added to the adapter/client: CreditMemo create/update,
|
||
zero-dollar Payment create, Invoice/CreditMemo void operation.
|
||
|
||
## 7. Risks & open questions
|
||
|
||
- The read-site audit is the riskiest step; an unmigrated site shows inflated
|
||
amounts after backfill. Mitigation: the audit feature requires the
|
||
enumerated-site list in SCRATCHPAD before the backfill merges, and the
|
||
backfill + derivation ship in the same release.
|
||
- Stripe payment links start charging balance due — a behavior fix, but call
|
||
it out in release notes.
|
||
- Tax handling on CreditMemos mirrors invoices (internal vs delegated); the
|
||
sign-flip must keep tax lines consistent — covered by transform tests.
|
||
|
||
## 8. Acceptance criteria / definition of done
|
||
|
||
- All features in `features.json` implemented; automated tests green;
|
||
slice-1 suites and existing credit/Stripe suites unaffected.
|
||
- Live sandbox smoke: credit note → CreditMemo in QBO → apply in Alga → QBO
|
||
invoice balance drops to match → both systems agree on customer balance;
|
||
void an exported invoice → QBO shows voided; portal payment link charges
|
||
balance due after a partial credit.
|