Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9e19140
Add cashless payments backed by the ticket QR code
feloeht Sep 20, 2026
601309c
Fix cashless feedback: fees, stock, top-up-only points, dashboard
feloeht Sep 20, 2026
4f6e12b
Route cashless sales through the order pipeline, use native fees
feloeht Sep 20, 2026
466a9fc
Attach cashless sales to the event occurrence, merge migrations
feloeht Sep 20, 2026
a4fb475
Add self-service cashless top-up entry from the event page
feloeht Sep 20, 2026
ff0b46a
Persist POS history server-side and fix top-up checkout conflict
feloeht Sep 20, 2026
fac7d37
Attach top-up orders to an occurrence and prefill checkout from the t…
feloeht Sep 20, 2026
9f5103c
Keep cashless top-ups out of sales statistics and reports
feloeht Sep 20, 2026
3b29531
Mask the ticket holder's surname on sales points that cannot top up
feloeht Sep 20, 2026
0d48cae
Match the cashless POS portal to the check-in app's branding
feloeht Sep 22, 2026
5aefaf5
Reuse the check-in scan zone in the cashless POS, gate charge on a scan
feloeht Sep 22, 2026
11d980c
Make the cashless top-up email an editable template
feloeht Sep 24, 2026
18efe3a
Add a cashless overview dashboard and a closure that moves leftover b…
feloeht Sep 28, 2026
b795499
Add transaction drill-downs and filters, move cashless closure to a d…
feloeht Sep 28, 2026
105092b
Align cashless settings and transaction filters with existing screens
feloeht Sep 28, 2026
42244ec
Move the cashless email template to the cashless settings and clean up
feloeht Sep 28, 2026
0632032
Fix the full-width Save button on the cashless settings page
feloeht Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 18 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ Gotchas:
#### Database & Migrations
- **DO** use auto-incrementing integer IDs (`$table->id()`), not UUIDs
- Use anonymous class syntax for migrations
- Postgres returns `decimal` columns as strings. Domain objects type them as `float`, so every new decimal column needs a `'column' => 'float'` entry in the Eloquent model's `getCastMap()` or hydration throws a `TypeError`

#### Cashless
- A top-up is a **normal order** containing a hidden system product flagged `products.is_cashless_topup`, provisioned by `CashlessTopupProductProvisionService`. Its fees are the account's native taxes & fees, attached to that product from the cashless settings screen. Cash top-ups at a sales point never touch the product, so they never pay the fee
- A sales point purchase is also a **normal order** (`CashlessPosOrderService`), created `COMPLETED` with `payment_provider = CASHLESS`. That is what makes it count in orders, product statistics, stock and reports. Reversing the purchase cancels the order through `OrderCancelService`. `CASHLESS` is not a checkout payment method: `UpdateEventSettingsRequest` deliberately only accepts Stripe and offline
- Top-up orders are **excluded from every sales statistic** (`EventStatisticsIncrementService`, `RefundService`, `CancellationService` all return early via `CashlessTopupOrderChecker`) and from the product-sales, events-performance and tax-summary reports: the money is only revenue once it is spent at a sales point, so counting both would double the gross. Refunding a remaining balance therefore never touches sales figures either
- Every balance mutation goes through `CashlessWalletService::record()` / `reverse()`, which take a row lock on the wallet. Never write `cashless_wallets.balance` directly — the ledger must always re-sum to the stored balance
- `cashless_transactions` is append-only. A mistake is corrected with a `REVERSAL` row, never by editing or deleting
- Crediting a top-up runs in `CreditCashlessWalletJob`, dispatched from `OrderStatusChangedEvent`. Keeping it queued is what stops the Unit suite from hitting the DB. A `PENDING` `cashless_topups` row against a `COMPLETED` order means the job never ran — that is the reconciliation signal
- Sales point writes are idempotent on `client_reference_id`, unique per sales point. Any new till operation must send one
- The till never computes a total itself: it asks `CashlessQuoteService` (`/quote`) so the amount shown to staff, and the amount to key into a card terminal, always match what the order will charge
- Visitors reach their balance without logging in: the event homepage links to `/cashless/:eventId` (type or scan the ticket ID), which redirects to `/cashless/:eventId/:ticketReference`. `CashlessWalletResolveService::resolveByTicketReference` accepts both `A-…` public ids and `a_…` short ids; the public wallet response only exposes the surname initial, and both public endpoints are throttled because the public id is the only secret
- The top-up confirmation email is the editable `EmailTemplateType::CASHLESS_TOPUP` template (Liquid, event/organizer level like order confirmation), built through `MailBuilderService::buildCashlessTopupConfirmationMail`. It is edited from the cashless settings page (`TemplateSettings` restricted with `types={['cashless_topup']}`); the event email screen omits it, only the organizer defaults screen lists it too. The blade view is only the fallback when no template exists — never send `CashlessTopupConfirmationMail` directly, and add tokens in `LiquidTemplateRenderer` and `EmailTokenContextBuilder` together
- The closure lives in the cashless settings page inside the shared `DangerZone`/`DangerZoneSection` (like organizer and event settings), not on the overview. Admin drill-downs link to the transactions list through `cashlessTransactionsPath()` using the `filterFields[cashless_wallet_id|cashless_sales_point_id][eq]` query params; the list also filters on `type` (`in`) and searches attendees through `query`
- Closing cashless (`CashlessClosureService`, one transaction) writes a `CLOSURE` ledger row per wallet with a balance, locks every wallet as `CLOSED`, sets `event_settings.cashless_closed_at`, and adds the total to `sales_total_gross` / `sales_total_before_additions` through `EventStatisticsCashlessClosureService` — atomic `incrementEach` that also bumps `version`, touching neither `orders_created` nor `products_sold` since no order exists. It is refused while balance refunds are still open, and it cannot be undone (closures are not reversible). Wallets first created after the closure are born `CLOSED`. Order-derived reports (product sales, events performance) do not include it: only the event statistics do

