Foodops
All docs

Guides · Updated 2026-08-23 · va8dcd1b

FoodOps Loyalty & Customer Credit

Audio narration for this article is generated with the weekly docs refresh.

Plain-English summary

Loyalty on the FoodOps side is real and end-to-end as of mid-2026: a restaurant configures a points program in the admin app, customers get enrolled (automatically, if the program says so), closed orders earn points (both PurpleLane and native programs now earn on every closed paid sale linked to a loyalty program — earning no longer requires a redemption; see Earning points), and the cashier can redeem points as money off right on the counter POS — either against the whole bill or against specific items. Behind the scenes a redemption is just a special fixed-amount discount tied to the program, and the actual points ledger is settled just after the order closes, once its invoice is committed. There's also a second flavour: PurpleLane, an external loyalty provider — when an organisation has that integration, the points balance lives at PurpleLane and Foodops fetches/redeems against it by the customer's mobile number.

Heads-up on older guidance: the Shops KB entry (Shops Loyalty Programs) states loyalty is "Not applicable to Foodops — this is a Shops-only feature". That is stale since 2026-05 — the counter POS redemption modal and customer Loyalty tab shipped in foodops.counter.app. Both products now share the same loyalty domain model.

When you'd use this

  • A regular asks "how many points do I have?" → customer detail page → Loyalty tab (or the redemption modal shows the balance)
  • A customer wants to pay part of the bill with points → Redeem loyalty points on the order screen
  • An owner wants a "1 point per 10 MVR, 100 points = 10 MVR off" scheme → configure a program in the admin app
  • The restaurant runs PurpleLane → points come from PurpleLane by phone number; earn/redeem flow back to PurpleLane automatically
  • A corporate client runs a tab instead of points → that's customer credit, not loyalty (see the credit section below)

Program mechanics (configuration)

Programs are configured in the FoodOps admin app (Sales → Loyalty Programs) backed by the sales API's CRUD endpoints [code: foodops-admin-app/apps/admin/src/pages/sales/LoyaltyProgramsPage.tsx @ bcde336; hubits-api-sales/.../LoyaltyProgramsController.cs @ 20fc7ca]. A program carries:

Field Meaning
PointsPerUnit / tier AccrualValue Earn rate — points per currency unit of the bill
LoyaltyPointAccrualBasis What "the bill" means for earning: % of bill total (the after-discount bill excluding its tax and service-charge portions — changed in prod, see Earning points) or % of bill subtotal (net)
ConversionFactor Redemption rate — points per 1 currency unit of discount (discount = points ÷ ConversionFactor)
MinimumPoints Minimum redemption per transaction
Active from / to Program validity window
AutoSignUpCustomers New customers get enrolled automatically at creation
ApplyWithDiscounts If off, orders that carry other discounts earn no points
ApplyWithLoyaltyPayments If off, an order where points were redeemed earns no points
RedeemableWithDiscounts If off, redemption is blocked on orders that already have a discount
ExpiryDurationInDays Points expiry (config field; enforcement not verified this pass)
Tiers Tier list with per-tier accrual values — note: only the first tier is used by the earn calculation today

Creating a program auto-generates a hidden system Discount record that redemptions attach to, and the first program in an org becomes the default program [code: haafai.domain/Haafai.Models/Services/LoyaltyPrograms/LoyaltyProgramService.cs:99-116 @ e1389444 (staging)].

Earning points

