Loyalora Manual, as shipped in the download
Loyalora — User Manual
A loyalty and rewards register: customers, programs, points or stamps earned, rewards redeemed, and a balance you can defend at the counter. Version 1.0.3 [src: app/controllers/api.php:22].
About this manual
Every statement here was written by reading Loyalora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug.
Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.
⭐ A balance is a sum, never a stored number
One rule holds the product up [src: app/src/Loyalty.php:5]. There is no balance column on the customer record and there will not be one, because a stored balance is a second source of truth, and it starts lying the first time anything fails between "write the entry" and "update the total" — a crash, a timeout, a rolled-back transaction, a hand-edited row [src: app/src/Loyalty.php:6].
The entries table is the balance; everything else reads it [src: app/src/Loyalty.php:8]. A customer's balance is the sum of their entries, optionally within one program [src: app/src/Loyalty.php:47].
⭐ Entries are immutable, and a mistake is corrected by writing the opposite
Nothing in this product updates or deletes an entry [src: app/src/Loyalty.php:11]. A mistake is corrected by writing a new, opposite entry with a reason, so the register can always answer "why is it this number" by replaying itself.
The source names the situation that requirement exists for: a loyalty balance that has to be defensible to the customer standing at the counter disputing it [src: app/src/Loyalty.php:14].
An adjustment therefore refuses to be anonymous — "An adjustment needs a reason — it is the only record of why" — and refuses to be empty, because an adjustment of zero would say nothing [src: app/src/Loyalty.php:248].
⭐ The same 500 points cannot be spent twice
Two staff redeeming the same balance at the same moment must not both succeed. The redemption path is the only writer that spends, and it does three things in one atomic step [src: app/src/Loyalty.php:17]:
- it runs inside a locked transaction —
BEGIN IMMEDIATEon SQLite, a transaction on MySQL [src: app/src/Loyalty.php:19]; - it re-reads the balance inside that lock rather than trusting the figure the screen was showing [src: app/src/Loyalty.php:211];
- it writes the spending entry and the redemption record before committing [src: app/src/Loyalty.php:221].
The source states the principle in six words: a balance read before the lock is a guess [src: app/src/Loyalty.php:20].
A negative adjustment is held to the same floor as a redemption, for the same reason: a register that lets a balance go below zero has stopped being a record of what the customer is owed [src: app/src/Loyalty.php:241].
Redemption also refuses politely rather than cryptically — a reward that no longer exists, one that has been switched off [src: app/src/Loyalty.php:196], one with no cost set [src: app/src/Loyalty.php:207], and a balance that is short, which comes back with the cost, the balance and the shortfall [src: app/src/Loyalty.php:217].
⭐ Earning is integer arithmetic, and it always rounds down
Points for a spend are floor(amount × rate) in integer arithmetic [src: app/src/Loyalty.php:89], and the source gives two reasons for rounding down rather than to nearest: rounding up is a giveaway the operator never agreed to, and "round to nearest" makes the same purchase worth a different number of points depending on the cent it lands on [src: app/src/Loyalty.php:84].
Executed at one point per unit, and at 2.5 per unit:
spend 1.00 @ 1.00/unit -> 1 point spend 1.00 @ 2.50/unit -> 2 points
spend 1.99 @ 1.00/unit -> 1 point spend 1.99 @ 2.50/unit -> 4 points
spend 2.50 @ 1.00/unit -> 2 points spend 3.99 @ 2.50/unit -> 9 points
spend 9.99 @ 1.00/unit -> 9 points
spend 0.01 @ 1.00/unit -> 0 points
And the screen says which way it rounds rather than leaving the customer to work it out [src: app/src/Loyalty.php:87]. Executed, the exact wording it produces:
1 point per unit spent, rounded down
2.5 points per unit spent, rounded down
0.5 stars per unit spent, rounded down (a program that renamed its points)
1 stamp per visit
Two modes, and neither leaks into the other
A program either awards points for money spent or stamps for visits [src: app/src/Loyalty.php:27], and both write the same signed integer.
The guard is symmetrical, and executed both ways:
a visit recorded against a points-per-spend program -> 0
a spend recorded against a stamps-per-visit program -> 0
Stamps themselves have no arithmetic to get wrong — one visit, N stamps [src: app/src/Loyalty.php:97]:
1 stamp/visit × 4 visits = 4 3 stamps/visit × 4 visits = 12
What the counter staff need to say next
The rewards view answers two questions, not one: which rewards this balance reaches, and what the nearest one out of reach still needs [src: app/src/Loyalty.php:133]. The source is explicit that the second half matters as much as the first — it is the only number that tells the person at the counter what to say next, and it is arithmetic the screen must not be left to do for itself [src: app/src/Loyalty.php:135].
Executed against four rewards costing 50, 120, 500 and 2,000:
balance 0 affordable: none next: Free coffee needs 50 more
balance 60 affordable: Free coffee next: Pastry needs 60 more
balance 600 affordable: Free lunch, Pastry, Coffee next: Hamper needs 1,400 more
Affordable rewards come back most expensive first, and the out-of-reach ones nearest-first [src: app/src/Loyalty.php:147], so both lists are already in the order somebody would read them aloud.
⭐ Four roles, and the rung that matters
Four, not the usual three [src: app/src/Auth.php:77]. The interesting boundary is not between reading and writing — it is between redeeming and adjusting, and the source explains it [src: app/src/Auth.php:70]:
Redeeming spends points a customer already earned, against a reward with a published cost — that is counter work, and a clerk does it. An adjustment mints or destroys points with nothing behind it but a typed reason; it is the only way a balance can move without an activity, so it is the only entry kind held back to a manager. A register where anyone at the till can create points is not a register anyone can defend.
Executing the product's own permission function rather than reading the map:
entry.earn viewer=n clerk=Y manager=Y admin=Y
entry.redeem viewer=n clerk=Y manager=Y admin=Y
entry.adjust viewer=n clerk=n manager=Y admin=Y
reward.edit viewer=n clerk=n manager=Y admin=Y
audit.view viewer=Y clerk=Y manager=Y admin=Y
So a clerk can run the counter all day and cannot mint a single point.
⚠️ A viewer can read the audit trail
The audit page requires audit.view [src: app/controllers/v3.php:320], and the viewer role holds the wildcard *.view [src: app/src/Auth.php:80], which matches it — as the executed table shows. If your read-only accounts are meant not to see who adjusted whose balance, that is not the behaviour you have. This has been raised against the product.
Backups, by contrast, are gated more tightly than most of this family: both downloads require an administrator outright rather than a permission [src: app/controllers/api.php:273], so no non-admin role can be configured into being able to take one [src: app/controllers/api.php:283].
Webhooks — correct names, and wired on every path
Three events are offered [src: app/src/Webhook.php:16] and all three genuinely fire, from each interface that can perform the action:
| event | browser | REST API | agent |
|---|---|---|---|
customer.created | [src: app/controllers/app.php:230], and each customer a CSV import adds [src: app/controllers/app.php:782] | [src: app/controllers/api.php:98] | no tool creates a customer |
points.earned | [src: app/controllers/app.php:369] | [src: app/controllers/api.php:147] | [src: app/controllers/v3.php:241] |
reward.redeemed | [src: app/controllers/app.php:389] | [src: app/controllers/api.php:171] | [src: app/controllers/v3.php:261] |
Worth stating plainly because it is not true of every product in this family: a subscriber sees the same events whether the work was done at the counter, through the API or by an automation.
The one email: a customer's own balance
Loyalora sends one kind of message. With both mail switches on in Settings, each customer's card has an Email balance button; pressing it sends that customer their balance per programme, what it reaches now and what the next reward still needs, through your own SMTP [src: app/src/BalanceMail.php:23]. The refusals come first and are said in words — mail off, balance emails off, no address, SMTP not set up, or a demo install — and every send attempt, sent or failed, is written to the outbox. The SMTP password is write-only on the Settings page: it is never printed back, and leaving the field blank keeps the stored one.
The API and agent access
Seven tools [src: app/controllers/v3.php:89]: five read — find a customer, their card, the reward list, the program list and the outstanding liability — and two write, recording activity [src: app/controllers/v3.php:200] and redeeming a reward [src: app/controllers/v3.php:248].
The API documentation states the boundary an integration should respect: a key is created read-only or full, and a caller does not post a points figure directly [src: app/controllers/api.php:205] — it reports the activity and the product decides what it is worth.
What Loyalora does not do
It does not store a balance [src: app/src/Loyalty.php:5].
It does not edit or delete an entry [src: app/src/Loyalty.php:11] — a correction is a new, opposite entry with a reason.
It does not accept an adjustment with no reason, or one of zero [src: app/src/Loyalty.php:248].
It does not let a balance go below zero [src: app/src/Loyalty.php:241].
It does not trust the balance the screen was showing [src: app/src/Loyalty.php:20].
It does not round a spend up [src: app/src/Loyalty.php:84].