Skip to content
Developer docs
⌘ K
Dashboard ↗

Guides

Loyalty cards

Loyalty is separate from referral and affiliate programs. Cards, rule versions, holders, passes and the loyalty ledger use their own company-owned tables. Earning and redeeming never write orders, referral rewards or the rewarded-order usage meter. The merchant guide covers the designer.

Rules and calculations

packages/core/src/loyalty.ts defines strict Zod inputs and pure earn, redemption, reversal and available-reward functions. Both the dashboard preview and the service call these functions. Money uses integer minor units. Spend earning calculates floor(spendCents * pointsPerUnit / unitCents) with integer arithmetic, then adds visit points.

Each stamp cycle stores its target, rule version and stamp count. Completed cycles remain in earning order until redeemed. Extra stamps enter the next cycle under the rules current at that earning operation. Rule edits preserve existing cycles and ledger entries. Points redemptions use the current catalog cost. Each ledger row records the version used for its calculation; stamp redemptions record the redeemed cycle's version. Cosmetic edits create no version.

The web card and both Wallet passes keep the first cycle's artwork. When completed cycles precede a partial cycle, the web card shows a translated carryover line below the artwork. Wallet passes append that line to the available-reward count. Remaining stamps use the partial cycle's saved target, even after a rule edit. With 12 stamps toward a 10-stamp reward, the artwork stays full, one reward is available, and the card shows 2 carryover stamps and 8 to go. With only completed cycles, it keeps zero remaining and shows no carryover line. These display calculations share the same core pass text in live mode, sandbox and previews. They do not change balances or ledger entries.

renderLoyaltyStampSvg returns text-free stamp artwork with configurable dimensions. It accepts built-in icons or a raster custom icon. Wallet integrations can supply an embedded PNG or JPEG data URL when rasterising the same SVG. Card names and reward text remain separate from this image.

Console operations

The console uses the signed-in account's company membership. GET /api/console/companies/:company/loyalty/cards lists cards. POST creates a card. GET and PUT on cards/:card read or replace its editable fields. POST cards/:card/archive archives it with an empty object body. GET cards/:card/versions reads the immutable rule history. DELETE cards/:card soft-deletes the card and invalidates its web-card secrets while retaining versions, passes and ledger rows. Archived cards stay in the dashboard list; deleted cards leave it. Editors and higher can write; viewers can read.

POST cards/:card/holders creates a holder and pass. It accepts name, optional email, optional international phone, optional birthday, locale, marketing consent and consent source. A supplied birthday needs month and day; year is optional. Emails are trimmed and lowercased. Phones strip spaces, dots, brackets and hyphens and must include a country code. Each present contact is unique within the company. Consent timestamps come from the service clock. The response reveals the web-card secret once. Storage keeps only its SHA-256 digest. The random barcode member code remains retrievable for later pass rendering.

POST passes/:pass/earn, passes/:pass/redeem and passes/:pass/reverse require an operationId. Earn accepts visits, stamp count or spend in minor units as appropriate. Redeem accepts a points reward ID. Reverse accepts an entry ID. A retry returns the original ledger result; changing the input or pass for the same operation ID is a conflict. A reversal appends the opposite delta and references its original entry. Stamp cycles record each earning operation's contribution, so reversing an earning removes only its stamps from those cycles. If any affected cycle has been redeemed, reverse the redemption first. Reversals cannot themselves be reversed, and insufficient remaining points prevent reversing spent point earnings.

Each balance transaction locks its company with SELECT ... FOR KEY SHARE and rejects deleted companies before locking its pass with SELECT ... FOR UPDATE. It appends history, updates the cached balance and queues loyalty.pass_changed in grizzlee_outbox before commit. The worker converts this internal event into Apple Wallet push jobs and Google Wallet object update jobs without sending a merchant webhook.

DELETE holders/:holder erases contact details and birthday, clears notes from the holder's ledger entries, withdraws marketing consent and invalidates every web-card secret for that holder. Anonymous passes and ledger rows remain. The ledger permits note redaction after insertion. Company deletion invalidates all loyalty secrets immediately and the erasure worker removes holder personal data, ledger notes, staff names and PIN hashes before completing company erasure. It also removes copied actorStaffName values from ledger JSON. Migration 0062 allows only that field's removal under the transaction's erasure flag; every other ledger field must stay unchanged. LoyaltyScanners.eraseCompany applies the same staff redaction to live and sandbox storage. Avoid putting personal data in ledger notes.

POST images accepts raw PNG or JPEG bytes, verifies the declared type and magic bytes and limits logos and stamp icons to 1 MB. ?purpose=banner accepts banner photos up to 5 MB. ?purpose=logo crops transparent margins from PNG logos before storing them. When at least three corners are opaque and match each other within 24 of 255 per channel, it also crops a border of that colour. The fourth corner can belong to the mark, such as a wordmark that starts at the image's edge. The colour stays opaque; nothing becomes transparent. JPEG logos and logos without margins are stored unchanged, because cropping a JPEG would mean re-encoding it as PNG and changing its stored media type. Images have random public IDs and immutable caching at /api/loyalty/images/:id. Cards can reference only images owned by their company. Uploaded bytes never change under an existing URL.

Sandbox

The local single-process sandbox stores the same records in apps/web/.data/sandbox.json. Its console routes use /api/sandbox/loyalty and an explicit sample company query parameter. Public routes resolve their company from the saved slug or secret in both modes. Sandbox mode remains disabled in production. The store sample has both card kinds, holders, earnings and redemptions. Public sign-ups and web cards work locally. No emails are sent, including recovery emails, and repeat sign-ups never expose an existing link. Scanner administration uses /api/sandbox/loyalty/scanner; /scan and /api/scan/:action use the same local write queue and scanner logic as live mode. The company query selects an existing sample company only in sandbox mode. Device cookies resolve their company from their stored record in both modes. Holder reporting, CSV export, adjustments and stats use these same local records. Apple Wallet and Google Wallet stay in preview mode in the sandbox.

Public sign-up and web cards

Cards accept an optional signupSlug on the existing create and update operations. It follows the program page address format, ^[a-z0-9][a-z0-9-]{2,79}$. Migration 0061_loyalty_signup.sql adds the globally unique nullable signup_slug column, checks its agreement with card data, indexes non-null web secret digests, adds recovery token and recipient reservation tables and removes the birthday requirement from the holder constraint. Card and company deletion clear the slug, web secrets and recovery tokens. Company deletion also clears recovery recipient reservations. Archiving retains the slug and closes enrollment.

/loyalty/join/:slug resolves the company server-side. Only active cards accept POST /api/loyalty/signup/:slug. The strict input accepts name, optional email, optional phone, optional birthday, marketingConsent and the website honeypot. At least one contact is required. The join form has one phone-or-email field, the same as the game claim form. An @ selects email. Otherwise the form removes phone punctuation and prefixes +1 for 10 digits without +. It shows each validation error under its field, sets aria-invalid, announces the first error with role="alert" and focuses the first invalid field. Birthday is a month select and a day field, with no year. The server still requires an international + number. It accepts no company, locale or consent source override. The service chooses a locale from Accept-Language among the company languages, falling back to the first. The join and claim forms send the page's language in Accept-Language, so a customer on ?lang=fr-CA is saved as French. The join, card and game pages accept ?lang= when it names a company language, then fall back to the browser and the first company language. They link to each company language when there is more than one. A new holder records that locale, the service's consent timestamp and signup_page:<cardId> as its source.

Enrollment reuses the existing holder and pass creation functions. A company lock serializes public contact matching and enrollment with console changes and company deletion. A matching holder's profile and consent remain unchanged. Matches return no card secret to the browser. If the holder has email and delivery is configured, the server sends a one-time recovery link only to the saved address. Phone-only matches receive a neutral message explaining that staff can give them their card at the counter. The ordinary join form inserts no pass for a match. Staff can create the pass or replace its private link from the scanner without email or SMS. Conflicting email and phone matches never merge holders or expose links. New holders receive their card immediately without email verification and can optionally receive its private link by email.

