Skip to content

Add a cashless payments feature - #1368

Open
feloeht wants to merge 17 commits into
HiEventsDev:developfrom
feloeht:feature/cashless
Open

feloeht wants to merge 17 commits into
HiEventsDev:developfrom
feloeht:feature/cashless

Conversation

@feloeht

@feloeht feloeht commented Sep 28, 2026

Copy link
Copy Markdown

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

  • I have read the contributing guidelines.
  • My code follows the coding standards of the project.
  • I have tested my changes, and they work as expected.
  • I understand that this PR will be closed if I do not follow the contributor guidelines and if this PR template is left unedited.

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.

🤖
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@feloeht

feloeht commented Sep 28, 2026

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@feloeht feloeht changed the title Feature/cashless Add a cashless payments feature Sep 28, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant