Stone City — Data Architecture

How leads, orders, customers, and quotes flow between Square POS, Stone Profits, and HubSpot — and what happens next when the dual-entry gap closes.

Live · all syncs green v1 · 2026-05-11 Internal · noindex

01 Overview

Stone City runs three customer-facing systems: a retail storefront on Square POS, a B2B operation through the Stone Profits ERP, and a marketing & lead-capture layer on HubSpot. HubSpot is the aggregator — every paid order, every B2B customer record, and every quote-in-flight mirrors into it nightly so the team has one view of the pipeline.

order-sync · 11:00 UTC form submit · realtime customer-sync · 11:30 UTC quote-sync · 12:00 UTC manual entry manual entry Square POS storefront hardware location LYERMKED68FG3 Web forms + manual stonecity.com forms warranty, contact HubSpot CRM deals · companies · contacts pipelines 889312001 / 895324173 marketing automation · workflows Stone Profits (SPS) ERP api.stoneprofits.com customers · quotes · orders · invoices Sales team enters customer + quote in SPS
▸ Click any system above to highlight its data flows.

One-way: Square → HubSpot, SPS → HubSpot, forms → HubSpot. Nothing flows back from HubSpot today. The dotted lines from Sales team mark the dual-entry friction: a rep enters the customer twice (once in HubSpot when it arrives as a lead, again in SPS when they start a quote). Closing that gap requires Stone Profits to expose write endpoints — covered below.

02 Source systems

Square POS

Storefront retail. Each completed transaction at the register becomes a closed deal in HubSpot.

Location IDLYERMKED68FG3
Environmentproduction
AuthSQUARE_ACCESS_TOKEN (long-lived)
SDKsquare@^44.0.1 (Node)
Stone Profits (SPS)

B2B ERP. Sales reps build quotes here. Customer records live here as authoritative master.

API baseapi.stoneprofits.com
AuthPOST /v1/token/loginv1 · username + password + authorizedNumber
Read endpointsPOST /v2/Customers, POST /v2/Quotes, GET /v2/Customers/{id}, GET /v2/Quotes/{id}
Write endpointsNot yet supportedsee ask
HubSpot — lead capture (independent of sync)

Real-time inbound, not mirrored from anywhere. Where every new lead first enters Stone City's universe.

Web formsSubmissions on stonecity.com (warranty form, contact, et al.) → HubSpot contacts in real-time
Manual entryShowroom walk-ins, phone leads — entered directly into HubSpot by team
Lead status enumNEW · QUOTE · SALES_ORDER · UNQUALIFIED · SQUARE_POS · SPS_B2B

03 Order Sync — Square POS → HubSpot deals

Daily 11:00 UTC. Pulls completed Square orders from the last 25 hours, creates a HubSpot deal per order in the Square POS pipeline, links the deal to a contact (looked up by email then phone), tags VIPs on $5k+ purchases, and writes line items.

Schedule
cron(0 11 * * ? *)
Window
last 25 hours
Today's runtime
566 ms (1 order processed)
Historical (no-window)
~600 s (1467 orders dedup)
Field mapping · Square order → HubSpot deal
HubSpot deal propertySource
dealname"Square Order " + order.id
amountorder.totalMoney.amount / 100
pipeline889312001 (Square POS pipeline)
dealstage1338115383 (closed-won)
closedateorder.closedAt
square_order_idorder.id (dedup key)
square_closed_atorder.closedAt
payment_methodJoined order.tenders[].type
Contact + company association logic
  1. If order.customerId exists → fetch the Square customer record.
  2. Try to match a HubSpot contact by email first (if Square has it), then by phone.
  3. If no match: create a new HubSpot contact, defaulting hs_marketable_status=false (Square POS contacts are not marketing-opted-in).
  4. Set hs_lead_status only if blank (preserves prior tags like SQUARE_POS from earlier syncs).
  5. Associate the new deal to the contact, update last_purchase_date, flag vip_customer=true if amount ≥ $5,000.
  6. If the customer has a companyName AND emailAddress in Square, enrich/link a HubSpot company too.