#### Enums
- Status enums go in `backend/app/DomainObjects/Status/`
Expand Down Expand Up @@ -168,6 +183,9 @@ Gotchas:
- Convention: kebab-case `<feature>-<element>`, e.g. `promo-code-create-button`, `webhook-submit-button`, `product-edit-menu-item`. For `CustomSelect`, options are auto-derived as `<dataTestId>-option-<value>`.
- Only add IDs for elements a test actually interacts with; don't blanket-annotate new UI.

#### Public (unauthenticated) pages
- `frontend/src/api/client.ts` holds `ALLOWED_UNAUTHENTICATED_PATHS`. A new public route must be listed there, otherwise the 401 from `/users/me` redirects anonymous visitors to the login page

#### Error Handling
- **DON'T** use `showNotification` from `@mantine/notifications`
- **DO** use `showSuccess`, `showError` from `frontend/src/utilites/notifications.tsx`
Expand Down
112 changes: 112 additions & 0 deletions backend/app/DomainObjects/CashlessSalesPointDomainObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace HiEvents\DomainObjects;

use Carbon\Carbon;
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
use Illuminate\Support\Collection;

class CashlessSalesPointDomainObject extends Generated\CashlessSalesPointDomainObjectAbstract implements IsSortable
{
/** @var Collection<ProductDomainObject>|null */
private ?Collection $products = null;

private ?EventDomainObject $event = null;

private ?float $salesTotal = null;

private ?int $transactionCount = null;

public static function getDefaultSort(): string
{
return self::CREATED_AT;
}

public static function getDefaultSortDirection(): string
{
return 'desc';
}

public static function getAllowedSorts(): AllowedSorts
{
return new AllowedSorts(
[
self::NAME => [
'asc' => __('Name A-Z'),
'desc' => __('Name Z-A'),
],
self::CREATED_AT => [
'desc' => __('Newest first'),
'asc' => __('Oldest first'),
],
]
);
}

public function getProducts(): ?Collection
{
return $this->products;
}

public function setProducts(?Collection $products): static
{
$this->products = $products;

return $this;
}

public function getEvent(): ?EventDomainObject
{
return $this->event;
}

public function setEvent(?EventDomainObject $event): static
{
$this->event = $event;

return $this;
}

public function getSalesTotal(): ?float
{
return $this->salesTotal;
}

public function setSalesTotal(?float $salesTotal): static
{
$this->salesTotal = $salesTotal ?? 0.0;

return $this;
}

public function getTransactionCount(): ?int
{
return $this->transactionCount;
}

public function setTransactionCount(?int $transactionCount): static
{
$this->transactionCount = $transactionCount ?? 0;

return $this;
}

public function isExpired(string $timezone): bool
{
if ($this->getExpiresAt() === null) {
return false;
}

return Carbon::parse($this->getExpiresAt())->setTimezone($timezone)->isPast();
}

public function isActivated(string $timezone): bool
{
if ($this->getActivatesAt() === null) {
return true;
}

return Carbon::parse($this->getActivatesAt())->setTimezone($timezone)->isPast();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace HiEvents\DomainObjects;

class CashlessSalesPointProductDomainObject extends Generated\CashlessSalesPointProductDomainObjectAbstract {}
5 changes: 5 additions & 0 deletions backend/app/DomainObjects/CashlessTopupDomainObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace HiEvents\DomainObjects;

class CashlessTopupDomainObject extends Generated\CashlessTopupDomainObjectAbstract {}
90 changes: 90 additions & 0 deletions backend/app/DomainObjects/CashlessTransactionDomainObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace HiEvents\DomainObjects;

use HiEvents\DomainObjects\Enums\CashlessTransactionType;
use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
use Illuminate\Support\Collection;

class CashlessTransactionDomainObject extends Generated\CashlessTransactionDomainObjectAbstract implements IsSortable
{
/** @var Collection<CashlessTransactionItemDomainObject>|null */
private ?Collection $items = null;

private ?CashlessSalesPointDomainObject $salesPoint = null;

private ?CashlessWalletDomainObject $wallet = null;

public static function getDefaultSort(): string
{
return self::CREATED_AT;
}

public static function getDefaultSortDirection(): string
{
return 'desc';
}

public static function getAllowedSorts(): AllowedSorts
{
return new AllowedSorts(
[
self::CREATED_AT => [
'desc' => __('Newest first'),
'asc' => __('Oldest first'),
],
self::AMOUNT => [
'desc' => __('Largest amount first'),
'asc' => __('Smallest amount first'),
],
]
);
}

public function getItems(): ?Collection
{
return $this->items;
}

public function setItems(?Collection $items): static
{
$this->items = $items;

return $this;
}

public function getSalesPoint(): ?CashlessSalesPointDomainObject
{
return $this->salesPoint;
}

public function setSalesPoint(?CashlessSalesPointDomainObject $salesPoint): static
{
$this->salesPoint = $salesPoint;

return $this;
}

public function getWallet(): ?CashlessWalletDomainObject
{
return $this->wallet;
}

public function setWallet(?CashlessWalletDomainObject $wallet): static
{
$this->wallet = $wallet;

return $this;
}

public function isReversal(): bool
{
return $this->getType() === CashlessTransactionType::REVERSAL->value;
}

public function isClosure(): bool
{
return $this->getType() === CashlessTransactionType::CLOSURE->value;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?php

namespace HiEvents\DomainObjects;

class CashlessTransactionItemDomainObject extends Generated\CashlessTransactionItemDomainObjectAbstract {}
75 changes: 75 additions & 0 deletions backend/app/DomainObjects/CashlessWalletDomainObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

namespace HiEvents\DomainObjects;

use HiEvents\DomainObjects\Interfaces\IsSortable;
use HiEvents\DomainObjects\SortingAndFiltering\AllowedSorts;
use HiEvents\DomainObjects\Status\CashlessWalletStatus;
use Illuminate\Support\Collection;

class CashlessWalletDomainObject extends Generated\CashlessWalletDomainObjectAbstract implements IsSortable
{
private ?AttendeeDomainObject $attendee = null;

/** @var Collection<CashlessTransactionDomainObject>|null */
private ?Collection $transactions = null;

public static function getDefaultSort(): string
{
return self::CREATED_AT;
}

public static function getDefaultSortDirection(): string
{
return 'desc';
}

public static function getAllowedSorts(): AllowedSorts
{
return new AllowedSorts(
[
self::BALANCE => [
'desc' => __('Highest balance first'),
'asc' => __('Lowest balance first'),
],
self::TOTAL_SPENT => [
'desc' => __('Highest spend first'),
'asc' => __('Lowest spend first'),
],
self::CREATED_AT => [
'desc' => __('Newest first'),
'asc' => __('Oldest first'),
],
]
);
}

public function getAttendee(): ?AttendeeDomainObject
{
return $this->attendee;
}

public function setAttendee(?AttendeeDomainObject $attendee): static
{
$this->attendee = $attendee;

return $this;
}

public function getTransactions(): ?Collection
{
return $this->transactions;
}

public function setTransactions(?Collection $transactions): static
{
$this->transactions = $transactions;

return $this;
}

public function isActive(): bool
{
return $this->getStatus() === CashlessWalletStatus::ACTIVE->value;
}
}
13 changes: 13 additions & 0 deletions backend/app/DomainObjects/Enums/CashlessRefundMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace HiEvents\DomainObjects\Enums;

enum CashlessRefundMethod: string
{
use BaseEnum;

case ORIGINAL_PAYMENT = 'ORIGINAL_PAYMENT';
case CASH = 'CASH';
}
14 changes: 14 additions & 0 deletions backend/app/DomainObjects/Enums/CashlessStaffPaymentMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace HiEvents\DomainObjects\Enums;

enum CashlessStaffPaymentMethod: string
{
use BaseEnum;

case CASH = 'CASH';
case CARD_TERMINAL = 'CARD_TERMINAL';
case OTHER = 'OTHER';
}
34 changes: 34 additions & 0 deletions backend/app/DomainObjects/Enums/CashlessTransactionType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace HiEvents\DomainObjects\Enums;

enum CashlessTransactionType: string
{
use BaseEnum;

case TOPUP_ONLINE = 'TOPUP_ONLINE';
case TOPUP_STAFF = 'TOPUP_STAFF';
case PURCHASE = 'PURCHASE';
case REVERSAL = 'REVERSAL';
case REFUND_REMAINING = 'REFUND_REMAINING';
case CLOSURE = 'CLOSURE';

public function isCredit(): bool
{
return in_array($this, [self::TOPUP_ONLINE, self::TOPUP_STAFF], true);
}

public static function getHumanReadableType(string $type): string
{
return match ($type) {
self::TOPUP_ONLINE->value => __('Online top-up'),
self::TOPUP_STAFF->value => __('Staff top-up'),
self::PURCHASE->value => __('Purchase'),
self::REVERSAL->value => __('Reversal'),
self::REFUND_REMAINING->value => __('Balance refund'),
self::CLOSURE->value => __('Balance closure'),
};
}
}
Loading
Loading