We accept that an immediate card redirect for new holders and a confirmation for matches reveal whether contact details already belong to a holder, because this is low sensitivity and counter conversion matters. Confirmation wording names the business and depends only on whether the request supplied email or phone, never on the saved profile or on whether an email went out. It does not reveal whether a phone match has a saved email address, and a send suppressed by the 10-minute limit looks the same.

Every new web-card secret uses 32 random bytes. Storage keeps only its SHA-256 digest. Recovery tokens also use 32 random bytes, are stored only as SHA-256 digests, expire after 24 hours and can be used once. GET /loyalty/recover/:token consumes a valid token and rotates the pass's web secret in one transaction, then redirects to the new web card. Every earlier web-card link stops working, including a link obtained by someone who signed up with another person's email. A holder joining another card gets a new pass after opening the recovery link or receiving the card from staff. Recovery rejects erased holders and deleted cards or companies. Existing passes can still be recovered after archiving; new passes require an active card. The sandbox follows the same rules through its single-process write queue.

Welcome and recovery messages use localized transactional email in packages/core/src/emails.ts and the existing Resend transport and configuration in packages/service/src/emails.ts. These card access messages do not require marketing consent. Without RESEND_API_KEY, the ordinary join form creates no recovery token or pass for a match. Scanner handoffs work without email delivery. A provider failure discards the attempted recovery token. Neither case changes the existing web secret, so saved card links keep working. Recovery emails are limited to one per normalized saved recipient per company every 10 minutes, across cards and IP addresses. An atomic reservation before sending enforces this rolling interval, including failed attempts. Suppressed sends return the same neutral confirmation. Sandbox sends no email and does not create recovery tokens through the public form.

/loyalty/card/:secret looks up the digest, rejects erased holders and deleted cards or companies, and renders the core SVG artwork, business name, first name, a one-line reward rule, balance and loyaltyAvailableRewards. webCard returns the company name as business. Both pages use the card design's background and foreground colours and its logo through LoyaltyShell. The sign-up page and poster pass blank to the artwork, which hides balance rows and the barcode. Its barcode contains only the random member code. PDF417 is the default; Code 128 and QR are also available. Web cards use bwip-js for barcode encoding. Member codes use 24 random bytes encoded as 32 URL-safe ASCII characters, compatible with all three formats. When Apple is configured in live mode and the company is on the Wallet company list, the page offers Add to Apple Wallet. Google Wallet has its own Add to Google Wallet button under the same conditions. Otherwise the page omits that button and shows no preview notice, because customers cannot act on it. When Apple is available and Google is not, a request whose User-Agent contains Android gets a line asking the customer to bookmark the page instead of the Apple button. Wallet previews stay in the dashboard editor. The join, card, poster and game pages name the business in their titles. A notFound() from the public card, join and game routes renders the localized not-found.tsx in apps/web/app/loyalty/card/, which the join/ and play/ segments re-export.

Web cards and their deletion responses use Cache-Control: private, no-store, Referrer-Policy: no-referrer and noindex, nofollow. DELETE /api/loyalty/card/:secret requires a same-origin request and { "confirm": true }. After resolving the secret and locking the company, it calls the existing single-holder erasure function, invalidating every pass and recovery token for that holder and clearing personal details and ledger notes. Anonymous history remains.

The existing fixed-minute rate-limit storage enforces 10 sign-up requests per IP and 60 per card per minute. The web-card page allows 600 lookups per minute for each IP and 60 for each IP and link, so customers on a shop's shared Wi-Fi do not block each other while guessed links stay bounded. Unknown card links also count toward the 60-request per-IP limit, which recovery links, Wallet downloads and deletion share. Sandbox uses local counters. Filled honeypots produce a neutral response without enrollment or email.

GET /api/loyalty/signup/:slug/qr?format=png downloads an 800-pixel PNG. format=svg downloads SVG. Both contain the canonical sign-up URL. /loyalty/join/:slug/poster?lang=<company-language> renders the design, translated scan invitation, reward line and QR. The poster QR encodes the sign-up URL with ?lang= set to the poster's language. The printed URL omits the scheme and the language. The QR is 80 mm on Letter and 60 mm on A5. Its print controls support Letter and A5. None of these public endpoints accept a company override.

Locations and scanner access

Migration 0062_loyalty_scanner.sql adds company-owned locations, scanner settings, staff and devices. Locations retain optional address and coordinates when archived. Apple and Google passes include up to 10 active company locations with coordinates while the card's automations.nearby.enabled is true or absent. Wallet decides when to show a nearby suggestion. Apple locations carry relevantText from automations.nearby.text in each pass language, or loyaltyNearbyDefaults when the card has no saved text. Google controls its nearby wording. When the switch is off, Apple passes have no locations and Google objects send empty locations and merchantLocations, because object updates are PATCH requests. Ledger rows reference locations, devices and staff through composite company foreign keys. scanner_id identifies the browser or paired device, and an identity sequence orders scanner actions even when timestamps match. Removing staff leaves a tombstone with their name, clears their PIN hash and ends their device sessions. Ledger JSON also saves the staff name at the time of the action.

Console routes under /api/console/companies/:company/loyalty/scanner require settings:write, including reads. GET returns locations, devices without credentials, active staff without hashes, and the PIN requirement. POST locations creates, PUT locations/:id replaces the editable fields, and DELETE archives. POST devices creates a pending pairing and returns its code once; DELETE devices/:id revokes it. POST staff accepts a name and four-digit PIN; DELETE staff/:id removes that staff member. PUT settings accepts only {requirePin: boolean}. No request body accepts a company ID.

loyalty:scan is an account permission for owners, admins and editors. It is deliberately absent from API key scopes and from viewer and partner rights. An authenticated membership selects the company for account scanning. A paired device's server-side record selects its company and fixed location. Scanner credentials never authenticate console or server-key routes.

Pairing and staff sessions

POST /api/scan/pair consumes an eight-character code with 40 random bits, valid for 10 minutes and a single use. Pairing requests are limited to 20 per client address per 10-minute window. The device creation response includes the QR as an SVG data URL, so displaying it needs no image request containing the secret. The QR points to /scan#pair=CODE; the page removes the fragment after reading it, and sends no referrer. The server issues a random 256-bit device token in the grizzlee_scanner_device cookie. It is httpOnly, Secure, SameSite=Strict and valid for one year. Storage keeps only SHA-256 digests of pairing codes and tokens.

GET /api/scan/session returns the company currency, content languages, active locations and device or staff session information. Public scanner text uses the best Accept-Language match among the company languages, falling back to the first language. Account scanners remember the last chosen location per company in local storage. The scanner manifest uses standalone display. The camera uses native BarcodeDetector when it supports QR, otherwise the maintained barcode-detector package. Its ZXing-C++ WebAssembly decoder supports QR and Code 128 in Safari and loads from /api/scan/decoder on this app's origin. Camera tracks stop when scanning finishes, the page hides or the component unmounts. Manual entry remains available.

POST /api/scan/pin accepts {pin: "1234"} to start a device's staff session or {pin: null} to switch staff. PINs use scrypt with a random 128-bit salt and a 64-byte derived key. Active PINs are unique per company. Five incorrect attempts lock the device for 15 minutes. Failed attempts commit before returning their error, including in the sandbox. A successful PIN clears the failure count. Changing the PIN requirement clears all current staff sessions. Every lookup and operation rechecks revocation, device expiry, location status and the active staff member when PINs are required.

Scanner balance operations

POST /api/scan/lookup accepts {code} and resolves the existing member code or web-card secret digest within the authenticated company. It returns the card, balance and only the holder's first name. POST /api/scan/operate accepts {passId, operation} with the existing strict operation schema and a client-generated operationId. The scanner sends one visit for points, or an explicit stamp count. It accepts no notes or company override. Account requests send the selected location in X-Scanner-Location; a device always uses its stored location. The scanner parses comma and point decimal amounts into integer minor units with integer arithmetic. The core functions calculate earning, available rewards, redemption and reversals for live mode, sandbox and previews.