Dedup + idempotency

For each order, search HubSpot for an existing deal where square_order_id == order.id. If found, skip. Otherwise create.

This means re-running the sync is safe — duplicates can't happen. With the 25h sinceDate window the work is small (typically a few orders); without the window the loop dedups every completed order ever (~1467 today, ~600s of HubSpot Search-API calls).

Infrastructure · Lambda + LaunchAgent in parallel
Lambda functionimg-integration-production-OrderSyncHandlerFunction-bcebxfuo
Log group/aws/lambda/img-integration-production-OrderSyncHandlerFunction-zsezdkkx
Runtimenodejs24.x · 1024 MB · 900 s timeout · 0 retries
Account (today)024033896674 (master)
Account (target)507024405504 (stonecity-prod)
LaunchAgent~/Library/LaunchAgents/com.img.sync-orders.plist

Today both run in parallel (Lambda at 11:00 UTC, LaunchAgent at 06:00 CT). Once 24–48 h of clean Lambda runs is confirmed, unload the LaunchAgent.

04 Customer Sync — SPS → HubSpot companies

Daily 11:30 UTC. Pulls all B2B customers from Stone Profits (~1,600 active records out of ~28k total), classifies each as B2B vs retail, then creates or updates HubSpot companies — matched first by SPS customer ID, then by name, then by domain. Linked contacts get SPS metadata stamped on them.

Schedule
cron(30 11 * * ? *)
B2B mode
enabled in Lambda
Local runtime baseline
~13 min (LaunchAgent, full set)
Fetch strategy
chunked by salesRepName
Fetch strategy · why chunked by sales rep

The SPS Customers v2 endpoint paginates at 200 records. For ~28k customers across ~140 pages, a single linear pass is slow and fragile. We instead discover the distinct salesRepName values up front, then fetch each rep's customers in parallel (with bounded concurrency). De-dup happens on the merged result by customer.id.

This is the SPS endpoint pattern the previous owner reverse-engineered with their contact at Stone Profits. The discovery probe uses fetchPage({ status: "Active" }, 1, 500) to enumerate reps before fan-out.

Classification · B2B vs retail vs skip

classifyCustomer(customer) at src/sync/customer-classifier.ts returns one of:

  • b2b — has a company name distinct from contact name, typically customer.type = "Contractor" / "Trade" / "Wholesale"
  • retail — individual buyer, no company
  • skip — generic placeholder records (e.g. "Walk-in Customer", "Cash Sale")

The Lambda runs --b2b-only mode (retail is handled by Order Sync via Square POS).

Match logic · 3-tier dedup

For each B2B customer, try to find an existing HubSpot company in this order:

  1. By SPS IDsps_customer_id == customer.id (most reliable; previous syncs stamped this)
  2. By name — exact match on company name
  3. By domain — derived from customer.email

If no match → create a new company, stamp sps_customer_id for future runs.

Field mapping · SPS customer → HubSpot company
HubSpot company propertySPS source
namecustomer.name
domainparsed from customer.email
sps_customer_idcustomer.id
sps_customer_codecustomer.code
sps_customer_typecustomer.type
sps_price_levelcustomer.priceLevel
sps_payment_termscustomer.paymentTerms
sps_credit_limitcustomer.creditLimit
sps_sales_repcustomer.primarySalesRep
sps_is_tax_exemptcustomer.isTaxExempt
sps_last_syncedISO timestamp at sync time

Linked HubSpot contacts get the same SPS metadata (sps_customer_id, sps_customer_type, sps_price_level, sps_sales_rep) plus hs_lead_status=SPS_B2B and hs_marketable_status=false.

SPS_B2B enum gotcha (resolved 2026-05-11): The HubSpot hs_lead_status property is an enumeration — values are constrained to a whitelist. SPS_B2B wasn't in the list when the field was first written by this sync, causing 1,600+ contact-link failures every morning. PATCH'd into the enum on 2026-05-11. Today's enum: NEW, QUOTE, SALES_ORDER, UNQUALIFIED, SQUARE_POS, SPS_B2B.

