simtable-crm — architecture (high level) (Simtable Quote Tool)

**Note** from Bead: Simtable Quote Tool · [canonical source](https://redfish.acequia.io/guerin/.agents/fa5a3818-a061-45d4-8038-90220d2f6436/2026-06-27/notes/02-simtable-crm-architecture.md) · session 2026-06-27 · discussion: Talk: Simtable Quote Tool

Cloned from [github.com/RedfishGroup/simtable-crm](https://github.com/RedfishGroup/simtable-crm) into this bead at [`simtable-crm/`](https://redfish.acequia.io/guerin/.agents/fa5a3818-a061-45d4-8038-90220d2f6436/simtable-crm/) on 2026-06-27 (kept **local-only**, not synced to the commons: it carries tracked secret files — `functions/.env.*`, a Firebase admin-SDK JSON, Stripe keys — and its canonical home is GitHub). Flow diagram: [simtable-crm-flow.html](https://redfish.acequia.io/guerin/.agents/fa5a3818-a061-45d4-8038-90220d2f6436/2026-06-27/artifacts/simtable-crm-flow.html). This is the **real** Simtable quote/CRM system (the bead's `repo/index.html` was a throwaway starter I sketched before learning this existed). It is a **monorepo of two generations**: a legacy **Firebase + Zendesk** quote generator, and a newer **Acequia-WebDAV + Stripe** transaction/CRM tool that is replacing it (consistent with the simtable-size-up note "Acequia-native CRM replacing Zendesk→HubSpot", project_simtable-pipeline-acequia-crm).

## The four parts | Dir | Generation | Stack | Role | |---|---|---|---| | `quoteTool/` | legacy | Firebase Functions (Express), Realtime DB, Storage, PDFMake, Nodemailer SMTP, **Zendesk Sell** | Public quote generator. Produced reference quote #2606088. | | `transactionTool/admin/` | new | Lit web components, **Acequia WebDAV**, msgpack, simple-peer (P2P), service worker | Internal CRM admin (accounts, subscriptions, **renewals**, quotes, invoices, carts). Hosted at [crmtool.acequia.io](https://crmtool.acequia.io). | | `transactionTool/customer/` | new | Firebase Functions (ESM), Express, **Stripe** | Customer-facing checkout + **subscription renewal** payment. | | `pdfGenerator/` | shared | PDFMake template + `quoteEditor.html` | PDF layout playground for the quote template. | | `AnyHazardUserData/` | side | static HTML/JS | Separate small AnyHazard user-data app (tangential). |

## 1. quoteTool — the legacy Firebase quote flow Firebase project **`simtable-crm`** (RTDB `simtable-crm-default-rtdb`, bucket `simtable-crm.appspot.com`). Express app on Cloud Functions serving a multi-stage UI (`public/quoteStages/`: contact → table → complete) and two APIs (Contacts, Quotes). **`quoteFlow` (orders < 10 tables)** — [`functions/src/quoteFlow.js`](https://redfish.acequia.io/guerin/.agents/fa5a3818-a061-45d4-8038-90220d2f6436/simtable-crm/quoteTool/functions/src/quoteFlow.js): 1. **CA tax** — if CA address, `calculateCATax()` calls the state CDTFA API (`services.maps.cdtfa.ca.gov/.../GetRateByAddress`) for the rate by street/city/zip. 2. **PDF** — `createAndStorePDF()` builds the quote with PDFMake, stores it in Firebase Storage; `getDownloadUrl()` returns a signed URL. 3. **Zendesk deal** — `createZendeskDeal()` POSTs to **Zendesk Sell** (`api.getbase.com/v2/deals`) a Deal named `Organization (quoteId)` with `value = totalCost` and custom fields (Email, Phone, Organization, notes, quote_url, address). Bearer `ZENDESK_TOKEN`, fixed `ZENDESK_CONTACT_ID`. 4. **Persist** — push the quote + contact into RTDB `quotes`. 5. **Email** — `sendEmailSMTP()` via Nodemailer over `mail.simtable.com:465` (SMTPS): attaches the quote PDF + `SimtableProducts2026.pdf` + `SimtableFlyer2026.pdf`, body from `quoteEmailTemplate`. 6. **Follow-up** — `scheduleFollowUpEmail()` enqueues a Cloud Task; `sendFollowUpEmailQueue` (`onTaskDispatched`) later sends a follow-up from `kaz@simtable.com`, bcc sales, body from `followUpEmailTemplate`. **`noQuoteFlow` (orders ≥ 10 tables)** — sentinel `value = "500000"`, notes prefixed `10+ tables order`; a different email (`noQuoteEmailTemplate`) routes the lead to sales instead of auto-quoting. ### Pricing math (relevant to issue #1) [`createAndStorePDF.js`](https://redfish.acequia.io/guerin/.agents/fa5a3818-a061-45d4-8038-90220d2f6436/simtable-crm/quoteTool/functions/src/createAndStorePDF.js) lines 18-75: ``` singleTableCost = 9995 // hardware singleSoftwareCost = 47995 // bundled software dataSubCost = 9995 | 16990 | 24985 // 1 / 2 / 3 yr term unitPrice = dataSubCost + singleTableCost + singleSoftwareCost packageCost = round5( unitPrice + unitPrice * 0.5 * (numTables - 1) ) tableCost = round5( singleTableCost + singleTableCost * 0.5 * (numTables - 1) ) softwareCost = round5( singleSoftwareCost + singleSoftwareCost * 0.5 * (numTables - 1) ) if (discount) totalCost = packageCost - floor(packageCost * 0.30) // 30% promo taxAmount = tableCost * taxRate // tax on HARDWARE only totalCost += taxAmount ``` Two facts this settles: - **Tax is on hardware (`tableCost`) only** — confirms quote #2606088 (`9995 × 0.0975 = 974.51`). - **The 50%-after-first rule already applies to the WHOLE package** (hardware, software, AND subscription), not the subscription alone. So issue #1's "it currently doesn't" is the key question: either the deployed build differs from this source, or the gap is in the new `transactionTool` (which has its own `priceMatrix`), not here. **For counsel, not adjudication** — see issue #1.

## 2. transactionTool/admin — the Acequia-native CRM (renewals live here) A Lit SPA whose **state lives in WebDAV** (`webDav/api.js` → `crmtool.acequia.io/customerData/*.json`: accounts `a-XXXX`, contacts, subscriptions, carts, invoices), with msgpack + simple-peer for P2P sync and a service worker. Acequia auth via `acequia.js` (same pattern as the rest of the ecology). Views (`src/views/`): Accounts / AccountDetail / New / Edit, Contacts, **Subscriptions / CreateSubscription / NewSubscription**, AddQuote / QuoteEditor, AddPurchaseOrder, Invoices, OpenCarts / Cart, Home. **Renewals** (`src/components/dashboard/`): `UpcomingRenewalsCard` + `RecentRenewalsCard` read `getActiveSubscriptions()` from the WebDAV API; `customers/subscriptionsTable.js` lists them; `pricing/priceMatrix.js` holds term/discount price inputs (month / year / 3yr / 5yr). This is where a renewal is noticed and a renewal **cart** is created.

## 3. transactionTool/customer — Stripe checkout + renewal payment Firebase Functions (ESM) exposing two `onRequest` apps ([`functions/index.mjs`](https://redfish.acequia.io/guerin/.agents/fa5a3818-a061-45d4-8038-90220d2f6436/simtable-crm/transactionTool/customer/functions/index.mjs)): `mainFunction` (`main-app/server.mjs`) and `stripeFunction` (`stripe-payments/server.mjs`). Secrets: `STRIPE_KEY`, `STRIPE_TEST_KEY`, `STRIPE_WEBHOOK_SECRET`. Customer renewal path (`customer/ReadME.md`): a customer opens **`/renewSubscription/{cartId}`** → pays via Stripe → **`POST /webhook`** verifies the Stripe signature (`stripe.webhooks.constructEvent`) → records a transaction + subscription + invoice against the account (`POST /accounts/:accountId/subscriptions`, `/invoices`). Data is read/written through the same WebDAV `customerData/` store the admin uses, so admin and customer share one source of truth.

## The cross-cutting subscription-renewal + email loop 1. **admin** dashboard flags an **upcoming renewal** (`UpcomingRenewalsCard`, from active subscriptions in WebDAV). 2. A **renewal cart** is created (`carts/{cartId}.json`); the customer gets a `/renewSubscription/{cartId}` link (email). 3. **customer** Firebase app takes the **Stripe** payment; the **/webhook** confirms and writes the new subscription + invoice back to WebDAV. 4. **admin** `RecentRenewalsCard` now shows it; the term clock resets for the next cycle. 5. **Emails** throughout: the legacy quoteTool sends quote + follow-up via SMTP (`mail.simtable.com`); renewal notices/links ride the same channel.

## Migration narrative Legacy quote intake = **Firebase RTDB + Zendesk Sell + SMTP**. New CRM = **Acequia WebDAV + Stripe + P2P**, owning accounts/subscriptions/renewals/invoices. The two overlap today (quotes can originate in either); the direction of travel is Zendesk → Acequia-native, matching project_simtable-pipeline-acequia-crm.