After a lookup, the scanner shows the stamp progress of the cycle being filled, the same cycle the pass artwork uses, or the points balance. The earn controls and rewards follow, and Give card sits below them. Give card lets staff choose any active company card. Lookup responses include availableCards, with each active card's ID and localized name. POST /api/scan/give-card accepts only {passId, cardId}. The source pass identifies the existing holder within the authenticated company. The action rechecks scanner permissions, location, device and staff PIN session, and rejects erased holders and inactive or deleted cards. A company lock serializes it with signup, recovery and holder erasure. A missing pass uses the same creation function as signup with the existing holder. An existing pass receives a new 32-byte web secret, just like recovery, so earlier private links stop working. The holder's profile, marketing consent, member code, balance, prizes and earned ledger entries stay unchanged.

The response contains the private web-card URL and an inline QR data URL with private, no-store headers. The scanner shows both in a dialog and tells the customer to scan with their phone camera. Closing the dialog clears the link from scanner state. The scanner never saves handoff links or QR data to browser storage. The database and sandbox store only the secret digest. The existing company audit log records loyalty.card_given against the pass, with whether it created a pass or replaced a link, and the scanner, location, user or device and staff IDs. It contains no private link or secret. This action does not add a balance ledger entry or change the last balance action available for undo.

The existing transaction still locks the company before the pass, preserves ledger and rule versions, updates the cached balance and queues loyalty.pass_changed. Scanner setup locks serialize location, PIN and revocation changes against scanner operations. A device row or account scanner advisory lock serializes actions from one scanner. Each scanner ledger entry records its location, authenticated user or device, staff ID when required, and scanner ID. Existing console operations retain their nullable location.

Stamp earning also checks cooldownMinutes against the pass's latest earning under its pass lock. It applies across scanners and console operations. The core engine still enforces maxStampsPerScan. A cooldown refusal returns loyalty_cooldown with remaining seconds. Reversing an earning does not remove its cooldown history. Points rules have no cooldown field. Previews have no clock and do not enforce cooldown.

Idempotent retries return the saved result before cooldown checks and must match the scanner and location as well as the original operation input. Before sending, the browser persists the operation ID, payload, company and location in localStorage. On load it restores an unresolved action and blocks new actions until staff retry it with its original input and location. Network errors, server errors and 401/403 responses keep the pending action through reloads and authentication recovery. Only success or a definitive domain or validation rejection clears it. If browser storage is unavailable, the scanner sends no balance operation. Scanner reversals must reference that scanner's latest action and arrive within 10 minutes. They call the existing reverse operation, append its opposite delta and preserve its refusal when stamps have been redeemed or points spent.

Apple Wallet

Configure the optional server provider on both web and worker processes as described in providers. AWS hosting forwards the four optional Apple settings in grizzlee/runtime to web and worker during activation. No Apple certificate is needed for the web card or designer preview. Sandbox requests never sign a pass, even when Apple environment values exist. In live mode, GET /api/loyalty/card/:secret/apple resolves the existing private web-card secret and returns application/vnd.apple.pkpass. It accepts no company override.

packages/service/src/apple-wallet-pass.ts uses passkit-generator for packaging and PKCS#7 signing and @resvg/resvg-js for PNG rendering. It calls the core renderLoyaltyStampSvg at 375x123, 750x246 and 1125x369 and loyaltyAvailableRewards for reward availability. Points cards use a single decorative stamp. Images contain no text. The card logo supplies PNG logo and icon files at three scales. The logo file keeps the logo's own proportions within 160x50 points, because Wallet left-aligns it, and the pass omits logoText so the logo stands alone; resvg converts JPEG uploads through an embedded raster image. Without a logo, the card's stamp icon supplies these required assets. No image fetch uses a customer-supplied URL.

Each store card uses the company name, card name and design colours, balance, next reward and reward-ready count. Its back fields show reward rules and the merchant name. No pass content includes the private web-card link. Its selected PDF417, Code 128 or QR barcode contains the existing member code with that code as alternative text. The header shows stamps remaining or points. Secondary fields show stamps until reward and rewards available, or next reward and points remaining. The auxiliary field shows the member first name. Balance and reward fields have localized change messages. The bundle includes .lproj strings for every company language, mapping fr-CA to Apple's fr. The first company language supplies the fallback text. Apple's official badge artwork comes from its Wallet guidelines.

Migration 0064_loyalty_apple_wallet.sql adds an encrypted Apple authentication token, modification times and update tags to passes. The serial number is the pass ID. First issuance saves the configured Pass Type ID and a random 256-bit authentication token. Regeneration decrypts and reuses that token. SecretBox uses the existing server encryption key with a pass-specific authenticated context. Keep that key stable across deployments. Authorization compares token hashes in constant time. Web-card secrets remain stored only as digests. Recovery rotates the web-card secret without changing the Apple token or pass content, so an existing Wallet token cannot retrieve the new private link. Erasure clears the encrypted Apple token and device push tokens, ends registrations and stops further downloads and pushes. An already downloaded pass cannot be remotely erased.

An issued pass keeps its original Pass Type ID. Both signing and pushing refuse and log a mismatch with configuration. A move to another Apple account needs newly issued passes; old passes cannot migrate. Renew certificates under the same Pass Type ID to keep its passes updating.

The Apple web service is /api/wallet/apple. Its /v1/devices/:device/registrations/:type/:serial POST requires Authorization: ApplePass <token> and {pushToken}. It returns 201 for a new active registration and 200 for an existing active registration. DELETE uses the same authorization and records a removal instead of deleting history. GET /v1/passes/:type/:serial authenticates the same token and returns the latest pass, Last-Modified, or 304 for If-Modified-Since. All responses prohibit intermediary caching.

As Apple's protocol requires, GET /v1/devices/:device/registrations/:type?passesUpdatedSince=tag and POST /v1/log have no per-pass authorization header. The list returns only active registrations matching that device library identifier and Pass Type ID, with serialNumbers and an opaque lastUpdated tag, or 204 when none changed. Device logs have a 16 KB body limit. These endpoints share a 120-request per-IP per-minute limit; secret-based downloads share the web card's 60-request limit.

A pass records content changes in the ledger transaction. Stored HTTP Last-Modified dates advance when the server serves a changed version. If two versions would share one HTTP date second, the response waits up to one second. This avoids a false 304 without sending a future date. Design, company name or language, and location edits also queue refreshes for issued passes. Outbox distribution assigns update tags using a transactional counter whose row stays locked until commit. This prevents a later tag from committing before an earlier one and hiding a pass from incremental polling. The counter affects distribution only; balance writes do not acquire it.

The worker creates one job per outbox event and active registration. It uses Node HTTP/2 and the pass certificate and key to send {} to production api.push.apple.com, with the stored Pass Type ID as the topic. The job lease expires after one minute. Connection errors, HTTP 429 and server failures retry with exponential backoff, starting at 30 seconds and stopping after eight attempts. HTTP 410 ends only the matching current registration, preserving removal history. Other client errors fail the job. Missing configuration leaves queued jobs unsent.

AppleWallet.status(authenticatedCompanyId, passId) returns first installs, active device count and removal count for holder reporting. Device library identifiers are stored as SHA-256 hashes. Registration rows retain their first install time, last registration time and latest unregistration time. An append-only removal table saves every unregistration date, including removals after reinstalls, APNs 410 responses and erasure. Its rows contain no device identifiers or push tokens. Repeated unregister requests do not count twice. Reinstalling the same device reactivates its row without adding another first install. Holder reporting checks active registrations directly with an indexed SQL existence query. Neither holder reporting nor the status hook returns device identifiers or push tokens.

Holder reporting and CSV

LoyaltyReporting in packages/service/src/loyalty-reporting.ts reads PostgreSQL or the sandbox state. GET holders accepts search, cardId, limit and cursor. It returns one row per pass with holder contact details, card, balance, last unreversed earning, joined time and wallet statuses derived from the pass. Erased holders and deleted cards do not appear in this list or its export. The dashboard renders the returned wallet channels rather than assigning a wallet status to every row. Web-card availability uses the pass's web secret digest. The list, holder detail and CSV also include apple:installed when the pass has at least one active device registration. Issuing or downloading a pass without registering a device does not mark it installed. Removing the last registration removes that status. No secret digest reaches the dashboard.

Paging uses pageCursor with descending created_at,id ordering and a scope bound to the company, search and card filter. Limits default to 50 and cannot exceed 100. Search matches literal substrings in name, email, phone or member code. SQL parameters escape LIKE wildcards.

