This document describes the local loyalty stack: data models, seed data, phone-based customer identity, automatic membership when a customer is linked to a sale, point earning and redemption on sales, tier reassignment from points and calendar-year spending, and what is not implemented yet (void/refund clawback automation, GATE/third-party).
- Program and tiers:
LoyaltyProgramandLoyaltyTierare seeded by_insert_loyalty()(default program name SaleFlex Rewards, Bronze → Platinum tiers). Point rates and welcome/birthday fields live onLoyaltyProgram. - Policy tables (per program, seeded with the default program):
LoyaltyProgramPolicy: Customer identifier mode (PHONEvs legacyLOYALTY_CARD), whether a phone is required to enroll, default country calling code for normalization (seed uses90), void/refund point policy placeholder (NONE/ future values), integration provider (LOCAL|GATE|EXTERNAL— onlyLOCALis active).LoyaltyEarnRule: Ordered rules (priorityascending) evaluated byLoyaltyEarnServiceat checkout completion (see Earning engine below).LoyaltyRedemptionPolicy: Caps and steps for POS redemption (max_basket_amount_share_from_points,minimum_points_to_redeem,points_redemption_step,allow_partial_redemption); consumed byLoyaltyRedemptionServicewhen the cashier uses BONUS (BONUS_PAYMENT).
- Membership:
CustomerLoyalty(one row per customer for the program). Optionalloyalty_card_numberremains for legacy or external card IDs; primary recognition is the customer’s phone. - Ledger:
LoyaltyPointTransactionrecords movements. New enrollments can create aWELCOMErow whenLoyaltyProgram.welcome_pointsis greater than zero. Completed sales createEARNEDand, when applicable,REDEEMED(negativepoints_amount) rows linked to the permanentTransactionHead. - In-app settings UI: The main menu SETTING form exposes Loyalty program, Loyalty policy, and Loyalty redemption tabs (alongside POS). One SAVE updates the resolved active-program rows; see Configuration and
pos/service/loyalty_settings_model.py.
phone_normalized: Digits-only canonical value, unique when set (multipleNULLallowed). Used for de-duplication and fast exact lookup.- Normalization is implemented in
LoyaltyService.normalize_phone()usingLoyaltyProgramPolicy.default_phone_country_calling_code(e.g. Turkish mobiles: strip a leading0, prepend90when the number does not already start with the country code). - On customer SAVE (
CustomerEvent._customer_detail_save_event): the service recomputesphone_normalizedand blocks the save if another active customer already has the same value (error dialog). - Customer list search: In addition to
LIKEon name, phone display string, and e-mail, if the search text normalizes to a full key, results can matchCustomer.phone_normalizedexactly.
When a non–walk-in customer is assigned to the active sale (_assign_customer_to_sale in CustomerEvent):
LoyaltyService.ensure_loyalty_on_sale_assignment()runs.- The customer’s
phone_normalizedis synced fromphone_number(same rules as save). - If enrollment requires a phone (
require_customer_phone_for_enrollment) and no normalized phone exists, noCustomerLoyaltyrow is created. - If the normalized phone duplicates another customer’s row, enrollment is skipped for that assignment (session rolled back for that attempt; sale customer assignment still stands).
- Otherwise a
CustomerLoyaltyrow is created on first need (lowest active tier),TransactionHeadTemp.loyalty_member_idis set, and welcome points plus aLoyaltyPointTransactionof typeWELCOMEare written when configured.
Walk-in customers are never enrolled.
LoyaltyService.member_qualifies_for_tier: For eachLoyaltyTier, if bothmin_points_requiredandmin_annual_spendingare set, the member qualifies when eitherlifetime_pointsmeets the point floor orannual_spentmeets the spending floor (matching the model docstring). If only one threshold is set, that condition alone applies.LoyaltyService.recalculate_membership_tier: Among active tiers for the program, ordered bytier_leveldescending, the member is assigned the first (highest) tier they qualify for. Called after new enrollment (including welcome points), when an existing member is loaded at sale assignment, and after a completed sale once spending and any earned points for that receipt have been applied.LoyaltyService.apply_completed_sale_to_membership: On each completed sale transaction, incrementstotal_purchases, addstotal_amounttototal_spent, updatesannual_spentfor the calendar year oftransaction_date_time(resets annual spending when the sale year is after the year oflast_activity_date), setslast_activity_date. Tier recalculation can be deferred when points are credited in the same session (recalculate_tier=False).
On PaymentService.copy_temp_to_permanent(), before the permanent TransactionHead is inserted:
LoyaltyEarnService.stage_document_earn(document_data)runs for sale transactions with a non–walk-in customer and an activeCustomerLoyaltyrow linked to the active program.- Document net total (v1):
TransactionHeadTemp.total_amount(must be ≥LoyaltyProgram.min_purchase_for_pointswhen that field is set). Base points:
floor(total_amount × points_per_currency × tier.points_multiplier)
Tier multiplier is taken from the member’sfk_loyalty_tier_idat earn time (before this sale’s earned points are added tolifetime_points).
Payment mix filter: IfLoyaltyProgram.settings_jsoncontainsearn_eligible_payment_types(array ofEventNamepayment strings such asCASH_PAYMENT), every non-cancelledTransactionPaymentTemp.payment_typeon the receipt must appear in that list or no points are earned on that sale (default seed lists common tenders; omit the key to allow all payment types). LoyaltyEarnRulerows for that program,priorityascending, byrule_type:DOCUMENT_TOTAL: Addsextra_pointsorbonus_pointsfromconfig_json(after applying the same tier multiplier).LINE_ITEM: Per active line (is_cancel/is_voidedexcluded).config_jsonmay includefk_product_idorproduct_code/plu, plusextra_points/bonus_points_per_lineand/orpoints_per_currencyon the line’stotal_price.CATEGORYorDEPARTMENT: Same line filters; matchfk_department_main_group_idand optionallyfk_department_sub_group_id, thenextra_pointsand/orpoints_per_currencyon matched line totals.PRODUCT_SETorBUNDLE: If every UUID inproduct_ids(orrequired_product_ids) appears on at least one active line with a non-nullfk_product_id, addsbonus_pointsorextra_points(tier multiplier applied).bundle_idalone does not match until catalog wiring exists.
TransactionHeadTemp.loyalty_points_earnedis set to the sum of the above (non-negative), subject to the payment-type filter. ATransactionLoyaltyTempsnapshot row is appended todocument_data["loyalty"](points_earned,points_redeemed,redemption_amount,points_balance_before/afterpreview,bonus_multiplier,campaign_bonus).
Then the permanent TransactionHead is created (copying loyalty_points_earned and loyalty_points_redeemed), related TransactionLoyalty and TransactionDiscount rows are copied from temp snapshots, and LoyaltyService.on_sale_transaction_completed(..., permanent_head_id=head.id):
- Applies spending counters (tier recalculation deferred during that call).
- Debits
REDEEMEDpoints (reducesavailable_points/total_points;lifetime_pointsunchanged) whenloyalty_points_redeemed> 0. - Credits
EARNEDpoints into balances and inserts the correspondingLoyaltyPointTransactionrows. - Runs
recalculate_membership_tieronce so new lifetime points affect tier.
Walk-in, missing customer, missing membership, or inactive program → loyalty_points_earned is set to 0 and no earn snapshot / ledger row.
LoyaltyProgram.currency_per_point: monetary discount per point redeemed (required).- On the PAYMENT form, the cashier enters whole points on the numpad, then presses BONUS (
PAY_TYPE_BONUS→EventName.BONUS_PAYMENT). LoyaltyRedemptionService.apply_points_redemptioncaps redemption by: memberavailable_points, remaining net amount due (total_amount − total_discount_amount − total_payment_amount), optionalmax_basket_amount_share_from_pointsonLoyaltyRedemptionPolicy, and policy minimum / step / partial rules.- A
TransactionDiscountTemprow is created withdiscount_type="LOYALTY",discount_codelike100PTS, anddiscount_amountequal to the applied currency value.TransactionHeadTemp.total_discount_amountandloyalty_points_redeemedincrease; the sale list and amount table refresh so the line appears as a discount (not a payment tender). PaymentServicetreats net amount due (gross minus discounts) everywhere: remaining balance, change, and document completion.- On completion,
LoyaltyServicepostsLoyaltyPointTransactionREDEEMEDwith negativepoints_amountand notes derived from loyalty discount lines.
Existing databases: insert a TransactionDiscountType row with code="LOYALTY" (see _insert_transaction_discount_types) if missing, or redemption copy to permanent TransactionDiscount will be skipped.
Void / refund / exchange: LoyaltyService.on_void_or_cancel_completed_sale is a stub for reversing earn/redeem and clawing back redemption value on returns; wire it when refund/exchange flows persist linked heads.
Tier percentage discount on product lines remains separate (not applied automatically at sale time).
- Customer Detail → Point movements (
CUSTOMER_LOYALTY_POINTS_GRID): read-only list ofLoyaltyPointTransactionfor the customer (cashier-facing audit). Seeded on new installs; existing DBs get the tab viaensure_customer_loyalty_points_gridon startup (pos/manager/application.py). - Closure → Receipt detail (
CLOSURE_RECEIPT_DETAIL_GRID): header includes Loyalty — points earned and Loyalty — points redeemed fromTransactionHead.
| Area | Location |
|---|---|
| Policy / rule models | data_layer/model/definition/loyalty_program_policy.py, loyalty_earn_rule.py, loyalty_redemption_policy.py |
| Customer phone column | data_layer/model/definition/customer.py |
| Seed data | data_layer/db_init_data/loyalty.py (_insert_loyalty_program_policy, _insert_loyalty_redemption_policy, _insert_loyalty_default_earn_rule) |
| Enrollment / tier / ledger credit | pos/service/loyalty_service.py (LoyaltyService) |
| Earn calculation + temp staging | pos/service/loyalty_earn_service.py (LoyaltyEarnService) |
| Redemption (BONUS button) | pos/service/loyalty_redemption_service.py (LoyaltyRedemptionService); pos/manager/event/payment.py (_bonus_payment_event) |
| Completed sale hook | pos/service/payment_service.py → LoyaltyEarnService.stage_document_earn, discount/payment/loyalty permanent copy, LoyaltyService.on_sale_transaction_completed, then CustomerSegmentService.on_sale_transaction_completed |
| Full temp→perm copy (e.g. cancel path) | pos/manager/document_manager.py — payment completion uses the slimmer PaymentService.copy_temp_to_permanent |
| UI / events | pos/manager/event/customer.py (save, search, assign) |
| Point-movements grid + receipt loyalty lines | user_interface/window/dynamic_dialog.py (_populate_customer_loyalty_points_grid, _populate_closure_receipt_detail_grid) |
metadata.create_all() creates new tables but does not add new columns to existing SQLite files. If you upgrade an old db.sqlite3, either recreate the database or run a manual migration (e.g. ALTER TABLE customer ADD COLUMN phone_normalized …) before relying on phone uniqueness and loyalty seed rows.
- Customer Management — search, save, sale assignment, Point movements tab (
CUSTOMER_LOYALTY_POINTS_GRID) - Customer Segmentation — marketing segments and
marketing_profile()(tier stays on loyalty side) - Database Models Overview — full model list
- Database Initialization —
_insert_loyalty - Service Layer —
LoyaltyService,CustomerSegmentService
Last Updated: 2026-04-10
Version: 1.0.0b7
License: MIT