🎁 Loyalty — for the developer
Updated
The CRM 1.12.0 model. The LoyaltyPointsByCustomer layout shows the customer, the Points resource, and hides a zero outgoing balance. User: configuring it. Why:
business.
Objects#
| Name | Kind |
|---|---|
LoyaltyPoints |
register, dimension Customer, resource Points, allowNegativeBalance: false |
LoyaltyTier |
the ladder dictionary: MinPoints, MaxRedemptionPerDocument, DiscountPercent, EarnRate |
CRMSettings |
LoyaltyEnabled, PointsPerCurrencyUnit — the fallback rate |
LoyaltyCampaign |
dictionary: a dated EarnRate, optional ItemGroup and CustomerKind (a reference to Inventory's CustomerType); the handler copies the type's code into the hidden CustomerType (String 8), which is what the service compares. Overlapping live windows of one group+type pair are refused |
LoyaltyCampaignService |
EarnRateOfAsync / OverlayOfAsync(date, itemGroup, customerType = null). Score: 2 for a matching group + 1 for a matching type; the higher score wins. null sees only campaigns without a type — this is how the phone calls OverlayOfAsync in CachedCampaign. FindOverlappingAsync compares the type case-insensitively |
SalesLoyaltyTx |
a CRM extension of the SalesInvoice.Issued subtype |
LoyaltyRedemption |
Draft → Redeemed, −Points |
SalesInvoiceLoyaltyDiscountHandler |
stamps DiscountPercent on SetSubtype Issued |
CustomerPurchaseHistoryReport |
CustomReport: SQL over SalesRealization × TP_SalesInvoiceLines, not over TR_Revenue (analytics are not columns) |
The earn rate, top down, per invoice line: a campaign whose group and
customer type both match → a group campaign → a type campaign → a global
campaign → LoyaltyTier.EarnRate > 0 → PointsPerCurrencyUnit → 1:1. An empty
campaign filter means "any". b2b and B2B are the same type.
Customer types: a reference with its code copied (Inventory 1.36.0)#
Customer.Kind and LoyaltyCampaign.CustomerKind reference Inventory's
CustomerType (Code up to 8, translatable Name). The old text columns
Customer.CustomerType and LoyaltyCampaign.CustomerType stay, hidden, holding
the type's code; the handlers keep them in step, and everything that
compares types (LoyaltyCampaignService, the invoice's buyer.type) still reads
the code. Why not just retype the column:
- Schema sync refuses a retype on a column that holds data (string →
uniqueidentifieris not an in-place conversion;SchemaSynchronizerskips it with a warning). Every stand's customers held text. - A required field is a
NOT NULLcolumn, and adding one to a populated table fails.Kindis nullable; the Customer handler refuses a new customer with no type instead. - A caller that writes only the text still works:
ICustomerTypes.EnsureAsyncresolves the code (creating the type when new), soPlatformBilling, the showcase package and every test that setsCustomerType = "B2B"keep their meaning. The nightly jobFillCustomerKindstypes the customers a data package wrote around the handler and those of tenants installed before 1.36.0. - A type's code does not change once saved: customers and campaigns hold a copy, and a changed code would silently stop campaigns matching.
Trap.
SaveRecordAsynchands back the caller's object, not what the hook wrote: a test reads the record again before asserting. (A partial update no longer reaches the before-save hook with zeros: the typed record carries the stored values, see Document save.)
SalesLoyaltyTx is a CRM extension of the SalesInvoice.Issued subtype.
The tier is not a field on the customer. One and the same rule:
MinPoints <= the balance, the highest one.
GetTransactions is synchronous: the balance, the tier and the campaign all
come through GetAwaiter().GetResult().
There are no service DTOs. Integer, not Int. The flag is IsDisabled on the
campaign, not IsActive.
Tests#
LoyaltyFlowTest — the accrual, the redemption, the tier's limit, the rate from
the settings, the rate from the tier reached, a zero EarnRate not overriding
the settings.
LoyaltyCampaignTest — the campaign beats the settings and the tier, an expired
window falls back, live windows of one group overlapping is rejected, another
group's campaign does not win, a mixed invoice counts per line, different groups
may share the calendar, the customer's own type beats the general campaign
(3 × 5 × 5 = 75), a different type stays on the settings (30), a catalog screen
without a customer still sees the general one, b2b overlaps B2B, text longer
than 8 characters is not saved, and group + type together give 3 × 5 × 9 +
1 × 5 × 5 = 160.
CustomerPurchaseHistoryTest — an issued line of 3×5=15 in the report's source,
a draft not.
Why not TR_Revenue#
A CustomReport needs columns in the SELECT. Revenue stores the customer and the
item in an AnalyticSet. The realization's lines are ordinary fields and the
same fact of sale. [Name] is normalised by ISqlDialect.
What not to do#
- Do not store the current tier on
Customer. - Do not take the rate from the balance after this invoice.
- Do not switch the module off with a zero
EarnRateon Bronze. - Do not compute one rate for the whole invoice: with an
ItemGroupthe rate lives on the line. - Do not show a campaign with a filled customer type on the phone:
OverlayOfAsyncwithout a type does not choose it. Otherwise the catalog screen promises a rate that onlyB2Bwill get. - Do not call HTTPS / files / reflection from a tx script.
Same topic for other readers: Analyst · User