Skip to Content
Deals Pipeline — Engineering Deep Dive

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
Deals Pipeline — Engineering Deep Dive

Dutchie to Redis Sync Pipeline

Updated 2026-09-14. This course is being rewritten; see Known Gotchas for current deal-pipeline traps.

BullMQ 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:

  1. Phase 1: Inventory Sync — fetches product inventory from the Dutchie Backoffice API per store into Postgres
  2. Phase 2: Product Enrichment — fills missing product details (images, potency, etc.) from the Dutchie Backoffice API (not Odoo)
  3. Phase 2b: Dutchie Plus special-price enrichment
  4. 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
  5. Phase 3b: Odoo Discount Sync — only when ODOO_DISCOUNT_SYNC_ENABLED=true
  6. 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:

  1. POS API (primary): GET api.pos.dutchie.com/discounts/v2/list with a per-LSP key from env var DUTCHIE_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.
  2. Backoffice API (fallback): POST v2/discount/get-discount via 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 via PUBLIC_REDIS_API_URL, and the single external writer (order sync, lane watcher, qty sync).
    • mintinvsvc-production.up.railway.app (MintDeals-Backend project): parity replica with DISABLE_EXTERNAL_WRITER_JOBS=true.
    Deploy both (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 key cache/index.js:46
  • Worker concurrency: packages/inventory-service/jobs/workers.js:45

Updated 2026-09-14.

5. Key Gotchas
View all
Deals Pipeline — Engineering Deep Dive
Dutchie to Redis Sync Pipeline
Updated 2026-09-14. This course is being rewritten; see Known Gotchas for current deal-pipeline traps.

BullMQ 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:

  1. Phase 1: Inventory Sync — fetches product inventory from the Dutchie Backoffice API per store into Postgres
  2. Phase 2: Product Enrichment — fills missing product details (images, potency, etc.) from the Dutchie Backoffice API (not Odoo)
  3. Phase 2b: Dutchie Plus special-price enrichment
  4. 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
  5. Phase 3b: Odoo Discount Sync — only when ODOO_DISCOUNT_SYNC_ENABLED=true
  6. 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:

  1. POS API (primary): GET api.pos.dutchie.com/discounts/v2/list with a per-LSP key from env var DUTCHIE_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.
  2. Backoffice API (fallback): POST v2/discount/get-discount via 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 via PUBLIC_REDIS_API_URL, and the single external writer (order sync, lane watcher, qty sync).
    • mintinvsvc-production.up.railway.app (MintDeals-Backend project): parity replica with DISABLE_EXTERNAL_WRITER_JOBS=true.
    Deploy both (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 key cache/index.js:46
  • Worker concurrency: packages/inventory-service/jobs/workers.js:45

Updated 2026-09-14.

Deals Pipeline — Engineering Deep Dive
PTL Webhook Pipeline
Updated 2026-09-14. This course is being rewritten; see Known Gotchas for current deal-pipeline traps.

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(). Takes location_id in the request body.
  • POST /api/locations/:id/discounts/bulk — called by the ptl-match-products.mjs script. Takes locationId in the URL path.
  • POST /api/locations/:id/discounts/full-replace — called by Odoo mint_redis_push; a separate path that also resolves computed_product_ids inline.

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-modules origin/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.