GET holders/:holder returns the saved profile, consent time and source, and every pass with rewards calculated by loyaltyAvailableRewards. GET holders/:holder/ledger pages ledger entries with their card, user, device, staff and location names. Both require read permission. The existing DELETE holders/:holder requires editor permission and calls the single-holder erasure operation. History remains anonymous after erasure.

GET holders/export accepts only search and cardId. The console checks settings:write, which grants access to owners and admins. It streams UTF-8 CSV with one row per pass and reads at most 100 rows per page. Stream cancellation stops further page reads. Quoted cells double embedded quotes and prefix spreadsheet formula triggers with an apostrophe, including triggers after whitespace or control characters. Export contains contact, birthday and consent columns. Responses use private, no-store caching. The sandbox uses the same generator over its local state.

Balance adjustments

POST passes/:pass/adjust requires editor permission and a strict body with operationId, signed integer delta and a nonempty note. It accepts no company override. The existing balance transaction and sandbox operation call loyaltyAdjust in core, append an adjust entry, update the pass and queue loyalty.pass_changed. The operation ID and input digest enforce the same idempotency rules as earnings. Scanner operations reject adjustments.

Positive stamp adjustments call the core earning function with an administrative scan limit, preserving the existing cycle targets. Negative adjustments remove stamps from the newest cycles first, preserving the remaining contributions and original rule versions. Within a cycle, contributions remain in operation-ID order so JSONB key ordering cannot change the result. Balances remain nonnegative safe integers. Adjustments bypass earning cooldowns and do not count as visits or earned units. Correct adjustments through another adjustment rather than reversing them. Prior earned ledger entries and rule versions never change.

The dashboard saves each adjustment operation in its own localStorage key before sending it, scoped to mode, authenticated company, pass and operation ID. Tabs cannot overwrite another operation's saved input. Uncertain outcomes retain the operation through reloads. Reopening the dialog restores one pending adjustment; reopen it again to retry any others. A retry sends the original amount, note and operation ID. Success or a definitive rejection removes only that operation's key; authentication failures and rate limits retain it. The dialog moves an older per-pass pending record to its operation key before retrying.

Aggregate stats

GET stats accepts range as 7, 30, 90 or all, optional cardId and locationId, and an optional IANA timeZone. The default is 30 days in UTC. A zone that Intl accepts but PostgreSQL rejects falls back to UTC, and the dashboard sends UTC when the browser zone fails the same schema. The dashboard sends the browser's time zone, since companies have no saved one yet. Bounded ranges start at local midnight in that zone, found with loyaltyDayStart, and include today through the service clock. Live SQL buckets with AT TIME ZONE and the sandbox with loyaltyLocalDay, so both modes put a 9 pm visit on the same local day. Seven and 30 days use daily buckets; 90 days and all time use Monday-based weeks. The response includes overall totals, each card, each location and the activity series. Empty periods receive zero values after aggregation.

Live reporting computes SQL aggregates over indexed pass and ledger columns. It never loads individual ledger rows to calculate totals. Grouping by holder before summing gives distinct sign-ups and active holders across cards. A pass enrollment contributes a sign-up in its period. Since enrollment saves no location, sign-ups belong to the null location group and a location filter excludes them. Active holders have any ledger entry, including adjustments and reversals. Returning holders have at least two unreversed earning operations in the period. An earning operation represents a visit regardless of the number of stamps or points it adds. The visits metric sums those operations, using the same unreversed-earning definition as the gallery.

The activePasses metric counts distinct passes with an unreversed earning, redemption or adjustment in the range. Reversal entries and enrollment alone do not qualify. The anti-reversal join covers all history, so a later reversal excludes the original activity. Each pass counts once in overall totals and once per card, location or time bucket with qualifying activity. A holder with activity on multiple passes can contribute several active passes. The dashboard labels this metric Active cards in totals and tables. Sandbox reporting uses the same definition.

Earned stamps and points and redeemed reward counts use an anti-reversal join across all history, so a later reversal also zeros its original entry in an earlier reporting range. Adjustments never increase earning or visit stats. Holder erasure and card deletion preserve anonymous reporting history. Counts in daily or weekly buckets are distinct within each bucket, not additive across time. Sandbox reporting applies these definitions to its local records.

appleWalletInstalls counts first device registrations whose first_registered_at falls in the range. Reinstalling the same pass on the same device does not add another install. appleWalletRemovals counts each saved unregistration event in the range, even when the device later reinstalls. Neither metric increases active holders, active cards or visits. Wallet events have no recorded location, so they appear in the null location group and a location filter excludes them. Totals, card and location tables, and daily or weekly buckets use the same definitions in live and sandbox reporting. Sandbox reporting reads loyalty.appleWallet.registrations and loyalty.appleWallet.removals when present; previewing a card creates no registration.

Migration 0063_loyalty_reporting.sql adds pass paging and card indexes and ledger date, card, location and latest-earning indexes. It creates no tables. Migration 0064_loyalty_apple_wallet.sql follows it and adds Apple pass metadata, registrations, removal history and push jobs. Its indexes cover active registrations by company and pass, first registrations by company and time, and removals by company and time. Live stats aggregate these indexed columns in SQL without loading device or removal records into the application. Existing company foreign keys and the unique reversal index support the joins.

Google Wallet

Configure the issuer and service-account key on web and worker as described in providers. Sandbox and unconfigured web cards show no Google Wallet button. The dashboard editor still previews the Google pass with the shared card artwork. Previews never call Google or record installs.

google-wallet-pass.ts creates one LoyaltyClass per card and one LoyaltyObject per pass. IDs combine the issuer ID with a card or pass ID hashed to safe hexadecimal characters. A class has the card name, company name, design colour and logo. Classes include all company languages, mapping fr-CA to fr. The first company language supplies fallback copy. A class initially has reviewStatus: UNDER_REVIEW and permits one Google account with all its devices to hold each object.

Card creation, card edits and company name or language edits queue internal loyalty.card_changed events. The worker upserts the class through the Wallet REST API. Migration 0065 queues existing cards too. Issuance also upserts the class and object before creating a save JWT, so the first save works before its background class job runs. Locations and design edits extend Apple's existing refresh trigger to queue changes for both Wallet providers.

GET /api/loyalty/card/:secret/google authenticates with the existing private web-card secret and returns a no-store redirect to https://pay.google.com/gp/v/save/<jwt>. The ten-minute RS256 JWT contains origins: [APP_ORIGIN] and only the object ID and class ID in payload.loyaltyObjects. The REST API creates or updates both records first, keeping the save URL short. The JWT includes no private web-card link. Requesting this JWT does not count as an install. Recovery rotates the private web-card link without changing the Wallet object or member code.

Objects show the holder's first name, stamps remaining or the points balance, matching reward fields, localized reward rules and the selected member barcode with its alternative text. The Google template puts balance and member name in its first row. Stamp cards show available rewards in the second row without repeating stamps remaining. Points cards show both reward fields there. The hero image and barcode follow. The editor previews this order; Apple uses a header field above its strip image. Native Wallet apps control final positioning. They include up to 10 active company locations with coordinates. Both Wallet providers call wallet-pass.ts for text, logos and stamp rasterisation. loyaltyAvailableRewards remains the only reward-availability calculation. Google hero images use the same 375:123 strip proportions as Apple, rendered at 1125x369 pixels. Logos use 330x330 PNGs. Generated images reuse immutable grizzlee_loyalty_images storage and its public endpoint. A digest deduplicates image bytes within a company, while a separate random 192-bit ID makes the URL unguessable. The URL changes with the bytes and never includes a card secret, holder name or member code. Image reads and writes use the same connection as pass preparation. Images commit before Google can fetch them. A session advisory lock for the card orders issuance and worker requests across the preparation commit and REST calls.

The worker consumes the existing loyalty.pass_changed events alongside Apple pushes. It PATCHes the current object balance, hero image, fields and locations. An object that returns 404 is skipped because its owner may never have saved it. Google and Apple share job leases, eight-attempt retry limits and exponential backoff starting at 30 seconds and capped at 24 hours. Failed SQL work rolls back before a fresh transaction records retry state, conditional on the job still owning its lease token. Jobs read current server records and stop after holder, card or company deletion invalidates the pass.

