ZuloOne
English
Sign in
Menu

09 · CRM — customers, loyalty, leads, and quotations

Updated

Solution layer. Depends on Inventory only for shared Item/ItemGroup data and does not extend it. Depends on Sales: CRM extends the realization document and continues the funnel into the sales order. This is a leaf model: no other workspace model depends on CRM.

← General specification

Purpose#

Three independent but related pieces:

  1. Loyalty program — accrues points on a realization document through an extension of another model's event (Sales does not know about CRM), redeems points with a separate document, and applies automatic discount by the reached tier.
  2. Sales funnel — lead (SalesLead) → quotation (SalesQuotation) → sales order (SalesOrder, an object of the Sales model). Both funnel documents do not post to any register. They are pure CRM artifacts; balance, stock, and receivable movements begin only with the order.
  3. Reporting — customer purchase history and customer points balance.

The model owns the scalar EDT Points and the LoyaltyPoints register.

What is created (object map)#

Kind Name Purpose
Model CRM module model, Solution(2) layer
NumberSequence ×6 CRMSettingsSeq, LoyaltyCampaignSeq, LoyaltyRedemptionSeq, LoyaltyTierSeq, SalesLeadSeq, SalesQuotationSeq numbering
EDT Points (scalar) points, owned by this model
EDT RefLoyaltyCampaign (Reference) declared but not used by any field; see invariants
Dictionary CRMSettings (singleton) general accrual rate and loyalty-program flag
Dictionary LoyaltyCampaign dated accrual-rate override by item group and customer type
Dictionary LoyaltyTier loyalty tiers: point threshold, discount, redemption limit
Document SalesLead lead (New→Qualified/Lost→Quoted)
Document SalesQuotation quotation (Draft→Issued→Converted, plus Cancelled branch)
Document LoyaltyRedemption point redemption (Draft→Redeemed)
TablePart SalesQuotationLines quotation line
Register LoyaltyPoints customer points balance; negative balance is prohibited at register level
Service ILeadService, ILoyaltyCampaignService, IQuotationService lead/quotation conversion and accrual-rate selection
DocumentExtension SalesRealization.CRM → SalesRealization (Sales) points accrual and loyalty discount
Command ×7 see Commands subtype transitions and conversions
Report CustomerPurchaseHistoryReport (CustomReport) customer purchase history (no points)
ReportView LoyaltyPointsByCustomer customer points balance (Opening/Income/Outcome/Closing)
PrintForm ×2 SalesLeadZrPrintForm, LoyaltyRedemptionZrPrintForm print forms
Menu menu.json 5 subgroups: Dictionaries, Documents, Registers, Settings, Reports
— reused — Item/ItemGroup (Inventory), SalesRealization/SalesOrder (Sales) read/extend; not created here

Total: 1 model, 6 number sequences, 2 EDTs, 3 dictionaries (one singleton), 3 documents, 1 table part, 1 register, 3 services, 1 document extension, 7 commands, 1 report, 1 report view, and 2 print forms.

model.json#

Solution(2) layer, modelVersion 1.12.0. Dependencies: Inventory (minVersion 1.0.0), Sales (minVersion 1.22.0). Direction is CRM → Sales and CRM → Inventory; there is no cycle. CRM is a leaf: no model.json in the workspace declares a dependency on it.

Number sequences#

CRMSettingsSeq, LoyaltyCampaignSeq, LoyaltyRedemptionSeq, LoyaltyTierSeq (padLength 3), SalesLeadSeq, SalesQuotationSeq.

EDT#

  • Points is scalar Decimal(18,2), owned by CRM. It is used in LoyaltyRedemption.Points, LoyaltyTier.MinPoints/ MaxRedemptionPerDocument, and the LoyaltyPoints register resource.
  • RefLoyaltyCampaign is a Reference EDT on LoyaltyCampaign. It is declared, but no field of any workspace object is assigned to it. It looks like a leftover and is not part of the current model contract.

Dictionaries#

CRMSettings (singleton)#

All-tenant loyalty-program settings (isSingleton:true, isCached:true).

Fields: see CRMSettings in Objects below.

Field Business rule
LoyaltyEnabled Empty means false, but accrual still follows the legacy path; see invariants.
PointsPerCurrencyUnit Default accrual rate. ≤0 or missing settings keep the legacy rate 1.

