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.
Purpose#
Three independent but related pieces:
- Loyalty program — accrues points on a realization document through an
extension of another model's event (
Salesdoes not know aboutCRM), redeems points with a separate document, and applies automatic discount by the reached tier. - Sales funnel — lead (
SalesLead) → quotation (SalesQuotation) → sales order (SalesOrder, an object of theSalesmodel). Both funnel documents do not post to any register. They are pure CRM artifacts; balance, stock, and receivable movements begin only with the order. - 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#
Pointsis scalarDecimal(18,2), owned byCRM. It is used inLoyaltyRedemption.Points,LoyaltyTier.MinPoints/MaxRedemptionPerDocument, and theLoyaltyPointsregister resource.RefLoyaltyCampaignis a Reference EDT onLoyaltyCampaign. 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 inOnBeforeSaveAsync, not inOnAfterPostAsync: the document subtype is written by a separate single-column update (SetSubtypeAsyncor 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 whenSubtype=="Issued", the document is not new, and manualDiscountPercenthas not been set. It takes the customer's tier on the balance before points from this invoice are accrued, and setsheader.DiscountPercentfromLoyaltyTier.SalesLoyaltyTx(partial class, tx script) calculates accrued points. It readsCRMSettingsonce (LoyaltyEnabled, rate; rate ≤0 or missing settings means legacy fallback1). 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+PointsinLoyaltyPointsfor 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.
Menu#
The CRM group → Dictionaries (CRMSettings, LoyaltyCampaign,
LoyaltyTier) · Documents (SalesLead, SalesQuotation,
LoyaltyRedemption) · Registers (LoyaltyPoints) · Settings ·
Reports (CustomerPurchaseHistoryReport, LoyaltyPointsByCustomer).
Links with other modules#
- Outgoing: Inventory — reads
Item/ItemGroupand does not extend them. Earlier revisions of this document stated otherwise, but theCustomerextension does not exist. Sales — extendsSalesRealizationfor accrual/discount and createsSalesOrderwhen converting a quotation. - Incoming: none.
CRMis a leaf model: nomodel.jsondepends on it, and noCRMobject 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 |
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 |