05 Quote Sync — SPS → HubSpot deals

Daily 12:00 UTC. Pulls SPS quotes modified in the last 25 hours, creates or updates a HubSpot deal per quote in the Quotes pipeline. Maps SPS transactionStatus / stage to HubSpot stage IDs (Quoted / Accepted / Lost). Tracks revisions.

Schedule
cron(0 12 * * ? *)
Window
last 25 hours
Pipeline
895324173
Page size
500 quotes/page
Stage mapping · SPS status → HubSpot stage
SPS stateHubSpot stage IDLabel
transactionStatus = "Quoted"1352031151Quoted
transactionStatus = "Accepted" · stage = "won"1352031152Accepted (won)
stage = "lost"1352031153Lost
Defensive: mapping checks both transactionStatus and stage because SPS's stage field is mostly null in production data.
Field mapping · SPS quote → HubSpot deal
HubSpot deal propertySPS source
dealname"{opportunityNumber || transactionNumber}{customer} ({jobName})"
amountquote.total
pipeline895324173
dealstagesee stage mapping
closedatequote.expiryDate
sps_quote_idquote.id (dedup key)
sps_opportunity_idquote.opportunityID
sps_quote_numberquote.transactionNumber
sps_revision_countincremented on update
sps_customer_idquote.customerID (used for company association)
sps_sales_repquote.primarySalesRep
sps_job_namequote.jobName
sps_project_typequote.projectTypeValue
sps_last_syncedISO timestamp at sync time

Empty strings are stripped before write — HubSpot rejects empty values on number/date properties.

Association · linking deal to company

Each quote has a customerID pointing to an SPS customer. Customer Sync runs at 11:30 UTC, 30 minutes before Quote Sync at 12:00 UTC, so by the time we process a new quote the matching HubSpot company already exists with sps_customer_id = customerID. Quote Sync looks up that company and creates a Deal–Company association alongside the deal create.

For backfill of older quotes whose companies didn't exist at sync time, there's a rematch-quotes-to-companies script in the repo (scripts/rematch-quotes-to-companies.ts).

06 HubSpot — the aggregator

HubSpot is where everything lands. Three pipelines, custom properties stamped by every sync, marketing automation triggered by lead status changes, and a reporting layer the team actually looks at.

Pipelines

IDNameSourceStages
889312001Square POSOrder Syncstage 1338115383 (closed-won, single stage)
895324173QuotesQuote SyncQuoted (1352031151) → Accepted (1352031152) → Lost (1352031153)

Lead status enum

Every contact gets an hs_lead_status tag indicating origin / state. Writes are set-if-blank — earlier tags are never overwritten (so a contact who started as NEW from a form, then became SQUARE_POS from a sale, keeps the SQUARE_POS tag when later customer-sync would otherwise tag them SPS_B2B).

ValueSet byMeaning
NEWform submission, manual entryJust arrived, not yet qualified
QUOTE(manual / workflow)A quote has been started
SALES_ORDER(manual / workflow)Closed-won, order placed
UNQUALIFIED(manual)Not a fit
SQUARE_POSOrder SyncWalked in, bought retail
SPS_B2BCustomer SyncB2B customer in SPS

Custom properties (selection)

On Deals
  • square_order_id, square_closed_at, payment_method
  • sps_quote_id, sps_quote_number, sps_opportunity_id, sps_opportunity_number
  • sps_transaction_status, sps_stage
  • sps_customer_id, sps_quote_date, sps_expiry_date
  • sps_job_name, sps_project_type, sps_sales_rep
  • sps_so_count, sps_so_numbers, sps_revision_count
  • closed_lost_reason, project_type