LoyaltyCampaign#

A dated accrual-rate override by item group and customer type.

Fields: see LoyaltyCampaign in Objects below.

Field Business rule
Code Unique campaign code.
Name Translatable campaign name.
EarnRate Must be greater than 0.
EffectiveFrom / EffectiveTo EffectiveFrom must be less than or equal to EffectiveTo; empty EffectiveTo means open-ended.
IsDisabled Disabled campaigns do not participate in overlap checks and are not selected.
ItemGroup Empty means all item groups.
CustomerType Trimmed to eight characters to match the customer-card field contract.

OnBeforeSaveAsync, when IsDisabled is false, calls ILoyaltyCampaignService.FindOverlappingAsync and rejects a second live campaign with the same (ItemGroup, CustomerType) and intersecting date window: “There is already another loyalty campaign for this group and customer type.” The check is not global. A campaign for all groups (empty ItemGroup) may coexist on the same dates with a campaign tied to a specific group. A collision is caught only on an exact match of the pair.

LoyaltyTier#

Tier ladder (isLogged:true, isCached:true).

Fields: see LoyaltyTier in Objects below.

Field Business rule
MinPoints Balance threshold from which the tier is reached.
MaxRedemptionPerDocument Per-document redemption limit at this tier.
DiscountPercent Must stay between 0 and 100.
EarnRate 0 or empty inherits CRMSettings.PointsPerCurrencyUnit.

The customer tier for discount and redemption limit is not an exact match. It is the highest reached MinPoints: balance 500 with Silver below 500 and Gold at MinPoints=500 gives Gold; a balance just below a threshold gives the previous tier. If tiers exist but the balance is below all thresholds, redemption is rejected and does not pass without a discount.

Documents#

SalesLead#

Subtypes: New (initial) → Qualified → Quoted; New/Qualified → Lost. requiresPosting:true, postOnSave:true, but the tx script body (SalesLeadTransactionsScript) is fully commented out. The lead never moves any register, confirmed by the test “Convert does NOT move Stock”.

Fields: see SalesLead in Objects below.

DeliveryDate is set by default through IRecordDefaults when the lead is created.

OnBeforeSaveAsync requires a non-empty Subject. If both Customer and Contract are filled, it resolves and validates the pair through ISalesContractService (Sales).

Commands: QualifyLead (“Qualify lead”, only on New, requires non-empty Subject), LoseLead (“Mark lost”, New+Qualified, with confirmation), ConvertLead (“Create quotation”, only on Qualified; see Commands).

SalesQuotation#

Subtypes: Draft (initial) → Issued → Converted; Draft/Issued → Cancelled. Like the lead, the tx script is fully commented out. Issuing a quotation moves no stock, tax, or debt, as confirmed by the test.

Fields: see SalesQuotation in Objects below.

SourceLead stores the lead Guid that produced the quotation. Repeated ConvertLead for the same lead does not create a second quotation.

The table part is Lines (SalesQuotationLines).

OnBeforeSaveAsync: while in Draft, recalculates line prices through ISalesLinePricing.ApplyFieldAsync; if Contact is not specified, it takes the main CustomerContact of the customer (Inventory); it also resolves and validates the Customer+Contract pair. OnBeforePostAsync (when switching to Issued) requires non-empty lines with Quantity > 0 and filled Customer/Contract/Location/DeliveryDate.

Commands: IssueQuotation (“Issue quotation”, Draft only), ConvertQuotation (“Create sales order”, only Issued, rejects expired ValidUntil), CancelQuotation (“Cancel quotation”, Draft+Issued, with confirmation).

SalesQuotationLines

Fields: see SalesQuotationLines in Objects below.

Field Business note
Quantity / BaseQuantity Quantity is normalized into hidden BaseQuantity using the selected unit.
PriceExplanation Stores the price origin shown to the user.

OnFieldChangedAsync for Item/Unit/Quantity/UnitPrice recalculates the line through ISalesLinePricing.ApplyFieldAsync.

LoyaltyRedemption#

Point redemption. The header has only two fields and no money amount.

Fields: see LoyaltyRedemption in Objects below.

Subtypes: Draft (initial) → Redeemed (isReadOnly). One tx script on Redeemed writes -Points to the LoyaltyPoints register.

