From cb01f398048d63730c8445bb278ec0d78c07efb6 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sun, 4 Oct 2026 20:35:10 -0400 Subject: [PATCH 1/3] feat(quickbooks): post Sales Receipts for settled DDB payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the #179 revenue leg of the Stripe → QBO sync design: one gross Sales Receipt per settled DDB-admin payment, deposited to the mapped Stripe clearing/balance account. - postPaidPaymentToQbo runs best-effort after every canonical `paid` commit (webhook + applyOutcome). Identity is positive and structural: only the app's own payments/ records qualify — Ollie/Spreedly and other foreign Stripe activity can never reach the seam. - processQboSyncRecord claims durable sync records under a short lease, gates on mapping and canonical Stripe settlement, then find-or-creates exactly one Sales Receipt — the ddb: correlation marker lets a retry adopt a landed-but-untracked write instead of double-posting. - SalesReceipt posts gross cents→decimal at paidAt, purpose-mapped income item, mapped clearing account (never Undeposited Funds/bank), generic customer, and GlobalTaxCalculation=NotApplicable for non-US companies only — no tax is posted (#184). - Transient failures land retryable (`failed`); validation/mapping/ canonical mismatches land `needs_attention`. invalid_grant rides the existing reauthorization path. The payment record is never touched — a QBO hiccup can never roll back a settled charge. - No backfill: only payments that reach `paid` after deploy produce sync records; the $5.00 pre-flight test is untouched. Closes #179 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../quickbooks-accounting-sync.md | 89 +-- docs/operations/payments.md | 14 + docs/operations/quickbooks.md | 111 +++- lib/payments-admin.ts | 21 +- lib/qbo-api.ts | 70 +++ lib/qbo-common.ts | 48 ++ lib/qbo-protocol.ts | 151 ++++- lib/qbo-sync.ts | 562 +++++++++++++++++- tests/lib/qbo-salesreceipt.test.ts | 206 +++++++ tests/lib/qbo-sync-worker.test.ts | 421 +++++++++++++ 10 files changed, 1611 insertions(+), 82 deletions(-) create mode 100644 tests/lib/qbo-salesreceipt.test.ts create mode 100644 tests/lib/qbo-sync-worker.test.ts diff --git a/docs/architecture/quickbooks-accounting-sync.md b/docs/architecture/quickbooks-accounting-sync.md index 82a67e3..5fce9ad 100644 --- a/docs/architecture/quickbooks-accounting-sync.md +++ b/docs/architecture/quickbooks-accounting-sync.md @@ -298,7 +298,7 @@ coupled to QBO availability — see §13. | Payment-tool transition | Sync record | QBO write | | --- | --- | --- | -| `paid` (via `checkout.session.completed`/async or manual refresh) | `stripe_payment` enqueue at the `paid` commit | `SalesReceipt` (gross → clearing) | +| `paid` (via `checkout.session.completed`/async or manual refresh) | `stripe_payment` enqueue at the `paid` commit | `SalesReceipt` (gross → clearing) — **implemented (#179)** | | `refunded` (via `commitRefund`) | `stripe_refund` enqueue at the `refunded` commit | `RefundReceipt` referencing the original receipt's lines | | `processing`, `failed`, `expired`, `canceled`, `created`, `awaiting_payment` | none | none — no money moved | | Dashboard refund (outside the app) | **gap** — see §8 | **gap** — see §8 | @@ -309,6 +309,13 @@ transition (`applyOutcome`/`commitRefund`), best-effort after the Firestore commit so a QBO-side hiccup can never roll back a payment transition. +**Implemented trigger (#179):** `postPaidPaymentToQbo(paymentId)` runs +after every `paid` commit — inside `applyOutcome` (manual refresh and +the cancel-race path) and after `processStripeEvent` in the webhook +handler. It builds the `stripe_payment` candidate from the canonical +`payments` record, enqueues it, and attempts the write inline. The +helper never throws and never touches the payment record. + ## 8. Refund model **`RefundReceipt`.** It is the Sales-Receipt-counterpart credit document: @@ -417,53 +424,61 @@ deterministic internal identity, QBO ids as correlation only. - A replayed `paid` transition, a redelivered Stripe webhook, or a retried worker run hits the existing `qboSyncRecords` document and - returns `duplicate` — never a second Sales Receipt. -- Worker behavior on an existing record: `synced` → no-op; `syncing` - (stale lease) → re-check whether a QBO write actually landed - (query by a stored doc number/private note correlation) before - writing again; `failed` → retry per §13. -- The QBO-side correlation written into the Sales Receipt (e.g. - `PrivateNote`/`DocNumber` containing `paymentId` + `paymentIntentId`) - gives a findable, human-auditable back-reference — belt to the - Firestore-dedupe suspenders, not a replacement. + returns `duplicate`/`already_synced` — never a second Sales Receipt. +- **Implemented worker behavior (#179):** a Firestore transaction claims + `pending`/`failed`/stale-`syncing` records under a 2-minute + `syncingLeaseUntil` lease — one claimant at a time. `synced` → + `already_synced` no-op. `needs_attention` → terminal until a human + requeues. +- **Provider-side recovery (implemented):** QBO offers no idempotency + key on create, so before every `POST /salesreceipt` the worker queries + the customer's recent receipts for the `ddb:` PrivateNote + marker. A write that landed but whose response was lost is adopted — + the record stores its entity id and marks `synced`. +- The QBO-side correlation written into the Sales Receipt + (`PrivateNote` = `ddb:` + Stripe PI/charge refs, + `DocNumber` = `DDB-…` truncated to 21 chars) gives a findable, + human-auditable back-reference — belt to the Firestore-dedupe + suspenders, not a replacement. ## 13. Failure / retry behavior - **Enqueue is best-effort, post-commit**: if it throws, the payment is still `paid`; a sweeper finds payments with no sync record and enqueues them (reconciliation job or on-demand admin action). -- **Trigger mechanism (open design point):** no cron exists in this repo - today (`vercel.json` has no `crons`). Options: (a) Vercel Cron hitting - an authenticated sweep route — preferred, bounded, observable; (b) - fire-and-forget processing inside the Stripe webhook request — - simplest but dies with the request on hard failure; (c) enqueue-then- - sweep hybrid: attempt inline, sweep hourly for stragglers. Decide in - the implementation issue. +- **Trigger mechanism (implemented, #179):** enqueue + inline write + attempt inside the request that committed `paid` — no cron exists in + this repo today (`vercel.json` has no `crons`). A bounded sweep route + for `pending`/`failed` stragglers remains #183. - **Attempts/backoff:** `attempts` counter + `lastAttemptAt` exist on - the record; bounded retries on transient `QboError`s - (`unavailable`/`rate-limited`), straight to `needs_attention` on - `validation` (bad mapping, deleted entity) — human fixes config, then - a retry action re-queues. -- **`invalid_grant` / reauthorization_required:** sync pauses cleanly; - pending records accumulate safely until reconnect. -- **QBO write succeeded but response lost:** the worst case — a - `syncing` record with no recorded `qboEntityId` must be reconciled by - querying for the correlation reference (§12) before re-posting. + the record; transient `QboError`s (`unavailable`/`rate_limited`, + token `refresh_failed`, Stripe fetch failures) land in `failed` — + retryable. `validation`/`permission_denied`/`configuration`, + unmapped purposes, missing mappings, and canonical-state mismatches + go straight to `needs_attention` — human fixes config, then a retry + action re-queues (manual `pending` reset until #183 ships). +- **`invalid_grant` / reauthorization_required:** sync pauses cleanly + (`failed` + `authorization_expired`); records accumulate safely until + reconnect. +- **QBO write succeeded but response lost:** handled — the correlation + query in §12 adopts the landed receipt instead of re-posting. ## 14. Rollout plan -1. **Pre-flight gate (blocking):** Chad completes §2 inspection; answers - recorded in the implementation issue. If an existing revenue leg is - found → pivot to Option D before any write work. -2. Create the **Stripe clearing account** in QBO (manual, accountant - confirms type — typically Other Current Asset or Bank). +1. ~~**Pre-flight gate (blocking)**~~ — resolved by #178: the two-lane + evidence confirmed DDB payments have no QBO record. +2. ~~Create the **Stripe clearing account** in QBO~~ — exists (`Stripe + Balance`, intentionally holds retained funds). 3. Configure mappings (clearing + 3 items + generic customer; tax code - left unset). -4. Implement the sync worker behind the existing seam; **shadow mode - first** (compute + log what would post, no writes) for a soak period. -5. Enable writes for **new** payments only; verify the first payout - reconciliation with the accountant. -6. Optional: backfill prior settled payments via a bounded admin action. + left unset). Until configured, sync records land in `needs_attention` + — writes are inert by construction, which provides the intended + rollout safety without a separate shadow mode. +4. ~~Implement the sync worker behind the existing seam~~ — implemented + in #179 (inline attempt at the `paid` commit; durable record). +5. Verify the first real Sales Receipt + the first mixed-source payout + reconciliation with the accountant before calling the lane done. +6. Optional: backfill prior settled payments via a bounded admin action + — explicitly not part of #179. 7. Refund sync after refund coverage (incl. Dashboard-refund gap) is decided. 8. Only then consider: fee automation, recon reports, per-customer diff --git a/docs/operations/payments.md b/docs/operations/payments.md index 474a9fb..a834dc5 100644 --- a/docs/operations/payments.md +++ b/docs/operations/payments.md @@ -163,6 +163,20 @@ card refunds can be asynchronous (`pending`), but reconciliation is driven by the next staff refund request checking canonical `Refund.status`, so a webhook subscription would add surface without changing the outcome. +## QuickBooks posting (settled payments) + +Every canonical `paid` commit — webhook-applied or manual-refresh — also +hands the payment to the QuickBooks sync (`postPaidPaymentToQbo`, +`lib/qbo-sync.ts`): a durable `qboSyncRecords` entry keyed +`{environment}:stripe_payment:{paymentId}` and an inline attempt to post +one gross Sales Receipt to the mapped Stripe clearing account. The +export is strictly downstream: it is best-effort, idempotent, and can +never change the payment's status. Only payments originating in this +tool qualify — Ollie/Spreedly and other foreign Stripe activity have no +`payments/` record and never reach the seam. Model, mappings, failure +behavior, and the no-backfill rule are in +[quickbooks.md](./quickbooks.md#sales-receipt-sync-ddb-payments-only). + ## Stripe API versions Two API versions coexist deliberately — the webhook endpoint is pinned to diff --git a/docs/operations/quickbooks.md b/docs/operations/quickbooks.md index 1ed3569..133c437 100644 --- a/docs/operations/quickbooks.md +++ b/docs/operations/quickbooks.md @@ -1,13 +1,15 @@ # QuickBooks Online Integration -Operational guide for the QuickBooks Online (QBO) connection foundation -(issue #161). This covers connecting the brewery's QuickBooks company, -keeping sandbox and production strictly separated, and operating the -integration day to day. - -**Scope:** connection foundation only — OAuth, token lifecycle, webhook -receipts, entity discovery, and accounting-mapping configuration. No -bookkeeping is posted yet (see +Operational guide for the QuickBooks Online (QBO) integration +(issues #161, #179). This covers connecting the brewery's QuickBooks +company, keeping sandbox and production strictly separated, the +DDB-payment Sales Receipt sync, and operating the integration day to +day. + +**Scope:** OAuth + token lifecycle, webhook receipts, entity discovery, +accounting-mapping configuration, and one bookkeeping write — gross +Sales Receipts for settled DDB-admin payments. Everything else (refunds, +payouts, fees, journals) remains deferred (see [What is deliberately not built](#what-is-deliberately-not-built)). ## Architecture at a glance @@ -185,30 +187,79 @@ misconfiguration fails closed instead of posting to the wrong books. by content identity; notifications for a realm other than the connected company are recorded but flagged ignored. +## Sales Receipt sync (DDB payments only) + +Implemented in #179 per the +[accounting design](../architecture/quickbooks-accounting-sync.md). +Exactly one thing is posted to QuickBooks: **one gross Sales Receipt per +settled DDB-admin payment, deposited to the mapped Stripe clearing +account.** + +### What qualifies + +A payment qualifies only through **positive DDB identity** — a canonical +record in the app's own `payments` collection that reached `paid` +through the normal Stripe-verified path. The worker re-verifies the +canonical Checkout Session before writing (session belongs to the same +internal payment id, `complete` + `paid`, amount and currency match the +stored record). + +Ollie/Spreedly wholesale charges and every other foreign Stripe +activity can never qualify: they have no `payments/` record, no +checkout-session metadata pointing at one, and no sync record is ever +enqueued for them. Ollie owns wholesale invoicing end to end — posting +for it would double-count revenue. + +### What posts + +| Field | Value | +| --- | --- | +| Entity | `SalesReceipt` — never Invoice + Payment, never a Deposit | +| Amount | **Gross** customer charge (`amountMinor / 100`). Stripe fee/net are not part of this leg | +| Date | `TxnDate` = the payment's `paidAt`, not processing time | +| Account | `DepositToAccountRef` = mapped Stripe clearing/balance account (e.g. `Stripe Balance`) — never Undeposited Funds, never the bank, never hardcoded | +| Item | `ItemRef` = mapped income item by purpose: tour (`brewery_tour`, `additional_guests`, `private_tour`), tasting (`brewery_tour_tasting`), other (`other`) | +| Customer | `CustomerRef` = mapped generic customer | +| Tax | No `TaxCodeRef`; `GlobalTaxCalculation: "NotApplicable"` sent for non-US companies, omitted for US (the field is required there and rejected here). Tax policy is #184 | +| Correlation | `PrivateNote` carries `ddb:` + Stripe PI/charge refs; `DocNumber` = `DDB-…` derived from the payment id | + +### Lifecycle and retry + +- On a canonical `paid` commit (webhook or manual refresh) the app + enqueues a durable `qboSyncRecords/{environment}:stripe_payment:{paymentId}` + record and attempts the write inline. +- `synced` — done; replays return the stored entity id. `failed` — + transient provider/Stripe/token failure, safe to retry. + `needs_attention` — a human must fix something first (mapping missing, + purpose unmapped, canonical mismatch, QBO validation rejection). +- A `syncing` claim carries a short lease; a stale claim is reclaimed, + and the worker always queries for the `ddb:` correlation + marker before creating — a write that landed but whose response was + lost is adopted, never duplicated. +- There is **no automatic retry sweep yet** (#183). To requeue a + `failed` or `needs_attention` record after fixing the cause, set its + `status` back to `pending` in Firestore — the next trigger or manual + call picks it up. +- QBO failures never affect the payment: it stays `paid`, and nothing + customer-facing implies the card charge failed. + +### No backfill + +Only payments that reach `paid` **after** this code is deployed produce +sync records. Historical paid payments — including the live `$5.00` +pre-flight test — are never posted automatically. Any future backfill +is an explicit, separately reviewed admin action. + ## What is deliberately not built -No bookkeeping exists yet — by design. The following are deferred until -the accounting policy is chosen and PR #156 (the Stripe payment tool) is -merged: - -- Stripe → QBO Sales Receipt / Invoice + Payment / Deposit creation -- Journal entries, refunds, payout and bank-feed reconciliation -- Automated tax mapping beyond the optional tax-code selection -- Any entity writes to QuickBooks at all — every current API call is - read-only - -The accounting model that work must follow is designed in -[QuickBooks Accounting Sync — Design](../architecture/quickbooks-accounting-sync.md) -— read it before posting anything; it records the double-counting risks -and the pre-flight inspection that must happen first. - -The seam for that work is `enqueueAccountingTransaction()` -(`lib/qbo-sync.ts`): a future producer hands it a -`QuickBooksSyncCandidate` (amount, currency, date, customer hints, -purpose, external refs). The sync record's document id is the -deterministic `sourceType:sourceId` identity, so retries and replays can -never post the same financial event twice — QBO entity ids recorded -later are correlation references, not the dedupe mechanism. +- Refund posting (`RefundReceipt` mirroring the original lines) — #180; + Stripe Dashboard refunds remain invisible to the app +- Stripe payout posting, Mercury deposit creation, clearing/balance + reconciliation, and fee accounting — #181 (mixed Ollie+DDB payouts + make this a distinct design problem) +- Automatic retry sweep / admin sync-status surface — #183 +- Tax posting — #184 (Curaçao tax policy undecided) +- Journal entries or any other entity writes ## Troubleshooting diff --git a/lib/payments-admin.ts b/lib/payments-admin.ts index eb6ea84..4823c5a 100644 --- a/lib/payments-admin.ts +++ b/lib/payments-admin.ts @@ -2,6 +2,7 @@ import "server-only"; import { FieldValue, Timestamp } from "firebase-admin/firestore"; import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; import { getStripeClient } from "@/lib/stripe"; +import { postPaidPaymentToQbo } from "@/lib/qbo-sync"; import { getStripeWebhookSecret, resolveCheckoutReturnBaseUrl, @@ -376,7 +377,7 @@ async function applyOutcome( ): Promise { if (!outcome) return false; const ref = getPaymentsCollection().doc(id); - return getFirebaseAdminDb().runTransaction(async (tx) => { + const applied = await getFirebaseAdminDb().runTransaction(async (tx) => { const snap = await tx.get(ref); if (!snap.exists) throw new PaymentNotFoundError(); const record = (snap.data() ?? {}) as Record; @@ -397,6 +398,14 @@ async function applyOutcome( }); return true; }); + if (applied && outcome.transition === "paid") { + // #179 — post the QBO revenue leg for this settled DDB payment. + // Best-effort post-commit: the helper is idempotent and swallows its + // own errors, so an accounting-export hiccup can never roll the + // payment back. + await postPaidPaymentToQbo(id); + } + return applied; } // Best-effort receipt/card enrichment for a `paid` transition. Failures are @@ -1062,5 +1071,15 @@ export async function handleStripeWebhook( paymentId: result.paymentId, toStatus: result.toStatus, }); + if ( + result.status === "applied" && + result.toStatus === "paid" && + result.paymentId + ) { + // #179 — same post-commit seam as applyOutcome: enqueue + attempt the + // Sales Receipt inline. Never throws; failure lands on the durable + // sync record for retry. + await postPaidPaymentToQbo(result.paymentId); + } return result; } diff --git a/lib/qbo-api.ts b/lib/qbo-api.ts index d5aaf29..74f82da 100644 --- a/lib/qbo-api.ts +++ b/lib/qbo-api.ts @@ -4,10 +4,13 @@ import { buildQboQueryUrl, canonicalizeCompanyInfo, canonicalizeQboQueryEntities, + canonicalizeQboSalesReceiptCreated, + canonicalizeQboSalesReceiptRefs, qboApiUrl, qboEntityQueryStatement, qboErrorForHttpStatus, qboQueryRowCount, + qboSalesReceiptCorrelationQuery, QBO_QUERY_MAX_PAGES, QBO_QUERY_PAGE_SIZE, toQboError, @@ -152,3 +155,70 @@ export async function queryQboEntities(input: { }); return out; } + +// --- Sales Receipt writes (issue #179) --- + +export interface QboSalesReceiptCreateResult { + id: string; + correlationId?: string; +} + +// Creates one Sales Receipt. The only bookkeeping write in the entire +// integration — callers supply a fully-built, mapping-resolved payload +// from buildQboSalesReceiptPayload; this boundary validates nothing about +// accounting, only transport and response shape. QBO offers no +// idempotency key on create, so retry dedupe is the sync record plus the +// correlation-marker query below. +export async function createQboSalesReceipt(input: { + environment: QboEnvironment; + realmId: string; + accessToken: string; + payload: Record; +}): Promise { + const { payload, correlationId } = await qboApiFetch({ + environment: input.environment, + realmId: input.realmId, + accessToken: input.accessToken, + path: "/salesreceipt", + method: "POST", + body: input.payload, + }); + const created = canonicalizeQboSalesReceiptCreated(payload); + logInfo("qbo.api.salesreceipt_created", { + environment: input.environment, + correlationId, + }); + return { ...created, correlationId }; +} + +// Provider-side recovery lookup: find a Sales Receipt already carrying +// this payment's correlation marker. Runs before every create so a +// retry after an ambiguous outcome (write landed, response lost) adopts +// the existing receipt instead of posting a second one. +export async function findQboSalesReceiptForMarker(input: { + environment: QboEnvironment; + realmId: string; + accessToken: string; + customerId: string; + marker: string; +}): Promise { + const { payload, correlationId } = await qboApiFetch({ + environment: input.environment, + realmId: input.realmId, + accessToken: input.accessToken, + url: buildQboQueryUrl( + input.environment, + input.realmId, + qboSalesReceiptCorrelationQuery(input.customerId) + ), + }); + const match = canonicalizeQboSalesReceiptRefs(payload).find((ref) => + ref.privateNote?.includes(input.marker) + ); + logInfo("qbo.api.salesreceipt_lookup", { + environment: input.environment, + found: Boolean(match), + correlationId, + }); + return match ? { id: match.id, correlationId } : null; +} diff --git a/lib/qbo-common.ts b/lib/qbo-common.ts index 32cb297..8ad5001 100644 --- a/lib/qbo-common.ts +++ b/lib/qbo-common.ts @@ -272,3 +272,51 @@ export function persistableQboSyncCandidate( Object.entries(candidate).filter(([, value]) => value !== undefined) ) as QuickBooksSyncCandidate; } + +// --- Sales Receipt write model (issue #179) --- + +// The only source type the sync worker posts today — a settled payment +// from the app's own `payments` collection. Positive identity is +// structural: only records the tool itself issued can carry this type. +export const QBO_STRIPE_PAYMENT_SOURCE_TYPE = "stripe_payment"; +export const QBO_SALES_RECEIPT_ENTITY_TYPE = "SalesReceipt"; + +// Maps a payment-tool `purpose` onto the income-item mapping field, per +// the accounting design doc. This is the QBO side's bookkeeping decision +// — the producer only supplies the purpose string. An unrecognized +// purpose returns null so the worker fails closed instead of posting to +// an arbitrary item. +export function qboIncomeItemKeyForPurpose( + purpose: string | undefined +): keyof QboAccountingMapping | null { + switch (purpose) { + // Tour-family purposes post to tour income — `additional_guests` and + // `private_tour` are tour volume, per the design doc. + case "brewery_tour": + case "additional_guests": + case "private_tour": + return "tourIncomeItemId"; + case "brewery_tour_tasting": + return "tastingIncomeItemId"; + case "other": + return "otherIncomeItemId"; + default: + return null; + } +} + +// Human-auditable correlation marker embedded in the Sales Receipt's +// PrivateNote. Provider-side recovery (design §12 — a write that landed +// but whose response was lost) matches on this token, so a retried sync +// adopts the existing receipt instead of posting a duplicate. +export function qboSalesReceiptMarker(sourceId: string): string { + return `ddb:${sourceId}`; +} + +// QBO DocNumber is limited to 21 characters. Deriving a stable, readable +// number from the source id gives QBO users a searchable back-reference +// and a second dedupe handle where custom transaction numbers are on. +export function qboSalesReceiptDocNumber(sourceId: string): string { + const clean = sourceId.replace(/[^A-Za-z0-9]/g, ""); + return `DDB-${clean.slice(-17)}`.slice(0, 21); +} diff --git a/lib/qbo-protocol.ts b/lib/qbo-protocol.ts index b548c57..ad281ba 100644 --- a/lib/qbo-protocol.ts +++ b/lib/qbo-protocol.ts @@ -14,10 +14,12 @@ import { QBO_TOKEN_URL, qboApiBaseUrl, } from "@/lib/qbo-config"; -import type { - QboDiscoveryEntityType, - QboEntitySummary, - QboEnvironment, +import { + qboSalesReceiptDocNumber, + qboSalesReceiptMarker, + type QboDiscoveryEntityType, + type QboEntitySummary, + type QboEnvironment, } from "@/lib/qbo-common"; // --- OAuth state --- @@ -563,3 +565,144 @@ export function canonicalizeQboQueryEntities( } return out; } + +// --- Sales Receipt writes (issue #179) --- + +// Everything needed to construct one gross Sales Receipt for a settled +// DDB-admin payment. All ids come from the validated accounting mapping +// — never hard-coded account/item/customer ids. +export interface QboSalesReceiptSpec { + /** Internal payment id — the durable source identity. */ + sourceId: string; + /** Gross charge amount in integer minor units (cents). */ + amountMinor: number; + /** Uppercase ISO-4217 code recorded on the payment (today: USD). */ + currency: string; + /** Settlement instant (payment `paidAt`), epoch ms. */ + paidAtMs: number; + description?: string; + clearingAccountId: string; + incomeItemId: string; + customerId: string; + paymentIntentId?: string; + chargeId?: string; + /** `"NotApplicable"` for non-US companies (QBO requires the field on + * sales transactions there); undefined for US companies, which reject + * it. Tax posting itself stays disabled — see design §10. */ + globalTaxCalculation?: "NotApplicable"; +} + +const QBO_PRIVATE_NOTE_MAX = 500; +const QBO_LINE_DESCRIPTION_MAX = 500; + +// Builds the POST /salesreceipt body. The gross customer charge is posted +// (never Stripe net — fees are #181) as a single SalesItemLineDetail line +// against the mapped item, deposited to the mapped Stripe clearing/ +// balance account. No TaxCodeRef is ever sent: tax policy for the CW +// company is an open accountant question (#184). +export function buildQboSalesReceiptPayload( + spec: QboSalesReceiptSpec +): Record { + const gross = spec.amountMinor / 100; + const refs = [ + `Deep Dive Brewing payment ${qboSalesReceiptMarker(spec.sourceId)}`, + spec.paymentIntentId ? `Stripe PI ${spec.paymentIntentId}` : null, + spec.chargeId ? `charge ${spec.chargeId}` : null, + ].filter((part): part is string => part !== null); + + return { + TxnDate: new Date(spec.paidAtMs).toISOString().slice(0, 10), + CurrencyRef: { value: spec.currency }, + CustomerRef: { value: spec.customerId }, + DepositToAccountRef: { value: spec.clearingAccountId }, + DocNumber: qboSalesReceiptDocNumber(spec.sourceId), + PrivateNote: refs.join("; ").slice(0, QBO_PRIVATE_NOTE_MAX), + ...(spec.globalTaxCalculation + ? { GlobalTaxCalculation: spec.globalTaxCalculation } + : {}), + Line: [ + { + Amount: gross, + DetailType: "SalesItemLineDetail", + ...(spec.description + ? { Description: spec.description.slice(0, QBO_LINE_DESCRIPTION_MAX) } + : {}), + SalesItemLineDetail: { + ItemRef: { value: spec.incomeItemId }, + Qty: 1, + UnitPrice: gross, + }, + }, + ], + }; +} + +// Canonical shape returned by POST /salesreceipt — only the provider +// entity id crosses the boundary (it is a correlation reference, never +// the dedupe key). +export function canonicalizeQboSalesReceiptCreated(payload: unknown): { + id: string; +} { + const receipt = + typeof payload === "object" && payload !== null + ? (payload as Record).SalesReceipt + : undefined; + const id = + typeof receipt === "object" && receipt !== null + ? (receipt as Record).Id + : undefined; + if (typeof id !== "string" && typeof id !== "number") { + throw new QboError( + "QuickBooks returned a malformed sales receipt response.", + "unexpected" + ); + } + return { id: String(id) }; +} + +// Query statement for provider-side recovery: recent receipts posted to +// the mapped generic customer. PrivateNote is not queryable server-side, +// so the statement bounds the window and callers match the correlation +// marker locally. A landed-but-untracked write is always recent at retry +// time, so the most recent receipts are sufficient. +export function qboSalesReceiptCorrelationQuery( + customerId: string +): string { + const safe = customerId.replace(/[^A-Za-z0-9_-]/g, ""); + return `select Id, PrivateNote, TxnDate from SalesReceipt where CustomerRef = '${safe}' orderby TxnDate desc maxresults 25`; +} + +export interface QboSalesReceiptRef { + id: string; + privateNote?: string; +} + +export function canonicalizeQboSalesReceiptRefs( + payload: unknown +): QboSalesReceiptRef[] { + const response = + typeof payload === "object" && payload !== null + ? (payload as Record).QueryResponse + : undefined; + const rows = + typeof response === "object" && response !== null + ? (response as Record).SalesReceipt + : undefined; + if (!Array.isArray(rows)) return []; + const out: QboSalesReceiptRef[] = []; + for (const row of rows) { + if (typeof row !== "object" || row === null) continue; + const r = row as Record; + const id = + typeof r.Id === "string" || typeof r.Id === "number" + ? String(r.Id) + : ""; + if (!id) continue; + out.push({ + id, + privateNote: + typeof r.PrivateNote === "string" ? r.PrivateNote : undefined, + }); + } + return out; +} diff --git a/lib/qbo-sync.ts b/lib/qbo-sync.ts index bfd78bf..578ae74 100644 --- a/lib/qbo-sync.ts +++ b/lib/qbo-sync.ts @@ -1,20 +1,47 @@ import "server-only"; -import { FieldValue } from "firebase-admin/firestore"; +import { FieldValue, Timestamp } from "firebase-admin/firestore"; import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; import { getQboEnvironment } from "@/lib/qbo-config"; -import { logInfo } from "@/lib/log"; +import { logError, logInfo, logWarn } from "@/lib/log"; +import { getStripeClient } from "@/lib/stripe"; +import { + normalizePaymentStatus, + paymentIdFromSession, + timestampMillis, + toPaymentProviderError, + PAYMENTS_COLLECTION, +} from "@/lib/payments-common"; +import { getQuickBooksAccessToken } from "@/lib/qbo-tokens"; +import { getQboMappingView } from "@/lib/qbo-mapping"; +import { + createQboSalesReceipt, + findQboSalesReceiptForMarker, +} from "@/lib/qbo-api"; +import { buildQboSalesReceiptPayload } from "@/lib/qbo-protocol"; +import { QboError } from "@/lib/qbo-errors"; import { normalizeQboSyncCandidate, persistableQboSyncCandidate, + qboIncomeItemKeyForPurpose, + qboSalesReceiptMarker, qboSyncIdFor, + QBO_CONNECTIONS_COLLECTION, + QBO_SALES_RECEIPT_ENTITY_TYPE, + QBO_STRIPE_PAYMENT_SOURCE_TYPE, QBO_SYNC_RECORDS_COLLECTION, + type QboEnvironment, type QuickBooksSyncCandidate, } from "@/lib/qbo-common"; -// Forward accounting-export contract (issue #161). This is the seam a -// future producer — the Stripe payments module once #156 merges — calls to -// hand a financial event to the QuickBooks integration. The producer -// supplies safe domain facts; the QBO side owns every bookkeeping decision. +// Accounting export for settled DDB-admin payments (issues #161/#179). +// The payments module calls postPaidPaymentToQbo after a canonical `paid` +// commit; this module owns every bookkeeping decision. +// +// Source identity is positive and structural: the only qualifying source +// is a record in the app's own `payments` collection whose canonical +// Stripe session points back at the same internal id. Ollie/Spreedly +// charges and any other foreign Stripe activity never reach this path — +// they have no payments/ document and no sync record is ever enqueued. // // Idempotency design: the sync record's document id is the deterministic // internal source identity (`environment:sourceType:sourceId`). A durable @@ -25,10 +52,11 @@ import { // colliding with the sandbox one. QBO entity ids stored later are // correlation references only — they are never the dedupe mechanism. // -// No bookkeeping is performed here: enqueuing creates a `pending` record -// and nothing more. The worker that maps candidates onto Sales Receipts, -// Invoices, or summarized journals is deliberately deferred — see -// docs/operations/quickbooks.md. +// Worker claim model: one `syncing` claimant at a time via a Firestore +// transaction with a short lease; a stale lease is reclaimable. Because +// QBO offers no create-time idempotency key, the worker queries for the +// correlation marker before every create — a write that landed but whose +// response was lost is adopted, never duplicated. export type QboEnqueueResult = | { syncId: string; outcome: "pending" } @@ -80,3 +108,517 @@ export async function enqueueAccountingTransaction( } return { syncId, outcome }; } + +// --- Sync worker (#179) --- + +// How long one claimant may work a record before a retry may reclaim it. +// Long enough for token refresh + two QBO calls; short enough that a +// crashed worker does not strand the record. +const SYNC_CLAIM_LEASE_MS = 2 * 60 * 1000; + +interface QboSyncRecordDoc { + sourceType?: string; + sourceId?: string; + status?: string; + qboEntityId?: string | null; + realmId?: string | null; + attempts?: number; + syncingLeaseUntil?: Timestamp; + lastErrorCode?: string | null; +} + +export type QboSyncProcessResult = + | { outcome: "synced"; qboEntityId: string; adopted: boolean } + | { outcome: "already_synced"; qboEntityId?: string | null } + | { outcome: "in_progress" } + | { outcome: "needs_attention"; reason: string } + | { outcome: "failed"; reason: string } + | { outcome: "missing" }; + +type SyncWriteResult = + | { + kind: "synced"; + qboEntityId: string; + realmId: string; + adopted: boolean; + correlationId?: string; + } + | { kind: "failed"; code: string; message: string; correlationId?: string } + | { + kind: "needs_attention"; + code: string; + message: string; + correlationId?: string; + }; + +function attention( + code: string, + message: string, + correlationId?: string +): SyncWriteResult { + return { kind: "needs_attention", code, message, correlationId }; +} + +function failure( + code: string, + message: string, + correlationId?: string +): SyncWriteResult { + return { kind: "failed", code, message, correlationId }; +} + +// QBO failure → record state. Validation/permission/configuration means +// retrying unchanged cannot help — a human fixes config first +// (needs_attention). Everything else is retryable (failed). +function classifyQboSyncError(error: unknown): SyncWriteResult { + if (error instanceof QboError) { + const nonRetryable = + error.kind === "validation" || + error.kind === "permission_denied" || + error.kind === "configuration"; + return nonRetryable + ? attention(error.kind, error.message, error.correlationId) + : failure(error.kind, error.message, error.correlationId); + } + return failure("unexpected", "QuickBooks sync failed unexpectedly — retry later."); +} + +function syncRef(syncId: string) { + return getFirebaseAdminDb() + .collection(QBO_SYNC_RECORDS_COLLECTION) + .doc(syncId); +} + +// Commits the outcome for a claimed record — but only while the claim is +// still ours. If the lease lapsed mid-flight and a successor reclaimed +// the record, this worker's verdict must not clobber its state. +async function finalizeSyncClaim( + syncId: string, + leaseUntilMs: number, + result: SyncWriteResult +): Promise { + const ref = syncRef(syncId); + await getFirebaseAdminDb().runTransaction(async (tx) => { + const snap = await tx.get(ref); + const record = snap.data() as QboSyncRecordDoc | undefined; + if ( + !record || + record.status !== "syncing" || + record.syncingLeaseUntil?.toMillis() !== leaseUntilMs + ) { + return; + } + const done: Record = { + syncingLeaseUntil: FieldValue.delete(), + updatedAt: Timestamp.now(), + lastQboCorrelationId: result.correlationId ?? null, + }; + if (result.kind === "synced") { + tx.update(ref, { + ...done, + status: "synced", + qboEntityType: QBO_SALES_RECEIPT_ENTITY_TYPE, + qboEntityId: result.qboEntityId, + realmId: result.realmId, + lastErrorCode: null, + lastErrorMessage: null, + }); + } else { + tx.update(ref, { + ...done, + status: result.kind, + lastErrorCode: result.code, + lastErrorMessage: result.message, + }); + } + }); +} + +// The bookkeeping work for one claimed stripe_payment record: verify the +// payment is genuinely settled (own record + canonical Stripe session), +// gate on mapping, then find-or-create exactly one Sales Receipt. +async function runSyncWrite( + sourceType: string | undefined, + sourceId: string | undefined, + environment: QboEnvironment +): Promise { + if (sourceType !== QBO_STRIPE_PAYMENT_SOURCE_TYPE || !sourceId) { + return attention( + "unsupported_source", + "This sync record type is not supported by the QBO writer." + ); + } + const db = getFirebaseAdminDb(); + + // --- Positive DDB identity + settlement (canonical record) --- + const paymentSnap = await db.collection(PAYMENTS_COLLECTION).doc(sourceId).get(); + const payment = paymentSnap.data(); + if (!payment) { + return attention( + "payment_missing", + "The DDB payment record for this sync no longer exists." + ); + } + const status = normalizePaymentStatus(payment.status); + // `refunded` still requires the original Sales Receipt — the reversal + // is a separate RefundReceipt concern (#180). + const settled = (status === "paid" || status === "refunded") && !payment.reconciliationIssue; + const paidAtMs = timestampMillis(payment.paidAt); + const sessionId = + typeof payment.stripeCheckoutSessionId === "string" && + payment.stripeCheckoutSessionId + ? payment.stripeCheckoutSessionId + : null; + const amountMinor = + typeof payment.amountMinor === "number" && payment.amountMinor > 0 + ? payment.amountMinor + : null; + const currency = + typeof payment.currency === "string" && payment.currency + ? payment.currency.toUpperCase() + : "USD"; + if (!settled || !paidAtMs || !sessionId || !amountMinor) { + return attention( + "payment_not_settled", + "The payment record does not show a verified settled charge." + ); + } + + // --- Mapping gate — fail closed before any provider write --- + const mappingView = await getQboMappingView(); + if (!mappingView.configured || !mappingView.mapping) { + return attention( + "mapping_unconfigured", + "QuickBooks accounting mapping is not configured for the connected company." + ); + } + const mapping = mappingView.mapping; + const itemKey = qboIncomeItemKeyForPurpose( + typeof payment.purpose === "string" ? payment.purpose : undefined + ); + const incomeItemId = itemKey ? mapping[itemKey] : undefined; + if (!itemKey || !incomeItemId) { + return attention( + "purpose_unmapped", + "The payment's purpose has no QuickBooks income item mapping." + ); + } + if (!mapping.stripeClearingAccountId) { + return attention( + "missing_stripeClearingAccountId", + "No Stripe clearing account is mapped for QuickBooks posting." + ); + } + if (!mapping.fallbackCustomerId) { + return attention( + "missing_fallbackCustomerId", + "No generic customer is mapped for QuickBooks posting." + ); + } + + // --- Canonical Stripe verification (provider boundary, never the + // stored record alone) --- + let session: Record; + try { + session = (await getStripeClient().checkout.sessions.retrieve( + sessionId + )) as unknown as Record; + } catch (error) { + return failure( + toPaymentProviderError(error).code, + "Stripe settlement could not be re-verified — retry later." + ); + } + if (paymentIdFromSession(session) !== sourceId) { + return attention( + "identity_mismatch", + "The Stripe session does not belong to this payment record." + ); + } + if (session.status === "open") { + return failure( + "stripe_not_settled", + "Stripe has not finished settling this payment — retry later." + ); + } + if (session.status !== "complete" || session.payment_status !== "paid") { + return attention( + "stripe_not_settled", + "Stripe's canonical state does not show this payment as settled." + ); + } + if (session.amount_total !== amountMinor) { + return attention( + "amount_mismatch", + "Stripe's settled amount differs from the payment record." + ); + } + const sessionCurrency = + typeof session.currency === "string" + ? session.currency.toUpperCase() + : null; + if (sessionCurrency !== currency) { + return attention( + "currency_mismatch", + "Stripe's settled currency differs from the payment record." + ); + } + const sessionPi = session.payment_intent; + const paymentIntentId = + (typeof sessionPi === "string" + ? sessionPi + : sessionPi && + typeof sessionPi === "object" && + typeof (sessionPi as { id?: unknown }).id === "string" + ? (sessionPi as { id: string }).id + : undefined) ?? + (typeof payment.stripePaymentIntentId === "string" + ? payment.stripePaymentIntentId + : undefined); + const chargeId = + typeof payment.stripeChargeId === "string" ? payment.stripeChargeId : undefined; + + // --- Provider calls (token + correlation lookup + create) --- + try { + const { accessToken, realmId } = await getQuickBooksAccessToken(); + + // `GlobalTaxCalculation` is required on sales transactions for non-US + // companies and rejected for US ones — derive it from the connected + // company's country, never from assumption. + const connectionSnap = await db + .collection(QBO_CONNECTIONS_COLLECTION) + .doc(environment) + .get(); + const companyCountry = connectionSnap.data()?.companyCountry; + const globalTaxCalculation = + companyCountry === "US" ? undefined : ("NotApplicable" as const); + + // Provider-side recovery: adopt a receipt that already carries this + // payment's marker rather than posting a duplicate. + const existing = await findQboSalesReceiptForMarker({ + environment, + realmId, + accessToken, + customerId: mapping.fallbackCustomerId, + marker: qboSalesReceiptMarker(sourceId), + }); + if (existing) { + return { + kind: "synced", + qboEntityId: existing.id, + realmId, + adopted: true, + correlationId: existing.correlationId, + }; + } + + const created = await createQboSalesReceipt({ + environment, + realmId, + accessToken, + payload: buildQboSalesReceiptPayload({ + sourceId, + amountMinor, + currency, + paidAtMs, + description: + typeof payment.description === "string" + ? payment.description + : undefined, + clearingAccountId: mapping.stripeClearingAccountId, + incomeItemId, + customerId: mapping.fallbackCustomerId, + paymentIntentId, + chargeId, + globalTaxCalculation, + }), + }); + return { + kind: "synced", + qboEntityId: created.id, + realmId, + adopted: false, + correlationId: created.correlationId, + }; + } catch (error) { + return classifyQboSyncError(error); + } +} + +// Processes one durable sync record through the Sales-Receipt write path. +// Idempotent end to end: a `synced` record returns its stored entity id, +// a fresh claim performs the gated write, and every outcome is committed +// back to the record so later triggers converge. +export async function processQboSyncRecord( + syncId: string +): Promise { + const environment = getQboEnvironment(); + const leaseUntilMs = Date.now() + SYNC_CLAIM_LEASE_MS; + const claim = await getFirebaseAdminDb().runTransaction(async (tx) => { + const snap = await tx.get(syncRef(syncId)); + const record = snap.data() as QboSyncRecordDoc | undefined; + if (!snap.exists || !record) return { action: "missing" as const }; + if (record.status === "synced") { + return { + action: "already_synced" as const, + qboEntityId: record.qboEntityId, + }; + } + if (record.status === "needs_attention") { + return { + action: "needs_attention" as const, + reason: + typeof record.lastErrorCode === "string" && record.lastErrorCode + ? record.lastErrorCode + : "needs_attention", + }; + } + if ( + record.status === "syncing" && + record.syncingLeaseUntil && + record.syncingLeaseUntil.toMillis() > Date.now() + ) { + return { action: "in_progress" as const }; + } + tx.update(syncRef(syncId), { + status: "syncing", + attempts: (typeof record.attempts === "number" ? record.attempts : 0) + 1, + lastAttemptAt: Timestamp.now(), + syncingLeaseUntil: Timestamp.fromMillis(leaseUntilMs), + updatedAt: Timestamp.now(), + }); + return { + action: "claimed" as const, + sourceType: record.sourceType, + sourceId: record.sourceId, + }; + }); + + switch (claim.action) { + case "missing": + return { outcome: "missing" }; + case "already_synced": + logInfo("qbo.sync.duplicate", { environment }); + return { outcome: "already_synced", qboEntityId: claim.qboEntityId }; + case "needs_attention": + return { outcome: "needs_attention", reason: claim.reason }; + case "in_progress": + return { outcome: "in_progress" }; + } + + const result = await runSyncWrite(claim.sourceType, claim.sourceId, environment); + await finalizeSyncClaim(syncId, leaseUntilMs, result); + + if (result.kind === "synced") { + logInfo("qbo.sync.posted", { + environment, + adopted: result.adopted, + correlationId: result.correlationId, + }); + return { + outcome: "synced", + qboEntityId: result.qboEntityId, + adopted: result.adopted, + }; + } + if (result.kind === "needs_attention") { + logWarn("qbo.sync.needs_attention", { + environment, + reason: result.code, + correlationId: result.correlationId, + }); + return { outcome: "needs_attention", reason: result.code }; + } + logError("qbo.sync.failed", undefined, { + environment, + reason: result.code, + correlationId: result.correlationId, + }); + return { outcome: "failed", reason: result.code }; +} + +// --- Producer seam (#179) --- + +// Called after a canonical `paid` commit (webhook or manual refresh). +// Enqueues the durable record, then attempts the write inline — the +// record's status captures the outcome either way, and a later sweep or +// retry (#183) picks up anything left pending/failed. +// +// NEVER throws and NEVER touches the payment record: accounting export +// must not roll back a settled charge or block the response path. +export async function postPaidPaymentToQbo(paymentId: string): Promise { + try { + const snap = await getFirebaseAdminDb() + .collection(PAYMENTS_COLLECTION) + .doc(paymentId) + .get(); + const payment = snap.data(); + if (!payment) { + logWarn("qbo.sync.skipped", { reason: "payment_missing", paymentId }); + return; + } + const status = normalizePaymentStatus(payment.status); + if (status !== "paid" && status !== "refunded") { + logInfo("qbo.sync.skipped", { reason: "not_ddb_settled", paymentId }); + return; + } + const paidAtMs = timestampMillis(payment.paidAt); + const externalRefs: Record = {}; + if ( + typeof payment.stripeCheckoutSessionId === "string" && + payment.stripeCheckoutSessionId + ) { + externalRefs.checkoutSessionId = payment.stripeCheckoutSessionId; + } + if ( + typeof payment.stripePaymentIntentId === "string" && + payment.stripePaymentIntentId + ) { + externalRefs.paymentIntentId = payment.stripePaymentIntentId; + } + if (typeof payment.stripeChargeId === "string" && payment.stripeChargeId) { + externalRefs.chargeId = payment.stripeChargeId; + } + const { syncId } = await enqueueAccountingTransaction({ + sourceType: QBO_STRIPE_PAYMENT_SOURCE_TYPE, + sourceId: paymentId, + amountMinorUnits: + typeof payment.amountMinor === "number" ? payment.amountMinor : 0, + currency: + typeof payment.currency === "string" && payment.currency + ? payment.currency + : "usd", + transactionDate: new Date(paidAtMs ?? Date.now()).toISOString(), + customerName: + typeof payment.customerName === "string" + ? payment.customerName + : undefined, + customerEmail: + typeof payment.customerEmail === "string" + ? payment.customerEmail + : undefined, + purpose: + typeof payment.purpose === "string" ? payment.purpose : undefined, + description: + typeof payment.description === "string" + ? payment.description + : undefined, + externalRefs: Object.keys(externalRefs).length ? externalRefs : undefined, + tourDate: + typeof payment.tourDate === "string" ? payment.tourDate : undefined, + attendeeCount: + typeof payment.attendeeCount === "number" + ? payment.attendeeCount + : undefined, + }); + await processQboSyncRecord(syncId); + } catch (error) { + // A deployment without QBO configured hits this on every paid + // payment — warn, don't alarm. Genuine failures are errors. + if (error instanceof QboError && error.kind === "configuration") { + logWarn("qbo.sync.unavailable", { paymentId }); + } else { + logError("qbo.sync.trigger_failed", error, { paymentId }); + } + } +} diff --git a/tests/lib/qbo-salesreceipt.test.ts b/tests/lib/qbo-salesreceipt.test.ts new file mode 100644 index 0000000..d1a588f --- /dev/null +++ b/tests/lib/qbo-salesreceipt.test.ts @@ -0,0 +1,206 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + qboIncomeItemKeyForPurpose, + qboSalesReceiptDocNumber, + qboSalesReceiptMarker, +} from "@/lib/qbo-common"; +import { + buildQboSalesReceiptPayload, + canonicalizeQboSalesReceiptCreated, + canonicalizeQboSalesReceiptRefs, + qboSalesReceiptCorrelationQuery, +} from "@/lib/qbo-protocol"; +import { QboError } from "@/lib/qbo-errors"; + +const BASE_SPEC = { + sourceId: "9f8e7d6c-1234-4abc-9def-0123456789ab", + amountMinor: 500, + currency: "USD", + paidAtMs: Date.UTC(2026, 1, 5, 14, 30, 0), + description: "Brewery tour", + clearingAccountId: "42", + incomeItemId: "7", + customerId: "88", + paymentIntentId: "pi_3abc", + chargeId: "ch_3abc", +} as const; + +describe("qboIncomeItemKeyForPurpose", () => { + it("maps tour-family purposes to the tour income item", () => { + for (const purpose of ["brewery_tour", "additional_guests", "private_tour"]) { + assert.strictEqual(qboIncomeItemKeyForPurpose(purpose), "tourIncomeItemId"); + } + }); + + it("maps tasting and other purposes to their items", () => { + assert.strictEqual( + qboIncomeItemKeyForPurpose("brewery_tour_tasting"), + "tastingIncomeItemId" + ); + assert.strictEqual(qboIncomeItemKeyForPurpose("other"), "otherIncomeItemId"); + }); + + it("fails closed on unknown or absent purposes", () => { + assert.strictEqual(qboIncomeItemKeyForPurpose("merch"), null); + assert.strictEqual(qboIncomeItemKeyForPurpose(""), null); + assert.strictEqual(qboIncomeItemKeyForPurpose(undefined), null); + }); +}); + +describe("qboSalesReceiptMarker / DocNumber", () => { + it("embeds the source id in a matchable marker", () => { + assert.strictEqual(qboSalesReceiptMarker("pay-1"), "ddb:pay-1"); + }); + + it("derives a deterministic DocNumber within QBO's 21-char limit", () => { + const doc = qboSalesReceiptDocNumber(BASE_SPEC.sourceId); + assert.ok(doc.startsWith("DDB-")); + assert.ok(doc.length <= 21); + assert.strictEqual(doc, qboSalesReceiptDocNumber(BASE_SPEC.sourceId)); + // Different source ids must not collide in the truncated suffix. + assert.notStrictEqual( + qboSalesReceiptDocNumber("aaaaaaaa-0000-0000-0000-000000000001"), + qboSalesReceiptDocNumber("bbbbbbbb-0000-0000-0000-000000000002") + ); + }); +}); + +describe("buildQboSalesReceiptPayload", () => { + it("posts the gross amount as decimal currency, never Stripe net", () => { + const payload = buildQboSalesReceiptPayload({ + ...BASE_SPEC, + amountMinor: 500, + }); + const line = (payload.Line as Record[])[0]; + assert.strictEqual(line.Amount, 5); + const detail = line.SalesItemLineDetail as Record; + assert.strictEqual(detail.Qty, 1); + assert.strictEqual(detail.UnitPrice, 5); + // $5.00 gross — the $0.45 fee and $4.55 net are #181's concern and + // must not appear anywhere in the payload. + assert.strictEqual(JSON.stringify(payload).includes("4.55"), false); + }); + + it("handles non-round cent amounts without float drift beyond cents", () => { + const payload = buildQboSalesReceiptPayload({ + ...BASE_SPEC, + amountMinor: 1234, + }); + assert.strictEqual( + (payload.Line as Record[])[0].Amount, + 12.34 + ); + }); + + it("targets the mapped item, clearing account, customer, and currency", () => { + const payload = buildQboSalesReceiptPayload(BASE_SPEC); + assert.deepStrictEqual(payload.CustomerRef, { value: "88" }); + assert.deepStrictEqual(payload.DepositToAccountRef, { value: "42" }); + assert.deepStrictEqual(payload.CurrencyRef, { value: "USD" }); + const detail = ( + payload.Line as Record[] + )[0].SalesItemLineDetail as Record; + assert.deepStrictEqual(detail.ItemRef, { value: "7" }); + // Never the generic deposit targets — no bank, no Undeposited Funds + // assumptions: the account id is exactly what was mapped. + }); + + it("dates the receipt at settlement (paidAt), not processing time", () => { + const payload = buildQboSalesReceiptPayload(BASE_SPEC); + assert.strictEqual(payload.TxnDate, "2026-02-05"); + }); + + it("carries the correlation marker and provider refs in PrivateNote", () => { + const payload = buildQboSalesReceiptPayload(BASE_SPEC); + const note = payload.PrivateNote as string; + assert.ok(note.includes(`ddb:${BASE_SPEC.sourceId}`)); + assert.ok(note.includes("pi_3abc")); + assert.ok(note.includes("ch_3abc")); + assert.strictEqual(payload.DocNumber, qboSalesReceiptDocNumber(BASE_SPEC.sourceId)); + }); + + it("sends GlobalTaxCalculation only for non-US companies — no US tax semantics", () => { + const nonUs = buildQboSalesReceiptPayload({ + ...BASE_SPEC, + globalTaxCalculation: "NotApplicable", + }); + assert.strictEqual(nonUs.GlobalTaxCalculation, "NotApplicable"); + const us = buildQboSalesReceiptPayload(BASE_SPEC); + assert.ok(!("GlobalTaxCalculation" in us)); + // Tax posting is disabled entirely: no TaxCodeRef anywhere, regardless. + assert.strictEqual(JSON.stringify(nonUs).includes("TaxCodeRef"), false); + assert.strictEqual(JSON.stringify(us).includes("TaxCodeRef"), false); + }); + + it("truncates the line description and omits it when absent", () => { + const long = buildQboSalesReceiptPayload({ + ...BASE_SPEC, + description: "x".repeat(600), + }); + assert.strictEqual( + ((long.Line as Record[])[0].Description as string) + .length, + 500 + ); + const minimal = buildQboSalesReceiptPayload({ + ...BASE_SPEC, + description: undefined, + }); + assert.ok(!("Description" in (minimal.Line as Record[])[0])); + }); +}); + +describe("canonicalizeQboSalesReceiptCreated", () => { + it("extracts the provider entity id", () => { + assert.deepStrictEqual( + canonicalizeQboSalesReceiptCreated({ SalesReceipt: { Id: "917" } }), + { id: "917" } + ); + assert.deepStrictEqual( + canonicalizeQboSalesReceiptCreated({ SalesReceipt: { Id: 42 } }), + { id: "42" } + ); + }); + + it("rejects a malformed response without leaking payload", () => { + assert.throws( + () => canonicalizeQboSalesReceiptCreated({ SalesReceipt: {} }), + (e) => e instanceof QboError && e.kind === "unexpected" + ); + assert.throws( + () => canonicalizeQboSalesReceiptCreated(null), + (e) => e instanceof QboError + ); + }); +}); + +describe("sales receipt correlation lookup", () => { + it("queries recent receipts for the mapped customer", () => { + const query = qboSalesReceiptCorrelationQuery("cust'7"); + assert.ok(query.includes("SalesReceipt")); + assert.ok(query.includes("CustomerRef = 'cust7'")); // sanitized id + assert.ok(query.includes("PrivateNote")); + }); + + it("canonicalizes id + privateNote refs for marker matching", () => { + const refs = canonicalizeQboSalesReceiptRefs({ + QueryResponse: { + SalesReceipt: [ + { Id: "1", PrivateNote: "Deep Dive Brewing payment ddb:pay-1; Stripe PI pi_1" }, + { Id: "2" }, + { PrivateNote: "no id — dropped" }, + ], + }, + }); + assert.deepStrictEqual(refs, [ + { + id: "1", + privateNote: "Deep Dive Brewing payment ddb:pay-1; Stripe PI pi_1", + }, + { id: "2", privateNote: undefined }, + ]); + assert.deepStrictEqual(canonicalizeQboSalesReceiptRefs({}), []); + assert.deepStrictEqual(canonicalizeQboSalesReceiptRefs(null), []); + }); +}); diff --git a/tests/lib/qbo-sync-worker.test.ts b/tests/lib/qbo-sync-worker.test.ts new file mode 100644 index 0000000..f594b71 --- /dev/null +++ b/tests/lib/qbo-sync-worker.test.ts @@ -0,0 +1,421 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; +import { Timestamp } from "firebase-admin/firestore"; +import { QboError } from "@/lib/qbo-errors"; +import { installQboDbMock, withEnv } from "./qbo-test-helpers"; + +// --- Shared mock state (reset per test) --- + +const firestore = installQboDbMock(); + +const state = { + // Stripe session the canonical fetch returns (or throws). + session: null as Record | null, + sessionError: null as unknown, + // Mapping view the worker sees. + mapping: { + configured: true, + mapping: { + stripeClearingAccountId: "acct-stripe-balance", + tourIncomeItemId: "item-tour", + tastingIncomeItemId: "item-tasting", + otherIncomeItemId: "item-other", + fallbackCustomerId: "cust-generic", + }, + } as { configured: boolean; mapping?: Record }, + tokenError: null as unknown, + // Provider-side recovery lookup result. + foundReceipt: null as { id: string; correlationId?: string } | null, + findError: null as unknown, + createResult: { id: "9001", correlationId: "tid-1" } as { + id: string; + correlationId?: string; + }, + createError: null as unknown, + createCalls: [] as Record[], + retrieveCalls: [] as string[], +}; + +mock.module("@/lib/stripe", { + namedExports: { + getStripeClient: () => ({ + checkout: { + sessions: { + retrieve: async (id: string) => { + state.retrieveCalls.push(id); + if (state.sessionError) throw state.sessionError; + return state.session; + }, + }, + }, + }), + }, +}); + +mock.module("@/lib/qbo-tokens", { + namedExports: { + getQuickBooksAccessToken: async () => { + if (state.tokenError) throw state.tokenError; + return { accessToken: "at", realmId: "realm-prod" }; + }, + }, +}); + +mock.module("@/lib/qbo-mapping", { + namedExports: { + getQboMappingView: async () => state.mapping, + }, +}); + +mock.module("@/lib/qbo-api", { + namedExports: { + findQboSalesReceiptForMarker: async () => { + if (state.findError) throw state.findError; + return state.foundReceipt; + }, + createQboSalesReceipt: async (input: { payload: Record }) => { + state.createCalls.push(input.payload); + if (state.createError) throw state.createError; + return state.createResult; + }, + }, +}); + +const ENV = { QBO_ENVIRONMENT: "sandbox" }; +const RECORDS = "qboSyncRecords"; +const PAYMENT_ID = "9f8e7d6c-1234-4abc-9def-0123456789ab"; +const SYNC_ID = `sandbox:stripe_payment:${PAYMENT_ID}`; + +function paidPayment(overrides: Record = {}) { + return { + status: "paid", + purpose: "other", + description: "QBO Test", + amountMinor: 500, + currency: "usd", + customerName: "Chad Nuttall", + stripeCheckoutSessionId: "cs_test_1", + stripePaymentIntentId: "pi_1", + stripeChargeId: "ch_1", + paidAt: "2026-02-05T14:30:00Z", + ...overrides, + }; +} + +function paidSession(overrides: Record = {}) { + return { + id: "cs_test_1", + object: "checkout.session", + status: "complete", + payment_status: "paid", + amount_total: 500, + currency: "usd", + payment_intent: "pi_1", + metadata: { paymentId: PAYMENT_ID }, + client_reference_id: PAYMENT_ID, + ...overrides, + }; +} + +function reset(seed: Record> = {}) { + firestore.reset({ + "qboConnections/sandbox": { + status: "connected", + realmId: "realm-prod", + companyCountry: "CW", + }, + ...seed, + }); + state.session = paidSession(); + state.sessionError = null; + state.mapping = { + configured: true, + mapping: { + stripeClearingAccountId: "acct-stripe-balance", + tourIncomeItemId: "item-tour", + tastingIncomeItemId: "item-tasting", + otherIncomeItemId: "item-other", + fallbackCustomerId: "cust-generic", + }, + }; + state.tokenError = null; + state.foundReceipt = null; + state.findError = null; + state.createResult = { id: "9001", correlationId: "tid-1" }; + state.createError = null; + state.createCalls = []; + state.retrieveCalls = []; +} + +async function post(paymentId: string) { + const { postPaidPaymentToQbo } = await import("@/lib/qbo-sync"); + return withEnv(ENV, () => postPaidPaymentToQbo(paymentId)); +} + +async function process(syncId: string) { + const { processQboSyncRecord } = await import("@/lib/qbo-sync"); + return withEnv(ENV, () => processQboSyncRecord(syncId)); +} + +function syncDoc() { + return firestore.docs.get(`${RECORDS}/${SYNC_ID}`); +} + +describe("postPaidPaymentToQbo — source identity", () => { + it("posts one Sales Receipt for a settled DDB payment", async () => { + reset({ [`payments/${PAYMENT_ID}`]: paidPayment() }); + await post(PAYMENT_ID); + + assert.strictEqual(state.createCalls.length, 1); + const payload = state.createCalls[0]; + const line = (payload.Line as Record[])[0]; + assert.strictEqual(line.Amount, 5); + assert.deepStrictEqual(payload.DepositToAccountRef, { + value: "acct-stripe-balance", + }); + assert.deepStrictEqual(payload.CustomerRef, { value: "cust-generic" }); + assert.deepStrictEqual( + (line.SalesItemLineDetail as Record).ItemRef, + { value: "item-other" } + ); + // Non-US (CW) company → explicit non-tax calculation, never US semantics. + assert.strictEqual(payload.GlobalTaxCalculation, "NotApplicable"); + assert.strictEqual(payload.TxnDate, "2026-02-05"); + assert.ok((payload.PrivateNote as string).includes(`ddb:${PAYMENT_ID}`)); + + const doc = syncDoc(); + assert.strictEqual(doc?.status, "synced"); + assert.strictEqual(doc?.qboEntityType, "SalesReceipt"); + assert.strictEqual(doc?.qboEntityId, "9001"); + assert.strictEqual(doc?.realmId, "realm-prod"); + }); + + it("selects the income item by purpose", async () => { + reset({ + [`payments/${PAYMENT_ID}`]: paidPayment({ purpose: "brewery_tour_tasting" }), + }); + await post(PAYMENT_ID); + const detail = (state.createCalls[0].Line as Record[])[0] + .SalesItemLineDetail as Record; + assert.deepStrictEqual(detail.ItemRef, { value: "item-tasting" }); + }); + + it("never enqueues non-DDB activity — a Stripe charge with no payments record", async () => { + reset(); // No payments/ document — an Ollie/Spreedly charge looks exactly like this. + await post("some-foreign-charge"); + assert.strictEqual(state.createCalls.length, 0); + assert.strictEqual(firestore.docs.has(`${RECORDS}/sandbox:stripe_payment:some-foreign-charge`), false); + }); + + it("skips a record that exists but has not settled", async () => { + reset({ [`payments/${PAYMENT_ID}`]: paidPayment({ status: "awaiting_payment" }) }); + await post(PAYMENT_ID); + assert.strictEqual(state.createCalls.length, 0); + assert.strictEqual(syncDoc(), undefined); + }); + + it("marks needs_attention when the canonical session belongs to a different payment", async () => { + reset({ [`payments/${PAYMENT_ID}`]: paidPayment() }); + state.session = paidSession({ metadata: { paymentId: "other-id" }, client_reference_id: "other-id" }); + firestore.reset({ + "qboConnections/sandbox": { status: "connected", realmId: "realm-prod", companyCountry: "CW" }, + [`payments/${PAYMENT_ID}`]: paidPayment(), + [`${RECORDS}/${SYNC_ID}`]: { + sourceType: "stripe_payment", + sourceId: PAYMENT_ID, + status: "pending", + }, + }); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "needs_attention"); + assert.strictEqual(syncDoc()?.lastErrorCode, "identity_mismatch"); + assert.strictEqual(state.createCalls.length, 0); + }); +}); + +describe("processQboSyncRecord — idempotency and concurrency", () => { + it("creates no second receipt for a replayed trigger", async () => { + reset({ [`payments/${PAYMENT_ID}`]: paidPayment() }); + await post(PAYMENT_ID); + await post(PAYMENT_ID); // webhook replay / manual retry + assert.strictEqual(state.createCalls.length, 1); + assert.strictEqual(syncDoc()?.status, "synced"); + }); + + it("returns already_synced without provider calls for a synced record", async () => { + reset({ + [`payments/${PAYMENT_ID}`]: paidPayment(), + [`${RECORDS}/${SYNC_ID}`]: { + sourceType: "stripe_payment", + sourceId: PAYMENT_ID, + status: "synced", + qboEntityId: "777", + }, + }); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "already_synced"); + assert.strictEqual(state.retrieveCalls.length, 0); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("does not double-post when a fresh claim is already in progress", async () => { + reset({ + [`payments/${PAYMENT_ID}`]: paidPayment(), + [`${RECORDS}/${SYNC_ID}`]: { + sourceType: "stripe_payment", + sourceId: PAYMENT_ID, + status: "syncing", + syncingLeaseUntil: Timestamp.fromMillis(Date.now() + 60_000), + }, + }); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "in_progress"); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("adopts a receipt that landed but whose response was lost", async () => { + reset({ + [`payments/${PAYMENT_ID}`]: paidPayment(), + // A stale claim — the previous worker died after the QBO write. + [`${RECORDS}/${SYNC_ID}`]: { + sourceType: "stripe_payment", + sourceId: PAYMENT_ID, + status: "syncing", + syncingLeaseUntil: Timestamp.fromMillis(Date.now() - 60_000), + }, + }); + state.foundReceipt = { id: "5550", correlationId: "tid-lookup" }; + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "synced"); + assert.strictEqual(state.createCalls.length, 0, "must not create a duplicate"); + assert.strictEqual(syncDoc()?.qboEntityId, "5550"); + assert.strictEqual(syncDoc()?.status, "synced"); + }); +}); + +describe("processQboSyncRecord — gates and failures", () => { + function seedPending(paymentOverrides: Record = {}) { + reset({ + [`payments/${PAYMENT_ID}`]: paidPayment(paymentOverrides), + [`${RECORDS}/${SYNC_ID}`]: { + sourceType: "stripe_payment", + sourceId: PAYMENT_ID, + status: "pending", + }, + }); + } + + it("fails closed when accounting mapping is not configured", async () => { + seedPending(); + state.mapping = { configured: false }; + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "needs_attention"); + assert.strictEqual(syncDoc()?.lastErrorCode, "mapping_unconfigured"); + assert.strictEqual(state.createCalls.length, 0); + assert.strictEqual(state.retrieveCalls.length, 0, "no Stripe call before the gate"); + }); + + it("fails closed when a required mapping field is missing", async () => { + seedPending(); + state.mapping = { + configured: true, + mapping: { fallbackCustomerId: "cust-generic", otherIncomeItemId: "item-other" }, + }; + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "needs_attention"); + assert.strictEqual(syncDoc()?.lastErrorCode, "missing_stripeClearingAccountId"); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("fails closed when the payment's purpose has no item mapping", async () => { + seedPending({ purpose: "gift_card" }); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "needs_attention"); + assert.strictEqual(syncDoc()?.lastErrorCode, "purpose_unmapped"); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("marks missing payment records for attention without any provider write", async () => { + reset({ + [`${RECORDS}/${SYNC_ID}`]: { + sourceType: "stripe_payment", + sourceId: PAYMENT_ID, + status: "pending", + }, + }); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "needs_attention"); + assert.strictEqual(syncDoc()?.lastErrorCode, "payment_missing"); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("marks an unsettled canonical session for attention", async () => { + seedPending(); + state.session = paidSession({ payment_status: "unpaid" }); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "needs_attention"); + assert.strictEqual(syncDoc()?.lastErrorCode, "stripe_not_settled"); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("treats an open Stripe session as a retryable failure", async () => { + seedPending(); + state.session = paidSession({ status: "open", payment_status: "unpaid" }); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "failed"); + assert.strictEqual(syncDoc()?.status, "failed"); + assert.strictEqual(syncDoc()?.lastErrorCode, "stripe_not_settled"); + }); + + it("quarantines an amount mismatch between record and canonical session", async () => { + seedPending(); + state.session = paidSession({ amount_total: 999 }); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "needs_attention"); + assert.strictEqual(syncDoc()?.lastErrorCode, "amount_mismatch"); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("keeps the record retryable when the Stripe fetch itself fails", async () => { + seedPending(); + state.sessionError = new Error("network down"); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "failed"); + assert.strictEqual(syncDoc()?.status, "failed"); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("keeps the record retryable on QBO 5xx while the payment stays paid", async () => { + seedPending(); + state.createError = new QboError("QuickBooks is temporarily unavailable.", "unavailable", 502, "tid-err"); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "failed"); + const doc = syncDoc(); + assert.strictEqual(doc?.status, "failed"); + assert.strictEqual(doc?.lastErrorCode, "unavailable"); + assert.strictEqual(doc?.lastQboCorrelationId, "tid-err"); + // The payment record itself is never touched by export failures. + assert.strictEqual(firestore.docs.get(`payments/${PAYMENT_ID}`)?.status, "paid"); + }); + + it("marks QBO 4xx validation failures for attention — retrying cannot help", async () => { + seedPending(); + state.createError = new QboError("QuickBooks rejected the request.", "validation", 400); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "needs_attention"); + assert.strictEqual(syncDoc()?.status, "needs_attention"); + assert.strictEqual(syncDoc()?.lastErrorCode, "validation"); + }); + + it("records invalid_grant reconnect state as retryable, not lost", async () => { + seedPending(); + state.tokenError = new QboError( + "QuickBooks authorization needs to be renewed — reconnect the integration.", + "authorization_expired" + ); + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "failed"); + assert.strictEqual(syncDoc()?.lastErrorCode, "authorization_expired"); + assert.strictEqual(state.createCalls.length, 0); + }); +}); From 8e761061ba94bf974f0696ffc8528bc577a017ec Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sun, 4 Oct 2026 20:56:31 -0400 Subject: [PATCH 2/3] fix(quickbooks): harden Sales Receipt posting per review - Create the sync record inside the paid-commit transaction so a process exit after settlement can never strand a paid payment without an accounting-export record (post-commit enqueue is now an idempotent ensure, not the durability mechanism). - Bound every Intuit request (20s timeout on API + token endpoint) and refuse to start a create with <30s of claim lease left, so a bounded create can never overlap a successor reclaiming the record. - Paginate the correlation-marker recovery query (TxnDate sort + startposition/maxresults) so a receipt outside the first page cannot be missed and duplicated. - Tests: in-commit create via the real webhook path, post-commit failure durability, unconfigured-env no-op, replay dedupe, lease fence, recovery pagination; fake Firestore gains subcollections, auto-ids, and tx.set. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../quickbooks-accounting-sync.md | 51 ++-- lib/payments-admin.ts | 79 +++++- lib/qbo-api.ts | 62 +++-- lib/qbo-protocol.ts | 40 ++- lib/qbo-sync.ts | 199 +++++++++------ lib/qbo-tokens.ts | 4 + tests/lib/qbo-paid-commit.test.ts | 239 ++++++++++++++++++ tests/lib/qbo-salesreceipt.test.ts | 21 +- tests/lib/qbo-sync-worker.test.ts | 23 ++ tests/lib/qbo-test-helpers.ts | 15 +- 10 files changed, 610 insertions(+), 123 deletions(-) create mode 100644 tests/lib/qbo-paid-commit.test.ts diff --git a/docs/architecture/quickbooks-accounting-sync.md b/docs/architecture/quickbooks-accounting-sync.md index 5fce9ad..8f7b141 100644 --- a/docs/architecture/quickbooks-accounting-sync.md +++ b/docs/architecture/quickbooks-accounting-sync.md @@ -304,17 +304,15 @@ coupled to QBO availability — see §13. | Dashboard refund (outside the app) | **gap** — see §8 | **gap** — see §8 | | Stripe payout arrives | none | none — existing path owns cash | -The enqueue happens inside the same code path that commits the status -transition (`applyOutcome`/`commitRefund`), best-effort after the -Firestore commit so a QBO-side hiccup can never roll back a payment -transition. - -**Implemented trigger (#179):** `postPaidPaymentToQbo(paymentId)` runs -after every `paid` commit — inside `applyOutcome` (manual refresh and -the cancel-race path) and after `processStripeEvent` in the webhook -handler. It builds the `stripe_payment` candidate from the canonical -`payments` record, enqueues it, and attempts the write inline. The -helper never throws and never touches the payment record. +**Implemented trigger (#179):** the durable `stripe_payment` sync record +is created inside the *same Firestore transaction* that commits `paid` +(both `applyOutcome` and the webhook's `updatePayment` path), so a +process exit between commit and post-commit work can never strand a +settled payment with no record. After the commit, +`postPaidPaymentToQbo(paymentId)` runs the independent ensure + inline +write attempt — it builds the candidate from the canonical `payments` +record, enqueues if the record somehow does not exist, and attempts the +write. The helper never throws and never touches the payment record. ## 8. Refund model @@ -431,10 +429,16 @@ deterministic internal identity, QBO ids as correlation only. `already_synced` no-op. `needs_attention` → terminal until a human requeues. - **Provider-side recovery (implemented):** QBO offers no idempotency - key on create, so before every `POST /salesreceipt` the worker queries - the customer's recent receipts for the `ddb:` PrivateNote - marker. A write that landed but whose response was lost is adopted — - the record stores its entity id and marks `synced`. + key on create, so before every `POST /salesreceipt` the worker pages + through the customer's receipts (newest `TxnDate` first) looking for + the `ddb:` PrivateNote marker. A write that landed but + whose response was lost is adopted — the record stores its entity id + and marks `synced`. +- **Lease fencing (implemented):** every Intuit request is bounded by a + 20-second timeout, and the worker refuses to start a create with less + than ~30 seconds of claim lease left. A bounded create therefore can + never still be in flight when a successor reclaims the record; an + aborted-but-landed write is caught by the marker lookup on retry. - The QBO-side correlation written into the Sales Receipt (`PrivateNote` = `ddb:` + Stripe PI/charge refs, `DocNumber` = `DDB-…` truncated to 21 chars) gives a findable, @@ -443,13 +447,16 @@ deterministic internal identity, QBO ids as correlation only. ## 13. Failure / retry behavior -- **Enqueue is best-effort, post-commit**: if it throws, the payment is - still `paid`; a sweeper finds payments with no sync record and enqueues - them (reconciliation job or on-demand admin action). -- **Trigger mechanism (implemented, #179):** enqueue + inline write - attempt inside the request that committed `paid` — no cron exists in - this repo today (`vercel.json` has no `crons`). A bounded sweep route - for `pending`/`failed` stragglers remains #183. +- **Sync record creation is atomic with the paid commit (implemented, + #179):** the record lands in the same transaction as the settlement + write, so the "paid payment with no sync record" hole cannot occur + for payments settled after this shipped. The post-commit enqueue is + an idempotent ensure, not the durability mechanism. +- **Trigger mechanism (implemented, #179):** in-commit create + inline + write attempt inside the request that committed `paid` — no cron + exists in this repo today (`vercel.json` has no `crons`). A bounded + sweep route for `pending`/`failed` stragglers remains #183 (it also + covers the theoretical case of a pre-#179 paid payment). - **Attempts/backoff:** `attempts` counter + `lastAttemptAt` exist on the record; transient `QboError`s (`unavailable`/`rate_limited`, token `refresh_failed`, Stripe fetch failures) land in `failed` — diff --git a/lib/payments-admin.ts b/lib/payments-admin.ts index 4823c5a..11118ba 100644 --- a/lib/payments-admin.ts +++ b/lib/payments-admin.ts @@ -2,7 +2,19 @@ import "server-only"; import { FieldValue, Timestamp } from "firebase-admin/firestore"; import { getFirebaseAdminDb } from "@/lib/firebase-admin-db"; import { getStripeClient } from "@/lib/stripe"; -import { postPaidPaymentToQbo } from "@/lib/qbo-sync"; +import { + buildQboSyncRecordDoc, + postPaidPaymentToQbo, + qboStripePaymentCandidate, +} from "@/lib/qbo-sync"; +import { getQboEnvironment } from "@/lib/qbo-config"; +import { + normalizeQboSyncCandidate, + qboSyncIdFor, + QBO_STRIPE_PAYMENT_SOURCE_TYPE, + QBO_SYNC_RECORDS_COLLECTION, + type QboEnvironment, +} from "@/lib/qbo-common"; import { getStripeWebhookSecret, resolveCheckoutReturnBaseUrl, @@ -366,6 +378,52 @@ export async function cancelAdminPayment( // --- Reconciliation (manual refresh + webhook shared path) --- +// Durable QBO enqueue inside the `paid` commit transaction: creates the +// sync record atomically with the settlement write so a process exit +// between commit and post-commit work can never strand a paid payment +// with no accounting-export record (a replayed webhook would skip the +// already-marked event). +// +// The record's document id is the deterministic source identity, so a +// later enqueue or worker attempt on the same payment converges on this +// same record. The function performs a transaction READ — callers must +// invoke it before issuing any writes in the transaction — and returns +// the create to apply alongside the payment writes, or null when the +// record already exists or QBO is not configured. A skipped create is +// never a failure: accounting export must not roll back a settled +// charge, and the post-commit helper retries the enqueue anyway. +async function qboSyncCreateForPaidCommit( + tx: FirebaseFirestore.Transaction, + paymentId: string, + settledPayment: Record +): Promise<{ + ref: FirebaseFirestore.DocumentReference; + doc: Record; +} | null> { + let environment: QboEnvironment; + let doc: Record; + try { + environment = getQboEnvironment(); + doc = buildQboSyncRecordDoc( + environment, + normalizeQboSyncCandidate( + qboStripePaymentCandidate(settledPayment, paymentId) + ) + ); + } catch { + // Unconfigured environment or a malformed candidate — the payment + // commit proceeds regardless; the post-commit enqueue logs it. + return null; + } + const ref = getFirebaseAdminDb() + .collection(QBO_SYNC_RECORDS_COLLECTION) + .doc( + qboSyncIdFor(environment, QBO_STRIPE_PAYMENT_SOURCE_TYPE, paymentId) + ); + if ((await tx.get(ref)).exists) return null; + return { ref, doc }; +} + // Applies a Stripe outcome to the stored record inside a transaction — // same planner the webhook uses, so manual refresh and webhook delivery // can never diverge. @@ -386,6 +444,12 @@ async function applyOutcome( const base = typeof record.eventCount === "number" ? record.eventCount : 0; const updates = { ...plan.updates, ...(extraUpdates ?? {}) }; + // #179 — the durable sync record commits atomically with the paid + // transition. The read must run before this transaction's writes. + const syncCreate = + updates.status === "paid" + ? await qboSyncCreateForPaidCommit(tx, id, { ...record, ...updates }) + : null; tx.update(ref, { ...updates, eventCount: base + plan.events.length, @@ -396,6 +460,7 @@ async function applyOutcome( eventDoc(draft, base + index, null) ); }); + if (syncCreate) tx.create(syncCreate.ref, syncCreate.doc); return true; }); if (applied && outcome.transition === "paid") { @@ -928,6 +993,17 @@ function makeStripeEventStore(): StripeEventStore { const record = cache.get(paymentId); const base = typeof record?.eventCount === "number" ? record.eventCount : 0; + // #179 — same atomic durable enqueue as applyOutcome: the + // sync record lands in the paid commit so a later crash or + // replay can never leave the payment without one. The read + // must run before this transaction's writes. + const syncCreate = + updates.status === "paid" + ? await qboSyncCreateForPaidCommit(tx, paymentId, { + ...(record ?? {}), + ...updates, + }) + : null; tx.update(payments.doc(paymentId), { ...updates, eventCount: base + events.length, @@ -941,6 +1017,7 @@ function makeStripeEventStore(): StripeEventStore { eventDoc(draft, base + index, null) ); }); + if (syncCreate) tx.create(syncCreate.ref, syncCreate.doc); }, markStripeEventProcessed: async (eventId, result, meta) => { tx.set(markers.doc(eventId), { diff --git a/lib/qbo-api.ts b/lib/qbo-api.ts index 74f82da..3d89e15 100644 --- a/lib/qbo-api.ts +++ b/lib/qbo-api.ts @@ -11,8 +11,10 @@ import { qboErrorForHttpStatus, qboQueryRowCount, qboSalesReceiptCorrelationQuery, + qboSalesReceiptRowCount, QBO_QUERY_MAX_PAGES, QBO_QUERY_PAGE_SIZE, + QBO_REQUEST_TIMEOUT_MS, toQboError, } from "@/lib/qbo-protocol"; import type { @@ -56,7 +58,14 @@ async function qboApiFetch( Authorization: `Bearer ${input.accessToken}`, Accept: "application/json", }; - const init: RequestInit = { method: input.method ?? "GET", headers }; + const init: RequestInit = { + method: input.method ?? "GET", + headers, + // Bounded so a stalled request cannot outlive the caller's sync + // claim; an aborted create is ambiguous (the write may still land) + // and is recovered by the correlation-marker lookup on retry. + signal: AbortSignal.timeout(QBO_REQUEST_TIMEOUT_MS), + }; if (input.body !== undefined) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(input.body); @@ -194,7 +203,9 @@ export async function createQboSalesReceipt(input: { // Provider-side recovery lookup: find a Sales Receipt already carrying // this payment's correlation marker. Runs before every create so a // retry after an ambiguous outcome (write landed, response lost) adopts -// the existing receipt instead of posting a second one. +// the existing receipt instead of posting a second one. Pages through +// the customer's receipts — a single page could miss a receipt with an +// older or backdated TxnDate. export async function findQboSalesReceiptForMarker(input: { environment: QboEnvironment; realmId: string; @@ -202,23 +213,40 @@ export async function findQboSalesReceiptForMarker(input: { customerId: string; marker: string; }): Promise { - const { payload, correlationId } = await qboApiFetch({ - environment: input.environment, - realmId: input.realmId, - accessToken: input.accessToken, - url: buildQboQueryUrl( - input.environment, - input.realmId, - qboSalesReceiptCorrelationQuery(input.customerId) - ), - }); - const match = canonicalizeQboSalesReceiptRefs(payload).find((ref) => - ref.privateNote?.includes(input.marker) - ); + let correlationId: string | undefined; + for (let page = 0; page < QBO_QUERY_MAX_PAGES; page++) { + const { payload, correlationId: tid } = await qboApiFetch({ + environment: input.environment, + realmId: input.realmId, + accessToken: input.accessToken, + url: buildQboQueryUrl( + input.environment, + input.realmId, + qboSalesReceiptCorrelationQuery( + input.customerId, + page * QBO_QUERY_PAGE_SIZE + 1, + QBO_QUERY_PAGE_SIZE + ) + ), + }); + correlationId ??= tid; + const match = canonicalizeQboSalesReceiptRefs(payload).find((ref) => + ref.privateNote?.includes(input.marker) + ); + if (match) { + logInfo("qbo.api.salesreceipt_lookup", { + environment: input.environment, + found: true, + correlationId, + }); + return { id: match.id, correlationId }; + } + if (qboSalesReceiptRowCount(payload) < QBO_QUERY_PAGE_SIZE) break; + } logInfo("qbo.api.salesreceipt_lookup", { environment: input.environment, - found: Boolean(match), + found: false, correlationId, }); - return match ? { id: match.id, correlationId } : null; + return null; } diff --git a/lib/qbo-protocol.ts b/lib/qbo-protocol.ts index ad281ba..6867427 100644 --- a/lib/qbo-protocol.ts +++ b/lib/qbo-protocol.ts @@ -492,6 +492,14 @@ export const QBO_QUERY_PAGE_SIZE = 1000; // Safety bound so a misbehaving provider can never page forever. export const QBO_QUERY_MAX_PAGES = 10; +// Every Intuit request is bounded. An unbounded fetch would let a stalled +// connection hold a worker's sync claim (or a token-refresh lease) open +// until the runtime kills it — and a successor reclaiming the claim would +// have no way to know the first request was still in flight. Twenty +// seconds is comfortably above Intuit's normal latency and comfortably +// below the sync-claim lease. +export const QBO_REQUEST_TIMEOUT_MS = 20_000; + export function qboEntityQueryStatement( type: QboDiscoveryEntityType, startPosition?: number, @@ -660,16 +668,34 @@ export function canonicalizeQboSalesReceiptCreated(payload: unknown): { return { id: String(id) }; } -// Query statement for provider-side recovery: recent receipts posted to -// the mapped generic customer. PrivateNote is not queryable server-side, -// so the statement bounds the window and callers match the correlation -// marker locally. A landed-but-untracked write is always recent at retry -// time, so the most recent receipts are sufficient. +// Query statement for provider-side recovery: receipts posted to the +// mapped generic customer, newest first. PrivateNote is not queryable +// server-side, so the statement fetches pages and callers match the +// correlation marker locally — pagination is required because a receipt +// with an older/backdated TxnDate could fall outside a single page. +// TxnDate is a documented sortable SalesReceipt field; MetaData.CreateTime +// is not, so the sort stays on TxnDate. export function qboSalesReceiptCorrelationQuery( - customerId: string + customerId: string, + startPosition = 1, + maxResults = QBO_QUERY_PAGE_SIZE ): string { const safe = customerId.replace(/[^A-Za-z0-9_-]/g, ""); - return `select Id, PrivateNote, TxnDate from SalesReceipt where CustomerRef = '${safe}' orderby TxnDate desc maxresults 25`; + return `select Id, PrivateNote, TxnDate from SalesReceipt where CustomerRef = '${safe}' orderby TxnDate desc startposition ${startPosition} maxresults ${maxResults}`; +} + +// Raw row count in a SalesReceipt QueryResponse page — the pagination +// signal for the correlation lookup (same contract as qboQueryRowCount +// for discovery entities: canonicalization may drop malformed rows, so +// the raw count decides whether another page may exist). +export function qboSalesReceiptRowCount(payload: unknown): number { + const response = + typeof payload === "object" && payload !== null + ? (payload as Record).QueryResponse + : undefined; + if (typeof response !== "object" || response === null) return 0; + const rows = (response as Record).SalesReceipt; + return Array.isArray(rows) ? rows.length : 0; } export interface QboSalesReceiptRef { diff --git a/lib/qbo-sync.ts b/lib/qbo-sync.ts index 578ae74..0dc7059 100644 --- a/lib/qbo-sync.ts +++ b/lib/qbo-sync.ts @@ -62,6 +62,38 @@ export type QboEnqueueResult = | { syncId: string; outcome: "pending" } | { syncId: string; outcome: "duplicate" }; +// The durable record body for a fresh sync record — shared between the +// standalone enqueue path and the atomic in-commit create the payments +// seam performs inside the `paid` transition transaction. +export function buildQboSyncRecordDoc( + environment: QboEnvironment, + normalized: QuickBooksSyncCandidate +): Record { + const syncId = qboSyncIdFor( + environment, + normalized.sourceType, + normalized.sourceId + ); + return { + syncId, + sourceType: normalized.sourceType, + sourceId: normalized.sourceId, + status: "pending", + environment, + realmId: null, + qboEntityType: null, + qboEntityId: null, + attempts: 0, + lastAttemptAt: null, + lastErrorCode: null, + lastErrorMessage: null, + idempotencyKey: syncId, + candidate: persistableQboSyncCandidate(normalized), + createdAt: FieldValue.serverTimestamp(), + updatedAt: FieldValue.serverTimestamp(), + }; +} + // Enqueues a financial event for future QBO export. Idempotent: replaying // the same source identity returns `duplicate` and leaves the existing // record (including its current status) untouched. @@ -82,24 +114,7 @@ export async function enqueueAccountingTransaction( const outcome = await getFirebaseAdminDb().runTransaction(async (tx) => { const existing = await tx.get(ref); if (existing.exists) return "duplicate" as const; - tx.create(ref, { - syncId, - sourceType: normalized.sourceType, - sourceId: normalized.sourceId, - status: "pending", - environment, - realmId: null, - qboEntityType: null, - qboEntityId: null, - attempts: 0, - lastAttemptAt: null, - lastErrorCode: null, - lastErrorMessage: null, - idempotencyKey: syncId, - candidate: persistableQboSyncCandidate(normalized), - createdAt: FieldValue.serverTimestamp(), - updatedAt: FieldValue.serverTimestamp(), - }); + tx.create(ref, buildQboSyncRecordDoc(environment, normalized)); return "pending" as const; }); @@ -116,6 +131,16 @@ export async function enqueueAccountingTransaction( // crashed worker does not strand the record. const SYNC_CLAIM_LEASE_MS = 2 * 60 * 1000; +// Minimum claim lease remaining before a Sales Receipt create may start. +// QBO offers no create-time idempotency key, so a create must never be +// in flight when the lease could lapse — a successor reclaiming the +// record would not know the POST was still running and could create a +// second receipt. With every provider call bounded by +// QBO_REQUEST_TIMEOUT_MS, a create started with this margin always +// resolves before the lease expires; a landed-but-aborted write is then +// adopted by the next attempt's marker lookup instead of duplicated. +const SYNC_CREATE_MIN_LEASE_MS = 30_000; + interface QboSyncRecordDoc { sourceType?: string; sourceId?: string; @@ -240,7 +265,8 @@ async function finalizeSyncClaim( async function runSyncWrite( sourceType: string | undefined, sourceId: string | undefined, - environment: QboEnvironment + environment: QboEnvironment, + leaseUntilMs: number ): Promise { if (sourceType !== QBO_STRIPE_PAYMENT_SOURCE_TYPE || !sourceId) { return attention( @@ -412,6 +438,15 @@ async function runSyncWrite( }; } + // The create is the only unrecoverable-side-effect call in the path — + // refuse to start it unless enough lease remains for the bounded + // request to resolve before a successor could reclaim the record. + if (Date.now() > leaseUntilMs - SYNC_CREATE_MIN_LEASE_MS) { + return failure( + "lease_expiring", + "The sync claim nearly expired before the QuickBooks write — retry later." + ); + } const created = await createQboSalesReceipt({ environment, realmId, @@ -506,7 +541,12 @@ export async function processQboSyncRecord( return { outcome: "in_progress" }; } - const result = await runSyncWrite(claim.sourceType, claim.sourceId, environment); + const result = await runSyncWrite( + claim.sourceType, + claim.sourceId, + environment, + leaseUntilMs + ); await finalizeSyncClaim(syncId, leaseUntilMs, result); if (result.kind === "synced") { @@ -539,10 +579,71 @@ export async function processQboSyncRecord( // --- Producer seam (#179) --- +// Builds the sync candidate from a settled payment record. Shared by the +// post-commit helper and the atomic in-commit create the payments seam +// performs inside the `paid` transition transaction — pass the merged +// post-update record there so paidAt/externalRefs are already populated. +export function qboStripePaymentCandidate( + payment: Record, + paymentId: string +): QuickBooksSyncCandidate { + const paidAtMs = timestampMillis(payment.paidAt); + const externalRefs: Record = {}; + if ( + typeof payment.stripeCheckoutSessionId === "string" && + payment.stripeCheckoutSessionId + ) { + externalRefs.checkoutSessionId = payment.stripeCheckoutSessionId; + } + if ( + typeof payment.stripePaymentIntentId === "string" && + payment.stripePaymentIntentId + ) { + externalRefs.paymentIntentId = payment.stripePaymentIntentId; + } + if (typeof payment.stripeChargeId === "string" && payment.stripeChargeId) { + externalRefs.chargeId = payment.stripeChargeId; + } + return { + sourceType: QBO_STRIPE_PAYMENT_SOURCE_TYPE, + sourceId: paymentId, + amountMinorUnits: + typeof payment.amountMinor === "number" ? payment.amountMinor : 0, + currency: + typeof payment.currency === "string" && payment.currency + ? payment.currency + : "usd", + transactionDate: new Date(paidAtMs ?? Date.now()).toISOString(), + customerName: + typeof payment.customerName === "string" + ? payment.customerName + : undefined, + customerEmail: + typeof payment.customerEmail === "string" + ? payment.customerEmail + : undefined, + purpose: + typeof payment.purpose === "string" ? payment.purpose : undefined, + description: + typeof payment.description === "string" + ? payment.description + : undefined, + externalRefs: Object.keys(externalRefs).length ? externalRefs : undefined, + tourDate: + typeof payment.tourDate === "string" ? payment.tourDate : undefined, + attendeeCount: + typeof payment.attendeeCount === "number" + ? payment.attendeeCount + : undefined, + }; +} + // Called after a canonical `paid` commit (webhook or manual refresh). -// Enqueues the durable record, then attempts the write inline — the -// record's status captures the outcome either way, and a later sweep or -// retry (#183) picks up anything left pending/failed. +// The durable sync record is normally created inside the commit +// transaction itself; this helper is the independent ensure + inline +// processing attempt — a record committed without an in-tx create still +// gets enqueued here, and anything left pending/failed is picked up by a +// later sweep or retry (#183). // // NEVER throws and NEVER touches the payment record: accounting export // must not roll back a settled charge or block the response path. @@ -562,55 +663,9 @@ export async function postPaidPaymentToQbo(paymentId: string): Promise { logInfo("qbo.sync.skipped", { reason: "not_ddb_settled", paymentId }); return; } - const paidAtMs = timestampMillis(payment.paidAt); - const externalRefs: Record = {}; - if ( - typeof payment.stripeCheckoutSessionId === "string" && - payment.stripeCheckoutSessionId - ) { - externalRefs.checkoutSessionId = payment.stripeCheckoutSessionId; - } - if ( - typeof payment.stripePaymentIntentId === "string" && - payment.stripePaymentIntentId - ) { - externalRefs.paymentIntentId = payment.stripePaymentIntentId; - } - if (typeof payment.stripeChargeId === "string" && payment.stripeChargeId) { - externalRefs.chargeId = payment.stripeChargeId; - } - const { syncId } = await enqueueAccountingTransaction({ - sourceType: QBO_STRIPE_PAYMENT_SOURCE_TYPE, - sourceId: paymentId, - amountMinorUnits: - typeof payment.amountMinor === "number" ? payment.amountMinor : 0, - currency: - typeof payment.currency === "string" && payment.currency - ? payment.currency - : "usd", - transactionDate: new Date(paidAtMs ?? Date.now()).toISOString(), - customerName: - typeof payment.customerName === "string" - ? payment.customerName - : undefined, - customerEmail: - typeof payment.customerEmail === "string" - ? payment.customerEmail - : undefined, - purpose: - typeof payment.purpose === "string" ? payment.purpose : undefined, - description: - typeof payment.description === "string" - ? payment.description - : undefined, - externalRefs: Object.keys(externalRefs).length ? externalRefs : undefined, - tourDate: - typeof payment.tourDate === "string" ? payment.tourDate : undefined, - attendeeCount: - typeof payment.attendeeCount === "number" - ? payment.attendeeCount - : undefined, - }); + const { syncId } = await enqueueAccountingTransaction( + qboStripePaymentCandidate(payment, paymentId) + ); await processQboSyncRecord(syncId); } catch (error) { // A deployment without QBO configured hits this on every paid diff --git a/lib/qbo-tokens.ts b/lib/qbo-tokens.ts index c3ec2fa..a7238bc 100644 --- a/lib/qbo-tokens.ts +++ b/lib/qbo-tokens.ts @@ -18,6 +18,7 @@ import { QBO_REFRESH_LEASE_MS, QBO_REFRESH_WAIT_ATTEMPTS, QBO_REFRESH_WAIT_DELAY_MS, + QBO_REQUEST_TIMEOUT_MS, type QboTokenEndpointRequest, type QboTokenSet, } from "@/lib/qbo-protocol"; @@ -90,6 +91,9 @@ export async function requestQboTokens( method: "POST", headers: request.headers, body: request.body, + // Bounded so a stalled token endpoint cannot hold the refresh + // lease (or a sync claim) open indefinitely. + signal: AbortSignal.timeout(QBO_REQUEST_TIMEOUT_MS), }); } catch { throw new QboError( diff --git a/tests/lib/qbo-paid-commit.test.ts b/tests/lib/qbo-paid-commit.test.ts new file mode 100644 index 0000000..7fd2f28 --- /dev/null +++ b/tests/lib/qbo-paid-commit.test.ts @@ -0,0 +1,239 @@ +import { describe, it, mock } from "node:test"; +import assert from "node:assert"; +import { QboError } from "@/lib/qbo-errors"; +import { installQboDbMock, withEnv } from "./qbo-test-helpers"; + +// Exercises the durable in-commit enqueue (#179): the QBO sync record is +// created inside the same Firestore transaction that settles the payment, +// driven here through the real webhook handler + real qbo-sync module — +// only the provider boundaries are mocked. + +const firestore = installQboDbMock(); + +const state = { + event: null as Record | null, + session: null as Record | null, + intent: null as Record | null, + mapping: { + configured: true, + mapping: { + stripeClearingAccountId: "acct-stripe-balance", + tourIncomeItemId: "item-tour", + tastingIncomeItemId: "item-tasting", + otherIncomeItemId: "item-other", + fallbackCustomerId: "cust-generic", + }, + } as { configured: boolean; mapping?: Record }, + foundReceipt: null as { id: string; correlationId?: string } | null, + createResult: { id: "9001", correlationId: "tid-1" } as { + id: string; + correlationId?: string; + }, + createError: null as unknown, + createCalls: [] as Record[], +}; + +mock.module("@/lib/stripe", { + namedExports: { + getStripeClient: () => ({ + webhooks: { constructEvent: () => state.event }, + checkout: { + sessions: { retrieve: async () => state.session }, + }, + paymentIntents: { retrieve: async () => state.intent }, + }), + }, +}); + +mock.module("@/lib/stripe-config", { + namedExports: { + getStripeWebhookSecret: () => "whsec_test", + resolveCheckoutReturnBaseUrl: () => "https://deepdivebrewing.com", + }, +}); + +mock.module("@/lib/qbo-tokens", { + namedExports: { + getQuickBooksAccessToken: async () => ({ + accessToken: "at", + realmId: "realm-prod", + }), + }, +}); + +mock.module("@/lib/qbo-mapping", { + namedExports: { getQboMappingView: async () => state.mapping }, +}); + +mock.module("@/lib/qbo-api", { + namedExports: { + findQboSalesReceiptForMarker: async () => state.foundReceipt, + createQboSalesReceipt: async (input: { + payload: Record; + }) => { + state.createCalls.push(input.payload); + if (state.createError) throw state.createError; + return state.createResult; + }, + }, +}); + +const ENV = { QBO_ENVIRONMENT: "sandbox" }; +const RECORDS = "qboSyncRecords"; +const PAYMENT_ID = "9f8e7d6c-1234-4abc-9def-0123456789ab"; +const SYNC_ID = `sandbox:stripe_payment:${PAYMENT_ID}`; + +function awaitingPayment() { + return { + status: "awaiting_payment", + stripeCheckoutSessionId: "cs_test_1", + amountMinor: 500, + currency: "usd", + purpose: "other", + description: "QBO Test", + customerName: "Chad Nuttall", + eventCount: 0, + }; +} + +function reset() { + firestore.reset({ + "qboConnections/sandbox": { + status: "connected", + realmId: "realm-prod", + companyCountry: "CW", + }, + [`payments/${PAYMENT_ID}`]: awaitingPayment(), + }); + state.event = { + id: "evt_paid_1", + type: "checkout.session.completed", + data: { object: { object: "checkout.session", id: "cs_test_1" } }, + }; + state.session = { + id: "cs_test_1", + object: "checkout.session", + status: "complete", + payment_status: "paid", + amount_total: 500, + currency: "usd", + payment_intent: "pi_1", + metadata: { paymentId: PAYMENT_ID }, + client_reference_id: PAYMENT_ID, + }; + state.intent = { + latest_charge: { + id: "ch_1", + receipt_url: "https://pay.stripe.com/receipts/test", + payment_method_details: { card: { brand: "visa", last4: "4242" } }, + }, + }; + state.foundReceipt = null; + state.createResult = { id: "9001", correlationId: "tid-1" }; + state.createError = null; + state.createCalls = []; + firestore.failGet.clear(); +} + +async function deliver(env: Record = ENV) { + const { handleStripeWebhook } = await import("@/lib/payments-admin"); + return withEnv(env, () => handleStripeWebhook("{}", "sig")); +} + +function syncDoc() { + return firestore.docs.get(`${RECORDS}/${SYNC_ID}`); +} + +describe("paid commit — durable QBO sync record (#179)", () => { + it("creates the sync record inside the paid commit and posts the receipt", async () => { + reset(); + const result = await deliver(); + assert.strictEqual(result.status, "applied"); + + // The record was committed atomically with the settlement — the + // post-commit worker then ran it to completion. + const doc = syncDoc(); + assert.ok(doc, "sync record must exist even before post-commit work"); + assert.strictEqual(doc.status, "synced"); + assert.strictEqual(doc.syncId, SYNC_ID); + assert.strictEqual(doc.environment, "sandbox"); + assert.strictEqual(doc.qboEntityId, "9001"); + + assert.strictEqual( + firestore.docs.get(`payments/${PAYMENT_ID}`)?.status, + "paid" + ); + assert.strictEqual(state.createCalls.length, 1); + }); + + it("leaves a durable pending record when the post-commit path fails", async () => { + reset(); + // Simulate the process losing its post-commit work: the enqueue's + // payment re-read fails, so nothing after the commit runs. + firestore.failGet.add(`payments/${PAYMENT_ID}`); + const result = await deliver(); + assert.strictEqual(result.status, "applied"); + + // The record landed inside the commit transaction — a later sweep or + // retry can process it; the payment is not stranded without one. + const doc = syncDoc(); + assert.ok(doc, "sync record must survive post-commit failure"); + assert.strictEqual(doc.status, "pending"); + assert.strictEqual(doc.attempts, 0); + assert.strictEqual( + firestore.docs.get(`payments/${PAYMENT_ID}`)?.status, + "paid" + ); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("still settles the payment when QBO is not configured", async () => { + reset(); + const result = await deliver({ QBO_ENVIRONMENT: undefined }); + assert.strictEqual(result.status, "applied"); + assert.strictEqual( + firestore.docs.get(`payments/${PAYMENT_ID}`)?.status, + "paid" + ); + assert.strictEqual( + firestore.docs.has(`${RECORDS}/${SYNC_ID}`), + false, + "no record is created for an unconfigured deployment" + ); + assert.strictEqual(state.createCalls.length, 0); + }); + + it("a replayed delivery creates neither a second record nor receipt", async () => { + reset(); + assert.strictEqual((await deliver()).status, "applied"); + assert.strictEqual((await deliver()).status, "duplicate"); + + assert.strictEqual(state.createCalls.length, 1); + assert.strictEqual(syncDoc()?.status, "synced"); + // Exactly one sync record exists for the payment. + const recordDocs = [...firestore.docs.keys()].filter((key) => + key.startsWith(RECORDS) + ); + assert.strictEqual(recordDocs.length, 1); + }); + + it("records a provider failure on the record — the payment stays paid", async () => { + reset(); + state.createError = new QboError( + "QuickBooks is temporarily unavailable.", + "unavailable", + 502, + "tid-err" + ); + const result = await deliver(); + assert.strictEqual(result.status, "applied"); + + const doc = syncDoc(); + assert.strictEqual(doc?.status, "failed"); + assert.strictEqual(doc?.lastErrorCode, "unavailable"); + assert.strictEqual( + firestore.docs.get(`payments/${PAYMENT_ID}`)?.status, + "paid" + ); + }); +}); diff --git a/tests/lib/qbo-salesreceipt.test.ts b/tests/lib/qbo-salesreceipt.test.ts index d1a588f..85f582b 100644 --- a/tests/lib/qbo-salesreceipt.test.ts +++ b/tests/lib/qbo-salesreceipt.test.ts @@ -10,6 +10,7 @@ import { canonicalizeQboSalesReceiptCreated, canonicalizeQboSalesReceiptRefs, qboSalesReceiptCorrelationQuery, + qboSalesReceiptRowCount, } from "@/lib/qbo-protocol"; import { QboError } from "@/lib/qbo-errors"; @@ -176,11 +177,27 @@ describe("canonicalizeQboSalesReceiptCreated", () => { }); describe("sales receipt correlation lookup", () => { - it("queries recent receipts for the mapped customer", () => { - const query = qboSalesReceiptCorrelationQuery("cust'7"); + it("queries recent receipts for the mapped customer with pagination", () => { + const query = qboSalesReceiptCorrelationQuery("cust'7", 101, 50); assert.ok(query.includes("SalesReceipt")); assert.ok(query.includes("CustomerRef = 'cust7'")); // sanitized id assert.ok(query.includes("PrivateNote")); + assert.ok(query.includes("startposition 101")); + assert.ok(query.includes("maxresults 50")); + // TxnDate is the documented sortable field — MetaData.CreateTime is + // not, so the recovery window orders by it. + assert.ok(query.includes("orderby TxnDate")); + }); + + it("counts raw SalesReceipt rows for the pagination signal", () => { + assert.strictEqual( + qboSalesReceiptRowCount({ + QueryResponse: { SalesReceipt: [{ Id: "1" }, { Id: "2" }] }, + }), + 2 + ); + assert.strictEqual(qboSalesReceiptRowCount({ QueryResponse: {} }), 0); + assert.strictEqual(qboSalesReceiptRowCount(null), 0); }); it("canonicalizes id + privateNote refs for marker matching", () => { diff --git a/tests/lib/qbo-sync-worker.test.ts b/tests/lib/qbo-sync-worker.test.ts index f594b71..01af710 100644 --- a/tests/lib/qbo-sync-worker.test.ts +++ b/tests/lib/qbo-sync-worker.test.ts @@ -34,6 +34,9 @@ const state = { createError: null as unknown, createCalls: [] as Record[], retrieveCalls: [] as string[], + // Milliseconds the canonical Stripe retrieve "consumes" — tests advance + // the mocked clock by this much to exercise the claim-lease fence. + retrieveDelayMs: 0, }; mock.module("@/lib/stripe", { @@ -43,6 +46,7 @@ mock.module("@/lib/stripe", { sessions: { retrieve: async (id: string) => { state.retrieveCalls.push(id); + if (state.retrieveDelayMs) mock.timers.tick(state.retrieveDelayMs); if (state.sessionError) throw state.sessionError; return state.session; }, @@ -145,6 +149,7 @@ function reset(seed: Record> = {}) { state.createError = null; state.createCalls = []; state.retrieveCalls = []; + state.retrieveDelayMs = 0; } async function post(paymentId: string) { @@ -407,6 +412,24 @@ describe("processQboSyncRecord — gates and failures", () => { assert.strictEqual(syncDoc()?.lastErrorCode, "validation"); }); + it("refuses to start the QBO create when the claim lease is nearly exhausted", async () => { + seedPending(); + mock.timers.enable({ apis: ["Date"], now: Date.now() }); + try { + // Canonical re-verification consumes all but the last seconds of + // the 2-minute claim — the create must not begin this late, or a + // successor could reclaim the record while the POST is in flight. + state.retrieveDelayMs = 95_000; + const result = await process(SYNC_ID); + assert.strictEqual(result.outcome, "failed"); + assert.strictEqual(syncDoc()?.lastErrorCode, "lease_expiring"); + assert.strictEqual(state.createCalls.length, 0); + } finally { + state.retrieveDelayMs = 0; + mock.timers.reset(); + } + }); + it("records invalid_grant reconnect state as retryable, not lost", async () => { seedPending(); state.tokenError = new QboError( diff --git a/tests/lib/qbo-test-helpers.ts b/tests/lib/qbo-test-helpers.ts index 9051ceb..8ed7a17 100644 --- a/tests/lib/qbo-test-helpers.ts +++ b/tests/lib/qbo-test-helpers.ts @@ -23,6 +23,7 @@ interface FakeSnapshot { export interface FakeDocRef { path: string; + collection(name: string): { doc(id?: string): FakeDocRef }; get(): Promise; update(data: Doc): Promise; set(data: Doc): Promise; @@ -32,11 +33,12 @@ interface FakeTx { get(ref: FakeDocRef): Promise; create(ref: FakeDocRef, data: Doc): void; update(ref: FakeDocRef, data: Doc): void; + set(ref: FakeDocRef, data: Doc): void; } export interface FakeFirestore { db: { - collection(name: string): { doc(id: string): FakeDocRef }; + collection(name: string): { doc(id?: string): FakeDocRef }; runTransaction(fn: (tx: FakeTx) => T | Promise): Promise; }; /** Live document contents keyed by "collection/docId". Tests may read @@ -116,11 +118,20 @@ export function createFakeFirestore(): FakeFirestore { applyWrite(ref.path, data); }, update: (ref, data) => applyWrite(ref.path, data), + set: (ref, data) => applyWrite(ref.path, data), }; + let autoIdCounter = 0; + // Stand-in for Firestore's client-generated document id — unique per + // test run and clearly synthetic. + const autoId = () => `__auto_${++autoIdCounter}`; + function docRef(path: string): FakeDocRef { return { path, + collection: (name: string) => ({ + doc: (id?: string) => docRef(`${path}/${name}/${id ?? autoId()}`), + }), get: () => failGet.has(path) ? Promise.reject(new Error(`Simulated Firestore failure: ${path}`)) @@ -132,7 +143,7 @@ export function createFakeFirestore(): FakeFirestore { const db = { collection: (name: string) => ({ - doc: (id: string) => docRef(`${name}/${id}`), + doc: (id?: string) => docRef(`${name}/${id ?? autoId()}`), }), runTransaction: (fn: (t: FakeTx) => T | Promise) => Promise.resolve(fn(tx)), From 06fa4b1792a20958279edda7c5ae17850ed166d5 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sun, 4 Oct 2026 21:06:37 -0400 Subject: [PATCH 3/3] fix(quickbooks): bound recovery lookup by settlement date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort alone cannot protect the marker lookup once the generic customer accumulates many receipts — a landed receipt with an old paidAt could sort beyond the page window and be duplicated. Filter on TxnDate = the payment's settlement date instead (a landed receipt always carries the posted TxnDate), keeping pagination as a backstop. MetaData.CreateTime remains unused: its sortability on SalesReceipt is not documented. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../quickbooks-accounting-sync.md | 11 ++++---- lib/qbo-api.ts | 3 +++ lib/qbo-protocol.ts | 26 ++++++++++++++----- lib/qbo-sync.ts | 1 + tests/lib/qbo-salesreceipt.test.ts | 24 +++++++++++++++-- 5 files changed, 51 insertions(+), 14 deletions(-) diff --git a/docs/architecture/quickbooks-accounting-sync.md b/docs/architecture/quickbooks-accounting-sync.md index 8f7b141..4f5fbf5 100644 --- a/docs/architecture/quickbooks-accounting-sync.md +++ b/docs/architecture/quickbooks-accounting-sync.md @@ -429,11 +429,12 @@ deterministic internal identity, QBO ids as correlation only. `already_synced` no-op. `needs_attention` → terminal until a human requeues. - **Provider-side recovery (implemented):** QBO offers no idempotency - key on create, so before every `POST /salesreceipt` the worker pages - through the customer's receipts (newest `TxnDate` first) looking for - the `ddb:` PrivateNote marker. A write that landed but - whose response was lost is adopted — the record stores its entity id - and marks `synced`. + key on create, so before every `POST /salesreceipt` the worker queries + the generic customer's receipts filtered to the payment's `TxnDate` + (its `paidAt`), paginating for the `ddb:` PrivateNote + marker. The date bound keeps the window small no matter how many + receipts accumulate. A write that landed but whose response was lost + is adopted — the record stores its entity id and marks `synced`. - **Lease fencing (implemented):** every Intuit request is bounded by a 20-second timeout, and the worker refuses to start a create with less than ~30 seconds of claim lease left. A bounded create therefore can diff --git a/lib/qbo-api.ts b/lib/qbo-api.ts index 3d89e15..5a08692 100644 --- a/lib/qbo-api.ts +++ b/lib/qbo-api.ts @@ -211,6 +211,8 @@ export async function findQboSalesReceiptForMarker(input: { realmId: string; accessToken: string; customerId: string; + /** The receipt's posted TxnDate (YYYY-MM-DD) — the payment's paidAt. */ + txnDate: string; marker: string; }): Promise { let correlationId: string | undefined; @@ -224,6 +226,7 @@ export async function findQboSalesReceiptForMarker(input: { input.realmId, qboSalesReceiptCorrelationQuery( input.customerId, + input.txnDate, page * QBO_QUERY_PAGE_SIZE + 1, QBO_QUERY_PAGE_SIZE ) diff --git a/lib/qbo-protocol.ts b/lib/qbo-protocol.ts index 6867427..84d7b59 100644 --- a/lib/qbo-protocol.ts +++ b/lib/qbo-protocol.ts @@ -668,20 +668,32 @@ export function canonicalizeQboSalesReceiptCreated(payload: unknown): { return { id: String(id) }; } +const QBO_TXN_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + // Query statement for provider-side recovery: receipts posted to the -// mapped generic customer, newest first. PrivateNote is not queryable -// server-side, so the statement fetches pages and callers match the -// correlation marker locally — pagination is required because a receipt -// with an older/backdated TxnDate could fall outside a single page. -// TxnDate is a documented sortable SalesReceipt field; MetaData.CreateTime -// is not, so the sort stays on TxnDate. +// mapped generic customer on the payment's own settlement date. The +// `TxnDate =` equality filter is the load-bearing bound — a landed +// receipt always carries the TxnDate we posted (paidAt), so the window +// stays small no matter how many receipts the generic customer +// accumulates. PrivateNote is not queryable server-side, so callers +// match the correlation marker locally; pagination is retained as a +// backstop for a busy settlement day. The sort uses TxnDate (a +// documented sortable SalesReceipt field) rather than +// MetaData.CreateTime, whose sortability is not documented. export function qboSalesReceiptCorrelationQuery( customerId: string, + txnDate: string, startPosition = 1, maxResults = QBO_QUERY_PAGE_SIZE ): string { const safe = customerId.replace(/[^A-Za-z0-9_-]/g, ""); - return `select Id, PrivateNote, TxnDate from SalesReceipt where CustomerRef = '${safe}' orderby TxnDate desc startposition ${startPosition} maxresults ${maxResults}`; + if (!QBO_TXN_DATE_PATTERN.test(txnDate)) { + throw new QboError( + "Sales receipt recovery query requires a YYYY-MM-DD transaction date.", + "unexpected" + ); + } + return `select Id, PrivateNote, TxnDate from SalesReceipt where CustomerRef = '${safe}' and TxnDate = '${txnDate}' orderby TxnDate desc startposition ${startPosition} maxresults ${maxResults}`; } // Raw row count in a SalesReceipt QueryResponse page — the pagination diff --git a/lib/qbo-sync.ts b/lib/qbo-sync.ts index 0dc7059..cf35b5b 100644 --- a/lib/qbo-sync.ts +++ b/lib/qbo-sync.ts @@ -426,6 +426,7 @@ async function runSyncWrite( realmId, accessToken, customerId: mapping.fallbackCustomerId, + txnDate: new Date(paidAtMs).toISOString().slice(0, 10), marker: qboSalesReceiptMarker(sourceId), }); if (existing) { diff --git a/tests/lib/qbo-salesreceipt.test.ts b/tests/lib/qbo-salesreceipt.test.ts index 85f582b..9d1e99a 100644 --- a/tests/lib/qbo-salesreceipt.test.ts +++ b/tests/lib/qbo-salesreceipt.test.ts @@ -177,11 +177,20 @@ describe("canonicalizeQboSalesReceiptCreated", () => { }); describe("sales receipt correlation lookup", () => { - it("queries recent receipts for the mapped customer with pagination", () => { - const query = qboSalesReceiptCorrelationQuery("cust'7", 101, 50); + it("queries the customer's receipts on the settlement date, paginated", () => { + const query = qboSalesReceiptCorrelationQuery( + "cust'7", + "2026-02-05", + 101, + 50 + ); assert.ok(query.includes("SalesReceipt")); assert.ok(query.includes("CustomerRef = 'cust7'")); // sanitized id assert.ok(query.includes("PrivateNote")); + // The TxnDate equality filter bounds the window to the payment's own + // settlement date — a landed receipt always carries the posted + // TxnDate (paidAt), no matter how many receipts the customer has. + assert.ok(query.includes("TxnDate = '2026-02-05'")); assert.ok(query.includes("startposition 101")); assert.ok(query.includes("maxresults 50")); // TxnDate is the documented sortable field — MetaData.CreateTime is @@ -189,6 +198,17 @@ describe("sales receipt correlation lookup", () => { assert.ok(query.includes("orderby TxnDate")); }); + it("rejects a malformed transaction date rather than building a bad query", () => { + assert.throws( + () => qboSalesReceiptCorrelationQuery("cust-1", "02/05/2026"), + (e) => e instanceof QboError + ); + assert.throws( + () => qboSalesReceiptCorrelationQuery("cust-1", "2026-02-05' OR '1'='1"), + (e) => e instanceof QboError + ); + }); + it("counts raw SalesReceipt rows for the pagination signal", () => { assert.strictEqual( qboSalesReceiptRowCount({