On Companies
  • sps_customer_id, sps_customer_code, sps_customer_type
  • sps_price_level, sps_payment_terms, sps_credit_limit
  • sps_is_tax_exempt, sps_sales_rep, sps_sales_rep_id
  • sps_parent_customer_id, sps_last_synced
  • contractor_tier (Gold/Silver/Bronze), last_order_date
On Contacts
  • sps_customer_id, sps_customer_type, sps_price_level, sps_sales_rep
  • vip_customer (flagged at $5k+ Square purchase)
  • last_purchase_date, total_lifetime_value
  • referral_code, review_requested_date
On Products
  • sps_id, sps_category, sps_subcategory, sps_type
  • sps_color, sps_finish, sps_thickness, sps_dimensions
  • price_wholesale, price_contractor
  • sps_supplier, sps_brand, sps_origin

Marketing automation + ops scripts

Beyond the three syncs, the same repo has setup-once / on-demand scripts for the rest of the HubSpot footprint. Defined in package.json · invoked manually:

ScriptPurpose
setup-emailsCreate marketing emails via HubSpot's /marketing/v3/emails API
setup-workflowsCreate automation workflows via /automation/v4/flows
setup-warranty-formCreate the warranty-claim form on the public site
setup-listsCreate dynamic contact lists
setup-*-propertiesIdempotently ensure custom deal/contact/company/product properties exist
tier-contractorsScore B2B companies by spend → Gold/Silver/Bronze on contractor_tier
daily-digestGenerate a daily activity summary report
backfill-contactsOne-off contact data backfill
rematch-quotesRe-link quote deals to companies whose sps_customer_id was set after the quote was synced

07 The dual-entry gap

The current sync architecture is one-way: everything flows into HubSpot, nothing flows out. That works for reporting and unified visibility but creates a manual handoff every time a HubSpot lead becomes an SPS quote.

Today's workflow when a new B2B lead converts to a quote:

  1. Lead enters HubSpot — form submission, walk-in, phone, manual. Logged as hs_lead_status=NEW with name, email, phone, company.
  2. Sales rep qualifies the lead, decides to quote.
  3. Rep manually re-enters the customer in Stone Profits — name, billing address, ship-to address, tax status, price level, sales rep, payment terms.
  4. Rep builds the quote in SPS, adds line items, sends to customer.
  5. Next 11:30 UTC, Customer Sync pulls the new SPS customer → matches the existing HubSpot company by name/domain → stamps sps_customer_id + other SPS metadata on the HubSpot record.
  6. Next 12:00 UTC, Quote Sync pulls the new SPS quote → creates a HubSpot deal in the Quotes pipeline linked to that company.
  7. Sales leadership sees the quote in HubSpot reporting the next morning.

Step 3 is the friction. The rep typed the customer's information into HubSpot (or it arrived via form) and now retypes it into SPS. Cost: ~5–10 minutes per new B2B customer, plus the data-drift risk if address fields differ between the two records.

Closing the gap requires SPS to expose write endpoints so HubSpot can push a "create customer + create quote" payload when the rep clicks a button in the HubSpot deal page (via a HubSpot custom card extension). The data flow becomes round-trip — see future state.

08 SPS write API ask Sent 2026-04-14

Email sent to the Stone Profits API contact on 2026-04-14 requesting six endpoints to unblock the write direction. Status: awaiting their response.

Must-haves (in priority order)

EndpointWhat it unlocks
POST /v2/CustomersCreate new SPS customer from JSON body (same field set as the GET response)
POST /v2/QuotesCreate a quote with line items in one call; returns quote ID + number + totals
GET /v2/QuotesPaginated quote listing with filters (currently quotes use POST query, GET listing missing)
PATCH /v2/Customers/{id}Partial customer updates for when a HubSpot contact's email / phone / address changes

Nice-to-haves

EndpointWhat it unlocks
Webhooks on quote statusReal-time accepted / declined / expired / converted events — no polling
POST /v2/Quotes/{id}/convert-to-orderFinalize accepted quote → sales order in one call