OnBeforePostAsync is owned by the RedeemLoyalty command too; this is intentional duplication of “one rule, two doors”. It requires Points > 0, Points ≤ current balance (ITotalsManager.GetBalanceAsync), and Points ≤ MaxRedemptionPerDocument of the highest reached LoyaltyTier. If tiers are configured but none is reached, redemption is rejected.

The monetary effect of point redemption as a discount lives separately in the SalesRealization.CRM extension. LoyaltyRedemption itself has no money connection.

Registers#

LoyaltyPoints#

isDoubleEntry:false, useBalanceTable:true, allowNegativeBalance:false. Analytic (dimension): Customer. Resource: Points (EDT Points).

Reports#

CustomerPurchaseHistoryReport (CustomReport)#

Customer purchase history: SQL over SalesRealization, excluding Draft/Cancelled. The report user help states explicitly: loyalty points are not included. They are visible only in LoyaltyPointsByCustomer.

Postgres gotcha: an unquoted AS Amount alias in SQL silently becomes lowercase (amount) in Postgres. The following report code does not find the column and throws KeyNotFoundException without a clear SQL error. The defect does not reproduce on SQL Server, the current development stand.

LoyaltyPointsByCustomer (ReportView)#

Report view over the LoyaltyPoints register, grouped by Customer, with Opening/Income/Outcome/Closing of the Points balance.

Commands#

Command Object Source subtype Check Effect
QualifyLead (“Qualify lead”) SalesLead New Subject is not empty. → Qualified
LoseLead (“Mark lost”) SalesLead New, Qualified confirmation → Lost
ConvertLead (“Create quotation”) SalesLead Qualified Customer/Contract/Location/DeliveryDate filled ILeadService.CreateQuotationAsync → draft quotation, lead → Quoted
IssueQuotation (“Issue quotation”) SalesQuotation Draft Quantity>0, Customer/Contract pair valid → Issued
ConvertQuotation (“Create sales order”) SalesQuotation Issued ValidUntil has not expired IQuotationService.ConvertToOrderAsync → draft order (Sales), quotation → Converted
CancelQuotation (“Cancel quotation”) SalesQuotation Draft, Issued confirmation → Cancelled
RedeemLoyalty (“Redeem points”) LoyaltyRedemption Draft same balance/limit checks as OnBeforePostAsync → Redeemed

Services#

ILeadService#

CreateQuotationAsync(Guid leadId) is idempotent. It first looks for an existing quotation with SourceLead == leadId; a repeated call does not create a duplicate. It requires lead subtype Qualified/Quoted and filled Customer/Contract/Location/DeliveryDate (non-Guid.Empty). It creates a draft quotation, copies the header, links through AddLinkAsync, and moves the lead to Quoted.

ILoyaltyCampaignService#

EarnRateOfAsync, OverlayOfAsync (returns the pair “rate, campaign name”), FindOverlappingAsync. Campaign selection priority: both group and customer type match — weight 3 (best); group only — 2; type only — 1; neither (open campaign) — 0 (worst acceptable); disabled campaigns — weight -1 and do not participate. With equal weight, the later EffectiveFrom wins. Group/type comparison is case-insensitive after trimming and uppercase conversion.

IQuotationService#

ConvertToOrderAsync(Guid quotationId) is idempotent. It first looks for an existing order with SourceQuotation == quotationId. It requires non-empty lines and subtype Issued/Converted, and rejects expired ValidUntil. It creates a draft SalesOrder (an object of the Sales model), copies the header and lines, links the documents, and moves the quotation to Converted.

Extension#

SalesRealization.CRM#

