Conversation
Attendees load money onto their ticket and pay with its QR code at bars and stands, so a festival can run without cash on site. A top-up is a normal order containing a hidden system product, so it goes through the existing checkout, Stripe Connect and invoicing untouched. A queued listener on OrderStatusChangedEvent credits the wallet once the order completes, and the buyer gets a receipt email showing the new balance. The top-up product deliberately carries no taxes or fees: VAT belongs on the drink, not on the advance. Balances live in an append-only ledger. Every mutation goes through CashlessWalletService, which takes a row lock, refuses to overdraw and keeps the stored balance in step with the ledger. Mistakes are corrected with reversal rows, never by editing history. Staff get a sales point app at /cashless/pos/<short id>, protected by a PIN and driven by the same scanner as check-in: scan a ticket, build a basket from the event's own products, charge the balance. Sales carry a client reference so a replayed request cannot debit twice. Organizers get a Cashless menu for balances, sales points, the transaction ledger, an export, a sales report, and refunds of unspent balance back to the original cards. Two fixes fell out of the work: decimal columns need a float cast or domain object hydration throws, and public routes must be listed in ALLOWED_UNAUTHENTICATED_PATHS or anonymous visitors bounce to login.
Nine issues from the first round of testing. Sales points can now sell nothing at all. Leaving the catalogue empty makes the point a top-up desk, and the till drops its charge tab. Online top-ups can carry a fee, so a 5 EUR load no longer costs the organizer more in card fees than it brings in. The fee is a fixed and a percentage component provisioned as ordinary taxes-and-fees rows attached to the top-up product, which means it shows up in the payment breakdown, the invoice and the reports, and it cannot leak onto cash top-ups: those never touch the product. Loading 20 EUR with a 0.25 + 1.5% fee charges 20.55 and credits 20.00. A sales point purchase now counts as a real product sale. It increases quantity sold and used capacity, refuses to oversell, and a reversal puts the stock back, so stock control finally works across the bar and the ticket shop. The event dashboard gains a cashless revenue chart alongside product sales and revenue, fed by a new daily endpoint. The web top-up can be switched off per event for organizers who want everything to go through the tills, and when it is on the checkout no longer asks a ticket holder to retype the name and email we already have. Smaller fixes: the Balances menu item stayed highlighted on every cashless sub-page, the empty states pointed at an illustration that does not exist, the till PIN box was small and masked, and a stray currency symbol sat at the end of the minimum top-up hint.
Sales point purchases were only decrementing stock, so they never showed up in products sold, orders or the reports built on them. A purchase now creates a completed order with a CASHLESS payment provider, through the same order creation and statistics services as a ticket sale, and reversing the sale cancels that order. CASHLESS is deliberately excluded from the payment methods an organizer can enable at checkout. Top-up fees no longer use two bespoke columns. The organizer picks from the account's own taxes and fees on the cashless settings screen and they are attached to the hidden top-up product, so they flow through the payment breakdown, invoices and reports like any other fee. The till asks the server to price every basket and top-up, and shows the result. For a card top-up it displays the exact amount to key into the terminal, fees included, so staff cannot undercharge by typing the bare amount. Quotes reuse the checkout tax calculator, so the two cannot drift. Attendees can now reach their balance from the order summary and the ticket email, and the wallet page explains where to top up when online top-ups are switched off. The till PIN is masked again, at a size that is still comfortable to type. Backend strings for the cashless flow, including the top-up receipt, are now translated; only the frontend catalogues had been before.
A cashless sale created its order without an event occurrence on the order items, but the daily statistics are aggregated per occurrence, so the sale showed up in the orders list and stayed out of gross sales. The normal checkout fills the occurrence in a request validation service that this path never goes through. The till now resolves it itself: the only occurrence for a single event, the one in progress for a recurring event, otherwise the next one. Reversing a sale is now recorded as a full refund of its order before the order is cancelled, the same sequence as a native refund, so refunded revenue is tracked instead of vanishing from the statistics. The four cashless migrations were a create, an add, a column that was then replaced, and the drop of that replacement. They are folded into a single create migration. The resulting schema was checked against the existing one object by object on a fresh database and is identical. Small cleanups from the same review: the settings lookup existed twice, and the refund response carried two fields that were always equal.
Visitors open the cashless section on the event homepage, type or scan their ticket ID and land on their wallet page to top up online. The public wallet endpoints resolve either the public id or the short id, mask the attendee surname and are throttled. Replaces the ticket-id lookup that was tied to the short id in the URL.
The top-up order no longer has the buyer email set at creation, which made the checkout treat it as already processed (409). The sales point history tab now loads from a new endpoint instead of local state, so it survives a page refresh.
…icket Online top-up orders now carry the event occurrence so they count in occurrence statistics. The buyer details are prefilled from the ticket again; the checkout's already-processed guard now looks at the payment status instead of the email so a prefilled order can still be completed.
A top-up is prepaid credit, not a sale: revenue is recognised when the balance is spent at a sales point. Top-up orders no longer feed event statistics (creation, refund, cancellation) nor the product-sales, events-performance and tax-summary reports, so gross sales are no longer counted twice and balance refunds do not alter them.
Sales points without staff top-ups only see the first name and the initial of the last name, matching the self-service wallet page. Masking is shared through CashlessAttendeeNameMasker.
The sales point screen was styled with plain Mantine defaults: a white page, a bordered header, and top tabs. It now uses the same shell as the check-in app — the gray canvas, the uppercase label / bold title header, and the frosted floating pill navigation with the gradient active state. The PIN gate gets the same gradient icon circle and elevated white card. The floating pill navigation is extracted into a shared FloatingTabBar component so check-in and the cashless POS stay visually in sync instead of drifting apart again.
The Charge and Top up tabs used a cropped camera-only square plus a manual text field. They now use the same full-width scan zone as check-in — camera or USB toggle, sound feedback — extracted into a shared TicketScanZone component and useUsbBarcodeScanner hook so both screens stay in sync. Check-in's own scan tab is rebuilt on top of the same component. The product catalogue and cart on the Charge tab only render once a ticket has been scanned, instead of showing an empty catalogue above an unusable scan zone.
The confirmation mail had its own layout with a panel block that leaked raw markup into the plain-text version. It now follows the other mails: a Liquid template (cashless_topup) editable per event or organizer, with the default rendered like the waitlist and cancellation mails and no panel. Also translates the POS navigation and scan-zone strings that were left untranslated in the previous change. 🤖
…alances into sales The new Overview screen totals what was loaded onto balances (online and at the event), spent, refunded and still left, with the daily chart and breakdowns per sales point and best-selling product. Closing cashless writes a CLOSURE ledger row for every wallet holding money, locks all wallets and adds the total to the event's gross sales through the statistics tables, without creating an order. It is refused while balance refunds are still open, cannot be undone, and wallets created afterwards are born closed. Closed balances show as such for visitors and at the sales points, and stay out of the wallet actions. 🤖
…anger zone Balances and sales points get a View transactions action that opens the transactions list already filtered on them. The list gains a search on attendee, quick filters by kind, a sales point filter and a removable attendee chip alongside the existing sort. The closure moves from the overview to the cashless settings page, inside the shared DangerZone component. The overview is regrouped into top-ups, spending and what is left over, one bordered row each. The KpiGrid separators are now 1px on standard-density screens, where the 0.5px gap could disappear entirely. 🤖
The cashless settings page now follows the event settings layout: a sticky section menu and one card per section (General, Top-ups, Refunds) with the shared heading, the danger zone last. The transactions list drops its bespoke quick filters for the FilterModal used by the orders page, filtering on type and sales point, next to the search and sort. The overview's last row is renamed Other statistics. 🤖
The cashless top-up email template is now edited from the cashless settings page; the event email screen no longer lists it. The template cards are driven by a list of visible types. Cleanup from a full review: Russian backend strings and the attendee token labels are translated, the French "Type" no longer reads as the verb "Taper", bare comments in tests are gone, the sales point wallet lookup is throttled, and the flagged routes and domain object stubs are formatted. 🤖
The Save button in each cashless settings card stretched to fill the card width. It now sits in its own block like the other settings pages, so it stays a normal inline-sized button aligned left. 🤖
Contributor
|
All contributors have signed the CLA ✍️ ✅ |
Author
|
I have read the CLA Document and I hereby sign the CLA |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes I've made
Adds a complete cashless payments feature: event visitors top up a wallet (linked to their ticket's QR code) and staff can then charge purchases against that balance from a point-of-sale interface.
Wallets tied to a ticket's QR code, with a public lookup flow (/cashless/:eventId → scan or type the ticket reference) that never requires login and only exposes the holder's surname initial.
Top-ups go through the normal order pipeline as a hidden system product, so they use the account's native taxes/fees; sales-point purchases are also normal orders (created COMPLETED, payment_provider = CASHLESS), so they count in orders, stock and reports like any other sale.
A dedicated POS UI (reusing the check-in app's scan zone/branding) that quotes the exact total via CashlessQuoteService before charging, so the screen and the card terminal always agree.
An append-only ledger (cashless_transactions) with row-locked balance mutations, refunds, transaction reversals (correction rows, never edits/deletes), and wallet status management.
An event closure flow that locks all wallets, records a CLOSURE ledger entry per wallet, and moves remaining balances into sales totals — irreversible, and blocked while balance refunds are still open.
Top-ups are excluded from every sales statistic and report (product sales, events performance, tax summary) since the money is only counted as revenue once spent at a sales point.
An editable email template for top-up confirmations (Liquid, same event/organizer-level system as order confirmation), configured from the new cashless settings screen.
A cashless overview dashboard, transaction list with drill-downs/filters, and sales-point management, aligned with the existing settings/filter UI patterns.
Why I've made these changes
Event organizers running cashless bars/vendors need a way to let attendees pre-load funds onto their ticket and pay without cards or cash at each stall, while keeping accurate accounting (wallet balances must always reconcile to the ledger, and top-up money shouldn't be double-counted as revenue until it's actually spent).
How I've tested these changes
Backend: new Unit and Feature test suites covering the wallet service (balance/lock/reversal logic), closure service, quote service, top-up order exclusion from statistics, wallet resolution, attendee name masking, mail token context, and repository/handler tests.
E2E: a Playwright spec (e2e/tests/cashless/cashless.spec.ts) driving the real top-up → sales-point purchase → closure flow against the dev stack, plus an updated email-templates spec for the new template type.
Manually exercised the flow against the local dev stack (top-up via public wallet page, POS charge, refund, transaction reversal, event closure).
Real event: It was tested during an actual festival with several hundred participants without any issues. I was able to gather feedback to improve certain features, as well as ideas for future enhancements (such as an interface for vendors to manage their sales locations, etc.).
Checklist