Open questions sent with the ask

  • Does the existing AuthorizedNumber grant write access, or do writes need a different token / OAuth scope?
  • Will POST endpoints accept an Idempotency-Key header so we can safely retry on network failures?
  • In /v2/Quotes line items, are products looked up by itemId (numeric) or sku (string)?
  • Timeline estimate? Even a rough "2 weeks / 2 months / next quarter" lets us sequence the HubSpot custom-card work.

The full email draft is in ~/img-integration/docs/HANDOFF-2026-04-14-customer-sync.md lines 443–519. The body covers payload examples for POST /v2/Quotes with line-item structure.

09 Phase 2 — Website rebuild + Sentinel concierge

Migrate www.stonecity.com from WordPress and products.stonecity.com from Stone Profits' IIS hosting into one unified Next.js application on AWS. Add an AI concierge (Sentinel) that helps customers navigate the 11,793-SKU catalog. Pricing stays off the public surface.

Architecture

                         ┌──────────────────────────┐
                         │  stonecity.com (Next.js) │
                         │  CloudFront + Lambda@Edge│
                         └──────────────────────────┘
                            │      │           │
                  ┌─────────┘      │           └──────────┐
                  ▼                ▼                      ▼
            ┌──────────┐     ┌──────────┐         ┌────────────┐
            │ S3       │     │ Sentinel │         │ HubSpot    │
            │ product  │ ──► │ (Bedrock │         │ Forms API  │
            │ cache    │     │  KB +    │         │ (quotes,   │
            │ (~60MB)  │     │  Claude) │         │  contact,  │
            └──────────┘     └──────────┘         │  trade-pro)│
                  ▲                               └────────────┘
                  │ nightly sync (3rd sink)
                  │
            ┌──────────────────────────────┐
            │  img-integration nightly job  │
            │  (existing — adds new sink)   │
            └──────────────────────────────┘
                  │
                  ▼
            ┌──────────┐
            │   SPS    │  (source of truth, read-only)
            │ /v2/Prod │
            └──────────┘

Decisions locked

DecisionChoice
FrameworkNext.js 14+ (App Router) — per CLAUDE.md standard
HostingSST v3 + OpenNext → CloudFront + Lambda@Edge + S3
AWS accountstonecity-prod (507024405504) once Free→Paid; master temporarily
Commerce modelCatalog + quote-request only — no cart, no checkout, no public pricing
Sentinel roleOn-site AI concierge — natural-language search + product recommendations
WP migrationFull replace — all WP pages migrate to Next.js, WordPress decommissioned at cutover
Product data ownershipSPS stays system of record; Stone City mirrors nightly to S3, read-only
Brand aestheticClay-inspired (iSM default) — same family as ism3.ai, iSM industries, CTAL/MMML/SD/GBML/AOE. Current stonecity.com identity retired at cutover.
products.stonecity.com301 redirect to apex /products/ on cutover; subdomain decommissioned

Data flow — SPS as source of truth, S3 as read-mirror

The existing nightly img-integration Lambda gets a third sink. One SPS pull, three downstream writes:

SinkStatusPurpose
HubSpot ProductsLiveB2B attribution + reporting on which products tie to which customers
Square CatalogLivePOS pricing reference at the register
s3://stonecity-products/Phase 2 buildWebsite read-mirror — Next.js generateStaticParams + Bedrock KB indexer both source from here

The pricing rule (public surface)

Pricing exists in our cache but is never serialized into any public response.

Surfaces that show price: ❌ product pages, ❌ catalog index, ❌ category/subcategory pages, ❌ concierge replies, ❌ structured data (no offers.price in JSON-LD), ❌ sitemaps.

Surfaces that may show price: ✅ internal admin (auth-gated), ✅ HubSpot deal records (post quote-request, internal CRM only).

Sentinel's system prompt forbids quoting prices. Customer pricing questions get this response: "Pricing depends on slab availability, finish, and project scope. The fastest way to get a real number is to request a quote — I can help you start one for the products you're interested in."

Sentinel concierge — Stone City joins apex-portal as tenant #9

