ZuloOne
English
Sign in
Menu

🎁 Loyalty — for the developer

Updated

← Developer wiki

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 → uniqueidentifier is not an in-place conversion; SchemaSynchronizer skips it with a warning). Every stand's customers held text.
  • A required field is a NOT NULL column, and adding one to a populated table fails. Kind is nullable; the Customer handler refuses a new customer with no type instead.
  • A caller that writes only the text still works: ICustomerTypes.EnsureAsync resolves the code (creating the type when new), so PlatformBilling, the showcase package and every test that sets CustomerType = "B2B" keep their meaning. The nightly job FillCustomerKinds types 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. SaveRecordAsync hands 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 EarnRate on Bronze.
  • Do not compute one rate for the whole invoice: with an ItemGroup the rate lives on the line.
  • Do not show a campaign with a filled customer type on the phone: OverlayOfAsync without a type does not choose it. Otherwise the catalog screen promises a rate that only B2B will get.
  • Do not call HTTPS / files / reflection from a tx script.

Same topic for other readers: Analyst · User

Where next#

Was this page helpful?