Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 67 additions & 44 deletions docs/architecture/quickbooks-accounting-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,16 +298,21 @@ 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 |
| 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):** 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

Expand Down Expand Up @@ -417,53 +422,71 @@ 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 generic customer's receipts filtered to the payment's `TxnDate`
(its `paidAt`), paginating for the `ddb:<paymentId>` 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
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:<paymentId>` + 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.
- **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; 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
Expand Down
14 changes: 14 additions & 0 deletions docs/operations/payments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 81 additions & 30 deletions docs/operations/quickbooks.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:<paymentId>` + 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:<paymentId>` 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

Expand Down
Loading
Loading