Major scope reduction (audit 2026-05-11): apex-portal already runs a multi-tenant concierge SaaS in production. 8 client sites are live tenants today — 4 service businesses (Signature Dentistry, Miami Medical Malpractice, Chicago Truck Accident Lawyers, All-One Exteriors) + 4 editorial atlases in the 4site rollout (Marque, Subdial, EldercareAtlas, RoofingTechPro). Stone City joins as the next tenant. We're not building a Bedrock concierge from scratch.

PieceDetail
Backendapex-portal /api/concierge SSE stream — Bedrock + ConverseStreamCommand on us.anthropic.claude-sonnet-4-6 (migrated 2026-05-05 from Anthropic Managed Agents)
Shared widget@isimplifyme/ui@^1.1.7+ — floating trigger, side-panel chat (480px desktop, full-screen mobile), mobile footer-collision detection
Tenant configs3://isimplifyme-concierge/tenants/stonecity/persona.json — brand voice, FAQs, tools_enabled allowlist, lead_form schema, pricing-rule system prompt
NEW for Stone City: product RAG toolsearch_stonecity_products tool registered in apex's MCP registry. Input: { query, max_results, category_filter }. Backend: Bedrock Knowledge Base over Stone City products (s3://stonecity-products/by-sku/*.json). Returns { sku, name, category, thumbnail_url, web_url, summary } — explicitly excludes pricing.
Enriched copyNightly Lambda generates 2-3 paragraph customer-facing descriptions from sparse SPS metadata via Claude. Indexed into the KB; sparse SPS fields → rich retrievable content.
Stone City site workMatches 4site rollout pattern exactly: npm i @isimplifyme/ui, mount widget in layout.tsx, add 3 proxy routes (/api/concierge, /api/concierge-lead, /api/concierge-emergency-tap), wire APEX_LEAD_SECRET from GitHub.
Cost~$5-20/mo Bedrock KB storage + ~$0.01-0.04 per concierge query (existing rate). ~$55 one-time enriched-copy backfill + ~$5-10/mo ongoing.

WP audit findings (2026-05-11)

Inventory of what migrates from www.stonecity.com:

Pages
23 (17 in sitemap, 6 draft/private)
Projects (custom post type)
81 all under /projects/residential-design/
Blog posts
53 last 2026-01-16
Total URLs to preserve
~158 + category + paginated sitemaps

Plugins to replace: Yoast SEO (sitemap + JSON-LD → Next.js native), HubSpot WP plugin v11.3.39 (form embeds → React component using hbspt.forms.create(); keep page tracking js.hs-scripts.com/50779605.js), Modula (gallery → custom React). Quform plugin is installed but inactive — safe to deactivate on cutover. Custom theme stone-city retired in favor of Clay-inspired Next.js. Full audit: ~/claude/projects/stonecity/wp-audit-2026-05-11.md.

HubSpot forms + meetings inventory

Live audit of portal 50779605 + WP page content (via REST). 5 active forms + 3 Meetings widgets. Forms stay in HubSpot — Next.js just re-embeds them.

PageTypeHubSpot ID
/contact/Formb9c937bc-… "General Contact Form updated"
/request-a-consultation/Formc60ebed1-… "Consultation Request form" older version live; newer "updated" exists but unused
/tile-sale/Formb5b290be-… "Tile Sale Lead Form"
/latest-sale/Form (reused)b5b290be-… (same as /tile-sale/)
(printed QR code)Form37a388bd-… "Register Your Stone Warranty"
/trade-pro/Meetingsmeetings.hubspot.com/sara647/45mins
/book/Meetings (×2)meetings.hubspot.com/sara647 + sara647/in-person-

Portal has 9 forms total, of which 5 are referenced from the live site. The other 4 are cleanup candidates: 0c38a461 Survey form, 7f8c2ff8 Sign up form, 864a5414 "New blank form (January 27, 2026)", plus two stale "updated" versions (c3a74d70 Consultation Request form updated, e58278ff Tile Sale Lead Form updated) that never replaced their predecessors. Resolve before rebuild — task tracked.

