Deals Pipeline — Engineering Deep Dive
How deals flow from Dutchie POS and Odoo PTL through Redis to the frontend. Covers deal sources, sync pipeline, discount matching, and customer-facing pages.
| Responsible | Juan Palomino |
|---|---|
| Last Update | 09/14/2026 |
| Completion Time | 12 hours |
| Members | 1 |
5. Key Gotchas
View allBullMQ Inventory Sync (enqueued every 10 minutes)
The inventory sync is a BullMQ repeatable job on mintinvsvc. It is enqueued every 10 minutes, but a full run takes ~100 minutes (per-store Dutchie pulls behind a shared rate limiter). Workers run with concurrency=1, so runs never overlap. Current phases:
- Phase 1: Inventory Sync — fetches product inventory from the Dutchie Backoffice API per store into Postgres
- Phase 2: Product Enrichment — fills missing product details (images, potency, etc.) from the Dutchie Backoffice API (not Odoo)
- Phase 2b: Dutchie Plus special-price enrichment
- Phase 3+4: Discount Sync + Cache Refresh, grouped by LSP — the first store in an LSP with a POS API key syncs the LSP-wide discounts; sibling stores that get 0 from their own sync receive a replicated copy (
syncDiscountsFrom); then every store in the LSP has its Redis cache refreshed - Phase 3b: Odoo Discount Sync — only when
ODOO_DISCOUNT_SYNC_ENABLED=true - Phase 5: Odoo Sync — pushes inventory to Odoo; failures are logged and swallowed, so the job still reports success
Separate CACHE_REFRESH job (also every 10 minutes): republishes Postgres inventory/discounts rows to Redis for every store, so Redis never lags Postgres by more than one interval regardless of what the long sync run is doing.
Phase 3 Detail: Discount Sync
Two API strategies tried in order:
- POS API (primary):
GET api.pos.dutchie.com/discounts/v2/listwith a per-LSP key from env varDUTCHIE_POS_API_KEY_{lspId}. Returns LSP-wide discounts with full restriction data. Which LSPs have a key is Railway env config — check the service variables rather than assuming a market list. - Backoffice API (fallback):
POST v2/discount/get-discountvia an authenticated Backoffice session, per location, enriched per discount.
Code: packages/inventory-service/services/discountSync.js:1563 (syncDiscounts)
Data Flow
Dutchie POS/Backoffice API
|
v
PostgreSQL (discounts table)
- Composite PK: {locationUUID}_{discountId}
- JSONB fields: products, brands, product_categories
- Day-of-week booleans: monday..sunday
- source column: dutchie or ptl
|
v (Phase 3+4 and CACHE_REFRESH: cacheSync.js refreshLocationCache)
Redis Cache
- Key: discounts:{locationUUID}
- Value: JSON array of all active discounts
|
v (HTTP GET /api/locations/:id/discounts)
Frontend (Cloudflare Pages)
Infrastructure
- Service: mintinvsvc on Railway (Nixpacks, Node 22)
- Two services, both named mintinvsvc — each with its own Redis and Postgres:
mintinvsvc-production-6aa5.up.railway.app(LetsGoMint project): what the storefront calls viaPUBLIC_REDIS_API_URL, and the single external writer (order sync, lane watcher, qty sync).mintinvsvc-production.up.railway.app(MintDeals-Backend project): parity replica withDISABLE_EXTERNAL_WRITER_JOBS=true.
npm run deploy:invsvc) and never stop -6aa5. - Job System: BullMQ with concurrency=1 (prevents overlapping)
Key Code References (origin/master, 2026-09-14)
- Job schedules:
packages/inventory-service/jobs/queues.js:209(INVENTORY_SYNC),:239(CACHE_REFRESH), writer gate:350(DISABLE_EXTERNAL_WRITER_JOBS) - Job processor:
packages/inventory-service/jobs/processors/inventorySync.js:62-235 - Cache refresh job:
packages/inventory-service/jobs/processors/cacheRefresh.js - Discount sync:
packages/inventory-service/services/discountSync.js:1563(syncDiscounts),:1774(syncDiscountsFrom),:880(resolveComputedProductIds) - Cache refresh:
packages/inventory-service/services/cacheSync.js:252(refreshLocationCache); Redis keycache/index.js:46 - Worker concurrency:
packages/inventory-service/jobs/workers.js:45
Updated 2026-09-14.
Odoo PTL to mintinvsvc via Webhook
When a PTL day is published in Odoo, its deals are pushed to mintinvsvc via webhook — bypassing the normal sync cycle. Publishing also pushes the deals live into Dutchie (mint.dutchie_discount_push.mode = live, gated per market and per store), so a PTL publish is not storefront-only.
Daily mirror: the ptlDealsRefresh job (11:00 UTC daily) copies live Dutchie deals into the PTL daily-deals sheets for every market. It writes mint.ptl.day state over RPC instead of calling action_publish(), so it never pushes anything back into Dutchie.
Flow
Odoo: mint.ptl.day → action_publish()
|
v
Creates/updates mint.discount records (source=ptl)
|
v
POST mintinvsvc/api/webhook/ptl-discount-sync
Body: { location_id, source: "ptl", discounts: [...] }
Header: X-API-Key
|
v
persistPtlDiscounts():
1. Ensures each discount has a ptl_-prefixed id
2. Resolves computed_product_ids from the posted restrictions
3. UPSERTs each row into Postgres (never deletes)
4. Merges into Redis: existing non-ptl_ rows + the posted PTL rows
(any ptl_ row not in this POST drops out of Redis)
|
v
cacheSync rebuilds Redis from Postgres on every sync,
so a partial POST only thins Redis until the next cycle
Endpoints
- POST /api/webhook/ptl-discount-sync — called by Odoo
action_publish(). Takeslocation_idin the request body. - POST /api/locations/:id/discounts/bulk — called by the
ptl-match-products.mjsscript. Takes locationId in the URL path. - POST /api/locations/:id/discounts/full-replace — called by Odoo
mint_redis_push; a separate path that also resolvescomputed_product_idsinline.
The first two both call persistPtlDiscounts(), which normalizes IDs with a ptl_ prefix so they can be distinguished from Dutchie discounts.
Store scoping (fixed, PR #348)
A discount without explicit store_ids used to fan out to every dispensary in every market (Arizona deals reached Florida on 2026-08-25). Since PR #348, an unscoped discount takes its market from its PTL deal (or the day being published). With neither, it is skipped and logged (PTL publish: skipped … with no store_ids and no market), so watch for that warning when a deal goes missing.
PTL ID Format
PTL discounts use the format {locationUUID}_ptl_{discountId} for their id field. Dutchie discounts use {locationUUID}_{numericId}. The ptl_ substring is what the merge logic uses to strip old PTL deals before adding new ones.
Key Code Paths
- Odoo publish:
mint_command_center/models/ptl_day.py:171(action_publish) — odoo-custom-modulesorigin/main - Odoo store map:
ptl_day.py:143(_get_store_uuid_map(market=None)) - Odoo webhook fire:
ptl_day.py:414(_push_discounts_to_redis) - Odoo payload builder:
ptl_day.py:551(_discount_to_webhook_payload) - Odoo full-replace push:
mint_redis_push/models/mint_discount.py:146 - Express webhook handler:
packages/inventory-service/api/server.js:4388(POST /api/webhook/ptl-discount-sync) - Express bulk handler:
server.js:4409; full-replace:server.js:4432 - Shared persist logic:
server.js:2659(persistPtlDiscounts; Postgres INSERT:2722, Redis merge:2809) - Daily mirror:
packages/inventory-service/jobs/processors/ptlDealsRefresh.js - Match script:
scripts/ptl-match-products.mjs
Updated 2026-09-14.