A newly available reward adds a TEXT_AND_NOTIFY message. Availability comes from the core function and compares reward IDs and counts with the last committed availability transition. Each update replaces the previous reward message so messages do not accumulate past Google's limit. The availability transition, notification reservation and job message flag commit together before calling Google. Retries for that job use TEXT, including after an uncertain send, and later jobs cannot notify for the same transition. Reservations allow at most three notifying attempts per rolling 24 hours. Further messages use TEXT. A QuotaExceeded response also falls back to TEXT and blocks notifying attempts for the next 24 hours. Ordinary balance changes add no message. Conservative reservations can suppress a notification after a failed send.

POST /api/wallet/google/callback verifies ECv2SigningOnly with Node crypto, Google's published roots at https://pay.google.com/gp/m/issuer/keys, and the signed intermediate P-256 key. It checks root, intermediate and message expiry, verifies the unmodified signed strings with their length prefixes, binds the recipient to the configured issuer and deduplicates nonces until message expiry. After taking the company and pass locks, the nonce insert checks message expiry again before changing wallet state. Root keys refresh at least hourly within their HTTP cache lifetime. The signed object ID resolves the company from storage. The class must match that pass's card. No company ID from the callback selects a company. See Google's callback protocol.

A verified save records the first install and queues a current update to cover changes since the JWT was issued. Repeated saves and reinstalls do not increase installs. A verified del records a removal transition and removes the installed status until another save arrives. Google callbacks are best effort, so these counts describe received callbacks and can miss saves or removals. They describe objects, while Apple's counts describe devices. Deleting a web card stops downloads and updates but cannot remove an already saved pass from Google Wallet.

Migration 0065_loyalty_google_wallet.sql adds object metadata, callback nonce deduplication, removal history, delivery jobs and notification reservations. It adds one nullable digest column to existing immutable image storage. Objects retain their issuer-specific ID, reward availability and first save time. No save JWT or access token is stored in the database. Access tokens stay in process memory. Wallet history contains no copied name, email, member code or private link.

Holder rows and detail report google:installed after a verified save while the pass remains valid and no removal is recorded. Stats add googleWalletInstalls and googleWalletRemovals to the existing totals, card and location groups, and time series. Their indexed SQL aggregates and sandbox records use the same definitions. Wallet events have no location and do not count as visits or active cards. The sandbox reads optional loyalty.googleWallet.objects and loyalty.googleWallet.removals history, and its previews create neither.

Migration 0067_loyalty_design.sql runs after 0066_companies.sql against the renamed company schema. It adds a company-owned banner image reference, raises immutable image storage to 5 MB and fills existing card JSON with bannerCropPosition: 50 and barcodeFormat: "pdf417". These are cosmetic settings and create no rule version. The authenticated image route retains the 1 MB limit for logos and icons.

The core renderer cover-crops the photo to the strip proportions, uses a vertical crop position between 0 and 100, and draws white stamp circles over it. Filled circles show the chosen icon in the card colour. Empty circles fade into the photo. Points strips contain the photo without stamps. With no photo the strip uses the card background. Wallet rasterisers embed banner and custom icon bytes as data URLs before calling resvg. They never fetch image URLs. Card text stays outside the artwork.

Staff cameras require native support for PDF417, Code 128 and QR together, or use the existing ZXing fallback with all three formats enabled.

/loyalty has Cards, Customers, Messages, Reports and Counter setup tabs, opened with ?tab= (holders, messages, stats, scanner; Cards has none). Wide screens list them in the sidebar and hide the tab row. The phone layout keeps the tab row. Scanner setup and its Open scanner action live in Counter setup. A dashboard link with both loyaltyCard and loyaltyTab opens that card editor tab instead.

Customers searches 300 ms after typing stops. The holder sheet's Activity table shows date, entry kind with its note, change, and staff, user, device and location names in one column.

Messages opens the composer in a Sheet from Send a message. Automatic messages lists every non-archived card's four automations and Near the shop with their saved on or off state, read from the gallery cards. Each row links to that card's ?tab=messages. The history shows date, message, card, recipients and status. The status cell adds the failed and excluded counts when they are above zero.

Reports shows visits, sign-ups, rewards redeemed and returning holders, then a bar chart of visits per bucket. More detail holds the other totals and the card, location and series tables. Stamp metrics hide without a stamp card and points metrics without a points card.

With no active locations and no devices, Counter setup shows an ordered setup panel. Add a location first, create a pairing code and open the scanner on the counter tablet or phone, then optionally add staff and require PINs. The devices step and the devices table show the full /scan address from the browser origin, with a copy field and a QR code from /api/qr. The pairing sheet names the same address. The location editor fills Map position from navigator.geolocation and hides Latitude and Longitude behind Enter coordinates by hand. Existing staff remain visible in the panel. Adding a location or creating a device restores the tables. Set up a scanner shows "Add a location first" beside the disabled action when there are no active locations. The PIN switch and its explanation stay with Staff. Archive location, Unpair and Remove staff member each open a confirm Modal that names the item before sending the DELETE request.

Cards renders the shared Wallet artwork in a gallery, with the card name under each tile, since artwork with a logo does not show the name. With no cards, it lists three setup steps: create the card, print the poster and set up the scanner. The last one links to Counter setup. A single New card action sits in the header. The dashed New card tile appears only when at least one card exists. GET cards?view=gallery calls LoyaltyReporting.gallery once for all cards. One SQL aggregate query returns each card, its current non-erased holder count and unreversed earning operations in the last seven UTC dates through the service clock. The sandbox applies the same definitions. There are no per-card requests.

Pending gallery reads are shared by URL across effect replays and remounts. Effect cleanup marks that subscriber stale rather than aborting the shared network request. Company and mode are part of the URL; stale subscribers cannot update another company. Reads leave the pending map after settlement. Holder changes and explicit retries request fresh data.

/loyalty/cards/new and /loyalty/cards/:id use the existing authenticated dashboard shell and company query selection. Browser refreshes and history navigation resolve the same routes. Existing cards use five tabs: Reward, Design, Share, Game and Automatic messages. Reward and Design show the sticky preview. Share holds the sign-up link, a Put this at your counter section with the sign-up QR, poster, downloads and game QR, and a closed Advanced group with the address. Reward is the default tab and has no ?tab= value. ?tab=design, ?tab=share, ?tab=game or ?tab=messages selects a tab, and the editor writes the current tab with history.replaceState, keeping the leave guard's history state. Inactive panels stay mounted and hidden, so unsaved edits survive tab switches and still take part in validation. The Game tab label shows On when the saved game is enabled. The Automatic messages label counts saved automations that are on. New cards use Reward, Design and Publish as an ordered stepper, with defaults translated into every company language. Earlier steps show a filled check circle, the current step has aria-current="step", and later steps are muted. Choosing a later step runs Continue and advances once after validation. Earlier and current steps remain directly accessible.

New cards show a secondary Save draft action in the header from the Design step on. Continue is the primary action at the bottom of Reward and Design. Publish card is the primary action at the bottom of Publish. Back appears on the last two steps. Draft saves and publishing stay on the saved editor route, where the saved sharing link, QR downloads and poster remain visible. Unsaved address edits do not change the saved sharing controls. Continue validates the current step. Save draft, Save, Publish card and Pause card validate every step, reveal the first invalid field and focus it. Validation switches to the tab that holds the field and opens any closed Disclosure or automation row containing it before focusing it.

Saved cards show the status as a Status badge. Draft cards get Publish card and active cards get Pause card. Each opens a confirm Modal and then saves the whole form with the new status, so other unsaved edits are saved too, and the dialog says so when the form is dirty. The pause dialog explains that draft closes sign-ups and blocks earning and redemption for existing holders, since loyaltyEntry, the scanner lookups, the game and automations all require an active card. A sticky bar with Unsaved changes, Discard and Save appears at the bottom of the viewport while a saved card's form is dirty. It sits above the phone tab bar and shows a save error in place of Unsaved changes. Editing clears that error. A successful save shows Card saved in a Toast. Discard releases the leave guard, then restores the saved card. The same dirty flag drives the leave guard. The dirty editor confirms before links, browser history navigation, company switching, the phone More menu or the account menu's Customer languages item leave the page, and beforeunload covers closing or reloading the tab. Switching editor tabs keeps every panel mounted, so it does not ask. Saving removes the history guard before any route replacement.