Phased plan

PhaseScopeEffort
0 DiscoveryHubSpot form cleanup (4 stale forms) · brand assets decision · WP admin done (27 pages, 81 projects, 53 posts, 9 HubSpot forms, 2 Modula galleries) · Sentinel resolved (tenant of apex-portal, not greenfield)1-2 days
1 FoundationNew stonecity-next repo, SST scaffold, marketing pages, staging environment1-2 weeks
2 Product catalogS3 cache sink in nightly Lambda · 11k product pages via ISR · sitemap · image strategy2-3 weeks
3 Forms + meetings re-embed5 HubSpot forms + 3 Meetings widgets re-embedded into Next.js. Forms stay in HubSpot. Preserve js.hs-scripts.com/50779605.js tracking.3-4 days
4 Sentinel concierge (tenant on apex-portal)Site: @isimplifyme/[email protected]+ + 3 proxy routes + widget mount. Apex side: DDB tenant row, persona JSON, NEW search_stonecity_products MCP tool, Bedrock KB + indexing.4-5 days
5 Content migrationBlog (53) + Projects (81) via WP REST → MDX · media library transfer · 301 mapping2 weeks
6 Production cutoverDNS · WP decommission · 301 monitoring · retire products.stonecity.com subdomain1 week
Total: 7-10 weeks dedicated focus, no SPS-side blockers. (Revised down: -3 days Phase 3 re-embed pattern, -7 days Phase 4 apex-portal tenant pattern.)

Open questions

  • Sentinel implementation source Resolved — apex-portal multi-tenant concierge; Stone City joins as tenant #9. Add search_stonecity_products tool to apex MCP registry.
  • Modula galleries — still need WP admin access to count active galleries and plan image extraction (other audit items resolved by REST API)
  • HubSpot form cleanup decision — for the two stale "updated" form versions in the portal (c3a74d70, e58278ff), either deploy them and retire the older live forms, or delete the orphans. See task #11.
  • Brand assets — Clay aesthetic (iSM default) or carry forward current stonecity.com identity?
  • products.stonecity.com URL fate — fold into /products on apex, or keep subdomain as 301?

Full spec: ~/claude/projects/stonecity/architecture-plan-2026-05-11.md. WP audit: ~/claude/projects/stonecity/wp-audit-2026-05-11.md. Update those docs as decisions are made.

10 Quote builder (future state, once SPS ships writes)

Once Stone Profits exposes the write endpoints above, a 4th sync direction becomes possible and the dual-entry gap closes.