The model's only extension: folder DocumentExtensions/SalesRealization.CRM/, target SalesRealization (Sales). CRM does not extend Customer (Inventory); it only reads it.

  • SalesRealizationLoyaltyDiscountHandler — [ExtensionOf("SalesRealization")]. It runs in OnBeforeSaveAsync, not in OnAfterPostAsync: the document subtype is written by a separate single-column update (SetSubtypeAsync or equivalent), so the discount must be applied before the engine starts building postings. Otherwise revenue, receivable, and points would use different amounts; see invariants. It only runs when Subtype=="Issued", the document is not new, and manual DiscountPercent has not been set. It takes the customer's tier on the balance before points from this invoice are accrued, and sets header.DiscountPercent from LoyaltyTier.
  • SalesLoyaltyTx (partial class, tx script) calculates accrued points. It reads CRMSettings once (LoyaltyEnabled, rate; rate ≤0 or missing settings means legacy fallback 1). For each line it selects the rate in priority order: LoyaltyCampaignService.EarnRateOfAsync → customer tier rate → CRMSettings/legacy. It calculates Σ(amount × rate), rounds to 2 digits (AwayFromZero), and posts +Points in LoyaltyPoints for the customer.

Business logic and invariants#

Automatic discount is applied on save, not on posting. Putting it in OnBeforePostAsync would desynchronize amounts: revenue/receivable would be calculated with one discount, while points, which see already saved DiscountPercent, would use another.

Manual discount is stronger than automatic discount. If the invoice already has DiscountPercent > 0, the loyalty-tier handler does not touch it. The manual value stays even if the customer's tier later changes.

The tier is the highest reached tier, not an exact match. It controls both automatic discount and redemption limit. Level order by MinPoints decides what is “higher”; an error in those thresholds produces illogical selection.

The discount range is strictly 0–100%. It is checked when saving a tier or campaign and again when applying it to the invoice. A value outside the range is a clear error, not a value to clamp.

100% discount legitimately zeros receivable, revenue, and points at once. It looks like “nothing happened”, but all effects are correctly zeroed.

Campaign overlap is checked by pair (ItemGroup, CustomerType), not globally. A campaign for all groups and a campaign for a specific group may legally share a period. Only two campaigns with the same pair collide.

Accrual rate priority: campaign, if applicable to the line → customer-tier rate when there is no campaign → CRMSettings.PointsPerCurrencyUnit → legacy 1 when settings do not exist or the rate is ≤0.

CRMSettings.LoyaltyEnabled does not block legacy accrual. Empty or explicitly false on a tenant without a settings rate still accrues points on the legacy path. Turning the flag on is not the only program switch, which is easy to mistake for a diagnostics bug.

Lead and quotation are CRM artifacts without register movement. SalesLead and SalesQuotation both have requiresPosting, but their tx scripts are empty. The real sales order affects stock, debt, and revenue; the funnel itself is only organizational.

Conversions are idempotent through a back reference. ConvertLead checks SourceLead on the quotation, and ConvertQuotation checks SourceQuotation on the order. Calling the command again from the interface does not create duplicates.

ConvertQuotation respects expired ValidUntil. An expired quotation is not converted even while it remains in Issued; the command does not silently create an order.

RedeemLoyalty duplicates OnBeforePostAsync checks intentionally. It is “one rule, two doors”: both points read the same balance and limit, and neither is the sole source of truth.

CustomerType is trimmed to 8 characters. This matches the width of the same-named field on the customer card. Excess data fails with a clear error when saving the campaign; it is not silently cut.

Purchase history deliberately does not show points. Points balance lives only in LoyaltyPointsByCustomer and the LoyaltyPoints register. This is a report purpose split, not an omission.

RefLoyaltyCampaign is an unused EDT. It is declared, but no workspace field refers to it. Do not design around it.

End-to-end scenario. Lead (New) → QualifyLead → ConvertLead creates a draft quotation with lines → IssueQuotation fixes prices and moves it to Issued → ConvertQuotation before ValidUntil expires creates a draft order in Sales → the normal Sales path to SalesRealization → the CRM extension first sets an automatic discount on save from the current customer tier, then the tx script accrues points at the effective rate. Later, LoyaltyRedemption/RedeemLoyalty writes off points limited by the balance and tier limit.

The CRM group → Dictionaries (CRMSettings, LoyaltyCampaign, LoyaltyTier) · Documents (SalesLead, SalesQuotation, LoyaltyRedemption) · Registers (LoyaltyPoints) · Settings · Reports (CustomerPurchaseHistoryReport, LoyaltyPointsByCustomer).

  • Outgoing: Inventory — reads Item/ItemGroup and does not extend them. Earlier revisions of this document stated otherwise, but the Customer extension does not exist. Sales — extends SalesRealization for accrual/discount and creates SalesOrder when converting a quotation.
  • Incoming: none. CRM is a leaf model: no model.json depends on it, and no CRM object is extended by another model.

