From 6b0bd8be8098eca11e0c235cef00a665ecf0e544 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 17 Oct 2026 19:27:10 -0400 Subject: [PATCH 1/3] docs(architecture): financial infrastructure simplification audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the Stripe/QBO/Ollie payment and reconciliation stack: inventory, flow traces, overengineering findings, native alternatives, and a keep/simplify/replace/remove plan. Investigation only — no code or accounting behavior changes. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../financial-simplification-audit.md | 509 ++++++++++++++++++ 1 file changed, 509 insertions(+) create mode 100644 docs/architecture/financial-simplification-audit.md diff --git a/docs/architecture/financial-simplification-audit.md b/docs/architecture/financial-simplification-audit.md new file mode 100644 index 0000000..fedbc44 --- /dev/null +++ b/docs/architecture/financial-simplification-audit.md @@ -0,0 +1,509 @@ +# Financial Architecture Simplification Audit + +**Date:** 2026-10-17 · **Scope:** Stripe payment collection, QuickBooks +Online (QBO) integration, Ollie POS (via Stripe/Spreedly), payout and +reconciliation automation, and the surrounding operational machinery. +**Status:** investigation only — no accounting behavior changes are proposed +for immediate implementation; each item lists its own gate. + +## Executive summary + +The codebase carries **~15,400 lines of financial `lib/` modules, ~1,100 +lines of API routes, ~4,100 lines of admin UI, and ~15,800 lines of +financial tests** — roughly 36,000 lines in total. About half of it is +justified and doing real work every day (the payments tool, the Stripe +webhook, the QBO connection, and the Sales Receipt sync). The other half is +a **payout-accounting automation platform** built across PRs #223–#239 +(2026-10-13 → 2026-10-15) that has **never executed a write in production**: +production runs `STRIPE_ACCOUNTING_MODE=observe`, so the decisions route, +the guarded executor, the apply route, and `automatic` mode all refuse by +construction. + +**The single most important finding:** nearly all of the exotic complexity +(confidence-tiered Ollie matching, Spreedly two-leg identity verification, +fingerprint-bound approvals, execution leases, uncertain-state recovery, +cutover/historical proposal variants) exists because **one Stripe account +carries two businesses' flows** — DDB web payments and Ollie/Spreedly +wholesale — so payouts are mixed-source and every generic tool (native +Stripe→QBO connectors included) would double-count the Ollie lane. The +pipeline exists to answer "which balance transactions in this payout are +ours, which are Ollie's, and what should be booked?" That *question* is +genuinely hard and the ledger that answers it is worth keeping. The +*machinery that acts on the answer* (approve → apply → auto-apply) is the +overbuild: at brewery volume (order of a few payouts and a few dozen +charges per month), the proposed entries are a checklist a human can post +in QuickBooks in minutes. + +**Recommended direction:** keep the observe-mode ledger as the permanent +discrepancy-detection surface, keep proposals as a computed worklist, and +**retire the dormant execution layer** (decisions, executor, apply route, +`automatic` mode, cutover/historical machinery) rather than continue +maintaining an un-armed write path that a single env var can switch from +"report" to "unattended QuickBooks writer." Independently, ask the +accountant whether Ollie payments even need reclassification — leaving them +in Undeposited Funds and matching the payout in the QBO bank feed is the +native mechanism for exactly this, and removes the business need behind the +most complex matching code in the repo. + +--- + +## 1. Current architecture + +``` + ┌──────────────────────────┐ + │ Stripe acct │ SHARED: + │ (DDB web + Ollie via │ mixed-source payouts + │ Spreedly gateway) │ + └───────┬──────────┬────────┘ + checkout.session.*│ │payout.* lifecycle + ▼ ▼ + ┌───────────────────────────────┐ + │ POST /api/webhooks/stripe │ signature verify, + │ (payments-admin.ts) │ stripeEvents dedupe + └───────┬───────────────┬───────┘ + │ │ after() → ledger build + canonical ▼ ▼ + re-fetch ┌───────────┐ ┌──────────────────────────┐ + + dedupe │ payments │ │ stripePayouts/{id} │ + + txn │ {id} paid │ │ + qboAccountingProposals │ observe-only + └─────┬─────┘ │ (processStripePayoutLedger)│ in prod + │ │ · lane classify │ + in-commit │ │ · Ollie match tiers │ + enqueue + ▼ │ · 3 proposal kinds │ + inline write┌──────────────────────────────────────┐ + │ qboSyncRecords (durable queue) │ + │ claim lease + backoff + needs_attention + └──────────┬───────────────────────────┘ + │ POST /v3/.../salesreceipt + ▼ + ┌──────────────────────────┐ ┌────────────────┐ + │ QuickBooks Online │◄────│ Ollie → QBO │ + │ · SalesReceipt (gross → │ │ (invoices + │ + │ Stripe Balance for │ │ payments to │ + │ card, cash acct for │ │ Undeposited │ + │ cash rail) │ │ Funds, method │ + │ · clearing/fee/transfer │ │ "Cash") │ + │ proposals: NEVER POST │ │ — native, no │ + │ in production │ │ DDB code │ + └──────────────────────────┘ └────────────────┘ + ┌─────────────────────────────────────────────────────────────────┐ + │ Recovery/oversight: Vercel cron /api/cron/qbo-sweep (daily) — │ + │ missed-enqueue recovery + due-record processing + payout-ledger │ + │ re-scan (35d). Admin: mapping UI, sync status + retry, recon │ + │ report (read-only), payout panel (proposals; decisions/apply │ + │ refuse in observe). QBO webhook → qboWebhookReceipts (stored, │ + │ never consumed). │ + └─────────────────────────────────────────────────────────────────┘ +``` + +### Flow A — DDB card payment → QBO (the live path) + +1. Staff creates a payment in `/admin/payments` + (`createAdminPayment`, `lib/payments-admin.ts`): durable + `payments/{clientRequestId}` record + event in a transaction, then one + Stripe Checkout Session (`idempotencyKey: paymentId`, card rail only). +2. Customer pays on the hosted page. `checkout.session.completed` → + webhook verifies signature, dedupes on `stripeEvents/{eventId}`, + re-fetches the session canonically, reconciles the stored snapshot + (mismatch → quarantine), commits `paid` + the `qboSyncRecords` doc **in + the same Firestore transaction**, then enriches receipt/card display. +3. `postPaidPaymentToQbo` attempts the write inline: re-verify canonical + session, mapping gate (fail closed on any missing required field), + marker lookup (`ddb:` PrivateNote scan on the generic + customer's receipts for that `TxnDate` — adoption, never duplication), + then `POST /salesreceipt` (gross, `paidAt` date, mapped clearing + account, mapped income item by purpose, `GlobalTaxCalculation` + semantics per company country). +4. Failures land in `failed` (6-step backoff → `needs_attention` after 8 + attempts) or `needs_attention` immediately for non-retryable causes. + The daily cron sweep retries due records and re-enqueues settled + payments that somehow lack a record (72 h window). Admin can requeue + manually (`/api/admin/quickbooks/sync/retry`). + +**The cash rail** (#206) shares the same seam: `cash_payment` sync record, +same worker, different deposit account/payment method, no provider calls. + +### Flow B — Ollie POS transaction + +Ollie creates the QBO Invoice + Payment itself (deposits to **Undeposited +Funds**, `PaymentMethodRef` = "Cash" regardless of the real rail) and +collects the money through Spreedly into **the same Stripe account** — +charge metadata carries `connect_agent: "Spreedly"` and a composite +`order_id` (`-`). DDB code does nothing until the +payout arrives; then the ledger tries to attribute each Ollie charge to a +QBO payment (see Flow C). **Historically these payments were treated as +cash in the books** — the current design wants to reclassify them to the +Stripe clearing account, which is a *choice*, not a requirement (see §5, +Ollie lane). + +### Flow C — Stripe payout + +1. `payout.*` webhook → `recordStripePayoutEvent`: dedupe + canonical + payout fetch + `stripePayouts/{id}` status doc, all inside the request; + `after()` runs `processStripePayoutLedger`. +2. The build fetches the payout's balance transactions (bounded pages), + builds the DDB identity index from `payments`, scans QBO Undeposited + Funds payments in a −45/+7-day window, resolves linked invoice + DocNumbers, then classifies every movement: + `ddb` (positive app identity) / `ollie` (match confidence + `verified`/`suggested`/`ambiguous`/`unmatched`, incl. Spreedly + composite-identity two-leg verification and customer-name demotion + rules) / `external` / `account_level` (fees, adjustments, the payout's + own debit). +3. It persists deterministic **proposals**: `move_payment_to_clearing` + (Ollie payment UF→Stripe Balance), `stripe_fee_expense` (JE: fee + expense / clearing), `payout_transfer` (clearing→mapped payout + account). Proposals are fingerprinted; decisions bind to the + fingerprint; `autoEligible` marks the narrow set `automatic` mode may + self-approve; `STRIPE_AUTOMATION_CUTOVER` splits eligible vs + `historical` proposals. +4. **In production (observe) this stops at step 3.** Decisions refuse, + apply refuses, the automatic sweep refuses. The payout panel is a + read-only worklist + discrepancy report (`ddb_receipt_unposted`, + ambiguous matches, foreign currency, unreconciled state). + +### Flow D — reconciliation report (manual) + +`POST /api/admin/quickbooks/reconciliation` → `runQboReconciliation` +(`lib/qbo-recon.ts`): a *windowed* (≤180 days) read-only scan — Stripe +balance transactions + totals, `payments` identity scan, charge/refund +enrichment (budget 40), QBO clearing/fee account balances and +existing-entry scan — that reports a clearing difference and findings +flags. Persisted snapshot `qboReconState/{env}`. + +## 2. Inventory of custom infrastructure + +| Subsystem | Files | ~LOC (impl) | Role | +| --- | --- | --- | --- | +| Payment tool (records, state machine, checkout spec, refund pipeline) | `lib/payments-common.ts` (1889), `lib/payments-admin.ts` (1321) | 3,210 | Core revenue collection | +| Stripe client + config | `lib/stripe.ts`, `lib/stripe-config.ts`, `lib/stripe-balance.ts` | ~620 | Provider reads + env policy | +| Stripe webhook endpoint | `app/api/webhooks/stripe/route.ts` + handlers | ~97 + in payments-admin | Settlement authority | +| QBO foundation | `lib/qbo.ts`, `qbo-tokens.ts`, `qbo-crypto.ts`, `qbo-config.ts`, `qbo-protocol.ts` (OAuth/query/canonicalization parts), `qbo-api.ts`, `qbo-errors.ts`, `qbo-common.ts`, `qbo-callback-cookie.ts`, `qbo-mapping.ts` | ~4,400 | OAuth, encrypted tokens, API client, mapping | +| Sales Receipt sync | `lib/qbo-sync.ts` (933), `lib/qbo-sweep.ts` (312), `lib/qbo-sync-admin.ts` (209), sync + retry + cron routes | ~1,600 | The only production write path | +| Reconciliation report | `lib/qbo-recon.ts` (439), `lib/qbo-recon-common.ts` (704), recon route, `admin-quickbooks-recon.tsx` (416) | ~1,650 | Read-only windowed report | +| Payout pipeline | `lib/stripe-payouts.ts` (1950), `lib/stripe-payouts-common.ts` (1766), payout + sync routes, `admin-quickbooks-payouts.tsx` (1111) | ~5,100 | Observe-mode ledger + proposals | +| Proposal **decisions** | `lib/stripe-payout-decisions.ts` (263), proposals route | ~380 | Approve/reject, fingerprint-bound — **dormant** | +| Proposal **executor** | `lib/stripe-payout-executor.ts` (821), apply route | ~910 | Guarded QBO writer — **dormant** | +| QBO webhook | `app/api/webhooks/quickbooks/route.ts` (68), `lib/qbo-webhook.ts` (127) | ~195 | Stores dedupe receipts — **no consumer** | +| Admin accounting UI | `admin-quickbooks-workspace.tsx` (634), `admin-quickbooks-mapping.tsx` (410), summary, pages | ~1,100 | Operations surface | +| Financial tests | `tests/lib/stripe-payout*.test.ts` (~5,900), `qbo-*`/`payments-*` (~9,900), api tests | ~15,800 | — | +| Collections | `payments`, `payments/events`, `stripeEvents`, `qboConnections`, `qboOauthStates`, `qboWebhookReceipts`, `qboConfig`, `qboSyncRecords`, `qboSweepState`, `qboReconState`, `stripePayouts`, `qboAccountingProposals` | 12 stores | all deny-all to clients | +| Env surface | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_ACCOUNTING_MODE`, `STRIPE_AUTOMATION_CUTOVER`, `QBO_ENVIRONMENT`, `QBO_CLIENT_ID`, `QBO_CLIENT_SECRET`, `QBO_REDIRECT_URI`, `QBO_WEBHOOK_VERIFIER_TOKEN`, `QBO_TOKEN_ENCRYPTION_KEY`, `CRON_SECRET`, `QBO_SWEEP_LOOKBACK_HOURS` | 12 vars | — | + +## 3. Findings — what is justified vs. overbuilt + +### Justified (this is the skeleton to keep) + +- **The payments tool and webhook settlement path.** Idempotent create, + canonical re-fetch, snapshot quarantine, terminal-state machine, + claim-based refund pipeline. This is real payment-safety work on a real + money surface; proportional. +- **QBO foundation** (OAuth one-time state, AES-256-GCM token storage, + single token-acquisition point with refresh lease, normalized error + taxonomy, environment isolation). Required for any write and correctly + scoped. +- **Durable sync record + worker** (`lib/qbo-sync.ts`). The in-commit + enqueue, deterministic `env:sourceType:sourceId` identity, claim lease, + and marker-based adoption are exactly the right pattern for a provider + with no idempotency keys. The 6-step backoff → `needs_attention` + escalation is simple and correct. +- **The daily cron sweep** — cheap, bounded recovery; at this volume it + will almost always no-op, which is the point. +- **Mapping config + live entity validation** — required to post safely; + fail-closed on incomplete config is the right gate. +- **Payout ingestion + ledger classification (observe)** — this is the + discrepancy-visibility requirement made real: `ddb_receipt_unposted` + catches a settled DDB payment whose Sales Receipt can't be proven; lane + attribution tells the operator what a mixed payout contains. Worth + keeping — *as a report*. + +### Overbuilt / disproportional (the audit's core findings) + +**F1. The proposal execution layer is a dormant write path.** +`stripe-payout-decisions.ts` (fingerprint-bound approve/reject, +`expectedFingerprint` conflict semantics, `confirmQboPaymentId`), +`stripe-payout-executor.ts` (claim lease, apply-time revalidation, +marker+legs recovery scan, `uncertain` state, adoption), the apply and +decision routes, and the decision UI in `admin-quickbooks-payouts.tsx` — +~1,300 implementation lines plus ~1,700 lines of dedicated tests plus UI — +exist to perform three QBO writes a human can do in minutes per payout. +Production has *never* armed it (`observe` refuses it end to end). It is +simultaneously the highest-risk code in the repo (the only path that can +mutate the books beyond Sales Receipts) and the least exercised. +Every future change to matching or proposals must keep this whole +apparatus consistent or it silently rots — a loaded gun in the repo. + +**F2. `automatic` mode was built before the pilot that would justify it.** +Self-approving `autoEligible` proposals, `pendingAutoApply` retry +scheduling, automation-recorded approvals that lapse on rebuild — all +shipped (#232) while production sits in `observe`. Speculative generality: +the correctness bar for unattended bookkeeping was engineered for a volume +and a confidence level the business hasn't reached. + +**F3. Cutover + historical-proposal machinery.** `STRIPE_AUTOMATION_CUTOVER`, +`:historical` proposal variants, `-HIST` marker namespaces, suppression of +historical proposals by existing entries, manual-only enforcement — built +to handle a backlog migration that (a) is a one-time event and (b) can be +done by hand in less time than the machinery took to design. ~Several +hundred lines inside `stripe-payouts*.ts`. + +**F4. The Ollie matching engine exceeds what the *decision* needs.** +Verified-tier matching requires: namespaced identifier extraction from +charge metadata/description, linked-invoice DocNumber resolution, the +Spreedly composite `order_id` two-leg corroboration (both legs must agree +on the same candidate), PaymentRefNum targeted lookups, cross-payout claim +pools, and customer-name demotion with a carved-out exception for fully +corroborated Spreedly matches. This is forensic-accounting-grade matching +to produce a `verified` badge whose only consumer is `autoEligible` — the +path that never runs. For a human-readable worklist, `suggested` (unique +amount+date+currency candidate) is sufficient evidence; a human confirms +in seconds at this scale. + +**F5. Two parallel reconciliation pipelines.** `qbo-recon.ts` (windowed +report) and `stripe-payouts.ts` (per-payout ledger) independently list +Stripe balance transactions, build a DDB identity index, classify +movements, and enrich unattributed charges — overlapping implementations +of the same idea (`resolveChargeAttribution` vs. the payout lane +classifier), ~1,650 + ~5,100 lines. The payout ledger is the richer, +more actionable one; the recon report's distinct value is only the +clearing-balance difference and the "are fees already booked" inspection. + +**F6. QBO webhook receipts are a dead end.** Verified, deduplicated, +persisted — and read by nothing. The comment says future sync consumes +them; the sync that shipped queries QBO directly instead. ~400 lines +(endpoint + module + tests + rules + indexes) of write-only storage. + +**F7. Distributed-lease proliferation.** Five distinct lease/fencing +mechanisms: sync-record claim lease, token-refresh lease, sweep cursor +CAS, payout-sync owner lease, executor claim lease. Each is locally +correct (Firestore has no other concurrency primitive and QBO writes +must not duplicate), but the pattern count signals a job-orchestration +platform built on a document store. Keeping the *sales-receipt* leases is +required; the payout-side leases mostly exist to protect the dormant +executor. + +**F8. Error/state surface area exceeds operating scale.** Sync statuses +× ~15 `needs_attention` reason codes; proposal statuses +(`proposed/blocked/approved/rejected/applied/stale`) × execution states +(`in_progress/applied/failed/refused/uncertain`) × confidence tiers × +lanes × scopes. Each individual state is defensible; the cartesian product +is sized for a payments-ops team, not a brewery. + +### Proportional but worth noting + +- The `payments` record + events subcollection + refund claim pipeline — + the audit trail is a feature, keep. +- `charge.refunded` is *not* subscribed — dashboard refunds are invisible + until the payout ledger surfaces them as exceptions (a real, if delayed, + detection path). #180 remains the explicit gap. +- Refund reversal posting is not implemented: a `refunded` payment's gross + Sales Receipt stays in the books until a human posts a RefundReceipt. + Documented manual procedure exists for cash; the same should apply to + card until #180 ships. + +## 4. Native alternatives — evaluated honestly + +| Alternative | What it actually does | Fit for DDB | +| --- | --- | --- | +| **Stripe Connector by QuickBooks** (Intuit's own app, free) | Auto-imports Stripe sales, refunds, fees, payouts → QBO; maps products→items; can match imported txns to existing QBO records | **Blocked by the shared account**: it imports *every* Stripe transaction — including Ollie/Spreedly charges that Ollie already books as invoices+payments → duplicate revenue. Becomes viable **only if DDB's web payments move to a dedicated Stripe account**. Caveats: CW (non-US) company support unverified; ad-hoc Checkout `price_data` items map weakly; introduces a second writer whose posting model (its own receipts + deposit) would collide with the current clearing model unless the custom sync is fully retired. | +| **QBO bank feed + match** | Deposits arriving at the bank match against UF payments / clearing transfers; "make deposits" handles batched settlement natively | **Strong.** This is the *native* mechanism for everything the proposal layer does: match a payout deposit to Ollie UF payments + clearing withdrawals + fee adjustments. Already partially in use (docs: payouts arrive as transfers involving UF). Zero code. | +| **Ollie → QBO integration** | Already creates invoices + payments; owns wholesale revenue end-to-end | Keep as-is. **Open question:** can Ollie's integration be configured to deposit payments to the Stripe clearing account (or another account) instead of UF? If yes, `move_payment_to_clearing` proposals lose their reason to exist. | +| **Stripe payout reconciliation report** (Dashboard CSV) | Per-payout itemization of charges/fees/refunds | Feeds a monthly fee JE with zero code; the accountant's existing tool for the same numbers the pipeline computes. | +| **Synder / A2X / Acodei etc.** ($20–80/mo) | Per-transaction or summarized payout sync | Same shared-account double-counting risk; adds vendor cost and a parallel posting model. Not recommended. | +| **Vercel cron** | Already used for the daily sweep | Correct tool; nothing to change. | + +## 5. The Ollie question (required careful examination) + +The user flagged: *Ollie payments have historically been represented as +cash in QBO.* Verified in code + docs: Ollie's own sync writes +`PaymentMethodRef = Cash` and deposits to Undeposited Funds; the books +reconciled payouts against those UF payments (bank feed / manual). + +The current design's `move_payment_to_clearing` proposal exists to +reclassify those payments into `Stripe Balance` so a single +clearing→bank transfer covers each payout. **But reclassification is not +the only correct model.** A mixed payout can settle two legs natively: +the Ollie share matched from UF (deposit match), the DDB share as a +clearing→bank transfer. That preserves the historical UF convention, +removes the write that touches Ollie's own records (the riskiest proposal +kind — it mutates a Payment Ollie created), and demotes the elaborate +verified-tier matching from *execution prerequisite* to *reporting aid*. + +This is a modeling decision for the accountant — both models are correct; +the UF-native one needs less machinery. + +## 6. Recommended minimum viable architecture + +``` +Stripe Checkout → payments record → webhook settle → Sales Receipt +(gross → clearing) [KEEP — the only auto-write] +Cash payment → same record → Sales Receipt (→ cash acct) [KEEP] +Ollie → QBO invoices+payments (UF) — Ollie's own integration [KEEP, untouched] +Payout webhook → ledger + classification + exception flags [KEEP as report] +Monthly close (human, ~15 min): per payout, post JE (fees, actual) + + transfer clearing→bank / bank-feed match UF payments [MANUAL, native] +Recon: single report (consolidated ledger view + clearing diff) [SIMPLIFY to one] +Refunds: document manual RefundReceipt procedure; #180 optional [DEFER or minimal] +``` + +In this architecture QuickBooks stays the system of record for money +movement and the app's job narrows to three things: **(1)** book the one +leg nobody else books (DDB gross receipts), **(2)** surface what a payout +contains and flag what doesn't reconcile, **(3)** never block a sale on +any of it. + +## 7. Keep / Simplify / Replace / Remove / Defer matrix + +| Component | Verdict | Rationale | +| --- | --- | --- | +| Payments tool (records, state machine, checkout, refunds) | **KEEP** | Core product + correct safety model | +| Stripe webhook (signature, dedupe, canonical re-fetch, quarantine) | **KEEP** | Settlement authority; required | +| QBO OAuth/token/crypto/connection layer | **KEEP** | Required for any write; correctly built | +| Mapping config + entity validation | **KEEP** | Posting safety gate | +| Sync queue + worker + backoff + marker adoption | **KEEP** | Prevents lost/duplicate receipts; right pattern | +| Daily sweep cron | **KEEP** | Bounded recovery, near-zero cost | +| Sync admin panel (counts, retry) | **KEEP** | The one ops surface that matters | +| Payout ingestion + ledger + lane classification (observe) | **KEEP (as report)** | Discrepancy visibility: `ddb_receipt_unposted`, unclassified/external flags, exceptions list | +| Computed proposals (3 kinds, rendered in admin) | **SIMPLIFY** | Keep as *computed-at-render checklist* — drop persistence of the proposal state machine (fingerprints/stale/revival) if no decision machinery remains | +| `suggested`-tier Ollie matching (amount+date+UF) | **SIMPLIFY** | Sufficient for a human worklist | +| `verified` tier: Spreedly two-leg, DocNumber resolution, name-demotion rules | **SIMPLIFY/DEFER** | Only feeds `autoEligible`/apply-time identity — dormant; demote to evidence annotation, not a gate | +| Fingerprint-bound decisions (`stripe-payout-decisions.ts`, proposals route) | **REMOVE** | Exists only to arm execution; never used in prod | +| Guarded executor (`stripe-payout-executor.ts`, apply route) | **REMOVE** | Dormant write path; highest-risk dead code. Correctness is preserved by human execution in QBO | +| `automatic` mode + `autoEligible` + `pendingAutoApply` | **REMOVE** | Speculative unattended bookkeeping; mode flag alone is a risk surface | +| Cutover/historical proposal machinery | **REMOVE** | One-time migration tooling; manual historical handling suffices | +| `STRIPE_ACCOUNTING_MODE` / `STRIPE_AUTOMATION_CUTOVER` env surface | **REMOVE** | Collapses with the execution layer | +| Windowed recon report (`qbo-recon*`) | **SIMPLIFY/MERGE** | Consolidate into the payout-ledger view; keep only clearing-balance diff + fee-booked inspection that the ledger lacks | +| QBO webhook + receipts collection | **REMOVE** | No consumer; unsubscribing at Intuit + deleting the route/storage removes ~400 lines. (Keep endpoint-removed docs note.) | +| Refund posting (RefundReceipt) — #180 | **DEFER** | Manual documented procedure acceptable at this volume; if built, keep minimal (enqueue on `refunded` → mirrored RefundReceipt — pipeline exists) | +| Fee posting (`stripe_fee` dormant vocabulary) | **DEFER** | Monthly accountant JE from Stripe payout report = zero code; revisit only if volume grows | +| Tax posting config — #184 | **DEFER (accountant gate)** | Mechanism ready; activation pending answers | +| Chargeback/dispute posting | **DEFER** | Classification+exception visibility already exists; posting manual | +| Stripe-account separation for Ollie vs web | **REPLACE (business change, not code)** | If Ollie can settle to a dedicated Stripe account (or Ollie's QBO integration can target the clearing account), the mixed-source problem — the root cause of most custom complexity — disappears, and even native connectors become viable | + +## 8. Prioritized remediation sequence + +Ranked by **maintenance savings ÷ financial risk**: + +**P0 — freeze the execution layer (docs/policy, near-zero risk)** +Set `STRIPE_ACCOUNTING_MODE` absent/`observe` permanently; document that +`approval`/`automatic` are retired vocabulary. Cost: trivial. This is the +default already — formalize it. + +**P1 — remove the dormant write path (~4–6k lines incl. tests/UI)** — S +Delete `stripe-payout-decisions.ts`, `stripe-payout-executor.ts`, the +proposals/apply/decision routes, `automatic`/`autoEligible`/`pendingAutoApply` +branches, cutover/historical proposal variants, and the decision/apply UI +blocks in `admin-quickbooks-payouts.tsx`. Keep proposal *computation* as a +read-only checklist on the payout card. Effort: medium (surgical deletion + +test pruning). Risk: low — path is unused; all safeguards (sales-receipt +dedupe, sync queue, ledger) unaffected. Rollback: git revert. + +**P2 — demote matching tiers (~300–500 lines)** — M +Keep `suggested`/`ambiguous`/`unmatched` display; reduce `verified` to an +evidence label (keep identifier extraction as *displayed evidence*, drop +it as an execution gate — including the executor-specific claim pools and +two-leg promotion if P1 lands). Risk: low; reporting-only. Rollback: revert. + +**P3 — consolidate reconciliation surfaces** — M +Fold the windowed recon report's unique value (clearing-balance diff, +existing-entries inspection) into the payout-ledger admin view; retire +`qbo-recon*` (~1,650 lines) or reduce it to a "balances" card. Risk: low — +read-only. Rollback: revert. + +**P4 — remove QBO webhook receipts** — S +Unsubscribe in the Intuit portal, delete route + `lib/qbo-webhook.ts` + +`qboWebhookReceipts` rules/tests. Confirm no future plan needs them. +Risk: trivial. Rollback: revert + re-subscribe. + +**P5 — bookkeeping-procedure documentation** — S +Write the ops doc for the manual monthly close: where fee totals come +from (Stripe payout report or proposal card), the two-leg payout match +(clearing transfer + UF match), refund reversal procedure (card + cash). +This is what makes "human executes" the designed path rather than tribal +knowledge. + +**Deferred items** keep their issues open: #180 (refund posting — decide +manual-forever vs minimal implementation), #184 (tax — accountant gate), +fee posting (monthly JE recommended), `automatic` payout execution +(revisit only if monthly payout count grows ≥10×). + +## 9. Estimated reduction + +| Change | Lines removed (approx.) | Notes | +| --- | --- | --- | +| P1 execution layer | ~4,000–6,000 (impl + routes + UI + tests) | biggest single win; also removes the only money-mutating surface beyond receipts | +| P2 matching demotion | ~300–600 | shrinks the hardest-to-review logic | +| P3 recon merge | ~1,200–1,650 | one reconciliation pipeline instead of two | +| P4 webhook receipts | ~400 | dead-end storage | +| **Total** | **~6,000–8,500 (~20–25% of the financial subsystem)** | plus retirement of the `approval`/`automatic` risk surface | + +Ongoing-maintenance effect: Stripe/QBO API changes, security reviews, and +payout-flow edits stop having to keep a dormant write path consistent; +the remaining surface is reads + one bounded write. + +## 10. Financial-correctness safeguards the simplification must preserve + +- Sales-Receipt dedupe (`env:source:sourceId` durable record + `ddb:` + marker adoption) — untouched. +- Positive DDB identity for posting — only `payments/` records post; the + Ollie lane is *never* written by us (after P1 there is no write that + could touch it at all). +- Webhook signature verification, canonical re-fetch, snapshot + quarantine — untouched. +- `needs_attention` human-review path — untouched. +- Ledger/exceptions continue to flag: unposted DDB receipts, ambiguous + matches, foreign currency, unattributed movements, clearing drift. +- Audit trail (`payments/events`, admin audit log, proposal history as + computed output) preserved. +- Nothing auto-posts: every QBO mutation beyond the Sales Receipt + requires a human in QBO — which is *strictly more conservative* than + the armed modes ever were. + +## 11. Where "simpler" would introduce accounting errors (explicit) + +- **Deleting the sync queue** in favor of fire-and-forget posting → lost + or duplicated receipts on transient failures. Not recommended. +- **Trusting webhook payloads** for amounts → corrupted records. Kept: + canonical re-fetch everywhere. +- **A generic Stripe→QBO connector on the shared account** → double-books + Ollie wholesale revenue. Explicitly rejected unless the Stripe account + is split. +- **Auto-matching Ollie payments by amount/date alone for *writes*** → + wrong-payment reclassification. Mitigated in the recommendation by + removing writes entirely; suggestions stay review-grade forever. +- **Posting net instead of gross** (or letting a connector do summarized + entries) → loses per-purpose income detail (tours vs tastings) the + business reports on. +- **Estimating fees** → wrong expense; actuals come from balance + transactions/payout report only. + +## 12. Decisions requiring owner / accountant input + +1. **Shared Stripe account**: can Ollie's Spreedly gateway point at a + separate/dedicated Stripe account for web payments? (Highest-leverage + question — eliminates mixed-source payouts entirely.) +2. **Ollie lane model**: reclassify Ollie payments to clearing (current + design) vs. leave in UF and two-leg match (native)? Also: can Ollie's + QBO integration target the clearing account at write time? +3. **Execution model**: app-executed proposals (approval mode) vs. + proposals-as-checklist + human posts in QBO (recommended). +4. **Fee booking**: per-payout JE vs monthly summary JE (recommend + monthly, from the Stripe report). +5. **Payout destination**: `Stripe Balance` clearing semantics vs bank — + accountant's call already encoded as `stripePayoutAccountId`. +6. **Refund gap (#180)**: manual procedure acceptable, or build minimal + RefundReceipt sync? +7. **Tax (#184)** — unchanged; still awaiting the accountant's answers. +8. **QBO webhook receipts**: safe to remove? (No consumer today; confirm + no planned use.) + +--- + +*Audit performed read-only against `main` (post-#239). No Stripe/QBO API +writes, no production mutations, no accounting changes. Verified against: +`docs/architecture/quickbooks-accounting-sync.md`, +`docs/operations/quickbooks.md`, `docs/operations/payments.md`, +`docs/TECHNICAL.md` §11, `lib/qbo-*.ts`, `lib/stripe-*.ts`, +`lib/payments-*.ts`, `app/api/admin/quickbooks/*`, `app/api/webhooks/*`, +`vercel.json`, `firestore.rules`, and git history (#161, #178–#184, #206, +#223–#239).* From ccb2c93b1456e62b5d8256bdc9b77c742db3fa99 Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 17 Oct 2026 19:36:54 -0400 Subject: [PATCH 2/3] docs(architecture): revise audit to commission payout automation The owner clarified that automatic Stripe payout accounting is a mandatory business requirement, so the original "retire the dormant executor / manual monthly journals" recommendation is rescinded. Sections 6-12 are revised to a commissioning plan: keep and validate the proposal/executor machinery through an approval-mode pilot, enable automatic mode on a cutover boundary, and simplify only what weakens nothing (QBO webhook dead path, recon consolidation after activation). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../financial-simplification-audit.md | 333 +++++++++++------- 1 file changed, 202 insertions(+), 131 deletions(-) diff --git a/docs/architecture/financial-simplification-audit.md b/docs/architecture/financial-simplification-audit.md index fedbc44..aac1d1c 100644 --- a/docs/architecture/financial-simplification-audit.md +++ b/docs/architecture/financial-simplification-audit.md @@ -6,6 +6,17 @@ reconciliation automation, and the surrounding operational machinery. **Status:** investigation only — no accounting behavior changes are proposed for immediate implementation; each item lists its own gate. +> **Revision (2026-10-19 follow-up):** the owner has clarified that +> **automatic payout accounting is a mandatory business requirement** — +> manual monthly journal entries are explicitly rejected. The original +> audit's "retire the dormant execution layer / human posts in QBO" +> recommendation is therefore **rescinded**. Sections 6–9 are revised to a +> **commissioning plan**: keep and prove the existing proposal/executor +> machinery, simplify only what weakens nothing, and activate through a +> controlled pilot. The factual findings (§§1–5) are unchanged — the code +> and its dormancy claims are still accurate; what changed is the verdict +> about what to do with it. + ## Executive summary The codebase carries **~15,400 lines of financial `lib/` modules, ~1,100 @@ -34,16 +45,20 @@ overbuild: at brewery volume (order of a few payouts and a few dozen charges per month), the proposed entries are a checklist a human can post in QuickBooks in minutes. -**Recommended direction:** keep the observe-mode ledger as the permanent -discrepancy-detection surface, keep proposals as a computed worklist, and -**retire the dormant execution layer** (decisions, executor, apply route, -`automatic` mode, cutover/historical machinery) rather than continue -maintaining an un-armed write path that a single env var can switch from -"report" to "unattended QuickBooks writer." Independently, ask the -accountant whether Ollie payments even need reclassification — leaving them -in Undeposited Funds and matching the payout in the QBO bank feed is the -native mechanism for exactly this, and removes the business need behind the -most complex matching code in the repo. +**Recommended direction (revised): commission the existing automation.** +The desired steady state is: Stripe settles → payout ledger classifies → +verified proposals post to QBO unattended → the owner is interrupted only +for genuine exceptions. The execution layer the original audit proposed +deleting is now the asset: its safety model (fingerprint-bound approvals, +claim leases, apply-time revalidation, marker adoption, `uncertain` +states) is *the* thing that makes unattended posting defensible. The +correct move is to **validate and activate it through a controlled pilot**, +not to remove it. What can still be simplified: the dead QBO webhook path +(F6), the duplicated recon pipeline (F5), and any orchestration that does +not protect the automatic write path. Independently, the accountant must +approve the clearing-account convention the executor implements +(reclassifying Ollie payments out of Undeposited Funds) — that is an +accounting-policy decision, not a code decision. --- @@ -219,6 +234,13 @@ flags. Persisted snapshot `qboReconState/{env}`. ### Overbuilt / disproportional (the audit's core findings) +> **Revision:** under the mandatory-automation requirement, F1–F4 remain +> accurate *descriptions* — but the verdict flips from "remove" to +> **"commission and validate"**. The execution layer is the deliverable, +> not the overbuild; what was disproportionate is that it was built to an +> unattended-execution standard and never activated. F5–F6 still identify +> genuine removable/mergeable code. + **F1. The proposal execution layer is a dormant write path.** `stripe-payout-decisions.ts` (fingerprint-bound approve/reject, `expectedFingerprint` conflict semantics, `confirmQboPaymentId`), @@ -332,25 +354,39 @@ verified-tier matching from *execution prerequisite* to *reporting aid*. This is a modeling decision for the accountant — both models are correct; the UF-native one needs less machinery. -## 6. Recommended minimum viable architecture +## 6. Recommended minimum viable architecture — REVISED (automated) ``` Stripe Checkout → payments record → webhook settle → Sales Receipt -(gross → clearing) [KEEP — the only auto-write] +(gross → clearing) [KEEP — auto-write 1] Cash payment → same record → Sales Receipt (→ cash acct) [KEEP] Ollie → QBO invoices+payments (UF) — Ollie's own integration [KEEP, untouched] -Payout webhook → ledger + classification + exception flags [KEEP as report] -Monthly close (human, ~15 min): per payout, post JE (fees, actual) - + transfer clearing→bank / bank-feed match UF payments [MANUAL, native] -Recon: single report (consolidated ledger view + clearing diff) [SIMPLIFY to one] -Refunds: document manual RefundReceipt procedure; #180 optional [DEFER or minimal] +Payout webhook → ledger + classification + proposals [KEEP] + ├─ move_payment_to_clearing (verified Ollie matches → auto) [COMMISSION] + ├─ stripe_fee_expense JE (actual fees; auto when fully [COMMISSION] + │ identified) — otherwise manual approval + ├─ payout_transfer clearing→bank (auto when fully identified) [COMMISSION] + └─ exceptions → admin attention queue (ambiguous, unmatched, [KEEP] + refunds, foreign currency, unposted receipts) +Cron sweep: missed-enqueue + due records + payout re-scan + + pendingAutoApply retry [KEEP] +Recon report → fold unique value (clearing-balance diff) into the + payout view or keep as the monthly-close check [SIMPLIFY later] +Refunds: ledger surfaces them as exceptions; posting stays manual + until #180 [DEFER posting] ``` -In this architecture QuickBooks stays the system of record for money -movement and the app's job narrows to three things: **(1)** book the one -leg nobody else books (DDB gross receipts), **(2)** surface what a payout -contains and flag what doesn't reconcile, **(3)** never block a sale on -any of it. +The automation boundary, as implemented, is already the right one: +**automatic** = every charge-side movement in the payout positively +identified (DDB `confirmed` or `verified` Ollie), ledger complete and +balanced, eligible scope only. **Exception** = suggested-only matches, +ambiguous/unmatched lines, refunds, foreign currency, account-level +movements — proposals stay manual and a human approves each in the admin +panel. Unattended execution never happens on a partially-understood +payout. + +The activation sequence and its gates live in the revised §8 and the +payout-automation ops doc. ## 7. Keep / Simplify / Replace / Remove / Defer matrix @@ -363,101 +399,118 @@ any of it. | Sync queue + worker + backoff + marker adoption | **KEEP** | Prevents lost/duplicate receipts; right pattern | | Daily sweep cron | **KEEP** | Bounded recovery, near-zero cost | | Sync admin panel (counts, retry) | **KEEP** | The one ops surface that matters | -| Payout ingestion + ledger + lane classification (observe) | **KEEP (as report)** | Discrepancy visibility: `ddb_receipt_unposted`, unclassified/external flags, exceptions list | -| Computed proposals (3 kinds, rendered in admin) | **SIMPLIFY** | Keep as *computed-at-render checklist* — drop persistence of the proposal state machine (fingerprints/stale/revival) if no decision machinery remains | -| `suggested`-tier Ollie matching (amount+date+UF) | **SIMPLIFY** | Sufficient for a human worklist | -| `verified` tier: Spreedly two-leg, DocNumber resolution, name-demotion rules | **SIMPLIFY/DEFER** | Only feeds `autoEligible`/apply-time identity — dormant; demote to evidence annotation, not a gate | -| Fingerprint-bound decisions (`stripe-payout-decisions.ts`, proposals route) | **REMOVE** | Exists only to arm execution; never used in prod | -| Guarded executor (`stripe-payout-executor.ts`, apply route) | **REMOVE** | Dormant write path; highest-risk dead code. Correctness is preserved by human execution in QBO | -| `automatic` mode + `autoEligible` + `pendingAutoApply` | **REMOVE** | Speculative unattended bookkeeping; mode flag alone is a risk surface | -| Cutover/historical proposal machinery | **REMOVE** | One-time migration tooling; manual historical handling suffices | -| `STRIPE_ACCOUNTING_MODE` / `STRIPE_AUTOMATION_CUTOVER` env surface | **REMOVE** | Collapses with the execution layer | -| Windowed recon report (`qbo-recon*`) | **SIMPLIFY/MERGE** | Consolidate into the payout-ledger view; keep only clearing-balance diff + fee-booked inspection that the ledger lacks | -| QBO webhook + receipts collection | **REMOVE** | No consumer; unsubscribing at Intuit + deleting the route/storage removes ~400 lines. (Keep endpoint-removed docs note.) | -| Refund posting (RefundReceipt) — #180 | **DEFER** | Manual documented procedure acceptable at this volume; if built, keep minimal (enqueue on `refunded` → mirrored RefundReceipt — pipeline exists) | -| Fee posting (`stripe_fee` dormant vocabulary) | **DEFER** | Monthly accountant JE from Stripe payout report = zero code; revisit only if volume grows | +| Payout ingestion + ledger + lane classification | **KEEP** | Discrepancy visibility + the evidence base automatic posting stands on | +| Computed proposals (3 kinds, deterministic ids, persisted) | **KEEP** | The review artifact AND the executor's unit of work; persistence makes decisions auditable and rebuilds idempotent | +| `suggested`-tier Ollie matching | **KEEP** | The manual-review tier — feeds proposals a human confirms | +| `verified` tier: Spreedly two-leg, DocNumber resolution, name-demotion rules | **KEEP** | Now load-bearing: it is the ONLY identity evidence that qualifies a move for unattended execution | +| Fingerprint-bound decisions (`stripe-payout-decisions.ts`, proposals route) | **KEEP** | Required for the manual exception path *and* the approval-mode pilot that precedes automatic | +| Guarded executor (`stripe-payout-executor.ts`, apply route) | **KEEP + VALIDATE** | The automatic write path itself; needs controlled pilot evidence before `automatic`, not removal | +| `automatic` mode + `autoEligible` + `pendingAutoApply` | **KEEP + COMMISSION** | The business goal. `autoEligible`'s gating (fully-identified, balanced, in-scope) is appropriately conservative — see validation | +| Cutover/historical proposal machinery | **KEEP (for now)** | Required by the executor's own gate (no cutover → execution refuses) and by backlog handling at activation. Revisit *after* the pre-automation backlog is cleared — it is a candidate for removal once history is settled | +| `STRIPE_ACCOUNTING_MODE` / `STRIPE_AUTOMATION_CUTOVER` env surface | **KEEP** | The activation gate — deliberately retained as the safety switch | +| Windowed recon report (`qbo-recon*`) | **SIMPLIFY/MERGE (defer)** | Keep as the period-close check during early automation; consolidation into the payout view is a *later* cleanup, not an activation gate | +| QBO webhook + receipts collection | **REMOVE** | No consumer; unsubscribing at Intuit + deleting the route/storage removes ~400 lines | +| Refund posting (RefundReceipt) — #180 | **DEFER** | Refunds surface as ledger exceptions today; manual procedure acceptable at this volume | | Tax posting config — #184 | **DEFER (accountant gate)** | Mechanism ready; activation pending answers | | Chargeback/dispute posting | **DEFER** | Classification+exception visibility already exists; posting manual | -| Stripe-account separation for Ollie vs web | **REPLACE (business change, not code)** | If Ollie can settle to a dedicated Stripe account (or Ollie's QBO integration can target the clearing account), the mixed-source problem — the root cause of most custom complexity — disappears, and even native connectors become viable | - -## 8. Prioritized remediation sequence - -Ranked by **maintenance savings ÷ financial risk**: - -**P0 — freeze the execution layer (docs/policy, near-zero risk)** -Set `STRIPE_ACCOUNTING_MODE` absent/`observe` permanently; document that -`approval`/`automatic` are retired vocabulary. Cost: trivial. This is the -default already — formalize it. - -**P1 — remove the dormant write path (~4–6k lines incl. tests/UI)** — S -Delete `stripe-payout-decisions.ts`, `stripe-payout-executor.ts`, the -proposals/apply/decision routes, `automatic`/`autoEligible`/`pendingAutoApply` -branches, cutover/historical proposal variants, and the decision/apply UI -blocks in `admin-quickbooks-payouts.tsx`. Keep proposal *computation* as a -read-only checklist on the payout card. Effort: medium (surgical deletion + -test pruning). Risk: low — path is unused; all safeguards (sales-receipt -dedupe, sync queue, ledger) unaffected. Rollback: git revert. - -**P2 — demote matching tiers (~300–500 lines)** — M -Keep `suggested`/`ambiguous`/`unmatched` display; reduce `verified` to an -evidence label (keep identifier extraction as *displayed evidence*, drop -it as an execution gate — including the executor-specific claim pools and -two-leg promotion if P1 lands). Risk: low; reporting-only. Rollback: revert. - -**P3 — consolidate reconciliation surfaces** — M -Fold the windowed recon report's unique value (clearing-balance diff, -existing-entries inspection) into the payout-ledger admin view; retire -`qbo-recon*` (~1,650 lines) or reduce it to a "balances" card. Risk: low — -read-only. Rollback: revert. - -**P4 — remove QBO webhook receipts** — S -Unsubscribe in the Intuit portal, delete route + `lib/qbo-webhook.ts` + -`qboWebhookReceipts` rules/tests. Confirm no future plan needs them. -Risk: trivial. Rollback: revert + re-subscribe. - -**P5 — bookkeeping-procedure documentation** — S -Write the ops doc for the manual monthly close: where fee totals come -from (Stripe payout report or proposal card), the two-leg payout match -(clearing transfer + UF match), refund reversal procedure (card + cash). -This is what makes "human executes" the designed path rather than tribal -knowledge. - -**Deferred items** keep their issues open: #180 (refund posting — decide -manual-forever vs minimal implementation), #184 (tax — accountant gate), -fee posting (monthly JE recommended), `automatic` payout execution -(revisit only if monthly payout count grows ≥10×). - -## 9. Estimated reduction - -| Change | Lines removed (approx.) | Notes | +| Stripe-account separation for Ollie vs web | **REPLACE (business change, not code)** | If Ollie can settle to a dedicated Stripe account, the mixed-source problem disappears and the matching machinery becomes a much simpler DDB-only reconciliation | + +## 8. Prioritized sequence — REVISED (commission, then simplify) + +Ordered by what must happen before `automatic` can be trusted, then by +maintenance savings: + +**C0 — validation groundwork (no production behavior change)** — done on +the commissioning branch: representative-scenario tests through the +ledger→proposal pipeline; focused fixes for gaps found during review +(e.g. manual-cover suppression now surfaces an `existing_manual_entry` +exception instead of skipping silently); removal of the dead QBO webhook +path. + +**C1 — accounting decisions (owner + accountant, no code)** +Approve: the Stripe-clearing convention for Ollie payments +(`move_payment_to_clearing` reclassifies Ollie's UF payments — the +alternative UF-native model is a different proposal kind that does not +exist today), the payout-account mapping (bank vs Payouts in Transit — +`stripePayoutAccountId`), the fee expense account, and the tax position +(#184). Without the clearing-convention sign-off, `verified` moves must +stay manual. + +**C2 — configuration + pilot under `approval` mode** +Complete the account mapping (clearing / fee / payout destination), set +`STRIPE_AUTOMATION_CUTOVER` to a date after which all payouts are known +clean, set `STRIPE_ACCOUNTING_MODE=approval`. Then: run the manual payout +sync for a few recent real payouts; review each computed proposal in the +admin panel; approve and apply the ones that are correct; verify the +resulting QBO entries by hand (move → JE → transfer). This is the only +way to gain real-world evidence the executor posts correctly — it has +**never** written to a live or sandbox company. + +**C3 — settle the backlog, then enable `automatic`** +Once the approval-mode pilot shows the three writes landing correctly and +the pre-cutover historical proposals are resolved (approve-and-apply or +reject), set `STRIPE_ACCOUNTING_MODE=automatic`. First payouts under +automatic: check the QBO entries against the payout ledger after each one +for the first few weeks; the exceptions list + `pendingAutoApply` retries +cover transient failures. + +**S1 — remove QBO webhook receipts** — S (done on the commissioning +branch): dead-end write-only storage, ~400 lines removed. + +**S2 — consolidate the recon report** — M (deferred, post-activation): +fold clearing-balance diff into the payout view; retire or shrink +`qbo-recon*` (~1,650 lines). Not an activation gate. + +**S3 — retire cutover/historical machinery** — S (deferred): once every +pre-automation payout is reconciled and no `historical` proposals remain, +the historical variants and `-HIST` marker namespace can be deleted. + +## 9. Estimated reduction — REVISED + +| Change | Lines affected (approx.) | Notes | | --- | --- | --- | -| P1 execution layer | ~4,000–6,000 (impl + routes + UI + tests) | biggest single win; also removes the only money-mutating surface beyond receipts | -| P2 matching demotion | ~300–600 | shrinks the hardest-to-review logic | -| P3 recon merge | ~1,200–1,650 | one reconciliation pipeline instead of two | -| P4 webhook receipts | ~400 | dead-end storage | -| **Total** | **~6,000–8,500 (~20–25% of the financial subsystem)** | plus retirement of the `approval`/`automatic` risk surface | - -Ongoing-maintenance effect: Stripe/QBO API changes, security reviews, and -payout-flow edits stop having to keep a dormant write path consistent; -the remaining surface is reads + one bounded write. - -## 10. Financial-correctness safeguards the simplification must preserve +| S1 QBO webhook receipts | ~400 removed | dead-end storage | +| S2 recon consolidation (later) | ~1,200–1,650 removed | one reconciliation surface | +| S3 cutover/historical retirement (after backlog settles) | ~several hundred | inside `stripe-payouts*.ts` | +| C0 commissioning fixes/tests | +small net | scenario tests add lines; the fixes prevent silent gaps | +| **Realistic total reduction** | **~1,600–2,500 (now), ~2,000–3,000 (after backlog)** | vs the ~6–8.5k the manual model would have freed | + +The honest trade-off: automation keeps ~3–4k more lines than the manual +model would have, but those lines *earn their keep* — they are the +difference between "posting unattended" and "owner does monthly +bookkeeping," which is the stated goal. The right maintenance metric +under automation is not lines but **proven execution**: an executor that +has posted correctly for weeks is cheaper to keep than a dormant one was +to delete. + +## 10. Financial-correctness safeguards — REVISED (what automation relies on) - Sales-Receipt dedupe (`env:source:sourceId` durable record + `ddb:` marker adoption) — untouched. -- Positive DDB identity for posting — only `payments/` records post; the - Ollie lane is *never* written by us (after P1 there is no write that - could touch it at all). +- Positive DDB identity for posting; Ollie lane writes go only through + `move_payment_to_clearing` on `verified` identity — `suggested` tier + never executes unattended. - Webhook signature verification, canonical re-fetch, snapshot quarantine — untouched. -- `needs_attention` human-review path — untouched. -- Ledger/exceptions continue to flag: unposted DDB receipts, ambiguous - matches, foreign currency, unattributed movements, clearing drift. -- Audit trail (`payments/events`, admin audit log, proposal history as - computed output) preserved. -- Nothing auto-posts: every QBO mutation beyond the Sales Receipt - requires a human in QBO — which is *strictly more conservative* than - the armed modes ever were. +- `needs_attention` / proposal-exception human-review paths — untouched + and load-bearing: they are the exception surface automatic mode routes + around, not through. +- Automatic-path hard gates, all enforced in code and unit-tested: + `autoEligible` requires a complete, balanced, single-currency, + fully-identified eligible ledger; `move` auto-eligibility requires + `verified` identity; aggregate writes require synced DDB receipts + (`ddb_gross_unposted`); approvals bind to a material fingerprint; + claims are leased; writes are revalidated at apply time; unknown write + outcomes park as `uncertain` and reconcile by marker before retrying; + pre-cutover dates and `historical` proposals refuse unattended + execution. +- Audit trail (`payments/events`, proposal `decisions[]`/`auditTrail`, + admin audit log) preserved — every automated action is recorded with + actor `automation`. +- Mode semantics: `observe` refuses every write; `approval` requires a + human decision; `automatic` self-approves only `autoEligible` + proposals. The mode flag is the kill switch — set it back to `observe` + and the executor refuses everything instantly. ## 11. Where "simpler" would introduce accounting errors (explicit) @@ -469,33 +522,49 @@ the remaining surface is reads + one bounded write. Ollie wholesale revenue. Explicitly rejected unless the Stripe account is split. - **Auto-matching Ollie payments by amount/date alone for *writes*** → - wrong-payment reclassification. Mitigated in the recommendation by - removing writes entirely; suggestions stay review-grade forever. + wrong-payment reclassification. The implemented model agrees: only + `verified` (identifier-corroborated) matches are `autoEligible`; + `suggested` stays review-grade forever. +- **Removing the fingerprint/lease/uncertain machinery from the + executor** → a write path that can't prove what it approved, can't + exclude concurrent writers, and can't distinguish "posted" from + "maybe posted." Those controls are the reason automation is defensible. - **Posting net instead of gross** (or letting a connector do summarized entries) → loses per-purpose income detail (tours vs tastings) the business reports on. - **Estimating fees** → wrong expense; actuals come from balance transactions/payout report only. - -## 12. Decisions requiring owner / accountant input - -1. **Shared Stripe account**: can Ollie's Spreedly gateway point at a - separate/dedicated Stripe account for web payments? (Highest-leverage - question — eliminates mixed-source payouts entirely.) -2. **Ollie lane model**: reclassify Ollie payments to clearing (current - design) vs. leave in UF and two-leg match (native)? Also: can Ollie's - QBO integration target the clearing account at write time? -3. **Execution model**: app-executed proposals (approval mode) vs. - proposals-as-checklist + human posts in QBO (recommended). -4. **Fee booking**: per-payout JE vs monthly summary JE (recommend - monthly, from the Stripe report). -5. **Payout destination**: `Stripe Balance` clearing semantics vs bank — - accountant's call already encoded as `stripePayoutAccountId`. -6. **Refund gap (#180)**: manual procedure acceptable, or build minimal +- **Aggregates posting before DDB receipts** → clearing outflows with no + proven inflow; the `ddb_gross_unposted` refusal exists for exactly this. + +## 12. Decisions requiring owner / accountant input — REVISED + +1. **Clearing convention for Ollie payments** (activation blocker): the + executor reclassifies Ollie's UF payments into the Stripe clearing + account so one transfer settles each payout. Approve this convention, + or say UF-native is required — the latter is a *different proposal + kind that does not exist* and would be new build. Related: can Ollie's + QBO integration target clearing at write time? If yes, move proposals + become unnecessary entirely. +2. **Payout destination** (`stripePayoutAccountId`): the bank account + Stripe settles into, or a "Payouts in Transit" clearing account — + accountant's call; the mapping supports either. +3. **Fee expense account** (`stripeFeeExpenseAccountId`): which QBO + expense account carries Stripe fees. +4. **Cutover date**: the first date after which all payouts are clean for + automation — everything earlier posts only through the manual + historical path. +5. **Refund gap (#180)**: refunds surface as exceptions; posting a + reversal stays manual. Acceptable at this volume, or build the RefundReceipt sync? -7. **Tax (#184)** — unchanged; still awaiting the accountant's answers. -8. **QBO webhook receipts**: safe to remove? (No consumer today; confirm - no planned use.) +6. **Tax (#184)** — unchanged; still awaiting the accountant's answers. + (Not an activation blocker for payout posting itself — it gates + receipt tax treatment, a separate decision.) +7. **Shared Stripe account** — still the highest-leverage simplification: + a dedicated DDB Stripe account would make the mixed-source matching + machinery unnecessary. Worth asking Ollie, but not blocking. +8. **QBO webhook receipts**: removed in the commissioning branch; if the + Intuit app still has a webhook subscription, unsubscribe it there. --- @@ -506,4 +575,6 @@ writes, no production mutations, no accounting changes. Verified against: `docs/TECHNICAL.md` §11, `lib/qbo-*.ts`, `lib/stripe-*.ts`, `lib/payments-*.ts`, `app/api/admin/quickbooks/*`, `app/api/webhooks/*`, `vercel.json`, `firestore.rules`, and git history (#161, #178–#184, #206, -#223–#239).* +#223–#239). Revised 2026-10-19 to reflect the owner's mandatory-automation +requirement: §§6–12 now describe commissioning the existing executor +rather than retiring it.* From ac242ac8f590fbc7f77fd8459215fc7d19e64c8d Mon Sep 17 00:00:00 2001 From: Chad Nuttall Date: Sat, 17 Oct 2026 19:55:22 -0400 Subject: [PATCH 3/3] docs(architecture): reconcile residual manual-model conclusions Exec summary, F1/F3/F4 verdicts, the bank-feed alternative, and the Ollie UF-vs-clearing section still argued for manual posting as the target. Reframe them under the mandatory-automation requirement: the executor and verified-tier matching are load-bearing safety controls, bank-feed match is the exception fallback, and UF-native is an accountant decision that would be new build rather than simplification. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../financial-simplification-audit.md | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/docs/architecture/financial-simplification-audit.md b/docs/architecture/financial-simplification-audit.md index aac1d1c..6beacb9 100644 --- a/docs/architecture/financial-simplification-audit.md +++ b/docs/architecture/financial-simplification-audit.md @@ -39,11 +39,15 @@ wholesale — so payouts are mixed-source and every generic tool (native Stripe→QBO connectors included) would double-count the Ollie lane. The pipeline exists to answer "which balance transactions in this payout are ours, which are Ollie's, and what should be booked?" That *question* is -genuinely hard and the ledger that answers it is worth keeping. The -*machinery that acts on the answer* (approve → apply → auto-apply) is the -overbuild: at brewery volume (order of a few payouts and a few dozen -charges per month), the proposed entries are a checklist a human can post -in QuickBooks in minutes. +genuinely hard and the ledger that answers it is worth keeping. ~~The +machinery that acts on the answer is the overbuild~~ — **revised:** the +machinery that acts on the answer (approve → apply → auto-apply) is the +automation the owner requires. Its cost is real, but its safety controls +(fingerprint-bound approvals, claim leases, apply-time revalidation, +marker adoption, `uncertain` states) are exactly what permit unattended +writes without risking duplicate or mis-directed postings. The remaining +problem is *proof*: it has never executed against a live or sandbox QBO +company, so commissioning — not removal — is the path. **Recommended direction (revised): commission the existing automation.** The desired steady state is: Stripe settles → payout ledger classifies → @@ -248,12 +252,13 @@ flags. Persisted snapshot `qboReconState/{env}`. marker+legs recovery scan, `uncertain` state, adoption), the apply and decision routes, and the decision UI in `admin-quickbooks-payouts.tsx` — ~1,300 implementation lines plus ~1,700 lines of dedicated tests plus UI — -exist to perform three QBO writes a human can do in minutes per payout. +implement the three QBO writes the owner wants posted automatically. Production has *never* armed it (`observe` refuses it end to end). It is simultaneously the highest-risk code in the repo (the only path that can -mutate the books beyond Sales Receipts) and the least exercised. -Every future change to matching or proposals must keep this whole -apparatus consistent or it silently rots — a loaded gun in the repo. +mutate the books beyond Sales Receipts) and the least exercised — so the +risk is *unproven* execution, not the existence of the path. The +commissioning plan (§8) supplies the missing evidence through a real +sandbox/approval-mode pilot before `automatic` is enabled. **F2. `automatic` mode was built before the pilot that would justify it.** Self-approving `autoEligible` proposals, `pendingAutoApply` retry @@ -265,9 +270,10 @@ and a confidence level the business hasn't reached. **F3. Cutover + historical-proposal machinery.** `STRIPE_AUTOMATION_CUTOVER`, `:historical` proposal variants, `-HIST` marker namespaces, suppression of historical proposals by existing entries, manual-only enforcement — built -to handle a backlog migration that (a) is a one-time event and (b) can be -done by hand in less time than the machinery took to design. ~Several -hundred lines inside `stripe-payouts*.ts`. +to handle a backlog migration that is a one-time event. ~Several +hundred lines inside `stripe-payouts*.ts`. **Revised:** keep until the +pre-cutover backlog is settled — it is what prevents a legacy payout from +auto-posting against already-reconciled books — then remove in C6. **F4. The Ollie matching engine exceeds what the *decision* needs.** Verified-tier matching requires: namespaced identifier extraction from @@ -275,11 +281,15 @@ charge metadata/description, linked-invoice DocNumber resolution, the Spreedly composite `order_id` two-leg corroboration (both legs must agree on the same candidate), PaymentRefNum targeted lookups, cross-payout claim pools, and customer-name demotion with a carved-out exception for fully -corroborated Spreedly matches. This is forensic-accounting-grade matching -to produce a `verified` badge whose only consumer is `autoEligible` — the -path that never runs. For a human-readable worklist, `suggested` (unique -amount+date+currency candidate) is sufficient evidence; a human confirms -in seconds at this scale. +corroborated Spreedly matches. ~~This is forensic-accounting-grade +matching to produce a `verified` badge whose only consumer is +`autoEligible` — the path that never runs.~~ **Revised:** `verified` is +the only tier that gates unattended writes, so under the +mandatory-automation requirement the forensic matching *is* the safety +control — it is what keeps amount/date collisions (e.g. two same-price +Ollie payments in one payout) from misfiling revenue. Keep it; the +simplification is that `suggested`/`ambiguous`/`unmatched` need no +execution path — they are already exception lanes. **F5. Two parallel reconciliation pipelines.** `qbo-recon.ts` (windowed report) and `stripe-payouts.ts` (per-payout ledger) independently list @@ -328,7 +338,7 @@ is sized for a payments-ops team, not a brewery. | Alternative | What it actually does | Fit for DDB | | --- | --- | --- | | **Stripe Connector by QuickBooks** (Intuit's own app, free) | Auto-imports Stripe sales, refunds, fees, payouts → QBO; maps products→items; can match imported txns to existing QBO records | **Blocked by the shared account**: it imports *every* Stripe transaction — including Ollie/Spreedly charges that Ollie already books as invoices+payments → duplicate revenue. Becomes viable **only if DDB's web payments move to a dedicated Stripe account**. Caveats: CW (non-US) company support unverified; ad-hoc Checkout `price_data` items map weakly; introduces a second writer whose posting model (its own receipts + deposit) would collide with the current clearing model unless the custom sync is fully retired. | -| **QBO bank feed + match** | Deposits arriving at the bank match against UF payments / clearing transfers; "make deposits" handles batched settlement natively | **Strong.** This is the *native* mechanism for everything the proposal layer does: match a payout deposit to Ollie UF payments + clearing withdrawals + fee adjustments. Already partially in use (docs: payouts arrive as transfers involving UF). Zero code. | +| **QBO bank feed + match** | Deposits arriving at the bank match against UF payments / clearing transfers; "make deposits" handles batched settlement natively | **Keep as the manual fallback for exceptions only** — not the target architecture. Under the mandatory-automation requirement the executor performs the UF→clearing moves and transfers itself; the bank feed remains the human tool for the exception lane and for pre-cutover history. If the accountant prefers the UF-native model (§5) this row becomes the posting mechanism and the executor's reclassification proposals are retired instead. | | **Ollie → QBO integration** | Already creates invoices + payments; owns wholesale revenue end-to-end | Keep as-is. **Open question:** can Ollie's integration be configured to deposit payments to the Stripe clearing account (or another account) instead of UF? If yes, `move_payment_to_clearing` proposals lose their reason to exist. | | **Stripe payout reconciliation report** (Dashboard CSV) | Per-payout itemization of charges/fees/refunds | Feeds a monthly fee JE with zero code; the accountant's existing tool for the same numbers the pipeline computes. | | **Synder / A2X / Acodei etc.** ($20–80/mo) | Per-transaction or summarized payout sync | Same shared-account double-counting risk; adds vendor cost and a parallel posting model. Not recommended. | @@ -351,8 +361,21 @@ removes the write that touches Ollie's own records (the riskiest proposal kind — it mutates a Payment Ollie created), and demotes the elaborate verified-tier matching from *execution prerequisite* to *reporting aid*. -This is a modeling decision for the accountant — both models are correct; -the UF-native one needs less machinery. +This is a modeling decision for the accountant — both models are correct. +The honest trade-off under the mandatory-automation requirement: + +- **Clearing model (implemented):** the executor does the UF→clearing + moves, the fee JE, and the transfer — fully automatic for verified + payouts, but it reclassifies records Ollie created. +- **UF-native model:** simpler QBO footprint and no writes to Ollie's + records, but the Ollie leg reconciles via the bank feed — meaning either + *manual* matching per payout (which contradicts the automation + requirement) or a different, unbuilt automation path. + +**Default: proceed with the implemented clearing model unless the +accountant objects** — switching to UF-native is new build, not a +simplification of what exists. This decision is listed in §12 and is a +precondition for production activation. ## 6. Recommended minimum viable architecture — REVISED (automated)