Earning is settled once the order's invoice is committed (moments after close) — the settlement job (ProcessLoyaltyPointsForCloseOrder, still in OrdersController) is now enqueued from PaymentProcessingService after invoice/payment posting commits (posting is async via the SalesPostingOutbox), no longer directly at CloseOrder. The in-code reason: at close time the invoice may not exist yet, so the job's invoice != null guard was silently skipping accrual [code: hubits-api-sales/.../Services/Counter/Payment/PaymentProcessingService.cs:420-427 @ main — enqueues ProcessLoyaltyPointsForCloseOrder after posting; OrdersController.cs:3696 job body @ main]. Both program types now earn on every closed paid sale linked to a loyalty program — earning no longer requires a redemption (this is a change from earlier builds — see below):

  • Native programs earn on every closed paid sale linked to a loyalty program — not just redeeming orders. AccruePoints is now enqueued unconditionally on close (inside the non-PurpleLane branch, whenever an invoice exists), with no redemption guard [code: OrdersController.cs:3792-3793 @ 19190b9 — "BackgroundJob.Enqueue<...LoyaltyProgramService>("sales", s => s.AccruePoints(customerId, orderIdLocal, transactionId, loyaltyProgramId));" with the comment "Accrue on every paid sale linked to a loyalty program — not just redemptions."]. For this to fire, the order must carry a LoyaltyProgramId; the counter now auto-links the order to the customer's default enrolled program on customer-attach, even when they aren't redeeming, so the common walk-in→attach-customer→pay flow accrues [code: OrdersController.cs:1076-1084 @ 19190b9 — sets salesOrder.LoyaltyProgramId from the customer's active (default-preferred) CustomerLoyaltyProgram]. The real gate now lives inside AccruePoints, which self-gates on: the customer being enrolled (a CustomerLoyaltyProgram row — else returns), hasOtherDiscounts && !ApplyWithDiscounts, loyaltyDiscount?.DiscountValue > 0 && !ApplyWithLoyaltyPayments, and a non-null first tier [code: haafai.domain/Haafai.Models/Services/LoyaltyPrograms/LoyaltyProgramService.cs:204-218 @ e1389444 (staging)]. When it accrues: points = tier.AccrualValue × totalAmount, where Bill Total basis = GrossTotal − TaxTotal − ServiceFeeTotal (changed in prod — was plain GrossTotal; points no longer earn on the tax or service-charge portions, while delivery/freight remains counted since only those two are subtracted) and Bill SubTotal basis = NetTotalCustomerLoyaltyProgram.Amount += points + a CustomerLoyaltyPointTransaction ledger row [code: LoyaltyProgramService.cs:236-250 @ b4908b7 (staging) — commit "fix(loyalty): adjust loyalty points accrual logic to exclude tax and service charges"].

    Changed in prod (was a documented bug): earlier builds early-returned from ProcessLoyaltyPointsForCloseOrder for non-fully-discounted orders, so a normal paid sale with no redemption accrued nothing for a native program. That guard has been removed — accrual now flows on every closed order and self-gates inside AccruePoints. The earlier KB note describing native earning as "redemption-only" is stale.

  • The customer must already be enrolled (a CustomerLoyaltyProgram row) — auto-enrolment happens at customer creation for programs with AutoSignUpCustomers [code: .../CounterCustomersController.cs:1035-1050 @ 19190b9].
  • DeductPoints (the redemption-side ledger entry) still fires only when the customer actually redeemed — gated on loyaltyDiscount != null && loyaltyDiscount.DiscountValue > 0 [code: OrdersController.cs:3783-3786 @ 19190b9]. So redemption deduction is conditional; earning accrual is not.
  • PurpleLane programs also earn on EVERY closed orderAddPoints(contact, grossTotal) fires unconditionally on close [code: OrdersController.cs:3761 @ 19190b9]; a redemption additionally sends RedeemPoints [code: OrdersController.cs:3756-3757 @ 19190b9]; the cached balance is invalidated ~20s later.

Redemption at the POS (counter app)

The order screen exposes a "Redeem loyalty points" button (enabled once a real customer is attached). It opens the Apply Loyalty modal [code: foodops.counter.app/src/components/loyalty/ApplyLoyaltyModal.tsx (980 lines) @ 1f77fc6]:

  1. Pick a program — the dropdown lists the customer's enrolled programs (or all active programs if none); PurpleLane programs are flagged.
  2. The modal loads the customer's points: total points, redeemable points, minimum redemption, conversion factor, and the max redeemable for this order size [code: hubits-api-sales/.../CounterLoyaltyProgramsController.cs — GET customer/{id}/program/{id}/details @ 20fc7ca].
  3. Two tabs (the modal remembers your last-used tab):
    • Bill Amount — redeem N points against the whole order; quick-select percentages are available; the discount preview = points ÷ conversion factor.
    • Select Items — tick specific items and assign points per item (item-level redemption). Server-side, item-level redemptions are folded into one order-level redemption at accept/save so the program link survives edits [code: OrderCreationService.cs:300-320; OrderUpdateService.cs:187-205].
  4. Switching tabs with data entered warns you it will clear the other tab (conflict dialog).
  5. Apply → the order total drops by the discount; the redemption rides on the order as a fixed-amount loyalty discount linked to the program. Validation: points ≥ program minimum, ≤ redeemable balance, ≤ max-for-this-order; if the order already has a regular discount and the program forbids stacking, the redemption discount is not applied. The redemption lowers the taxable base: the redeemed value is subtracted from each line's net before tax is computed (net = netBeforeDiscount − discount − loyaltyShare, then tax is back-extracted as tax = gross − net where gross = net × (1 + rate) — matching the server, not the older net × rate), so both subtotal and tax drop, counted once [code: foodops.counter.app/src/lib/finance/serverParityMath.ts:114-142 @ main — computeParityLine: grossTotal = r3a(netTotal * (1 + rate), 3)then Step 8taxTotal = r3a(grossTotal - netTotal, 3), commented "tax back-extraction — never rate × net"].
  6. The actual points deduction happens at invoice commitment, moments after close (DeductPoints: points = ConversionFactor × discount value — same settlement job as earning, enqueued from PaymentProcessingService), in the same ledger as earning.

Editing an order pre-populates the modal with the already-applied points (e2e-asserted: L1–L5 in e2e/17-loyalty.spec.ts).

The points→discount conversion ratio (ConversionFactor)

The ratio customers ask about is the program's ConversionFactor — how many points buy 1 unit of currency as discount:

  • Redeeming: discount = points ÷ ConversionFactor. This is server-authoritative — on save the sales API computes the loyalty discount as LoyaltyPointsRedemptionValue / ConversionFactor (the counter submits the raw points; the server stores the resulting fixed-amount discount) [code: hubits-api-sales/.../Services/Counter/Orders/OrderCreationService.cs:668-685 @ 4c95164 (main); identical on edit: OrderUpdateService.cs:294-304]. The POS modal previews with the same math [code: foodops.counter.app/src/lib/finance/financeEngine.ts:497-500 @ main — calculateLoyaltyDiscountFromPoints returns points / conversionFactor], and the Select Items tab spells the ratio out to the cashier: "Each point equals {1 ÷ ConversionFactor} discount" [code: src/components/loyalty/ApplyLoyaltyModal.tsx:846 @ main].
  • Settling: the points-ledger deduction after close is the exact inverse — points removed = ConversionFactor × discount value [code: haafai.domain/Haafai.Models/Services/LoyaltyPrograms/LoyaltyProgramService.cs:291 @ staging].
  • Worked example: factor 10 → 100 points = MVR 10 off (each point is worth MVR 0.10). Factor 1 (the admin form's default) → 1 point = MVR 1.
  • Where it's configured: admin app → Sales → Loyalty Programs → the program form's Redemption section — the "Conversion factor" field (required, must be greater than 0, defaults to 1), next to "Minimum points" [code: foodops-admin-app/apps/admin/src/components/loyalty-programs/LoyaltyProgramForm.tsx:386-406 (fields), 230-232 (>0 validation), 66 (default '1') @ main]. It's shown read-only on the program's details page (KPI strip + settings rows) [code: apps/admin/src/pages/sales/LoyaltyProgramDetailsPage.tsx:196,309 @ main].
  • The ratio also caps redemption per order: max redeemable points = min(customer's balance, orderTotal × ConversionFactor) — you can't convert more points than the bill is worth [code: hubits-api-sales/.../CounterLoyaltyProgramsController.cs:295-298 (native), 245-248 (PurpleLane) @ main].
  • PurpleLane differs: for a PurpleLane-linked program the server applies the submitted redemption value as the discount amount directly — no ÷ConversionFactor division on the discount side [code: OrderCreationService.cs:645-655 — DiscountValue = viewModel.LoyaltyPointsRedemptionValue, stored as-is] — while native programs always divide. (The details endpoint still returns the program's ConversionFactor and uses it for the PurpleLane per-order cap.)
  • The server re-checks the program's MinimumPoints at save time, not just in the modal [code: OrderCreationService.cs:675; OrderUpdateService.cs:294 — both require the submitted points ≥ MinimumPoints].

PurpleLane specifics

  • Per-organisation integration record maps PurpleLane to one loyalty program.
  • The customer's balance is fetched live from PurpleLane by mobile/phone number, cached 10 minutes (60s for misses); a customer without a stored mobile/phone can't be looked up.
  • The server knocks 0.01 off the fetched balance before offering it for redemption (a float-safety guard).
  • For PurpleLane, the redemption value is used as the discount amount directly, and redeem/earn calls are queued to PurpleLane on close.

Offline behaviour

Loyalty needs a connection: the customer must be server-synced before points can load (offline-created customers show no loyalty until synced), and the customer detail Loyalty tab shows a "requires a connection" state offline. Program names are cached locally as reference data, but balances and redemption settings are online lookups. Redemption is disabled entirely while offline, for every program: when the device is offline the Redeem loyalty points button is greyed out with the tooltip "Loyalty needs an internet connection" and the modal cannot open (gated on navigator.onLine) [code: foodops.counter.app/src/components/orders/OrderSummary.tsx:2434-2435 @ 87ec7ed]. Earning is unaffected — points still accrue server-side when an offline order later syncs and closes.

Viewing a customer's loyalty

The customer detail page has a Loyalty tab (shipped 2026-05-17): programs the customer is enrolled in, points balance, accrued amount, enrolment date — read-only [code: foodops.counter.app/src/components/sales/CustomerLoyaltyProgramsTab.tsx]. See Customers & CRM for the full customer-record picture.

Loyalty vs customer credit (don't mix them up)

Loyalty Customer credit
What it is Points earned from past spend, redeemed as discount Permission to owe — sale closes on account, balance collected later
Where configured Loyalty Programs (admin) Customer record: Payment Term + Credit Limit
POS action "Redeem loyalty points" → discount "Credit"/"Credit & Close" settlement → adds to Outstanding
Guard rails Min/max points, program rules Credit-limit check at checkout (hard block on credit sales, no override)
Settled by Invoice commitment, moments after close (points ledger) "Accept payment" / credit-payment collection later

Credit mechanics (outstanding lifecycle, credit-limit checks, Accept-payment collection) are documented in Customers & CRM — this entry doesn't duplicate them.

Key concepts

  • Loyalty program — the earning/redemption rule set; generates a hidden system discount that redemptions attach to.
  • Enrolment — a per-customer-per-program row holding the points balance (Amount); auto-created when AutoSignUpCustomers is on.
  • Accrual basis — earn on gross (bill total) or net (subtotal). The only two supported bases.
  • ConversionFactor — points per 1 MVR of discount. 100 points at factor 10 = MVR 10 off.
  • Points ledger — every earn/deduct is a CustomerLoyaltyPointTransaction tied to the order, so balances are auditable.
  • Bill Amount vs Select Items — order-level vs item-level redemption tabs in the POS modal; item-level folds into one order-level redemption on save.
  • PurpleLane — external loyalty provider; balance lives outside Foodops, keyed by customer phone number.
  • Default program — first program created; used as fallback when an order earns without an explicit program link.

Common questions

Q: Is GST charged on the full bill or on the amount after loyalty points are redeemed? A: On the amount after redemption. A redemption rides the order as a fixed-amount loyalty discount that lowers the taxable base: the redeemed value is subtracted from each line's net before tax is computed, so both the subtotal and the GST drop together. The customer pays GST only on what's actually left to pay. [code: foodops.counter.app/src/lib/finance/serverParityMath.ts:114-142 @ main — computeParityLine: net after discount+loyalty share, then tax back-extracted as gross − net (Step 8, "never rate × net")]

Q: Does FoodOps have loyalty, or is that Shops-only? A: FoodOps has it, end-to-end, since 2026-05: program config (admin app), auto-enrolment, earning at close, and a redemption modal at the counter POS. The "Shops-only" note in the Shops KB entry predates this.

Q: How does a customer earn points? A: Attach the real customer record (Walk-In earns nothing) and make sure the customer is enrolled in a program. Both PurpleLane and native programs now earn on every closed paid sale linked to a loyalty program — a redemption is no longer required to earn (this changed in prod; the older "native earns only on redeeming orders" guidance is stale — see Earning points). The counter auto-links the order to the customer's default enrolled program on attach, so a normal walk-in→attach-customer→pay sale accrues. Earning is still skipped if the order carries other discounts and the program's ApplyWithDiscounts/ApplyWithLoyaltyPayments rules forbid it. When it earns: points = tier accrual value × the accrual basis (the bill total excluding its tax and service-charge portions, or the net subtotal, per program config), credited by a background job once the order's invoice posts (moments after close).

Q: How much discount do points give? A: discount = points ÷ ConversionFactor. The modal shows the calculated discount live before you apply.

Q: Can a customer redeem on an order that already has a discount? A: Only if the program's "redeemable with discounts" setting allows it; otherwise the redemption is blocked/not applied. Conversely, an order may earn no points if it carried other discounts and the program disallows that.

Q: Why does the redeem button do nothing for a brand-new customer? A: Two common causes: the customer was created offline and hasn't synced yet (loyalty needs the server ID), or they're not enrolled in any program and the org has no programs configured.

Q: Can points be redeemed against specific items only? A: Yes — the Select Items tab assigns points per item. Note the subtotal drops accordingly (item-level loyalty reduces Sub Total, not just the total — this is e2e-guarded).

Q: Is there a minimum redemption? A: Yes — the program's MinimumPoints. Below that the modal validates and refuses.

Q: What's the maximum a customer can redeem on one order? A: The lesser of their redeemable balance and the max-redeemable-for-this-order figure the API computes from the order total (you can't redeem more than the bill).

Q: When do points actually leave the customer's balance? A: Just after close, once the order's invoice is committed — together with earning, in the points ledger. Cancelling an un-closed order with a pending redemption therefore never touches the balance.

Q: How does PurpleLane differ from a native program? A: Balance lives at PurpleLane (fetched by the customer's mobile number, cached ~10 min), redemption value is applied as the discount directly, and earn/redeem are pushed to PurpleLane after close. If the customer record has no phone number, lookup fails.

Q: Do points expire? Are there tiers like Silver/Gold? A: The program schema has ExpiryDurationInDays, tier lists, and reset-on-tier-change flags, but the current earn code uses only the first tier's accrual value, and expiry enforcement was not located in this pass. Treat multi-tier and expiry as configured-but-not-fully-active until verified.

Q: Where do I see a history of a customer's points movements? A: Each earn/deduct writes a ledger row tied to the order, but no counter screen lists the raw ledger yet — the Loyalty tab shows current balances per program. (The counter's LoyaltyHistory.tsx component is an empty stub.)

Q: Can the cashier adjust points manually (goodwill points)? A: No manual points-adjustment surface was found in counter or admin app code in this pass.

Q: What exactly is the points-to-discount conversion ratio, and where do I change it? A: It's the program's Conversion factor — how many points equal 1 MVR of discount: discount = points ÷ factor. Factor 10 means 100 points = MVR 10 off; factor 1 (the form default) means 1 point = MVR 1. Set it in the admin app under Sales → Loyalty Programs → (the program) → Redemption → "Conversion factor" (it must be greater than 0). The POS shows the effect live — the Select Items tab literally states how much one point is worth — and the same ratio caps redemption per order (never more points than the bill × factor). PurpleLane programs differ: the redemption value is applied as the discount directly. See The points→discount conversion ratio.

Edge cases and known issues

Redemption applied but order never closed

The discount sits on the order; the points balance is untouched until close. If the order is cancelled, nothing was ever deducted — correct behaviour, but the customer may have been told points were used.

PurpleLane balance looks stale after a redemption

Balances are cached ~10 minutes server-side and PurpleLane settles asynchronously; the cache is busted ~20s after close, but a quick consecutive lookup can still show the old number. Wait and reload.

Customer enrolled in nothing sees "all programs" in the picker

When a customer has no enrolment rows, the API deliberately returns every active program as a selectable option (so first-time redemption flows aren't dead ends). Earning still requires a real enrolment row — programs with auto-signup create it at customer creation; for older customers enrolment is managed from the admin app's Loyalty Programs surface.

Item-level + order-level loyalty on the same order

The modal prevents mixing (tab switch clears the other tab's data). Server-side, item-level entries are folded into a single order-level redemption on save.

types/loyalty.ts overpromises

The counter's src/types/loyalty.ts declares tier benefits, cashback types, rule engines, etc. — these are aspirational TypeScript types, not shipped behaviour. Don't quote them as features.

Honest gaps (verified 2026-06-11)

  • No POS points-ledger/history screen (LoyaltyHistory.tsx, PointsBalance.tsx, TierStatus.tsx, LoyaltyProgramSelector.tsx are stubs; the live UI is ApplyLoyaltyModal + the customer Loyalty tab + LoyaltyRemainingPointsDisplay).
  • Multi-tier progression and points expiry: schema exists, enforcement not found.
  • No manual points adjustment.
  • No offline redemption.
  • Program creation is admin-app/legacy territory — there is no program-config UI in the counter.

Related

  • Customers & CRM — customer records, Loyalty tab, credit limits, outstanding collection

  • Shops Loyalty Programs — the Shops-side entry (same domain model; its "not applicable to Foodops" note is stale)

  • POS Takeaway Order — where redemption fits in the order flow

  • Returns, Refunds & Voids — unwinding orders that carried redemptions

  • User Stories: Customers — US-INT-006 (loyalty program setup)

  • 2026-07-17 (doc-as-test, live UI walk on the admin app — sprint Track C): PASS, all four ConversionFactor sub-claims. Verified live in Sales → Loyalty Programs → program form → Redemption: the on-screen label is verbatim "Conversion factor" beside "Minimum points"; entering 0 blocks submit with "Conversion factor must be greater than 0"; a fresh create form pre-fills 1 (the documented default); the details page renders it read-only (KPI tile "CONVERSION FACTOR 1.00" + a read-only settings row). A test program ZZZ-DOC-TEST was created for the read-only check then deleted (list returned to empty). Screenshots 225-229. (Live admin app is app.salesmade.io; admin.salesmade.io does not resolve.) NOTE: the stacking-guard behavior flagged in the 2026-07-16 entry below was NOT walked here — it stays undocumented pending the eng verdict on ticket 86eyavfq1 (ISSUE #30).

  • 2026-07-16 (codebase, gap-mining sprint — TRUE_GAP: loyalty points-to-discount conversion ratio): added section "The points→discount conversion ratio (ConversionFactor)" + 1 Q&A. Verified against hubits-api-sales@4c95164 (main), foodops.counter.app@8faa421 (main), haafai.domain@4087060 (staging), foodops-admin-app@d0349a8 (main): server-authoritative redemption formula discount = submitted points ÷ ConversionFactor (OrderCreationService.cs:672; OrderUpdateService.cs:296); ledger deduction is the inverse, points = ConversionFactor × discount value (LoyaltyProgramService.cs:291); client preview uses identical division (financeEngine.ts:497-500) and the item tab shows "Each point equals {1/factor} discount" (ApplyLoyaltyModal.tsx:846); per-order cap = min(balance, orderTotal × ConversionFactor) (CounterLoyaltyProgramsController.cs:245-248, 295-298); configured in the admin form's Redemption section, "Conversion factor" required > 0, default 1 (LoyaltyProgramForm.tsx:66, 230-232, 386-406); PurpleLane redemption value applied as the discount directly, no division (OrderCreationService.cs:645-655). Also re-confirmed at new pins: PurpleLane RedeemPoints/AddPoints on close and AccruePoints/DeductPoints enqueues (OrdersController.cs ~3790-3827, lines shifted from the 19190b9 citations, behavior unchanged) and the domain accrual gates/formula. REPOINT: the admin program form moved from LoyaltyProgramFormDialog.tsx to the full-page LoyaltyProgramFormPage.tsx + components/loyalty-programs/LoyaltyProgramForm.tsx — source_repos glob updated. FLAG (not written into the entry): the server-side stacking guard at OrderCreationService.cs:674 reads !(DiscountValue > 0 && RedeemableWithDiscounts), which appears logically INVERTED versus the documented RedeemableWithDiscounts semantics (it would apply redemption when stacking is forbidden and block it when allowed); the modal enforces the correct rule client-side. Needs a dev-confirm before documenting server behavior — money-sensitive. Sidecar extended.

  • 2026-07-04 — foodops.counter.app commit range 8d24b71..4626f5e ('feat: enhance ApplyLoyaltyModal to filter loyalty programs by customer and add minimum redemption validation') refines two already-documented POS behaviors without contradicting them. (1) Program picker: the dropdown now explicitly fetches via a customer-scoped getCustomerLoyaltyPrograms(serverCustomerId) call when a customer is attached, and only falls back to getLoyaltyProgramsForDropdown() (all outlet programs) when no customer is attached — formalizing the 'Pick a program' step's enrolled-programs behavior via a dedicated endpoint rather than client-side filtering of an all-programs fetch. (2) Minimum redemption UX: the balance banner now always surfaces 'Min. redemption: N points' alongside the existing redeemable/max figures, and a new orderCannotMeetMinimum check adds a distinct message — 'This order is too small to meet the minimum redemption of N points.' — for the edge case where the order's own max-redeemable-for-this-order figure is below the program's MinimumPoints (previously only the simpler 'entered points below minimum' case was surfaced). Worth folding into the 'Is there a minimum redemption?' Q&A and step 2 of the Redemption-at-the-POS walkthrough on next full pass; nothing here contradicts current text. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-02 — agent-resolved HELD pipeline items (both MONEY-SENSITIVE; adversary correctly demanded first-party proof — LEARNINGS #50). Verified against executable code: (1) hubits-api-sales 8114e31..7d620e8 — loyalty settlement TRIGGER moved: CloseOrder no longer enqueues ProcessLoyaltyPointsForCloseOrder; PaymentProcessingService.cs:420-427 @main now enqueues it AFTER invoice/payment posting commits (async via SalesPostingOutbox), because at close time the invoice may not exist and the job's invoice!=null guard was silently SKIPPING accrual (a real accrual-skip bug, now fixed). Job body unchanged in OrdersController.cs:3696. (2) haafai.domain e138944..b4908b7 — Bill-Total accrual basis CHANGED: LoyaltyProgramService.cs:238-244 @staging now computes GrossTotal − TaxTotal − ServiceFeeTotal (was plain GrossTotal) — points no longer earn on tax or service-charge amounts; freight remains counted (only those two are subtracted). Entry corrected throughout (summary, accrual-basis table, Earning points, deduction timing, 2 Q&A, redemption-vs-credit table); repinned sales 7d620e8 / domain b4908b7.

Note: Seeded 2026-06-11 · codebase only (audit P1-10 coverage gap) Verified_by: claude-code · Last verified: 2026-06-11 New entry authored against prod branches: foodops.counter.app@1f77fc6 (main), hubits-api-sales@20fc7ca (main), haafai.domain@a2bd90e (staging — per corrected prod-branch table), foodops-admin-app@bcde336 (main). Verified end-to-end: program CRUD (PointsPerUnit/ConversionFactor/MinimumPoints/accrual basis/auto-signup/stacking flags; auto-generated system discount; first-program-is-default), auto-enrolment at customer creation, earn-at-close via Hangfire (AccruePoints = first tier's AccrualValue × gross-or-net; ApplyWithDiscounts/ApplyWithLoyaltyPayments gates; ledger rows), POS redemption via ApplyLoyaltyModal (Bill Amount vs Select Items tabs, min/max validation, item-level folded to order-level on save, redemption stored as fixed-amount loyalty discount, DeductPoints at close), and the PurpleLane external integration (phone-keyed live balance, 10-min cache, 0.01 reduction, async earn/redeem + cache bust). e2e coverage L1–L5 in e2e/17-loyalty.spec.ts. Flagged drift: Shops loyalty entry's "Not applicable to Foodops" claim is stale (not edited — outside this lane). No live UI walk; UI-TODOs in place.

2026-06-29 — NEW-stack re-verification for the July-10 launch (legacy haafai.app.foodops monolith fully retired). Re-pinned to prod HEADs: foodops.counter.app@32669a9 (main), hubits-api-sales@19190b9 (main), haafai.domain@e1389444 (staging), foodops-admin-app@f6ffd0b (main). MONEY-SENSITIVE CORRECTION — native loyalty accrual is no longer redemption-gated. The prior KB fact ("native programs earn ONLY on redeeming orders"; the loyaltyDiscount != null && DiscountValue > 0 guard around AccruePoints) is stale. In prod, ProcessLoyaltyPointsForCloseOrder enqueues AccruePoints unconditionally for non-PurpleLane orders that have an invoice (OrdersController.cs:3792-3793 @ 19190b9), with the comment "Accrue on every paid sale linked to a loyalty program — not just redemptions." The order is auto-linked to the customer's default enrolled program on customer-attach (OrdersController.cs:1076-1084 @ 19190b9), so the common walk-in→attach→pay flow accrues. Real gating moved INTO AccruePoints, which self-gates on enrollment + ApplyWithDiscounts/ApplyWithLoyaltyPayments + a non-null tier (LoyaltyProgramService.cs:204-218 @ e1389444). DeductPoints (redemption ledger) still fires only on an actual redemption (OrdersController.cs:3783-3786). PurpleLane AddPoints still unconditional on close (:3761). The earlier "likely product intent-vs-impl bug" note is RESOLVED — the bug was fixed in prod. Verified server pricing/redemption-taxable-base path unchanged. UI-TODOs (live modal walk) retained. FLAGGED for orchestrator re-check (money-sensitive).

Additional verification events are appended here by the weekly KB sync and by manual re-verification passes. See _meta/failure-log.md for the global failure index.

Go beyond POS.
Choose the total solution.

Focus on creating the best experience for your guests while we handle the rest.