What changes

  • New: HubSpot Deal → SPS Customer + Quote (push, real-time). Triggered by a HubSpot custom-card button on the deal sidebar.
  • Sales rep opens a qualified HubSpot deal, clicks "Quote in SPS", a dialog asks for line items + sales rep + expiry, POSTs to /v2/Customers (if SPS doesn't already have one) then /v2/Quotes, stamps the returned quote ID back on the HubSpot deal.
  • Existing Quote Sync becomes redundant for in-flight quotes (they're already in HubSpot from creation) but stays useful for status updates and for quotes created directly in SPS (older workflow).
  • Webhook listener (if SPS ships webhooks) replaces the daily Quote Sync polling for status changes. Latency drops from up to 24h → seconds.

Implementation footprint

  • HubSpot custom card extension (UI extension API) — small React component shown on deal page
  • Backend Lambda (or extend the existing Lambdas) handling the POST flow, idempotent via the HubSpot deal ID
  • SPS write client in src/clients/sps-customers.ts + sps-quotes.ts (currently read-only)

Time-to-build estimate once SPS endpoints exist: ~2 weeks for the basic create-customer-and-quote flow, +1 week for the webhook listener if webhooks ship.

11 Operational

AWS accounts

AccountIDUse
Master (iSimplifyMe LLC)024033896674All current client work + temporary Lambda home for img-integration
stonecity-prod507024405504Target account once Free → Paid upgrade completes. Today: SubscriptionRequiredException on S3/Lambda probes.

Schedule cheat-sheet (all UTC)

UTCCT (CDT)What fires
11:0006:00Order Sync (Lambda + LaunchAgent parallel)
11:3006:30Customer Sync
12:0007:00Quote Sync

Lambda inventory

FunctionLog group suffix
img-integration-production-OrderSyncHandlerFunction-bcebxfuozsezdkkx
img-integration-production-CustomerSyncHandlerFunction-hwxszatavatkakab
img-integration-production-QuoteSyncHandlerFunction-dfeeubxxbbbkvhdr

Monitoring (TODO)

  • CloudWatch alarm on Lambda Errors metric → SNS → email [email protected]
  • Optional: alarm on Duration > 600s (early warning before 900s timeout)
  • Optional: alarm on Invocations < 1 over 24h (cron didn't fire)

Migration plan (master → stonecity-prod)

  1. Wait for Free → Paid upgrade on 507024405504 (manual in Billing console, takes up to 24h)
  2. Verify S3 + SSM probes return clean in stonecity-prod-sso profile
  3. cd ~/img-integration/.worktrees/lambda-migration
  4. AWS_PROFILE=master-sso npx sst remove --stage production (remove from master)
  5. AWS_PROFILE=stonecity-prod-sso npx sst deploy --stage production (redeploy)
  6. Verify next-morning CRON fires cleanly in the new account
  7. Unload local LaunchAgents

Quick reference commands

# Tail order sync logs
aws logs tail /aws/lambda/img-integration-production-OrderSyncHandlerFunction-zsezdkkx \
  --profile master-sso --since 5m --follow

# Manual one-off invocation (sync)
aws lambda invoke --profile master-sso \
  --function-name img-integration-production-OrderSyncHandlerFunction-bcebxfuo \
  --invocation-type Event --payload '{}' /tmp/out.json

# Unload local LaunchAgents after Lambda confidence
launchctl unload ~/Library/LaunchAgents/com.img.sync-orders.plist
launchctl unload ~/Library/LaunchAgents/com.img.sync-customers.plist
launchctl unload ~/Library/LaunchAgents/com.img.sync-quotes.plist

# Re-run a sync locally
cd ~/img-integration && npm run sync-orders

12 Code map

Repo: ~/img-integration/

PathWhat it does
src/index.tsCLI dispatcher — handles sync-orders, sync-customers, sync-quotes, setup-*, etc.
src/lambda/sync-orders.tsLambda handler, 25h sinceDate window, wraps OrderSync
src/lambda/sync-customers.tsLambda handler, --b2b-only mode, wraps CustomerSync
src/lambda/sync-quotes.tsLambda handler, 25h modifiedSince window, wraps QuoteSync
src/sync/order-sync.tsOrderSync class — Square orders → HubSpot deals
src/sync/customer-sync.tsCustomerSync class — SPS customers → HubSpot companies
src/sync/quote-sync.tsQuoteSync class — SPS quotes → HubSpot deals
src/sync/customer-classifier.tsB2B vs retail vs skip classification
src/sync/customer-field-mapper.tsSPS customer → HubSpot property mappings
src/sync/company-enrichment.tsSquare POS contact → linked HubSpot company creation
src/clients/square.tsSquare SDK wrapper · orders, customers, catalog
src/clients/hubspot.tsHubSpot SDK wrapper · ~30 methods covering deals, contacts, companies, line items, properties, lists, workflows
src/clients/sps-auth.tsPOST /v1/token/loginv1 + token caching
src/clients/sps-customers.tsPOST /v2/Customers query, chunked fetch by salesRep
src/clients/sps-quotes.tsPOST /v2/Quotes query, paginated
src/utils/retry.tswithRetry() — exponential backoff on HTTP 429 only
sst.config.tsSST v4 config — 3 Cron Lambdas, env vars baked at deploy time