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_ordervialink-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 callskernel.createDocument+kernel.submitDocumentto post a balancedLOYALTY-REDEMPTIONjournal (drliability_transaction_kind, crredemption_credit_kind).earn/expirenever touch GL. - Idempotency everywhere:
consent,earn,redeem,reward,expireeach build a deterministicidempotency_keyand short-circuit returning{ idempotent: true }if a prior row exists; the kernel posting reuseskernel:${idem}so a retry cannot double-post. - FIFO lot accounting:
availableLotsorders by expiry thenoccurred_at;insertConsumptionwrites the negative ledger entry plus onepoint_burn_allocationper lot consumed, and throwspoint allocation failedif it can't fully cover the burn — balance is always view-derived, never a mutable counter. - Append-only audit:
point_ledgerCHECK constraints force sign-by-kind (earn/adjust_credit/migrate_inpositive,burn/expire/adjust_debit/reversenegative); 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/qualifyis a fan-out — it may create anerp_party, upsert party facets, and spawn an opportunity in one call;opportunity/link-orderruns a guardedALTER TABLE erp_sales_order ADD COLUMN opportunity_id(D1CREATE IF NOT EXISTSskips new columns) and fails closed (409) if the SO is already linked elsewhere.