Both editor modes show card language tabs only for companies with multiple languages. Stamp targets keep the Stamps per reward number input and add Fewer stamps and More stamps buttons, bounded to 1 through 50. For stamp cards, Advanced starts closed and summarises stamps per visit, maxStampsPerScan and cooldownMinutes with translated plural forms. It contains Stamps per visit, Most stamps staff can give at once, and Wait before the same card can get another stamp with a minutes suffix. Points cards have no Advanced group, so points per visit, spend earning and the rewards catalog stay visible. Spend earning is one sentence, "Customers earn 1 point for every $1.00 spent". Its amount is typed in the company currency and saved as unitCents. In the new-card wizard, Card type stays at the top of Reward. Saved cards show the card type as plain text instead. The editor does not show the rule version number.

Design starts with Logo upload. The thumbnail sits on the card colour. When at least three corners of the uploaded logo are opaque and share a colour that covers more than 15 percent of a 48 by 48 sample, which catches a round logo on a white square, a warning Notice asks for a wide version with a transparent background. One Colours fieldset follows. It holds Colours from your logo, swatches without hex text that appear once a logo is uploaded, then Theme, then a closed Custom colours Disclosure with Card colour, Text colour and Small headings colour. Those map to background, foreground and label. Espresso, Sage, Oat, Blush, Ink, Tomato, Butter and Graphite come from loyaltyThemes and set all three together. A theme stays selected only while all three colours match. Picking a card colour leaves the other two unchanged. Stamp cards then show Stamp icon tiles. More design options starts closed and contains Banner photo, its Banner crop position control, Custom stamp icon on stamp cards only, and Code shown on the card. Its choices are Wide barcode (recommended), Line barcode and Square QR code, for pdf417, code128 and qr. Its summary reports the banner, custom icon and barcode choices.

Publish summarises the card type, reward text in the first company language, stamps per reward and sign-up link. Points cards show the catalog reward count instead of reward text and stamps. A sentence below explains that publishing makes the link, QR downloads and counter poster ready to share. The new-card steps have no address field. The address starts as the business name slug, and when signup-address reports it taken, the editor adds a four-character suffix. If the first save still returns slug_taken, it retries once with a new suffix and only shows the error when that fails too. Saved cards change it under Advanced on Share.

GET design-defaults reads only the authenticated company's saved embed brand colour. The sandbox uses an existing card's brand colour when present. GET signup-address?slug=...&cardId=... reports address availability. A card ID exempts only that company's own card; the save transaction still enforces global uniqueness. Neither operation accepts a company ID in its body.

The editor uses the dashboard's existing fonts, colour tokens and controls. Archive card and Delete card sit in a card actions Menu in the header. Each opens a confirm Modal. Changing a saved sign-up address shows a warning Notice under the field, because the old slug gets no redirect and printed posters, the sign-up QR and the game QR stop resolving. A disabled fieldset also disables Disclosure toggles, so read-only viewers see every closed group and automation row open. New cards get their initial label colour from loyaltyLabelColor. The live preview uses the core renderer, pass text, barcode encoder and reward engine. Apple and Google use SegmentedControl beside the small Show back or Show front button. Preview language appears only for companies with multiple languages. A rounded panel with the sunken surface token centres the pass. Test the card starts closed below that panel and contains the existing trial controls. The preview stays sticky on wide screens. Gallery balances are illustrative. Trial actions in the editor never write ledger activity. Rule edits preserve trial stamp cycles; changing the card kind resets them. Logo colour suggestions come from a small client-side canvas without sending the logo to another service. Below 1024 pixels, the preview column is hidden and a Preview button at the bottom of the screen opens the preview in a Sheet. Tabs other than Reward and Design hide the preview and use the full width.

Counter games

Migration 0068_loyalty_game.sql adds grizzlee_loyalty_plays, grizzlee_loyalty_prizes and the optional ledger prize_id. Game configuration lives in card JSON, outside reward rule versions. loyaltyGameSchema is strict. It accepts wheel, slots, mystery, coin, dice, scratch, quiz and timer. Prize weights are positive integer basis points. Their sum plus noPrizeBps must equal 10000. Prize and question IDs are unique within the game. Names, questions and choices must cover every company language. Quiz games need a question. Time zones must be valid IANA names.

The existing authenticated card POST and PUT routes accept an optional game with this shape. Card writes accept JSON up to 512 KiB to fit the full translated question set.

ts
game: {
  enabled: boolean;
  type: "wheel" | "slots" | "mystery" | "coin" | "dice" | "scratch" | "quiz" | "timer";
  prizes: { id: string; name: LoyaltyText; weightBps: number; dailyStock?: number }[];
  noPrizeBps: number;
  prizeStart: "now" | "next_visit"; // Defaults to next_visit.
  prizeValidDays: number; // 1..365, default 30.
  timeZone: string;
  timer: { toleranceMs: number }; // 20..2000, default 100.
  quiz: {
    seconds: number; // 3..60, default 10.
    questions: { id: string; text: LoyaltyText; choices: LoyaltyText[]; answer: number }[];
  };
  asks: { instagramUrl?: string; tiktokUrl?: string; googleReviewUrl?: string };
}

Prize lists contain 1 to 12 rows. Question lists contain up to 20 questions, each with 2 to 4 choices and a zero-based answer index. Ask URLs must use HTTPS. The dashboard fills timeZone from the merchant's browser on save. Game edits preserve existing plays and prize snapshots.

/loyalty/play/:slug uses the existing signup slug and company languages. Only active cards with an enabled game resolve. The Share tab shows the game QR, Download QR and Open game page under Put this at your counter when the saved card is active and its saved game is enabled. When the saved game is enabled on a draft card, it shows a note asking to publish the card instead. The Game tab points to Share for the QR and lists Game stats below the setup once the game has plays. The public page receives no quiz answers or question pool. All eight games have separate components behind GamePresentationProps. Chance games animate only to the server outcome. The wheel draws at least eight equal slices, repeats prizes when there is no no-prize share, and fills unused slices with localized No prize labels otherwise. Slice geometry does not represent the odds. Rules lists the server odds and names the reset time from the last part of the IANA zone, such as Toronto. The page shows the first prize with a public weight above zero under the title. Slots and dice show no caption before the play starts, and a landed loss reads So close! A spin lasts about 4.5 seconds and lands at a random position inside a matching slice. Reels stop in sequence, mystery cards reveal the selected outcome before the alternatives, coin picks determine the animated landing side, and dice show doubles for wins. The scratch canvas samples erased alpha coverage and reveals the remaining foil at 55 percent. Keyboard and reduced-motion users have a Reveal button. The quiz owns its countdown and submits the choice or timeout to finish. The stopwatch measures with performance.now(), shows the first three seconds, then hides both digits and progress until Stop. Games stay mounted after landing. Wins show a brief brand-colour confetti burst and a ticket with both dates. Web cards reuse that ticket with a Ready today badge. Reduced motion uses a short fade and disables confetti.

All public mutations below require same-origin requests, JSON and the httpOnly device cookie issued by start. Live requests also use the configured origin guard. IP limits apply. There is no company field in a public request. Slug resolution determines it. Success bodies wrap the result in { data }. Errors use the existing { error: { code, message, ... } } shape. Responses are private and not cached.

RouteRequestResponse data
POST /api/loyalty/game/:slug/start{}{ playId, settled, game, outcome?, claimToken?, question? }
POST /api/loyalty/game/:slug/finish{ type: "quiz", playId, answer } or { type: "timer", playId, clientElapsedMs }{ playId, settled: true, game, outcome, claimToken? }
POST /api/loyalty/game/:slug/claim{ playId, claimToken, signup? }{ claimed, cardUrl?, confirmation? }
POST /api/loyalty/game/:slug/tap{ playId, ask: "instagramUrl" | "tiktokUrl" | "googleReviewUrl" }{ url }
POST /api/loyalty/game/:slug/done{}{} and clears the follow-up cookie
GET /api/loyalty/game/:slug/qr?format=pngNo body. Use format=svg for SVG.Image download encoding /loyalty/play/:slug.
GET /api/console/companies/:company/loyalty/cards/:card/game-statsNo body. Requires authenticated membership.{ plays, wins, winPrizes, prizesRedeemed, taps, returningHolders30Days }