Objects#

Generated from workspace metadata by node tools/gen-reference.mjs; edit the metadata, not this block.

Dictionaries#

CRMSettings — CRM settings

Field Caption Type Required
ID Code String(50) Yes
LoyaltyEnabled Loyalty enabled Boolean No
PointsPerCurrencyUnit Loyalty points per currency unit Decimal(18,4) No

LoyaltyCampaign — Loyalty campaigns

Dated earn-rate overlay. Optional ItemGroup and CustomerType scope the window; empty means every line or every customer. A more specific window beats a wider one, then LoyaltyTier and CRMSettings.

Field Caption Type Required
Code Code String(32) Yes
CustomerKind Customer type → CustomerType No
CustomerType Customer type code String(8) No
EarnRate Earn rate Decimal(18,4) Yes
EffectiveFrom Effective from DateTime Yes
EffectiveTo Effective to DateTime No
ID ID String(50) Yes
IsDisabled Disabled Boolean No
ItemGroup Item group → ItemGroup No
Name Name String(256) Yes

LoyaltyTier — Loyalty tier

Ladder of loyalty tiers: the accumulated balance decides the tier, the tier decides redemption limits, standing discount and earn rate

Field Caption Type Required
DiscountPercent Discount, % Decimal(5,2) Yes
EarnRate Earn rate Decimal(18,4) No
ID Code String(50) Yes
MaxRedemptionPerDocument Max redemption per document Points Yes
MinPoints Minimum points Points Yes
Name Name String(100) Yes

Documents#

LoyaltyRedemption — Loyalty redemption

Header fields:

Field Caption Type Required
Customer Customer → Customer Yes
ID Number String(50) Yes
Points Points Points Yes

Subtypes:

Subtype Caption
Draft Draft
Redeemed Redeemed

Commands:

Command Caption Target subtype
RedeemLoyalty Redeem points —

SalesLead — Sales lead

Header fields:

Field Caption Type Required
Agent Agent → SalesPerson No
Contact Contact → CustomerContact No
Contract Contract → SalesContract No
Customer Customer → Customer No
DeliveryDate Delivery date DateTime No
Email Email String(200) No
ID Number String(50) Yes
Location Ship-from location → StoreCell No
Notes Notes String(2000) No
Outlet Outlet → CustomerOutlet No
Phone Phone String(50) No
Source Source String(100) No
Subject Subject String(200) Yes

Subtypes:

Subtype Caption
New New
Qualified Qualified
Quoted Quoted
Lost Lost

Commands:

Command Caption Target subtype
QualifyLead Qualify lead —
ConvertLead Create quotation —
LoseLead Mark lost —

SalesQuotation — Sales quotation

Header fields:

Field Caption Type Required
Agent Agent → SalesPerson No
Contact Contact → CustomerContact No
Contract Contract → SalesContract Yes
Customer Customer → Customer Yes
DeliveryDate Delivery date DateTime Yes
DeliveryTerm Delivery term → DeliveryTerm No
DiscountPercent Discount, % Percent No
ID Number String(50) Yes
Location Ship-from location → StoreCell Yes
Notes Notes String(2000) No
Outlet Outlet → CustomerOutlet No
PaymentTerm Payment term → PaymentTerm No
SourceLead Source lead Guid No
ValidUntil Valid until DateTime No

Subtypes:

Subtype Caption
Draft Draft
Issued Issued
Converted Converted
Cancelled Cancelled

Commands:

Command Caption Target subtype
IssueQuotation Issue quotation —
ConvertQuotation Create sales order —
CancelQuotation Cancel quotation —

Registers#

LoyaltyPoints — Loyalty points

Dimensions:

Dimension Caption Type Required
Customer Customer → Customer —

Resources:

Resource Caption Type Required
Points Points Points —

Table parts#

SalesQuotationLines — Lines

Used by: SalesQuotation.

Field Caption Type Required
BaseQuantity Qty in base unit Qty No
Item Item → Item Yes
PriceExplanation Price origin String(500) No
Quantity Quantity Qty Yes
Unit Unit → UnitOfMeasure No
UnitPrice Unit price Money Yes
Was this page helpful?