Skip to content

CRM & Loyalty — Process flows

Architecture & lifecycle auto-generated from code (dispatch, CHECK status enums, SET status writes). Sequences & notes are authored.

Architecture

%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"state":{"useMaxWidth":false}}}%%
flowchart LR
  C["Client"] --> A["Pages Fn crm<br/>bolt_session auth · company_id scope"]
  A --> D["dispatch<br/>REST resources"]
  D --> K["Kernel<br/>erp_document + journal<br/>atomic GL / stock"]
  K --> DB[("D1 tables")]
  D --> DB
  D --> EV["events<br/>append audit trail"]
  EV --> DB
  DB -.-> T["crm_loyalty_pipeline<br/>crm_loyalty_pipeline_stage<br/>crm_loyalty_lead<br/>party<br/>crm_loyalty_opportunity<br/>crm_loyalty_consent_ledger<br/>crm_loyalty_retention_policy<br/>crm_loyalty_dsar"]

Lifecycle

Kernel document lifecycle (erp_document.docstatus)

Every money/stock-moving document in this module posts through the shared kernel, walking a fixed docstatus:

%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"state":{"useMaxWidth":false}}}%%
stateDiagram-v2
  [*] --> Draft: createDocument (docstatus 0)
  Draft --> Submitted: submitDocument (docstatus 1, journal posted)
  Submitted --> Cancelled: cancelDocument (docstatus 2, reversed)
  Submitted --> [*]
  Cancelled --> [*]

crm_loyalty_lead.status

States: New (initial) · Working · Qualified · Disqualified · Merged

crm_loyalty_program.status

States: Draft (initial) · Active · Paused · Ended · Cancelled

crm_loyalty_dsar.status

States: Open (initial) · InProgress · Completed · Rejected

Key flow: Reward redemption posts loyalty liability to GL

reward/redeem (with reward_id + membership_id in the body) wraps points/redeem, which consumes point lots FIFO and — only when a monetary value is present — posts a balanced journal through the kernel.

%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","actorBkg":"#2d2d30","actorBorder":"#569cd6","actorTextColor":"#d4d4d4","signalColor":"#9cdcfe","signalTextColor":"#d4d4d4","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa","labelBoxBkgColor":"#3a3d41","labelBoxBorderColor":"#dcdcaa"},"sequence":{"useMaxWidth":false}}}%%
sequenceDiagram
  autonumber
  participant C as Client
  participant API as CRM API (redeemReward)
  participant D1 as D1 tables + views
  participant K as Kernel / GL

  C->>API: POST reward/redeem {membership_id, reward_id}
  API->>D1: load membership+program (must be Active), active reward
  API->>D1: idempotency check (reward_redemption)
  Note over API: else call redeemPoints(reward.points_cost)
  API->>D1: availableLots FIFO (point_lot_available)
  API->>API: assertCanConsume(lots, points)
  alt monetary_value > 0
    API->>K: resolveAccount(liability, redemption_offset)
    API->>K: createDocument LOYALTY-REDEMPTION
    API->>K: submitDocument (dr liability / cr offset)
    K-->>API: kernel_document_id
  end
  API->>D1: insert burn ledger entry (points < 0)
  API->>D1: insert burn_allocation per lot (FIFO)
  API->>D1: insert reward_redemption row
  API-->>C: balance + tier + posting

Notes

  • Purpose: unify pre-sale CRM (leads/opportunities feeding erp_sales_order via link-order) with post-sale loyalty (points programs, tiers, rewards) and PDPA-style privacy (consent ledger, retention policy, DSAR) under one tenant-scoped surface.
  • Money-safety: points are only ledger rows; real GL impact happens exclusively in postMonetaryRedemption, which calls kernel.createDocument + kernel.submitDocument to post a balanced LOYALTY-REDEMPTION journal (dr liability_transaction_kind, cr redemption_credit_kind). earn/expire never touch GL.
  • Idempotency everywhere: consent, earn, redeem, reward, expire each build a deterministic idempotency_key and short-circuit returning { idempotent: true } if a prior row exists; the kernel posting reuses kernel:${idem} so a retry cannot double-post.
  • FIFO lot accounting: availableLots orders by expiry then occurred_at; insertConsumption writes the negative ledger entry plus one point_burn_allocation per lot consumed, and throws point allocation failed if it can't fully cover the burn — balance is always view-derived, never a mutable counter.
  • Append-only audit: point_ledger CHECK constraints force sign-by-kind (earn/adjust_credit/migrate_in positive, burn/expire/adjust_debit/reverse negative); opportunity moves and consent grant/withdraw are recorded as immutable events, with current state exposed through views (consent_current, membership_tier_projection, weighted_forecast).
  • Gotchas seen in code: lead/qualify is a fan-out — it may create an erp_party, upsert party facets, and spawn an opportunity in one call; opportunity/link-order runs a guarded ALTER TABLE erp_sales_order ADD COLUMN opportunity_id (D1 CREATE IF NOT EXISTS skips new columns) and fails closed (409) if the SO is already linked elsewhere.