outcome is null for a settled loss or { prizeId, definitionId, name, validFrom, expiresAt, timeZone } for a win. name contains all saved translations. game is the play's saved public configuration with only quiz.seconds, so it never contains the question pool or answers. This keeps the presentation consistent if the merchant edits settings after the page loads. Public prize weights become zero for exhausted stock and its share moves to no prize. The saved configuration keeps its original weights. The result presents the odds used before reserving that win. Pending skill plays omit outcome. Quiz starts include question: { text, choices, seconds }. Timer starts include no question. Quiz answer -1 records a timeout. The server rejects unknown fields. Chance games settle on start and do not need finish. Finish retries preserve the outcome and issue a fresh claim token for an unclaimed win. Only the token digest is stored.

signup has the existing public signup shape, including name, email or phone, optional birthday, marketing consent and the honeypot website field. The game claim form collects only name, one phone-or-email field and marketing consent, the same contact field as the join form. An @ selects email validation. The client removes phone punctuation and prefixes +1 for 10 digits without +. Other numbers need an international + prefix. It sends the existing signup shape and uses the same server validation, contact matching and recovery delivery. The ordinary join form still accepts birthdays. An internal resolution flag lets game claims attach to the resolved pass without disclosing a returning holder's private link. Existing holders retain their profile and consent. If necessary, game signup enrolls the matched holder into this card before attaching the prize. This already covers phone-only matches. Their prize stays on that pass while the confirmation directs them to staff. Giving the same card from the scanner replaces its link and reveals the prize already attached to it. No claim token retry or card cookie is needed for this handoff. A remembered pass uses an httpOnly cookie containing its web secret. New signup, recovery and game claim set that cookie. Claim requires both the device credential and the random claim token. Repeating a claim onto the same pass succeeds without creating another prize.

Postgres takes the company share lock, then the card update lock while recording plays and stock. This serializes simultaneous starts and finishes for a card. Holder erasure takes the company update lock before reading game records, so game writes cannot restore erased links or credentials. Start loads only the device's play for the current local day. The page load reads the device cookie and loads the same row, so a device that already played today sees Come back tomorrow before pressing Play. When the pass cookie resolves, a loss links to GET /loyalty/play/:slug/card instead of the join page. That route reads the httpOnly pass cookie and redirects with 303 to the web card, or back to the game when the pass is missing or belongs to another card, so the card secret never appears in the game page. The language links hide once a play starts, because reloading would lose an unclaimed prize. Finish, claim and tap load only the requested play. Existing records use targeted updates. Only a new play or prize uses an insert. Signup and claim associate earlier anonymous plays with a targeted update without loading their game snapshots. The sandbox runs the same settlement code within its existing single-process write queue. Every play stores a crypto.randomInt(10000) draw and a snapshot of its game settings. Chance games use the full 10000 range. Successful quiz and timer attempts use relative prize weights without the configured no-prize share. An exhausted prize's share remains a loss. Stock counts all wins in the prize's local day, including unclaimed wins. A unique device, card and local-day key prevents a second daily play.

Quiz settlement checks the saved answer and arrival within its countdown plus three seconds. Timer settlement uses only abs(clientElapsedMs - 10000) <= toleranceMs for the result. Server elapsed time must be at least client elapsed minus 250 ms and at most client elapsed plus 3000 ms. Values outside those bounds receive game_timing. Network time never changes a verified stop from a win to a loss.

A win snapshots the prize name, start and expiry. For next-visit prizes, validity starts at the next local midnight and lasts the configured number of local days. Today prizes start at win time and last the configured number of 24-hour days. Claim sets the pass and claim day on that prize. A unique pass, card and claim-day key and the service check enforce one claim per local day. game_already_played and game_already_claimed are 409 responses. Asks remain optional after a loss or secured claim. No panel renders when there are no configured links. Taps are counted on the play and never alter a prize.

Scanner POST /api/scan/operate and authenticated passes/:pass/redeem accept { operationId, prizeId } for a prize redemption. The scanner wraps this in { passId, operation: { kind: "redeem", operationId, prizeId } }. A reward ID and prize ID cannot be combined. The operation locks the pass and prize, checks ownership, start, expiry and redemption status, then appends a redeem entry with delta: 0 and prizeId. Its result has the unchanged balance and stamp cycles. Existing operation digests, scanner retries and undo checks apply. Reversal restores that exact prize without extending its dates. Early, expired and used prizes return loyalty_prize_early, loyalty_prize_expired and loyalty_prize_redeemed.

Ordinary loyalty stats exclude prize redemptions from rewardsRedeemed. They add no stamps, points or visits. Game stats count all plays, wins per saved prize definition, current redeemed prizes and taps. winPrizes preserves names for definitions removed from configuration. The return figure counts distinct holders with an unreversed earning visit within 30 days after their first associated play, across that company's cards. Signup and claim associate earlier anonymous plays from the same device and card where possible.

Winning with a remembered pass, claiming and redeeming queue loyalty.pass_changed. An anonymous win has no pass to update until claim. Wallet renderers include active prize snapshots and validity dates. Apple adds a back field and a Prize auxiliary field for usable prizes. Google adds a localized text module. Distributing a pass change advances both Apple's update tag and apple_updated_at, including prize redemption and undo when balance and stamp cycles stay unchanged. Balance changes still advance the timestamp through the existing pass trigger. The worker queues another pass update at the saved start and expiry times, using an indexed due timestamp on each claimed prize. Redemption cancels that timestamp and undo restores it. Web cards list unredeemed, unexpired prizes, including future start dates.

Holder erasure detaches associated prizes and plays, including anonymous plays carrying the same device digest. It clears pass links, device and claim digests, claim dates, redemption entry links and Wallet refresh times. Prize IDs, card IDs, definition IDs and win dates remain so erasure cannot replenish daily stock. Anonymous play, win, redemption and tap totals remain. Detached records have no pass for the returning-holder calculation, customer card or scanner. Detached plays cannot issue or use claim tokens, and detached prizes cannot be redeemed. Erasure retains anonymous ledger history. Plays and claimed prizes belonging to another holder on a shared device remain. Company erasure also deletes anonymous game records.

For a fresh local sandbox, run GRIZZLEE_MODE=sandbox pnpm dev. Open http://127.0.0.1:3210/loyalty and choose the sample store. The seeded stamp card has signup slug atelier-coffee and an enabled wheel with a guaranteed coffee prize, usable tomorrow, plus sample Instagram and Google review links. Replace the sample links with the merchant's own addresses before using the QR at a counter. Open /loyalty/play/atelier-coffee, play and claim using new contact details. Use /loyalty/cards/:id to change the game, its dates or asks. Existing sandbox files keep their saved configuration; enable a game and save its signup address in the editor. /scan uses the existing sample-company scanner setup. Configure a location and device in Counter setup first. To redeem in the same day, set Prize starts to Today before winning. Use a separate browser profile for another device, or wait for the next local day. Sandbox storage is local only and sends no email or Wallet requests.

Merchant messages

The console Loyalty routes share the card permission checks. Viewers can read history. Editors, admins and owners can create, preview and cancel messages. Company identity comes from the signed-in membership. Request bodies accept no company override.

MethodPath below /api/console/companies/:company/loyaltyBody and result
GETmessagesThe newest 200 manual messages plus every scheduled or sending manual message, newest first, with delivery counts
POSTmessages/preview{ "cardId": "optional-active-card-id" }. Returns recipients and excluded. Use {} for all active cards.
POSTmessages{ "text": { "en": "Today's news" }, "cardId": "optional-active-card-id", "scheduledAt": "optional-ISO-UTC-instant" }. Returns the saved message.
POSTmessages/:message/cancel{}. Cancels a message that is still scheduled.

