Home›Loyalora›Manual
Loyalora · Manual

Loyalora Manual

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 4.1.3 [src: app/controllers/api.php:37].

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 IMMEDIATE on 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 behavior 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:

eventbrowserREST APIagent
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 customer's card (1.1)

Every customer has a card code (8 characters, no 0/O/1/I lookalikes) and a private card link, /c/<token>, which the card's QR code opens [src: app/src/Card.php]. Both are made the first time the customer's page is opened. Print card gives a wallet-size card with the business name, the customer's first name, the code and the QR; New card (customer editors) replaces both, and the old code and QR stop working at once.

At the counter, Scan or type a card on the customers page opens the customer from the code or from the URL a USB scanner types when it reads the QR; the customer search also matches a card code [src: app/controllers/cards.php card_lookup]. The customer scans the same QR with their phone to see their own page: their first name, balance per program, what it reaches now and their last ten entries — no surname, email or phone, and nothing about anyone else. Wrong links are rate-limited, an inactive customer's link shows nothing, and the page asks search engines not to index it [src: app/controllers/cards.php card_public]. The balance email ends with the same link. The QR encoder is written from the QR standard and checked by reading every symbol back with an independent decoder [src: app/src/Qr.php, app/tests/qr_check.php].

Points from the till (1.1)

POST /api/sales is the door for a till: it takes the card code (or a customer id), the amount and the till's own sale reference, and the program's rate decides the points, exactly as at the counter [src: app/controllers/cards.php lo_record_sale]. With more than one points-per-spend program active the till names one. The same reference posted again records nothing and answers with the first entry, so a till can retry after a timeout without awarding twice. A spend that earns nothing at the rate records nothing. The key must belong to a clerk or above.

Customers join from their own phone (1.1)

Settings → Customers join themselves switches it on (it is off on a new install and after an upgrade). Print the counter poster gives a printable page with a large QR code that opens /enrol [src: app/views/enrol_poster.php]. The customer types their name, and an email and phone if they like; their card is created and opens on their phone at once, so they can show it straight away [src: app/controllers/cards.php lo_enrol]. Such customers carry a joined online label.

The page never says whether someone is already on file. If the email they type belongs to an existing customer, they still get a new card, and that customer's screen shows a same email note with a link to the other record, so staff can decide whether it is one person [src: app/controllers/cards.php lo_same_email]. Each connection can start a limited number of cards every ten minutes (the login limiter), the form carries a hidden field that people never fill in, and with the switch off the page says joining online is not open.

VIP tiers (1.1)

On a program, VIP tiers takes one tier per line: Name | points earned in the last 12 months | multiplier, for example Silver | 500 | 1.25 and Gold | 1500 | 1.5 [src: app/src/Tiers.php]. A customer's tier is the highest one their earned points in that program over the last 365 days reach; redemptions and adjustments do not count, and a refunded sale comes off. The multiplier is applied inside the one earn door, Loyalty::earn(), and rounded down like every other earn, so the counter, the till, the API and an AI assistant all give the same answer; the entry's reason ends with the tier, such as "(Gold ×1.5)". Lines that do not make sense are dropped when you save, so the box shows what counts. The customer's screen and their own page show the tier and what the next one needs.

Bonus points by item or category (1.1)

Bonus points per item takes one rule per line: Item or category | points for each one, for example Coffee | 10 [src: app/src/ItemRules.php]. The counter's Record activity box lists the program's items with a quantity each; a till sends "items": {"Coffee": 2} with the sale. The bonus is added to what the spend or visit earns (a tier then applies to the total), names match regardless of capitals, an item not on the list earns nothing, and a quantity above 100 counts as 100.

Several shops (1.1)

Settings → Shops lists your shops; add one by name, or close one (its history stays). With shops set up, the counter's Record activity box asks which shop it is and remembers the choice for that clerk's next customers, including redemptions and adjustments [src: app/controllers/app.php lo_counter_location]. Every entry stores its shop; the ledger filters by shop and the ledger CSV has a Shop column. A till or an AI assistant sends "location": <id>; an unknown or closed shop is refused. Balances are shared across shops.

Redeeming at the till (1.2)

POST /api/sales/redeem lets a till redeem a reward for the card in front of it: the card code (or a customer id), the reward and the till's own reference [src: app/controllers/cards.php lo_till_redeem]. The reference is claimed before the redemption runs, so a till that retries after a timeout redeems once and gets the first answer back. If the balance does not cover the reward, the answer is 409 with the number of points missing, nothing is spent, and the reference can be used again. The same lock that stops two people spending the same points at the counter applies here. The key must belong to someone who may redeem (entry.redeem, clerk and above).

Refunds from the till (1.1)

POST /api/sales/refund with the till's sale reference takes back what that sale earned, as an adjustment titled "Refund of till sale …" [src: app/controllers/cards.php lo_refund_sale]. It is recorded once per sale: a retry answers that it was already recorded. If the customer has already spent some of those points, only what is left is taken back: the balance never goes below zero, and the reply's already_spent says how many stayed spent. The key must belong to a clerk or above.

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 program, 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].

Data requests: what an erase removes and what it keeps (4.1.1)

A customer's edit page carries Data request for anyone allowed to edit customers: download their data as JSON, or erase their details. Erasing replaces their name, email, phone and notes, and the balance emails sent to them, with [removed]. Their card's entries and points stay: the balance on your books is the sum of those entries.

Kept: the audit trail's lines that this customer was added or edited, which carry their name, because the audit trail cannot be edited.

Looking after your install (from 4.0)

Updates from inside Loyalora

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Loyalora never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Loyalora is unavailable for about a minute while this runs. The update replaces Loyalora's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Loyalora shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Loyalora is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: a loyalty scheme with customers, points and rewards to try things on. You can remove it in one click. [src: app/install/index.php:108]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample customers, points and rewards? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the Backup & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:78]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Loyalora reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Loyalora keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/loyalora/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Loyalora sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Loyalora on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. On Ownware Cloud, opening the app from the console starts a fresh session.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Loyalora's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

On Ownware Cloud, opening Loyalora from the console starts a fresh session [src: app/index.php:72].

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:20].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Loyalora's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Loyalora in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Loyalora framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Loyalora's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Loyalora · Quickstart · API · Test run