Text must be plain text, nonempty in every company language, and at most 200 characters per language. The existing language validator checks messages and card text. The dashboard converts its local date and time input to an ISO UTC instant. Scheduled instants must be in the future. Without scheduledAt, sending starts immediately. Status is scheduled, sending, sent or cancelled. Sent means all recipient deliveries have finished, including failed deliveries.

Migration 0069_loyalty_messages.sql adds company-owned messages and per-pass delivery rows. The unique company, message and pass key prevents duplicate recipients across retries. LoyaltyMessages.send(companyId, messageId) computes recipients once, at send time, and is callable by the worker. The worker checks due messages in its existing loop. Cancellation and sending serialize through the same company and message locks. All-card sends target active cards. Recipients count consenting live passes. Excluded counts distinct live holders without marketing consent on those cards. A scheduled card that is archived or deleted before sending has no recipients.

Sending locks holders and passes in erasure order, snapshots the excluded count, saves delivery rows, updates the Apple pass timestamp and appends one loyalty.pass_changed event per pass in one transaction. The existing outbox distributor creates wallet jobs. It skips locked passes or cards for a later attempt and settles deliveries after releasing outbox locks. No wallet jobs means the web-only delivery succeeds. Per-pass results wait for all associated provider jobs. One failed provider job fails the pass delivery after retries finish. Completed provider results take precedence over later card deletion or holder erasure. Delivered counts provider update success, not notification receipt. Wallet jobs lock the company and pass before locking their job row, so erasure can cancel pending jobs in the same order. They recheck the live pass and consenting holder before sending. Every Google object write, including issuance and ordinary balance or reward updates, holds the pass lock while sending current news. Google records job completion before releasing that lock.

Google messages use the existing grizzlee_google_wallet_notifications reservations and notify_blocked_until guard. The same three attempts per pass in a rolling 24 hours cover reward-ready alerts and merchant messages. Within the allowance, addMessage uses TEXT_AND_NOTIFY; otherwise it uses TEXT. A quota rejection falls back to TEXT. Merchant jobs save a durable attempt marker after PATCH succeeds, immediately before addMessage. Temporary PATCH failures remain retryable without that marker. After an uncertain attempt or restart, a retry reads the object for that message ID. A confirmed message succeeds; an absent message fails without another send. This choice preserves at-most-once delivery when the provider outcome is unknown. Reward alerts keep their existing silent retry behavior.

Each pass shows only the latest merchant message. Apple adds a translated Latest news back field with changeMessage: "%@" and uses the existing APNs update push. Its message body uses a dedicated localization key so merchant text cannot replace field-label translations. Apple notification delivery is best-effort and only the newest change may appear. Google keeps the latest news in a translated text module as well as its Wallet message. If a newer send overtakes a queued message, that older job refreshes the latest news without posting another notice. Web cards show the text and sent date. Web-only cards never notify. Unlimited sends do not mean unlimited notifications.

Holder erasure reconciles completed provider outcomes before deleting per-pass message delivery rows through eraseLoyaltyMessageDeliveries in erasure.ts. It clears pass and message IDs from message outbox payloads and Google jobs, marks pending message events distributed and terminates pending Apple and Google message jobs. Anonymous counts remain. Only deliveries stopped before completion add to the failed count. The sandbox removes the same delivery rows and outbox associations. Core LoyaltyState permits an empty outbox payload after erasure. Sandbox messages complete as delivered and never call Wallet APIs. The existing sandbox store queue processes due messages before serving its next request and persists that work, including on reads. Its routes use the same paths below /api/sandbox/loyalty.

Automatic messages

Card POST and PUT accept an optional strict automations object alongside game. It does not change reward rule versions. The dashboard fills timeZone from the browser on save. Core validates it with Intl.DateTimeFormat and rejects numeric offsets. Each text uses the message plain-text schema, with 1 to 200 characters in every company language. All switches default to false. Optional nearby holds enabled, default true, and text with 1 to 80 plain-text characters per language. The worker never sends it. It changes only Wallet pass content, and the existing card triggers queue Apple and Google updates when card data changes. Cards without nearby behave as if it were on with the default text. Installed passes pick up that default text on their next update.

json
{
  "timeZone": "America/Toronto",
  "welcome": { "enabled": false, "text": { "en": "Thanks for joining us!" } },
  "birthday": {
    "enabled": false,
    "text": { "en": "Happy birthday!" },
    "bonus": 0
  },
  "winBack": {
    "enabled": false,
    "text": { "en": "Come visit us again." },
    "days": 30
  },
  "rewardReminder": {
    "enabled": false,
    "text": { "en": "Your reward is waiting." },
    "days": 7
  }
}

Birthday bonuses are integers from 0 to 1000, in stamps or points according to the card kind. Win-back days range from 7 to 365. The dashboard calls winBack We miss you. Reward reminder days range from 1 to 365. The service records welcomeEnabledAt when welcome changes from off to on. Clients cannot supply it. Only passes created after that instant get a welcome message. Disabling and enabling welcome starts a new enrollment window without repeating earlier sends.

LoyaltyAutomations.runDue reads active cards in batches of 25 and processes due passes in batches of 50 per card. Between batches, the worker processes scheduled messages, distributes outbox events and sends queued Wallet and webhook deliveries. Selection has its own error boundary. A failed recipient rolls back without stopping other recipients or delivery work and stays eligible for retry. An indexed check timestamp limits normal selection to once per local day. Card saves and ledger activity make passes eligible for another check. New passes start unchecked. The sandbox calls the same automation service inside its existing write queue before handling the next request, including read requests, and persists its changes. It rolls back each failed recipient's pass, ledger entries, messages and outbox events before continuing. Automated sandbox sends select their pass before looking up holders.

Birthday uses the local year as its period and fires only on the saved month and day. February 29 uses February 28 in non-leap years. Win-back uses the latest unreversed earning entry ID and local calendar days since that visit. A new visit supplies a new period. Reward reminders use stable reward IDs and an ordinal for each completed stamp cycle. Legacy stamp groups receive UUIDs before ledger operations can split them. The redeemed cycle keeps its ID and ordinal, and the remaining group advances to the next ordinal. Points rewards use threshold crossings; a redemption that leaves another reward available starts another period. Undo preserves the restored reward's identity and original availability date. Existing passes derive missing reminder dates from ledger earnings. Changing a points reward cost starts availability no earlier than that rule version. Reminders neither expire points nor change saved rule versions.

Migration 0070_loyalty_automations.sql adds indexed selection timestamps, cached reward availability and message automation fields. A unique index on company, pass, automation and period prevents repeated sends. The worker locks the company, holder and pass, checks live status and consent again, then stores an immutable text snapshot and sends through LoyaltyMessages.send to exactly one pass. Birthday bonuses call the same ledger calculation and persistence functions as other balance changes, in the message transaction. Their birthday ledger kind appears as Birthday bonus in activity reporting, separate from adjustments and visit earnings. Bonuses update balances, stamp cycles, reward-ready alerts and Wallet through the existing outbox.

Automatic sends use the same delivery rows, Wallet jobs and Google rolling notification budget as manual messages. Marketing consent is required at selection and provider dispatch. Before outbox distribution and each provider attempt, reward reminders also check their reward ID and ordinal against current availability and card rules. Redeeming that reward stops its pending reminder, including provider retries. Reward-ready alerts stay transactional. Erasure clears automated messages' pass IDs, period keys and reward identifiers along with delivery rows and keeps anonymous send totals. It also removes pass and entry IDs from birthday bonus outbox events and stops their pending Wallet jobs. Birthday bonus ledger entries follow the existing ledger retention rules. The sandbox clears the same outbox associations and automation keys. GET messages hides automated messages and includes every scheduled or sending manual message alongside the newest 200 manual messages.

GET /api/console/companies/:company/loyalty/cards/:card/automation-stats returns welcome, birthday, winBack and rewardReminder, each with sends and returningHolders. The sandbox exposes the same suffix under /api/sandbox/loyalty. The console derives company access from authenticated membership. Sends count messages with a selected recipient in the last rolling 30 days, including pending or failed provider deliveries. For birthday and win-back, returning holders count distinct holders with an unreversed earning on that pass after a qualifying send and within 14 days. Recent sends have a shorter observation period. Other automation kinds return zero for this field. Erased holders keep anonymous send totals but no longer count as returning holders.