diff --git a/CLAUDE.md b/CLAUDE.md index 14d0e4a469..2ac3834d06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` @@ -168,6 +183,9 @@ Gotchas: - Convention: kebab-case `-`, e.g. `promo-code-create-button`, `webhook-submit-button`, `product-edit-menu-item`. For `CustomSelect`, options are auto-derived as `-option-`. - 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` diff --git a/backend/app/DomainObjects/CashlessSalesPointDomainObject.php b/backend/app/DomainObjects/CashlessSalesPointDomainObject.php new file mode 100644 index 0000000000..c146fbd6b3 --- /dev/null +++ b/backend/app/DomainObjects/CashlessSalesPointDomainObject.php @@ -0,0 +1,112 @@ +|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(); + } +} diff --git a/backend/app/DomainObjects/CashlessSalesPointProductDomainObject.php b/backend/app/DomainObjects/CashlessSalesPointProductDomainObject.php new file mode 100644 index 0000000000..6896edb75c --- /dev/null +++ b/backend/app/DomainObjects/CashlessSalesPointProductDomainObject.php @@ -0,0 +1,5 @@ +|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; + } +} diff --git a/backend/app/DomainObjects/CashlessTransactionItemDomainObject.php b/backend/app/DomainObjects/CashlessTransactionItemDomainObject.php new file mode 100644 index 0000000000..44bd004d2c --- /dev/null +++ b/backend/app/DomainObjects/CashlessTransactionItemDomainObject.php @@ -0,0 +1,5 @@ +|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; + } +} diff --git a/backend/app/DomainObjects/Enums/CashlessRefundMethod.php b/backend/app/DomainObjects/Enums/CashlessRefundMethod.php new file mode 100644 index 0000000000..acf3984c39 --- /dev/null +++ b/backend/app/DomainObjects/Enums/CashlessRefundMethod.php @@ -0,0 +1,13 @@ +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'), + }; + } +} diff --git a/backend/app/DomainObjects/Enums/EmailTemplateType.php b/backend/app/DomainObjects/Enums/EmailTemplateType.php index d8b5590843..c3acb0ddb5 100644 --- a/backend/app/DomainObjects/Enums/EmailTemplateType.php +++ b/backend/app/DomainObjects/Enums/EmailTemplateType.php @@ -9,6 +9,7 @@ enum EmailTemplateType: string case ORDER_CONFIRMATION = 'order_confirmation'; case ATTENDEE_TICKET = 'attendee_ticket'; case OCCURRENCE_CANCELLATION = 'occurrence_cancellation'; + case CASHLESS_TOPUP = 'cashless_topup'; public function label(): string { @@ -16,6 +17,7 @@ public function label(): string self::ORDER_CONFIRMATION => __('Order Confirmation'), self::ATTENDEE_TICKET => __('Attendee Ticket'), self::OCCURRENCE_CANCELLATION => __('Date Cancellation'), + self::CASHLESS_TOPUP => __('Cashless Top-up'), }; } @@ -25,6 +27,7 @@ public function description(): string self::ORDER_CONFIRMATION => __('Sent to the customer after placing an order'), self::ATTENDEE_TICKET => __('Sent to each attendee with their ticket'), self::OCCURRENCE_CANCELLATION => __('Sent to attendees when a scheduled date is cancelled'), + self::CASHLESS_TOPUP => __('Sent to attendees when their cashless balance is topped up online'), }; } @@ -34,6 +37,7 @@ public function ctaUrlToken(): string self::ORDER_CONFIRMATION => 'order.url', self::ATTENDEE_TICKET => 'ticket.url', self::OCCURRENCE_CANCELLATION => 'event.url', + self::CASHLESS_TOPUP => 'cashless.url', }; } } diff --git a/backend/app/DomainObjects/Enums/PaymentProviders.php b/backend/app/DomainObjects/Enums/PaymentProviders.php index 8eb53645c9..6441040fc7 100644 --- a/backend/app/DomainObjects/Enums/PaymentProviders.php +++ b/backend/app/DomainObjects/Enums/PaymentProviders.php @@ -8,4 +8,5 @@ enum PaymentProviders: string case STRIPE = 'STRIPE'; case OFFLINE = 'OFFLINE'; + case CASHLESS = 'CASHLESS'; } diff --git a/backend/app/DomainObjects/Enums/ReportTypes.php b/backend/app/DomainObjects/Enums/ReportTypes.php index 4808c61843..a8466c5ac2 100644 --- a/backend/app/DomainObjects/Enums/ReportTypes.php +++ b/backend/app/DomainObjects/Enums/ReportTypes.php @@ -10,4 +10,5 @@ enum ReportTypes: string case DAILY_SALES_REPORT = 'daily_sales_report'; case PROMO_CODES_REPORT = 'promo_codes_report'; case OCCURRENCE_SUMMARY = 'occurrence_summary'; + case CASHLESS_SALES = 'cashless_sales'; } diff --git a/backend/app/DomainObjects/Generated/CashlessSalesPointDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CashlessSalesPointDomainObjectAbstract.php new file mode 100644 index 0000000000..5665e689ef --- /dev/null +++ b/backend/app/DomainObjects/Generated/CashlessSalesPointDomainObjectAbstract.php @@ -0,0 +1,188 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'short_id' => $this->short_id ?? null, + 'name' => $this->name ?? null, + 'description' => $this->description ?? null, + 'access_pin' => $this->access_pin ?? null, + 'activates_at' => $this->activates_at ?? null, + 'expires_at' => $this->expires_at ?? null, + 'allow_staff_topups' => $this->allow_staff_topups ?? null, + 'deleted_at' => $this->deleted_at ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setShortId(string $short_id): self + { + $this->short_id = $short_id; + return $this; + } + + public function getShortId(): string + { + return $this->short_id; + } + + public function setName(string $name): self + { + $this->name = $name; + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setAccessPin(?string $access_pin): self + { + $this->access_pin = $access_pin; + return $this; + } + + public function getAccessPin(): ?string + { + return $this->access_pin; + } + + public function setActivatesAt(?string $activates_at): self + { + $this->activates_at = $activates_at; + return $this; + } + + public function getActivatesAt(): ?string + { + return $this->activates_at; + } + + public function setExpiresAt(?string $expires_at): self + { + $this->expires_at = $expires_at; + return $this; + } + + public function getExpiresAt(): ?string + { + return $this->expires_at; + } + + public function setAllowStaffTopups(bool $allow_staff_topups): self + { + $this->allow_staff_topups = $allow_staff_topups; + return $this; + } + + public function getAllowStaffTopups(): bool + { + return $this->allow_staff_topups; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/CashlessSalesPointProductDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CashlessSalesPointProductDomainObjectAbstract.php new file mode 100644 index 0000000000..12c59e4be5 --- /dev/null +++ b/backend/app/DomainObjects/Generated/CashlessSalesPointProductDomainObjectAbstract.php @@ -0,0 +1,62 @@ + $this->id ?? null, + 'cashless_sales_point_id' => $this->cashless_sales_point_id ?? null, + 'product_id' => $this->product_id ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setCashlessSalesPointId(int $cashless_sales_point_id): self + { + $this->cashless_sales_point_id = $cashless_sales_point_id; + return $this; + } + + public function getCashlessSalesPointId(): int + { + return $this->cashless_sales_point_id; + } + + public function setProductId(int $product_id): self + { + $this->product_id = $product_id; + return $this; + } + + public function getProductId(): int + { + return $this->product_id; + } +} diff --git a/backend/app/DomainObjects/Generated/CashlessTopupDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CashlessTopupDomainObjectAbstract.php new file mode 100644 index 0000000000..8d1afa158a --- /dev/null +++ b/backend/app/DomainObjects/Generated/CashlessTopupDomainObjectAbstract.php @@ -0,0 +1,146 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'cashless_wallet_id' => $this->cashless_wallet_id ?? null, + 'order_id' => $this->order_id ?? null, + 'cashless_transaction_id' => $this->cashless_transaction_id ?? null, + 'amount' => $this->amount ?? null, + 'status' => $this->status ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setCashlessWalletId(int $cashless_wallet_id): self + { + $this->cashless_wallet_id = $cashless_wallet_id; + return $this; + } + + public function getCashlessWalletId(): int + { + return $this->cashless_wallet_id; + } + + public function setOrderId(int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): int + { + return $this->order_id; + } + + public function setCashlessTransactionId(?int $cashless_transaction_id): self + { + $this->cashless_transaction_id = $cashless_transaction_id; + return $this; + } + + public function getCashlessTransactionId(): ?int + { + return $this->cashless_transaction_id; + } + + public function setAmount(float $amount): self + { + $this->amount = $amount; + return $this; + } + + public function getAmount(): float + { + return $this->amount; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/CashlessTransactionDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CashlessTransactionDomainObjectAbstract.php new file mode 100644 index 0000000000..8ad28ddd23 --- /dev/null +++ b/backend/app/DomainObjects/Generated/CashlessTransactionDomainObjectAbstract.php @@ -0,0 +1,244 @@ + $this->id ?? null, + 'cashless_wallet_id' => $this->cashless_wallet_id ?? null, + 'event_id' => $this->event_id ?? null, + 'order_id' => $this->order_id ?? null, + 'cashless_sales_point_id' => $this->cashless_sales_point_id ?? null, + 'created_by_user_id' => $this->created_by_user_id ?? null, + 'reverses_transaction_id' => $this->reverses_transaction_id ?? null, + 'short_id' => $this->short_id ?? null, + 'type' => $this->type ?? null, + 'amount' => $this->amount ?? null, + 'balance_after' => $this->balance_after ?? null, + 'staff_payment_method' => $this->staff_payment_method ?? null, + 'client_reference_id' => $this->client_reference_id ?? null, + 'notes' => $this->notes ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setCashlessWalletId(int $cashless_wallet_id): self + { + $this->cashless_wallet_id = $cashless_wallet_id; + return $this; + } + + public function getCashlessWalletId(): int + { + return $this->cashless_wallet_id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setOrderId(?int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): ?int + { + return $this->order_id; + } + + public function setCashlessSalesPointId(?int $cashless_sales_point_id): self + { + $this->cashless_sales_point_id = $cashless_sales_point_id; + return $this; + } + + public function getCashlessSalesPointId(): ?int + { + return $this->cashless_sales_point_id; + } + + public function setCreatedByUserId(?int $created_by_user_id): self + { + $this->created_by_user_id = $created_by_user_id; + return $this; + } + + public function getCreatedByUserId(): ?int + { + return $this->created_by_user_id; + } + + public function setReversesTransactionId(?int $reverses_transaction_id): self + { + $this->reverses_transaction_id = $reverses_transaction_id; + return $this; + } + + public function getReversesTransactionId(): ?int + { + return $this->reverses_transaction_id; + } + + public function setShortId(string $short_id): self + { + $this->short_id = $short_id; + return $this; + } + + public function getShortId(): string + { + return $this->short_id; + } + + public function setType(string $type): self + { + $this->type = $type; + return $this; + } + + public function getType(): string + { + return $this->type; + } + + public function setAmount(float $amount): self + { + $this->amount = $amount; + return $this; + } + + public function getAmount(): float + { + return $this->amount; + } + + public function setBalanceAfter(float $balance_after): self + { + $this->balance_after = $balance_after; + return $this; + } + + public function getBalanceAfter(): float + { + return $this->balance_after; + } + + public function setStaffPaymentMethod(?string $staff_payment_method): self + { + $this->staff_payment_method = $staff_payment_method; + return $this; + } + + public function getStaffPaymentMethod(): ?string + { + return $this->staff_payment_method; + } + + public function setClientReferenceId(?string $client_reference_id): self + { + $this->client_reference_id = $client_reference_id; + return $this; + } + + public function getClientReferenceId(): ?string + { + return $this->client_reference_id; + } + + public function setNotes(?string $notes): self + { + $this->notes = $notes; + return $this; + } + + public function getNotes(): ?string + { + return $this->notes; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/CashlessTransactionItemDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CashlessTransactionItemDomainObjectAbstract.php new file mode 100644 index 0000000000..7c5e49f80e --- /dev/null +++ b/backend/app/DomainObjects/Generated/CashlessTransactionItemDomainObjectAbstract.php @@ -0,0 +1,160 @@ + $this->id ?? null, + 'cashless_transaction_id' => $this->cashless_transaction_id ?? null, + 'product_id' => $this->product_id ?? null, + 'product_price_id' => $this->product_price_id ?? null, + 'product_title' => $this->product_title ?? null, + 'unit_price' => $this->unit_price ?? null, + 'quantity' => $this->quantity ?? null, + 'total' => $this->total ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setCashlessTransactionId(int $cashless_transaction_id): self + { + $this->cashless_transaction_id = $cashless_transaction_id; + return $this; + } + + public function getCashlessTransactionId(): int + { + return $this->cashless_transaction_id; + } + + public function setProductId(?int $product_id): self + { + $this->product_id = $product_id; + return $this; + } + + public function getProductId(): ?int + { + return $this->product_id; + } + + public function setProductPriceId(?int $product_price_id): self + { + $this->product_price_id = $product_price_id; + return $this; + } + + public function getProductPriceId(): ?int + { + return $this->product_price_id; + } + + public function setProductTitle(string $product_title): self + { + $this->product_title = $product_title; + return $this; + } + + public function getProductTitle(): string + { + return $this->product_title; + } + + public function setUnitPrice(float $unit_price): self + { + $this->unit_price = $unit_price; + return $this; + } + + public function getUnitPrice(): float + { + return $this->unit_price; + } + + public function setQuantity(int $quantity): self + { + $this->quantity = $quantity; + return $this; + } + + public function getQuantity(): int + { + return $this->quantity; + } + + public function setTotal(float $total): self + { + $this->total = $total; + return $this; + } + + public function getTotal(): float + { + return $this->total; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/CashlessWalletDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/CashlessWalletDomainObjectAbstract.php new file mode 100644 index 0000000000..7bc1f4a442 --- /dev/null +++ b/backend/app/DomainObjects/Generated/CashlessWalletDomainObjectAbstract.php @@ -0,0 +1,188 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'attendee_id' => $this->attendee_id ?? null, + 'balance' => $this->balance ?? null, + 'total_topped_up' => $this->total_topped_up ?? null, + 'total_spent' => $this->total_spent ?? null, + 'total_refunded' => $this->total_refunded ?? null, + 'currency' => $this->currency ?? null, + 'status' => $this->status ?? null, + 'deleted_at' => $this->deleted_at ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): int + { + return $this->event_id; + } + + public function setAttendeeId(int $attendee_id): self + { + $this->attendee_id = $attendee_id; + return $this; + } + + public function getAttendeeId(): int + { + return $this->attendee_id; + } + + public function setBalance(float $balance): self + { + $this->balance = $balance; + return $this; + } + + public function getBalance(): float + { + return $this->balance; + } + + public function setTotalToppedUp(float $total_topped_up): self + { + $this->total_topped_up = $total_topped_up; + return $this; + } + + public function getTotalToppedUp(): float + { + return $this->total_topped_up; + } + + public function setTotalSpent(float $total_spent): self + { + $this->total_spent = $total_spent; + return $this; + } + + public function getTotalSpent(): float + { + return $this->total_spent; + } + + public function setTotalRefunded(float $total_refunded): self + { + $this->total_refunded = $total_refunded; + return $this; + } + + public function getTotalRefunded(): float + { + return $this->total_refunded; + } + + public function setCurrency(string $currency): self + { + $this->currency = $currency; + return $this; + } + + public function getCurrency(): string + { + return $this->currency; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index 063047b600..b66879f10f 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -12,6 +12,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const PLURAL_NAME = 'event_settings'; final public const ID = 'id'; final public const EVENT_ID = 'event_id'; + final public const CASHLESS_TOPUP_PRODUCT_ID = 'cashless_topup_product_id'; final public const PRE_CHECKOUT_MESSAGE = 'pre_checkout_message'; final public const POST_CHECKOUT_MESSAGE = 'post_checkout_message'; final public const PRODUCT_PAGE_MESSAGE = 'product_page_message'; @@ -70,9 +71,16 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const SHOW_AVAILABLE_OCCURRENCE_CAPACITY = 'show_available_occurrence_capacity'; final public const HIDE_SOLD_OUT_OCCURRENCES = 'hide_sold_out_occurrences'; final public const GET_TICKETS_BUTTON_TEXT = 'get_tickets_button_text'; + final public const CASHLESS_ENABLED = 'cashless_enabled'; + final public const CASHLESS_MIN_TOPUP_AMOUNT = 'cashless_min_topup_amount'; + final public const CASHLESS_ALLOW_REMAINING_BALANCE_REFUND = 'cashless_allow_remaining_balance_refund'; + final public const CASHLESS_REFUND_DEADLINE_AT = 'cashless_refund_deadline_at'; + final public const CASHLESS_ONLINE_TOPUP_ENABLED = 'cashless_online_topup_enabled'; + final public const CASHLESS_CLOSED_AT = 'cashless_closed_at'; protected int $id; protected int $event_id; + protected ?int $cashless_topup_product_id = null; protected ?string $pre_checkout_message = null; protected ?string $post_checkout_message = null; protected ?string $product_page_message = null; @@ -131,12 +139,19 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected bool $show_available_occurrence_capacity = false; protected bool $hide_sold_out_occurrences = false; protected ?string $get_tickets_button_text = null; + protected bool $cashless_enabled = false; + protected float $cashless_min_topup_amount = 5.0; + protected bool $cashless_allow_remaining_balance_refund = false; + protected ?string $cashless_refund_deadline_at = null; + protected bool $cashless_online_topup_enabled = true; + protected ?string $cashless_closed_at = null; public function toArray(): array { return [ 'id' => $this->id ?? null, 'event_id' => $this->event_id ?? null, + 'cashless_topup_product_id' => $this->cashless_topup_product_id ?? null, 'pre_checkout_message' => $this->pre_checkout_message ?? null, 'post_checkout_message' => $this->post_checkout_message ?? null, 'product_page_message' => $this->product_page_message ?? null, @@ -195,6 +210,12 @@ public function toArray(): array 'show_available_occurrence_capacity' => $this->show_available_occurrence_capacity ?? null, 'hide_sold_out_occurrences' => $this->hide_sold_out_occurrences ?? null, 'get_tickets_button_text' => $this->get_tickets_button_text ?? null, + 'cashless_enabled' => $this->cashless_enabled ?? null, + 'cashless_min_topup_amount' => $this->cashless_min_topup_amount ?? null, + 'cashless_allow_remaining_balance_refund' => $this->cashless_allow_remaining_balance_refund ?? null, + 'cashless_refund_deadline_at' => $this->cashless_refund_deadline_at ?? null, + 'cashless_online_topup_enabled' => $this->cashless_online_topup_enabled ?? null, + 'cashless_closed_at' => $this->cashless_closed_at ?? null, ]; } @@ -220,6 +241,17 @@ public function getEventId(): int return $this->event_id; } + public function setCashlessTopupProductId(?int $cashless_topup_product_id): self + { + $this->cashless_topup_product_id = $cashless_topup_product_id; + return $this; + } + + public function getCashlessTopupProductId(): ?int + { + return $this->cashless_topup_product_id; + } + public function setPreCheckoutMessage(?string $pre_checkout_message): self { $this->pre_checkout_message = $pre_checkout_message; @@ -858,4 +890,70 @@ public function getGetTicketsButtonText(): ?string { return $this->get_tickets_button_text; } + + public function setCashlessEnabled(bool $cashless_enabled): self + { + $this->cashless_enabled = $cashless_enabled; + return $this; + } + + public function getCashlessEnabled(): bool + { + return $this->cashless_enabled; + } + + public function setCashlessMinTopupAmount(float $cashless_min_topup_amount): self + { + $this->cashless_min_topup_amount = $cashless_min_topup_amount; + return $this; + } + + public function getCashlessMinTopupAmount(): float + { + return $this->cashless_min_topup_amount; + } + + public function setCashlessAllowRemainingBalanceRefund(bool $cashless_allow_remaining_balance_refund): self + { + $this->cashless_allow_remaining_balance_refund = $cashless_allow_remaining_balance_refund; + return $this; + } + + public function getCashlessAllowRemainingBalanceRefund(): bool + { + return $this->cashless_allow_remaining_balance_refund; + } + + public function setCashlessRefundDeadlineAt(?string $cashless_refund_deadline_at): self + { + $this->cashless_refund_deadline_at = $cashless_refund_deadline_at; + return $this; + } + + public function getCashlessRefundDeadlineAt(): ?string + { + return $this->cashless_refund_deadline_at; + } + + public function setCashlessOnlineTopupEnabled(bool $cashless_online_topup_enabled): self + { + $this->cashless_online_topup_enabled = $cashless_online_topup_enabled; + return $this; + } + + public function getCashlessOnlineTopupEnabled(): bool + { + return $this->cashless_online_topup_enabled; + } + + public function setCashlessClosedAt(?string $cashless_closed_at): self + { + $this->cashless_closed_at = $cashless_closed_at; + return $this; + } + + public function getCashlessClosedAt(): ?string + { + return $this->cashless_closed_at; + } } diff --git a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php index 1501a9327c..324c9bb95d 100644 --- a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php @@ -39,6 +39,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const WAITLIST_ENABLED = 'waitlist_enabled'; final public const IS_ADDON_ONLY = 'is_addon_only'; final public const SEQUENTIAL_TIER_RELEASE_ENABLED = 'sequential_tier_release_enabled'; + final public const IS_CASHLESS_TOPUP = 'is_cashless_topup'; protected int $id; protected int $event_id; @@ -69,6 +70,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected ?bool $waitlist_enabled = null; protected bool $is_addon_only = false; protected bool $sequential_tier_release_enabled = false; + protected bool $is_cashless_topup = false; public function toArray(): array { @@ -102,6 +104,7 @@ public function toArray(): array 'waitlist_enabled' => $this->waitlist_enabled ?? null, 'is_addon_only' => $this->is_addon_only ?? null, 'sequential_tier_release_enabled' => $this->sequential_tier_release_enabled ?? null, + 'is_cashless_topup' => $this->is_cashless_topup ?? null, ]; } @@ -423,4 +426,15 @@ public function getSequentialTierReleaseEnabled(): bool { return $this->sequential_tier_release_enabled; } + + public function setIsCashlessTopup(bool $is_cashless_topup): self + { + $this->is_cashless_topup = $is_cashless_topup; + return $this; + } + + public function getIsCashlessTopup(): bool + { + return $this->is_cashless_topup; + } } diff --git a/backend/app/DomainObjects/Status/CashlessTopupStatus.php b/backend/app/DomainObjects/Status/CashlessTopupStatus.php new file mode 100644 index 0000000000..f7934d4471 --- /dev/null +++ b/backend/app/DomainObjects/Status/CashlessTopupStatus.php @@ -0,0 +1,16 @@ +transactions = $transactions; + + return $this; + } + + public function collection(): Collection + { + return collect($this->transactions->items()); + } + + public function headings(): array + { + return [ + __('Reference'), + __('Date'), + __('Type'), + __('Amount'), + __('Balance After'), + __('Ticket ID'), + __('Attendee'), + __('Sales Point'), + __('Payment Method'), + __('Items'), + __('Notes'), + ]; + } + + /** + * @param CashlessTransactionDomainObject $transaction + */ + public function map($transaction): array + { + $attendee = $transaction->getWallet()?->getAttendee(); + + return [ + $transaction->getShortId(), + Carbon::parse($transaction->getCreatedAt())->format('Y-m-d H:i:s'), + CashlessTransactionType::getHumanReadableType($transaction->getType()), + $transaction->getAmount(), + $transaction->getBalanceAfter(), + $attendee?->getPublicId(), + $attendee ? trim($attendee->getFirstName().' '.$attendee->getLastName()) : null, + $transaction->getSalesPoint()?->getName(), + $transaction->getStaffPaymentMethod(), + $this->describeItems($transaction), + $transaction->getNotes(), + ]; + } + + public function styles(Worksheet $sheet): array + { + return [ + 1 => ['font' => ['bold' => true]], + ]; + } + + private function describeItems(CashlessTransactionDomainObject $transaction): ?string + { + $items = $transaction->getItems(); + + if ($items === null || $items->isEmpty()) { + return null; + } + + return $items + ->map(fn (CashlessTransactionItemDomainObject $item) => sprintf( + '%d x %s', + $item->getQuantity(), + $item->getProductTitle(), + )) + ->implode(', '); + } +} diff --git a/backend/app/Helper/IdHelper.php b/backend/app/Helper/IdHelper.php index cb7935fb8d..1afa60836d 100644 --- a/backend/app/Helper/IdHelper.php +++ b/backend/app/Helper/IdHelper.php @@ -8,6 +8,8 @@ class IdHelper { public const ATTENDEE_PREFIX = 'a'; + public const ATTENDEE_PREFIX_PUBLIC = 'A-'; + public const ORDER_PREFIX = 'o'; public const EVENT_PREFIX = 'e'; @@ -24,6 +26,10 @@ class IdHelper public const EVENT_LOCATION_PREFIX = 'el'; + public const CASHLESS_SALES_POINT_PREFIX = 'csp'; + + public const CASHLESS_TRANSACTION_PREFIX = 'ctx'; + public static function shortId(string $prefix, int $length = 13): string { return sprintf('%s_%s', $prefix, Str::random($length)); diff --git a/backend/app/Helper/Url.php b/backend/app/Helper/Url.php index 5ebc6d6be8..42faaf4794 100644 --- a/backend/app/Helper/Url.php +++ b/backend/app/Helper/Url.php @@ -30,6 +30,8 @@ class Url public const ACCOUNT_DANGER_ZONE = 'app.frontend_urls.account_danger_zone'; + public const CASHLESS_WALLET = 'app.frontend_urls.cashless_wallet'; + public static function getFrontEndUrlFromConfig(string $key, array $queryParams = []): string { $url = config('app.frontend_url').config($key); diff --git a/backend/app/Http/Actions/Cashless/CloseCashlessAction.php b/backend/app/Http/Actions/Cashless/CloseCashlessAction.php new file mode 100644 index 0000000000..13618d3455 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/CloseCashlessAction.php @@ -0,0 +1,50 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $result = $this->closeCashlessHandler->handle( + eventId: $eventId, + userId: $this->getAuthenticatedUser()->getId(), + ); + } catch (CashlessClosureNotAllowedException|CashlessNotEnabledException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_CONFLICT); + } + + return $this->resourceResponse( + resource: CashlessClosureResultResource::class, + data: $result, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/GetCashlessSettingsAction.php b/backend/app/Http/Actions/Cashless/GetCashlessSettingsAction.php new file mode 100644 index 0000000000..7e15a7222e --- /dev/null +++ b/backend/app/Http/Actions/Cashless/GetCashlessSettingsAction.php @@ -0,0 +1,32 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->resourceResponse( + resource: CashlessSettingsResource::class, + data: $this->getCashlessSettingsHandler->handle($eventId), + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/GetCashlessStatsAction.php b/backend/app/Http/Actions/Cashless/GetCashlessStatsAction.php new file mode 100644 index 0000000000..a663a37385 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/GetCashlessStatsAction.php @@ -0,0 +1,46 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $validated = Validator::make($request->query->all(), [ + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after_or_equal:start_date'], + ])->validate(); + + return $this->resourceResponse( + resource: CashlessDailyStatsResource::class, + data: $this->getCashlessStatsHandler->handle( + eventId: $eventId, + startDate: Carbon::parse($validated['start_date'])->toDateString(), + endDate: Carbon::parse($validated['end_date'])->toDateString(), + ), + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/GetCashlessSummaryAction.php b/backend/app/Http/Actions/Cashless/GetCashlessSummaryAction.php new file mode 100644 index 0000000000..ce43f52884 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/GetCashlessSummaryAction.php @@ -0,0 +1,31 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->resourceResponse( + resource: CashlessSummaryResource::class, + data: $this->getCashlessSummaryHandler->handle($eventId), + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/BaseCashlessSalesPointAction.php b/backend/app/Http/Actions/Cashless/Public/BaseCashlessSalesPointAction.php new file mode 100644 index 0000000000..64ccc023b5 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/BaseCashlessSalesPointAction.php @@ -0,0 +1,18 @@ +header(self::SESSION_HEADER); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/CreateCashlessPurchasePublicAction.php b/backend/app/Http/Actions/Cashless/Public/CreateCashlessPurchasePublicAction.php new file mode 100644 index 0000000000..0171e99a20 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/CreateCashlessPurchasePublicAction.php @@ -0,0 +1,71 @@ +createCashlessPurchasePublicHandler->handle(new CreateCashlessPurchaseDTO( + sales_point_short_id: $salesPointShortId, + attendee_public_id: $request->input('attendee_public_id'), + items: new Collection(array_map( + static fn (array $item) => new CashlessPurchaseItemRequestDTO( + product_id: (int) $item['product_id'], + product_price_id: (int) $item['product_price_id'], + quantity: (int) $item['quantity'], + ), + $request->validated('items'), + )), + client_reference_id: $request->input('client_reference_id'), + locale: $this->localeService->getLocaleOrDefault($request->getPreferredLanguage()), + session_token: $this->getSessionToken($request), + )); + } catch (CashlessSalesPointAccessException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_UNAUTHORIZED); + } catch (InsufficientCashlessBalanceException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_CONFLICT); + } catch (CashlessNotEnabledException|CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_NOT_FOUND); + } + + return $this->resourceResponse( + resource: CashlessTransactionResourcePublic::class, + data: $transaction, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/CreateCashlessSalesPointSessionAction.php b/backend/app/Http/Actions/Cashless/Public/CreateCashlessSalesPointSessionAction.php new file mode 100644 index 0000000000..160bf40aaa --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/CreateCashlessSalesPointSessionAction.php @@ -0,0 +1,44 @@ +salesPointAccessService->authenticate( + shortId: $salesPointShortId, + pin: $request->input('pin'), + ); + } catch (CashlessSalesPointAccessException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_UNAUTHORIZED, + ); + } + + return $this->jsonResponse(['token' => $token], ResponseCodes::HTTP_CREATED); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/CreateCashlessTopupPublicAction.php b/backend/app/Http/Actions/Cashless/Public/CreateCashlessTopupPublicAction.php new file mode 100644 index 0000000000..99ef8e585c --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/CreateCashlessTopupPublicAction.php @@ -0,0 +1,70 @@ +sessionIdentifierService->getSessionId(); + + try { + $order = $this->createCashlessTopupHandler->handle(CreateCashlessTopupDTO::from([ + 'event_id' => $eventId, + 'ticket_reference' => $ticketReference, + 'amount' => (float) $request->input('amount'), + 'session_identifier' => $sessionId, + 'is_user_authenticated' => $this->isUserAuthenticated(), + 'order_locale' => $this->localeService->getLocaleOrDefault($request->getPreferredLanguage()), + ])); + } catch (CashlessNotEnabledException|CashlessWalletUnavailableException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_NOT_FOUND, + ); + } + + $order->setSessionIdentifier($sessionId); + + return $this->resourceResponse( + resource: OrderResourcePublic::class, + data: $order, + statusCode: ResponseCodes::HTTP_CREATED, + )->withCookie( + cookie: $this->sessionIdentifierService->getSessionCookie(), + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/CreateStaffTopupPublicAction.php b/backend/app/Http/Actions/Cashless/Public/CreateStaffTopupPublicAction.php new file mode 100644 index 0000000000..76440a5c42 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/CreateStaffTopupPublicAction.php @@ -0,0 +1,56 @@ +createStaffTopupPublicHandler->handle(new CreateStaffTopupDTO( + sales_point_short_id: $salesPointShortId, + attendee_public_id: $request->input('attendee_public_id'), + amount: (float) $request->input('amount'), + payment_method: CashlessStaffPaymentMethod::from($request->input('payment_method')), + client_reference_id: $request->input('client_reference_id'), + session_token: $this->getSessionToken($request), + notes: $request->input('notes'), + )); + } catch (CashlessSalesPointAccessException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_UNAUTHORIZED); + } catch (CashlessNotEnabledException|CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_NOT_FOUND); + } + + return $this->resourceResponse( + resource: CashlessTransactionResourcePublic::class, + data: $transaction, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/GetCashlessQuotePublicAction.php b/backend/app/Http/Actions/Cashless/Public/GetCashlessQuotePublicAction.php new file mode 100644 index 0000000000..29df363fea --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/GetCashlessQuotePublicAction.php @@ -0,0 +1,58 @@ +input('topup_amount'); + + try { + $quote = $this->getCashlessQuotePublicHandler->handle( + salesPointShortId: $salesPointShortId, + sessionToken: $this->getSessionToken($request), + items: new Collection(array_map( + static fn (array $item) => new CashlessPurchaseItemRequestDTO( + product_id: (int) $item['product_id'], + product_price_id: (int) $item['product_price_id'], + quantity: (int) $item['quantity'], + ), + $request->input('items', []) ?? [], + )), + topupAmount: $topupAmount === null ? null : (float) $topupAmount, + ); + } catch (CashlessSalesPointAccessException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_UNAUTHORIZED); + } catch (CashlessNotEnabledException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_NOT_FOUND); + } + + return $this->resourceResponse( + resource: CashlessQuoteResource::class, + data: $quote, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/GetCashlessSalesPointPublicAction.php b/backend/app/Http/Actions/Cashless/Public/GetCashlessSalesPointPublicAction.php new file mode 100644 index 0000000000..9b79b3a41c --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/GetCashlessSalesPointPublicAction.php @@ -0,0 +1,44 @@ +getCashlessSalesPointPublicHandler->handle( + salesPointShortId: $salesPointShortId, + sessionToken: $this->getSessionToken($request), + ); + } catch (CashlessSalesPointAccessException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_NOT_FOUND, + ); + } + + return $this->resourceResponse( + resource: CashlessSalesPointResourcePublic::class, + data: $salesPoint, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/GetCashlessSalesPointTransactionsPublicAction.php b/backend/app/Http/Actions/Cashless/Public/GetCashlessSalesPointTransactionsPublicAction.php new file mode 100644 index 0000000000..0b3db93bb2 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/GetCashlessSalesPointTransactionsPublicAction.php @@ -0,0 +1,39 @@ +getCashlessSalesPointTransactionsPublicHandler->handle( + salesPointShortId: $salesPointShortId, + sessionToken: $this->getSessionToken($request), + ); + } catch (CashlessSalesPointAccessException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_UNAUTHORIZED); + } + + return $this->resourceResponse( + resource: CashlessTransactionResourcePublic::class, + data: $transactions, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/GetCashlessWalletForSalesPointAction.php b/backend/app/Http/Actions/Cashless/Public/GetCashlessWalletForSalesPointAction.php new file mode 100644 index 0000000000..49df8e43db --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/GetCashlessWalletForSalesPointAction.php @@ -0,0 +1,48 @@ +getCashlessWalletForSalesPointHandler->handle( + salesPointShortId: $salesPointShortId, + attendeePublicId: $attendeePublicId, + sessionToken: $this->getSessionToken($request), + ); + } catch (CashlessSalesPointAccessException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_UNAUTHORIZED); + } catch (CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_NOT_FOUND); + } + + return $this->resourceResponse( + resource: CashlessWalletResourcePublic::class, + data: $wallet, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/GetCashlessWalletPublicAction.php b/backend/app/Http/Actions/Cashless/Public/GetCashlessWalletPublicAction.php new file mode 100644 index 0000000000..1d33ef0122 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/GetCashlessWalletPublicAction.php @@ -0,0 +1,40 @@ +getCashlessWalletPublicHandler->handle($eventId, $ticketReference); + } catch (CashlessNotEnabledException|CashlessWalletUnavailableException $e) { + return $this->errorResponse( + message: $e->getMessage(), + statusCode: ResponseCodes::HTTP_NOT_FOUND, + ); + } + + return $this->resourceResponse( + resource: CashlessWalletResourcePublic::class, + data: $wallet, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Public/ReverseCashlessTransactionPublicAction.php b/backend/app/Http/Actions/Cashless/Public/ReverseCashlessTransactionPublicAction.php new file mode 100644 index 0000000000..6ac687bd1d --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Public/ReverseCashlessTransactionPublicAction.php @@ -0,0 +1,54 @@ +reverseCashlessTransactionPublicHandler->handle( + salesPointShortId: $salesPointShortId, + transactionShortId: $transactionShortId, + sessionToken: $this->getSessionToken($request), + ); + } catch (CashlessSalesPointAccessException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_UNAUTHORIZED); + } catch (CashlessTransactionNotReversibleException|InsufficientCashlessBalanceException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_CONFLICT); + } catch (CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_NOT_FOUND); + } + + return $this->resourceResponse( + resource: CashlessTransactionResourcePublic::class, + data: $reversal, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/SalesPoint/CreateCashlessSalesPointAction.php b/backend/app/Http/Actions/Cashless/SalesPoint/CreateCashlessSalesPointAction.php new file mode 100644 index 0000000000..c6e356adac --- /dev/null +++ b/backend/app/Http/Actions/Cashless/SalesPoint/CreateCashlessSalesPointAction.php @@ -0,0 +1,50 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $salesPoint = $this->createCashlessSalesPointHandler->handle(UpsertCashlessSalesPointDTO::from([ + ...$request->validated(), + 'event_id' => $eventId, + ])); + } catch (UnrecognizedProductIdException $e) { + throw ValidationException::withMessages([ + 'product_ids' => $e->getMessage(), + ]); + } + + return $this->resourceResponse( + resource: CashlessSalesPointResource::class, + data: $salesPoint, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/SalesPoint/DeleteCashlessSalesPointAction.php b/backend/app/Http/Actions/Cashless/SalesPoint/DeleteCashlessSalesPointAction.php new file mode 100644 index 0000000000..e2f07cab47 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/SalesPoint/DeleteCashlessSalesPointAction.php @@ -0,0 +1,26 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->deleteCashlessSalesPointHandler->handle($eventId, $salesPointId); + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/Cashless/SalesPoint/GetCashlessSalesPointAction.php b/backend/app/Http/Actions/Cashless/SalesPoint/GetCashlessSalesPointAction.php new file mode 100644 index 0000000000..b9fde77f05 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/SalesPoint/GetCashlessSalesPointAction.php @@ -0,0 +1,32 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->resourceResponse( + resource: CashlessSalesPointResource::class, + data: $this->getCashlessSalesPointHandler->handle($eventId, $salesPointId), + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/SalesPoint/GetCashlessSalesPointsAction.php b/backend/app/Http/Actions/Cashless/SalesPoint/GetCashlessSalesPointsAction.php new file mode 100644 index 0000000000..6b0cd1f9bb --- /dev/null +++ b/backend/app/Http/Actions/Cashless/SalesPoint/GetCashlessSalesPointsAction.php @@ -0,0 +1,31 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->filterableResourceResponse( + resource: CashlessSalesPointResource::class, + data: $this->getCashlessSalesPointsHandler->handle($eventId, $this->getPaginationQueryParams($request)), + domainObject: CashlessSalesPointDomainObject::class, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/SalesPoint/UpdateCashlessSalesPointAction.php b/backend/app/Http/Actions/Cashless/SalesPoint/UpdateCashlessSalesPointAction.php new file mode 100644 index 0000000000..bcbfed2392 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/SalesPoint/UpdateCashlessSalesPointAction.php @@ -0,0 +1,54 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $salesPoint = $this->updateCashlessSalesPointHandler->handle(UpsertCashlessSalesPointDTO::from([ + ...$request->validated(), + 'event_id' => $eventId, + 'id' => $salesPointId, + ])); + } catch (UnrecognizedProductIdException $e) { + throw ValidationException::withMessages([ + 'product_ids' => $e->getMessage(), + ]); + } + + return $this->resourceResponse( + resource: CashlessSalesPointResource::class, + data: $salesPoint, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/UpdateCashlessSettingsAction.php b/backend/app/Http/Actions/Cashless/UpdateCashlessSettingsAction.php new file mode 100644 index 0000000000..d0b4d4208a --- /dev/null +++ b/backend/app/Http/Actions/Cashless/UpdateCashlessSettingsAction.php @@ -0,0 +1,44 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $settings = $this->updateCashlessSettingsHandler->handle(UpdateCashlessSettingsDTO::from([ + 'event_id' => $eventId, + 'cashless_enabled' => $request->boolean('cashless_enabled'), + 'cashless_min_topup_amount' => (float) $request->input('cashless_min_topup_amount'), + 'cashless_allow_remaining_balance_refund' => $request->boolean('cashless_allow_remaining_balance_refund'), + 'cashless_refund_deadline_at' => $request->input('cashless_refund_deadline_at'), + 'cashless_online_topup_enabled' => $request->boolean('cashless_online_topup_enabled'), + 'cashless_topup_tax_and_fee_ids' => array_map('intval', $request->input('cashless_topup_tax_and_fee_ids', [])), + ])); + + return $this->resourceResponse( + resource: CashlessSettingsResource::class, + data: $settings, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/CreateOrganizerTopupAction.php b/backend/app/Http/Actions/Cashless/Wallet/CreateOrganizerTopupAction.php new file mode 100644 index 0000000000..4d8e0b50f1 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/CreateOrganizerTopupAction.php @@ -0,0 +1,51 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $transaction = $this->createOrganizerTopupHandler->handle( + eventId: $eventId, + walletId: $walletId, + amount: (float) $request->input('amount'), + paymentMethod: CashlessStaffPaymentMethod::from($request->input('payment_method')), + userId: $this->getAuthenticatedUser()->getId(), + notes: $request->input('notes'), + ); + } catch (CashlessNotEnabledException|CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_CONFLICT); + } + + return $this->resourceResponse( + resource: CashlessTransactionResource::class, + data: $transaction, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/ExportCashlessTransactionsAction.php b/backend/app/Http/Actions/Cashless/Wallet/ExportCashlessTransactionsAction.php new file mode 100644 index 0000000000..188d3f7777 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/ExportCashlessTransactionsAction.php @@ -0,0 +1,36 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $transactions = $this->getCashlessTransactionsHandler->handle($eventId, new QueryParamsDTO( + page: 1, + per_page: 10000, + )); + + return Excel::download( + $this->export->withData($transactions), + 'cashless-transactions.xlsx' + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/GetCashlessTransactionsAction.php b/backend/app/Http/Actions/Cashless/Wallet/GetCashlessTransactionsAction.php new file mode 100644 index 0000000000..709866b6e4 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/GetCashlessTransactionsAction.php @@ -0,0 +1,31 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->filterableResourceResponse( + resource: CashlessTransactionResource::class, + data: $this->getCashlessTransactionsHandler->handle($eventId, $this->getPaginationQueryParams($request)), + domainObject: CashlessTransactionDomainObject::class, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/GetCashlessWalletAction.php b/backend/app/Http/Actions/Cashless/Wallet/GetCashlessWalletAction.php new file mode 100644 index 0000000000..43b9f1d333 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/GetCashlessWalletAction.php @@ -0,0 +1,32 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->resourceResponse( + resource: CashlessWalletResource::class, + data: $this->getCashlessWalletHandler->handle($eventId, $walletId), + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/GetCashlessWalletsAction.php b/backend/app/Http/Actions/Cashless/Wallet/GetCashlessWalletsAction.php new file mode 100644 index 0000000000..1414d4dba3 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/GetCashlessWalletsAction.php @@ -0,0 +1,31 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + return $this->filterableResourceResponse( + resource: CashlessWalletResource::class, + data: $this->getCashlessWalletsHandler->handle($eventId, $this->getPaginationQueryParams($request)), + domainObject: CashlessWalletDomainObject::class, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/LookupCashlessWalletAction.php b/backend/app/Http/Actions/Cashless/Wallet/LookupCashlessWalletAction.php new file mode 100644 index 0000000000..6a6f4564fe --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/LookupCashlessWalletAction.php @@ -0,0 +1,47 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $wallet = $this->lookupCashlessWalletHandler->handle( + eventId: $eventId, + attendeePublicId: $request->input('attendee_public_id'), + ); + } catch (CashlessNotEnabledException|CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_NOT_FOUND); + } + + return $this->resourceResponse( + resource: CashlessWalletResource::class, + data: $wallet, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/RefundCashlessWalletAction.php b/backend/app/Http/Actions/Cashless/Wallet/RefundCashlessWalletAction.php new file mode 100644 index 0000000000..0c178140ff --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/RefundCashlessWalletAction.php @@ -0,0 +1,51 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $result = $this->refundCashlessWalletHandler->handle( + eventId: $eventId, + walletId: $walletId, + method: CashlessRefundMethod::from($request->input('method')), + userId: $this->getAuthenticatedUser()->getId(), + ); + } catch (RefundNotPossibleException|CashlessNotEnabledException|CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_CONFLICT); + } + + return $this->resourceResponse( + resource: CashlessRefundResultResource::class, + data: $result, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/ReverseCashlessTransactionAction.php b/backend/app/Http/Actions/Cashless/Wallet/ReverseCashlessTransactionAction.php new file mode 100644 index 0000000000..0f06400161 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/ReverseCashlessTransactionAction.php @@ -0,0 +1,54 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $reversal = $this->reverseCashlessTransactionHandler->handle( + eventId: $eventId, + transactionId: $transactionId, + userId: $this->getAuthenticatedUser()->getId(), + notes: $request->input('notes'), + ); + } catch (CashlessTransactionNotReversibleException|InsufficientCashlessBalanceException|CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_CONFLICT); + } + + return $this->resourceResponse( + resource: CashlessTransactionResource::class, + data: $reversal, + statusCode: ResponseCodes::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Cashless/Wallet/UpdateCashlessWalletStatusAction.php b/backend/app/Http/Actions/Cashless/Wallet/UpdateCashlessWalletStatusAction.php new file mode 100644 index 0000000000..e677935510 --- /dev/null +++ b/backend/app/Http/Actions/Cashless/Wallet/UpdateCashlessWalletStatusAction.php @@ -0,0 +1,49 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $wallet = $this->updateCashlessWalletStatusHandler->handle( + eventId: $eventId, + walletId: $walletId, + status: CashlessWalletStatus::from($request->input('status')), + ); + } catch (CashlessWalletUnavailableException $e) { + return $this->errorResponse($e->getMessage(), ResponseCodes::HTTP_CONFLICT); + } + + return $this->resourceResponse( + resource: CashlessWalletResource::class, + data: $wallet, + ); + } +} diff --git a/backend/app/Http/Request/Cashless/CreateCashlessPurchaseRequest.php b/backend/app/Http/Request/Cashless/CreateCashlessPurchaseRequest.php new file mode 100644 index 0000000000..8a80bdc28c --- /dev/null +++ b/backend/app/Http/Request/Cashless/CreateCashlessPurchaseRequest.php @@ -0,0 +1,22 @@ + ['required', 'string', 'max:64'], + 'client_reference_id' => ['required', 'string', 'max:64'], + 'items' => ['required', 'array', 'min:1', 'max:50'], + 'items.*.product_id' => ['required', 'integer'], + 'items.*.product_price_id' => ['required', 'integer'], + 'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/CreateCashlessQuoteRequest.php b/backend/app/Http/Request/Cashless/CreateCashlessQuoteRequest.php new file mode 100644 index 0000000000..cb9dda0d86 --- /dev/null +++ b/backend/app/Http/Request/Cashless/CreateCashlessQuoteRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'numeric', 'min:0', 'max:10000'], + 'items' => ['nullable', 'array', 'max:50'], + 'items.*.product_id' => ['required', 'integer'], + 'items.*.product_price_id' => ['required', 'integer'], + 'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/CreateCashlessSalesPointSessionRequest.php b/backend/app/Http/Request/Cashless/CreateCashlessSalesPointSessionRequest.php new file mode 100644 index 0000000000..718d359052 --- /dev/null +++ b/backend/app/Http/Request/Cashless/CreateCashlessSalesPointSessionRequest.php @@ -0,0 +1,17 @@ + ['nullable', 'string', 'max:32'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/CreateCashlessTopupRequest.php b/backend/app/Http/Request/Cashless/CreateCashlessTopupRequest.php new file mode 100644 index 0000000000..e1fe9218d7 --- /dev/null +++ b/backend/app/Http/Request/Cashless/CreateCashlessTopupRequest.php @@ -0,0 +1,17 @@ + ['required', 'numeric', 'min:0.01', 'max:10000'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/CreateOrganizerTopupRequest.php b/backend/app/Http/Request/Cashless/CreateOrganizerTopupRequest.php new file mode 100644 index 0000000000..f2ecc6fb30 --- /dev/null +++ b/backend/app/Http/Request/Cashless/CreateOrganizerTopupRequest.php @@ -0,0 +1,21 @@ + ['required', 'numeric', 'min:0.01', 'max:10000'], + 'payment_method' => ['required', Rule::in(CashlessStaffPaymentMethod::valuesArray())], + 'notes' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/CreateStaffTopupRequest.php b/backend/app/Http/Request/Cashless/CreateStaffTopupRequest.php new file mode 100644 index 0000000000..e0d6c45cfe --- /dev/null +++ b/backend/app/Http/Request/Cashless/CreateStaffTopupRequest.php @@ -0,0 +1,23 @@ + ['required', 'string', 'max:64'], + 'client_reference_id' => ['required', 'string', 'max:64'], + 'amount' => ['required', 'numeric', 'min:0.01', 'max:10000'], + 'payment_method' => ['required', Rule::in(CashlessStaffPaymentMethod::valuesArray())], + 'notes' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/LookupCashlessWalletRequest.php b/backend/app/Http/Request/Cashless/LookupCashlessWalletRequest.php new file mode 100644 index 0000000000..455c60a119 --- /dev/null +++ b/backend/app/Http/Request/Cashless/LookupCashlessWalletRequest.php @@ -0,0 +1,17 @@ + ['required', 'string', 'max:64'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/RefundCashlessWalletRequest.php b/backend/app/Http/Request/Cashless/RefundCashlessWalletRequest.php new file mode 100644 index 0000000000..5a591c9643 --- /dev/null +++ b/backend/app/Http/Request/Cashless/RefundCashlessWalletRequest.php @@ -0,0 +1,19 @@ + ['required', Rule::in(CashlessRefundMethod::valuesArray())], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/ReverseCashlessTransactionRequest.php b/backend/app/Http/Request/Cashless/ReverseCashlessTransactionRequest.php new file mode 100644 index 0000000000..e97e62925e --- /dev/null +++ b/backend/app/Http/Request/Cashless/ReverseCashlessTransactionRequest.php @@ -0,0 +1,17 @@ + ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/UpdateCashlessSettingsRequest.php b/backend/app/Http/Request/Cashless/UpdateCashlessSettingsRequest.php new file mode 100644 index 0000000000..656bb180d5 --- /dev/null +++ b/backend/app/Http/Request/Cashless/UpdateCashlessSettingsRequest.php @@ -0,0 +1,23 @@ + ['required', 'boolean'], + 'cashless_min_topup_amount' => ['required', 'numeric', 'min:0.01', 'max:10000'], + 'cashless_allow_remaining_balance_refund' => ['required', 'boolean'], + 'cashless_refund_deadline_at' => ['nullable', 'date'], + 'cashless_online_topup_enabled' => ['required', 'boolean'], + 'cashless_topup_tax_and_fee_ids' => ['present', 'array'], + 'cashless_topup_tax_and_fee_ids.*' => ['required', 'integer'], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/UpdateCashlessWalletStatusRequest.php b/backend/app/Http/Request/Cashless/UpdateCashlessWalletStatusRequest.php new file mode 100644 index 0000000000..fc959340f4 --- /dev/null +++ b/backend/app/Http/Request/Cashless/UpdateCashlessWalletStatusRequest.php @@ -0,0 +1,19 @@ + ['required', Rule::in([CashlessWalletStatus::ACTIVE->value, CashlessWalletStatus::FROZEN->value])], + ]; + } +} diff --git a/backend/app/Http/Request/Cashless/UpsertCashlessSalesPointRequest.php b/backend/app/Http/Request/Cashless/UpsertCashlessSalesPointRequest.php new file mode 100644 index 0000000000..eb17e83c81 --- /dev/null +++ b/backend/app/Http/Request/Cashless/UpsertCashlessSalesPointRequest.php @@ -0,0 +1,24 @@ + ['required', 'string', 'max:100'], + 'description' => ['nullable', 'string', 'max:1000'], + 'product_ids' => ['present', 'array'], + 'product_ids.*' => ['required', 'integer'], + 'allow_staff_topups' => ['required', 'boolean'], + 'access_pin' => ['nullable', 'string', 'min:4', 'max:32'], + 'activates_at' => ['nullable', 'date'], + 'expires_at' => ['nullable', 'date', 'after:activates_at'], + ]; + } +} diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index fcf5d328b8..eee4ac8489 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -51,7 +51,7 @@ public function rules(): array // Payment settings 'payment_providers' => ['array'], - 'payment_providers.*' => ['string', Rule::in(PaymentProviders::valuesArray())], + 'payment_providers.*' => ['string', Rule::in([PaymentProviders::STRIPE->value, PaymentProviders::OFFLINE->value])], 'offline_payment_instructions' => ['string', 'nullable', Rule::requiredIf(fn () => in_array(PaymentProviders::OFFLINE->name, $this->input('payment_providers', []), true))], 'allow_orders_awaiting_offline_payment_to_check_in' => ['boolean'], diff --git a/backend/app/Jobs/Cashless/CreditCashlessWalletJob.php b/backend/app/Jobs/Cashless/CreditCashlessWalletJob.php new file mode 100644 index 0000000000..951926e746 --- /dev/null +++ b/backend/app/Jobs/Cashless/CreditCashlessWalletJob.php @@ -0,0 +1,29 @@ +handle($this->order); + } +} diff --git a/backend/app/Listeners/Order/CreditCashlessWalletOnOrderCompletedListener.php b/backend/app/Listeners/Order/CreditCashlessWalletOnOrderCompletedListener.php new file mode 100644 index 0000000000..fe18c21651 --- /dev/null +++ b/backend/app/Listeners/Order/CreditCashlessWalletOnOrderCompletedListener.php @@ -0,0 +1,19 @@ +order->getStatus() !== OrderStatus::COMPLETED->name) { + return; + } + + dispatch(new CreditCashlessWalletJob($event->order)); + } +} diff --git a/backend/app/Mail/Attendee/AttendeeTicketMail.php b/backend/app/Mail/Attendee/AttendeeTicketMail.php index d36edcfd75..2e868fba0f 100644 --- a/backend/app/Mail/Attendee/AttendeeTicketMail.php +++ b/backend/app/Mail/Attendee/AttendeeTicketMail.php @@ -89,6 +89,13 @@ public function content(): Content $this->event->getId(), $this->attendee->getShortId(), ), + 'cashlessWalletUrl' => $this->eventSettings->getCashlessEnabled() + ? sprintf( + Url::getFrontEndUrlFromConfig(Url::CASHLESS_WALLET), + $this->event->getId(), + $this->attendee->getShortId(), + ) + : null, ] ); } diff --git a/backend/app/Mail/Cashless/CashlessTopupConfirmationMail.php b/backend/app/Mail/Cashless/CashlessTopupConfirmationMail.php new file mode 100644 index 0000000000..1178c8f48b --- /dev/null +++ b/backend/app/Mail/Cashless/CashlessTopupConfirmationMail.php @@ -0,0 +1,71 @@ +renderedTemplate?->subject ?? __('💳 Your cashless balance for :event', [ + 'event' => Str::limit($this->event->getTitle(), 50), + ]); + + return new Envelope( + replyTo: $this->eventSettings->getSupportEmail(), + subject: $subject, + ); + } + + public function content(): Content + { + if ($this->renderedTemplate) { + return new Content( + markdown: 'emails.custom-template', + with: [ + 'renderedBody' => $this->renderedTemplate->body, + 'renderedCta' => $this->renderedTemplate->cta, + 'eventSettings' => $this->eventSettings, + ] + ); + } + + return new Content( + markdown: 'emails.cashless.topup-confirmation', + with: [ + 'event' => $this->event, + 'eventSettings' => $this->eventSettings, + 'organizer' => $this->organizer, + 'attendee' => $this->attendee, + 'toppedUpAmount' => $this->toppedUpAmount, + 'newBalance' => $this->newBalance, + 'walletUrl' => $this->walletUrl, + ] + ); + } +} diff --git a/backend/app/Models/CashlessSalesPoint.php b/backend/app/Models/CashlessSalesPoint.php new file mode 100644 index 0000000000..8071a604fd --- /dev/null +++ b/backend/app/Models/CashlessSalesPoint.php @@ -0,0 +1,27 @@ +belongsToMany( + related: Product::class, + table: 'cashless_sales_point_products', + ); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/backend/app/Models/CashlessTopup.php b/backend/app/Models/CashlessTopup.php new file mode 100644 index 0000000000..ba0591899e --- /dev/null +++ b/backend/app/Models/CashlessTopup.php @@ -0,0 +1,27 @@ +belongsTo(CashlessWallet::class, 'cashless_wallet_id'); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + protected function getCastMap(): array + { + return [ + 'amount' => 'float', + ]; + } +} diff --git a/backend/app/Models/CashlessTransaction.php b/backend/app/Models/CashlessTransaction.php new file mode 100644 index 0000000000..6e30b48f92 --- /dev/null +++ b/backend/app/Models/CashlessTransaction.php @@ -0,0 +1,39 @@ +belongsTo(CashlessWallet::class, 'cashless_wallet_id'); + } + + public function sales_point(): BelongsTo + { + return $this->belongsTo(CashlessSalesPoint::class, 'cashless_sales_point_id'); + } + + public function items(): HasMany + { + return $this->hasMany(CashlessTransactionItem::class); + } + + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + protected function getCastMap(): array + { + return [ + 'amount' => 'float', + 'balance_after' => 'float', + ]; + } +} diff --git a/backend/app/Models/CashlessTransactionItem.php b/backend/app/Models/CashlessTransactionItem.php new file mode 100644 index 0000000000..416266f58f --- /dev/null +++ b/backend/app/Models/CashlessTransactionItem.php @@ -0,0 +1,28 @@ +belongsTo(CashlessTransaction::class, 'cashless_transaction_id'); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + protected function getCastMap(): array + { + return [ + 'unit_price' => 'float', + 'total' => 'float', + ]; + } +} diff --git a/backend/app/Models/CashlessWallet.php b/backend/app/Models/CashlessWallet.php new file mode 100644 index 0000000000..aae06ab48b --- /dev/null +++ b/backend/app/Models/CashlessWallet.php @@ -0,0 +1,39 @@ +belongsTo(Event::class); + } + + public function attendee(): BelongsTo + { + return $this->belongsTo(Attendee::class); + } + + public function transactions(): HasMany + { + return $this->hasMany(CashlessTransaction::class); + } + + protected function getCastMap(): array + { + return [ + 'balance' => 'float', + 'total_topped_up' => 'float', + 'total_spent' => 'float', + 'total_refunded' => 'float', + ]; + } +} diff --git a/backend/app/Models/EventSetting.php b/backend/app/Models/EventSetting.php index b29d1af5f2..3a95c023ab 100644 --- a/backend/app/Models/EventSetting.php +++ b/backend/app/Models/EventSetting.php @@ -14,6 +14,7 @@ protected function getCastMap(): array 'payment_providers' => 'array', 'ticket_design_settings' => 'array', 'homepage_theme_settings' => 'array', + 'cashless_min_topup_amount' => 'float', ]; } } diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index ef370a80dc..4105a476e5 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -16,6 +16,11 @@ use HiEvents\Repository\Eloquent\AttendeeCheckInRepository; use HiEvents\Repository\Eloquent\AttendeeRepository; use HiEvents\Repository\Eloquent\CapacityAssignmentRepository; +use HiEvents\Repository\Eloquent\CashlessSalesPointRepository; +use HiEvents\Repository\Eloquent\CashlessTopupRepository; +use HiEvents\Repository\Eloquent\CashlessTransactionItemRepository; +use HiEvents\Repository\Eloquent\CashlessTransactionRepository; +use HiEvents\Repository\Eloquent\CashlessWalletRepository; use HiEvents\Repository\Eloquent\CheckInListRepository; use HiEvents\Repository\Eloquent\EmailTemplateRepository; use HiEvents\Repository\Eloquent\EventDailyStatisticRepository; @@ -75,6 +80,11 @@ use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; +use HiEvents\Repository\Interfaces\CashlessSalesPointRepositoryInterface; +use HiEvents\Repository\Interfaces\CashlessTopupRepositoryInterface; +use HiEvents\Repository\Interfaces\CashlessTransactionItemRepositoryInterface; +use HiEvents\Repository\Interfaces\CashlessTransactionRepositoryInterface; +use HiEvents\Repository\Interfaces\CashlessWalletRepositoryInterface; use HiEvents\Repository\Interfaces\CheckInListRepositoryInterface; use HiEvents\Repository\Interfaces\EmailTemplateRepositoryInterface; use HiEvents\Repository\Interfaces\EventDailyStatisticRepositoryInterface; @@ -159,6 +169,11 @@ class RepositoryServiceProvider extends ServiceProvider CapacityAssignmentRepositoryInterface::class => CapacityAssignmentRepository::class, StripeCustomerRepositoryInterface::class => StripeCustomerRepository::class, CheckInListRepositoryInterface::class => CheckInListRepository::class, + CashlessWalletRepositoryInterface::class => CashlessWalletRepository::class, + CashlessTransactionRepositoryInterface::class => CashlessTransactionRepository::class, + CashlessTransactionItemRepositoryInterface::class => CashlessTransactionItemRepository::class, + CashlessSalesPointRepositoryInterface::class => CashlessSalesPointRepository::class, + CashlessTopupRepositoryInterface::class => CashlessTopupRepository::class, AttendeeCheckInRepositoryInterface::class => AttendeeCheckInRepository::class, ProductCategoryRepositoryInterface::class => ProductCategoryRepository::class, InvoiceRepositoryInterface::class => InvoiceRepository::class, diff --git a/backend/app/Repository/DTO/CashlessSalesPointStatsDTO.php b/backend/app/Repository/DTO/CashlessSalesPointStatsDTO.php new file mode 100644 index 0000000000..1780bbe99d --- /dev/null +++ b/backend/app/Repository/DTO/CashlessSalesPointStatsDTO.php @@ -0,0 +1,14 @@ + + */ +class CashlessSalesPointRepository extends BaseRepository implements CashlessSalesPointRepositoryInterface +{ + protected function getModel(): string + { + return CashlessSalesPoint::class; + } + + public function getDomainObject(): string + { + return CashlessSalesPointDomainObject::class; + } + + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator + { + $where = [ + [CashlessSalesPointDomainObjectAbstract::EVENT_ID, '=', $eventId], + ]; + + if (! empty($params->query)) { + $where[] = static function (Builder $builder) use ($params) { + $builder->where(CashlessSalesPointDomainObjectAbstract::NAME, 'ilike', '%'.$params->query.'%'); + }; + } + + $this->model = $this->model->orderBy( + column: $this->validateSortColumn($params->sort_by, CashlessSalesPointDomainObject::class), + direction: $this->validateSortDirection($params->sort_direction, CashlessSalesPointDomainObject::class), + ); + + return $this->paginateWhere( + where: $where, + limit: $params->per_page, + page: $params->page, + ); + } + + public function syncProducts(int $salesPointId, array $productIds): void + { + $this->runQuery(function () use ($salesPointId, $productIds) { + CashlessSalesPoint::find($salesPointId)?->products()->sync(array_unique($productIds)); + }); + } + + public function getStatsByIds(array $salesPointIds): Collection + { + if ($salesPointIds === []) { + return collect(); + } + + $placeholders = implode(',', array_fill(0, count($salesPointIds), '?')); + + $sql = <<value; + + $rows = $this->db->select($sql, [ + $purchase, + ...$salesPointIds, + $purchase, + CashlessTransactionType::REVERSAL->value, + $purchase, + ]); + + return collect($rows)->map( + static fn ($row) => new CashlessSalesPointStatsDTO( + salesPointId: (int) $row->cashless_sales_point_id, + salesTotal: (float) $row->sales_total, + transactionCount: (int) $row->transaction_count, + ) + ); + } +} diff --git a/backend/app/Repository/Eloquent/CashlessTopupRepository.php b/backend/app/Repository/Eloquent/CashlessTopupRepository.php new file mode 100644 index 0000000000..723dd93c6b --- /dev/null +++ b/backend/app/Repository/Eloquent/CashlessTopupRepository.php @@ -0,0 +1,25 @@ + + */ +class CashlessTopupRepository extends BaseRepository implements CashlessTopupRepositoryInterface +{ + protected function getModel(): string + { + return CashlessTopup::class; + } + + public function getDomainObject(): string + { + return CashlessTopupDomainObject::class; + } +} diff --git a/backend/app/Repository/Eloquent/CashlessTransactionItemRepository.php b/backend/app/Repository/Eloquent/CashlessTransactionItemRepository.php new file mode 100644 index 0000000000..9140300475 --- /dev/null +++ b/backend/app/Repository/Eloquent/CashlessTransactionItemRepository.php @@ -0,0 +1,25 @@ + + */ +class CashlessTransactionItemRepository extends BaseRepository implements CashlessTransactionItemRepositoryInterface +{ + protected function getModel(): string + { + return CashlessTransactionItem::class; + } + + public function getDomainObject(): string + { + return CashlessTransactionItemDomainObject::class; + } +} diff --git a/backend/app/Repository/Eloquent/CashlessTransactionRepository.php b/backend/app/Repository/Eloquent/CashlessTransactionRepository.php new file mode 100644 index 0000000000..f8d67ba415 --- /dev/null +++ b/backend/app/Repository/Eloquent/CashlessTransactionRepository.php @@ -0,0 +1,104 @@ + + */ +class CashlessTransactionRepository extends BaseRepository implements CashlessTransactionRepositoryInterface +{ + protected function getModel(): string + { + return CashlessTransaction::class; + } + + public function getDomainObject(): string + { + return CashlessTransactionDomainObject::class; + } + + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator + { + $where = [ + [CashlessTransactionDomainObjectAbstract::EVENT_ID, '=', $eventId], + ]; + + if (! empty($params->query)) { + $where[] = static function (Builder $builder) use ($params) { + $builder->whereHas('wallet.attendee', static function (Builder $attendeeBuilder) use ($params) { + $attendeeBuilder + ->where(AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere(AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere(AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%') + ->orWhere(AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%'); + }); + }; + } + + $this->applyFilterFields($params, [ + CashlessTransactionDomainObjectAbstract::TYPE, + CashlessTransactionDomainObjectAbstract::CASHLESS_SALES_POINT_ID, + CashlessTransactionDomainObjectAbstract::CASHLESS_WALLET_ID, + ]); + + $this->model = $this->model->orderBy( + column: $this->validateSortColumn($params->sort_by, CashlessTransactionDomainObject::class), + direction: $this->validateSortDirection($params->sort_direction, CashlessTransactionDomainObject::class), + ); + + return $this->paginateWhere( + where: $where, + limit: $params->per_page, + page: $params->page, + ); + } + + public function findByWalletId(int $walletId, int $limit): Collection + { + return $this->runQuery(fn () => $this->handleResults( + $this->model + ->where(CashlessTransactionDomainObjectAbstract::CASHLESS_WALLET_ID, $walletId) + ->orderByDesc(CashlessTransactionDomainObjectAbstract::CREATED_AT) + ->limit($limit) + ->get() + )); + } + + public function findBySalesPointId(int $salesPointId, int $limit): Collection + { + return $this->runQuery(fn () => $this->handleResults( + $this->model + ->where(CashlessTransactionDomainObjectAbstract::CASHLESS_SALES_POINT_ID, $salesPointId) + ->orderByDesc(CashlessTransactionDomainObjectAbstract::ID) + ->limit($limit) + ->get() + )); + } + + public function findCreditingTopupsForRefund(int $walletId): Collection + { + return $this->runQuery(fn () => $this->handleResults( + $this->model + ->where(CashlessTransactionDomainObjectAbstract::CASHLESS_WALLET_ID, $walletId) + ->whereIn(CashlessTransactionDomainObjectAbstract::TYPE, [ + CashlessTransactionType::TOPUP_ONLINE->value, + CashlessTransactionType::TOPUP_STAFF->value, + ]) + ->orderByDesc(CashlessTransactionDomainObjectAbstract::CREATED_AT) + ->get() + )); + } +} diff --git a/backend/app/Repository/Eloquent/CashlessWalletRepository.php b/backend/app/Repository/Eloquent/CashlessWalletRepository.php new file mode 100644 index 0000000000..60fe6ceace --- /dev/null +++ b/backend/app/Repository/Eloquent/CashlessWalletRepository.php @@ -0,0 +1,67 @@ + + */ +class CashlessWalletRepository extends BaseRepository implements CashlessWalletRepositoryInterface +{ + protected function getModel(): string + { + return CashlessWallet::class; + } + + public function getDomainObject(): string + { + return CashlessWalletDomainObject::class; + } + + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator + { + $where = [ + [CashlessWalletDomainObjectAbstract::EVENT_ID, '=', $eventId], + ]; + + if (! empty($params->query)) { + $where[] = static function (Builder $builder) use ($params) { + $builder->whereHas('attendee', static function (Builder $attendeeBuilder) use ($params) { + $attendeeBuilder + ->where(AttendeeDomainObjectAbstract::FIRST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere(AttendeeDomainObjectAbstract::LAST_NAME, 'ilike', '%'.$params->query.'%') + ->orWhere(AttendeeDomainObjectAbstract::EMAIL, 'ilike', '%'.$params->query.'%') + ->orWhere(AttendeeDomainObjectAbstract::PUBLIC_ID, 'ilike', '%'.$params->query.'%'); + }); + }; + } + + $this->model = $this->model->orderBy( + column: $this->validateSortColumn($params->sort_by, CashlessWalletDomainObject::class), + direction: $this->validateSortDirection($params->sort_direction, CashlessWalletDomainObject::class), + ); + + return $this->paginateWhere( + where: $where, + limit: $params->per_page, + page: $params->page, + ); + } + + public function lockById(int $walletId): ?CashlessWalletDomainObject + { + return $this->runQuery(fn () => $this->handleSingleResult( + $this->model->where('id', $walletId)->lockForUpdate()->first() + )); + } +} diff --git a/backend/app/Repository/Eloquent/ProductRepository.php b/backend/app/Repository/Eloquent/ProductRepository.php index 0403e5362f..40a93decdb 100644 --- a/backend/app/Repository/Eloquent/ProductRepository.php +++ b/backend/app/Repository/Eloquent/ProductRepository.php @@ -30,6 +30,7 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware { $where = [ [ProductDomainObjectAbstract::EVENT_ID, '=', $eventId], + [ProductDomainObjectAbstract::IS_CASHLESS_TOPUP, '=', false], ]; if (! empty($params->query)) { diff --git a/backend/app/Repository/Interfaces/CashlessSalesPointRepositoryInterface.php b/backend/app/Repository/Interfaces/CashlessSalesPointRepositoryInterface.php new file mode 100644 index 0000000000..6cc588387a --- /dev/null +++ b/backend/app/Repository/Interfaces/CashlessSalesPointRepositoryInterface.php @@ -0,0 +1,25 @@ + + */ +interface CashlessSalesPointRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; + + public function syncProducts(int $salesPointId, array $productIds): void; + + /** + * @param array $salesPointIds + * @return Collection + */ + public function getStatsByIds(array $salesPointIds): Collection; +} diff --git a/backend/app/Repository/Interfaces/CashlessTopupRepositoryInterface.php b/backend/app/Repository/Interfaces/CashlessTopupRepositoryInterface.php new file mode 100644 index 0000000000..816c587276 --- /dev/null +++ b/backend/app/Repository/Interfaces/CashlessTopupRepositoryInterface.php @@ -0,0 +1,10 @@ + + */ +interface CashlessTopupRepositoryInterface extends RepositoryInterface {} diff --git a/backend/app/Repository/Interfaces/CashlessTransactionItemRepositoryInterface.php b/backend/app/Repository/Interfaces/CashlessTransactionItemRepositoryInterface.php new file mode 100644 index 0000000000..d242a3ee94 --- /dev/null +++ b/backend/app/Repository/Interfaces/CashlessTransactionItemRepositoryInterface.php @@ -0,0 +1,10 @@ + + */ +interface CashlessTransactionItemRepositoryInterface extends RepositoryInterface {} diff --git a/backend/app/Repository/Interfaces/CashlessTransactionRepositoryInterface.php b/backend/app/Repository/Interfaces/CashlessTransactionRepositoryInterface.php new file mode 100644 index 0000000000..886c0dc666 --- /dev/null +++ b/backend/app/Repository/Interfaces/CashlessTransactionRepositoryInterface.php @@ -0,0 +1,31 @@ + + */ +interface CashlessTransactionRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; + + /** + * @return Collection + */ + public function findByWalletId(int $walletId, int $limit): Collection; + + /** + * @return Collection + */ + public function findBySalesPointId(int $salesPointId, int $limit): Collection; + + /** + * @return Collection + */ + public function findCreditingTopupsForRefund(int $walletId): Collection; +} diff --git a/backend/app/Repository/Interfaces/CashlessWalletRepositoryInterface.php b/backend/app/Repository/Interfaces/CashlessWalletRepositoryInterface.php new file mode 100644 index 0000000000..ee7199ea71 --- /dev/null +++ b/backend/app/Repository/Interfaces/CashlessWalletRepositoryInterface.php @@ -0,0 +1,17 @@ + + */ +interface CashlessWalletRepositoryInterface extends RepositoryInterface +{ + public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; + + public function lockById(int $walletId): ?CashlessWalletDomainObject; +} diff --git a/backend/app/Resources/Cashless/CashlessClosureResultResource.php b/backend/app/Resources/Cashless/CashlessClosureResultResource.php new file mode 100644 index 0000000000..324b8bd53c --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessClosureResultResource.php @@ -0,0 +1,23 @@ + $this->wallets_closed, + 'amount_closed' => $this->amount_closed, + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessDailyStatsResource.php b/backend/app/Resources/Cashless/CashlessDailyStatsResource.php new file mode 100644 index 0000000000..b59e90d779 --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessDailyStatsResource.php @@ -0,0 +1,25 @@ + $this->date, + 'topped_up' => $this->topped_up, + 'spent' => $this->spent, + 'refunded' => $this->refunded, + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessQuoteResource.php b/backend/app/Resources/Cashless/CashlessQuoteResource.php new file mode 100644 index 0000000000..d0e53a1774 --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessQuoteResource.php @@ -0,0 +1,25 @@ + $this->subtotal, + 'fees' => $this->fees, + 'taxes' => $this->taxes, + 'total' => $this->total, + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessRefundResultResource.php b/backend/app/Resources/Cashless/CashlessRefundResultResource.php new file mode 100644 index 0000000000..87c6041fc0 --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessRefundResultResource.php @@ -0,0 +1,23 @@ + $this->refunded_amount, + 'unrefundable_amount' => $this->unrefundable_amount, + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessSalesPointResource.php b/backend/app/Resources/Cashless/CashlessSalesPointResource.php new file mode 100644 index 0000000000..ca033162fa --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessSalesPointResource.php @@ -0,0 +1,41 @@ + $this->getId(), + 'event_id' => $this->getEventId(), + 'short_id' => $this->getShortId(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'has_access_pin' => $this->getAccessPin() !== null, + 'allow_staff_topups' => $this->getAllowStaffTopups(), + 'activates_at' => $this->getActivatesAt(), + 'expires_at' => $this->getExpiresAt(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + $this->mergeWhen($this->getSalesTotal() !== null, fn () => [ + 'sales_total' => $this->getSalesTotal(), + 'transaction_count' => $this->getTransactionCount(), + ]), + 'products' => $this->when( + $this->getProducts() !== null, + fn () => ProductResource::collection($this->getProducts()), + ), + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessSalesPointResourcePublic.php b/backend/app/Resources/Cashless/CashlessSalesPointResourcePublic.php new file mode 100644 index 0000000000..1bb4835f6a --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessSalesPointResourcePublic.php @@ -0,0 +1,33 @@ + $this->getShortId(), + 'name' => $this->getName(), + 'description' => $this->getDescription(), + 'requires_pin' => $this->getAccessPin() !== null, + 'allow_staff_topups' => $this->getAllowStaffTopups(), + 'currency' => $this->getEvent()?->getCurrency(), + 'event_title' => $this->getEvent()?->getTitle(), + 'products' => $this->when( + $this->getProducts() !== null, + fn () => ProductResourcePublic::collection($this->getProducts()), + ), + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessSettingsResource.php b/backend/app/Resources/Cashless/CashlessSettingsResource.php new file mode 100644 index 0000000000..b8705fda31 --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessSettingsResource.php @@ -0,0 +1,30 @@ + $this->event_id, + 'cashless_enabled' => $this->cashless_enabled, + 'cashless_topup_product_id' => $this->cashless_topup_product_id, + 'cashless_min_topup_amount' => $this->cashless_min_topup_amount, + 'cashless_allow_remaining_balance_refund' => $this->cashless_allow_remaining_balance_refund, + 'cashless_refund_deadline_at' => $this->cashless_refund_deadline_at, + 'cashless_online_topup_enabled' => $this->cashless_online_topup_enabled, + 'cashless_closed_at' => $this->cashless_closed_at, + 'cashless_topup_tax_and_fee_ids' => $this->cashless_topup_tax_and_fee_ids, + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessSummaryResource.php b/backend/app/Resources/Cashless/CashlessSummaryResource.php new file mode 100644 index 0000000000..437bda2f9a --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessSummaryResource.php @@ -0,0 +1,45 @@ + $this->wallets_total, + 'wallets_with_balance' => $this->wallets_with_balance, + 'wallets_frozen' => $this->wallets_frozen, + 'wallets_closed' => $this->wallets_closed, + 'outstanding_balance' => $this->outstanding_balance, + 'topped_up_online' => $this->topped_up_online, + 'topped_up_staff' => $this->topped_up_staff, + 'spent' => $this->spent, + 'refunded' => $this->refunded, + 'closed' => $this->closed, + 'purchases_count' => $this->purchases_count, + 'sales_points' => array_map(static fn (CashlessSalesPointSummaryDTO $salesPoint) => [ + 'name' => $salesPoint->name, + 'spent' => $salesPoint->spent, + 'purchases_count' => $salesPoint->purchases_count, + 'topped_up' => $salesPoint->topped_up, + ], $this->sales_points), + 'top_products' => array_map(static fn (CashlessProductSummaryDTO $product) => [ + 'title' => $product->title, + 'quantity' => $product->quantity, + 'total' => $product->total, + ], $this->top_products), + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessTransactionItemResource.php b/backend/app/Resources/Cashless/CashlessTransactionItemResource.php new file mode 100644 index 0000000000..07ed9dd29b --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessTransactionItemResource.php @@ -0,0 +1,26 @@ + $this->getProductId(), + 'product_title' => $this->getProductTitle(), + 'unit_price' => $this->getUnitPrice(), + 'quantity' => $this->getQuantity(), + 'total' => $this->getTotal(), + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessTransactionResource.php b/backend/app/Resources/Cashless/CashlessTransactionResource.php new file mode 100644 index 0000000000..ba7bde5155 --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessTransactionResource.php @@ -0,0 +1,48 @@ + $this->getId(), + 'short_id' => $this->getShortId(), + 'cashless_wallet_id' => $this->getCashlessWalletId(), + /** @var 'TOPUP_ONLINE'|'TOPUP_STAFF'|'PURCHASE'|'REVERSAL'|'REFUND_REMAINING' */ + 'type' => $this->getType(), + 'amount' => $this->getAmount(), + 'balance_after' => $this->getBalanceAfter(), + 'order_id' => $this->getOrderId(), + 'cashless_sales_point_id' => $this->getCashlessSalesPointId(), + /** @var 'CASH'|'CARD_TERMINAL'|'OTHER'|null */ + 'staff_payment_method' => $this->getStaffPaymentMethod(), + 'reverses_transaction_id' => $this->getReversesTransactionId(), + 'notes' => $this->getNotes(), + 'created_at' => $this->getCreatedAt(), + 'items' => $this->when( + $this->getItems() !== null, + fn () => CashlessTransactionItemResource::collection($this->getItems()), + ), + $this->mergeWhen($this->getSalesPoint() !== null, fn () => [ + 'sales_point_name' => $this->getSalesPoint()->getName(), + ]), + $this->mergeWhen($this->getWallet()?->getAttendee() !== null, fn () => [ + 'attendee_public_id' => $this->getWallet()->getAttendee()->getPublicId(), + 'attendee_name' => trim( + $this->getWallet()->getAttendee()->getFirstName().' '.$this->getWallet()->getAttendee()->getLastName() + ), + ]), + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessTransactionResourcePublic.php b/backend/app/Resources/Cashless/CashlessTransactionResourcePublic.php new file mode 100644 index 0000000000..44745a21a1 --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessTransactionResourcePublic.php @@ -0,0 +1,38 @@ + $this->getId(), + 'short_id' => $this->getShortId(), + /** @var 'TOPUP_ONLINE'|'TOPUP_STAFF'|'PURCHASE'|'REVERSAL'|'REFUND_REMAINING' */ + 'type' => $this->getType(), + 'amount' => $this->getAmount(), + 'balance_after' => $this->getBalanceAfter(), + 'reverses_transaction_id' => $this->getReversesTransactionId(), + 'created_at' => $this->getCreatedAt(), + $this->mergeWhen($this->getItems() !== null, fn () => [ + 'items' => CashlessTransactionItemResource::collection($this->getItems()), + ]), + $this->mergeWhen($this->getWallet()?->getAttendee() !== null, fn () => [ + 'attendee_public_id' => $this->getWallet()->getAttendee()->getPublicId(), + ]), + $this->mergeWhen($this->getSalesPoint() !== null, fn () => [ + 'sales_point_name' => $this->getSalesPoint()->getName(), + ]), + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessWalletResource.php b/backend/app/Resources/Cashless/CashlessWalletResource.php new file mode 100644 index 0000000000..a8fcaf1c3a --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessWalletResource.php @@ -0,0 +1,43 @@ + $this->getId(), + 'event_id' => $this->getEventId(), + 'attendee_id' => $this->getAttendeeId(), + 'balance' => $this->getBalance(), + 'total_topped_up' => $this->getTotalToppedUp(), + 'total_spent' => $this->getTotalSpent(), + 'total_refunded' => $this->getTotalRefunded(), + 'currency' => $this->getCurrency(), + /** @var 'ACTIVE'|'FROZEN'|'CLOSED' */ + 'status' => $this->getStatus(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + $this->mergeWhen($this->getAttendee() !== null, fn () => [ + 'attendee_public_id' => $this->getAttendee()->getPublicId(), + 'attendee_first_name' => $this->getAttendee()->getFirstName(), + 'attendee_last_name' => $this->getAttendee()->getLastName(), + 'attendee_email' => $this->getAttendee()->getEmail(), + ]), + 'transactions' => $this->when( + $this->getTransactions() !== null, + fn () => CashlessTransactionResource::collection($this->getTransactions()), + ), + ]; + } +} diff --git a/backend/app/Resources/Cashless/CashlessWalletResourcePublic.php b/backend/app/Resources/Cashless/CashlessWalletResourcePublic.php new file mode 100644 index 0000000000..064f6dba27 --- /dev/null +++ b/backend/app/Resources/Cashless/CashlessWalletResourcePublic.php @@ -0,0 +1,35 @@ + $this->getBalance(), + 'total_topped_up' => $this->getTotalToppedUp(), + 'total_spent' => $this->getTotalSpent(), + 'total_refunded' => $this->getTotalRefunded(), + 'currency' => $this->getCurrency(), + /** @var 'ACTIVE'|'FROZEN'|'CLOSED' */ + 'status' => $this->getStatus(), + $this->mergeWhen($this->getAttendee() !== null, fn () => [ + 'attendee_public_id' => $this->getAttendee()->getPublicId(), + 'attendee_name' => trim($this->getAttendee()->getFirstName().' '.$this->getAttendee()->getLastName()), + ]), + $this->mergeWhen($this->getTransactions() !== null, fn () => [ + 'transactions' => CashlessTransactionResourcePublic::collection($this->getTransactions()), + ]), + ]; + } +} diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 69360b99bf..7beac63c2b 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -86,6 +86,12 @@ public function toArray($request): array // Waitlist settings 'waitlist_auto_process' => $this->getWaitlistAutoProcess(), 'waitlist_offer_timeout_minutes' => $this->getWaitlistOfferTimeoutMinutes(), + + // Cashless settings + 'cashless_enabled' => $this->getCashlessEnabled(), + 'cashless_min_topup_amount' => $this->getCashlessMinTopupAmount(), + 'cashless_allow_remaining_balance_refund' => $this->getCashlessAllowRemainingBalanceRefund(), + 'cashless_refund_deadline_at' => $this->getCashlessRefundDeadlineAt(), ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 882f838b8d..f18fce421f 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -61,6 +61,11 @@ public function toArray($request): array 'price_display_mode' => $this->getPriceDisplayMode(), + // Cashless settings + 'cashless_enabled' => $this->getCashlessEnabled(), + 'cashless_min_topup_amount' => $this->getCashlessMinTopupAmount(), + 'cashless_online_topup_enabled' => $this->getCashlessOnlineTopupEnabled(), + // Payment settings 'payment_providers' => $this->getPaymentProviders(), 'offline_payment_instructions' => $this->getOfflinePaymentInstructions(), diff --git a/backend/app/Services/Application/Handlers/Cashless/CloseCashlessHandler.php b/backend/app/Services/Application/Handlers/Cashless/CloseCashlessHandler.php new file mode 100644 index 0000000000..beb374064c --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/CloseCashlessHandler.php @@ -0,0 +1,30 @@ +cashlessClosureService->close($eventId, $userId); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/CreateCashlessTopupHandler.php b/backend/app/Services/Application/Handlers/Cashless/CreateCashlessTopupHandler.php new file mode 100644 index 0000000000..477741c8f7 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/CreateCashlessTopupHandler.php @@ -0,0 +1,170 @@ +cashlessSettingsService->getEnabledSettings($topupData->event_id); + + if (! $settings->getCashlessOnlineTopupEnabled()) { + throw new CashlessNotEnabledException( + __('Online top-ups are turned off for this event. Please top up at a sales point.') + ); + } + + $this->validateAmount($topupData->amount, $settings); + + $wallet = $this->walletResolveService->resolveByTicketReference( + eventId: $topupData->event_id, + ticketReference: $topupData->ticket_reference, + ); + + if (! $wallet->isActive()) { + throw new CashlessWalletUnavailableException( + __('This cashless wallet is not active.') + ); + } + + $topupProduct = $this->getTopupProduct($settings); + $occurrenceId = $this->occurrenceResolver->resolveForSale($topupData->event_id); + + return $this->databaseManager->transaction(function () use ($topupData, $wallet, $topupProduct, $occurrenceId) { + $order = $this->createOrderHandler->handle( + eventId: $topupData->event_id, + createOrderPublicDTO: CreateOrderPublicDTO::fromArray([ + 'is_user_authenticated' => $topupData->is_user_authenticated, + 'session_identifier' => $topupData->session_identifier, + 'order_locale' => $topupData->order_locale, + 'products' => new Collection([ + new ProductOrderDetailsDTO( + product_id: $topupProduct->getId(), + quantities: new Collection([ + new OrderProductPriceDTO( + quantity: 1, + price_id: $topupProduct->getProductPrices()->first()->getId(), + price: $topupData->amount, + ), + ]), + event_occurrence_id: $occurrenceId, + ), + ]), + ]), + ); + + $this->createTopup($wallet, $order, $topupData->amount); + + return $this->prefillBuyerFromTicket($order, $wallet); + }); + } + + /** + * @throws ValidationException + */ + private function validateAmount(float $amount, EventSettingDomainObject $settings): void + { + if ($amount < $settings->getCashlessMinTopupAmount()) { + throw ValidationException::withMessages([ + 'amount' => __('The minimum top-up amount is :amount', [ + 'amount' => $settings->getCashlessMinTopupAmount(), + ]), + ]); + } + } + + /** + * @throws CashlessNotEnabledException + */ + private function getTopupProduct(EventSettingDomainObject $settings): ProductDomainObject + { + $product = $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findFirstWhere([ + ProductDomainObjectAbstract::ID => $settings->getCashlessTopupProductId(), + ]); + + if ($product?->getProductPrices()?->first() === null) { + throw new CashlessNotEnabledException( + __('Cashless top-ups are not available for this event.') + ); + } + + return $product; + } + + private function prefillBuyerFromTicket( + OrderDomainObject $order, + CashlessWalletDomainObject $wallet, + ): OrderDomainObject { + $attendee = $wallet->getAttendee(); + + if ($attendee === null) { + return $order; + } + + return $this->orderRepository->updateFromArray($order->getId(), [ + OrderDomainObjectAbstract::FIRST_NAME => $attendee->getFirstName(), + OrderDomainObjectAbstract::LAST_NAME => $attendee->getLastName(), + OrderDomainObjectAbstract::EMAIL => $attendee->getEmail(), + ]); + } + + private function createTopup(CashlessWalletDomainObject $wallet, OrderDomainObject $order, float $amount): void + { + $this->topupRepository->create([ + CashlessTopupDomainObjectAbstract::EVENT_ID => $wallet->getEventId(), + CashlessTopupDomainObjectAbstract::CASHLESS_WALLET_ID => $wallet->getId(), + CashlessTopupDomainObjectAbstract::ORDER_ID => $order->getId(), + CashlessTopupDomainObjectAbstract::AMOUNT => Currency::round($amount), + CashlessTopupDomainObjectAbstract::STATUS => CashlessTopupStatus::PENDING->value, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/CreditCashlessTopupHandler.php b/backend/app/Services/Application/Handlers/Cashless/CreditCashlessTopupHandler.php new file mode 100644 index 0000000000..d736522175 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/CreditCashlessTopupHandler.php @@ -0,0 +1,108 @@ +topupRepository->findFirstWhere([ + CashlessTopupDomainObjectAbstract::ORDER_ID => $order->getId(), + CashlessTopupDomainObjectAbstract::STATUS => CashlessTopupStatus::PENDING->value, + ]); + + if ($topup === null) { + return; + } + + $transaction = $this->walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $topup->getCashlessWalletId(), + type: CashlessTransactionType::TOPUP_ONLINE, + positive_amount: $topup->getAmount(), + order_id: $order->getId(), + )); + + $this->markAsCredited($topup, $transaction); + + $this->sendConfirmation($topup, $transaction, $order); + } + + private function markAsCredited(CashlessTopupDomainObject $topup, CashlessTransactionDomainObject $transaction): void + { + $this->topupRepository->updateWhere( + attributes: [ + CashlessTopupDomainObjectAbstract::STATUS => CashlessTopupStatus::CREDITED->value, + CashlessTopupDomainObjectAbstract::CASHLESS_TRANSACTION_ID => $transaction->getId(), + ], + where: [ + CashlessTopupDomainObjectAbstract::ID => $topup->getId(), + CashlessTopupDomainObjectAbstract::STATUS => CashlessTopupStatus::PENDING->value, + ], + ); + } + + private function sendConfirmation( + CashlessTopupDomainObject $topup, + CashlessTransactionDomainObject $transaction, + OrderDomainObject $order, + ): void { + /** @var CashlessWalletDomainObject $wallet */ + $wallet = $this->walletRepository->findById($topup->getCashlessWalletId()); + + /** @var AttendeeDomainObject $attendee */ + $attendee = $this->attendeeRepository->findById($wallet->getAttendeeId()); + + $event = $this->eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($wallet->getEventId()); + + $this->mailer + ->to($attendee->getEmail()) + ->locale($order->getLocale()) + ->send($this->mailBuilderService->buildCashlessTopupConfirmationMail( + wallet: $wallet, + transaction: $transaction, + attendee: $attendee, + event: $event, + eventSettings: $event->getEventSettings(), + organizer: $event->getOrganizer(), + )); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/DTO/CashlessSettingsDTO.php b/backend/app/Services/Application/Handlers/Cashless/DTO/CashlessSettingsDTO.php new file mode 100644 index 0000000000..cd6275eaeb --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/DTO/CashlessSettingsDTO.php @@ -0,0 +1,21 @@ + */ + public array $cashless_topup_tax_and_fee_ids, + ) {} +} diff --git a/backend/app/Services/Application/Handlers/Cashless/DTO/CreateCashlessPurchaseDTO.php b/backend/app/Services/Application/Handlers/Cashless/DTO/CreateCashlessPurchaseDTO.php new file mode 100644 index 0000000000..b66aefd93f --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/DTO/CreateCashlessPurchaseDTO.php @@ -0,0 +1,20 @@ + */ + public Collection $items, + public string $client_reference_id, + public string $locale, + public ?string $session_token = null, + ) {} +} diff --git a/backend/app/Services/Application/Handlers/Cashless/DTO/CreateCashlessTopupDTO.php b/backend/app/Services/Application/Handlers/Cashless/DTO/CreateCashlessTopupDTO.php new file mode 100644 index 0000000000..83e812eb73 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/DTO/CreateCashlessTopupDTO.php @@ -0,0 +1,17 @@ + */ + public array $cashless_topup_tax_and_fee_ids, + public ?string $cashless_refund_deadline_at = null, + ) {} +} diff --git a/backend/app/Services/Application/Handlers/Cashless/DTO/UpsertCashlessSalesPointDTO.php b/backend/app/Services/Application/Handlers/Cashless/DTO/UpsertCashlessSalesPointDTO.php new file mode 100644 index 0000000000..b8b62bdccd --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/DTO/UpsertCashlessSalesPointDTO.php @@ -0,0 +1,20 @@ +cashlessSettingsService->getSettings($eventId); + + return new CashlessSettingsDTO( + event_id: $eventId, + cashless_enabled: $settings->getCashlessEnabled(), + cashless_topup_product_id: $settings->getCashlessTopupProductId(), + cashless_min_topup_amount: $settings->getCashlessMinTopupAmount(), + cashless_allow_remaining_balance_refund: $settings->getCashlessAllowRemainingBalanceRefund(), + cashless_refund_deadline_at: $settings->getCashlessRefundDeadlineAt(), + cashless_online_topup_enabled: $settings->getCashlessOnlineTopupEnabled(), + cashless_closed_at: $settings->getCashlessClosedAt(), + cashless_topup_tax_and_fee_ids: $this->topupTaxAndFeeIds($settings->getCashlessTopupProductId()), + ); + } + + private function topupTaxAndFeeIds(?int $topupProductId): array + { + if ($topupProductId === null) { + return []; + } + + $product = $this->productRepository + ->loadRelation(TaxAndFeesDomainObject::class) + ->findFirstWhere([ProductDomainObjectAbstract::ID => $topupProductId]); + + return ($product?->getTaxAndFees() ?? collect()) + ->map(fn (TaxAndFeesDomainObject $taxOrFee) => $taxOrFee->getId()) + ->values() + ->toArray(); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/GetCashlessStatsHandler.php b/backend/app/Services/Application/Handlers/Cashless/GetCashlessStatsHandler.php new file mode 100644 index 0000000000..97a91a64f5 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/GetCashlessStatsHandler.php @@ -0,0 +1,20 @@ +cashlessStatsFetchService->getDailyStats($eventId, $startDate, $endDate); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/GetCashlessSummaryHandler.php b/backend/app/Services/Application/Handlers/Cashless/GetCashlessSummaryHandler.php new file mode 100644 index 0000000000..88d05a18e1 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/GetCashlessSummaryHandler.php @@ -0,0 +1,20 @@ +cashlessSummaryService->getSummary($eventId); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Public/CreateCashlessPurchasePublicHandler.php b/backend/app/Services/Application/Handlers/Cashless/Public/CreateCashlessPurchasePublicHandler.php new file mode 100644 index 0000000000..a40feb438d --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Public/CreateCashlessPurchasePublicHandler.php @@ -0,0 +1,119 @@ +salesPointAccessService->resolveAuthorised( + shortId: $purchaseData->sales_point_short_id, + token: $purchaseData->session_token, + ); + + $this->cashlessSettingsService->getEnabledSettings($salesPoint->getEventId()); + + $alreadyRecorded = $this->findByClientReference($salesPoint, $purchaseData->client_reference_id); + + if ($alreadyRecorded !== null) { + return $alreadyRecorded; + } + + $this->catalogueService->assertSellableAtSalesPoint($salesPoint, $purchaseData->items); + + $wallet = $this->walletResolveService->resolveByAttendeePublicId( + eventId: $salesPoint->getEventId(), + attendeePublicId: $purchaseData->attendee_public_id, + ); + + return $this->databaseManager->transaction(function () use ($purchaseData, $salesPoint, $wallet) { + $order = $this->posOrderService->recordSale( + eventId: $salesPoint->getEventId(), + attendee: $wallet->getAttendee(), + items: $purchaseData->items, + locale: $purchaseData->locale, + ); + + return $this->walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $wallet->getId(), + type: CashlessTransactionType::PURCHASE, + positive_amount: $order->getTotalGross(), + order_id: $order->getId(), + sales_point_id: $salesPoint->getId(), + client_reference_id: $purchaseData->client_reference_id, + items: $this->ledgerItems($order), + )); + }); + } + + /** + * @return Collection + */ + private function ledgerItems(OrderDomainObject $order): Collection + { + return $order->getOrderItems()->map(fn (OrderItemDomainObject $item) => new CashlessTransactionItemDTO( + product_id: $item->getProductId(), + product_price_id: $item->getProductPriceId(), + product_title: $item->getItemName(), + unit_price: $item->getPrice(), + quantity: $item->getQuantity(), + total: $item->getTotalGross(), + )); + } + + private function findByClientReference( + CashlessSalesPointDomainObject $salesPoint, + string $clientReferenceId, + ): ?CashlessTransactionDomainObject { + return $this->transactionRepository->findFirstWhere([ + CashlessTransactionDomainObjectAbstract::CASHLESS_SALES_POINT_ID => $salesPoint->getId(), + CashlessTransactionDomainObjectAbstract::CLIENT_REFERENCE_ID => $clientReferenceId, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Public/CreateStaffTopupPublicHandler.php b/backend/app/Services/Application/Handlers/Cashless/Public/CreateStaffTopupPublicHandler.php new file mode 100644 index 0000000000..31b2eb439b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Public/CreateStaffTopupPublicHandler.php @@ -0,0 +1,77 @@ +salesPointAccessService->resolveAuthorised( + shortId: $topupData->sales_point_short_id, + token: $topupData->session_token, + ); + + if (! $salesPoint->getAllowStaffTopups()) { + throw new CashlessSalesPointAccessException( + __('This sales point is not allowed to top up balances.') + ); + } + + $this->cashlessSettingsService->getEnabledSettings($salesPoint->getEventId()); + + $alreadyRecorded = $this->transactionRepository->findFirstWhere([ + CashlessTransactionDomainObjectAbstract::CASHLESS_SALES_POINT_ID => $salesPoint->getId(), + CashlessTransactionDomainObjectAbstract::CLIENT_REFERENCE_ID => $topupData->client_reference_id, + ]); + + if ($alreadyRecorded !== null) { + return $alreadyRecorded; + } + + $wallet = $this->walletResolveService->resolveByAttendeePublicId( + eventId: $salesPoint->getEventId(), + attendeePublicId: $topupData->attendee_public_id, + ); + + return $this->walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $wallet->getId(), + type: CashlessTransactionType::TOPUP_STAFF, + positive_amount: $topupData->amount, + sales_point_id: $salesPoint->getId(), + staff_payment_method: $topupData->payment_method, + client_reference_id: $topupData->client_reference_id, + notes: $topupData->notes, + )); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessQuotePublicHandler.php b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessQuotePublicHandler.php new file mode 100644 index 0000000000..9915f12ef4 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessQuotePublicHandler.php @@ -0,0 +1,42 @@ + $items + * + * @throws CashlessSalesPointAccessException + * @throws CashlessNotEnabledException + */ + public function handle( + string $salesPointShortId, + ?string $sessionToken, + Collection $items, + ?float $topupAmount, + ): CashlessQuoteDTO { + $salesPoint = $this->salesPointAccessService->resolveAuthorised($salesPointShortId, $sessionToken); + + if ($topupAmount !== null) { + return $this->quoteService->quoteTopup($salesPoint->getEventId(), $topupAmount); + } + + return $this->quoteService->quoteBasket($salesPoint, $items); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessSalesPointPublicHandler.php b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessSalesPointPublicHandler.php new file mode 100644 index 0000000000..68fc775000 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessSalesPointPublicHandler.php @@ -0,0 +1,34 @@ +salesPointAccessService->resolveAuthorised($salesPointShortId, $sessionToken); + } catch (CashlessSalesPointAccessException $exception) { + $salesPoint = $this->salesPointAccessService->resolveUnauthenticated($salesPointShortId); + + if ($salesPoint === null) { + throw $exception; + } + + return $salesPoint->setProducts(null); + } + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessSalesPointTransactionsPublicHandler.php b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessSalesPointTransactionsPublicHandler.php new file mode 100644 index 0000000000..6d3c697f88 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessSalesPointTransactionsPublicHandler.php @@ -0,0 +1,39 @@ +salesPointAccessService->resolveAuthorised($salesPointShortId, $sessionToken); + + return $this->transactionRepository + ->loadRelation(new Relationship(CashlessTransactionItemDomainObject::class, name: 'items')) + ->loadRelation(new Relationship(CashlessWalletDomainObject::class, name: 'wallet', nested: [ + new Relationship(AttendeeDomainObject::class, name: 'attendee'), + ])) + ->findBySalesPointId($salesPoint->getId(), self::TRANSACTION_LIMIT); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessWalletForSalesPointHandler.php b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessWalletForSalesPointHandler.php new file mode 100644 index 0000000000..496b0e658f --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessWalletForSalesPointHandler.php @@ -0,0 +1,54 @@ +salesPointAccessService->resolveAuthorised($salesPointShortId, $sessionToken); + + $wallet = $this->walletResolveService->resolveByAttendeePublicId( + eventId: $salesPoint->getEventId(), + attendeePublicId: $attendeePublicId, + ); + + if (! $salesPoint->getAllowStaffTopups()) { + $this->nameMasker->maskLastName($wallet->getAttendee()); + } + + return $wallet->setTransactions( + $this->transactionRepository + ->loadRelation(new Relationship(CashlessTransactionItemDomainObject::class, name: 'items')) + ->findByWalletId($wallet->getId(), self::RECENT_TRANSACTION_LIMIT) + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessWalletPublicHandler.php b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessWalletPublicHandler.php new file mode 100644 index 0000000000..697ddbe79b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Public/GetCashlessWalletPublicHandler.php @@ -0,0 +1,48 @@ +cashlessSettingsService->getEnabledSettings($eventId); + + $wallet = $this->walletResolveService->resolveByTicketReference($eventId, $ticketReference); + + $this->nameMasker->maskLastName($wallet->getAttendee()); + + return $wallet->setTransactions( + $this->transactionRepository + ->loadRelation(new Relationship(CashlessTransactionItemDomainObject::class, name: 'items')) + ->loadRelation(new Relationship(CashlessSalesPointDomainObject::class, name: 'sales_point')) + ->findByWalletId($wallet->getId(), self::RECENT_TRANSACTION_LIMIT) + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Public/ReverseCashlessTransactionPublicHandler.php b/backend/app/Services/Application/Handlers/Cashless/Public/ReverseCashlessTransactionPublicHandler.php new file mode 100644 index 0000000000..e513fc193c --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Public/ReverseCashlessTransactionPublicHandler.php @@ -0,0 +1,62 @@ +salesPointAccessService->resolveAuthorised($salesPointShortId, $sessionToken); + + $transaction = $this->transactionRepository->findFirstWhere([ + CashlessTransactionDomainObjectAbstract::SHORT_ID => $transactionShortId, + CashlessTransactionDomainObjectAbstract::CASHLESS_SALES_POINT_ID => $salesPoint->getId(), + ]); + + if ($transaction === null) { + throw new CashlessTransactionNotReversibleException( + __('That transaction was not made at this sales point.') + ); + } + + if (Carbon::parse($transaction->getCreatedAt())->addMinutes(self::REVERSAL_WINDOW_MINUTES)->isPast()) { + throw new CashlessTransactionNotReversibleException( + __('This transaction is too old to be reversed here. Please ask the organizer.') + ); + } + + return $this->reversalService->reverse($transaction, reversedByUserId: null); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/SalesPoint/CreateCashlessSalesPointHandler.php b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/CreateCashlessSalesPointHandler.php new file mode 100644 index 0000000000..1aeef99381 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/CreateCashlessSalesPointHandler.php @@ -0,0 +1,27 @@ +salesPointService->create($salesPointData); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/SalesPoint/DeleteCashlessSalesPointHandler.php b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/DeleteCashlessSalesPointHandler.php new file mode 100644 index 0000000000..53ba2e48ae --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/DeleteCashlessSalesPointHandler.php @@ -0,0 +1,23 @@ +salesPointRepository->deleteWhere([ + CashlessSalesPointDomainObjectAbstract::ID => $salesPointId, + CashlessSalesPointDomainObjectAbstract::EVENT_ID => $eventId, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/SalesPoint/GetCashlessSalesPointHandler.php b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/GetCashlessSalesPointHandler.php new file mode 100644 index 0000000000..c90219d5dc --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/GetCashlessSalesPointHandler.php @@ -0,0 +1,37 @@ +salesPointRepository + ->loadRelation(ProductDomainObject::class) + ->findFirstWhere([ + CashlessSalesPointDomainObjectAbstract::ID => $salesPointId, + CashlessSalesPointDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($salesPoint === null) { + throw new ResourceNotFoundException(__('This sales point could not be found.')); + } + + return $salesPoint; + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/SalesPoint/GetCashlessSalesPointsHandler.php b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/GetCashlessSalesPointsHandler.php new file mode 100644 index 0000000000..a1b4f113b5 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/GetCashlessSalesPointsHandler.php @@ -0,0 +1,42 @@ +salesPointRepository + ->loadRelation(ProductDomainObject::class) + ->findByEventId($eventId, $params); + + $stats = $this->salesPointRepository + ->getStatsByIds($paginator->getCollection() + ->map(fn (CashlessSalesPointDomainObject $salesPoint) => $salesPoint->getId()) + ->toArray()) + ->keyBy(fn (CashlessSalesPointStatsDTO $stat) => $stat->salesPointId); + + $paginator->setCollection( + $paginator->getCollection()->map( + fn (CashlessSalesPointDomainObject $salesPoint) => $salesPoint + ->setSalesTotal($stats->get($salesPoint->getId())?->salesTotal) + ->setTransactionCount($stats->get($salesPoint->getId())?->transactionCount) + ) + ); + + return $paginator; + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/SalesPoint/UpdateCashlessSalesPointHandler.php b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/UpdateCashlessSalesPointHandler.php new file mode 100644 index 0000000000..32e66a9c43 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/SalesPoint/UpdateCashlessSalesPointHandler.php @@ -0,0 +1,41 @@ +salesPointRepository->findFirstWhere([ + CashlessSalesPointDomainObjectAbstract::ID => $salesPointData->id, + CashlessSalesPointDomainObjectAbstract::EVENT_ID => $salesPointData->event_id, + ]); + + if ($existing === null) { + throw new ResourceNotFoundException(__('This sales point could not be found.')); + } + + return $this->salesPointService->update($existing, $salesPointData); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/UpdateCashlessSettingsHandler.php b/backend/app/Services/Application/Handlers/Cashless/UpdateCashlessSettingsHandler.php new file mode 100644 index 0000000000..0d8809186b --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/UpdateCashlessSettingsHandler.php @@ -0,0 +1,75 @@ +databaseManager->transaction(function () use ($settingsData) { + $existingSettings = $this->cashlessSettingsService->getSettings($settingsData->event_id); + $event = $this->eventRepository->findById($settingsData->event_id); + + $this->eventSettingsRepository->updateWhere( + attributes: [ + EventSettingDomainObjectAbstract::CASHLESS_ENABLED => $settingsData->cashless_enabled, + EventSettingDomainObjectAbstract::CASHLESS_MIN_TOPUP_AMOUNT => $settingsData->cashless_min_topup_amount, + EventSettingDomainObjectAbstract::CASHLESS_ALLOW_REMAINING_BALANCE_REFUND => $settingsData->cashless_allow_remaining_balance_refund, + EventSettingDomainObjectAbstract::CASHLESS_ONLINE_TOPUP_ENABLED => $settingsData->cashless_online_topup_enabled, + EventSettingDomainObjectAbstract::CASHLESS_REFUND_DEADLINE_AT => $settingsData->cashless_refund_deadline_at + ? DateHelper::convertToUTC($settingsData->cashless_refund_deadline_at, $event->getTimezone()) + : null, + ], + where: [EventSettingDomainObjectAbstract::EVENT_ID => $settingsData->event_id], + ); + + if ($settingsData->cashless_enabled) { + $this->topupProductProvisionService->provision( + event: $event, + eventSettings: $existingSettings, + minimumTopupAmount: $settingsData->cashless_min_topup_amount, + ); + + $this->taxAndProductAssociationService->addTaxesToProduct( + new TaxAndProductAssociateParams( + productId: $this->cashlessSettingsService->getSettings($settingsData->event_id)->getCashlessTopupProductId(), + accountId: $event->getAccountId(), + taxAndFeeIds: $settingsData->cashless_topup_tax_and_fee_ids, + ), + ); + } + + return $this->getCashlessSettingsHandler->handle($settingsData->event_id); + }); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Wallet/CreateOrganizerTopupHandler.php b/backend/app/Services/Application/Handlers/Cashless/Wallet/CreateOrganizerTopupHandler.php new file mode 100644 index 0000000000..7e58a4dc6f --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Wallet/CreateOrganizerTopupHandler.php @@ -0,0 +1,51 @@ +cashlessSettingsService->getEnabledSettings($eventId); + + $wallet = $this->getCashlessWalletHandler->handle($eventId, $walletId); + + return $this->walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $wallet->getId(), + type: CashlessTransactionType::TOPUP_STAFF, + positive_amount: $amount, + created_by_user_id: $userId, + staff_payment_method: $paymentMethod, + notes: $notes, + )); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessTransactionsHandler.php b/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessTransactionsHandler.php new file mode 100644 index 0000000000..2939b29f4e --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessTransactionsHandler.php @@ -0,0 +1,32 @@ +transactionRepository + ->loadRelation(new Relationship(CashlessTransactionItemDomainObject::class, name: 'items')) + ->loadRelation(new Relationship(CashlessSalesPointDomainObject::class, name: 'sales_point')) + ->loadRelation(new Relationship(CashlessWalletDomainObject::class, name: 'wallet', nested: [ + new Relationship(AttendeeDomainObject::class, name: 'attendee'), + ])) + ->findByEventId($eventId, $params); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessWalletHandler.php b/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessWalletHandler.php new file mode 100644 index 0000000000..899efb9e1a --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessWalletHandler.php @@ -0,0 +1,49 @@ +walletRepository + ->loadRelation(new Relationship(AttendeeDomainObject::class, name: 'attendee')) + ->findFirstWhere([ + CashlessWalletDomainObjectAbstract::ID => $walletId, + CashlessWalletDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($wallet === null) { + throw new ResourceNotFoundException(__('This cashless wallet could not be found.')); + } + + return $wallet->setTransactions( + $this->transactionRepository + ->loadRelation(new Relationship(CashlessTransactionItemDomainObject::class, name: 'items')) + ->loadRelation(new Relationship(CashlessSalesPointDomainObject::class, name: 'sales_point')) + ->findByWalletId($walletId, self::TRANSACTION_LIMIT) + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessWalletsHandler.php b/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessWalletsHandler.php new file mode 100644 index 0000000000..2ca01fadcc --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Wallet/GetCashlessWalletsHandler.php @@ -0,0 +1,25 @@ +walletRepository + ->loadRelation(new Relationship(AttendeeDomainObject::class, name: 'attendee')) + ->findByEventId($eventId, $params); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Wallet/LookupCashlessWalletHandler.php b/backend/app/Services/Application/Handlers/Cashless/Wallet/LookupCashlessWalletHandler.php new file mode 100644 index 0000000000..81364d3ee7 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Wallet/LookupCashlessWalletHandler.php @@ -0,0 +1,30 @@ +cashlessSettingsService->getEnabledSettings($eventId); + + return $this->walletResolveService->resolveByAttendeePublicId($eventId, $attendeePublicId); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Wallet/RefundCashlessWalletHandler.php b/backend/app/Services/Application/Handlers/Cashless/Wallet/RefundCashlessWalletHandler.php new file mode 100644 index 0000000000..3feffedd06 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Wallet/RefundCashlessWalletHandler.php @@ -0,0 +1,127 @@ +cashlessSettingsService->getEnabledSettings($eventId); + + if (! $this->cashlessSettingsService->isRefundWindowOpen($settings)) { + throw new RefundNotPossibleException( + __('Balance refunds are not open for this event.') + ); + } + + $wallet = $this->getCashlessWalletHandler->handle($eventId, $walletId); + + if ($wallet->getBalance() <= 0) { + throw new RefundNotPossibleException(__('This balance is already empty.')); + } + + $refundedAmount = $method === CashlessRefundMethod::ORIGINAL_PAYMENT + ? $this->refundToOriginalPayments($eventId, $wallet) + : $wallet->getBalance(); + + if ($refundedAmount > 0) { + $this->recordRefund($wallet, $refundedAmount, $userId, $method); + } + + return new RefundCashlessWalletResultDTO( + refunded_amount: $refundedAmount, + unrefundable_amount: Currency::round($wallet->getBalance() - $refundedAmount), + ); + } + + /** + * @throws Throwable + */ + private function refundToOriginalPayments(int $eventId, CashlessWalletDomainObject $wallet): float + { + $remaining = $wallet->getBalance(); + $refunded = 0.0; + + $topups = $this->transactionRepository + ->findCreditingTopupsForRefund($wallet->getId()) + ->filter(fn (CashlessTransactionDomainObject $topup) => $topup->getOrderId() !== null); + + foreach ($topups as $topup) { + if ($remaining <= 0) { + break; + } + + $amount = Currency::round(min($remaining, $topup->getAmount())); + + $this->refundOrderHandler->handle(new RefundOrderDTO( + event_id: $eventId, + order_id: $topup->getOrderId(), + amount: $amount, + notify_buyer: true, + cancel_order: false, + )); + + $remaining = Currency::round($remaining - $amount); + $refunded = Currency::round($refunded + $amount); + } + + return $refunded; + } + + /** + * @throws Throwable + */ + private function recordRefund( + CashlessWalletDomainObject $wallet, + float $amount, + int $userId, + CashlessRefundMethod $method, + ): void { + $this->walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $wallet->getId(), + type: CashlessTransactionType::REFUND_REMAINING, + positive_amount: $amount, + created_by_user_id: $userId, + notes: __('Balance refunded via :method', ['method' => $method->value]), + )); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Wallet/ReverseCashlessTransactionHandler.php b/backend/app/Services/Application/Handlers/Cashless/Wallet/ReverseCashlessTransactionHandler.php new file mode 100644 index 0000000000..8ae901d643 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Wallet/ReverseCashlessTransactionHandler.php @@ -0,0 +1,48 @@ +transactionRepository->findFirstWhere([ + CashlessTransactionDomainObjectAbstract::ID => $transactionId, + CashlessTransactionDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($transaction === null) { + throw new ResourceNotFoundException(__('This transaction could not be found.')); + } + + return $this->reversalService->reverse($transaction, reversedByUserId: $userId, notes: $notes); + } +} diff --git a/backend/app/Services/Application/Handlers/Cashless/Wallet/UpdateCashlessWalletStatusHandler.php b/backend/app/Services/Application/Handlers/Cashless/Wallet/UpdateCashlessWalletStatusHandler.php new file mode 100644 index 0000000000..d3608b0930 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Cashless/Wallet/UpdateCashlessWalletStatusHandler.php @@ -0,0 +1,45 @@ +getCashlessWalletHandler->handle($eventId, $walletId); + + if ($wallet->getStatus() === CashlessWalletStatus::CLOSED->value) { + throw new CashlessWalletUnavailableException( + __('A closed cashless wallet cannot be changed.') + ); + } + + $this->walletRepository->updateWhere( + attributes: [CashlessWalletDomainObjectAbstract::STATUS => $status->value], + where: [ + CashlessWalletDomainObjectAbstract::ID => $walletId, + CashlessWalletDomainObjectAbstract::EVENT_ID => $eventId, + ], + ); + + return $this->getCashlessWalletHandler->handle($eventId, $walletId); + } +} diff --git a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php index f9f7ae137d..4197953906 100644 --- a/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php +++ b/backend/app/Services/Application/Handlers/Order/CompleteOrderHandler.php @@ -291,7 +291,7 @@ private function extractAnswer(array $response): mixed */ private function validateOrder(OrderDomainObject $order): void { - if ($order->getEmail() !== null) { + if ($order->getPaymentStatus() !== null) { throw new ResourceConflictException(__('This order has already been processed')); } diff --git a/backend/app/Services/Domain/Cashless/CashlessAttendeeNameMasker.php b/backend/app/Services/Domain/Cashless/CashlessAttendeeNameMasker.php new file mode 100644 index 0000000000..6223515b5a --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessAttendeeNameMasker.php @@ -0,0 +1,19 @@ +getLastName(); + + $attendee?->setLastName( + $lastName === null || $lastName === '' ? '' : mb_strtoupper(mb_substr($lastName, 0, 1)).'.' + ); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessClosureService.php b/backend/app/Services/Domain/Cashless/CashlessClosureService.php new file mode 100644 index 0000000000..3d33a6cf32 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessClosureService.php @@ -0,0 +1,98 @@ +databaseManager->transaction(function () use ($eventId, $closedByUserId) { + $settings = $this->settingsService->getEnabledSettings($eventId); + + if ($settings->getCashlessClosedAt() !== null) { + throw new CashlessClosureNotAllowedException( + __('Cashless has already been closed for this event.') + ); + } + + if ($this->settingsService->isRefundWindowOpen($settings)) { + throw new CashlessClosureNotAllowedException( + __('Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.') + ); + } + + $walletsWithBalance = $this->walletRepository->findWhere([ + [CashlessWalletDomainObjectAbstract::EVENT_ID, '=', $eventId], + [CashlessWalletDomainObjectAbstract::BALANCE, '>', 0], + ]); + + $amountClosed = 0.0; + + /** @var CashlessWalletDomainObject $wallet */ + foreach ($walletsWithBalance as $wallet) { + $transaction = $this->walletService->close($wallet->getId(), $closedByUserId); + $amountClosed += abs($transaction->getAmount()); + } + + $amountClosed = Currency::round($amountClosed); + + $this->walletRepository->updateWhere( + attributes: [CashlessWalletDomainObjectAbstract::STATUS => CashlessWalletStatus::CLOSED->value], + where: [CashlessWalletDomainObjectAbstract::EVENT_ID => $eventId], + ); + + if ($amountClosed > 0) { + $this->statisticsService->recordClosedBalance( + eventId: $eventId, + occurrenceId: $this->occurrenceResolver->resolveForSale($eventId), + date: Carbon::now()->format('Y-m-d'), + amount: $amountClosed, + ); + } + + $this->eventSettingsRepository->updateWhere( + attributes: [EventSettingDomainObjectAbstract::CASHLESS_CLOSED_AT => Carbon::now()], + where: [EventSettingDomainObjectAbstract::EVENT_ID => $eventId], + ); + + return new CashlessClosureResultDTO( + wallets_closed: $walletsWithBalance->count(), + amount_closed: $amountClosed, + ); + }); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessOccurrenceResolver.php b/backend/app/Services/Domain/Cashless/CashlessOccurrenceResolver.php new file mode 100644 index 0000000000..6eb74a5469 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessOccurrenceResolver.php @@ -0,0 +1,45 @@ +occurrenceRepository->findWhere( + where: [ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + EventOccurrenceDomainObjectAbstract::STATUS => EventOccurrenceStatus::ACTIVE->name, + ], + orderAndDirections: [ + new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, OrderAndDirection::DIRECTION_ASC), + ], + ); + + $now = Carbon::now(); + + $current = $occurrences->first( + fn (EventOccurrenceDomainObject $occurrence) => Carbon::parse($occurrence->getStartDate())->lte($now) + && ($occurrence->getEndDate() === null || Carbon::parse($occurrence->getEndDate())->gte($now)), + ); + + $upcoming = $occurrences->first( + fn (EventOccurrenceDomainObject $occurrence) => Carbon::parse($occurrence->getStartDate())->gt($now), + ); + + return ($current ?? $upcoming ?? $occurrences->last())?->getId(); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessPosOrderService.php b/backend/app/Services/Domain/Cashless/CashlessPosOrderService.php new file mode 100644 index 0000000000..77e7438a2d --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessPosOrderService.php @@ -0,0 +1,114 @@ + $items + * + * @throws Throwable + */ + public function recordSale( + int $eventId, + AttendeeDomainObject $attendee, + Collection $items, + string $locale, + ): OrderDomainObject { + $occurrenceId = $this->occurrenceResolver->resolveForSale($eventId); + + $order = $this->createOrderHandler->handle( + eventId: $eventId, + createOrderPublicDTO: CreateOrderPublicDTO::fromArray([ + 'is_user_authenticated' => true, + 'session_identifier' => Str::random(40), + 'order_locale' => $locale, + 'products' => $items->map(fn (CashlessPurchaseItemRequestDTO $item) => new ProductOrderDetailsDTO( + product_id: $item->product_id, + quantities: new Collection([ + new OrderProductPriceDTO( + quantity: $item->quantity, + price_id: $item->product_price_id, + ), + ]), + event_occurrence_id: $occurrenceId, + )), + ]), + deleteExistingOrdersForSession: false, + ); + + $completedOrder = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->updateFromArray($order->getId(), [ + OrderDomainObjectAbstract::FIRST_NAME => $attendee->getFirstName(), + OrderDomainObjectAbstract::LAST_NAME => $attendee->getLastName(), + OrderDomainObjectAbstract::EMAIL => $attendee->getEmail(), + OrderDomainObjectAbstract::STATUS => OrderStatus::COMPLETED->name, + OrderDomainObjectAbstract::PAYMENT_STATUS => OrderPaymentStatus::PAYMENT_RECEIVED->name, + OrderDomainObjectAbstract::PAYMENT_PROVIDER => PaymentProviders::CASHLESS->value, + OrderDomainObjectAbstract::RESERVED_UNTIL => null, + ]); + + $this->productQuantityUpdateService->updateQuantitiesFromOrder($completedOrder); + $this->statisticsIncrementService->incrementForOrder($completedOrder); + + return $completedOrder; + } + + /** + * @throws Throwable + */ + public function cancelSale(int $orderId): void + { + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->findById($orderId); + + $this->statisticsRefundService->updateForRefund( + $order, + MoneyValue::fromFloat($order->getTotalGross(), $order->getCurrency()), + ); + + $this->orderRepository->updateFromArray($orderId, [ + OrderDomainObjectAbstract::TOTAL_REFUNDED => $order->getTotalGross(), + OrderDomainObjectAbstract::REFUND_STATUS => OrderRefundStatus::REFUNDED->name, + ]); + + $this->orderCancelService->cancelOrder($order); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessQuoteService.php b/backend/app/Services/Domain/Cashless/CashlessQuoteService.php new file mode 100644 index 0000000000..bffe629e67 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessQuoteService.php @@ -0,0 +1,96 @@ + $items + */ + public function quoteBasket(CashlessSalesPointDomainObject $salesPoint, Collection $items): CashlessQuoteDTO + { + $subtotal = 0.0; + $fees = 0.0; + $taxes = 0.0; + + foreach ($items as $item) { + $product = $salesPoint->getProducts() + ?->first(fn (ProductDomainObject $candidate) => $candidate->getId() === $item->product_id); + + $price = $product?->getProductPrices() + ?->first(fn (ProductPriceDomainObject $candidate) => $candidate->getId() === $item->product_price_id); + + if ($price === null) { + continue; + } + + $calculated = $this->taxAndFeeCalculationService->calculateTaxAndFeesForProduct( + product: $product, + price: $price->getPrice(), + quantity: $item->quantity, + ); + + $subtotal += $price->getPrice() * $item->quantity; + $fees += $calculated->feeTotal; + $taxes += $calculated->taxTotal; + } + + return $this->toQuote($subtotal, $fees, $taxes); + } + + /** + * @throws CashlessNotEnabledException + */ + public function quoteTopup(int $eventId, float $amount): CashlessQuoteDTO + { + $settings = $this->cashlessSettingsService->getEnabledSettings($eventId); + + $product = $this->productRepository + ->loadRelation(TaxAndFeesDomainObject::class) + ->findFirstWhere([ + ProductDomainObjectAbstract::ID => $settings->getCashlessTopupProductId(), + ]); + + if ($product === null) { + return $this->toQuote($amount, 0.0, 0.0); + } + + $calculated = $this->taxAndFeeCalculationService->calculateTaxAndFeesForProduct( + product: $product, + price: $amount, + ); + + return $this->toQuote($amount, $calculated->feeTotal, $calculated->taxTotal); + } + + private function toQuote(float $subtotal, float $fees, float $taxes): CashlessQuoteDTO + { + return new CashlessQuoteDTO( + subtotal: Currency::round($subtotal), + fees: Currency::round($fees), + taxes: Currency::round($taxes), + total: Currency::round($subtotal + $fees + $taxes), + ); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessReversalService.php b/backend/app/Services/Domain/Cashless/CashlessReversalService.php new file mode 100644 index 0000000000..6ea96de7c6 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessReversalService.php @@ -0,0 +1,40 @@ +walletService->reverse($transaction, $reversedByUserId, $notes); + + if ($transaction->getType() === CashlessTransactionType::PURCHASE->value && $transaction->getOrderId() !== null) { + $this->posOrderService->cancelSale($transaction->getOrderId()); + } + + return $reversal; + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessSalesPointAccessService.php b/backend/app/Services/Domain/Cashless/CashlessSalesPointAccessService.php new file mode 100644 index 0000000000..82b17cdfd9 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessSalesPointAccessService.php @@ -0,0 +1,110 @@ +findOpenSalesPoint($shortId); + + if ($salesPoint->getAccessPin() !== null + && ! $this->hashManager->check((string) $pin, $salesPoint->getAccessPin())) { + throw new CashlessSalesPointAccessException(__('That PIN is not correct.')); + } + + $token = Str::random(40); + + $this->cache->put($this->cacheKey($shortId, $token), $salesPoint->getId(), self::SESSION_TTL_SECONDS); + + return $token; + } + + /** + * @throws CashlessSalesPointAccessException + */ + public function resolveAuthorised(string $shortId, ?string $token): CashlessSalesPointDomainObject + { + $salesPoint = $this->findOpenSalesPoint($shortId); + + if ($salesPoint->getAccessPin() === null) { + return $salesPoint; + } + + if ($token === null || $this->cache->get($this->cacheKey($shortId, $token)) !== $salesPoint->getId()) { + throw new CashlessSalesPointAccessException(__('This till session has expired. Please enter the PIN again.')); + } + + return $salesPoint; + } + + public function resolveUnauthenticated(string $shortId): ?CashlessSalesPointDomainObject + { + try { + return $this->findOpenSalesPoint($shortId); + } catch (CashlessSalesPointAccessException) { + return null; + } + } + + /** + * @throws CashlessSalesPointAccessException + */ + private function findOpenSalesPoint(string $shortId): CashlessSalesPointDomainObject + { + $salesPoint = $this->salesPointRepository + ->loadRelation(new Relationship(ProductDomainObject::class, nested: [ + new Relationship(ProductPriceDomainObject::class), + new Relationship(TaxAndFeesDomainObject::class), + ])) + ->findFirstWhere([ + CashlessSalesPointDomainObjectAbstract::SHORT_ID => $shortId, + ]); + + if ($salesPoint === null) { + throw new CashlessSalesPointAccessException(__('This sales point could not be found.')); + } + + /** @var EventDomainObject $event */ + $event = $this->eventRepository->findById($salesPoint->getEventId()); + + if ($salesPoint->isExpired($event->getTimezone()) || ! $salesPoint->isActivated($event->getTimezone())) { + throw new CashlessSalesPointAccessException(__('This sales point is not currently open.')); + } + + return $salesPoint->setEvent($event); + } + + private function cacheKey(string $shortId, string $token): string + { + return sprintf('cashless_sales_point_session_%s_%s', $shortId, $token); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessSalesPointCatalogueService.php b/backend/app/Services/Domain/Cashless/CashlessSalesPointCatalogueService.php new file mode 100644 index 0000000000..6860c3364d --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessSalesPointCatalogueService.php @@ -0,0 +1,42 @@ + $items + * + * @throws ValidationException + */ + public function assertSellableAtSalesPoint( + CashlessSalesPointDomainObject $salesPoint, + Collection $items, + ): void { + $items->each(function (CashlessPurchaseItemRequestDTO $item) use ($salesPoint) { + $product = $salesPoint->getProducts() + ?->first(fn (ProductDomainObject $product) => $product->getId() === $item->product_id); + + if ($product === null) { + throw ValidationException::withMessages([ + 'items' => __('That product is not sold at this sales point.'), + ]); + } + + if ($product->getProductType() !== ProductType::GENERAL->name || $product->getIsCashlessTopup()) { + throw ValidationException::withMessages([ + 'items' => __('Only general products can be sold at a cashless sales point.'), + ]); + } + }); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessSalesPointService.php b/backend/app/Services/Domain/Cashless/CashlessSalesPointService.php new file mode 100644 index 0000000000..38f508f14b --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessSalesPointService.php @@ -0,0 +1,109 @@ +databaseManager->transaction(function () use ($salesPointData) { + $this->eventProductValidationService->validateProductIds( + $salesPointData->product_ids, + $salesPointData->event_id, + ); + + $salesPoint = $this->salesPointRepository->create([ + ...$this->commonAttributes($salesPointData), + CashlessSalesPointDomainObjectAbstract::EVENT_ID => $salesPointData->event_id, + CashlessSalesPointDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CASHLESS_SALES_POINT_PREFIX), + CashlessSalesPointDomainObjectAbstract::ACCESS_PIN => $salesPointData->access_pin + ? $this->hashManager->make($salesPointData->access_pin) + : null, + ]); + + $this->salesPointRepository->syncProducts($salesPoint->getId(), $salesPointData->product_ids); + + return $this->reload($salesPoint->getId()); + }); + } + + /** + * @throws UnrecognizedProductIdException + * @throws Throwable + */ + public function update( + CashlessSalesPointDomainObject $existing, + UpsertCashlessSalesPointDTO $salesPointData, + ): CashlessSalesPointDomainObject { + return $this->databaseManager->transaction(function () use ($existing, $salesPointData) { + $this->eventProductValidationService->validateProductIds( + $salesPointData->product_ids, + $salesPointData->event_id, + ); + + $attributes = $this->commonAttributes($salesPointData); + + if ($salesPointData->access_pin !== null) { + $attributes[CashlessSalesPointDomainObjectAbstract::ACCESS_PIN] = $this->hashManager->make($salesPointData->access_pin); + } + + $this->salesPointRepository->updateFromArray($existing->getId(), $attributes); + + $this->salesPointRepository->syncProducts($existing->getId(), $salesPointData->product_ids); + + return $this->reload($existing->getId()); + }); + } + + private function reload(int $salesPointId): CashlessSalesPointDomainObject + { + return $this->salesPointRepository + ->loadRelation(ProductDomainObject::class) + ->findById($salesPointId); + } + + private function commonAttributes(UpsertCashlessSalesPointDTO $salesPointData): array + { + $timezone = $this->eventRepository->findById($salesPointData->event_id)->getTimezone(); + + return [ + CashlessSalesPointDomainObjectAbstract::NAME => $salesPointData->name, + CashlessSalesPointDomainObjectAbstract::DESCRIPTION => $salesPointData->description, + CashlessSalesPointDomainObjectAbstract::ALLOW_STAFF_TOPUPS => $salesPointData->allow_staff_topups, + CashlessSalesPointDomainObjectAbstract::ACTIVATES_AT => $salesPointData->activates_at + ? DateHelper::convertToUTC($salesPointData->activates_at, $timezone) + : null, + CashlessSalesPointDomainObjectAbstract::EXPIRES_AT => $salesPointData->expires_at + ? DateHelper::convertToUTC($salesPointData->expires_at, $timezone) + : null, + ]; + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessSettingsService.php b/backend/app/Services/Domain/Cashless/CashlessSettingsService.php new file mode 100644 index 0000000000..1a39b2da34 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessSettingsService.php @@ -0,0 +1,65 @@ +eventSettingsRepository->findFirstWhere([ + EventSettingDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($settings === null) { + throw new ResourceNotFoundException( + __('Settings for event :id could not be found.', ['id' => $eventId]) + ); + } + + return $settings; + } + + /** + * @throws CashlessNotEnabledException + * @throws ResourceNotFoundException + */ + public function getEnabledSettings(int $eventId): EventSettingDomainObject + { + $settings = $this->getSettings($eventId); + + if (! $settings->getCashlessEnabled()) { + throw new CashlessNotEnabledException( + __('Cashless payments are not enabled for this event.') + ); + } + + return $settings; + } + + public function isRefundWindowOpen(EventSettingDomainObject $settings): bool + { + if (! $settings->getCashlessAllowRemainingBalanceRefund()) { + return false; + } + + $deadline = $settings->getCashlessRefundDeadlineAt(); + + return $deadline === null || Carbon::parse($deadline)->isFuture(); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessStatsFetchService.php b/backend/app/Services/Domain/Cashless/CashlessStatsFetchService.php new file mode 100644 index 0000000000..bc09d415d4 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessStatsFetchService.php @@ -0,0 +1,81 @@ + + */ + public function getDailyStats(int $eventId, string $startDate, string $endDate): Collection + { + $topupOnline = CashlessTransactionType::TOPUP_ONLINE->value; + $topupStaff = CashlessTransactionType::TOPUP_STAFF->value; + $purchase = CashlessTransactionType::PURCHASE->value; + $reversal = CashlessTransactionType::REVERSAL->value; + $refund = CashlessTransactionType::REFUND_REMAINING->value; + + $query = <<= :startDateFilter::date + AND t.created_at::date <= :endDateFilter::date + ) + SELECT + ds.date, + COALESCE(SUM(m.topped_up), 0) AS topped_up, + COALESCE(SUM(m.spent), 0) AS spent, + COALESCE(SUM(m.refunded), 0) AS refunded + FROM date_series ds + LEFT JOIN movements m ON m.date = ds.date + GROUP BY ds.date + ORDER BY ds.date ASC; + SQL; + + $results = $this->db->select($query, [ + 'startDate' => $startDate, + 'endDate' => $endDate, + 'startDateFilter' => $startDate, + 'endDateFilter' => $endDate, + 'eventId' => $eventId, + ]); + + $currentTime = Carbon::now('UTC')->toTimeString(); + + return collect($results)->map(static fn (object $row) => new CashlessDailyStatsDTO( + date: (new Carbon($row->date))->format('Y-m-d').' '.$currentTime, + topped_up: (float) $row->topped_up, + spent: (float) $row->spent, + refunded: (float) $row->refunded, + )); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessSummaryService.php b/backend/app/Services/Domain/Cashless/CashlessSummaryService.php new file mode 100644 index 0000000000..e15c1434a2 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessSummaryService.php @@ -0,0 +1,138 @@ +fetchWalletTotals($eventId); + $movements = $this->fetchMovements($eventId); + + return new CashlessSummaryDTO( + wallets_total: (int) $wallets->wallets_total, + wallets_with_balance: (int) $wallets->wallets_with_balance, + wallets_frozen: (int) $wallets->wallets_frozen, + wallets_closed: (int) $wallets->wallets_closed, + outstanding_balance: (float) $wallets->outstanding_balance, + topped_up_online: (float) $movements->topped_up_online, + topped_up_staff: (float) $movements->topped_up_staff, + spent: (float) $movements->spent, + refunded: (float) $movements->refunded, + closed: (float) $movements->closed, + purchases_count: (int) $movements->purchases_count, + sales_points: $this->fetchSalesPoints($eventId), + top_products: $this->fetchTopProducts($eventId), + ); + } + + private function fetchWalletTotals(int $eventId): object + { + $frozen = CashlessWalletStatus::FROZEN->value; + $closed = CashlessWalletStatus::CLOSED->value; + + return $this->db->selectOne(<< 0) AS wallets_with_balance, + COUNT(*) FILTER (WHERE status = '$frozen') AS wallets_frozen, + COUNT(*) FILTER (WHERE status = '$closed') AS wallets_closed, + COALESCE(SUM(balance), 0) AS outstanding_balance + FROM cashless_wallets + WHERE event_id = :eventId AND deleted_at IS NULL + SQL, ['eventId' => $eventId]); + } + + private function fetchMovements(int $eventId): object + { + $online = CashlessTransactionType::TOPUP_ONLINE->value; + $staff = CashlessTransactionType::TOPUP_STAFF->value; + $purchase = CashlessTransactionType::PURCHASE->value; + $reversal = CashlessTransactionType::REVERSAL->value; + $refund = CashlessTransactionType::REFUND_REMAINING->value; + $closure = CashlessTransactionType::CLOSURE->value; + + return $this->db->selectOne(<< $eventId]); + } + + /** + * @return array + */ + private function fetchSalesPoints(int $eventId): array + { + $staff = CashlessTransactionType::TOPUP_STAFF->value; + $purchase = CashlessTransactionType::PURCHASE->value; + $reversal = CashlessTransactionType::REVERSAL->value; + + $rows = $this->db->select(<< $eventId]); + + return array_map(static fn (object $row) => new CashlessSalesPointSummaryDTO( + name: $row->name, + spent: (float) $row->spent, + purchases_count: (int) $row->purchases_count, + topped_up: (float) $row->topped_up, + ), $rows); + } + + /** + * @return array + */ + private function fetchTopProducts(int $eventId): array + { + $purchase = CashlessTransactionType::PURCHASE->value; + + $rows = $this->db->select(<< $eventId, 'limit' => self::TOP_PRODUCTS_LIMIT]); + + return array_map(static fn (object $row) => new CashlessProductSummaryDTO( + title: $row->title, + quantity: (int) $row->quantity, + total: (float) $row->total, + ), $rows); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessTopupOrderChecker.php b/backend/app/Services/Domain/Cashless/CashlessTopupOrderChecker.php new file mode 100644 index 0000000000..260d61df32 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessTopupOrderChecker.php @@ -0,0 +1,22 @@ +topupRepository->findFirstWhere([ + CashlessTopupDomainObjectAbstract::ORDER_ID => $orderId, + ]) !== null; + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessTopupProductProvisionService.php b/backend/app/Services/Domain/Cashless/CashlessTopupProductProvisionService.php new file mode 100644 index 0000000000..ff34d72f1d --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessTopupProductProvisionService.php @@ -0,0 +1,107 @@ +findExistingProduct($eventSettings); + + if ($existingProduct !== null) { + return $this->syncMinimumAmount($existingProduct, $minimumTopupAmount); + } + + $product = $this->createProductService->createProduct( + product: (new ProductDomainObject) + ->setEventId($event->getId()) + ->setProductCategoryId($this->resolveCategoryId($event->getId())) + ->setTitle(__('Cashless credit')) + ->setDescription(__('Top up the balance attached to your ticket and pay cashless at the event.')) + ->setType(ProductPriceType::DONATION->name) + ->setProductType(ProductType::GENERAL->name) + ->setIsHidden(true) + ->setProductPrices(new Collection([ + (new ProductPriceDomainObject)->setPrice($minimumTopupAmount), + ])), + accountId: $event->getAccountId(), + ); + + $this->productRepository->updateFromArray($product->getId(), [ + ProductDomainObjectAbstract::IS_CASHLESS_TOPUP => true, + ]); + + $this->eventSettingsRepository->updateWhere( + attributes: [EventSettingDomainObjectAbstract::CASHLESS_TOPUP_PRODUCT_ID => $product->getId()], + where: [EventSettingDomainObjectAbstract::EVENT_ID => $event->getId()], + ); + + return $product; + } + + private function findExistingProduct(EventSettingDomainObject $eventSettings): ?ProductDomainObject + { + if ($eventSettings->getCashlessTopupProductId() === null) { + return null; + } + + return $this->productRepository + ->loadRelation(ProductPriceDomainObject::class) + ->findFirstWhere([ + ProductDomainObjectAbstract::ID => $eventSettings->getCashlessTopupProductId(), + ]); + } + + private function syncMinimumAmount(ProductDomainObject $product, float $minimumTopupAmount): ProductDomainObject + { + $price = $product->getProductPrices()?->first(); + + if ($price !== null && $price->getPrice() !== $minimumTopupAmount) { + $this->productPriceRepository->updateFromArray($price->getId(), [ + 'price' => $minimumTopupAmount, + ]); + } + + return $product; + } + + private function resolveCategoryId(int $eventId): ?int + { + return $this->productCategoryRepository->findFirstWhere([ + ProductCategoryDomainObjectAbstract::EVENT_ID => $eventId, + ])?->getId(); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessWalletResolveService.php b/backend/app/Services/Domain/Cashless/CashlessWalletResolveService.php new file mode 100644 index 0000000000..e62aa12cd2 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessWalletResolveService.php @@ -0,0 +1,98 @@ +resolveForAttendee( + $this->findAttendee($eventId, AttendeeDomainObjectAbstract::PUBLIC_ID, strtoupper(trim($attendeePublicId))) + ); + } + + /** + * @throws CashlessWalletUnavailableException + */ + public function resolveByTicketReference(int $eventId, string $ticketReference): CashlessWalletDomainObject + { + $reference = trim($ticketReference); + + return str_starts_with(strtoupper($reference), IdHelper::ATTENDEE_PREFIX_PUBLIC) + ? $this->resolveByAttendeePublicId($eventId, $reference) + : $this->resolveForAttendee($this->findAttendee($eventId, AttendeeDomainObjectAbstract::SHORT_ID, $reference)); + } + + /** + * @throws CashlessWalletUnavailableException + */ + private function findAttendee(int $eventId, string $field, string $value): AttendeeDomainObject + { + $attendee = $this->attendeeRepository->findFirstWhere([ + $field => $value, + AttendeeDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if ($attendee === null) { + throw new CashlessWalletUnavailableException( + __('No ticket was found for this code.') + ); + } + + if ($attendee->getStatus() !== AttendeeStatus::ACTIVE->name) { + throw new CashlessWalletUnavailableException( + __('This ticket is not active, so it cannot hold a cashless balance.') + ); + } + + return $attendee; + } + + private function resolveForAttendee(AttendeeDomainObject $attendee): CashlessWalletDomainObject + { + $wallet = $this->walletRepository->findFirstWhere([ + CashlessWalletDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(), + ]); + + if ($wallet !== null) { + return $wallet->setAttendee($attendee); + } + + /** @var EventDomainObject $event */ + $event = $this->eventRepository->findById($attendee->getEventId()); + + return $this->walletRepository->create([ + CashlessWalletDomainObjectAbstract::EVENT_ID => $attendee->getEventId(), + CashlessWalletDomainObjectAbstract::ATTENDEE_ID => $attendee->getId(), + CashlessWalletDomainObjectAbstract::CURRENCY => $event->getCurrency(), + CashlessWalletDomainObjectAbstract::STATUS => $this->settingsService->getSettings($attendee->getEventId())->getCashlessClosedAt() === null + ? CashlessWalletStatus::ACTIVE->value + : CashlessWalletStatus::CLOSED->value, + ])->setAttendee($attendee); + } +} diff --git a/backend/app/Services/Domain/Cashless/CashlessWalletService.php b/backend/app/Services/Domain/Cashless/CashlessWalletService.php new file mode 100644 index 0000000000..43eb530eb9 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/CashlessWalletService.php @@ -0,0 +1,285 @@ +databaseManager->transaction(function () use ($transactionData) { + $wallet = $this->lockWallet($transactionData->wallet_id); + + $signedAmount = $this->signAmount($transactionData->type, $transactionData->positive_amount); + + return $this->write( + wallet: $wallet, + type: $transactionData->type, + signedAmount: $signedAmount, + totalsDelta: $this->totalsDelta($transactionData->type, $signedAmount), + orderId: $transactionData->order_id, + salesPointId: $transactionData->sales_point_id, + createdByUserId: $transactionData->created_by_user_id, + staffPaymentMethod: $transactionData->staff_payment_method?->value, + clientReferenceId: $transactionData->client_reference_id, + notes: $transactionData->notes, + reversesTransactionId: null, + items: $transactionData->items, + ); + }); + } + + /** + * @throws CashlessTransactionNotReversibleException + * @throws CashlessWalletUnavailableException + * @throws InsufficientCashlessBalanceException + * @throws Throwable + */ + public function reverse( + CashlessTransactionDomainObject $transaction, + ?int $reversedByUserId, + ?string $notes = null, + ): CashlessTransactionDomainObject { + if ($transaction->isReversal()) { + throw new CashlessTransactionNotReversibleException( + __('A reversal cannot itself be reversed.') + ); + } + + if ($transaction->isClosure()) { + throw new CashlessTransactionNotReversibleException( + __('A cashless closure cannot be reversed.') + ); + } + + $existingReversal = $this->transactionRepository->findFirstWhere([ + CashlessTransactionDomainObjectAbstract::REVERSES_TRANSACTION_ID => $transaction->getId(), + ]); + + if ($existingReversal !== null) { + throw new CashlessTransactionNotReversibleException( + __('This transaction has already been reversed.') + ); + } + + return $this->databaseManager->transaction(function () use ($transaction, $reversedByUserId, $notes) { + $wallet = $this->lockWallet($transaction->getCashlessWalletId(), requireActive: false); + + $signedAmount = -$transaction->getAmount(); + $originalType = CashlessTransactionType::from($transaction->getType()); + + return $this->write( + wallet: $wallet, + type: CashlessTransactionType::REVERSAL, + signedAmount: $signedAmount, + totalsDelta: $this->invertDelta( + $this->totalsDelta($originalType, $transaction->getAmount()) + ), + orderId: null, + salesPointId: $transaction->getCashlessSalesPointId(), + createdByUserId: $reversedByUserId, + staffPaymentMethod: null, + clientReferenceId: null, + notes: $notes, + reversesTransactionId: $transaction->getId(), + items: null, + ); + }); + } + + /** + * @throws CashlessWalletUnavailableException + * @throws Throwable + */ + public function close(int $walletId, ?int $closedByUserId): ?CashlessTransactionDomainObject + { + return $this->databaseManager->transaction(function () use ($walletId, $closedByUserId) { + $wallet = $this->lockWallet($walletId, requireActive: false); + + $transaction = $wallet->getBalance() > 0 + ? $this->write( + wallet: $wallet, + type: CashlessTransactionType::CLOSURE, + signedAmount: -$wallet->getBalance(), + totalsDelta: $this->totalsDelta(CashlessTransactionType::CLOSURE, $wallet->getBalance()), + orderId: null, + salesPointId: null, + createdByUserId: $closedByUserId, + staffPaymentMethod: null, + clientReferenceId: null, + notes: null, + reversesTransactionId: null, + items: null, + ) + : null; + + $this->walletRepository->updateFromArray($wallet->getId(), [ + CashlessWalletDomainObjectAbstract::STATUS => CashlessWalletStatus::CLOSED->value, + ]); + + return $transaction; + }); + } + + /** + * @throws CashlessWalletUnavailableException + */ + private function lockWallet(int $walletId, bool $requireActive = true): CashlessWalletDomainObject + { + $wallet = $this->walletRepository->lockById($walletId); + + if ($wallet === null) { + throw new CashlessWalletUnavailableException( + __('This cashless wallet could not be found.') + ); + } + + if ($requireActive && ! $wallet->isActive()) { + throw new CashlessWalletUnavailableException( + __('This cashless wallet is not active.') + ); + } + + return $wallet; + } + + /** + * @param Collection|null $items + * + * @throws InsufficientCashlessBalanceException + */ + private function write( + CashlessWalletDomainObject $wallet, + CashlessTransactionType $type, + float $signedAmount, + array $totalsDelta, + ?int $orderId, + ?int $salesPointId, + ?int $createdByUserId, + ?string $staffPaymentMethod, + ?string $clientReferenceId, + ?string $notes, + ?int $reversesTransactionId, + ?Collection $items, + ): CashlessTransactionDomainObject { + $newBalance = Currency::round($wallet->getBalance() + $signedAmount); + + if ($newBalance < 0) { + throw new InsufficientCashlessBalanceException( + __('The cashless balance is too low for this transaction.') + ); + } + + $this->walletRepository->updateFromArray($wallet->getId(), [ + CashlessWalletDomainObjectAbstract::BALANCE => $newBalance, + CashlessWalletDomainObjectAbstract::TOTAL_TOPPED_UP => Currency::round( + $wallet->getTotalToppedUp() + $totalsDelta['topped_up'] + ), + CashlessWalletDomainObjectAbstract::TOTAL_SPENT => Currency::round( + $wallet->getTotalSpent() + $totalsDelta['spent'] + ), + CashlessWalletDomainObjectAbstract::TOTAL_REFUNDED => Currency::round( + $wallet->getTotalRefunded() + $totalsDelta['refunded'] + ), + ]); + + $transaction = $this->transactionRepository->create([ + CashlessTransactionDomainObjectAbstract::SHORT_ID => IdHelper::shortId(IdHelper::CASHLESS_TRANSACTION_PREFIX), + CashlessTransactionDomainObjectAbstract::CASHLESS_WALLET_ID => $wallet->getId(), + CashlessTransactionDomainObjectAbstract::EVENT_ID => $wallet->getEventId(), + CashlessTransactionDomainObjectAbstract::TYPE => $type->value, + CashlessTransactionDomainObjectAbstract::AMOUNT => Currency::round($signedAmount), + CashlessTransactionDomainObjectAbstract::BALANCE_AFTER => $newBalance, + CashlessTransactionDomainObjectAbstract::ORDER_ID => $orderId, + CashlessTransactionDomainObjectAbstract::CASHLESS_SALES_POINT_ID => $salesPointId, + CashlessTransactionDomainObjectAbstract::CREATED_BY_USER_ID => $createdByUserId, + CashlessTransactionDomainObjectAbstract::REVERSES_TRANSACTION_ID => $reversesTransactionId, + CashlessTransactionDomainObjectAbstract::STAFF_PAYMENT_METHOD => $staffPaymentMethod, + CashlessTransactionDomainObjectAbstract::CLIENT_REFERENCE_ID => $clientReferenceId, + CashlessTransactionDomainObjectAbstract::NOTES => $notes, + ]); + + if ($items !== null && $items->isNotEmpty()) { + $this->transactionItemRepository->insert( + $items->map(static fn (CashlessTransactionItemDTO $item) => [ + CashlessTransactionItemDomainObjectAbstract::CASHLESS_TRANSACTION_ID => $transaction->getId(), + CashlessTransactionItemDomainObjectAbstract::PRODUCT_ID => $item->product_id, + CashlessTransactionItemDomainObjectAbstract::PRODUCT_PRICE_ID => $item->product_price_id, + CashlessTransactionItemDomainObjectAbstract::PRODUCT_TITLE => $item->product_title, + CashlessTransactionItemDomainObjectAbstract::UNIT_PRICE => $item->unit_price, + CashlessTransactionItemDomainObjectAbstract::QUANTITY => $item->quantity, + CashlessTransactionItemDomainObjectAbstract::TOTAL => $item->total, + 'created_at' => now(), + 'updated_at' => now(), + ])->toArray() + ); + } + + return $transaction; + } + + private function signAmount(CashlessTransactionType $type, float $amount): float + { + return $type->isCredit() ? abs($amount) : -abs($amount); + } + + /** + * @return array{topped_up: float, spent: float, refunded: float} + */ + private function totalsDelta(CashlessTransactionType $type, float $amount): array + { + $magnitude = abs($amount); + + return match ($type) { + CashlessTransactionType::TOPUP_ONLINE, + CashlessTransactionType::TOPUP_STAFF => ['topped_up' => $magnitude, 'spent' => 0.0, 'refunded' => 0.0], + CashlessTransactionType::PURCHASE => ['topped_up' => 0.0, 'spent' => $magnitude, 'refunded' => 0.0], + CashlessTransactionType::REFUND_REMAINING => ['topped_up' => 0.0, 'spent' => 0.0, 'refunded' => $magnitude], + CashlessTransactionType::REVERSAL, + CashlessTransactionType::CLOSURE => ['topped_up' => 0.0, 'spent' => 0.0, 'refunded' => 0.0], + }; + } + + /** + * @param array{topped_up: float, spent: float, refunded: float} $delta + * @return array{topped_up: float, spent: float, refunded: float} + */ + private function invertDelta(array $delta): array + { + return array_map(static fn (float $value) => -$value, $delta); + } +} diff --git a/backend/app/Services/Domain/Cashless/DTO/CashlessClosureResultDTO.php b/backend/app/Services/Domain/Cashless/DTO/CashlessClosureResultDTO.php new file mode 100644 index 0000000000..685f753bd1 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/DTO/CashlessClosureResultDTO.php @@ -0,0 +1,13 @@ + */ + public array $sales_points, + /** @var array */ + public array $top_products, + ) {} +} diff --git a/backend/app/Services/Domain/Cashless/DTO/CashlessTransactionItemDTO.php b/backend/app/Services/Domain/Cashless/DTO/CashlessTransactionItemDTO.php new file mode 100644 index 0000000000..e887b7ccd8 --- /dev/null +++ b/backend/app/Services/Domain/Cashless/DTO/CashlessTransactionItemDTO.php @@ -0,0 +1,17 @@ +|null */ + public ?Collection $items = null, + ) {} +} diff --git a/backend/app/Services/Domain/Email/EmailTemplateService.php b/backend/app/Services/Domain/Email/EmailTemplateService.php index 0d65b5fc51..27da922c7a 100644 --- a/backend/app/Services/Domain/Email/EmailTemplateService.php +++ b/backend/app/Services/Domain/Email/EmailTemplateService.php @@ -144,6 +144,10 @@ private function getDefaultCTAs(): array 'label' => __('View Event'), 'url_token' => 'event.url', ], + EmailTemplateType::CASHLESS_TOPUP->value => [ + 'label' => __('View my balance'), + 'url_token' => 'cashless.url', + ], ]; } @@ -203,6 +207,25 @@ private function getDefaultTemplates(): array Best regards,
{{ organizer.name }} +LIQUID + ], + EmailTemplateType::CASHLESS_TOPUP->value => [ + 'subject' => '💳 Your cashless balance for {{ event.title }}', + 'body' => <<<'LIQUID' +Your cashless balance is topped up
+ +Hello {{ attendee.name }},
+ +We have added {{ cashless.topped_up_amount }} to the cashless balance attached to your ticket for {{ event.title }}.
+ +New balance: {{ cashless.new_balance }}
+ +Show the QR code on your ticket at any bar or stand to pay — no cash needed.
+ +If you have any questions or need assistance, please reply to this email or contact {{ settings.support_email }}.
+ +Thank you,
+{{ organizer.name }} LIQUID ], EmailTemplateType::ATTENDEE_TICKET->value => [ diff --git a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php index a2fee4556e..f52495c4f4 100644 --- a/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php +++ b/backend/app/Services/Domain/Email/EmailTokenContextBuilder.php @@ -215,6 +215,68 @@ public function buildOccurrenceCancellationContext( ]; } + public function buildCashlessTopupContext( + AttendeeDomainObject $attendee, + EventDomainObject $event, + OrganizerDomainObject $organizer, + EventSettingDomainObject $eventSettings, + string $toppedUpAmount, + string $newBalance, + string $walletUrl, + ): array { + $eventStartDate = $event->getStartDate() ? new Carbon(DateHelper::convertFromUTC($event->getStartDate(), $event->getTimezone())) : null; + $eventEndDate = $event->getEndDate() ? new Carbon(DateHelper::convertFromUTC($event->getEndDate(), $event->getTimezone())) : null; + + $eventLocation = $event->getEventLocation(); + $structuredAddress = $this->extractStructuredAddress($eventLocation); + + return [ + 'event' => [ + 'title' => $event->getTitle(), + 'date' => $eventStartDate?->format('F j, Y') ?? '', + 'time' => $eventStartDate?->format('g:i A') ?? '', + 'end_date' => $eventEndDate?->format('F j, Y') ?? '', + 'end_time' => $eventEndDate?->format('g:i A') ?? '', + 'full_address' => $structuredAddress ? AddressHelper::formatAddress($structuredAddress) : '', + 'location_details' => $structuredAddress, + 'description' => $event->getDescription() ?? '', + 'timezone' => $event->getTimezone(), + ], + + 'event_location' => $this->buildLocationContext($eventLocation, $structuredAddress), + + 'occurrence' => [ + 'start_date' => $eventStartDate?->format('F j, Y') ?? '', + 'start_time' => $eventStartDate?->format('g:i A') ?? '', + 'end_date' => $eventEndDate?->format('F j, Y') ?? '', + 'end_time' => $eventEndDate?->format('g:i A') ?? '', + 'label' => '', + ], + + 'organizer' => [ + 'name' => $organizer->getName() ?? '', + 'email' => $organizer->getEmail() ?? '', + ], + + 'settings' => [ + 'support_email' => $eventSettings->getSupportEmail() ?? $organizer->getEmail() ?? '', + 'offline_payment_instructions' => '', + 'post_checkout_message' => $eventSettings->getPostCheckoutMessage() ?? '', + ], + + 'attendee' => [ + 'name' => trim($attendee->getFirstName().' '.$attendee->getLastName()), + 'email' => $attendee->getEmail() ?? '', + ], + + 'cashless' => [ + 'topped_up_amount' => $toppedUpAmount, + 'new_balance' => $newBalance, + 'url' => $walletUrl, + ], + ]; + } + public function buildPreviewContext(string $templateType): array { $baseContext = [ @@ -293,6 +355,18 @@ public function buildPreviewContext(string $templateType): array ]; } + if ($templateType === 'cashless_topup') { + $baseContext['attendee'] = [ + 'name' => 'John Smith', + 'email' => 'john@example.com', + ]; + $baseContext['cashless'] = [ + 'topped_up_amount' => '$20.00', + 'new_balance' => '$35.00', + 'url' => 'https://example.com/cashless/123/A-ABC1234', + ]; + } + if ($templateType === 'occurrence_cancellation') { $baseContext['cancellation'] = [ 'refund_issued' => true, diff --git a/backend/app/Services/Domain/Email/MailBuilderService.php b/backend/app/Services/Domain/Email/MailBuilderService.php index 4f0e4d55ce..d35434e8a2 100644 --- a/backend/app/Services/Domain/Email/MailBuilderService.php +++ b/backend/app/Services/Domain/Email/MailBuilderService.php @@ -4,6 +4,8 @@ use Carbon\Carbon; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\CashlessTransactionDomainObject; +use HiEvents\DomainObjects\CashlessWalletDomainObject; use HiEvents\DomainObjects\Enums\EmailTemplateType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventOccurrenceDomainObject; @@ -11,8 +13,11 @@ use HiEvents\DomainObjects\InvoiceDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; +use HiEvents\Helper\Currency; use HiEvents\Helper\DateHelper; +use HiEvents\Helper\Url; use HiEvents\Mail\Attendee\AttendeeTicketMail; +use HiEvents\Mail\Cashless\CashlessTopupConfirmationMail; use HiEvents\Mail\Occurrence\OccurrenceCancellationMail; use HiEvents\Mail\Order\OrderSummary; use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO; @@ -202,4 +207,73 @@ private function renderOccurrenceCancellationTemplate( return $this->emailTemplateService->renderTemplate($template, $context); } + + public function buildCashlessTopupConfirmationMail( + CashlessWalletDomainObject $wallet, + CashlessTransactionDomainObject $transaction, + AttendeeDomainObject $attendee, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer, + ): CashlessTopupConfirmationMail { + $toppedUpAmount = Currency::format(abs($transaction->getAmount()), $wallet->getCurrency()); + $newBalance = Currency::format($transaction->getBalanceAfter(), $wallet->getCurrency()); + $walletUrl = sprintf( + Url::getFrontEndUrlFromConfig(Url::CASHLESS_WALLET), + $event->getId(), + $attendee->getShortId(), + ); + + return new CashlessTopupConfirmationMail( + attendee: $attendee, + event: $event, + eventSettings: $eventSettings, + organizer: $organizer, + toppedUpAmount: $toppedUpAmount, + newBalance: $newBalance, + walletUrl: $walletUrl, + renderedTemplate: $this->renderCashlessTopupTemplate( + $attendee, + $event, + $eventSettings, + $organizer, + $toppedUpAmount, + $newBalance, + $walletUrl, + ), + ); + } + + private function renderCashlessTopupTemplate( + AttendeeDomainObject $attendee, + EventDomainObject $event, + EventSettingDomainObject $eventSettings, + OrganizerDomainObject $organizer, + string $toppedUpAmount, + string $newBalance, + string $walletUrl, + ): ?RenderedEmailTemplateDTO { + $template = $this->emailTemplateService->getTemplateByType( + type: EmailTemplateType::CASHLESS_TOPUP, + accountId: $event->getAccountId(), + eventId: $event->getId(), + organizerId: $organizer->getId() + ); + + if (! $template) { + return null; + } + + $context = $this->tokenContextBuilder->buildCashlessTopupContext( + $attendee, + $event, + $organizer, + $eventSettings, + $toppedUpAmount, + $newBalance, + $walletUrl, + ); + + return $this->emailTemplateService->renderTemplate($template, $context); + } } diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php index 9b3764fce5..d4490ab8ac 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsCancellationService.php @@ -20,6 +20,7 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Domain\Cashless\CashlessTopupOrderChecker; use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Carbon; @@ -42,6 +43,7 @@ public function __construct( private readonly PromoCodeRepositoryInterface $promoCodeRepository, private readonly ProductRepositoryInterface $productRepository, private readonly AffiliateRepositoryInterface $affiliateRepository, + private readonly CashlessTopupOrderChecker $topupOrderChecker, ) {} /** @@ -50,6 +52,10 @@ public function __construct( */ public function decrementForCancelledOrder(OrderDomainObject $order): void { + if ($this->topupOrderChecker->isTopupOrder($order->getId())) { + return; + } + $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->findById($order->getId()); diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsCashlessClosureService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsCashlessClosureService.php new file mode 100644 index 0000000000..190d41ab32 --- /dev/null +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsCashlessClosureService.php @@ -0,0 +1,58 @@ +add($this->eventStatisticsRepository, ['event_id' => $eventId], $amount); + $this->add($this->eventDailyStatisticRepository, ['event_id' => $eventId, 'date' => $date], $amount); + + if ($occurrenceId === null) { + return; + } + + $this->add( + $this->eventOccurrenceStatisticRepository, + ['event_id' => $eventId, 'event_occurrence_id' => $occurrenceId], + $amount, + ); + $this->add( + $this->eventOccurrenceDailyStatisticRepository, + ['event_id' => $eventId, 'event_occurrence_id' => $occurrenceId, 'date' => $date], + $amount, + ); + } + + private function add(RepositoryInterface $repository, array $identity, float $amount): void + { + $updated = $repository->incrementEach( + columns: ['sales_total_gross' => $amount, 'sales_total_before_additions' => $amount, 'version' => 1], + where: $identity, + ); + + if ($updated === 0) { + $repository->create([ + ...$identity, + 'sales_total_gross' => $amount, + 'sales_total_before_additions' => $amount, + ]); + } + } +} diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php index feb84d3672..48ca78b531 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php @@ -17,6 +17,7 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Domain\Cashless\CashlessTopupOrderChecker; use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier; use Illuminate\Database\DatabaseManager; use Illuminate\Support\Carbon; @@ -36,6 +37,7 @@ public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly LoggerInterface $logger, private readonly Retrier $retrier, + private readonly CashlessTopupOrderChecker $topupOrderChecker, ) {} /** @@ -46,6 +48,10 @@ public function __construct( */ public function incrementForOrder(OrderDomainObject $order): void { + if ($this->topupOrderChecker->isTopupOrder($order->getId())) { + return; + } + $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) ->findById($order->getId()); diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php index baca05e21c..6d7ac9aa80 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsRefundService.php @@ -11,6 +11,7 @@ use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Services\Domain\Cashless\CashlessTopupOrderChecker; use HiEvents\Values\MoneyValue; use Illuminate\Support\Facades\DB; use Psr\Log\LoggerInterface; @@ -25,6 +26,7 @@ public function __construct( private readonly EventOccurrenceDailyStatisticRepositoryInterface $eventOccurrenceDailyStatisticRepository, private readonly OrderRepositoryInterface $orderRepository, private readonly LoggerInterface $logger, + private readonly CashlessTopupOrderChecker $topupOrderChecker, ) {} /** @@ -32,6 +34,10 @@ public function __construct( */ public function updateForRefund(OrderDomainObject $order, MoneyValue $refundAmount): void { + if ($this->topupOrderChecker->isTopupOrder($order->getId())) { + return; + } + $this->updateAggregateStatisticsForRefund($order, $refundAmount); $this->updateDailyStatisticsForRefund($order, $refundAmount); diff --git a/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php b/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php index cffe095efc..2f49cfb44a 100644 --- a/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php +++ b/backend/app/Services/Domain/Report/Factory/ReportServiceFactory.php @@ -4,6 +4,7 @@ use HiEvents\DomainObjects\Enums\ReportTypes; use HiEvents\Services\Domain\Report\AbstractReportService; +use HiEvents\Services\Domain\Report\Reports\CashlessSalesReport; use HiEvents\Services\Domain\Report\Reports\DailySalesReport; use HiEvents\Services\Domain\Report\Reports\OccurrenceSummaryReport; use HiEvents\Services\Domain\Report\Reports\ProductSalesReport; @@ -19,6 +20,7 @@ public function create(ReportTypes $reportType): AbstractReportService ReportTypes::DAILY_SALES_REPORT => App::make(DailySalesReport::class), ReportTypes::PROMO_CODES_REPORT => App::make(PromoCodesReport::class), ReportTypes::OCCURRENCE_SUMMARY => App::make(OccurrenceSummaryReport::class), + ReportTypes::CASHLESS_SALES => App::make(CashlessSalesReport::class), }; } } diff --git a/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php b/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php index d801d1d385..b9c26a6e5c 100644 --- a/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php +++ b/backend/app/Services/Domain/Report/OrganizerReports/EventsPerformanceReport.php @@ -43,6 +43,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr WHERE o.event_id IN (SELECT id FROM organizer_events) AND o.status = '$completedStatus' AND o.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM cashless_topups ct WHERE ct.order_id = o.id) $orderCurrencyFilter GROUP BY o.event_id ), @@ -56,6 +57,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr AND o.status = '$completedStatus' AND o.deleted_at IS NULL AND oi.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM cashless_topups ct WHERE ct.order_id = o.id) $orderCurrencyFilter GROUP BY o.event_id ) diff --git a/backend/app/Services/Domain/Report/OrganizerReports/TaxSummaryReport.php b/backend/app/Services/Domain/Report/OrganizerReports/TaxSummaryReport.php index 97e22eb268..72260ee827 100644 --- a/backend/app/Services/Domain/Report/OrganizerReports/TaxSummaryReport.php +++ b/backend/app/Services/Domain/Report/OrganizerReports/TaxSummaryReport.php @@ -33,6 +33,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?string $curr AND o.status = '$completedStatus' AND o.deleted_at IS NULL AND e.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM cashless_topups ct WHERE ct.order_id = o.id) $currencyFilter AND DATE(o.created_at) BETWEEN '$startDateStr' AND '$endDateStr' ) diff --git a/backend/app/Services/Domain/Report/Reports/CashlessSalesReport.php b/backend/app/Services/Domain/Report/Reports/CashlessSalesReport.php new file mode 100644 index 0000000000..80bfb28c59 --- /dev/null +++ b/backend/app/Services/Domain/Report/Reports/CashlessSalesReport.php @@ -0,0 +1,65 @@ +format('Y-m-d H:i:s'); + $endDateString = $endDate->format('Y-m-d H:i:s'); + $purchase = CashlessTransactionType::PURCHASE->value; + $reversal = CashlessTransactionType::REVERSAL->value; + + return <<= '$startDateString' + AND t.created_at <= '$endDateString' + AND (t.type = '$purchase' OR (t.type = '$reversal' AND r.type = '$purchase')) + ), + item_lines AS ( + SELECT + s.cashless_sales_point_id, + i.product_title, + SUM(i.quantity * s.direction) AS quantity_sold, + SUM(i.total * s.direction) AS product_revenue + FROM sales s + JOIN cashless_transaction_items i ON i.cashless_transaction_id = s.items_source_id + GROUP BY s.cashless_sales_point_id, i.product_title + ) + SELECT + COALESCE(sp.name, '-') AS sales_point, + il.product_title, + il.quantity_sold, + ROUND(il.product_revenue, 2) AS product_revenue, + sp_totals.transaction_count, + ROUND(sp_totals.sales_point_revenue, 2) AS sales_point_revenue + FROM item_lines il + LEFT JOIN cashless_sales_points sp ON sp.id = il.cashless_sales_point_id + LEFT JOIN ( + SELECT + cashless_sales_point_id, + COUNT(*) FILTER (WHERE direction = 1) AS transaction_count, + SUM(net_amount) AS sales_point_revenue + FROM sales + GROUP BY cashless_sales_point_id + ) sp_totals ON sp_totals.cashless_sales_point_id = il.cashless_sales_point_id + WHERE il.quantity_sold <> 0 + ORDER BY sales_point, product_revenue DESC; + SQL; + } +} diff --git a/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php b/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php index a6bd36effc..05f498215a 100644 --- a/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php +++ b/backend/app/Services/Domain/Report/Reports/ProductSalesReport.php @@ -45,6 +45,7 @@ protected function getSqlQuery(Carbon $startDate, Carbon $endDate, ?int $occurre LEFT JOIN filtered_orders fo ON fo.product_id = p.id WHERE p.event_id = :event_id AND p.deleted_at IS NULL + AND p.is_cashless_topup = false GROUP BY p.id, p.title, p.type ORDER BY p."order" SQL; diff --git a/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php b/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php index 3b18c1cdd7..0d4e858fa3 100644 --- a/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php +++ b/backend/app/Services/Infrastructure/Email/LiquidTemplateRenderer.php @@ -255,10 +255,39 @@ public function getAvailableTokens(EmailTemplateType $type): array ], ]; + $cashlessTokens = [ + [ + 'token' => '{{ attendee.name }}', + 'description' => __('The attendee\'s full name'), + 'example' => 'John Smith', + ], + [ + 'token' => '{{ attendee.email }}', + 'description' => __('The attendee\'s email'), + 'example' => 'john@example.com', + ], + [ + 'token' => '{{ cashless.topped_up_amount }}', + 'description' => __('The amount that was just added to the balance'), + 'example' => '$20.00', + ], + [ + 'token' => '{{ cashless.new_balance }}', + 'description' => __('The balance available after this top-up'), + 'example' => '$35.00', + ], + [ + 'token' => '{{ cashless.url }}', + 'description' => __('Link to view the cashless balance'), + 'example' => 'https://example.com/cashless/123/A-ABC1234', + ], + ]; + return match ($type) { EmailTemplateType::ORDER_CONFIRMATION => array_merge($commonTokens, $orderTokens), EmailTemplateType::ATTENDEE_TICKET => array_merge($commonTokens, $orderTokens, $attendeeTokens), EmailTemplateType::OCCURRENCE_CANCELLATION => array_merge($commonTokens, $cancellationTokens), + EmailTemplateType::CASHLESS_TOPUP => array_merge($commonTokens, $cashlessTokens), }; } } diff --git a/backend/config/app.php b/backend/config/app.php index 544ac9538c..1aecc61ed1 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -64,6 +64,7 @@ 'organizer_order_summary' => '/manage/event/%d/orders#order-%d', 'ticket_lookup' => '/my-tickets/%s', 'account_danger_zone' => '/account/danger-zone', + 'cashless_wallet' => '/cashless/%d/%s', ], /** diff --git a/backend/database/migrations/2026_09_20_000001_create_cashless_tables.php b/backend/database/migrations/2026_09_20_000001_create_cashless_tables.php new file mode 100644 index 0000000000..dba9455805 --- /dev/null +++ b/backend/database/migrations/2026_09_20_000001_create_cashless_tables.php @@ -0,0 +1,155 @@ +id(); + $table->foreignId('event_id')->constrained('events')->onDelete('cascade'); + $table->foreignId('attendee_id')->constrained('attendees')->onDelete('cascade'); + $table->decimal('balance', 14, 2)->default(0); + $table->decimal('total_topped_up', 14, 2)->default(0); + $table->decimal('total_spent', 14, 2)->default(0); + $table->decimal('total_refunded', 14, 2)->default(0); + $table->string('currency', 3); + $table->enum('status', CashlessWalletStatus::valuesArray())->default(CashlessWalletStatus::ACTIVE->value); + $table->softDeletes(); + $table->timestamps(); + + $table->index('event_id'); + $table->index(['event_id', 'status']); + }); + + DB::statement('CREATE UNIQUE INDEX cashless_wallets_attendee_id_unique ON cashless_wallets(attendee_id) WHERE deleted_at IS NULL'); + + Schema::create('cashless_sales_points', static function (Blueprint $table) { + $table->id(); + $table->foreignId('event_id')->constrained('events')->onDelete('cascade'); + $table->string('short_id'); + $table->string('name', 100); + $table->text('description')->nullable(); + $table->string('access_pin')->nullable(); + $table->timestamp('activates_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->boolean('allow_staff_topups')->default(true); + $table->softDeletes(); + $table->timestamps(); + + $table->index('event_id'); + $table->index('short_id'); + }); + + Schema::create('cashless_sales_point_products', static function (Blueprint $table) { + $table->id(); + $table->foreignId('cashless_sales_point_id')->constrained('cashless_sales_points')->onDelete('cascade'); + $table->foreignId('product_id')->constrained('products')->onDelete('cascade'); + + $table->unique(['cashless_sales_point_id', 'product_id']); + }); + + Schema::create('cashless_transactions', static function (Blueprint $table) { + $table->id(); + $table->string('short_id'); + $table->foreignId('cashless_wallet_id')->constrained('cashless_wallets')->onDelete('cascade'); + $table->foreignId('event_id')->constrained('events')->onDelete('cascade'); + $table->enum('type', CashlessTransactionType::valuesArray()); + $table->decimal('amount', 14, 2); + $table->decimal('balance_after', 14, 2); + $table->foreignId('order_id')->nullable()->constrained('orders')->onDelete('set null'); + $table->foreignId('cashless_sales_point_id')->nullable()->constrained('cashless_sales_points')->onDelete('set null'); + $table->foreignId('created_by_user_id')->nullable()->constrained('users')->onDelete('set null'); + $table->foreignId('reverses_transaction_id')->nullable()->constrained('cashless_transactions')->onDelete('set null'); + $table->enum('staff_payment_method', CashlessStaffPaymentMethod::valuesArray())->nullable(); + $table->string('client_reference_id')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index('cashless_wallet_id'); + $table->index(['event_id', 'type']); + $table->index(['cashless_sales_point_id', 'created_at']); + $table->index('short_id'); + }); + + DB::statement('CREATE UNIQUE INDEX cashless_transactions_order_id_unique ON cashless_transactions(order_id) WHERE order_id IS NOT NULL'); + DB::statement('CREATE UNIQUE INDEX cashless_transactions_client_reference_unique ON cashless_transactions(cashless_sales_point_id, client_reference_id) WHERE client_reference_id IS NOT NULL'); + DB::statement('CREATE UNIQUE INDEX cashless_transactions_reverses_transaction_id_unique ON cashless_transactions(reverses_transaction_id) WHERE reverses_transaction_id IS NOT NULL'); + + Schema::create('cashless_transaction_items', static function (Blueprint $table) { + $table->id(); + $table->foreignId('cashless_transaction_id')->constrained('cashless_transactions')->onDelete('cascade'); + $table->foreignId('product_id')->nullable()->constrained('products')->onDelete('set null'); + $table->foreignId('product_price_id')->nullable()->constrained('product_prices')->onDelete('set null'); + $table->string('product_title'); + $table->decimal('unit_price', 14, 2); + $table->integer('quantity'); + $table->decimal('total', 14, 2); + $table->timestamps(); + + $table->index('cashless_transaction_id'); + $table->index('product_id'); + }); + + Schema::create('cashless_topups', static function (Blueprint $table) { + $table->id(); + $table->foreignId('event_id')->constrained('events')->onDelete('cascade'); + $table->foreignId('cashless_wallet_id')->constrained('cashless_wallets')->onDelete('cascade'); + $table->foreignId('order_id')->constrained('orders')->onDelete('cascade'); + $table->foreignId('cashless_transaction_id')->nullable()->constrained('cashless_transactions')->onDelete('set null'); + $table->decimal('amount', 14, 2); + $table->enum('status', CashlessTopupStatus::valuesArray())->default(CashlessTopupStatus::PENDING->value); + $table->timestamps(); + + $table->unique('order_id'); + $table->index(['cashless_wallet_id', 'status']); + }); + + Schema::table('event_settings', static function (Blueprint $table) { + $table->boolean('cashless_enabled')->default(false); + $table->foreignId('cashless_topup_product_id')->nullable()->constrained('products')->onDelete('set null'); + $table->decimal('cashless_min_topup_amount', 14, 2)->default(5); + $table->boolean('cashless_allow_remaining_balance_refund')->default(false); + $table->timestamp('cashless_refund_deadline_at')->nullable(); + $table->boolean('cashless_online_topup_enabled')->default(true); + }); + + Schema::table('products', static function (Blueprint $table) { + $table->boolean('is_cashless_topup')->default(false); + }); + } + + public function down(): void + { + Schema::table('products', static function (Blueprint $table) { + $table->dropColumn('is_cashless_topup'); + }); + + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropForeign(['cashless_topup_product_id']); + $table->dropColumn([ + 'cashless_enabled', + 'cashless_topup_product_id', + 'cashless_min_topup_amount', + 'cashless_allow_remaining_balance_refund', + 'cashless_refund_deadline_at', + 'cashless_online_topup_enabled', + ]); + }); + + Schema::dropIfExists('cashless_topups'); + Schema::dropIfExists('cashless_transaction_items'); + Schema::dropIfExists('cashless_transactions'); + Schema::dropIfExists('cashless_sales_point_products'); + Schema::dropIfExists('cashless_sales_points'); + Schema::dropIfExists('cashless_wallets'); + } +}; diff --git a/backend/database/migrations/2026_09_28_000001_add_cashless_closure.php b/backend/database/migrations/2026_09_28_000001_add_cashless_closure.php new file mode 100644 index 0000000000..979dab6b4c --- /dev/null +++ b/backend/database/migrations/2026_09_28_000001_add_cashless_closure.php @@ -0,0 +1,41 @@ +timestamp('cashless_closed_at')->nullable(); + }); + + $this->replaceTransactionTypeConstraint(CashlessTransactionType::valuesArray()); + } + + public function down(): void + { + DB::table('cashless_transactions')->where('type', CashlessTransactionType::CLOSURE->value)->delete(); + + $this->replaceTransactionTypeConstraint(array_values(array_diff( + CashlessTransactionType::valuesArray(), + [CashlessTransactionType::CLOSURE->value], + ))); + + Schema::table('event_settings', function (Blueprint $table) { + $table->dropColumn('cashless_closed_at'); + }); + } + + private function replaceTransactionTypeConstraint(array $types): void + { + $list = implode(', ', array_map(static fn (string $type) => "'".$type."'", $types)); + + DB::statement('ALTER TABLE cashless_transactions DROP CONSTRAINT cashless_transactions_type_check'); + DB::statement("ALTER TABLE cashless_transactions ADD CONSTRAINT cashless_transactions_type_check CHECK (type::text IN ($list))"); + } +}; diff --git a/backend/lang/de.json b/backend/lang/de.json index e56d05ac3c..c3c05daf49 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "Die Veranstaltung muss auf manuelle Überprüfung warten, um genehmigt werden zu können", "Event must be pending manual review to be confirmed as spam": "Die Veranstaltung muss auf manuelle Überprüfung warten, um als Spam bestätigt werden zu können", "Event approved and published": "Veranstaltung genehmigt und veröffentlicht", - "Event confirmed as spam": "Veranstaltung als Spam bestätigt" + "Event confirmed as spam": "Veranstaltung als Spam bestätigt", + "A reversal cannot itself be reversed.": "Eine Stornierung kann nicht selbst storniert werden.", + "Amount": "Betrag", + "Attendee": "Teilnehmer", + "Balance After": "Guthaben danach", + "Balance refund": "Guthabenerstattung", + "Balance refunded via :method": "Guthaben erstattet über :method", + "Balance refunds are not open for this event.": "Guthabenerstattungen sind für diese Veranstaltung nicht geöffnet.", + "Cashless credit": "Cashless-Guthaben", + "Cashless payments are not enabled for this event.": "Cashless-Zahlungen sind für diese Veranstaltung nicht aktiviert.", + "Cashless top-ups are not available for this event.": "Cashless-Aufladungen sind für diese Veranstaltung nicht verfügbar.", + "Date": "Datum", + "Hello :name,": "Hallo :name,", + "Highest balance first": "Höchstes Guthaben zuerst", + "Highest spend first": "Höchste Ausgaben zuerst", + "Items": "Artikel", + "Largest amount first": "Größter Betrag zuerst", + "Lowest balance first": "Niedrigstes Guthaben zuerst", + "Lowest spend first": "Niedrigste Ausgaben zuerst", + "New balance": "Neues Guthaben", + "No ticket was found for this code.": "Zu diesem Code wurde kein Ticket gefunden.", + "Online top-up": "Online-Aufladung", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Online-Aufladungen sind für diese Veranstaltung deaktiviert. Bitte laden Sie an einer Verkaufsstelle auf.", + "Only general products can be sold at a cashless sales point.": "An einer Cashless-Verkaufsstelle können nur allgemeine Produkte verkauft werden.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Bezahlen Sie vor Ort cashless: Laden Sie das Guthaben dieses Tickets auf und zahlen Sie mit dessen QR-Code.", + "Payment Method": "Zahlungsart", + "Purchase": "Kauf", + "Reference": "Referenz", + "Reversal": "Stornierung", + "Sales Point": "Verkaufsstelle", + "Settings for event :id could not be found.": "Die Einstellungen für Veranstaltung :id wurden nicht gefunden.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Zeigen Sie den QR-Code Ihres Tickets an jeder Bar oder jedem Stand — ganz ohne Bargeld.", + "Smallest amount first": "Kleinster Betrag zuerst", + "Staff top-up": "Aufladung durch das Team", + "That PIN is not correct.": "Diese PIN ist nicht korrekt.", + "That product is not sold at this sales point.": "Dieses Produkt wird an dieser Verkaufsstelle nicht verkauft.", + "That transaction was not made at this sales point.": "Diese Transaktion wurde nicht an dieser Verkaufsstelle durchgeführt.", + "The cashless balance is too low for this transaction.": "Das Cashless-Guthaben reicht für diese Transaktion nicht aus.", + "The minimum top-up amount is :amount": "Der Mindestaufladebetrag beträgt :amount", + "This balance is already empty.": "Dieses Guthaben ist bereits leer.", + "This cashless wallet could not be found.": "Dieses Cashless-Guthaben wurde nicht gefunden.", + "This cashless wallet is not active.": "Dieses Cashless-Guthaben ist nicht aktiv.", + "This sales point could not be found.": "Diese Verkaufsstelle wurde nicht gefunden.", + "This sales point is not allowed to top up balances.": "Diese Verkaufsstelle darf keine Guthaben aufladen.", + "This sales point is not currently open.": "Diese Verkaufsstelle ist derzeit nicht geöffnet.", + "This ticket is not active, so it cannot hold a cashless balance.": "Dieses Ticket ist nicht aktiv und kann daher kein Cashless-Guthaben halten.", + "This till session has expired. Please enter the PIN again.": "Die Kassensitzung ist abgelaufen. Bitte geben Sie die PIN erneut ein.", + "This transaction could not be found.": "Diese Transaktion wurde nicht gefunden.", + "This transaction has already been reversed.": "Diese Transaktion wurde bereits storniert.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Diese Transaktion ist zu alt, um hier storniert zu werden. Bitte wenden Sie sich an den Veranstalter.", + "Ticket ID": "Ticket-ID", + "Top up my balance": "Mein Guthaben aufladen", + "Top up the balance attached to your ticket and pay cashless at the event.": "Laden Sie das Guthaben Ihres Tickets auf und zahlen Sie vor Ort cashless.", + "Type": "Typ", + "View my balance": "Mein Guthaben ansehen", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Wir haben **:amount** auf das Cashless-Guthaben Ihres Tickets für **:event** gebucht.", + "Your cashless balance is topped up": "Ihr Cashless-Guthaben wurde aufgeladen", + "💳 Your cashless balance for :event": "💳 Ihr Cashless-Guthaben für :event", + "Cashless Top-up": "Cashless-Aufladung", + "Sent to attendees when their cashless balance is topped up online": "Wird an Teilnehmer gesendet, wenn ihr Cashless-Guthaben online aufgeladen wird", + "The amount that was just added to the balance": "Der Betrag, der dem Guthaben gerade gutgeschrieben wurde", + "The balance available after this top-up": "Das nach dieser Aufladung verfügbare Guthaben", + "Link to view the cashless balance": "Link zum Anzeigen des Cashless-Guthabens", + "Balance closure": "Guthaben-Abschluss", + "A cashless closure cannot be reversed.": "Ein Cashless-Abschluss kann nicht rückgängig gemacht werden.", + "Cashless has already been closed for this event.": "Cashless wurde für diese Veranstaltung bereits abgeschlossen.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Guthaben-Rückerstattungen sind noch offen. Deaktivieren Sie sie oder warten Sie die Rückerstattungsfrist ab, bevor Sie Cashless abschließen.", + "A closed cashless wallet cannot be changed.": "Ein abgeschlossenes Cashless-Guthaben kann nicht geändert werden.", + "The attendee's full name": "Der vollständige Name des Teilnehmers", + "The attendee's email": "Die E-Mail-Adresse des Teilnehmers" } diff --git a/backend/lang/el.json b/backend/lang/el.json index f456d67145..9bdd3a69a9 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "Η εκδήλωση πρέπει να βρίσκεται σε αναμονή χειροκίνητου ελέγχου για να εγκριθεί", "Event must be pending manual review to be confirmed as spam": "Η εκδήλωση πρέπει να βρίσκεται σε αναμονή χειροκίνητου ελέγχου για να επιβεβαιωθεί ως spam", "Event approved and published": "Η εκδήλωση εγκρίθηκε και δημοσιεύτηκε", - "Event confirmed as spam": "Η εκδήλωση επιβεβαιώθηκε ως spam" + "Event confirmed as spam": "Η εκδήλωση επιβεβαιώθηκε ως spam", + "A reversal cannot itself be reversed.": "Μια ακύρωση δεν μπορεί να ακυρωθεί.", + "Amount": "Ποσό", + "Attendee": "Επισκέπτης", + "Balance After": "Υπόλοιπο μετά", + "Balance refund": "Επιστροφή υπολοίπου", + "Balance refunded via :method": "Το υπόλοιπο επιστράφηκε μέσω :method", + "Balance refunds are not open for this event.": "Οι επιστροφές υπολοίπου δεν είναι ανοιχτές για αυτή την εκδήλωση.", + "Cashless credit": "Πίστωση cashless", + "Cashless payments are not enabled for this event.": "Οι πληρωμές cashless δεν είναι ενεργοποιημένες για αυτή την εκδήλωση.", + "Cashless top-ups are not available for this event.": "Οι ανανεώσεις cashless δεν είναι διαθέσιμες για αυτή την εκδήλωση.", + "Date": "Ημερομηνία", + "Hello :name,": "Γεια σας :name,", + "Highest balance first": "Πρώτα το μεγαλύτερο υπόλοιπο", + "Highest spend first": "Πρώτα οι μεγαλύτερες δαπάνες", + "Items": "Είδη", + "Largest amount first": "Πρώτα το μεγαλύτερο ποσό", + "Lowest balance first": "Πρώτα το μικρότερο υπόλοιπο", + "Lowest spend first": "Πρώτα οι μικρότερες δαπάνες", + "New balance": "Νέο υπόλοιπο", + "No ticket was found for this code.": "Δεν βρέθηκε εισιτήριο για αυτόν τον κωδικό.", + "Online top-up": "Διαδικτυακή ανανέωση", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Οι διαδικτυακές ανανεώσεις είναι απενεργοποιημένες για αυτή την εκδήλωση. Κάντε ανανέωση σε ένα σημείο πώλησης.", + "Only general products can be sold at a cashless sales point.": "Σε σημείο πώλησης cashless μπορούν να πωληθούν μόνο γενικά προϊόντα.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Πληρώστε cashless στην εκδήλωση: φορτώστε το υπόλοιπο αυτού του εισιτηρίου και πληρώστε με τον κωδικό QR του.", + "Payment Method": "Τρόπος πληρωμής", + "Purchase": "Αγορά", + "Reference": "Αναφορά", + "Reversal": "Ακύρωση", + "Sales Point": "Σημείο πώλησης", + "Settings for event :id could not be found.": "Οι ρυθμίσεις της εκδήλωσης :id δεν βρέθηκαν.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Δείξτε τον κωδικό QR του εισιτηρίου σας σε οποιοδήποτε μπαρ ή περίπτερο για να πληρώσετε, χωρίς μετρητά.", + "Smallest amount first": "Πρώτα το μικρότερο ποσό", + "Staff top-up": "Ανανέωση από το προσωπικό", + "That PIN is not correct.": "Αυτό το PIN δεν είναι σωστό.", + "That product is not sold at this sales point.": "Αυτό το προϊόν δεν πωλείται σε αυτό το σημείο πώλησης.", + "That transaction was not made at this sales point.": "Αυτή η συναλλαγή δεν έγινε σε αυτό το σημείο πώλησης.", + "The cashless balance is too low for this transaction.": "Το υπόλοιπο cashless δεν επαρκεί για αυτή τη συναλλαγή.", + "The minimum top-up amount is :amount": "Το ελάχιστο ποσό ανανέωσης είναι :amount", + "This balance is already empty.": "Αυτό το υπόλοιπο είναι ήδη κενό.", + "This cashless wallet could not be found.": "Αυτό το υπόλοιπο cashless δεν βρέθηκε.", + "This cashless wallet is not active.": "Αυτό το υπόλοιπο cashless δεν είναι ενεργό.", + "This sales point could not be found.": "Αυτό το σημείο πώλησης δεν βρέθηκε.", + "This sales point is not allowed to top up balances.": "Αυτό το σημείο πώλησης δεν επιτρέπεται να ανανεώνει υπόλοιπα.", + "This sales point is not currently open.": "Αυτό το σημείο πώλησης δεν είναι ανοιχτό αυτή τη στιγμή.", + "This ticket is not active, so it cannot hold a cashless balance.": "Αυτό το εισιτήριο δεν είναι ενεργό, επομένως δεν μπορεί να έχει υπόλοιπο cashless.", + "This till session has expired. Please enter the PIN again.": "Η συνεδρία του ταμείου έληξε. Εισαγάγετε ξανά το PIN.", + "This transaction could not be found.": "Αυτή η συναλλαγή δεν βρέθηκε.", + "This transaction has already been reversed.": "Αυτή η συναλλαγή έχει ήδη ακυρωθεί.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Αυτή η συναλλαγή είναι πολύ παλιά για να ακυρωθεί εδώ. Επικοινωνήστε με τον διοργανωτή.", + "Ticket ID": "Αναγνωριστικό εισιτηρίου", + "Top up my balance": "Ανανέωση του υπολοίπου μου", + "Top up the balance attached to your ticket and pay cashless at the event.": "Φορτώστε το υπόλοιπο που συνδέεται με το εισιτήριό σας και πληρώστε cashless στην εκδήλωση.", + "Type": "Τύπος", + "View my balance": "Προβολή του υπολοίπου μου", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Προσθέσαμε **:amount** στο υπόλοιπο cashless που συνδέεται με το εισιτήριό σας για την εκδήλωση **:event**.", + "Your cashless balance is topped up": "Το υπόλοιπό σας cashless ανανεώθηκε", + "💳 Your cashless balance for :event": "💳 Το υπόλοιπό σας cashless για :event", + "Cashless Top-up": "Φόρτιση cashless", + "Sent to attendees when their cashless balance is topped up online": "Αποστέλλεται στους συμμετέχοντες όταν το cashless υπόλοιπό τους φορτίζεται διαδικτυακά", + "The amount that was just added to the balance": "Το ποσό που μόλις προστέθηκε στο υπόλοιπο", + "The balance available after this top-up": "Το διαθέσιμο υπόλοιπο μετά από αυτή τη φόρτιση", + "Link to view the cashless balance": "Σύνδεσμος για προβολή του cashless υπολοίπου", + "Balance closure": "Κλείσιμο υπολοίπου", + "A cashless closure cannot be reversed.": "Ένα κλείσιμο cashless δεν μπορεί να αναιρεθεί.", + "Cashless has already been closed for this event.": "Το cashless έχει ήδη κλείσει για αυτή την εκδήλωση.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Οι επιστροφές υπολοίπου είναι ακόμη ανοιχτές. Απενεργοποιήστε τις ή περιμένετε την προθεσμία επιστροφής πριν κλείσετε το cashless.", + "A closed cashless wallet cannot be changed.": "Ένα κλειστό cashless πορτοφόλι δεν μπορεί να αλλάξει.", + "The attendee's full name": "Το πλήρες όνομα του συμμετέχοντα", + "The attendee's email": "Το email του συμμετέχοντα" } diff --git a/backend/lang/es.json b/backend/lang/es.json index f43ed37531..74054f3bb3 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -765,5 +765,73 @@ "Event must be pending manual review to be approved": "El evento debe estar pendiente de revisión manual para poder aprobarse", "Event must be pending manual review to be confirmed as spam": "El evento debe estar pendiente de revisión manual para poder confirmarse como spam", "Event approved and published": "Evento aprobado y publicado", - "Event confirmed as spam": "Evento confirmado como spam" + "Event confirmed as spam": "Evento confirmado como spam", + "A reversal cannot itself be reversed.": "Una anulación no puede anularse a sí misma.", + "Attendee": "Asistente", + "Balance After": "Saldo después", + "Balance refund": "Reembolso de saldo", + "Balance refunded via :method": "Saldo reembolsado mediante :method", + "Balance refunds are not open for this event.": "Los reembolsos de saldo no están abiertos para este evento.", + "Cashless credit": "Crédito cashless", + "Cashless payments are not enabled for this event.": "Los pagos cashless no están activados para este evento.", + "Cashless top-ups are not available for this event.": "Las recargas cashless no están disponibles para este evento.", + "Date": "Fecha", + "Hello :name,": "Hola :name,", + "Highest balance first": "Mayor saldo primero", + "Highest spend first": "Mayor gasto primero", + "Items": "Artículos", + "Largest amount first": "Mayor importe primero", + "Lowest balance first": "Menor saldo primero", + "Lowest spend first": "Menor gasto primero", + "New balance": "Nuevo saldo", + "No ticket was found for this code.": "No se encontró ninguna entrada para este código.", + "Online top-up": "Recarga en línea", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Las recargas en línea están desactivadas para este evento. Recarga en un punto de venta.", + "Only general products can be sold at a cashless sales point.": "En un punto de venta cashless solo se pueden vender productos generales.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Paga cashless en el evento: recarga el saldo de esta entrada y paga con su código QR.", + "Payment Method": "Método de pago", + "Purchase": "Compra", + "Reference": "Referencia", + "Reversal": "Anulación", + "Sales Point": "Punto de venta", + "Settings for event :id could not be found.": "No se encontraron los ajustes del evento :id.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Muestra el código QR de tu entrada en cualquier barra o puesto para pagar, sin efectivo.", + "Smallest amount first": "Menor importe primero", + "Staff top-up": "Recarga del equipo", + "That PIN is not correct.": "Ese PIN no es correcto.", + "That product is not sold at this sales point.": "Ese producto no se vende en este punto de venta.", + "That transaction was not made at this sales point.": "Esa transacción no se realizó en este punto de venta.", + "The cashless balance is too low for this transaction.": "El saldo cashless es insuficiente para esta transacción.", + "The minimum top-up amount is :amount": "El importe mínimo de recarga es :amount", + "This balance is already empty.": "Este saldo ya está vacío.", + "This cashless wallet could not be found.": "No se encontró este saldo cashless.", + "This cashless wallet is not active.": "Este saldo cashless no está activo.", + "This sales point could not be found.": "No se encontró este punto de venta.", + "This sales point is not allowed to top up balances.": "Este punto de venta no puede recargar saldos.", + "This sales point is not currently open.": "Este punto de venta no está abierto en este momento.", + "This ticket is not active, so it cannot hold a cashless balance.": "Esta entrada no está activa, por lo que no puede tener saldo cashless.", + "This till session has expired. Please enter the PIN again.": "La sesión de la caja ha caducado. Introduce el PIN de nuevo.", + "This transaction could not be found.": "No se encontró esta transacción.", + "This transaction has already been reversed.": "Esta transacción ya fue anulada.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Esta transacción es demasiado antigua para anularse aquí. Consulta con el organizador.", + "Ticket ID": "ID de la entrada", + "Top up my balance": "Recargar mi saldo", + "Top up the balance attached to your ticket and pay cashless at the event.": "Recarga el saldo asociado a tu entrada y paga cashless en el evento.", + "Type": "Tipo", + "View my balance": "Ver mi saldo", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Hemos añadido **:amount** al saldo cashless asociado a tu entrada para **:event**.", + "Your cashless balance is topped up": "Tu saldo cashless se ha recargado", + "💳 Your cashless balance for :event": "💳 Tu saldo cashless para :event", + "Cashless Top-up": "Recarga cashless", + "Sent to attendees when their cashless balance is topped up online": "Se envía a los asistentes cuando su saldo cashless se recarga en línea", + "The amount that was just added to the balance": "El importe que se acaba de añadir al saldo", + "The balance available after this top-up": "El saldo disponible tras esta recarga", + "Link to view the cashless balance": "Enlace para ver el saldo cashless", + "Balance closure": "Cierre de saldo", + "A cashless closure cannot be reversed.": "Un cierre de cashless no se puede revertir.", + "Cashless has already been closed for this event.": "El cashless ya se cerró para este evento.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Los reembolsos de saldo siguen abiertos. Desactívalos o espera a la fecha límite de reembolso antes de cerrar el cashless.", + "A closed cashless wallet cannot be changed.": "Un monedero cashless cerrado no se puede modificar.", + "The attendee's full name": "El nombre completo del asistente", + "The attendee's email": "El correo electrónico del asistente" } diff --git a/backend/lang/fi.json b/backend/lang/fi.json index 9a06255405..bf400434c9 100644 --- a/backend/lang/fi.json +++ b/backend/lang/fi.json @@ -917,5 +917,73 @@ "Balance Due": "Maksettava", "Every tier except the last needs a quantity when tiers are released in order.": "Kun tasot julkaistaan järjestyksessä, kaikille tasoille viimeistä lukuun ottamatta on määritettävä määrä.", "This price is not on sale yet": "Tämä hinta ei ole vielä myynnissä", - "Price is locked until earlier tiers sell out": "Hinta on lukittu, kunnes aiemmat tasot on myyty loppuun" + "Price is locked until earlier tiers sell out": "Hinta on lukittu, kunnes aiemmat tasot on myyty loppuun", + "A reversal cannot itself be reversed.": "Peruutusta ei voi peruuttaa uudelleen.", + "Attendee": "Osallistuja", + "Balance After": "Saldo jälkeen", + "Balance refund": "Saldon palautus", + "Balance refunded via :method": "Saldo palautettu tavalla :method", + "Balance refunds are not open for this event.": "Saldon palautukset eivät ole avoinna tälle tapahtumalle.", + "Cashless credit": "Cashless-saldo", + "Cashless payments are not enabled for this event.": "Cashless-maksut eivät ole käytössä tässä tapahtumassa.", + "Cashless top-ups are not available for this event.": "Cashless-lataukset eivät ole käytettävissä tässä tapahtumassa.", + "Date": "Päivämäärä", + "Hello :name,": "Hei :name,", + "Highest balance first": "Suurin saldo ensin", + "Highest spend first": "Suurin kulutus ensin", + "Items": "Tuotteet", + "Largest amount first": "Suurin summa ensin", + "Lowest balance first": "Pienin saldo ensin", + "Lowest spend first": "Pienin kulutus ensin", + "New balance": "Uusi saldo", + "No ticket was found for this code.": "Tälle koodille ei löytynyt lippua.", + "Online top-up": "Verkkolataus", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Verkkolataukset on poistettu käytöstä tästä tapahtumasta. Lataa myyntipisteessä.", + "Only general products can be sold at a cashless sales point.": "Cashless-myyntipisteessä voi myydä vain yleisiä tuotteita.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Maksa cashless-tavalla tapahtumassa: lataa tämän lipun saldoa ja maksa sen QR-koodilla.", + "Payment Method": "Maksutapa", + "Purchase": "Osto", + "Reference": "Viite", + "Reversal": "Peruutus", + "Sales Point": "Myyntipiste", + "Settings for event :id could not be found.": "Tapahtuman :id asetuksia ei löytynyt.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Näytä lippusi QR-koodi missä tahansa baarissa tai kojussa ja maksa ilman käteistä.", + "Smallest amount first": "Pienin summa ensin", + "Staff top-up": "Henkilökunnan lataus", + "That PIN is not correct.": "PIN-koodi ei ole oikein.", + "That product is not sold at this sales point.": "Tätä tuotetta ei myydä tässä myyntipisteessä.", + "That transaction was not made at this sales point.": "Tätä tapahtumaa ei tehty tässä myyntipisteessä.", + "The cashless balance is too low for this transaction.": "Cashless-saldo ei riitä tähän tapahtumaan.", + "The minimum top-up amount is :amount": "Vähimmäislataus on :amount", + "This balance is already empty.": "Tämä saldo on jo tyhjä.", + "This cashless wallet could not be found.": "Tätä cashless-saldoa ei löytynyt.", + "This cashless wallet is not active.": "Tämä cashless-saldo ei ole aktiivinen.", + "This sales point could not be found.": "Tätä myyntipistettä ei löytynyt.", + "This sales point is not allowed to top up balances.": "Tämä myyntipiste ei saa ladata saldoja.", + "This sales point is not currently open.": "Tämä myyntipiste ei ole tällä hetkellä auki.", + "This ticket is not active, so it cannot hold a cashless balance.": "Tämä lippu ei ole aktiivinen, joten sillä ei voi olla cashless-saldoa.", + "This till session has expired. Please enter the PIN again.": "Kassan istunto on vanhentunut. Syötä PIN-koodi uudelleen.", + "This transaction could not be found.": "Tätä tapahtumaa ei löytynyt.", + "This transaction has already been reversed.": "Tämä tapahtuma on jo peruttu.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Tämä tapahtuma on liian vanha peruttavaksi täällä. Ota yhteyttä järjestäjään.", + "Ticket ID": "Lipun tunnus", + "Top up my balance": "Lataa saldoni", + "Top up the balance attached to your ticket and pay cashless at the event.": "Lataa lippuusi liitettyä saldoa ja maksa cashless-tavalla tapahtumassa.", + "Type": "Tyyppi", + "View my balance": "Näytä saldoni", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Lisäsimme **:amount** lippuusi liitettyyn cashless-saldoon tapahtumaan **:event**.", + "Your cashless balance is topped up": "Cashless-saldosi on ladattu", + "💳 Your cashless balance for :event": "💳 Cashless-saldosi tapahtumaan :event", + "Cashless Top-up": "Cashless-lataus", + "Sent to attendees when their cashless balance is topped up online": "Lähetetään osallistujille, kun heidän cashless-saldonsa ladataan verkossa", + "The amount that was just added to the balance": "Saldoon juuri lisätty summa", + "The balance available after this top-up": "Latauksen jälkeen käytettävissä oleva saldo", + "Link to view the cashless balance": "Linkki cashless-saldon katseluun", + "Balance closure": "Saldon sulkeminen", + "A cashless closure cannot be reversed.": "Cashlessin sulkemista ei voi perua.", + "Cashless has already been closed for this event.": "Cashless on jo suljettu tälle tapahtumalle.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Saldon palautukset ovat vielä auki. Poista ne käytöstä tai odota palautusten määräaikaa ennen cashlessin sulkemista.", + "A closed cashless wallet cannot be changed.": "Suljettua cashless-lompakkoa ei voi muuttaa.", + "The attendee's full name": "Osallistujan koko nimi", + "The attendee's email": "Osallistujan sähköposti" } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index a71aaaf4bb..93de73e417 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "L'événement doit être en attente de révision manuelle pour être approuvé", "Event must be pending manual review to be confirmed as spam": "L'événement doit être en attente de révision manuelle pour être confirmé comme spam", "Event approved and published": "Événement approuvé et publié", - "Event confirmed as spam": "Événement confirmé comme spam" + "Event confirmed as spam": "Événement confirmé comme spam", + "A reversal cannot itself be reversed.": "Une annulation ne peut pas elle-même être annulée.", + "Amount": "Montant", + "Attendee": "Participant", + "Balance After": "Solde après", + "Balance refund": "Remboursement du solde", + "Balance refunded via :method": "Solde remboursé via :method", + "Balance refunds are not open for this event.": "Les remboursements de solde ne sont pas ouverts pour cet événement.", + "Cashless credit": "Crédit cashless", + "Cashless payments are not enabled for this event.": "Les paiements cashless ne sont pas activés pour cet événement.", + "Cashless top-ups are not available for this event.": "Les recharges cashless ne sont pas disponibles pour cet événement.", + "Date": "Date", + "Hello :name,": "Bonjour :name,", + "Highest balance first": "Solde le plus élevé d'abord", + "Highest spend first": "Dépenses les plus élevées d'abord", + "Items": "Articles", + "Largest amount first": "Montant le plus élevé d'abord", + "Lowest balance first": "Solde le plus bas d'abord", + "Lowest spend first": "Dépenses les plus basses d'abord", + "New balance": "Nouveau solde", + "No ticket was found for this code.": "Aucun billet ne correspond à ce code.", + "Online top-up": "Recharge en ligne", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Les recharges en ligne sont désactivées pour cet événement. Veuillez recharger à un point de vente.", + "Only general products can be sold at a cashless sales point.": "Seuls les produits généraux peuvent être vendus à un point de vente cashless.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Payez en cashless sur place : rechargez la cagnotte de ce billet et payez avec son QR code.", + "Payment Method": "Moyen de paiement", + "Purchase": "Achat", + "Reference": "Référence", + "Reversal": "Annulation", + "Sales Point": "Point de vente", + "Settings for event :id could not be found.": "Les réglages de l'événement :id sont introuvables.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Présentez le QR code de votre billet à n'importe quel bar ou stand pour payer — sans espèces.", + "Smallest amount first": "Montant le plus bas d'abord", + "Staff top-up": "Recharge par l'équipe", + "That PIN is not correct.": "Ce code PIN est incorrect.", + "That product is not sold at this sales point.": "Ce produit n'est pas vendu à ce point de vente.", + "That transaction was not made at this sales point.": "Cette transaction n'a pas été effectuée à ce point de vente.", + "The cashless balance is too low for this transaction.": "Le solde cashless est insuffisant pour cette transaction.", + "The minimum top-up amount is :amount": "Le montant de recharge minimum est de :amount", + "This balance is already empty.": "Ce solde est déjà vide.", + "This cashless wallet could not be found.": "Cette cagnotte cashless est introuvable.", + "This cashless wallet is not active.": "Cette cagnotte cashless n'est pas active.", + "This sales point could not be found.": "Ce point de vente est introuvable.", + "This sales point is not allowed to top up balances.": "Ce point de vente n'est pas autorisé à recharger les cagnottes.", + "This sales point is not currently open.": "Ce point de vente n'est pas ouvert actuellement.", + "This ticket is not active, so it cannot hold a cashless balance.": "Ce billet n'est pas actif, il ne peut donc pas porter de solde cashless.", + "This till session has expired. Please enter the PIN again.": "La session de cette caisse a expiré. Veuillez ressaisir le code PIN.", + "This transaction could not be found.": "Cette transaction est introuvable.", + "This transaction has already been reversed.": "Cette transaction a déjà été annulée.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Cette transaction est trop ancienne pour être annulée ici. Veuillez contacter l'organisateur.", + "Ticket ID": "ID du billet", + "Top up my balance": "Recharger ma cagnotte", + "Top up the balance attached to your ticket and pay cashless at the event.": "Rechargez la cagnotte associée à votre billet et payez en cashless sur place.", + "Type": "Type", + "View my balance": "Voir ma cagnotte", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Nous avons ajouté **:amount** à la cagnotte cashless associée à votre billet pour **:event**.", + "Your cashless balance is topped up": "Votre cagnotte cashless a été rechargée", + "💳 Your cashless balance for :event": "💳 Votre cagnotte cashless pour :event", + "Cashless Top-up": "Recharge cashless", + "Sent to attendees when their cashless balance is topped up online": "Envoyé aux participants lorsque leur cagnotte cashless est rechargée en ligne", + "The amount that was just added to the balance": "Le montant qui vient d'être ajouté à la cagnotte", + "The balance available after this top-up": "La cagnotte disponible après cette recharge", + "Link to view the cashless balance": "Lien pour consulter la cagnotte cashless", + "Balance closure": "Clôture de cagnotte", + "A cashless closure cannot be reversed.": "Une clôture cashless ne peut pas être annulée.", + "Cashless has already been closed for this event.": "Le cashless a déjà été clôturé pour cet événement.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Les remboursements de cagnotte sont encore ouverts. Désactivez-les ou attendez la date limite de remboursement avant de clôturer le cashless.", + "A closed cashless wallet cannot be changed.": "Une cagnotte clôturée ne peut pas être modifiée.", + "The attendee's full name": "Le nom complet du participant", + "The attendee's email": "L'email du participant" } diff --git a/backend/lang/hu.json b/backend/lang/hu.json index eaf2205965..1ad2c08de5 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -725,5 +725,74 @@ "Event must be pending manual review to be approved": "Az eseménynek kézi felülvizsgálatra kell várnia a jóváhagyáshoz", "Event must be pending manual review to be confirmed as spam": "Az eseménynek kézi felülvizsgálatra kell várnia a spamként való megerősítéshez", "Event approved and published": "Esemény jóváhagyva és közzétéve", - "Event confirmed as spam": "Esemény spamként megerősítve" + "Event confirmed as spam": "Esemény spamként megerősítve", + "A reversal cannot itself be reversed.": "A sztornó nem sztornózható újra.", + "Amount": "Összeg", + "Attendee": "Résztvevő", + "Balance After": "Egyenleg utána", + "Balance refund": "Egyenleg visszatérítése", + "Balance refunded via :method": "Egyenleg visszatérítve ezen keresztül: :method", + "Balance refunds are not open for this event.": "Az egyenleg-visszatérítés nem elérhető ehhez az eseményhez.", + "Cashless credit": "Cashless kredit", + "Cashless payments are not enabled for this event.": "A cashless fizetés nincs bekapcsolva ehhez az eseményhez.", + "Cashless top-ups are not available for this event.": "A cashless feltöltés nem érhető el ehhez az eseményhez.", + "Date": "Dátum", + "Hello :name,": "Szia :name,", + "Highest balance first": "Legmagasabb egyenleg elöl", + "Highest spend first": "Legmagasabb költés elöl", + "Items": "Tételek", + "Largest amount first": "Legnagyobb összeg elöl", + "Lowest balance first": "Legalacsonyabb egyenleg elöl", + "Lowest spend first": "Legalacsonyabb költés elöl", + "New balance": "Új egyenleg", + "No ticket was found for this code.": "Ehhez a kódhoz nem található jegy.", + "Online top-up": "Online feltöltés", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Az online feltöltés ki van kapcsolva ennél az eseménynél. Kérjük, tölts fel egy értékesítési ponton.", + "Only general products can be sold at a cashless sales point.": "Cashless értékesítési ponton csak általános termékek adhatók el.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Fizess cashless a helyszínen: töltsd fel a jegy egyenlegét, és fizess a QR-kódjával.", + "Payment Method": "Fizetési mód", + "Purchase": "Vásárlás", + "Reference": "Hivatkozás", + "Reversal": "Sztornó", + "Sales Point": "Értékesítési pont", + "Settings for event :id could not be found.": "A(z) :id esemény beállításai nem találhatók.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Mutasd meg a jegyed QR-kódját bármelyik bárban vagy standon, és készpénz nélkül fizethetsz.", + "Smallest amount first": "Legkisebb összeg elöl", + "Staff top-up": "Csapat általi feltöltés", + "That PIN is not correct.": "Ez a PIN nem helyes.", + "That product is not sold at this sales point.": "Ezt a terméket nem árulják ezen az értékesítési ponton.", + "That transaction was not made at this sales point.": "Ezt a tranzakciót nem ezen az értékesítési ponton végezték.", + "The cashless balance is too low for this transaction.": "A cashless egyenleg nem elegendő ehhez a tranzakcióhoz.", + "The minimum top-up amount is :amount": "A minimális feltöltési összeg :amount", + "This balance is already empty.": "Ez az egyenleg már üres.", + "This cashless wallet could not be found.": "Ez a cashless egyenleg nem található.", + "This cashless wallet is not active.": "Ez a cashless egyenleg nem aktív.", + "This sales point could not be found.": "Ez az értékesítési pont nem található.", + "This sales point is not allowed to top up balances.": "Ez az értékesítési pont nem tölthet fel egyenlegeket.", + "This sales point is not currently open.": "Ez az értékesítési pont jelenleg nincs nyitva.", + "This ticket is not active, so it cannot hold a cashless balance.": "Ez a jegy nem aktív, ezért nem lehet cashless egyenlege.", + "This till session has expired. Please enter the PIN again.": "A kassza munkamenete lejárt. Add meg újra a PIN-t.", + "This transaction could not be found.": "Ez a tranzakció nem található.", + "This transaction has already been reversed.": "Ezt a tranzakciót már sztornózták.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Ez a tranzakció túl régi ahhoz, hogy itt sztornózzák. Fordulj a szervezőhöz.", + "Ticket ID": "Jegyazonosító", + "Top up my balance": "Egyenlegem feltöltése", + "Top up the balance attached to your ticket and pay cashless at the event.": "Töltsd fel a jegyedhez tartozó egyenleget, és fizess cashless a helyszínen.", + "Type": "Típus", + "View my balance": "Egyenlegem megtekintése", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "**:amount** összeget írtunk jóvá a(z) **:event** eseményre szóló jegyedhez tartozó cashless egyenlegen.", + "Your cashless balance is topped up": "A cashless egyenleged fel lett töltve", + "💳 Your cashless balance for :event": "💳 A cashless egyenleged: :event", + "Cashless Top-up": "Cashless feltöltés", + "Sent to attendees when their cashless balance is topped up online": "Akkor kapják meg a résztvevők, amikor a cashless egyenlegüket online feltöltik", + "The amount that was just added to the balance": "Az egyenlegre az imént jóváírt összeg", + "The balance available after this top-up": "A feltöltés után elérhető egyenleg", + "Link to view the cashless balance": "Hivatkozás a cashless egyenleg megtekintéséhez", + "Balance closure": "Egyenleg lezárása", + "A cashless closure cannot be reversed.": "A cashless lezárása nem vonható vissza.", + "Cashless has already been closed for this event.": "A cashless már le van zárva ennél az eseménynél.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Az egyenleg-visszatérítések még nyitva vannak. A cashless lezárása előtt kapcsolja ki őket, vagy várja meg a visszatérítési határidőt.", + "A closed cashless wallet cannot be changed.": "A lezárt cashless pénztárca nem módosítható.", + "The attendee's full name": "A résztvevő teljes neve", + "The attendee's email": "A résztvevő e-mail-címe" } diff --git a/backend/lang/it.json b/backend/lang/it.json index d8930dca92..80c84fb724 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -721,5 +721,74 @@ "Event must be pending manual review to be approved": "L'evento deve essere in attesa di revisione manuale per poter essere approvato", "Event must be pending manual review to be confirmed as spam": "L'evento deve essere in attesa di revisione manuale per poter essere confermato come spam", "Event approved and published": "Evento approvato e pubblicato", - "Event confirmed as spam": "Evento confermato come spam" + "Event confirmed as spam": "Evento confermato come spam", + "A reversal cannot itself be reversed.": "Uno storno non può essere a sua volta stornato.", + "Amount": "Importo", + "Attendee": "Partecipante", + "Balance After": "Credito dopo", + "Balance refund": "Rimborso del credito", + "Balance refunded via :method": "Credito rimborsato tramite :method", + "Balance refunds are not open for this event.": "I rimborsi del credito non sono aperti per questo evento.", + "Cashless credit": "Credito cashless", + "Cashless payments are not enabled for this event.": "I pagamenti cashless non sono attivi per questo evento.", + "Cashless top-ups are not available for this event.": "Le ricariche cashless non sono disponibili per questo evento.", + "Date": "Data", + "Hello :name,": "Ciao :name,", + "Highest balance first": "Credito più alto prima", + "Highest spend first": "Spesa più alta prima", + "Items": "Articoli", + "Largest amount first": "Importo più alto prima", + "Lowest balance first": "Credito più basso prima", + "Lowest spend first": "Spesa più bassa prima", + "New balance": "Nuovo credito", + "No ticket was found for this code.": "Nessun biglietto trovato per questo codice.", + "Online top-up": "Ricarica online", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Le ricariche online sono disattivate per questo evento. Ricarica a un punto vendita.", + "Only general products can be sold at a cashless sales point.": "A un punto vendita cashless si possono vendere solo prodotti generici.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Paga cashless all'evento: ricarica il credito di questo biglietto e paga con il suo codice QR.", + "Payment Method": "Metodo di pagamento", + "Purchase": "Acquisto", + "Reference": "Riferimento", + "Reversal": "Storno", + "Sales Point": "Punto vendita", + "Settings for event :id could not be found.": "Impossibile trovare le impostazioni dell'evento :id.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Mostra il codice QR del tuo biglietto in qualsiasi bar o stand per pagare, senza contanti.", + "Smallest amount first": "Importo più basso prima", + "Staff top-up": "Ricarica dello staff", + "That PIN is not correct.": "Questo PIN non è corretto.", + "That product is not sold at this sales point.": "Questo prodotto non è venduto a questo punto vendita.", + "That transaction was not made at this sales point.": "Questa transazione non è stata effettuata a questo punto vendita.", + "The cashless balance is too low for this transaction.": "Il credito cashless è insufficiente per questa transazione.", + "The minimum top-up amount is :amount": "L'importo minimo di ricarica è :amount", + "This balance is already empty.": "Questo credito è già vuoto.", + "This cashless wallet could not be found.": "Impossibile trovare questo credito cashless.", + "This cashless wallet is not active.": "Questo credito cashless non è attivo.", + "This sales point could not be found.": "Impossibile trovare questo punto vendita.", + "This sales point is not allowed to top up balances.": "Questo punto vendita non può ricaricare i crediti.", + "This sales point is not currently open.": "Questo punto vendita non è aperto al momento.", + "This ticket is not active, so it cannot hold a cashless balance.": "Questo biglietto non è attivo, quindi non può avere un credito cashless.", + "This till session has expired. Please enter the PIN again.": "La sessione della cassa è scaduta. Inserisci di nuovo il PIN.", + "This transaction could not be found.": "Impossibile trovare questa transazione.", + "This transaction has already been reversed.": "Questa transazione è già stata stornata.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Questa transazione è troppo vecchia per essere stornata qui. Rivolgiti all'organizzatore.", + "Ticket ID": "ID biglietto", + "Top up my balance": "Ricarica il mio credito", + "Top up the balance attached to your ticket and pay cashless at the event.": "Ricarica il credito associato al tuo biglietto e paga cashless all'evento.", + "Type": "Tipo", + "View my balance": "Vedi il mio credito", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Abbiamo aggiunto **:amount** al credito cashless associato al tuo biglietto per **:event**.", + "Your cashless balance is topped up": "Il tuo credito cashless è stato ricaricato", + "💳 Your cashless balance for :event": "💳 Il tuo credito cashless per :event", + "Cashless Top-up": "Ricarica cashless", + "Sent to attendees when their cashless balance is topped up online": "Inviata ai partecipanti quando il loro credito cashless viene ricaricato online", + "The amount that was just added to the balance": "L'importo appena aggiunto al credito", + "The balance available after this top-up": "Il credito disponibile dopo questa ricarica", + "Link to view the cashless balance": "Link per vedere il credito cashless", + "Balance closure": "Chiusura del credito", + "A cashless closure cannot be reversed.": "Una chiusura cashless non può essere annullata.", + "Cashless has already been closed for this event.": "Il cashless è già stato chiuso per questo evento.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "I rimborsi del credito sono ancora aperti. Disattivali o attendi la scadenza del rimborso prima di chiudere il cashless.", + "A closed cashless wallet cannot be changed.": "Un portafoglio cashless chiuso non può essere modificato.", + "The attendee's full name": "Il nome completo del partecipante", + "The attendee's email": "L'email del partecipante" } diff --git a/backend/lang/ko.json b/backend/lang/ko.json index dc3dfef8fc..ae27b472c2 100644 --- a/backend/lang/ko.json +++ b/backend/lang/ko.json @@ -918,5 +918,73 @@ "This offer expires on :date. Please complete your order before it expires.": "이 제안은 :date에 만료됩니다. 만료 전에 주문을 완료해 주세요.", "Every tier except the last needs a quantity when tiers are released in order.": "등급을 순차적으로 공개할 때는 마지막 등급을 제외한 모든 등급에 수량이 필요합니다.", "This price is not on sale yet": "이 가격은 아직 판매 전입니다", - "Price is locked until earlier tiers sell out": "이전 등급이 매진될 때까지 이 가격은 잠겨 있습니다" + "Price is locked until earlier tiers sell out": "이전 등급이 매진될 때까지 이 가격은 잠겨 있습니다", + "A reversal cannot itself be reversed.": "취소 내역은 다시 취소할 수 없습니다.", + "Attendee": "참가자", + "Balance After": "거래 후 잔액", + "Balance refund": "잔액 환불", + "Balance refunded via :method": "잔액이 :method(으)로 환불됨", + "Balance refunds are not open for this event.": "이 이벤트는 잔액 환불이 열려 있지 않습니다.", + "Cashless credit": "캐시리스 크레딧", + "Cashless payments are not enabled for this event.": "이 이벤트는 캐시리스 결제가 활성화되어 있지 않습니다.", + "Cashless top-ups are not available for this event.": "이 이벤트는 캐시리스 충전을 사용할 수 없습니다.", + "Date": "날짜", + "Hello :name,": "안녕하세요 :name님,", + "Highest balance first": "잔액 높은 순", + "Highest spend first": "사용액 높은 순", + "Items": "항목", + "Largest amount first": "금액 큰 순", + "Lowest balance first": "잔액 낮은 순", + "Lowest spend first": "사용액 낮은 순", + "New balance": "새 잔액", + "No ticket was found for this code.": "이 코드에 해당하는 티켓을 찾을 수 없습니다.", + "Online top-up": "온라인 충전", + "Online top-ups are turned off for this event. Please top up at a sales point.": "이 이벤트는 온라인 충전이 꺼져 있습니다. 판매처에서 충전해 주세요.", + "Only general products can be sold at a cashless sales point.": "캐시리스 판매처에서는 일반 상품만 판매할 수 있습니다.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "행사장에서 캐시리스로 결제하세요. 이 티켓의 잔액을 충전하고 QR 코드로 결제합니다.", + "Payment Method": "결제 수단", + "Purchase": "구매", + "Reference": "참조", + "Reversal": "취소", + "Sales Point": "판매처", + "Settings for event :id could not be found.": "이벤트 :id의 설정을 찾을 수 없습니다.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "어느 바나 부스에서든 티켓의 QR 코드를 보여주면 현금 없이 결제됩니다.", + "Smallest amount first": "금액 작은 순", + "Staff top-up": "스태프 충전", + "That PIN is not correct.": "PIN이 올바르지 않습니다.", + "That product is not sold at this sales point.": "이 상품은 이 판매처에서 판매하지 않습니다.", + "That transaction was not made at this sales point.": "이 거래는 이 판매처에서 이루어진 것이 아닙니다.", + "The cashless balance is too low for this transaction.": "이 거래를 처리하기에는 캐시리스 잔액이 부족합니다.", + "The minimum top-up amount is :amount": "최소 충전 금액은 :amount입니다", + "This balance is already empty.": "이 잔액은 이미 비어 있습니다.", + "This cashless wallet could not be found.": "이 캐시리스 잔액을 찾을 수 없습니다.", + "This cashless wallet is not active.": "이 캐시리스 잔액은 활성 상태가 아닙니다.", + "This sales point could not be found.": "이 판매처를 찾을 수 없습니다.", + "This sales point is not allowed to top up balances.": "이 판매처는 잔액을 충전할 수 없습니다.", + "This sales point is not currently open.": "이 판매처는 현재 운영 중이 아닙니다.", + "This ticket is not active, so it cannot hold a cashless balance.": "이 티켓은 활성 상태가 아니어서 캐시리스 잔액을 가질 수 없습니다.", + "This till session has expired. Please enter the PIN again.": "계산대 세션이 만료되었습니다. PIN을 다시 입력해 주세요.", + "This transaction could not be found.": "이 거래를 찾을 수 없습니다.", + "This transaction has already been reversed.": "이 거래는 이미 취소되었습니다.", + "This transaction is too old to be reversed here. Please ask the organizer.": "이 거래는 너무 오래되어 여기서 취소할 수 없습니다. 주최자에게 문의하세요.", + "Ticket ID": "티켓 ID", + "Top up my balance": "내 잔액 충전", + "Top up the balance attached to your ticket and pay cashless at the event.": "티켓에 연결된 잔액을 충전하고 행사장에서 캐시리스로 결제하세요.", + "Type": "유형", + "View my balance": "내 잔액 보기", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "**:event** 티켓에 연결된 캐시리스 잔액에 **:amount**을(를) 충전했습니다.", + "Your cashless balance is topped up": "캐시리스 잔액이 충전되었습니다", + "💳 Your cashless balance for :event": "💳 :event 캐시리스 잔액", + "Cashless Top-up": "캐시리스 충전", + "Sent to attendees when their cashless balance is topped up online": "캐시리스 잔액이 온라인으로 충전되면 참석자에게 발송됩니다", + "The amount that was just added to the balance": "방금 잔액에 추가된 금액", + "The balance available after this top-up": "이번 충전 후 사용 가능한 잔액", + "Link to view the cashless balance": "캐시리스 잔액 보기 링크", + "Balance closure": "잔액 마감", + "A cashless closure cannot be reversed.": "캐시리스 마감은 되돌릴 수 없습니다.", + "Cashless has already been closed for this event.": "이 이벤트의 캐시리스는 이미 마감되었습니다.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "잔액 환불이 아직 열려 있습니다. 캐시리스를 마감하기 전에 환불을 끄거나 환불 기한을 기다려 주세요.", + "A closed cashless wallet cannot be changed.": "마감된 캐시리스 지갑은 변경할 수 없습니다.", + "The attendee's full name": "참석자의 전체 이름", + "The attendee's email": "참석자의 이메일" } diff --git a/backend/lang/nl.json b/backend/lang/nl.json index 415004f380..deb38440c5 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "Het evenement moet op handmatige beoordeling wachten om goedgekeurd te kunnen worden", "Event must be pending manual review to be confirmed as spam": "Het evenement moet op handmatige beoordeling wachten om als spam bevestigd te kunnen worden", "Event approved and published": "Evenement goedgekeurd en gepubliceerd", - "Event confirmed as spam": "Evenement bevestigd als spam" + "Event confirmed as spam": "Evenement bevestigd als spam", + "A reversal cannot itself be reversed.": "Een terugboeking kan zelf niet worden teruggeboekt.", + "Amount": "Bedrag", + "Attendee": "Bezoeker", + "Balance After": "Saldo daarna", + "Balance refund": "Saldoterugbetaling", + "Balance refunded via :method": "Saldo terugbetaald via :method", + "Balance refunds are not open for this event.": "Saldoterugbetalingen zijn voor dit evenement niet geopend.", + "Cashless credit": "Cashless-tegoed", + "Cashless payments are not enabled for this event.": "Cashless-betalingen zijn niet ingeschakeld voor dit evenement.", + "Cashless top-ups are not available for this event.": "Cashless-opwaarderingen zijn niet beschikbaar voor dit evenement.", + "Date": "Datum", + "Hello :name,": "Hallo :name,", + "Highest balance first": "Hoogste saldo eerst", + "Highest spend first": "Hoogste uitgaven eerst", + "Items": "Items", + "Largest amount first": "Hoogste bedrag eerst", + "Lowest balance first": "Laagste saldo eerst", + "Lowest spend first": "Laagste uitgaven eerst", + "New balance": "Nieuw saldo", + "No ticket was found for this code.": "Er is geen ticket gevonden voor deze code.", + "Online top-up": "Online opwaardering", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Online opwaarderen is uitgeschakeld voor dit evenement. Waardeer op bij een verkooppunt.", + "Only general products can be sold at a cashless sales point.": "Bij een cashless verkooppunt kunnen alleen algemene producten worden verkocht.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Betaal cashless op het evenement: waardeer het saldo van dit ticket op en betaal met de QR-code.", + "Payment Method": "Betaalmethode", + "Purchase": "Aankoop", + "Reference": "Referentie", + "Reversal": "Terugboeking", + "Sales Point": "Verkooppunt", + "Settings for event :id could not be found.": "De instellingen van evenement :id zijn niet gevonden.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Toon de QR-code van je ticket bij elke bar of kraam om te betalen, zonder contant geld.", + "Smallest amount first": "Laagste bedrag eerst", + "Staff top-up": "Opwaardering door team", + "That PIN is not correct.": "Die pincode klopt niet.", + "That product is not sold at this sales point.": "Dat product wordt bij dit verkooppunt niet verkocht.", + "That transaction was not made at this sales point.": "Die transactie is niet bij dit verkooppunt gedaan.", + "The cashless balance is too low for this transaction.": "Het cashless-saldo is te laag voor deze transactie.", + "The minimum top-up amount is :amount": "Het minimale opwaardeerbedrag is :amount", + "This balance is already empty.": "Dit saldo is al leeg.", + "This cashless wallet could not be found.": "Dit cashless-saldo is niet gevonden.", + "This cashless wallet is not active.": "Dit cashless-saldo is niet actief.", + "This sales point could not be found.": "Dit verkooppunt is niet gevonden.", + "This sales point is not allowed to top up balances.": "Dit verkooppunt mag geen saldi opwaarderen.", + "This sales point is not currently open.": "Dit verkooppunt is momenteel niet open.", + "This ticket is not active, so it cannot hold a cashless balance.": "Dit ticket is niet actief en kan dus geen cashless-saldo bevatten.", + "This till session has expired. Please enter the PIN again.": "De kassasessie is verlopen. Voer de pincode opnieuw in.", + "This transaction could not be found.": "Deze transactie is niet gevonden.", + "This transaction has already been reversed.": "Deze transactie is al teruggeboekt.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Deze transactie is te oud om hier terug te boeken. Neem contact op met de organisator.", + "Ticket ID": "Ticket-ID", + "Top up my balance": "Mijn saldo opwaarderen", + "Top up the balance attached to your ticket and pay cashless at the event.": "Waardeer het saldo van je ticket op en betaal cashless op het evenement.", + "Type": "Type", + "View my balance": "Mijn saldo bekijken", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "We hebben **:amount** toegevoegd aan het cashless-saldo van je ticket voor **:event**.", + "Your cashless balance is topped up": "Je cashless-saldo is opgewaardeerd", + "💳 Your cashless balance for :event": "💳 Je cashless-saldo voor :event", + "Cashless Top-up": "Cashless opwaardering", + "Sent to attendees when their cashless balance is topped up online": "Verstuurd naar deelnemers wanneer hun cashless saldo online wordt opgewaardeerd", + "The amount that was just added to the balance": "Het bedrag dat zojuist aan het saldo is toegevoegd", + "The balance available after this top-up": "Het beschikbare saldo na deze opwaardering", + "Link to view the cashless balance": "Link om het cashless saldo te bekijken", + "Balance closure": "Saldo-afsluiting", + "A cashless closure cannot be reversed.": "Een cashless-afsluiting kan niet ongedaan worden gemaakt.", + "Cashless has already been closed for this event.": "Cashless is voor dit evenement al afgesloten.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Terugbetalingen van saldo staan nog open. Schakel ze uit of wacht op de terugbetalingsdeadline voordat je cashless afsluit.", + "A closed cashless wallet cannot be changed.": "Een afgesloten cashless-portemonnee kan niet worden gewijzigd.", + "The attendee's full name": "De volledige naam van de deelnemer", + "The attendee's email": "Het e-mailadres van de deelnemer" } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 71231b4d07..64506e4f7d 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -917,5 +917,73 @@ "Balance Due": "Saldo do zapłaty", "Every tier except the last needs a quantity when tiers are released in order.": "Gdy poziomy są udostępniane po kolei, każdy poziom oprócz ostatniego musi mieć określoną ilość.", "This price is not on sale yet": "Ta cena nie jest jeszcze w sprzedaży.", - "Price is locked until earlier tiers sell out": "Cena jest zablokowana do momentu wyprzedania wcześniejszych poziomów." + "Price is locked until earlier tiers sell out": "Cena jest zablokowana do momentu wyprzedania wcześniejszych poziomów.", + "A reversal cannot itself be reversed.": "Storna nie można ponownie stornować.", + "Attendee": "Uczestnik", + "Balance After": "Saldo po", + "Balance refund": "Zwrot salda", + "Balance refunded via :method": "Saldo zwrócone przez :method", + "Balance refunds are not open for this event.": "Zwroty salda nie są otwarte dla tego wydarzenia.", + "Cashless credit": "Kredyt cashless", + "Cashless payments are not enabled for this event.": "Płatności cashless nie są włączone dla tego wydarzenia.", + "Cashless top-ups are not available for this event.": "Doładowania cashless nie są dostępne dla tego wydarzenia.", + "Date": "Data", + "Hello :name,": "Cześć :name,", + "Highest balance first": "Najwyższe saldo najpierw", + "Highest spend first": "Największe wydatki najpierw", + "Items": "Pozycje", + "Largest amount first": "Największa kwota najpierw", + "Lowest balance first": "Najniższe saldo najpierw", + "Lowest spend first": "Najmniejsze wydatki najpierw", + "New balance": "Nowe saldo", + "No ticket was found for this code.": "Nie znaleziono biletu dla tego kodu.", + "Online top-up": "Doładowanie online", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Doładowania online są wyłączone dla tego wydarzenia. Doładuj w punkcie sprzedaży.", + "Only general products can be sold at a cashless sales point.": "W punkcie sprzedaży cashless można sprzedawać tylko produkty ogólne.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Płać cashless na wydarzeniu: doładuj saldo tego biletu i płać jego kodem QR.", + "Payment Method": "Metoda płatności", + "Purchase": "Zakup", + "Reference": "Numer referencyjny", + "Reversal": "Storno", + "Sales Point": "Punkt sprzedaży", + "Settings for event :id could not be found.": "Nie znaleziono ustawień wydarzenia :id.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Pokaż kod QR biletu w dowolnym barze lub na stoisku, aby zapłacić — bez gotówki.", + "Smallest amount first": "Najmniejsza kwota najpierw", + "Staff top-up": "Doładowanie przez zespół", + "That PIN is not correct.": "Ten PIN jest nieprawidłowy.", + "That product is not sold at this sales point.": "Ten produkt nie jest sprzedawany w tym punkcie sprzedaży.", + "That transaction was not made at this sales point.": "Ta transakcja nie została wykonana w tym punkcie sprzedaży.", + "The cashless balance is too low for this transaction.": "Saldo cashless jest zbyt niskie na tę transakcję.", + "The minimum top-up amount is :amount": "Minimalna kwota doładowania to :amount", + "This balance is already empty.": "To saldo jest już puste.", + "This cashless wallet could not be found.": "Nie znaleziono tego salda cashless.", + "This cashless wallet is not active.": "To saldo cashless jest nieaktywne.", + "This sales point could not be found.": "Nie znaleziono tego punktu sprzedaży.", + "This sales point is not allowed to top up balances.": "Ten punkt sprzedaży nie może doładowywać sald.", + "This sales point is not currently open.": "Ten punkt sprzedaży jest obecnie zamknięty.", + "This ticket is not active, so it cannot hold a cashless balance.": "Ten bilet jest nieaktywny, więc nie może mieć salda cashless.", + "This till session has expired. Please enter the PIN again.": "Sesja kasy wygasła. Wpisz PIN ponownie.", + "This transaction could not be found.": "Nie znaleziono tej transakcji.", + "This transaction has already been reversed.": "Ta transakcja została już stornowana.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Ta transakcja jest zbyt stara, aby ją tu stornować. Skontaktuj się z organizatorem.", + "Ticket ID": "Identyfikator biletu", + "Top up my balance": "Doładuj moje saldo", + "Top up the balance attached to your ticket and pay cashless at the event.": "Doładuj saldo powiązane z Twoim biletem i płać cashless na wydarzeniu.", + "Type": "Typ", + "View my balance": "Zobacz moje saldo", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Dodaliśmy **:amount** do salda cashless powiązanego z Twoim biletem na **:event**.", + "Your cashless balance is topped up": "Twoje saldo cashless zostało doładowane", + "💳 Your cashless balance for :event": "💳 Twoje saldo cashless na :event", + "Cashless Top-up": "Doładowanie cashless", + "Sent to attendees when their cashless balance is topped up online": "Wysyłane uczestnikom, gdy ich saldo cashless zostanie doładowane online", + "The amount that was just added to the balance": "Kwota, która właśnie została dodana do salda", + "The balance available after this top-up": "Saldo dostępne po tym doładowaniu", + "Link to view the cashless balance": "Link do podglądu salda cashless", + "Balance closure": "Zamknięcie salda", + "A cashless closure cannot be reversed.": "Zamknięcia cashless nie można cofnąć.", + "Cashless has already been closed for this event.": "Cashless został już zamknięty dla tego wydarzenia.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Zwroty salda są nadal otwarte. Wyłącz je lub poczekaj na termin zwrotów przed zamknięciem cashless.", + "A closed cashless wallet cannot be changed.": "Zamkniętego portfela cashless nie można zmienić.", + "The attendee's full name": "Pełne imię i nazwisko uczestnika", + "The attendee's email": "Adres e-mail uczestnika" } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 4f600e4a68..9970919171 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "O evento deve estar aguardando revisão manual para ser aprovado", "Event must be pending manual review to be confirmed as spam": "O evento deve estar aguardando revisão manual para ser confirmado como spam", "Event approved and published": "Evento aprovado e publicado", - "Event confirmed as spam": "Evento confirmado como spam" + "Event confirmed as spam": "Evento confirmado como spam", + "A reversal cannot itself be reversed.": "Um cancelamento não pode ser cancelado.", + "Amount": "Valor", + "Attendee": "Participante", + "Balance After": "Saldo depois", + "Balance refund": "Reembolso de saldo", + "Balance refunded via :method": "Saldo reembolsado via :method", + "Balance refunds are not open for this event.": "Os reembolsos de saldo não estão abertos para este evento.", + "Cashless credit": "Crédito cashless", + "Cashless payments are not enabled for this event.": "Os pagamentos cashless não estão ativados para este evento.", + "Cashless top-ups are not available for this event.": "As recargas cashless não estão disponíveis para este evento.", + "Date": "Data", + "Hello :name,": "Olá :name,", + "Highest balance first": "Maior saldo primeiro", + "Highest spend first": "Maior gasto primeiro", + "Items": "Itens", + "Largest amount first": "Maior valor primeiro", + "Lowest balance first": "Menor saldo primeiro", + "Lowest spend first": "Menor gasto primeiro", + "New balance": "Novo saldo", + "No ticket was found for this code.": "Nenhum ingresso foi encontrado para este código.", + "Online top-up": "Recarga online", + "Online top-ups are turned off for this event. Please top up at a sales point.": "As recargas online estão desativadas para este evento. Recarregue em um ponto de venda.", + "Only general products can be sold at a cashless sales point.": "Em um ponto de venda cashless só podem ser vendidos produtos gerais.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Pague cashless no evento: recarregue o saldo deste ingresso e pague com o QR code dele.", + "Payment Method": "Forma de pagamento", + "Purchase": "Compra", + "Reference": "Referência", + "Reversal": "Cancelamento", + "Sales Point": "Ponto de venda", + "Settings for event :id could not be found.": "As configurações do evento :id não foram encontradas.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Mostre o QR code do seu ingresso em qualquer bar ou barraca para pagar, sem dinheiro.", + "Smallest amount first": "Menor valor primeiro", + "Staff top-up": "Recarga pela equipe", + "That PIN is not correct.": "Esse PIN não está correto.", + "That product is not sold at this sales point.": "Esse produto não é vendido neste ponto de venda.", + "That transaction was not made at this sales point.": "Essa transação não foi feita neste ponto de venda.", + "The cashless balance is too low for this transaction.": "O saldo cashless é insuficiente para esta transação.", + "The minimum top-up amount is :amount": "O valor mínimo de recarga é :amount", + "This balance is already empty.": "Este saldo já está vazio.", + "This cashless wallet could not be found.": "Este saldo cashless não foi encontrado.", + "This cashless wallet is not active.": "Este saldo cashless não está ativo.", + "This sales point could not be found.": "Este ponto de venda não foi encontrado.", + "This sales point is not allowed to top up balances.": "Este ponto de venda não pode recarregar saldos.", + "This sales point is not currently open.": "Este ponto de venda não está aberto no momento.", + "This ticket is not active, so it cannot hold a cashless balance.": "Este ingresso não está ativo, portanto não pode ter saldo cashless.", + "This till session has expired. Please enter the PIN again.": "A sessão do caixa expirou. Digite o PIN novamente.", + "This transaction could not be found.": "Esta transação não foi encontrada.", + "This transaction has already been reversed.": "Esta transação já foi cancelada.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Esta transação é muito antiga para ser cancelada aqui. Fale com o organizador.", + "Ticket ID": "ID do ingresso", + "Top up my balance": "Recarregar meu saldo", + "Top up the balance attached to your ticket and pay cashless at the event.": "Recarregue o saldo associado ao seu ingresso e pague cashless no evento.", + "Type": "Tipo", + "View my balance": "Ver meu saldo", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Adicionamos **:amount** ao saldo cashless associado ao seu ingresso para **:event**.", + "Your cashless balance is topped up": "Seu saldo cashless foi recarregado", + "💳 Your cashless balance for :event": "💳 Seu saldo cashless para :event", + "Cashless Top-up": "Recarga cashless", + "Sent to attendees when their cashless balance is topped up online": "Enviado aos participantes quando o saldo cashless é recarregado online", + "The amount that was just added to the balance": "O valor que acabou de ser adicionado ao saldo", + "The balance available after this top-up": "O saldo disponível após esta recarga", + "Link to view the cashless balance": "Link para ver o saldo cashless", + "Balance closure": "Encerramento de saldo", + "A cashless closure cannot be reversed.": "Um encerramento cashless não pode ser revertido.", + "Cashless has already been closed for this event.": "O cashless já foi encerrado para este evento.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Os reembolsos de saldo ainda estão abertos. Desative-os ou aguarde o prazo de reembolso antes de encerrar o cashless.", + "A closed cashless wallet cannot be changed.": "Uma carteira cashless encerrada não pode ser alterada.", + "The attendee's full name": "O nome completo do participante", + "The attendee's email": "O e-mail do participante" } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 2e8178f2c0..8308567c18 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "O evento tem de estar a aguardar revisão manual para poder ser aprovado", "Event must be pending manual review to be confirmed as spam": "O evento tem de estar a aguardar revisão manual para poder ser confirmado como spam", "Event approved and published": "Evento aprovado e publicado", - "Event confirmed as spam": "Evento confirmado como spam" + "Event confirmed as spam": "Evento confirmado como spam", + "A reversal cannot itself be reversed.": "Uma anulação não pode ser anulada.", + "Amount": "Montante", + "Attendee": "Participante", + "Balance After": "Saldo depois", + "Balance refund": "Reembolso de saldo", + "Balance refunded via :method": "Saldo reembolsado através de :method", + "Balance refunds are not open for this event.": "Os reembolsos de saldo não estão abertos para este evento.", + "Cashless credit": "Crédito cashless", + "Cashless payments are not enabled for this event.": "Os pagamentos cashless não estão ativados para este evento.", + "Cashless top-ups are not available for this event.": "Os carregamentos cashless não estão disponíveis para este evento.", + "Date": "Data", + "Hello :name,": "Olá :name,", + "Highest balance first": "Maior saldo primeiro", + "Highest spend first": "Maior despesa primeiro", + "Items": "Artigos", + "Largest amount first": "Maior montante primeiro", + "Lowest balance first": "Menor saldo primeiro", + "Lowest spend first": "Menor despesa primeiro", + "New balance": "Novo saldo", + "No ticket was found for this code.": "Não foi encontrado nenhum bilhete para este código.", + "Online top-up": "Carregamento online", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Os carregamentos online estão desativados para este evento. Carregue num ponto de venda.", + "Only general products can be sold at a cashless sales point.": "Num ponto de venda cashless só podem ser vendidos produtos gerais.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Pague cashless no evento: carregue o saldo deste bilhete e pague com o respetivo código QR.", + "Payment Method": "Método de pagamento", + "Purchase": "Compra", + "Reference": "Referência", + "Reversal": "Anulação", + "Sales Point": "Ponto de venda", + "Settings for event :id could not be found.": "Não foram encontradas as definições do evento :id.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Mostre o código QR do seu bilhete em qualquer bar ou banca para pagar, sem dinheiro.", + "Smallest amount first": "Menor montante primeiro", + "Staff top-up": "Carregamento pela equipa", + "That PIN is not correct.": "Esse PIN não está correto.", + "That product is not sold at this sales point.": "Esse produto não é vendido neste ponto de venda.", + "That transaction was not made at this sales point.": "Essa transação não foi feita neste ponto de venda.", + "The cashless balance is too low for this transaction.": "O saldo cashless é insuficiente para esta transação.", + "The minimum top-up amount is :amount": "O montante mínimo de carregamento é :amount", + "This balance is already empty.": "Este saldo já está vazio.", + "This cashless wallet could not be found.": "Este saldo cashless não foi encontrado.", + "This cashless wallet is not active.": "Este saldo cashless não está ativo.", + "This sales point could not be found.": "Este ponto de venda não foi encontrado.", + "This sales point is not allowed to top up balances.": "Este ponto de venda não pode carregar saldos.", + "This sales point is not currently open.": "Este ponto de venda não está aberto neste momento.", + "This ticket is not active, so it cannot hold a cashless balance.": "Este bilhete não está ativo, por isso não pode ter saldo cashless.", + "This till session has expired. Please enter the PIN again.": "A sessão da caixa expirou. Introduza novamente o PIN.", + "This transaction could not be found.": "Esta transação não foi encontrada.", + "This transaction has already been reversed.": "Esta transação já foi anulada.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Esta transação é demasiado antiga para ser anulada aqui. Contacte o organizador.", + "Ticket ID": "ID do bilhete", + "Top up my balance": "Carregar o meu saldo", + "Top up the balance attached to your ticket and pay cashless at the event.": "Carregue o saldo associado ao seu bilhete e pague cashless no evento.", + "Type": "Tipo", + "View my balance": "Ver o meu saldo", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Adicionámos **:amount** ao saldo cashless associado ao seu bilhete para **:event**.", + "Your cashless balance is topped up": "O seu saldo cashless foi carregado", + "💳 Your cashless balance for :event": "💳 O seu saldo cashless para :event", + "Cashless Top-up": "Carregamento cashless", + "Sent to attendees when their cashless balance is topped up online": "Enviado aos participantes quando o saldo cashless é carregado online", + "The amount that was just added to the balance": "O montante que acabou de ser adicionado ao saldo", + "The balance available after this top-up": "O saldo disponível após este carregamento", + "Link to view the cashless balance": "Ligação para ver o saldo cashless", + "Balance closure": "Encerramento de saldo", + "A cashless closure cannot be reversed.": "Um encerramento cashless não pode ser revertido.", + "Cashless has already been closed for this event.": "O cashless já foi encerrado para este evento.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Os reembolsos de saldo ainda estão abertos. Desative-os ou aguarde o prazo de reembolso antes de encerrar o cashless.", + "A closed cashless wallet cannot be changed.": "Uma carteira cashless encerrada não pode ser alterada.", + "The attendee's full name": "O nome completo do participante", + "The attendee's email": "O email do participante" } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 1523d351b4..9ae2f34da7 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -708,5 +708,74 @@ "Event must be pending manual review to be approved": "Событие должно ожидать ручной проверки, чтобы его можно было одобрить", "Event must be pending manual review to be confirmed as spam": "Событие должно ожидать ручной проверки, чтобы его можно было подтвердить как спам", "Event approved and published": "Событие одобрено и опубликовано", - "Event confirmed as spam": "Событие подтверждено как спам" + "Event confirmed as spam": "Событие подтверждено как спам", + "Cashless Top-up": "Пополнение cashless", + "Sent to attendees when their cashless balance is topped up online": "Отправляется участникам, когда их cashless-баланс пополнен онлайн", + "The amount that was just added to the balance": "Сумма, только что добавленная на баланс", + "The balance available after this top-up": "Баланс, доступный после этого пополнения", + "Link to view the cashless balance": "Ссылка для просмотра cashless-баланса", + "Balance closure": "Закрытие баланса", + "A cashless closure cannot be reversed.": "Закрытие cashless нельзя отменить.", + "Cashless has already been closed for this event.": "Cashless для этого мероприятия уже закрыт.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Возвраты баланса ещё открыты. Отключите их или дождитесь крайнего срока возврата перед закрытием cashless.", + "A closed cashless wallet cannot be changed.": "Закрытый кошелёк cashless нельзя изменить.", + "The attendee's full name": "Полное имя участника", + "The attendee's email": "Электронная почта участника", + "Largest amount first": "Сначала самая крупная сумма", + "Smallest amount first": "Сначала самая маленькая сумма", + "Highest balance first": "Сначала самый большой баланс", + "Lowest balance first": "Сначала самый маленький баланс", + "Highest spend first": "Сначала самые большие траты", + "Lowest spend first": "Сначала самые маленькие траты", + "Online top-up": "Онлайн-пополнение", + "Staff top-up": "Пополнение на месте", + "Purchase": "Покупка", + "Reversal": "Отмена", + "Balance refund": "Возврат баланса", + "Reference": "Референс", + "Date": "Дата", + "Type": "Тип", + "Amount": "Сумма", + "Balance After": "Баланс после", + "Ticket ID": "Номер билета", + "Attendee": "Участник", + "Sales Point": "Точка продаж", + "Payment Method": "Способ оплаты", + "Items": "Позиции", + "💳 Your cashless balance for :event": "💳 Ваш баланс cashless для :event", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Онлайн-пополнения отключены для этого мероприятия. Пополните баланс в точке продаж.", + "This cashless wallet is not active.": "Этот кошелёк cashless неактивен.", + "The minimum top-up amount is :amount": "Минимальная сумма пополнения — :amount", + "Cashless top-ups are not available for this event.": "Пополнение cashless недоступно для этого мероприятия.", + "This sales point is not allowed to top up balances.": "Эта точка продаж не может пополнять балансы.", + "That transaction was not made at this sales point.": "Эта транзакция не была совершена в этой точке продаж.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Эта транзакция слишком старая, чтобы отменить её здесь. Обратитесь к организатору.", + "This sales point could not be found.": "Точка продаж не найдена.", + "This cashless wallet could not be found.": "Кошелёк cashless не найден.", + "Balance refunds are not open for this event.": "Возврат баланса для этого мероприятия недоступен.", + "This balance is already empty.": "Этот баланс уже пуст.", + "Balance refunded via :method": "Баланс возвращён через :method", + "This transaction could not be found.": "Транзакция не найдена.", + "That PIN is not correct.": "Неверный PIN-код.", + "This till session has expired. Please enter the PIN again.": "Сессия кассы истекла. Введите PIN-код ещё раз.", + "This sales point is not currently open.": "Эта точка продаж сейчас закрыта.", + "That product is not sold at this sales point.": "Этот товар не продаётся в этой точке продаж.", + "Only general products can be sold at a cashless sales point.": "В точке продаж cashless можно продавать только обычные товары.", + "Settings for event :id could not be found.": "Настройки мероприятия :id не найдены.", + "Cashless payments are not enabled for this event.": "Платежи cashless не включены для этого мероприятия.", + "Cashless credit": "Кредит cashless", + "Top up the balance attached to your ticket and pay cashless at the event.": "Пополните баланс, привязанный к вашему билету, и оплачивайте cashless на мероприятии.", + "No ticket was found for this code.": "По этому коду билет не найден.", + "This ticket is not active, so it cannot hold a cashless balance.": "Этот билет неактивен, поэтому на нём не может быть баланса cashless.", + "A reversal cannot itself be reversed.": "Отмену нельзя отменить.", + "This transaction has already been reversed.": "Эта транзакция уже отменена.", + "The cashless balance is too low for this transaction.": "Баланс cashless слишком мал для этой транзакции.", + "View my balance": "Посмотреть мой баланс", + "Your cashless balance is topped up": "Ваш баланс cashless пополнен", + "Hello :name,": "Здравствуйте, :name!", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Мы добавили **:amount** на баланс cashless, привязанный к вашему билету на **:event**.", + "New balance": "Новый баланс", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Покажите QR-код вашего билета в любом баре или на стенде, чтобы заплатить — без наличных.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Платите cashless на мероприятии: пополните баланс этого билета и оплачивайте его QR-кодом.", + "Top up my balance": "Пополнить баланс" } diff --git a/backend/lang/se.json b/backend/lang/se.json index 22dca73b30..9522838156 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "Evenemanget måste vänta på manuell granskning för att kunna godkännas", "Event must be pending manual review to be confirmed as spam": "Evenemanget måste vänta på manuell granskning för att kunna bekräftas som spam", "Event approved and published": "Evenemanget har godkänts och publicerats", - "Event confirmed as spam": "Evenemanget har bekräftats som spam" + "Event confirmed as spam": "Evenemanget har bekräftats som spam", + "A reversal cannot itself be reversed.": "En makulering kan inte makuleras igen.", + "Amount": "Belopp", + "Attendee": "Deltagare", + "Balance After": "Saldo efter", + "Balance refund": "Återbetalning av saldo", + "Balance refunded via :method": "Saldo återbetalat via :method", + "Balance refunds are not open for this event.": "Återbetalning av saldo är inte öppen för det här evenemanget.", + "Cashless credit": "Cashless-kredit", + "Cashless payments are not enabled for this event.": "Cashless-betalningar är inte aktiverade för det här evenemanget.", + "Cashless top-ups are not available for this event.": "Cashless-påfyllning är inte tillgänglig för det här evenemanget.", + "Date": "Datum", + "Hello :name,": "Hej :name,", + "Highest balance first": "Högst saldo först", + "Highest spend first": "Högst utgifter först", + "Items": "Artiklar", + "Largest amount first": "Störst belopp först", + "Lowest balance first": "Lägst saldo först", + "Lowest spend first": "Lägst utgifter först", + "New balance": "Nytt saldo", + "No ticket was found for this code.": "Ingen biljett hittades för den här koden.", + "Online top-up": "Påfyllning online", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Påfyllning online är avstängd för det här evenemanget. Fyll på vid ett försäljningsställe.", + "Only general products can be sold at a cashless sales point.": "Vid ett cashless-försäljningsställe kan bara allmänna produkter säljas.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Betala cashless på plats: fyll på saldot på den här biljetten och betala med dess QR-kod.", + "Payment Method": "Betalningssätt", + "Purchase": "Köp", + "Reference": "Referens", + "Reversal": "Makulering", + "Sales Point": "Försäljningsställe", + "Settings for event :id could not be found.": "Inställningarna för evenemang :id hittades inte.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Visa QR-koden på din biljett i valfri bar eller valfritt stånd för att betala, utan kontanter.", + "Smallest amount first": "Minst belopp först", + "Staff top-up": "Påfyllning av personal", + "That PIN is not correct.": "Koden är felaktig.", + "That product is not sold at this sales point.": "Den produkten säljs inte vid det här försäljningsstället.", + "That transaction was not made at this sales point.": "Den transaktionen gjordes inte vid det här försäljningsstället.", + "The cashless balance is too low for this transaction.": "Cashless-saldot räcker inte för den här transaktionen.", + "The minimum top-up amount is :amount": "Minsta påfyllningsbelopp är :amount", + "This balance is already empty.": "Det här saldot är redan tomt.", + "This cashless wallet could not be found.": "Det här cashless-saldot hittades inte.", + "This cashless wallet is not active.": "Det här cashless-saldot är inte aktivt.", + "This sales point could not be found.": "Det här försäljningsstället hittades inte.", + "This sales point is not allowed to top up balances.": "Det här försäljningsstället får inte fylla på saldon.", + "This sales point is not currently open.": "Det här försäljningsstället är inte öppet just nu.", + "This ticket is not active, so it cannot hold a cashless balance.": "Den här biljetten är inte aktiv och kan därför inte ha ett cashless-saldo.", + "This till session has expired. Please enter the PIN again.": "Kassans session har gått ut. Ange koden igen.", + "This transaction could not be found.": "Den här transaktionen hittades inte.", + "This transaction has already been reversed.": "Den här transaktionen har redan makulerats.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Den här transaktionen är för gammal för att makuleras här. Kontakta arrangören.", + "Ticket ID": "Biljett-ID", + "Top up my balance": "Fyll på mitt saldo", + "Top up the balance attached to your ticket and pay cashless at the event.": "Fyll på saldot kopplat till din biljett och betala cashless på plats.", + "Type": "Typ", + "View my balance": "Visa mitt saldo", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Vi har lagt till **:amount** på cashless-saldot kopplat till din biljett för **:event**.", + "Your cashless balance is topped up": "Ditt cashless-saldo har fyllts på", + "💳 Your cashless balance for :event": "💳 Ditt cashless-saldo för :event", + "Cashless Top-up": "Cashless-påfyllning", + "Sent to attendees when their cashless balance is topped up online": "Skickas till deltagare när deras cashless-saldo fylls på online", + "The amount that was just added to the balance": "Beloppet som just lades till saldot", + "The balance available after this top-up": "Saldot som är tillgängligt efter denna påfyllning", + "Link to view the cashless balance": "Länk för att se cashless-saldot", + "Balance closure": "Saldoavslut", + "A cashless closure cannot be reversed.": "En cashless-stängning kan inte ångras.", + "Cashless has already been closed for this event.": "Cashless har redan stängts för det här evenemanget.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Återbetalningar av saldo är fortfarande öppna. Stäng av dem eller vänta på återbetalningsfristen innan du stänger cashless.", + "A closed cashless wallet cannot be changed.": "En stängd cashless-plånbok kan inte ändras.", + "The attendee's full name": "Deltagarens fullständiga namn", + "The attendee's email": "Deltagarens e-post" } diff --git a/backend/lang/sk.json b/backend/lang/sk.json index a4bf682933..79648bc08a 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "Podujatie musí čakať na manuálnu kontrolu, aby mohlo byť schválené", "Event must be pending manual review to be confirmed as spam": "Podujatie musí čakať na manuálnu kontrolu, aby mohlo byť potvrdené ako spam", "Event approved and published": "Podujatie bolo schválené a zverejnené", - "Event confirmed as spam": "Podujatie bolo potvrdené ako spam" + "Event confirmed as spam": "Podujatie bolo potvrdené ako spam", + "A reversal cannot itself be reversed.": "Storno nemožno znova stornovať.", + "Amount": "Suma", + "Attendee": "Účastník", + "Balance After": "Zostatok po", + "Balance refund": "Vrátenie zostatku", + "Balance refunded via :method": "Zostatok vrátený cez :method", + "Balance refunds are not open for this event.": "Vrátenie zostatku nie je pre toto podujatie otvorené.", + "Cashless credit": "Cashless kredit", + "Cashless payments are not enabled for this event.": "Cashless platby nie sú pre toto podujatie zapnuté.", + "Cashless top-ups are not available for this event.": "Cashless dobitia nie sú pre toto podujatie k dispozícii.", + "Date": "Dátum", + "Hello :name,": "Ahoj :name,", + "Highest balance first": "Najvyšší zostatok ako prvý", + "Highest spend first": "Najvyššie výdavky ako prvé", + "Items": "Položky", + "Largest amount first": "Najvyššia suma ako prvá", + "Lowest balance first": "Najnižší zostatok ako prvý", + "Lowest spend first": "Najnižšie výdavky ako prvé", + "New balance": "Nový zostatok", + "No ticket was found for this code.": "Pre tento kód sa nenašiel žiadny lístok.", + "Online top-up": "Online dobitie", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Online dobitia sú pre toto podujatie vypnuté. Dobite na predajnom mieste.", + "Only general products can be sold at a cashless sales point.": "Na cashless predajnom mieste sa dajú predávať len všeobecné produkty.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Plaťte cashless na podujatí: dobite zostatok tohto lístka a plaťte jeho QR kódom.", + "Payment Method": "Spôsob platby", + "Purchase": "Nákup", + "Reference": "Referencia", + "Reversal": "Storno", + "Sales Point": "Predajné miesto", + "Settings for event :id could not be found.": "Nastavenia podujatia :id sa nenašli.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Ukážte QR kód na svojom lístku v ktoromkoľvek bare či stánku a zaplaťte bez hotovosti.", + "Smallest amount first": "Najnižšia suma ako prvá", + "Staff top-up": "Dobitie tímom", + "That PIN is not correct.": "Tento PIN nie je správny.", + "That product is not sold at this sales point.": "Tento produkt sa na tomto predajnom mieste nepredáva.", + "That transaction was not made at this sales point.": "Táto transakcia nebola uskutočnená na tomto predajnom mieste.", + "The cashless balance is too low for this transaction.": "Cashless zostatok nestačí na túto transakciu.", + "The minimum top-up amount is :amount": "Minimálna suma dobitia je :amount", + "This balance is already empty.": "Tento zostatok je už prázdny.", + "This cashless wallet could not be found.": "Tento cashless zostatok sa nenašiel.", + "This cashless wallet is not active.": "Tento cashless zostatok nie je aktívny.", + "This sales point could not be found.": "Toto predajné miesto sa nenašlo.", + "This sales point is not allowed to top up balances.": "Toto predajné miesto nesmie dobíjať zostatky.", + "This sales point is not currently open.": "Toto predajné miesto momentálne nie je otvorené.", + "This ticket is not active, so it cannot hold a cashless balance.": "Tento lístok nie je aktívny, preto nemôže mať cashless zostatok.", + "This till session has expired. Please enter the PIN again.": "Relácia pokladne vypršala. Zadajte PIN znova.", + "This transaction could not be found.": "Táto transakcia sa nenašla.", + "This transaction has already been reversed.": "Táto transakcia už bola stornovaná.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Táto transakcia je príliš stará na storno tu. Obráťte sa na organizátora.", + "Ticket ID": "ID lístka", + "Top up my balance": "Dobiť môj zostatok", + "Top up the balance attached to your ticket and pay cashless at the event.": "Dobite zostatok viazaný na váš lístok a plaťte cashless na podujatí.", + "Type": "Typ", + "View my balance": "Zobraziť môj zostatok", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Pripísali sme **:amount** na cashless zostatok viazaný na váš lístok na **:event**.", + "Your cashless balance is topped up": "Váš cashless zostatok bol dobitý", + "💳 Your cashless balance for :event": "💳 Váš cashless zostatok pre :event", + "Cashless Top-up": "Cashless dobitie", + "Sent to attendees when their cashless balance is topped up online": "Odosiela sa účastníkom, keď sa ich cashless zostatok dobije online", + "The amount that was just added to the balance": "Suma, ktorá bola práve pripísaná na zostatok", + "The balance available after this top-up": "Zostatok dostupný po tomto dobití", + "Link to view the cashless balance": "Odkaz na zobrazenie cashless zostatku", + "Balance closure": "Uzavretie zostatku", + "A cashless closure cannot be reversed.": "Uzavretie cashless sa nedá zrušiť.", + "Cashless has already been closed for this event.": "Cashless už bol pre túto udalosť uzavretý.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Vrátenie zostatkov je stále otvorené. Pred uzavretím cashless ho vypnite alebo počkajte na lehotu vrátenia.", + "A closed cashless wallet cannot be changed.": "Uzavretú cashless peňaženku nemožno zmeniť.", + "The attendee's full name": "Celé meno účastníka", + "The attendee's email": "E-mail účastníka" } diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 5532fd501f..0fbcbaff10 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -735,5 +735,74 @@ "Event must be pending manual review to be approved": "Etkinliğin onaylanabilmesi için manuel inceleme bekliyor olması gerekir", "Event must be pending manual review to be confirmed as spam": "Etkinliğin spam olarak onaylanabilmesi için manuel inceleme bekliyor olması gerekir", "Event approved and published": "Etkinlik onaylandı ve yayınlandı", - "Event confirmed as spam": "Etkinlik spam olarak onaylandı" + "Event confirmed as spam": "Etkinlik spam olarak onaylandı", + "A reversal cannot itself be reversed.": "Bir iptal işlemi tekrar iptal edilemez.", + "Amount": "Tutar", + "Attendee": "Katılımcı", + "Balance After": "İşlem sonrası bakiye", + "Balance refund": "Bakiye iadesi", + "Balance refunded via :method": "Bakiye :method ile iade edildi", + "Balance refunds are not open for this event.": "Bu etkinlik için bakiye iadeleri açık değil.", + "Cashless credit": "Nakitsiz kredi", + "Cashless payments are not enabled for this event.": "Bu etkinlik için nakitsiz ödemeler etkin değil.", + "Cashless top-ups are not available for this event.": "Bu etkinlik için nakitsiz yüklemeler kullanılamıyor.", + "Date": "Tarih", + "Hello :name,": "Merhaba :name,", + "Highest balance first": "Önce en yüksek bakiye", + "Highest spend first": "Önce en yüksek harcama", + "Items": "Öğeler", + "Largest amount first": "Önce en yüksek tutar", + "Lowest balance first": "Önce en düşük bakiye", + "Lowest spend first": "Önce en düşük harcama", + "New balance": "Yeni bakiye", + "No ticket was found for this code.": "Bu koda ait bilet bulunamadı.", + "Online top-up": "Çevrimiçi yükleme", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Bu etkinlik için çevrimiçi yükleme kapalı. Lütfen bir satış noktasında yükleyin.", + "Only general products can be sold at a cashless sales point.": "Nakitsiz satış noktasında yalnızca genel ürünler satılabilir.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Etkinlikte nakitsiz ödeyin: bu biletin bakiyesini yükleyin ve QR koduyla ödeyin.", + "Payment Method": "Ödeme yöntemi", + "Purchase": "Satın alma", + "Reference": "Referans", + "Reversal": "İptal", + "Sales Point": "Satış noktası", + "Settings for event :id could not be found.": "Etkinlik :id için ayarlar bulunamadı.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Ödemek için biletinizdeki QR kodunu herhangi bir barda veya stantta gösterin, nakit gerekmez.", + "Smallest amount first": "Önce en düşük tutar", + "Staff top-up": "Ekip yüklemesi", + "That PIN is not correct.": "Bu PIN doğru değil.", + "That product is not sold at this sales point.": "Bu ürün bu satış noktasında satılmıyor.", + "That transaction was not made at this sales point.": "Bu işlem bu satış noktasında yapılmadı.", + "The cashless balance is too low for this transaction.": "Nakitsiz bakiye bu işlem için yetersiz.", + "The minimum top-up amount is :amount": "Asgari yükleme tutarı :amount", + "This balance is already empty.": "Bu bakiye zaten boş.", + "This cashless wallet could not be found.": "Bu nakitsiz bakiye bulunamadı.", + "This cashless wallet is not active.": "Bu nakitsiz bakiye etkin değil.", + "This sales point could not be found.": "Bu satış noktası bulunamadı.", + "This sales point is not allowed to top up balances.": "Bu satış noktasının bakiye yükleme izni yok.", + "This sales point is not currently open.": "Bu satış noktası şu anda açık değil.", + "This ticket is not active, so it cannot hold a cashless balance.": "Bu bilet etkin değil, bu yüzden nakitsiz bakiye tutamaz.", + "This till session has expired. Please enter the PIN again.": "Kasa oturumunun süresi doldu. Lütfen PIN'i tekrar girin.", + "This transaction could not be found.": "Bu işlem bulunamadı.", + "This transaction has already been reversed.": "Bu işlem zaten iptal edilmiş.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Bu işlem burada iptal edilemeyecek kadar eski. Lütfen organizatöre başvurun.", + "Ticket ID": "Bilet kimliği", + "Top up my balance": "Bakiyemi yükle", + "Top up the balance attached to your ticket and pay cashless at the event.": "Biletinize bağlı bakiyeyi yükleyin ve etkinlikte nakitsiz ödeyin.", + "Type": "Tür", + "View my balance": "Bakiyemi görüntüle", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "**:event** için biletinize bağlı nakitsiz bakiyeye **:amount** ekledik.", + "Your cashless balance is topped up": "Nakitsiz bakiyeniz yüklendi", + "💳 Your cashless balance for :event": "💳 :event için nakitsiz bakiyeniz", + "Cashless Top-up": "Cashless bakiye yükleme", + "Sent to attendees when their cashless balance is topped up online": "Cashless bakiyeleri çevrimiçi yüklendiğinde katılımcılara gönderilir", + "The amount that was just added to the balance": "Bakiyeye az önce eklenen tutar", + "The balance available after this top-up": "Bu yüklemeden sonra kullanılabilir bakiye", + "Link to view the cashless balance": "Cashless bakiyesini görüntüleme bağlantısı", + "Balance closure": "Bakiye kapanışı", + "A cashless closure cannot be reversed.": "Bir cashless kapanışı geri alınamaz.", + "Cashless has already been closed for this event.": "Bu etkinlik için cashless zaten kapatıldı.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Bakiye iadeleri hâlâ açık. Cashless'i kapatmadan önce bunları devre dışı bırakın veya iade son tarihini bekleyin.", + "A closed cashless wallet cannot be changed.": "Kapatılmış bir cashless cüzdanı değiştirilemez.", + "The attendee's full name": "Katılımcının tam adı", + "The attendee's email": "Katılımcının e-postası" } diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 270fa6627f..e159e01dd5 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "Sự kiện phải đang chờ xem xét thủ công thì mới có thể được phê duyệt", "Event must be pending manual review to be confirmed as spam": "Sự kiện phải đang chờ xem xét thủ công thì mới có thể được xác nhận là spam", "Event approved and published": "Sự kiện đã được phê duyệt và xuất bản", - "Event confirmed as spam": "Sự kiện đã được xác nhận là spam" + "Event confirmed as spam": "Sự kiện đã được xác nhận là spam", + "A reversal cannot itself be reversed.": "Không thể huỷ một giao dịch huỷ.", + "Amount": "Số tiền", + "Attendee": "Người tham dự", + "Balance After": "Số dư sau", + "Balance refund": "Hoàn số dư", + "Balance refunded via :method": "Số dư đã hoàn qua :method", + "Balance refunds are not open for this event.": "Chưa mở hoàn số dư cho sự kiện này.", + "Cashless credit": "Tín dụng cashless", + "Cashless payments are not enabled for this event.": "Sự kiện này chưa bật thanh toán cashless.", + "Cashless top-ups are not available for this event.": "Sự kiện này không hỗ trợ nạp tiền cashless.", + "Date": "Ngày", + "Hello :name,": "Xin chào :name,", + "Highest balance first": "Số dư cao nhất trước", + "Highest spend first": "Chi tiêu cao nhất trước", + "Items": "Mặt hàng", + "Largest amount first": "Số tiền lớn nhất trước", + "Lowest balance first": "Số dư thấp nhất trước", + "Lowest spend first": "Chi tiêu thấp nhất trước", + "New balance": "Số dư mới", + "No ticket was found for this code.": "Không tìm thấy vé nào cho mã này.", + "Online top-up": "Nạp tiền trực tuyến", + "Online top-ups are turned off for this event. Please top up at a sales point.": "Nạp tiền trực tuyến đã bị tắt cho sự kiện này. Vui lòng nạp tại một điểm bán.", + "Only general products can be sold at a cashless sales point.": "Điểm bán cashless chỉ có thể bán sản phẩm chung.", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "Thanh toán cashless tại sự kiện: nạp số dư của vé này và thanh toán bằng mã QR của vé.", + "Payment Method": "Phương thức thanh toán", + "Purchase": "Mua hàng", + "Reference": "Mã tham chiếu", + "Reversal": "Huỷ giao dịch", + "Sales Point": "Điểm bán", + "Settings for event :id could not be found.": "Không tìm thấy cài đặt của sự kiện :id.", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "Xuất trình mã QR trên vé tại bất kỳ quầy bar hay gian hàng nào để thanh toán, không cần tiền mặt.", + "Smallest amount first": "Số tiền nhỏ nhất trước", + "Staff top-up": "Nạp bởi nhân viên", + "That PIN is not correct.": "Mã PIN không đúng.", + "That product is not sold at this sales point.": "Sản phẩm đó không được bán tại điểm bán này.", + "That transaction was not made at this sales point.": "Giao dịch đó không được thực hiện tại điểm bán này.", + "The cashless balance is too low for this transaction.": "Số dư cashless không đủ cho giao dịch này.", + "The minimum top-up amount is :amount": "Số tiền nạp tối thiểu là :amount", + "This balance is already empty.": "Số dư này đã hết.", + "This cashless wallet could not be found.": "Không tìm thấy số dư cashless này.", + "This cashless wallet is not active.": "Số dư cashless này không hoạt động.", + "This sales point could not be found.": "Không tìm thấy điểm bán này.", + "This sales point is not allowed to top up balances.": "Điểm bán này không được phép nạp số dư.", + "This sales point is not currently open.": "Điểm bán này hiện chưa mở.", + "This ticket is not active, so it cannot hold a cashless balance.": "Vé này không hoạt động nên không thể có số dư cashless.", + "This till session has expired. Please enter the PIN again.": "Phiên quầy thu ngân đã hết hạn. Vui lòng nhập lại mã PIN.", + "This transaction could not be found.": "Không tìm thấy giao dịch này.", + "This transaction has already been reversed.": "Giao dịch này đã được huỷ.", + "This transaction is too old to be reversed here. Please ask the organizer.": "Giao dịch này quá cũ để huỷ tại đây. Vui lòng liên hệ ban tổ chức.", + "Ticket ID": "ID vé", + "Top up my balance": "Nạp số dư của tôi", + "Top up the balance attached to your ticket and pay cashless at the event.": "Nạp số dư gắn với vé của bạn và thanh toán cashless tại sự kiện.", + "Type": "Loại", + "View my balance": "Xem số dư của tôi", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "Chúng tôi đã cộng **:amount** vào số dư cashless gắn với vé của bạn cho **:event**.", + "Your cashless balance is topped up": "Số dư cashless của bạn đã được nạp", + "💳 Your cashless balance for :event": "💳 Số dư cashless của bạn cho :event", + "Cashless Top-up": "Nạp tiền cashless", + "Sent to attendees when their cashless balance is topped up online": "Gửi cho người tham dự khi số dư cashless của họ được nạp trực tuyến", + "The amount that was just added to the balance": "Số tiền vừa được thêm vào số dư", + "The balance available after this top-up": "Số dư khả dụng sau lần nạp này", + "Link to view the cashless balance": "Liên kết để xem số dư cashless", + "Balance closure": "Đóng số dư", + "A cashless closure cannot be reversed.": "Không thể hoàn tác việc đóng cashless.", + "Cashless has already been closed for this event.": "Cashless đã được đóng cho sự kiện này.", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "Việc hoàn tiền số dư vẫn đang mở. Hãy tắt nó hoặc chờ hạn hoàn tiền trước khi đóng cashless.", + "A closed cashless wallet cannot be changed.": "Không thể thay đổi ví cashless đã đóng.", + "The attendee's full name": "Họ tên đầy đủ của người tham dự", + "The attendee's email": "Email của người tham dự" } diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 88493dddba..c31e08aade 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "活动必须处于等待人工审核状态才能批准", "Event must be pending manual review to be confirmed as spam": "活动必须处于等待人工审核状态才能确认为垃圾内容", "Event approved and published": "活动已批准并发布", - "Event confirmed as spam": "活动已确认为垃圾内容" + "Event confirmed as spam": "活动已确认为垃圾内容", + "A reversal cannot itself be reversed.": "撤销操作本身不能再被撤销。", + "Amount": "金额", + "Attendee": "参与者", + "Balance After": "交易后余额", + "Balance refund": "余额退款", + "Balance refunded via :method": "余额已通过 :method 退还", + "Balance refunds are not open for this event.": "此活动未开放余额退款。", + "Cashless credit": "无现金额度", + "Cashless payments are not enabled for this event.": "此活动未启用无现金支付。", + "Cashless top-ups are not available for this event.": "此活动不支持无现金充值。", + "Date": "日期", + "Hello :name,": "您好 :name,", + "Highest balance first": "余额从高到低", + "Highest spend first": "消费从高到低", + "Items": "商品", + "Largest amount first": "金额从大到小", + "Lowest balance first": "余额从低到高", + "Lowest spend first": "消费从低到高", + "New balance": "新余额", + "No ticket was found for this code.": "未找到与该代码对应的门票。", + "Online top-up": "在线充值", + "Online top-ups are turned off for this event. Please top up at a sales point.": "此活动已关闭在线充值。请在销售点充值。", + "Only general products can be sold at a cashless sales point.": "无现金销售点只能销售通用商品。", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "现场无现金支付:为此门票充值,并使用其二维码付款。", + "Payment Method": "支付方式", + "Purchase": "消费", + "Reference": "参考号", + "Reversal": "撤销", + "Sales Point": "销售点", + "Settings for event :id could not be found.": "未找到活动 :id 的设置。", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "在任意吧台或摊位出示门票上的二维码即可付款,无需现金。", + "Smallest amount first": "金额从小到大", + "Staff top-up": "工作人员充值", + "That PIN is not correct.": "PIN 码不正确。", + "That product is not sold at this sales point.": "该商品不在此销售点出售。", + "That transaction was not made at this sales point.": "该交易不是在此销售点完成的。", + "The cashless balance is too low for this transaction.": "无现金余额不足,无法完成此交易。", + "The minimum top-up amount is :amount": "最低充值金额为 :amount", + "This balance is already empty.": "此余额已为空。", + "This cashless wallet could not be found.": "未找到此无现金余额。", + "This cashless wallet is not active.": "此无现金余额未启用。", + "This sales point could not be found.": "未找到此销售点。", + "This sales point is not allowed to top up balances.": "此销售点无权为余额充值。", + "This sales point is not currently open.": "此销售点当前未营业。", + "This ticket is not active, so it cannot hold a cashless balance.": "此门票未激活,因此无法持有无现金余额。", + "This till session has expired. Please enter the PIN again.": "此收银台会话已过期。请重新输入 PIN 码。", + "This transaction could not be found.": "未找到此交易。", + "This transaction has already been reversed.": "此交易已被撤销。", + "This transaction is too old to be reversed here. Please ask the organizer.": "此交易已过久,无法在此撤销。请联系主办方。", + "Ticket ID": "门票 ID", + "Top up my balance": "为我的余额充值", + "Top up the balance attached to your ticket and pay cashless at the event.": "为您门票关联的余额充值,并在活动现场使用无现金支付。", + "Type": "类型", + "View my balance": "查看我的余额", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "我们已为您在 **:event** 的门票关联的无现金余额充入 **:amount**。", + "Your cashless balance is topped up": "您的无现金余额已充值", + "💳 Your cashless balance for :event": "💳 您在 :event 的无现金余额", + "Cashless Top-up": "无现金充值", + "Sent to attendees when their cashless balance is topped up online": "当参与者的无现金余额在线充值后发送给他们", + "The amount that was just added to the balance": "刚刚添加到余额的金额", + "The balance available after this top-up": "本次充值后的可用余额", + "Link to view the cashless balance": "查看无现金余额的链接", + "Balance closure": "余额关闭", + "A cashless closure cannot be reversed.": "无现金关闭无法撤销。", + "Cashless has already been closed for this event.": "此活动的无现金已关闭。", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "余额退款仍处于开放状态。关闭无现金前请先停用退款或等待退款截止时间。", + "A closed cashless wallet cannot be changed.": "已关闭的无现金钱包无法更改。", + "The attendee's full name": "参与者的全名", + "The attendee's email": "参与者的电子邮箱" } diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index e8a03d5d7b..bfd1e87302 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -720,5 +720,74 @@ "Event must be pending manual review to be approved": "活動必須處於等待人工審核狀態才能批准", "Event must be pending manual review to be confirmed as spam": "活動必須處於等待人工審核狀態才能確認為垃圾內容", "Event approved and published": "活動已批准並發佈", - "Event confirmed as spam": "活動已確認為垃圾內容" + "Event confirmed as spam": "活動已確認為垃圾內容", + "A reversal cannot itself be reversed.": "撤銷操作本身不能再被撤銷。", + "Amount": "金額", + "Attendee": "參加者", + "Balance After": "交易後餘額", + "Balance refund": "餘額退款", + "Balance refunded via :method": "餘額已透過 :method 退還", + "Balance refunds are not open for this event.": "此活動未開放餘額退款。", + "Cashless credit": "無現金額度", + "Cashless payments are not enabled for this event.": "此活動未啟用無現金支付。", + "Cashless top-ups are not available for this event.": "此活動不支援無現金充值。", + "Date": "日期", + "Hello :name,": "你好 :name,", + "Highest balance first": "餘額由高至低", + "Highest spend first": "消費由高至低", + "Items": "商品", + "Largest amount first": "金額由大至小", + "Lowest balance first": "餘額由低至高", + "Lowest spend first": "消費由低至高", + "New balance": "新餘額", + "No ticket was found for this code.": "找不到與此代碼對應的門票。", + "Online top-up": "線上充值", + "Online top-ups are turned off for this event. Please top up at a sales point.": "此活動已關閉線上充值。請在銷售點充值。", + "Only general products can be sold at a cashless sales point.": "無現金銷售點只可銷售一般商品。", + "Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.": "現場無現金支付:為此門票充值,並使用其二維碼付款。", + "Payment Method": "付款方式", + "Purchase": "消費", + "Reference": "參考編號", + "Reversal": "撤銷", + "Sales Point": "銷售點", + "Settings for event :id could not be found.": "找不到活動 :id 的設定。", + "Show the QR code on your ticket at any bar or stand to pay — no cash needed.": "在任何吧檯或攤位出示門票上的二維碼即可付款,無須現金。", + "Smallest amount first": "金額由小至大", + "Staff top-up": "工作人員充值", + "That PIN is not correct.": "PIN 碼不正確。", + "That product is not sold at this sales point.": "該商品不在此銷售點出售。", + "That transaction was not made at this sales point.": "該交易並非在此銷售點完成。", + "The cashless balance is too low for this transaction.": "無現金餘額不足,無法完成此交易。", + "The minimum top-up amount is :amount": "最低充值金額為 :amount", + "This balance is already empty.": "此餘額已為空。", + "This cashless wallet could not be found.": "找不到此無現金餘額。", + "This cashless wallet is not active.": "此無現金餘額未啟用。", + "This sales point could not be found.": "找不到此銷售點。", + "This sales point is not allowed to top up balances.": "此銷售點無權為餘額充值。", + "This sales point is not currently open.": "此銷售點目前未營業。", + "This ticket is not active, so it cannot hold a cashless balance.": "此門票未啟用,因此無法持有無現金餘額。", + "This till session has expired. Please enter the PIN again.": "此收銀機工作階段已過期。請重新輸入 PIN 碼。", + "This transaction could not be found.": "找不到此交易。", + "This transaction has already been reversed.": "此交易已被撤銷。", + "This transaction is too old to be reversed here. Please ask the organizer.": "此交易已過久,無法在此撤銷。請聯絡主辦方。", + "Ticket ID": "門票 ID", + "Top up my balance": "為我的餘額充值", + "Top up the balance attached to your ticket and pay cashless at the event.": "為你門票關聯的餘額充值,並在活動現場使用無現金支付。", + "Type": "類型", + "View my balance": "查看我的餘額", + "We have added **:amount** to the cashless balance attached to your ticket for **:event**.": "我們已為你在 **:event** 的門票關聯的無現金餘額充入 **:amount**。", + "Your cashless balance is topped up": "你的無現金餘額已充值", + "💳 Your cashless balance for :event": "💳 你在 :event 的無現金餘額", + "Cashless Top-up": "無現金增值", + "Sent to attendees when their cashless balance is topped up online": "當參與者的無現金餘額在線增值後發送給他們", + "The amount that was just added to the balance": "剛剛添加到餘額的金額", + "The balance available after this top-up": "本次增值後的可用餘額", + "Link to view the cashless balance": "查看無現金餘額的連結", + "Balance closure": "餘額關閉", + "A cashless closure cannot be reversed.": "無現金關閉無法撤銷。", + "Cashless has already been closed for this event.": "此活動的無現金已關閉。", + "Balance refunds are still open. Turn them off or wait for the refund deadline before closing cashless.": "餘額退款仍處於開放狀態。關閉無現金前請先停用退款或等待退款截止時間。", + "A closed cashless wallet cannot be changed.": "已關閉的無現金錢包無法更改。", + "The attendee's full name": "參加者的全名", + "The attendee's email": "參加者的電郵" } diff --git a/backend/resources/views/emails/cashless/topup-confirmation.blade.php b/backend/resources/views/emails/cashless/topup-confirmation.blade.php new file mode 100644 index 0000000000..b6f757e4a8 --- /dev/null +++ b/backend/resources/views/emails/cashless/topup-confirmation.blade.php @@ -0,0 +1,32 @@ +@php /** @var \HiEvents\DomainObjects\EventDomainObject $event */ @endphp +@php /** @var \HiEvents\DomainObjects\EventSettingDomainObject $eventSettings */ @endphp +@php /** @var \HiEvents\DomainObjects\OrganizerDomainObject $organizer */ @endphp +@php /** @var \HiEvents\DomainObjects\AttendeeDomainObject $attendee */ @endphp +@php /** @var string $toppedUpAmount */ @endphp +@php /** @var string $newBalance */ @endphp +@php /** @var string $walletUrl */ @endphp + +@php /** @see \HiEvents\Mail\Cashless\CashlessTopupConfirmationMail */ @endphp + + +# {{ __('Your cashless balance is topped up') }} + +{{ __('Hello :name,', ['name' => $attendee->getFirstName()]) }} + +{{ __('We have added **:amount** to the cashless balance attached to your ticket for **:event**.', ['amount' => $toppedUpAmount, 'event' => $event->getTitle()]) }} + +{{ __('New balance') }}: **{{ $newBalance }}** + +{{ __('Show the QR code on your ticket at any bar or stand to pay — no cash needed.') }} + + +{{ __('View my balance') }} + + +{{ __('If you have any questions or need assistance, please respond to this email.') }} + +{{ __('Thank you') }},
+{{ $organizer->getName() ?: config('app.name') }} + +{!! $eventSettings->getGetEmailFooterHtml() !!} +
diff --git a/backend/resources/views/emails/orders/attendee-ticket.blade.php b/backend/resources/views/emails/orders/attendee-ticket.blade.php index 2e7514b488..0a6e01e624 100644 --- a/backend/resources/views/emails/orders/attendee-ticket.blade.php +++ b/backend/resources/views/emails/orders/attendee-ticket.blade.php @@ -6,6 +6,7 @@ @php /** @var \HiEvents\DomainObjects\OrderDomainObject $order */ @endphp @php /** @var \HiEvents\DomainObjects\EventOccurrenceDomainObject|null $occurrence */ @endphp @php /** @var string $ticketUrl */ @endphp +@php /** @var ?string $cashlessWalletUrl */ @endphp @php /** @see \HiEvents\Mail\Attendee\AttendeeTicketMail */ @endphp @php @@ -68,6 +69,14 @@ {{ __('View Ticket') }} +@if($cashlessWalletUrl) +{{ __('Pay cashless at the event: top up the balance attached to this ticket and pay with its QR code.') }} + + +{{ __('Top up my balance') }} + +@endif + {{ __('If you have any questions or need assistance, please reply to this email or contact the event organizer') }} {{ __('at') }} {{$eventSettings->getSupportEmail()}}. diff --git a/backend/routes/api.php b/backend/routes/api.php index bea4da1ff3..7a6a5a7189 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -76,6 +76,35 @@ use HiEvents\Http\Actions\CapacityAssignments\GetCapacityAssignmentAction; use HiEvents\Http\Actions\CapacityAssignments\GetCapacityAssignmentsAction; use HiEvents\Http\Actions\CapacityAssignments\UpdateCapacityAssignmentAction; +use HiEvents\Http\Actions\Cashless\CloseCashlessAction; +use HiEvents\Http\Actions\Cashless\GetCashlessSettingsAction; +use HiEvents\Http\Actions\Cashless\GetCashlessStatsAction; +use HiEvents\Http\Actions\Cashless\GetCashlessSummaryAction; +use HiEvents\Http\Actions\Cashless\Public\CreateCashlessPurchasePublicAction; +use HiEvents\Http\Actions\Cashless\Public\CreateCashlessSalesPointSessionAction; +use HiEvents\Http\Actions\Cashless\Public\CreateCashlessTopupPublicAction; +use HiEvents\Http\Actions\Cashless\Public\CreateStaffTopupPublicAction; +use HiEvents\Http\Actions\Cashless\Public\GetCashlessQuotePublicAction; +use HiEvents\Http\Actions\Cashless\Public\GetCashlessSalesPointPublicAction; +use HiEvents\Http\Actions\Cashless\Public\GetCashlessSalesPointTransactionsPublicAction; +use HiEvents\Http\Actions\Cashless\Public\GetCashlessWalletForSalesPointAction; +use HiEvents\Http\Actions\Cashless\Public\GetCashlessWalletPublicAction; +use HiEvents\Http\Actions\Cashless\Public\ReverseCashlessTransactionPublicAction; +use HiEvents\Http\Actions\Cashless\SalesPoint\CreateCashlessSalesPointAction; +use HiEvents\Http\Actions\Cashless\SalesPoint\DeleteCashlessSalesPointAction; +use HiEvents\Http\Actions\Cashless\SalesPoint\GetCashlessSalesPointAction; +use HiEvents\Http\Actions\Cashless\SalesPoint\GetCashlessSalesPointsAction; +use HiEvents\Http\Actions\Cashless\SalesPoint\UpdateCashlessSalesPointAction; +use HiEvents\Http\Actions\Cashless\UpdateCashlessSettingsAction; +use HiEvents\Http\Actions\Cashless\Wallet\CreateOrganizerTopupAction; +use HiEvents\Http\Actions\Cashless\Wallet\ExportCashlessTransactionsAction; +use HiEvents\Http\Actions\Cashless\Wallet\GetCashlessTransactionsAction; +use HiEvents\Http\Actions\Cashless\Wallet\GetCashlessWalletAction; +use HiEvents\Http\Actions\Cashless\Wallet\GetCashlessWalletsAction; +use HiEvents\Http\Actions\Cashless\Wallet\LookupCashlessWalletAction; +use HiEvents\Http\Actions\Cashless\Wallet\RefundCashlessWalletAction; +use HiEvents\Http\Actions\Cashless\Wallet\ReverseCashlessTransactionAction; +use HiEvents\Http\Actions\Cashless\Wallet\UpdateCashlessWalletStatusAction; use HiEvents\Http\Actions\CheckInLists\CreateCheckInListAction; use HiEvents\Http\Actions\CheckInLists\DeleteCheckInListAction; use HiEvents\Http\Actions\CheckInLists\GetCheckInListAction; @@ -499,6 +528,27 @@ function (Router $router): void { $router->put('/events/{event_id}/check-in-lists/{check_in_list_id}', UpdateCheckInListAction::class); $router->delete('/events/{event_id}/check-in-lists/{check_in_list_id}', DeleteCheckInListAction::class); + // Cashless + $router->get('/events/{event_id}/cashless/settings', GetCashlessSettingsAction::class); + $router->get('/events/{event_id}/cashless/stats', GetCashlessStatsAction::class); + $router->get('/events/{event_id}/cashless/summary', GetCashlessSummaryAction::class); + $router->post('/events/{event_id}/cashless/close', CloseCashlessAction::class); + $router->put('/events/{event_id}/cashless/settings', UpdateCashlessSettingsAction::class); + $router->post('/events/{event_id}/cashless/sales-points', CreateCashlessSalesPointAction::class); + $router->get('/events/{event_id}/cashless/sales-points', GetCashlessSalesPointsAction::class); + $router->get('/events/{event_id}/cashless/sales-points/{sales_point_id}', GetCashlessSalesPointAction::class); + $router->put('/events/{event_id}/cashless/sales-points/{sales_point_id}', UpdateCashlessSalesPointAction::class); + $router->delete('/events/{event_id}/cashless/sales-points/{sales_point_id}', DeleteCashlessSalesPointAction::class); + $router->get('/events/{event_id}/cashless/wallets', GetCashlessWalletsAction::class); + $router->post('/events/{event_id}/cashless/wallets/lookup', LookupCashlessWalletAction::class); + $router->get('/events/{event_id}/cashless/wallets/{wallet_id}', GetCashlessWalletAction::class); + $router->post('/events/{event_id}/cashless/wallets/{wallet_id}/topups', CreateOrganizerTopupAction::class); + $router->patch('/events/{event_id}/cashless/wallets/{wallet_id}/status', UpdateCashlessWalletStatusAction::class); + $router->post('/events/{event_id}/cashless/wallets/{wallet_id}/refund', RefundCashlessWalletAction::class); + $router->get('/events/{event_id}/cashless/transactions', GetCashlessTransactionsAction::class); + $router->post('/events/{event_id}/cashless/transactions/export', ExportCashlessTransactionsAction::class); + $router->post('/events/{event_id}/cashless/transactions/{transaction_id}/reverse', ReverseCashlessTransactionAction::class); + // Webhooks $router->post('/events/{event_id}/webhooks', CreateWebhookAction::class); $router->get('/events/{event_id}/webhooks', GetWebhooksAction::class); @@ -660,6 +710,24 @@ function (Router $router): void { $router->post('/check-in-lists/{check_in_list_short_id}/check-ins', CreateAttendeeCheckInPublicAction::class); $router->delete('/check-in-lists/{check_in_list_short_id}/check-ins/{check_in_short_id}', DeleteAttendeeCheckInPublicAction::class); + // Cashless + $router->get('/events/{event_id}/cashless/{ticket_reference}', GetCashlessWalletPublicAction::class) + ->middleware('throttle:30,1'); + $router->post('/events/{event_id}/cashless/{ticket_reference}/topup', CreateCashlessTopupPublicAction::class) + ->middleware('throttle:20,1'); + + // Cashless sales points + $router->post('/cashless/sales-points/{sales_point_short_id}/session', CreateCashlessSalesPointSessionAction::class) + ->middleware('throttle:10,1'); + $router->get('/cashless/sales-points/{sales_point_short_id}', GetCashlessSalesPointPublicAction::class); + $router->get('/cashless/sales-points/{sales_point_short_id}/wallets/{attendee_public_id}', GetCashlessWalletForSalesPointAction::class) + ->middleware('throttle:300,1'); + $router->get('/cashless/sales-points/{sales_point_short_id}/transactions', GetCashlessSalesPointTransactionsPublicAction::class); + $router->post('/cashless/sales-points/{sales_point_short_id}/quote', GetCashlessQuotePublicAction::class); + $router->post('/cashless/sales-points/{sales_point_short_id}/purchases', CreateCashlessPurchasePublicAction::class); + $router->post('/cashless/sales-points/{sales_point_short_id}/topups', CreateStaffTopupPublicAction::class); + $router->post('/cashless/sales-points/{sales_point_short_id}/transactions/{transaction_short_id}/reverse', ReverseCashlessTransactionPublicAction::class); + // Color themes $router->get('/color-themes', GetColorThemesAction::class); diff --git a/backend/tests/Feature/Repository/Eloquent/CashlessTransactionRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/CashlessTransactionRepositoryTest.php new file mode 100644 index 0000000000..00340e5ba5 --- /dev/null +++ b/backend/tests/Feature/Repository/Eloquent/CashlessTransactionRepositoryTest.php @@ -0,0 +1,192 @@ +repository = $this->app->make(CashlessTransactionRepositoryInterface::class); + $walletService = $this->app->make(CashlessWalletService::class); + + $now = now()->toDateTimeString(); + $user = User::factory()->withAccount()->create(); + $accountId = $user->accounts()->first()->id; + + $organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $accountId, + 'name' => 'Organizer', + 'email' => 'organizer@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventId = DB::table('events')->insertGetId([ + 'title' => 'Event', + 'account_id' => $accountId, + 'user_id' => $user->id, + 'organizer_id' => $organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $productId = DB::table('products')->insertGetId([ + 'title' => 'Beer', + 'event_id' => $this->eventId, + 'product_type' => 'GENERAL', + 'order' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $priceId = DB::table('product_prices')->insertGetId([ + 'product_id' => $productId, + 'price' => 5.00, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $orderId = DB::table('orders')->insertGetId([ + 'event_id' => $this->eventId, + 'short_id' => 'o_'.uniqid(), + 'public_id' => 'O-'.strtoupper(uniqid()), + 'status' => 'COMPLETED', + 'currency' => 'USD', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->salesPointId = DB::table('cashless_sales_points')->insertGetId([ + 'event_id' => $this->eventId, + 'short_id' => 'csp_'.uniqid(), + 'name' => 'Main Bar', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->marieWalletId = $this->makeWallet($orderId, $productId, $priceId, 'Marie', 'Durand'); + $this->paulWalletId = $this->makeWallet($orderId, $productId, $priceId, 'Paul', 'Martin'); + + $walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $this->marieWalletId, + type: CashlessTransactionType::TOPUP_ONLINE, + positive_amount: 20.00, + )); + $walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $this->marieWalletId, + type: CashlessTransactionType::PURCHASE, + positive_amount: 5.00, + sales_point_id: $this->salesPointId, + )); + $walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $this->paulWalletId, + type: CashlessTransactionType::TOPUP_STAFF, + positive_amount: 10.00, + sales_point_id: $this->salesPointId, + )); + } + + public function test_it_can_be_filtered_to_one_wallet(): void + { + $ids = $this->walletIdsFor(['filter_fields' => ['cashless_wallet_id' => ['eq' => $this->paulWalletId]]]); + + $this->assertSame([$this->paulWalletId], $ids); + } + + public function test_it_can_be_filtered_to_one_sales_point(): void + { + $result = $this->repository->findByEventId($this->eventId, QueryParamsDTO::fromArray([ + 'filter_fields' => ['cashless_sales_point_id' => ['eq' => $this->salesPointId]], + ])); + + $this->assertCount(2, $result->items()); + } + + public function test_it_can_be_filtered_to_several_types(): void + { + $result = $this->repository->findByEventId($this->eventId, QueryParamsDTO::fromArray([ + 'filter_fields' => ['type' => ['in' => 'TOPUP_ONLINE,TOPUP_STAFF']], + ])); + + $this->assertCount(2, $result->items()); + } + + public function test_it_can_be_searched_by_attendee_name(): void + { + $this->assertCount(2, $this->walletIdsFor(['query' => 'durand'])); + $this->assertSame([$this->paulWalletId], $this->walletIdsFor(['query' => 'Paul'])); + } + + public function test_a_search_with_no_match_returns_nothing(): void + { + $this->assertSame([], $this->walletIdsFor(['query' => 'nobody-here'])); + } + + private function walletIdsFor(array $params): array + { + return collect($this->repository->findByEventId($this->eventId, QueryParamsDTO::fromArray($params))->items()) + ->map(fn ($transaction) => $transaction->getCashlessWalletId()) + ->all(); + } + + private function makeWallet(int $orderId, int $productId, int $priceId, string $first, string $last): int + { + $now = now()->toDateTimeString(); + + $attendeeId = DB::table('attendees')->insertGetId([ + 'event_id' => $this->eventId, + 'order_id' => $orderId, + 'product_id' => $productId, + 'product_price_id' => $priceId, + 'status' => 'ACTIVE', + 'email' => strtolower($first).'@example.test', + 'first_name' => $first, + 'last_name' => $last, + 'public_id' => 'A-'.strtoupper(substr(uniqid(), -7)), + 'short_id' => 'a_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return DB::table('cashless_wallets')->insertGetId([ + 'event_id' => $this->eventId, + 'attendee_id' => $attendeeId, + 'currency' => 'USD', + 'status' => CashlessWalletStatus::ACTIVE->value, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } +} diff --git a/backend/tests/Feature/Services/Domain/Cashless/CashlessClosureServiceTest.php b/backend/tests/Feature/Services/Domain/Cashless/CashlessClosureServiceTest.php new file mode 100644 index 0000000000..cc0a4b6357 --- /dev/null +++ b/backend/tests/Feature/Services/Domain/Cashless/CashlessClosureServiceTest.php @@ -0,0 +1,344 @@ +closureService = $this->app->make(CashlessClosureService::class); + $this->walletService = $this->app->make(CashlessWalletService::class); + + $now = now()->toDateTimeString(); + $user = User::factory()->withAccount()->create(); + $this->userId = $user->id; + $this->accountId = $user->accounts()->first()->id; + + $organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Cashless Organizer', + 'email' => 'organizer@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventId = DB::table('events')->insertGetId([ + 'title' => 'Cashless Event', + 'account_id' => $this->accountId, + 'user_id' => $user->id, + 'organizer_id' => $organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('event_settings')->insert([ + 'event_id' => $this->eventId, + 'cashless_enabled' => true, + 'cashless_allow_remaining_balance_refund' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->productId = DB::table('products')->insertGetId([ + 'title' => 'Beer', + 'event_id' => $this->eventId, + 'product_type' => 'GENERAL', + 'order' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->productPriceId = DB::table('product_prices')->insertGetId([ + 'product_id' => $this->productId, + 'price' => 5.00, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->orderId = DB::table('orders')->insertGetId([ + 'event_id' => $this->eventId, + 'short_id' => 'o_'.uniqid(), + 'public_id' => 'O-'.strtoupper(uniqid()), + 'status' => 'COMPLETED', + 'currency' => 'USD', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function test_closing_moves_every_remaining_balance_into_the_sales_totals(): void + { + $first = $this->makeWalletWithBalance(10.00); + $second = $this->makeWalletWithBalance(5.50); + $empty = $this->makeWalletWithBalance(0.00); + + $result = $this->closureService->close($this->eventId, $this->userId); + + $this->assertSame(2, $result->wallets_closed); + $this->assertSame(15.5, $result->amount_closed); + + foreach ([$first, $second, $empty] as $walletId) { + $wallet = DB::table('cashless_wallets')->find($walletId); + $this->assertSame(0.0, (float) $wallet->balance); + $this->assertSame(CashlessWalletStatus::CLOSED->value, $wallet->status); + } + + $statistics = DB::table('event_statistics')->where('event_id', $this->eventId)->first(); + $this->assertSame(15.5, (float) $statistics->sales_total_gross); + $this->assertSame(15.5, (float) $statistics->sales_total_before_additions); + $this->assertSame(0, (int) $statistics->orders_created); + $this->assertSame(0, (int) $statistics->products_sold); + + $daily = DB::table('event_daily_statistics')->where('event_id', $this->eventId)->first(); + $this->assertSame(15.5, (float) $daily->sales_total_gross); + + $this->assertNotNull(DB::table('event_settings')->where('event_id', $this->eventId)->value('cashless_closed_at')); + } + + public function test_closing_adds_to_existing_statistics_and_bumps_their_version(): void + { + DB::table('event_statistics')->insert([ + 'event_id' => $this->eventId, + 'sales_total_gross' => 100.00, + 'sales_total_before_additions' => 90.00, + 'version' => 3, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $this->makeWalletWithBalance(20.00); + + $this->closureService->close($this->eventId, $this->userId); + + $statistics = DB::table('event_statistics')->where('event_id', $this->eventId)->first(); + $this->assertSame(120.0, (float) $statistics->sales_total_gross); + $this->assertSame(110.0, (float) $statistics->sales_total_before_additions); + $this->assertSame(4, (int) $statistics->version); + } + + public function test_the_ledger_still_resums_to_zero_after_closing(): void + { + $walletId = $this->makeWalletWithBalance(30.00); + + $this->closureService->close($this->eventId, $this->userId); + + $closure = DB::table('cashless_transactions') + ->where('cashless_wallet_id', $walletId) + ->where('type', CashlessTransactionType::CLOSURE->value) + ->first(); + + $this->assertSame(-30.0, (float) $closure->amount); + $this->assertSame(0.0, (float) $closure->balance_after); + $this->assertSame($this->userId, (int) $closure->created_by_user_id); + $this->assertSame(0.0, (float) DB::table('cashless_transactions')->where('cashless_wallet_id', $walletId)->sum('amount')); + } + + public function test_cashless_can_only_be_closed_once(): void + { + $this->makeWalletWithBalance(10.00); + $this->closureService->close($this->eventId, $this->userId); + + $this->expectException(CashlessClosureNotAllowedException::class); + + $this->closureService->close($this->eventId, $this->userId); + } + + public function test_closing_is_refused_while_balance_refunds_are_still_open(): void + { + $walletId = $this->makeWalletWithBalance(10.00); + DB::table('event_settings')->where('event_id', $this->eventId)->update([ + 'cashless_allow_remaining_balance_refund' => true, + 'cashless_refund_deadline_at' => now()->addDay(), + ]); + + $refused = false; + + try { + $this->closureService->close($this->eventId, $this->userId); + } catch (CashlessClosureNotAllowedException) { + $refused = true; + } + + $this->assertTrue($refused); + + $this->assertSame(10.0, (float) DB::table('cashless_wallets')->find($walletId)->balance); + $this->assertNull(DB::table('event_settings')->where('event_id', $this->eventId)->value('cashless_closed_at')); + $this->assertSame(0, DB::table('event_statistics')->where('event_id', $this->eventId)->count()); + } + + public function test_closing_is_allowed_once_the_refund_deadline_has_passed(): void + { + $this->makeWalletWithBalance(10.00); + DB::table('event_settings')->where('event_id', $this->eventId)->update([ + 'cashless_allow_remaining_balance_refund' => true, + 'cashless_refund_deadline_at' => now()->subDay(), + ]); + + $result = $this->closureService->close($this->eventId, $this->userId); + + $this->assertSame(10.0, $result->amount_closed); + } + + public function test_a_closed_wallet_refuses_new_transactions(): void + { + $walletId = $this->makeWalletWithBalance(10.00); + $this->closureService->close($this->eventId, $this->userId); + + $this->expectException(CashlessWalletUnavailableException::class); + + $this->walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $walletId, + type: CashlessTransactionType::TOPUP_STAFF, + positive_amount: 5.00, + )); + } + + public function test_a_closure_cannot_be_reversed(): void + { + $walletId = $this->makeWalletWithBalance(10.00); + $this->closureService->close($this->eventId, $this->userId); + + $closure = $this->app->make(CashlessTransactionRepositoryInterface::class) + ->findFirstWhere([ + 'cashless_wallet_id' => $walletId, + 'type' => CashlessTransactionType::CLOSURE->value, + ]); + + $this->expectException(CashlessTransactionNotReversibleException::class); + + $this->walletService->reverse($closure, reversedByUserId: null); + } + + public function test_a_wallet_created_after_the_closure_is_born_closed(): void + { + $this->closureService->close($this->eventId, $this->userId); + $attendee = $this->makeAttendee(); + + $wallet = $this->app->make(CashlessWalletResolveService::class) + ->resolveByAttendeePublicId($this->eventId, $attendee['public_id']); + + $this->assertSame(CashlessWalletStatus::CLOSED->value, $wallet->getStatus()); + } + + public function test_the_summary_accounts_for_every_dollar(): void + { + $walletId = $this->makeWalletWithBalance(40.00); + $this->walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $walletId, + type: CashlessTransactionType::PURCHASE, + positive_amount: 12.00, + items: collect([new CashlessTransactionItemDTO( + product_id: $this->productId, + product_price_id: $this->productPriceId, + product_title: 'Beer', + unit_price: 4.00, + quantity: 3, + total: 12.00, + )]), + )); + $this->makeWalletWithBalance(8.00); + + $this->closureService->close($this->eventId, $this->userId); + $summary = $this->app->make(CashlessSummaryService::class)->getSummary($this->eventId); + + $this->assertSame(48.0, $summary->topped_up_online + $summary->topped_up_staff); + $this->assertSame(12.0, $summary->spent); + $this->assertSame(36.0, $summary->closed); + $this->assertSame(0.0, $summary->outstanding_balance); + $this->assertSame(1, $summary->purchases_count); + $this->assertSame(2, $summary->wallets_total); + $this->assertSame(2, $summary->wallets_closed); + $this->assertSame('Beer', $summary->top_products[0]->title); + $this->assertSame(3, $summary->top_products[0]->quantity); + } + + private function makeAttendee(): array + { + $now = now()->toDateTimeString(); + $publicId = 'A-'.strtoupper(substr(uniqid(), -7)); + + $attendeeId = DB::table('attendees')->insertGetId([ + 'event_id' => $this->eventId, + 'order_id' => $this->orderId, + 'product_id' => $this->productId, + 'product_price_id' => $this->productPriceId, + 'status' => 'ACTIVE', + 'email' => uniqid().'@example.test', + 'first_name' => 'Marie', + 'last_name' => 'Durand', + 'public_id' => $publicId, + 'short_id' => 'a_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return ['id' => $attendeeId, 'public_id' => $publicId]; + } + + private function makeWalletWithBalance(float $balance): int + { + $now = now()->toDateTimeString(); + + $walletId = DB::table('cashless_wallets')->insertGetId([ + 'event_id' => $this->eventId, + 'attendee_id' => $this->makeAttendee()['id'], + 'currency' => 'USD', + 'status' => CashlessWalletStatus::ACTIVE->value, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if ($balance > 0) { + $this->walletService->record(new RecordCashlessTransactionDTO( + wallet_id: $walletId, + type: CashlessTransactionType::TOPUP_ONLINE, + positive_amount: $balance, + )); + } + + return $walletId; + } +} diff --git a/backend/tests/Feature/Services/Domain/Cashless/CashlessWalletServiceTest.php b/backend/tests/Feature/Services/Domain/Cashless/CashlessWalletServiceTest.php new file mode 100644 index 0000000000..d38a472fdd --- /dev/null +++ b/backend/tests/Feature/Services/Domain/Cashless/CashlessWalletServiceTest.php @@ -0,0 +1,265 @@ +service = $this->app->make(CashlessWalletService::class); + + $now = now()->toDateTimeString(); + $user = User::factory()->withAccount()->create(); + $accountId = $user->accounts()->first()->id; + + $organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $accountId, + 'name' => 'Cashless Organizer', + 'email' => 'organizer@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventId = DB::table('events')->insertGetId([ + 'title' => 'Cashless Event', + 'account_id' => $accountId, + 'user_id' => $user->id, + 'organizer_id' => $organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->productId = DB::table('products')->insertGetId([ + 'title' => 'Beer', + 'event_id' => $this->eventId, + 'product_type' => 'GENERAL', + 'order' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->productPriceId = DB::table('product_prices')->insertGetId([ + 'product_id' => $this->productId, + 'price' => 5.00, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $orderId = DB::table('orders')->insertGetId([ + 'event_id' => $this->eventId, + 'short_id' => 'o_'.uniqid(), + 'public_id' => 'O-'.strtoupper(uniqid()), + 'status' => 'COMPLETED', + 'currency' => 'USD', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->attendeeId = DB::table('attendees')->insertGetId([ + 'event_id' => $this->eventId, + 'order_id' => $orderId, + 'product_id' => $this->productId, + 'product_price_id' => $this->productPriceId, + 'status' => 'ACTIVE', + 'email' => 'attendee@example.test', + 'first_name' => 'Marie', + 'last_name' => 'Durand', + 'public_id' => 'A-'.strtoupper(uniqid()), + 'short_id' => 'a_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->walletId = DB::table('cashless_wallets')->insertGetId([ + 'event_id' => $this->eventId, + 'attendee_id' => $this->attendeeId, + 'currency' => 'USD', + 'status' => CashlessWalletStatus::ACTIVE->value, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function test_a_full_lifecycle_leaves_the_ledger_and_the_balance_in_agreement(): void + { + $this->topUp(50.00); + $purchase = $this->purchase(12.50); + $this->service->reverse($purchase, reversedByUserId: null); + $this->purchase(20.00); + $this->refundRemaining(30.00); + + $wallet = DB::table('cashless_wallets')->find($this->walletId); + $ledgerSum = (float) DB::table('cashless_transactions') + ->where('cashless_wallet_id', $this->walletId) + ->sum('amount'); + + $this->assertSame(0.0, (float) $wallet->balance); + $this->assertSame((float) $wallet->balance, $ledgerSum); + $this->assertSame(50.0, (float) $wallet->total_topped_up); + $this->assertSame(20.0, (float) $wallet->total_spent); + $this->assertSame(30.0, (float) $wallet->total_refunded); + } + + public function test_every_transaction_records_the_balance_it_left_behind(): void + { + $this->topUp(40.00); + $this->purchase(15.00); + + $balancesAfter = DB::table('cashless_transactions') + ->where('cashless_wallet_id', $this->walletId) + ->orderBy('id') + ->pluck('balance_after') + ->map(static fn ($value) => (float) $value) + ->all(); + + $this->assertSame([40.0, 25.0], $balancesAfter); + } + + public function test_a_purchase_beyond_the_balance_is_refused_and_writes_nothing(): void + { + $this->topUp(10.00); + + $refused = false; + + try { + $this->purchase(10.01); + } catch (InsufficientCashlessBalanceException) { + $refused = true; + } + + $this->assertTrue($refused); + + $this->assertSame(10.0, (float) DB::table('cashless_wallets')->find($this->walletId)->balance); + $this->assertSame(1, DB::table('cashless_transactions')->where('cashless_wallet_id', $this->walletId)->count()); + } + + public function test_purchase_items_are_stored_against_the_transaction(): void + { + $this->topUp(30.00); + $transaction = $this->purchase(10.00); + + $items = DB::table('cashless_transaction_items') + ->where('cashless_transaction_id', $transaction->getId()) + ->get(); + + $this->assertCount(1, $items); + $this->assertSame('Beer', $items->first()->product_title); + $this->assertSame(2, $items->first()->quantity); + } + + public function test_a_frozen_wallet_refuses_new_transactions(): void + { + $this->topUp(25.00); + + DB::table('cashless_wallets') + ->where('id', $this->walletId) + ->update(['status' => CashlessWalletStatus::FROZEN->value]); + + $this->expectException(CashlessWalletUnavailableException::class); + + $this->purchase(5.00); + } + + public function test_a_transaction_can_only_be_reversed_once(): void + { + $this->topUp(30.00); + $purchase = $this->purchase(5.00); + + $this->service->reverse($purchase, reversedByUserId: null); + + $this->expectException(CashlessTransactionNotReversibleException::class); + + $this->service->reverse($purchase, reversedByUserId: null); + } + + public function test_an_attendee_cannot_hold_two_wallets(): void + { + $this->expectException(QueryException::class); + + DB::table('cashless_wallets')->insert([ + 'event_id' => $this->eventId, + 'attendee_id' => $this->attendeeId, + 'currency' => 'USD', + 'status' => CashlessWalletStatus::ACTIVE->value, + 'created_at' => now()->toDateTimeString(), + 'updated_at' => now()->toDateTimeString(), + ]); + } + + private function topUp(float $amount): void + { + $this->service->record(new RecordCashlessTransactionDTO( + wallet_id: $this->walletId, + type: CashlessTransactionType::TOPUP_STAFF, + positive_amount: $amount, + staff_payment_method: CashlessStaffPaymentMethod::CASH, + )); + } + + private function purchase(float $amount) + { + return $this->service->record(new RecordCashlessTransactionDTO( + wallet_id: $this->walletId, + type: CashlessTransactionType::PURCHASE, + positive_amount: $amount, + items: new Collection([ + new CashlessTransactionItemDTO( + product_id: $this->productId, + product_price_id: $this->productPriceId, + product_title: 'Beer', + unit_price: $amount / 2, + quantity: 2, + total: $amount, + ), + ]), + )); + } + + private function refundRemaining(float $amount): void + { + $this->service->record(new RecordCashlessTransactionDTO( + wallet_id: $this->walletId, + type: CashlessTransactionType::REFUND_REMAINING, + positive_amount: $amount, + )); + } +} diff --git a/backend/tests/Unit/Mail/Cashless/CashlessTopupConfirmationMailTest.php b/backend/tests/Unit/Mail/Cashless/CashlessTopupConfirmationMailTest.php new file mode 100644 index 0000000000..60cf3e7c2c --- /dev/null +++ b/backend/tests/Unit/Mail/Cashless/CashlessTopupConfirmationMailTest.php @@ -0,0 +1,61 @@ +makeMail(); + + $this->assertSame('emails.cashless.topup-confirmation', $mail->content()->markdown); + $this->assertSame('💳 Your cashless balance for Summer Fest', $mail->envelope()->subject); + } + + public function test_default_view_shows_the_balance_without_layout_artifacts(): void + { + $html = $this->makeMail()->render(); + + $this->assertStringContainsString('$35.00', $html); + $this->assertStringContainsString('$20.00', $html); + $this->assertStringContainsString('https://example.com/cashless/1/a_abc', $html); + $this->assertStringNotContainsString('makeMail(new RenderedEmailTemplateDTO( + subject: 'Custom subject', + body: '

Custom body

', + cta: ['label' => 'View my balance', 'url' => 'https://example.com/cashless/1/a_abc'], + )); + + $content = $mail->content(); + + $this->assertSame('emails.custom-template', $content->markdown); + $this->assertSame('

Custom body

', $content->with['renderedBody']); + $this->assertSame('Custom subject', $mail->envelope()->subject); + } + + private function makeMail(?RenderedEmailTemplateDTO $renderedTemplate = null): CashlessTopupConfirmationMail + { + return new CashlessTopupConfirmationMail( + attendee: (new AttendeeDomainObject)->setFirstName('Marie')->setLastName('Durand'), + event: (new EventDomainObject)->setId(1)->setTitle('Summer Fest'), + eventSettings: (new EventSettingDomainObject)->setSupportEmail('support@example.com'), + organizer: (new OrganizerDomainObject)->setName('Organizer'), + toppedUpAmount: '$20.00', + newBalance: '$35.00', + walletUrl: 'https://example.com/cashless/1/a_abc', + renderedTemplate: $renderedTemplate, + ); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Cashless/GetCashlessSalesPointTransactionsPublicHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Cashless/GetCashlessSalesPointTransactionsPublicHandlerTest.php new file mode 100644 index 0000000000..a6663f256e --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Cashless/GetCashlessSalesPointTransactionsPublicHandlerTest.php @@ -0,0 +1,62 @@ +accessService = Mockery::mock(CashlessSalesPointAccessService::class); + $this->transactionRepository = Mockery::mock(CashlessTransactionRepositoryInterface::class); + $this->handler = new GetCashlessSalesPointTransactionsPublicHandler( + $this->accessService, + $this->transactionRepository, + ); + } + + public function test_it_returns_the_transactions_of_the_authorised_sales_point(): void + { + $transactions = new Collection([new CashlessTransactionDomainObject]); + + $this->accessService->shouldReceive('resolveAuthorised') + ->with('csp_abc', 'token') + ->andReturn((new CashlessSalesPointDomainObject)->setId(7)); + $this->transactionRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->transactionRepository->shouldReceive('findBySalesPointId') + ->with(7, 200) + ->once() + ->andReturn($transactions); + + $this->assertSame($transactions, $this->handler->handle('csp_abc', 'token')); + } + + public function test_it_refuses_an_unauthorised_session(): void + { + $this->accessService->shouldReceive('resolveAuthorised') + ->andThrow(new CashlessSalesPointAccessException('nope')); + $this->transactionRepository->shouldNotReceive('findBySalesPointId'); + + $this->expectException(CashlessSalesPointAccessException::class); + + $this->handler->handle('csp_abc', null); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Cashless/GetCashlessWalletForSalesPointHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Cashless/GetCashlessWalletForSalesPointHandlerTest.php new file mode 100644 index 0000000000..11af1caadb --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Cashless/GetCashlessWalletForSalesPointHandlerTest.php @@ -0,0 +1,58 @@ +lookUpWith(allowStaffTopups: false); + + $this->assertSame('Marie', $wallet->getAttendee()->getFirstName()); + $this->assertSame('D.', $wallet->getAttendee()->getLastName()); + } + + public function test_a_sales_point_that_can_top_up_sees_the_full_name(): void + { + $wallet = $this->lookUpWith(allowStaffTopups: true); + + $this->assertSame('Durand', $wallet->getAttendee()->getLastName()); + } + + private function lookUpWith(bool $allowStaffTopups): CashlessWalletDomainObject + { + $accessService = Mockery::mock(CashlessSalesPointAccessService::class); + $accessService->shouldReceive('resolveAuthorised')->andReturn( + (new CashlessSalesPointDomainObject)->setEventId(1)->setAllowStaffTopups($allowStaffTopups) + ); + + $wallet = (new CashlessWalletDomainObject) + ->setId(3) + ->setAttendee((new AttendeeDomainObject)->setFirstName('Marie')->setLastName('Durand')); + $resolver = Mockery::mock(CashlessWalletResolveService::class); + $resolver->shouldReceive('resolveByAttendeePublicId')->andReturn($wallet); + + $transactions = Mockery::mock(CashlessTransactionRepositoryInterface::class); + $transactions->shouldReceive('loadRelation')->andReturnSelf(); + $transactions->shouldReceive('findByWalletId')->andReturn(new Collection); + + return (new GetCashlessWalletForSalesPointHandler( + $accessService, + $resolver, + $transactions, + new CashlessAttendeeNameMasker, + ))->handle('csp_x', 'A-ABC1234', 'token'); + } +} diff --git a/backend/tests/Unit/Services/Domain/Cashless/CashlessAttendeeNameMaskerTest.php b/backend/tests/Unit/Services/Domain/Cashless/CashlessAttendeeNameMaskerTest.php new file mode 100644 index 0000000000..4b4a22e0d5 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Cashless/CashlessAttendeeNameMaskerTest.php @@ -0,0 +1,45 @@ +setFirstName('Marie')->setLastName('Durand'); + + (new CashlessAttendeeNameMasker)->maskLastName($attendee); + + $this->assertSame('Marie', $attendee->getFirstName()); + $this->assertSame('D.', $attendee->getLastName()); + } + + public function test_the_initial_is_uppercased_and_multibyte_safe(): void + { + $attendee = (new AttendeeDomainObject)->setLastName('élodie'); + + (new CashlessAttendeeNameMasker)->maskLastName($attendee); + + $this->assertSame('É.', $attendee->getLastName()); + } + + public function test_an_empty_last_name_stays_empty(): void + { + $attendee = (new AttendeeDomainObject)->setLastName(''); + + (new CashlessAttendeeNameMasker)->maskLastName($attendee); + + $this->assertSame('', $attendee->getLastName()); + } + + public function test_a_missing_attendee_is_ignored(): void + { + (new CashlessAttendeeNameMasker)->maskLastName(null); + + $this->addToAssertionCount(1); + } +} diff --git a/backend/tests/Unit/Services/Domain/Cashless/CashlessClosureServiceTest.php b/backend/tests/Unit/Services/Domain/Cashless/CashlessClosureServiceTest.php new file mode 100644 index 0000000000..2507cf9116 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Cashless/CashlessClosureServiceTest.php @@ -0,0 +1,146 @@ +settingsService = Mockery::mock(CashlessSettingsService::class); + $this->walletRepository = Mockery::mock(CashlessWalletRepositoryInterface::class); + $this->walletService = Mockery::mock(CashlessWalletService::class); + $this->occurrenceResolver = Mockery::mock(CashlessOccurrenceResolver::class); + $this->statisticsService = Mockery::mock(EventStatisticsCashlessClosureService::class); + $this->eventSettingsRepository = Mockery::mock(EventSettingsRepositoryInterface::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction')->andReturnUsing(static fn (callable $callback) => $callback()); + + $this->service = new CashlessClosureService( + $this->settingsService, + $this->walletRepository, + $this->walletService, + $this->occurrenceResolver, + $this->statisticsService, + $this->eventSettingsRepository, + $databaseManager, + ); + } + + public function test_it_refuses_when_cashless_is_not_enabled(): void + { + $this->settingsService->shouldReceive('getEnabledSettings')->andThrow(new CashlessNotEnabledException('off')); + $this->walletService->shouldNotReceive('close'); + + $this->expectException(CashlessNotEnabledException::class); + + $this->service->close(7, 1); + } + + public function test_it_refuses_when_already_closed(): void + { + $this->settingsService->shouldReceive('getEnabledSettings') + ->andReturn((new EventSettingDomainObject)->setCashlessClosedAt('2026-09-28 10:00:00')); + $this->walletService->shouldNotReceive('close'); + + $this->expectException(CashlessClosureNotAllowedException::class); + + $this->service->close(7, 1); + } + + public function test_it_refuses_while_balance_refunds_are_open(): void + { + $settings = new EventSettingDomainObject; + $this->settingsService->shouldReceive('getEnabledSettings')->andReturn($settings); + $this->settingsService->shouldReceive('isRefundWindowOpen')->with($settings)->andReturn(true); + $this->walletService->shouldNotReceive('close'); + $this->eventSettingsRepository->shouldNotReceive('updateWhere'); + + $this->expectException(CashlessClosureNotAllowedException::class); + + $this->service->close(7, 1); + } + + public function test_it_closes_every_wallet_with_a_balance_and_records_the_total_as_sales(): void + { + $settings = new EventSettingDomainObject; + $this->settingsService->shouldReceive('getEnabledSettings')->andReturn($settings); + $this->settingsService->shouldReceive('isRefundWindowOpen')->andReturn(false); + + $this->walletRepository->shouldReceive('findWhere')->andReturn(new Collection([ + (new CashlessWalletDomainObject)->setId(1), + (new CashlessWalletDomainObject)->setId(2), + ])); + $this->walletService->shouldReceive('close')->with(1, 9)->once() + ->andReturn((new CashlessTransactionDomainObject)->setAmount(-10.25)); + $this->walletService->shouldReceive('close')->with(2, 9)->once() + ->andReturn((new CashlessTransactionDomainObject)->setAmount(-5.50)); + + $this->walletRepository->shouldReceive('updateWhere')->once()->withArgs( + fn (array $attributes, array $where) => $attributes['status'] === 'CLOSED' && $where['event_id'] === 7 + ); + $this->occurrenceResolver->shouldReceive('resolveForSale')->with(7)->andReturn(3); + $this->statisticsService->shouldReceive('recordClosedBalance')->once()->withArgs( + fn (int $eventId, ?int $occurrenceId, string $date, float $amount) => $eventId === 7 + && $occurrenceId === 3 + && $amount === 15.75 + ); + $this->eventSettingsRepository->shouldReceive('updateWhere')->once()->withArgs( + fn (array $attributes, array $where) => isset($attributes['cashless_closed_at']) && $where['event_id'] === 7 + ); + + $result = $this->service->close(7, 9); + + $this->assertSame(2, $result->wallets_closed); + $this->assertSame(15.75, $result->amount_closed); + } + + public function test_it_records_no_sales_when_there_is_nothing_left_to_close(): void + { + $this->settingsService->shouldReceive('getEnabledSettings')->andReturn(new EventSettingDomainObject); + $this->settingsService->shouldReceive('isRefundWindowOpen')->andReturn(false); + $this->walletRepository->shouldReceive('findWhere')->andReturn(new Collection); + $this->walletRepository->shouldReceive('updateWhere')->once(); + $this->statisticsService->shouldNotReceive('recordClosedBalance'); + $this->eventSettingsRepository->shouldReceive('updateWhere')->once(); + + $result = $this->service->close(7, 9); + + $this->assertSame(0, $result->wallets_closed); + $this->assertSame(0.0, $result->amount_closed); + } +} diff --git a/backend/tests/Unit/Services/Domain/Cashless/CashlessOccurrenceResolverTest.php b/backend/tests/Unit/Services/Domain/Cashless/CashlessOccurrenceResolverTest.php new file mode 100644 index 0000000000..09dabaec29 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Cashless/CashlessOccurrenceResolverTest.php @@ -0,0 +1,81 @@ +resolverFor([$this->occurrence(80, '+2 days', '+3 days')]); + + $this->assertSame(80, $resolver->resolveForSale(71)); + } + + public function test_the_occurrence_in_progress_wins_over_a_later_one(): void + { + $resolver = $this->resolverFor([ + $this->occurrence(1, '-3 days', '-2 days'), + $this->occurrence(2, '-1 hour', '+2 hours'), + $this->occurrence(3, '+1 day', '+2 days'), + ]); + + $this->assertSame(2, $resolver->resolveForSale(3)); + } + + public function test_between_dates_the_next_upcoming_occurrence_is_used(): void + { + $resolver = $this->resolverFor([ + $this->occurrence(1, '-3 days', '-2 days'), + $this->occurrence(2, '+1 day', '+2 days'), + $this->occurrence(3, '+5 days', '+6 days'), + ]); + + $this->assertSame(2, $resolver->resolveForSale(3)); + } + + public function test_once_every_date_has_passed_the_last_one_is_used(): void + { + $resolver = $this->resolverFor([ + $this->occurrence(1, '-5 days', '-4 days'), + $this->occurrence(2, '-3 days', '-2 days'), + ]); + + $this->assertSame(2, $resolver->resolveForSale(3)); + } + + public function test_an_event_without_occurrences_resolves_to_nothing(): void + { + $this->assertNull($this->resolverFor([])->resolveForSale(3)); + } + + private function resolverFor(array $occurrences): CashlessOccurrenceResolver + { + $repository = Mockery::mock(EventOccurrenceRepositoryInterface::class); + $repository->shouldReceive('findWhere')->andReturn(new Collection($occurrences)); + + return new CashlessOccurrenceResolver($repository); + } + + private function occurrence(int $id, string $start, string $end): EventOccurrenceDomainObject + { + return (new EventOccurrenceDomainObject) + ->setId($id) + ->setStartDate(Carbon::parse($start)->toDateTimeString()) + ->setEndDate(Carbon::parse($end)->toDateTimeString()); + } +} diff --git a/backend/tests/Unit/Services/Domain/Cashless/CashlessQuoteServiceTest.php b/backend/tests/Unit/Services/Domain/Cashless/CashlessQuoteServiceTest.php new file mode 100644 index 0000000000..6a3c17dac8 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Cashless/CashlessQuoteServiceTest.php @@ -0,0 +1,92 @@ +taxCalculator = Mockery::mock(TaxAndFeeCalculationService::class); + $this->settingsService = Mockery::mock(CashlessSettingsService::class); + $this->productRepository = Mockery::mock(ProductRepositoryInterface::class); + + $this->service = new CashlessQuoteService( + $this->taxCalculator, + $this->settingsService, + $this->productRepository, + ); + } + + public function test_a_basket_quote_adds_taxes_and_fees_to_the_subtotal(): void + { + $product = (new ProductDomainObject)->setId(4); + $price = (new ProductPriceDomainObject)->setId(9)->setPrice(5.00); + $product->setProductPrices(new Collection([$price])); + + $salesPoint = (new CashlessSalesPointDomainObject)->setProducts(new Collection([$product])); + + $this->taxCalculator + ->shouldReceive('calculateTaxAndFeesForProduct') + ->once() + ->with($product, 5.00, 3) + ->andReturn(new TaxCalculationResponse(feeTotal: 0.0, taxTotal: 1.50, rollUp: [])); + + $quote = $this->service->quoteBasket($salesPoint, new Collection([ + new CashlessPurchaseItemRequestDTO(product_id: 4, product_price_id: 9, quantity: 3), + ])); + + $this->assertSame(15.00, $quote->subtotal); + $this->assertSame(1.50, $quote->taxes); + $this->assertSame(16.50, $quote->total); + } + + public function test_a_topup_quote_includes_the_online_fee_the_terminal_must_be_charged(): void + { + $this->settingsService + ->shouldReceive('getEnabledSettings') + ->with(2) + ->andReturn((new EventSettingDomainObject)->setCashlessTopupProductId(4)); + + $product = (new ProductDomainObject)->setId(4); + + $this->productRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->productRepository->shouldReceive('findFirstWhere')->andReturn($product); + + $this->taxCalculator + ->shouldReceive('calculateTaxAndFeesForProduct') + ->once() + ->with($product, 20.00) + ->andReturn(new TaxCalculationResponse(feeTotal: 0.35, taxTotal: 0.0, rollUp: [])); + + $quote = $this->service->quoteTopup(eventId: 2, amount: 20.00); + + $this->assertSame(20.00, $quote->subtotal); + $this->assertSame(0.35, $quote->fees); + $this->assertSame(20.35, $quote->total); + } +} diff --git a/backend/tests/Unit/Services/Domain/Cashless/CashlessTopupOrderCheckerTest.php b/backend/tests/Unit/Services/Domain/Cashless/CashlessTopupOrderCheckerTest.php new file mode 100644 index 0000000000..bb5cf0437d --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Cashless/CashlessTopupOrderCheckerTest.php @@ -0,0 +1,28 @@ +shouldReceive('findFirstWhere')->with(['order_id' => 5])->andReturn(new CashlessTopupDomainObject); + + $this->assertTrue((new CashlessTopupOrderChecker($repository))->isTopupOrder(5)); + } + + public function test_an_order_without_a_topup_row_is_not_a_topup_order(): void + { + $repository = Mockery::mock(CashlessTopupRepositoryInterface::class); + $repository->shouldReceive('findFirstWhere')->with(['order_id' => 6])->andReturn(null); + + $this->assertFalse((new CashlessTopupOrderChecker($repository))->isTopupOrder(6)); + } +} diff --git a/backend/tests/Unit/Services/Domain/Cashless/CashlessWalletResolveServiceTest.php b/backend/tests/Unit/Services/Domain/Cashless/CashlessWalletResolveServiceTest.php new file mode 100644 index 0000000000..209ea87cb2 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Cashless/CashlessWalletResolveServiceTest.php @@ -0,0 +1,138 @@ +attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class); + $this->walletRepository = Mockery::mock(CashlessWalletRepositoryInterface::class); + + $this->eventRepository = Mockery::mock(EventRepositoryInterface::class); + $this->settingsService = Mockery::mock(CashlessSettingsService::class); + + $this->service = new CashlessWalletResolveService( + $this->attendeeRepository, + $this->walletRepository, + $this->eventRepository, + $this->settingsService, + ); + } + + public function test_a_public_id_reference_is_matched_case_insensitively_on_public_id(): void + { + $this->expectAttendeeLookup(AttendeeDomainObjectAbstract::PUBLIC_ID, 'A-QKS3J8R', AttendeeStatus::ACTIVE); + $this->walletRepository->shouldReceive('findFirstWhere')->andReturn(new CashlessWalletDomainObject); + + $wallet = $this->service->resolveByTicketReference(5, ' a-qks3j8r '); + + $this->assertNotNull($wallet->getAttendee()); + } + + public function test_a_short_id_reference_is_matched_on_short_id(): void + { + $this->expectAttendeeLookup(AttendeeDomainObjectAbstract::SHORT_ID, 'a_abc123', AttendeeStatus::ACTIVE); + $this->walletRepository->shouldReceive('findFirstWhere')->andReturn(new CashlessWalletDomainObject); + + $this->assertNotNull($this->service->resolveByTicketReference(5, 'a_abc123')->getAttendee()); + } + + public function test_an_unknown_ticket_is_unavailable(): void + { + $this->attendeeRepository->shouldReceive('findFirstWhere')->andReturn(null); + + $this->expectException(CashlessWalletUnavailableException::class); + + $this->service->resolveByTicketReference(5, 'A-NOPE123'); + } + + public function test_a_cancelled_ticket_is_unavailable(): void + { + $this->expectAttendeeLookup(AttendeeDomainObjectAbstract::PUBLIC_ID, 'A-QKS3J8R', AttendeeStatus::CANCELLED); + + $this->expectException(CashlessWalletUnavailableException::class); + + $this->service->resolveByTicketReference(5, 'A-QKS3J8R'); + } + + public function test_a_new_wallet_is_active_while_cashless_is_open(): void + { + $this->expectNewWalletWithSettings(new EventSettingDomainObject); + + $this->assertSame(CashlessWalletStatus::ACTIVE->value, $this->createdWalletStatus()); + } + + public function test_a_new_wallet_is_closed_once_cashless_has_been_closed(): void + { + $this->expectNewWalletWithSettings((new EventSettingDomainObject)->setCashlessClosedAt('2026-09-28 10:00:00')); + + $this->assertSame(CashlessWalletStatus::CLOSED->value, $this->createdWalletStatus()); + } + + private array $createdWallet = []; + + private function createdWalletStatus(): string + { + $this->service->resolveByAttendeePublicId(5, 'A-QKS3J8R'); + + return $this->createdWallet[CashlessWalletDomainObjectAbstract::STATUS]; + } + + private function expectNewWalletWithSettings(EventSettingDomainObject $settings): void + { + $this->expectAttendeeLookup(AttendeeDomainObjectAbstract::PUBLIC_ID, 'A-QKS3J8R', AttendeeStatus::ACTIVE); + $this->walletRepository->shouldReceive('findFirstWhere')->andReturn(null); + $this->eventRepository->shouldReceive('findById')->andReturn((new EventDomainObject)->setCurrency('EUR')); + $this->settingsService->shouldReceive('getSettings')->with(5)->andReturn($settings); + $this->walletRepository->shouldReceive('create')->andReturnUsing(function (array $attributes) { + $this->createdWallet = $attributes; + + return new CashlessWalletDomainObject; + }); + } + + private function expectAttendeeLookup(string $field, string $value, AttendeeStatus $status): void + { + $attendee = (new AttendeeDomainObject)->setId(9)->setEventId(5)->setStatus($status->name); + + $this->attendeeRepository + ->shouldReceive('findFirstWhere') + ->with([ + $field => $value, + AttendeeDomainObjectAbstract::EVENT_ID => 5, + ]) + ->once() + ->andReturn($attendee); + } +} diff --git a/backend/tests/Unit/Services/Domain/Cashless/CashlessWalletServiceTest.php b/backend/tests/Unit/Services/Domain/Cashless/CashlessWalletServiceTest.php new file mode 100644 index 0000000000..96ef2a79fb --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Cashless/CashlessWalletServiceTest.php @@ -0,0 +1,326 @@ +walletRepository = Mockery::mock(CashlessWalletRepositoryInterface::class); + $this->transactionRepository = Mockery::mock(CashlessTransactionRepositoryInterface::class); + $this->transactionItemRepository = Mockery::mock(CashlessTransactionItemRepositoryInterface::class); + + $databaseManager = Mockery::mock(DatabaseManager::class); + $databaseManager->shouldReceive('transaction')->andReturnUsing(static fn (callable $callback) => $callback()); + + $this->service = new CashlessWalletService( + $this->walletRepository, + $this->transactionRepository, + $this->transactionItemRepository, + $databaseManager, + ); + } + + public function test_online_topup_credits_the_balance_and_tracks_the_total(): void + { + $this->givenWallet(balance: 10.00, toppedUp: 10.00); + $this->expectTransactionCreated(); + + $this->service->record(new RecordCashlessTransactionDTO( + wallet_id: 1, + type: CashlessTransactionType::TOPUP_ONLINE, + positive_amount: 25.00, + order_id: 99, + )); + + $this->assertSame(35.00, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::BALANCE]); + $this->assertSame(35.00, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::TOTAL_TOPPED_UP]); + $this->assertSame(0.0, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::TOTAL_SPENT]); + $this->assertSame(25.00, $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::AMOUNT]); + $this->assertSame(35.00, $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::BALANCE_AFTER]); + } + + public function test_staff_topup_records_the_payment_method(): void + { + $this->givenWallet(balance: 0.00); + $this->expectTransactionCreated(); + + $this->service->record(new RecordCashlessTransactionDTO( + wallet_id: 1, + type: CashlessTransactionType::TOPUP_STAFF, + positive_amount: 20.00, + sales_point_id: 7, + created_by_user_id: 3, + staff_payment_method: CashlessStaffPaymentMethod::CASH, + )); + + $this->assertSame( + CashlessStaffPaymentMethod::CASH->value, + $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::STAFF_PAYMENT_METHOD], + ); + $this->assertSame(20.00, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::BALANCE]); + } + + public function test_purchase_debits_the_balance_and_stores_the_items(): void + { + $this->givenWallet(balance: 30.00, toppedUp: 30.00); + $this->expectTransactionCreated(); + + $this->transactionItemRepository + ->shouldReceive('insert') + ->once() + ->withArgs(function (array $rows) { + $this->assertCount(1, $rows); + $this->assertSame('Beer', $rows[0]['product_title']); + + return true; + }) + ->andReturnTrue(); + + $this->service->record(new RecordCashlessTransactionDTO( + wallet_id: 1, + type: CashlessTransactionType::PURCHASE, + positive_amount: 12.50, + sales_point_id: 7, + items: new Collection([ + new CashlessTransactionItemDTO( + product_id: 4, + product_price_id: 9, + product_title: 'Beer', + unit_price: 6.25, + quantity: 2, + total: 12.50, + ), + ]), + )); + + $this->assertSame(17.50, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::BALANCE]); + $this->assertSame(12.50, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::TOTAL_SPENT]); + $this->assertSame(-12.50, $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::AMOUNT]); + } + + public function test_purchase_is_rejected_when_the_balance_is_too_low(): void + { + $this->givenWallet(balance: 5.00); + + $this->expectException(InsufficientCashlessBalanceException::class); + + $this->service->record(new RecordCashlessTransactionDTO( + wallet_id: 1, + type: CashlessTransactionType::PURCHASE, + positive_amount: 5.01, + )); + } + + public function test_a_frozen_wallet_cannot_be_used(): void + { + $this->givenWallet(balance: 50.00, status: CashlessWalletStatus::FROZEN); + + $this->expectException(CashlessWalletUnavailableException::class); + + $this->service->record(new RecordCashlessTransactionDTO( + wallet_id: 1, + type: CashlessTransactionType::PURCHASE, + positive_amount: 5.00, + )); + } + + public function test_reversing_a_purchase_gives_the_money_back_and_undoes_the_spend_total(): void + { + $this->givenWallet(balance: 17.50, toppedUp: 30.00, spent: 12.50); + $this->expectTransactionCreated(); + + $this->transactionRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturnNull(); + + $this->service->reverse($this->purchaseTransaction(), reversedByUserId: 3); + + $this->assertSame(30.00, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::BALANCE]); + $this->assertSame(0.0, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::TOTAL_SPENT]); + $this->assertSame(12.50, $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::AMOUNT]); + $this->assertSame( + CashlessTransactionType::REVERSAL->value, + $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::TYPE], + ); + } + + public function test_a_transaction_cannot_be_reversed_twice(): void + { + $this->transactionRepository + ->shouldReceive('findFirstWhere') + ->once() + ->andReturn(Mockery::mock(CashlessTransactionDomainObject::class)); + + $this->expectException(CashlessTransactionNotReversibleException::class); + + $this->service->reverse($this->purchaseTransaction(), reversedByUserId: 3); + } + + public function test_a_reversal_cannot_itself_be_reversed(): void + { + $reversal = (new CashlessTransactionDomainObject) + ->setId(88) + ->setCashlessWalletId(1) + ->setType(CashlessTransactionType::REVERSAL->value) + ->setAmount(12.50); + + $this->expectException(CashlessTransactionNotReversibleException::class); + + $this->service->reverse($reversal, reversedByUserId: 3); + } + + public function test_closing_a_wallet_writes_a_closure_for_the_whole_balance_and_closes_it(): void + { + $this->givenWallet(balance: 12.50, toppedUp: 30.00, spent: 17.50); + $this->expectTransactionCreated(); + + $transaction = $this->service->close(walletId: 1, closedByUserId: 9); + + $this->assertNotNull($transaction); + $this->assertSame(CashlessTransactionType::CLOSURE->value, $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::TYPE]); + $this->assertSame(-12.50, $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::AMOUNT]); + $this->assertSame(0.0, $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::BALANCE_AFTER]); + $this->assertSame(9, $this->capturedTransaction[CashlessTransactionDomainObjectAbstract::CREATED_BY_USER_ID]); + $this->assertSame(CashlessWalletStatus::CLOSED->value, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::STATUS]); + } + + public function test_closing_leaves_the_topped_up_spent_and_refunded_totals_alone(): void + { + $this->givenWallet(balance: 12.50, toppedUp: 30.00, spent: 17.50, refunded: 0.0); + $this->expectTransactionCreated(); + $this->service->close(walletId: 1, closedByUserId: null); + + $writeUpdate = $this->walletUpdates[0]; + $this->assertSame(30.00, $writeUpdate[CashlessWalletDomainObjectAbstract::TOTAL_TOPPED_UP]); + $this->assertSame(17.50, $writeUpdate[CashlessWalletDomainObjectAbstract::TOTAL_SPENT]); + $this->assertSame(0.0, $writeUpdate[CashlessWalletDomainObjectAbstract::TOTAL_REFUNDED]); + } + + public function test_closing_an_empty_wallet_writes_no_transaction(): void + { + $this->givenWallet(balance: 0.00); + $this->transactionRepository->shouldNotReceive('create'); + + $transaction = $this->service->close(walletId: 1, closedByUserId: null); + + $this->assertNull($transaction); + $this->assertSame(CashlessWalletStatus::CLOSED->value, $this->capturedWalletUpdate[CashlessWalletDomainObjectAbstract::STATUS]); + } + + public function test_a_frozen_wallet_can_still_be_closed(): void + { + $this->givenWallet(balance: 8.00, status: CashlessWalletStatus::FROZEN); + $this->expectTransactionCreated(); + + $this->assertNotNull($this->service->close(walletId: 1, closedByUserId: null)); + } + + public function test_a_closure_cannot_be_reversed(): void + { + $closure = (new CashlessTransactionDomainObject) + ->setId(90) + ->setCashlessWalletId(1) + ->setType(CashlessTransactionType::CLOSURE->value) + ->setAmount(-12.50); + + $this->expectException(CashlessTransactionNotReversibleException::class); + + $this->service->reverse($closure, reversedByUserId: 3); + } + + private function purchaseTransaction(): CashlessTransactionDomainObject + { + return (new CashlessTransactionDomainObject) + ->setId(55) + ->setCashlessWalletId(1) + ->setCashlessSalesPointId(7) + ->setType(CashlessTransactionType::PURCHASE->value) + ->setAmount(-12.50); + } + + private function givenWallet( + float $balance, + float $toppedUp = 0.0, + float $spent = 0.0, + float $refunded = 0.0, + CashlessWalletStatus $status = CashlessWalletStatus::ACTIVE, + ): void { + $wallet = (new CashlessWalletDomainObject) + ->setId(1) + ->setEventId(2) + ->setAttendeeId(3) + ->setCurrency('EUR') + ->setBalance($balance) + ->setTotalToppedUp($toppedUp) + ->setTotalSpent($spent) + ->setTotalRefunded($refunded) + ->setStatus($status->value); + + $this->walletRepository->shouldReceive('lockById')->with(1)->andReturn($wallet); + + $this->walletRepository + ->shouldReceive('updateFromArray') + ->andReturnUsing(function (int $id, array $attributes) use ($wallet) { + $this->capturedWalletUpdate = $attributes; + $this->walletUpdates[] = $attributes; + + return $wallet; + }); + } + + private function expectTransactionCreated(): void + { + $this->transactionRepository + ->shouldReceive('create') + ->once() + ->andReturnUsing(function (array $attributes) { + $this->capturedTransaction = $attributes; + + return (new CashlessTransactionDomainObject) + ->setId(1000) + ->setCashlessWalletId(1) + ->setType($attributes[CashlessTransactionDomainObjectAbstract::TYPE]) + ->setAmount($attributes[CashlessTransactionDomainObjectAbstract::AMOUNT]); + }); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php index 1e0b8eb918..774c24f960 100644 --- a/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php +++ b/backend/tests/Unit/Services/Domain/Email/EmailTokenContextBuilderTest.php @@ -103,6 +103,37 @@ public function test_builds_attendee_ticket_context(): void $this->assertEquals('Great Organizer', $context['organizer']['name']); } + public function test_builds_cashless_topup_context(): void + { + $context = $this->contextBuilder->buildCashlessTopupContext( + $this->createMockAttendee(), + $this->createMockEvent(), + $this->createMockOrganizer(), + $this->createMockEventSettings(), + '$20.00', + '$35.00', + 'https://example.com/cashless/1/a_abc', + ); + + $this->assertSame('$20.00', $context['cashless']['topped_up_amount']); + $this->assertSame('$35.00', $context['cashless']['new_balance']); + $this->assertSame('https://example.com/cashless/1/a_abc', $context['cashless']['url']); + $this->assertArrayHasKey('name', $context['attendee']); + $this->assertArrayHasKey('title', $context['event']); + $this->assertArrayHasKey('name', $context['organizer']); + $this->assertArrayHasKey('support_email', $context['settings']); + } + + public function test_cashless_preview_context_exposes_the_cashless_tokens(): void + { + $context = $this->contextBuilder->buildPreviewContext('cashless_topup'); + + $this->assertArrayHasKey('topped_up_amount', $context['cashless']); + $this->assertArrayHasKey('new_balance', $context['cashless']); + $this->assertArrayHasKey('url', $context['cashless']); + $this->assertArrayHasKey('name', $context['attendee']); + } + public function test_whitelists_only_allowed_tokens_for_order_confirmation(): void { $order = $this->createMockOrder(); diff --git a/backend/tests/Unit/Services/Domain/Email/MailBuilderServiceTest.php b/backend/tests/Unit/Services/Domain/Email/MailBuilderServiceTest.php index 754101386b..8ad446de84 100644 --- a/backend/tests/Unit/Services/Domain/Email/MailBuilderServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Email/MailBuilderServiceTest.php @@ -2,6 +2,11 @@ namespace Tests\Unit\Services\Domain\Email; +use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\CashlessTransactionDomainObject; +use HiEvents\DomainObjects\CashlessWalletDomainObject; +use HiEvents\DomainObjects\EmailTemplateDomainObject; +use HiEvents\DomainObjects\Enums\EmailTemplateType; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; @@ -9,6 +14,8 @@ use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\Status\OrderPaymentStatus; use HiEvents\DomainObjects\Status\OrderStatus; +use HiEvents\Mail\Cashless\CashlessTopupConfirmationMail; +use HiEvents\Services\Domain\Email\DTO\RenderedEmailTemplateDTO; use HiEvents\Services\Domain\Email\EmailTemplateService; use HiEvents\Services\Domain\Email\EmailTokenContextBuilder; use HiEvents\Services\Domain\Email\MailBuilderService; @@ -72,6 +79,65 @@ public function test_order_summary_blade_fallback_receives_rendered_instructions ); } + public function test_cashless_topup_mail_uses_the_default_view_when_no_template_exists(): void + { + $emailTemplateService = m::mock(EmailTemplateService::class); + $emailTemplateService->shouldReceive('getTemplateByType')->andReturn(null); + $emailTemplateService->shouldNotReceive('renderTemplate'); + + $mail = $this->makeCashlessMail($emailTemplateService); + + $this->assertSame('emails.cashless.topup-confirmation', $mail->content()->markdown); + } + + public function test_cashless_topup_mail_renders_the_custom_template_when_one_exists(): void + { + $template = new EmailTemplateDomainObject; + $rendered = new RenderedEmailTemplateDTO('Custom subject', '

Body

', null); + + $emailTemplateService = m::mock(EmailTemplateService::class); + $emailTemplateService->shouldReceive('getTemplateByType') + ->withArgs(fn ($type) => $type === EmailTemplateType::CASHLESS_TOPUP) + ->andReturn($template); + $emailTemplateService->shouldReceive('renderTemplate') + ->withArgs(fn ($given, array $context) => $given === $template + && $context['cashless']['topped_up_amount'] === '$20.00' + && $context['cashless']['new_balance'] === '$35.00') + ->andReturn($rendered); + + $mail = $this->makeCashlessMail($emailTemplateService); + + $this->assertSame('emails.custom-template', $mail->content()->markdown); + $this->assertSame('Custom subject', $mail->envelope()->subject); + } + + private function makeCashlessMail(EmailTemplateService $emailTemplateService): CashlessTopupConfirmationMail + { + $service = new MailBuilderService( + $emailTemplateService, + app(EmailTokenContextBuilder::class), + app(OfflinePaymentInstructionsRenderService::class), + ); + + $organizer = (new OrganizerDomainObject)->setId(1)->setName('Organizer')->setEmail('organizer@example.com'); + $settings = (new EventSettingDomainObject)->setSupportEmail('support@example.com'); + $event = (new EventDomainObject) + ->setId(10) + ->setAccountId(1) + ->setTitle('Summer Fest') + ->setCurrency('USD') + ->setTimezone('UTC'); + $attendee = (new AttendeeDomainObject) + ->setShortId('a_abc') + ->setFirstName('Marie') + ->setLastName('Durand') + ->setEmail('marie@example.com'); + $wallet = (new CashlessWalletDomainObject)->setCurrency('USD'); + $transaction = (new CashlessTransactionDomainObject)->setAmount(20.0)->setBalanceAfter(35.0); + + return $service->buildCashlessTopupConfirmationMail($wallet, $transaction, $attendee, $event, $settings, $organizer); + } + protected function tearDown(): void { m::close(); diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php index 4cadb349ae..14c6d53404 100644 --- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCancellationServiceTest.php @@ -17,6 +17,7 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Domain\Cashless\CashlessTopupOrderChecker; use HiEvents\Services\Domain\EventStatistics\EventStatisticsCancellationService; use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier; use Illuminate\Database\DatabaseManager; @@ -82,6 +83,7 @@ protected function setUp(): void $this->promoCodeRepository, $this->productRepository, $this->affiliateRepository, + Mockery::mock(CashlessTopupOrderChecker::class, ['isTopupOrder' => false]), ); } diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCashlessClosureServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCashlessClosureServiceTest.php new file mode 100644 index 0000000000..1605214d4c --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsCashlessClosureServiceTest.php @@ -0,0 +1,96 @@ +eventRepository = Mockery::mock(EventStatisticRepositoryInterface::class); + $this->dailyRepository = Mockery::mock(EventDailyStatisticRepositoryInterface::class); + $this->occurrenceRepository = Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class); + $this->occurrenceDailyRepository = Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class); + + $this->service = new EventStatisticsCashlessClosureService( + $this->eventRepository, + $this->dailyRepository, + $this->occurrenceRepository, + $this->occurrenceDailyRepository, + ); + } + + public function test_it_adds_the_amount_to_the_gross_and_before_additions_on_every_level_and_bumps_the_version(): void + { + $expectedColumns = ['sales_total_gross' => 15.5, 'sales_total_before_additions' => 15.5, 'version' => 1]; + + $this->eventRepository->shouldReceive('incrementEach') + ->once()->with($expectedColumns, [], ['event_id' => 7])->andReturn(1); + $this->dailyRepository->shouldReceive('incrementEach') + ->once()->with($expectedColumns, [], ['event_id' => 7, 'date' => '2026-09-28'])->andReturn(1); + $this->occurrenceRepository->shouldReceive('incrementEach') + ->once()->with($expectedColumns, [], ['event_id' => 7, 'event_occurrence_id' => 3])->andReturn(1); + $this->occurrenceDailyRepository->shouldReceive('incrementEach') + ->once()->with($expectedColumns, [], ['event_id' => 7, 'event_occurrence_id' => 3, 'date' => '2026-09-28'])->andReturn(1); + + $this->service->recordClosedBalance(eventId: 7, occurrenceId: 3, date: '2026-09-28', amount: 15.5); + + $this->addToAssertionCount(1); + } + + public function test_it_creates_the_rows_that_do_not_exist_yet_without_touching_order_counters(): void + { + $this->eventRepository->shouldReceive('incrementEach')->andReturn(0); + $this->dailyRepository->shouldReceive('incrementEach')->andReturn(0); + $this->occurrenceRepository->shouldReceive('incrementEach')->andReturn(1); + $this->occurrenceDailyRepository->shouldReceive('incrementEach')->andReturn(1); + + $this->eventRepository->shouldReceive('create')->once()->with([ + 'event_id' => 7, + 'sales_total_gross' => 15.5, + 'sales_total_before_additions' => 15.5, + ]); + $this->dailyRepository->shouldReceive('create')->once()->with([ + 'event_id' => 7, + 'date' => '2026-09-28', + 'sales_total_gross' => 15.5, + 'sales_total_before_additions' => 15.5, + ]); + + $this->service->recordClosedBalance(eventId: 7, occurrenceId: 3, date: '2026-09-28', amount: 15.5); + + $this->addToAssertionCount(1); + } + + public function test_it_skips_the_occurrence_tables_without_an_occurrence(): void + { + $this->eventRepository->shouldReceive('incrementEach')->andReturn(1); + $this->dailyRepository->shouldReceive('incrementEach')->andReturn(1); + $this->occurrenceRepository->shouldNotReceive('incrementEach'); + $this->occurrenceDailyRepository->shouldNotReceive('incrementEach'); + + $this->service->recordClosedBalance(eventId: 7, occurrenceId: null, date: '2026-09-28', amount: 15.5); + + $this->addToAssertionCount(1); + } +} diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php index 44609f8517..cac869cee9 100644 --- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsIncrementServiceTest.php @@ -17,6 +17,7 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface; +use HiEvents\Services\Domain\Cashless\CashlessTopupOrderChecker; use HiEvents\Services\Domain\EventStatistics\EventStatisticsIncrementService; use HiEvents\Services\Infrastructure\Utlitiy\Retry\Retrier; use Illuminate\Database\DatabaseManager; @@ -75,7 +76,8 @@ protected function setUp(): void $this->databaseManager, $this->orderRepository, $this->logger, - $this->retrier + $this->retrier, + Mockery::mock(CashlessTopupOrderChecker::class, ['isTopupOrder' => false]), ); } diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php index 5e15459401..4ba40133bf 100644 --- a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsRefundServiceTest.php @@ -12,6 +12,7 @@ use HiEvents\Repository\Interfaces\EventOccurrenceStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\EventStatisticRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Services\Domain\Cashless\CashlessTopupOrderChecker; use HiEvents\Services\Domain\EventStatistics\EventStatisticsRefundService; use HiEvents\Values\MoneyValue; use Illuminate\Database\Query\Expression; @@ -62,7 +63,8 @@ protected function setUp(): void $this->eventOccurrenceStatisticRepository, $this->eventOccurrenceDailyStatisticRepository, $this->orderRepository, - $this->logger + $this->logger, + Mockery::mock(CashlessTopupOrderChecker::class, ['isTopupOrder' => false]), ); } @@ -373,7 +375,8 @@ public function test_update_for_refund_updates_occurrence_stats_for_order_with_i $this->eventOccurrenceStatisticRepository, $this->eventOccurrenceDailyStatisticRepository, $this->orderRepository, - $this->logger + $this->logger, + Mockery::mock(CashlessTopupOrderChecker::class, ['isTopupOrder' => false]), ); $this->stubAggregateAndDailyPaths($eventId); @@ -438,7 +441,8 @@ public function test_update_for_refund_splits_refund_across_multiple_occurrences $this->eventOccurrenceStatisticRepository, $this->eventOccurrenceDailyStatisticRepository, $this->orderRepository, - $this->logger + $this->logger, + Mockery::mock(CashlessTopupOrderChecker::class, ['isTopupOrder' => false]), ); $this->stubAggregateAndDailyPaths($eventId); @@ -502,7 +506,8 @@ public function test_update_for_refund_skips_occurrence_path_when_no_items_have_ $this->eventOccurrenceStatisticRepository, $this->eventOccurrenceDailyStatisticRepository, $this->orderRepository, - $this->logger + $this->logger, + Mockery::mock(CashlessTopupOrderChecker::class, ['isTopupOrder' => false]), ); $this->stubAggregateAndDailyPaths($eventId); diff --git a/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsTopupOrderTest.php b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsTopupOrderTest.php new file mode 100644 index 0000000000..0ee9ae8a61 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/EventStatistics/EventStatisticsTopupOrderTest.php @@ -0,0 +1,94 @@ + true]), + ); + + $order = (new OrderDomainObject)->setId(42); + + $service->incrementForOrder($order); + + $this->addToAssertionCount(1); + } + + public function test_refund_ignores_cashless_topup_orders(): void + { + $service = new EventStatisticsRefundService( + Mockery::mock(EventStatisticRepositoryInterface::class), + Mockery::mock(EventDailyStatisticRepositoryInterface::class), + Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class), + Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class), + Mockery::mock(OrderRepositoryInterface::class), + Mockery::mock(LoggerInterface::class), + Mockery::mock(CashlessTopupOrderChecker::class, ['isTopupOrder' => true]), + ); + + $order = (new OrderDomainObject)->setId(42); + + $service->updateForRefund($order, MoneyValue::fromFloat(10.0, 'USD')); + + $this->addToAssertionCount(1); + } + + public function test_cancellation_ignores_cashless_topup_orders(): void + { + $service = new EventStatisticsCancellationService( + Mockery::mock(EventStatisticRepositoryInterface::class), + Mockery::mock(EventDailyStatisticRepositoryInterface::class), + Mockery::mock(EventOccurrenceStatisticRepositoryInterface::class), + Mockery::mock(EventOccurrenceDailyStatisticRepositoryInterface::class), + Mockery::mock(AttendeeRepositoryInterface::class), + Mockery::mock(OrderRepositoryInterface::class), + Mockery::mock(LoggerInterface::class), + Mockery::mock(DatabaseManager::class), + Mockery::mock(Retrier::class), + Mockery::mock(PromoCodeRepositoryInterface::class), + Mockery::mock(ProductRepositoryInterface::class), + Mockery::mock(AffiliateRepositoryInterface::class), + Mockery::mock(CashlessTopupOrderChecker::class, ['isTopupOrder' => true]), + ); + + $order = (new OrderDomainObject)->setId(42); + + $service->decrementForCancelledOrder($order); + + $this->addToAssertionCount(1); + } +} diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts index 3d917c4c49..1b6b9cdcc0 100644 --- a/e2e/api/api-client.ts +++ b/e2e/api/api-client.ts @@ -37,6 +37,9 @@ import type { RegisterPayload, TaxOrFee, UpdateOccurrencePayload, + CashlessSalesPoint, + CashlessSettings, + CreateCashlessSalesPointPayload, Webhook, } from './types'; @@ -182,6 +185,38 @@ export class ApiClient { return check(this.request.patch(`events/${eventId}/settings`, { headers: jsonHeaders, data: settings })); } + updateCashlessSettings( + eventId: number, + payload: { + cashless_enabled: boolean; + cashless_min_topup_amount: number; + cashless_allow_remaining_balance_refund: boolean; + cashless_refund_deadline_at?: string | null; + cashless_online_topup_enabled?: boolean; + cashless_topup_tax_and_fee_ids?: number[]; + }, + ): Promise { + return unwrap( + this.request.put(`events/${eventId}/cashless/settings`, { + headers: jsonHeaders, + data: { + cashless_online_topup_enabled: true, + cashless_topup_tax_and_fee_ids: [], + ...payload, + }, + }), + ); + } + + createCashlessSalesPoint(eventId: number, payload: CreateCashlessSalesPointPayload): Promise { + return unwrap( + this.request.post(`events/${eventId}/cashless/sales-points`, { + headers: jsonHeaders, + data: { allow_staff_topups: true, ...payload }, + }), + ); + } + createPromoCode(eventId: number, payload: CreatePromoCodePayload): Promise { return unwrap( this.request.post(`events/${eventId}/promo-codes`, { diff --git a/e2e/api/types.ts b/e2e/api/types.ts index 8d676896a4..39a6361710 100644 --- a/e2e/api/types.ts +++ b/e2e/api/types.ts @@ -313,7 +313,7 @@ export interface CreateWebhookPayload { } export interface CreateEmailTemplatePayload { - template_type: 'order_confirmation' | 'attendee_ticket' | 'occurrence_cancellation'; + template_type: 'order_confirmation' | 'attendee_ticket' | 'occurrence_cancellation' | 'cashless_topup'; subject: string; body: string; ctaLabel: string; @@ -336,3 +336,31 @@ export interface RecurrenceRule { duration_minutes?: number; default_capacity?: number; } + +export interface CashlessSettings { + event_id: number; + cashless_enabled: boolean; + cashless_topup_product_id: number | null; + cashless_closed_at: string | null; + cashless_min_topup_amount: number; + cashless_allow_remaining_balance_refund: boolean; + cashless_refund_deadline_at: string | null; + cashless_online_topup_enabled: boolean; + cashless_topup_tax_and_fee_ids: number[]; +} + +export interface CreateCashlessSalesPointPayload { + name: string; + product_ids: number[]; + allow_staff_topups?: boolean; + access_pin?: string | null; + description?: string | null; +} + +export interface CashlessSalesPoint { + id: number; + short_id: string; + name: string; + has_access_pin: boolean; + allow_staff_topups: boolean; +} diff --git a/e2e/pages/cashless.page.ts b/e2e/pages/cashless.page.ts new file mode 100644 index 0000000000..6c2f40fab8 --- /dev/null +++ b/e2e/pages/cashless.page.ts @@ -0,0 +1,171 @@ +import type { Locator, Page } from '@playwright/test'; + +export class CashlessSalesPointPage { + constructor(private readonly page: Page) {} + + async goto(eventId: number): Promise { + await this.page.goto(`/manage/event/${eventId}/cashless/sales-points`); + await this.page.waitForLoadState('networkidle'); + } + + async create(name: string, productTitle: string): Promise { + await this.page.getByTestId('cashless-sales-point-create-button').click(); + await this.page.getByLabel(/^Name/).fill(name); + await this.page.getByRole('combobox', { name: 'What can be sold here?' }).click(); + await this.page.getByRole('option', { name: productTitle }).click(); + await this.page.keyboard.press('Escape'); + await this.page.getByTestId('cashless-sales-point-submit-button').click(); + } + + row(name: string): Locator { + return this.page.getByRole('row').filter({ hasText: name }); + } +} + +export class CashlessWalletsPage { + constructor(private readonly page: Page) {} + + async goto(eventId: number): Promise { + await this.page.goto(`/manage/event/${eventId}/cashless`); + await this.page.waitForLoadState('networkidle'); + } + + row(ticketPublicId: string): Locator { + return this.page.getByRole('row').filter({ hasText: ticketPublicId }); + } + + async topUpByTicketId(ticketPublicId: string, amount: number): Promise { + await this.page.getByTestId('cashless-ticket-topup-button').click(); + await this.page.getByTestId('cashless-ticket-lookup-input').fill(ticketPublicId); + await this.page.getByTestId('cashless-ticket-lookup-submit-button').click(); + await this.page.getByLabel('Amount to add').fill(String(amount)); + await this.page.getByTestId('cashless-topup-submit-button').click(); + } +} + +export class CashlessPosPage { + constructor(private readonly page: Page) {} + + async goto(salesPointShortId: string): Promise { + await this.page.goto(`/cashless/pos/${salesPointShortId}`); + await this.page.waitForLoadState('networkidle'); + } + + async unlock(pin: string): Promise { + await this.page.getByTestId('cashless-pos-pin-input').fill(pin); + await this.page.getByTestId('cashless-pos-pin-submit-button').click(); + } + + async lookUpTicket(ticketPublicId: string, inputTestId = 'cashless-pos-ticket-input'): Promise { + await this.page.getByTestId(inputTestId).fill(ticketPublicId); + await this.page.getByRole('button', { name: 'Find' }).click(); + } + + async addProduct(priceId: number): Promise { + await this.page.getByTestId(`cashless-pos-product-${priceId}`).click(); + } + + chargeButton(): Locator { + return this.page.getByTestId('cashless-pos-charge-button'); + } +} + +export class CashlessWalletPublicPage { + constructor(private readonly page: Page) {} + + async goto(eventId: number, ticketReference: string): Promise { + await this.page.goto(`/cashless/${eventId}/${ticketReference}`); + await this.page.waitForLoadState('networkidle'); + } + + balance(): Locator { + return this.page.getByText('Available to spend'); + } +} + +export class CashlessTopupEntryPage { + constructor(private readonly page: Page) {} + + async gotoFromEventPage(eventId: number, slug: string): Promise { + await this.page.goto(`/event/${eventId}/${slug}`); + await this.page.waitForLoadState('networkidle'); + await this.page.getByTestId('event-cashless-topup-link').click(); + await this.page.waitForURL(new RegExp(`/cashless/${eventId}$`)); + } + + async submitTicketId(ticketId: string): Promise { + await this.page.getByTestId('cashless-entry-ticket-input').fill(ticketId); + await this.page.getByTestId('cashless-entry-continue-button').click(); + } +} + +export class CashlessOverviewPage { + constructor(private readonly page: Page) {} + + async goto(eventId: number): Promise { + await this.page.goto(`/manage/event/${eventId}/cashless/overview`); + await this.page.waitForLoadState('networkidle'); + } + + kpiValue(name: 'outstanding' | 'topped-up' | 'spent' | 'refunded' | 'closed' | 'purchases'): Locator { + return this.page.getByTestId(`cashless-overview-${name}-value`); + } +} + +export class CashlessSettingsPage { + constructor(private readonly page: Page) {} + + async goto(eventId: number): Promise { + await this.page.goto(`/manage/event/${eventId}/cashless/settings`); + await this.page.waitForLoadState('networkidle'); + } + + dangerZone(): Locator { + return this.page.getByRole('heading', { name: 'Danger Zone' }); + } + + async openCloseModal(): Promise { + await this.page.getByTestId('cashless-close-button').click(); + } + + closeSubmitButton(): Locator { + return this.page.getByTestId('cashless-close-submit-button'); + } + + async confirmAndClose(): Promise { + await this.page.getByTestId('cashless-close-confirm-checkbox').check(); + await this.closeSubmitButton().click(); + } +} + +export class CashlessTransactionsPage { + constructor(private readonly page: Page) {} + + async goto(eventId: number): Promise { + await this.page.goto(`/manage/event/${eventId}/cashless/transactions`); + await this.page.waitForLoadState('networkidle'); + } + + async filterBy(field: 'Type' | 'Sales point', optionLabel: string): Promise { + await this.page.getByRole('button', { name: /^Filters/ }).click(); + const dialog = this.page.getByRole('dialog'); + await dialog.getByPlaceholder(`Select ${field.toLowerCase()}`).click(); + await this.page.getByRole('option', { name: optionLabel, exact: true }).click(); + await dialog.getByRole('heading').click(); + await dialog.getByRole('button', { name: 'Apply' }).click(); + await dialog.waitFor({ state: 'hidden' }); + } + + async resetFilters(): Promise { + await this.page.getByRole('button', { name: /^Filters/ }).click(); + await this.page.getByRole('dialog').getByRole('button', { name: 'Reset' }).click(); + } + + async search(query: string): Promise { + await this.page.getByPlaceholder(/Search by name/).fill(query); + } + + row(text: string): Locator { + return this.page.getByRole('row').filter({ hasText: text }); + } +} diff --git a/e2e/tests/cashless/cashless.spec.ts b/e2e/tests/cashless/cashless.spec.ts new file mode 100644 index 0000000000..13124eb5fb --- /dev/null +++ b/e2e/tests/cashless/cashless.spec.ts @@ -0,0 +1,358 @@ +import { test, expect } from '../../fixtures'; +import { + CashlessOverviewPage, + CashlessSettingsPage, + CashlessTransactionsPage, + CashlessPosPage, + CashlessTopupEntryPage, + CashlessSalesPointPage, + CashlessWalletPublicPage, + CashlessWalletsPage, +} from '../../pages/cashless.page'; +import { createCompletedOrder, createLiveEventWithFreeTicket } from '../../api/factory'; +import { uniqueName } from '../../utils/unique'; + +const SALES_POINT_PIN = '4321'; + +const seedCashlessEvent = async (api: any, publicApi: any, organizerId: number) => { + const event = await createLiveEventWithFreeTicket(api, organizerId); + + await api.updateCashlessSettings(event.eventId, { + cashless_enabled: true, + cashless_min_topup_amount: 5, + cashless_allow_remaining_balance_refund: true, + }); + + const categories = await api.listProductCategories(event.eventId); + const drink = await api.createProduct(event.eventId, { + title: 'Beer', + product_type: 'GENERAL', + type: 'PAID', + product_category_id: categories[0].id, + prices: [{ price: 5 }], + }); + const drinkWithPrices = await api.getProduct(event.eventId, drink.id); + + const order = await createCompletedOrder(publicApi, event); + + return { + event, + order, + drinkId: drink.id as number, + drinkPriceId: drinkWithPrices.prices[0].id as number, + }; +}; + +test.describe('cashless', () => { + test('@smoke an organizer creates a sales point and sees it listed', async ({ authedPage, api, account, publicApi }) => { + const { event } = await seedCashlessEvent(api, publicApi, account.organizerId); + const salesPointName = uniqueName('Main Bar'); + + const salesPoints = new CashlessSalesPointPage(authedPage); + await salesPoints.goto(event.eventId); + await salesPoints.create(salesPointName, 'Beer'); + + await expect(salesPoints.row(salesPointName)).toBeVisible(); + await expect(salesPoints.row(salesPointName)).toContainText('Beer'); + }); + + test('an organizer tops up a ticket and the attendee sees the balance', async ({ + authedPage, + api, + account, + publicApi, + }) => { + const { event, order } = await seedCashlessEvent(api, publicApi, account.organizerId); + const attendee = order.attendees[0]; + + const wallets = new CashlessWalletsPage(authedPage); + await wallets.goto(event.eventId); + await wallets.topUpByTicketId(attendee.publicId, 30); + + await expect(authedPage.getByText('Balance topped up')).toBeVisible(); + await expect(wallets.row(attendee.publicId)).toContainText('$30.00'); + }); + + test('staff charge a ticket at a sales point and the balance drops', async ({ + authedPage, + page, + api, + account, + publicApi, + }) => { + const { event, order, drinkId, drinkPriceId } = await seedCashlessEvent(api, publicApi, account.organizerId); + const attendee = order.attendees[0]; + + const salesPoint = await api.createCashlessSalesPoint(event.eventId, { + name: uniqueName('Bar'), + product_ids: [drinkId], + access_pin: SALES_POINT_PIN, + }); + + const wallets = new CashlessWalletsPage(authedPage); + await wallets.goto(event.eventId); + await wallets.topUpByTicketId(attendee.publicId, 40); + await expect(authedPage.getByText('Balance topped up')).toBeVisible(); + + const pos = new CashlessPosPage(page); + await pos.goto(salesPoint.short_id); + await pos.unlock(SALES_POINT_PIN); + + await pos.lookUpTicket(attendee.publicId); + await expect(page.getByText(attendee.publicId)).toBeVisible(); + + await pos.addProduct(drinkPriceId); + await pos.addProduct(drinkPriceId); + await expect(pos.chargeButton()).toContainText('$10.00'); + + await pos.chargeButton().click(); + await expect(page.getByText('$30.00 left')).toBeVisible(); + + await page.getByRole('button', { name: 'History' }).click(); + await expect(page.getByText('2 × Beer')).toBeVisible(); + + await page.reload(); + await page.getByRole('button', { name: 'History' }).click(); + await expect(page.getByText('2 × Beer')).toBeVisible(); + await expect(page.getByText(attendee.publicId)).toBeVisible(); + + const publicWallet = new CashlessWalletPublicPage(page); + await publicWallet.goto(event.eventId, attendee.shortId); + await expect(publicWallet.balance()).toBeVisible(); + await expect(page.getByText('2 × Beer')).toBeVisible(); + }); + + test('the Balances menu item is only highlighted on the balances screen', async ({ authedPage, api, account, publicApi }) => { + const { event } = await seedCashlessEvent(api, publicApi, account.organizerId); + + const balances = authedPage.getByRole('link', { name: 'Balances' }); + + await authedPage.goto(`/manage/event/${event.eventId}/cashless`); + await expect(balances).toHaveAttribute('aria-current', 'page'); + + await authedPage.goto(`/manage/event/${event.eventId}/cashless/sales-points`); + await expect(balances).not.toHaveAttribute('aria-current', 'page'); + await expect(authedPage.getByRole('link', { name: 'Sales Points' })).toHaveAttribute('aria-current', 'page'); + }); + + test('a top-up-only sales point shows the amount to key into the card terminal', async ({ page, api, account, publicApi }) => { + const { event, order } = await seedCashlessEvent(api, publicApi, account.organizerId); + const attendee = order.attendees[0]; + + const { id: accountId } = await api.getAccount(); + const fee = await api.createTaxOrFee(accountId, { + name: uniqueName('Card handling'), + calculation_type: 'FIXED', + type: 'FEE', + rate: 0.35, + is_active: true, + is_default: false, + }); + await api.updateCashlessSettings(event.eventId, { + cashless_enabled: true, + cashless_min_topup_amount: 5, + cashless_allow_remaining_balance_refund: false, + cashless_topup_tax_and_fee_ids: [fee.id], + }); + + const salesPoint = await api.createCashlessSalesPoint(event.eventId, { + name: uniqueName('Top-up desk'), + product_ids: [], + }); + + const pos = new CashlessPosPage(page); + await pos.goto(salesPoint.short_id); + + await expect(page.getByRole('button', { name: 'Charge' })).toHaveCount(0); + + await pos.lookUpTicket(attendee.publicId, 'cashless-pos-topup-ticket-input'); + await page.getByRole('combobox', { name: 'How did they pay?' }).click(); + await page.getByRole('option', { name: 'Card terminal' }).click(); + await page.getByTestId('cashless-pos-topup-amount-input').fill('20'); + + await expect(page.getByTestId('cashless-pos-terminal-total')).toHaveText('$20.35'); + }); + + test('a sales point refuses the wrong PIN', async ({ page, api, account, publicApi }) => { + const { event, drinkId } = await seedCashlessEvent(api, publicApi, account.organizerId); + + const salesPoint = await api.createCashlessSalesPoint(event.eventId, { + name: uniqueName('Bar'), + product_ids: [drinkId], + access_pin: SALES_POINT_PIN, + }); + + const pos = new CashlessPosPage(page); + await pos.goto(salesPoint.short_id); + await pos.unlock('0000'); + + await expect(page.getByText('That PIN is not correct.')).toBeVisible(); + }); + + test('a visitor reaches their balance from the event page by typing their ticket ID', async ({ + page, + api, + account, + publicApi, + }) => { + const { event, order } = await seedCashlessEvent(api, publicApi, account.organizerId); + const attendee = order.attendees[0]; + + const entry = new CashlessTopupEntryPage(page); + await entry.gotoFromEventPage(event.eventId, event.slug); + + await entry.submitTicketId('not-a-ticket'); + await expect(page.getByText("That doesn't look like a ticket ID")).toBeVisible(); + + await entry.submitTicketId(attendee.publicId.toLowerCase()); + await expect(page).toHaveURL(new RegExp(`/cashless/${event.eventId}/${attendee.publicId}$`)); + await expect(new CashlessWalletPublicPage(page).balance()).toBeVisible(); + }); + + test('a visitor tops up online and reaches a checkout already filled from the ticket', async ({ + page, + api, + account, + publicApi, + }) => { + const { event, order } = await seedCashlessEvent(api, publicApi, account.organizerId); + const attendee = order.attendees[0]; + + const wallet = new CashlessWalletPublicPage(page); + await wallet.goto(event.eventId, attendee.publicId); + await page.getByTestId('cashless-topup-button').click(); + + await expect(page).toHaveURL(new RegExp(`/checkout/${event.eventId}/o_[^/]+/details`)); + await expect(page.getByRole('textbox', { name: 'First Name', exact: true })).toHaveValue(order.buyerFirstName); + await expect(page.getByRole('textbox', { name: 'Email Address', exact: true })).toHaveValue(order.buyerEmail); + await page.getByRole('button', { name: 'Continue to Payment' }).click(); + + await expect(page).toHaveURL(/\/(payment|summary)$/); + await expect(page.getByText(/already been processed/i)).toHaveCount(0); + }); + + test('the overview totals the balances and closing moves what is left into sales', async ({ + authedPage, + api, + account, + publicApi, + }) => { + const { event, order } = await seedCashlessEvent(api, publicApi, account.organizerId); + const attendee = order.attendees[0]; + + const wallets = new CashlessWalletsPage(authedPage); + await wallets.goto(event.eventId); + await wallets.topUpByTicketId(attendee.publicId, 30); + await expect(authedPage.getByText('Balance topped up')).toBeVisible(); + + const overview = new CashlessOverviewPage(authedPage); + await overview.goto(event.eventId); + await expect(overview.kpiValue('outstanding')).toHaveText('$30.00'); + await expect(overview.kpiValue('topped-up')).toHaveText('$30.00'); + await expect(overview.kpiValue('spent')).toHaveText('$0.00'); + + const settings = new CashlessSettingsPage(authedPage); + await settings.goto(event.eventId); + await expect(settings.dangerZone()).toBeVisible(); + await settings.openCloseModal(); + await expect(authedPage.getByTestId('cashless-close-refund-warning')).toBeVisible(); + await authedPage.getByTestId('cashless-close-confirm-checkbox').check(); + await expect(settings.closeSubmitButton()).toBeDisabled(); + await authedPage.keyboard.press('Escape'); + + await api.updateCashlessSettings(event.eventId, { + cashless_enabled: true, + cashless_min_topup_amount: 5, + cashless_allow_remaining_balance_refund: false, + }); + await authedPage.reload(); + + await settings.openCloseModal(); + await expect(authedPage.getByTestId('cashless-close-amount')).toHaveText('$30.00'); + await settings.confirmAndClose(); + + await expect(authedPage.getByTestId('cashless-close-button')).toHaveCount(0); + + await overview.goto(event.eventId); + await expect(authedPage.getByTestId('cashless-closed-notice')).toBeVisible(); + await expect(overview.kpiValue('outstanding')).toHaveText('$0.00'); + await expect(overview.kpiValue('closed')).toHaveText('$30.00'); + + await wallets.goto(event.eventId); + await expect(wallets.row(attendee.publicId)).toContainText('CLOSED'); + }); + + test('a closed balance can no longer be topped up online', async ({ page, authedPage, api, account, publicApi }) => { + const { event, order } = await seedCashlessEvent(api, publicApi, account.organizerId); + const attendee = order.attendees[0]; + + await api.updateCashlessSettings(event.eventId, { + cashless_enabled: true, + cashless_min_topup_amount: 5, + cashless_allow_remaining_balance_refund: false, + }); + const settings = new CashlessSettingsPage(authedPage); + await settings.goto(event.eventId); + await settings.openCloseModal(); + await settings.confirmAndClose(); + await expect(authedPage.getByTestId('cashless-close-button')).toHaveCount(0); + + const wallet = new CashlessWalletPublicPage(page); + await wallet.goto(event.eventId, attendee.publicId); + await expect(page.getByText('Cashless is closed for this event')).toBeVisible(); + await expect(page.getByTestId('cashless-topup-button')).toHaveCount(0); + }); + + test('an organizer jumps from a balance or a sales point to its transactions and filters them', async ({ + authedPage, + api, + account, + publicApi, + }) => { + const { event, order, drinkId } = await seedCashlessEvent(api, publicApi, account.organizerId); + const attendee = order.attendees[0]; + const salesPointName = uniqueName('Bar'); + await api.createCashlessSalesPoint(event.eventId, { + name: salesPointName, + product_ids: [drinkId], + access_pin: SALES_POINT_PIN, + }); + + const wallets = new CashlessWalletsPage(authedPage); + await wallets.goto(event.eventId); + await wallets.topUpByTicketId(attendee.publicId, 30); + await expect(authedPage.getByText('Balance topped up')).toBeVisible(); + + await wallets.row(attendee.publicId).getByRole('button').click(); + await authedPage.getByTestId('cashless-wallet-transactions-menu-item').click(); + + const transactions = new CashlessTransactionsPage(authedPage); + await expect(authedPage).toHaveURL(/cashless\/transactions\?.*cashless_wallet_id/); + await expect(authedPage.getByTestId('cashless-transactions-wallet-pill')).toBeVisible(); + await expect(transactions.row(attendee.publicId)).toBeVisible(); + + await transactions.goto(event.eventId); + await transactions.filterBy('Type', 'Purchase'); + await expect(authedPage.getByText('No cashless activity yet')).toBeVisible(); + await transactions.resetFilters(); + await expect(transactions.row(attendee.publicId)).toBeVisible(); + await transactions.filterBy('Type', 'Staff top-up'); + await expect(transactions.row(attendee.publicId)).toBeVisible(); + + await transactions.resetFilters(); + await expect(authedPage.getByRole('button', { name: 'Filters', exact: true })).toBeVisible(); + await transactions.search('nobody-with-that-name'); + await expect(authedPage.getByText('No search results.')).toBeVisible(); + await transactions.search(attendee.publicId); + await expect(transactions.row(attendee.publicId)).toBeVisible(); + + const salesPoints = new CashlessSalesPointPage(authedPage); + await salesPoints.goto(event.eventId); + await salesPoints.row(salesPointName).getByRole('button').click(); + await authedPage.getByTestId('cashless-sales-point-transactions-menu-item').click(); + await expect(authedPage).toHaveURL(/cashless\/transactions\?.*cashless_sales_point_id/); + await expect(authedPage.getByRole('button', { name: 'Filters (1)' })).toBeVisible(); + await authedPage.getByRole('button', { name: 'Filters (1)' }).click(); + await expect(authedPage.getByRole('dialog').getByText(salesPointName)).toBeVisible(); + }); +}); diff --git a/e2e/tests/management/email-templates.spec.ts b/e2e/tests/management/email-templates.spec.ts index 7e82b6a1e8..0ba487afa6 100644 --- a/e2e/tests/management/email-templates.spec.ts +++ b/e2e/tests/management/email-templates.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '../../fixtures'; import { EmailTemplatePage } from '../../pages/email-template.page'; -import { createDraftEvent, createFreshOrganizer } from '../../api/factory'; +import { createDraftEvent, createFreshOrganizer, createLiveEventWithFreeTicket } from '../../api/factory'; import { uniqueName } from '../../utils/unique'; test.describe('email templates', () => { @@ -23,6 +23,32 @@ test.describe('email templates', () => { await expect(card.getByText('Active', { exact: true })).toBeVisible(); }); + test('the cashless top-up template lives in the cashless settings, not the event email templates', async ({ authedPage, api, account }) => { + const event = await createLiveEventWithFreeTicket(api, account.organizerId); + await api.updateCashlessSettings(event.eventId, { + cashless_enabled: true, + cashless_min_topup_amount: 5, + cashless_allow_remaining_balance_refund: true, + }); + const templates = new EmailTemplatePage(authedPage); + + await templates.gotoEventTemplates(event.eventId); + await expect(templates.templateCard('Order Confirmation')).toBeVisible(); + await expect(templates.templateCard('Cashless Top-up')).toHaveCount(0); + + const subject = uniqueName('Cashless Subject'); + await authedPage.goto(`/manage/event/${event.eventId}/cashless/settings`); + await authedPage.waitForLoadState('networkidle'); + await expect(templates.templateCard('Order Confirmation')).toHaveCount(0); + + await templates.openCreateEditor('Cashless Top-up'); + await expect(templates.subjectInput()).toHaveValue(/cashless balance/); + await templates.fillEditor(subject, uniqueName('Cashless body copy')); + await templates.saveTemplate(); + + await expect(templates.templateCard('Cashless Top-up').getByText(subject)).toBeVisible(); + }); + test('the template preview renders the saved subject and body', async ({ authedPage, api, account }) => { const event = await createDraftEvent(api, account.organizerId); const subject = uniqueName('Preview Subject'); diff --git a/frontend/src/api/cashless-public.client.ts b/frontend/src/api/cashless-public.client.ts new file mode 100644 index 0000000000..a14dcb229f --- /dev/null +++ b/frontend/src/api/cashless-public.client.ts @@ -0,0 +1,105 @@ +import {publicApi} from "./public-client"; +import { + CashlessQuote, + CashlessSalesPointPublic, + CashlessStaffPaymentMethod, + CashlessTransaction, + CashlessWalletPublic, + GenericDataResponse, + IdParam, + Order, +} from "../types"; + +export interface CashlessPurchaseItem { + product_id: number; + product_price_id: number; + quantity: number; +} + +const sessionHeaders = (sessionToken?: string | null) => + sessionToken ? {headers: {'X-Cashless-Session': sessionToken}} : undefined; + +export const publicCashlessClient = { + getWallet: async (eventId: IdParam, ticketReference: IdParam) => { + const response = await publicApi.get>( + `/events/${eventId}/cashless/${ticketReference}`, + ); + return response.data; + }, + createTopup: async (eventId: IdParam, ticketReference: IdParam, amount: number) => { + const response = await publicApi.post>( + `/events/${eventId}/cashless/${ticketReference}/topup`, {amount}, + ); + return response.data; + }, + + getSalesPoint: async (salesPointShortId: IdParam, sessionToken?: string | null) => { + const response = await publicApi.get>( + `/cashless/sales-points/${salesPointShortId}`, sessionHeaders(sessionToken), + ); + return response.data; + }, + createSession: async (salesPointShortId: IdParam, pin?: string) => { + const response = await publicApi.post<{ token: string }>( + `/cashless/sales-points/${salesPointShortId}/session`, {pin}, + ); + return response.data; + }, + getWalletAtSalesPoint: async (salesPointShortId: IdParam, attendeePublicId: IdParam, sessionToken?: string | null) => { + const response = await publicApi.get>( + `/cashless/sales-points/${salesPointShortId}/wallets/${attendeePublicId}`, sessionHeaders(sessionToken), + ); + return response.data; + }, + getQuote: async ( + salesPointShortId: IdParam, + payload: { items?: CashlessPurchaseItem[]; topup_amount?: number }, + sessionToken?: string | null, + ) => { + const response = await publicApi.post>( + `/cashless/sales-points/${salesPointShortId}/quote`, payload, sessionHeaders(sessionToken), + ); + return response.data; + }, + createPurchase: async ( + salesPointShortId: IdParam, + payload: { attendee_public_id: string; client_reference_id: string; items: CashlessPurchaseItem[] }, + sessionToken?: string | null, + ) => { + const response = await publicApi.post>( + `/cashless/sales-points/${salesPointShortId}/purchases`, payload, sessionHeaders(sessionToken), + ); + return response.data; + }, + createStaffTopup: async ( + salesPointShortId: IdParam, + payload: { + attendee_public_id: string; + client_reference_id: string; + amount: number; + payment_method: CashlessStaffPaymentMethod; + notes?: string; + }, + sessionToken?: string | null, + ) => { + const response = await publicApi.post>( + `/cashless/sales-points/${salesPointShortId}/topups`, payload, sessionHeaders(sessionToken), + ); + return response.data; + }, + getSalesPointTransactions: async (salesPointShortId: IdParam, sessionToken?: string | null) => { + const response = await publicApi.get>( + `/cashless/sales-points/${salesPointShortId}/transactions`, + sessionHeaders(sessionToken), + ); + return response.data; + }, + reverseTransaction: async (salesPointShortId: IdParam, transactionShortId: IdParam, sessionToken?: string | null) => { + const response = await publicApi.post>( + `/cashless/sales-points/${salesPointShortId}/transactions/${transactionShortId}/reverse`, + {}, + sessionHeaders(sessionToken), + ); + return response.data; + }, +}; diff --git a/frontend/src/api/cashless.client.ts b/frontend/src/api/cashless.client.ts new file mode 100644 index 0000000000..6b0e49def8 --- /dev/null +++ b/frontend/src/api/cashless.client.ts @@ -0,0 +1,132 @@ +import {api} from "./client"; +import { + CashlessClosureResult, + CashlessDailyStats, + CashlessRefundResult, + CashlessSalesPoint, + CashlessSettings, + CashlessSummary, + CashlessTransaction, + CashlessWallet, + CashlessWalletStatus, + CreateCashlessTopupRequest, + GenericDataResponse, + GenericPaginatedResponse, + IdParam, + QueryFilters, + UpsertCashlessSalesPointRequest, +} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; + +export const cashlessClient = { + getSettings: async (eventId: IdParam) => { + const response = await api.get>(`events/${eventId}/cashless/settings`); + return response.data; + }, + updateSettings: async (eventId: IdParam, settings: Omit) => { + const response = await api.put>(`events/${eventId}/cashless/settings`, settings); + return response.data; + }, + + getSummary: async (eventId: IdParam) => { + const response = await api.get>(`events/${eventId}/cashless/summary`); + return response.data; + }, + + closeCashless: async (eventId: IdParam) => { + const response = await api.post>(`events/${eventId}/cashless/close`); + return response.data; + }, + + getDailyStats: async (eventId: IdParam, startDate: string, endDate: string) => { + const response = await api.get<{ data: CashlessDailyStats[] }>( + `events/${eventId}/cashless/stats?start_date=${startDate}&end_date=${endDate}`, + ); + return response.data; + }, + + allSalesPoints: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await api.get>( + `events/${eventId}/cashless/sales-points` + queryParamsHelper.buildQueryString(pagination), + ); + return response.data; + }, + findSalesPointById: async (eventId: IdParam, salesPointId: IdParam) => { + const response = await api.get>( + `events/${eventId}/cashless/sales-points/${salesPointId}`, + ); + return response.data; + }, + createSalesPoint: async (eventId: IdParam, salesPoint: UpsertCashlessSalesPointRequest) => { + const response = await api.post>( + `events/${eventId}/cashless/sales-points`, salesPoint, + ); + return response.data; + }, + updateSalesPoint: async (eventId: IdParam, salesPointId: IdParam, salesPoint: UpsertCashlessSalesPointRequest) => { + const response = await api.put>( + `events/${eventId}/cashless/sales-points/${salesPointId}`, salesPoint, + ); + return response.data; + }, + deleteSalesPoint: async (eventId: IdParam, salesPointId: IdParam) => { + const response = await api.delete(`events/${eventId}/cashless/sales-points/${salesPointId}`); + return response.data; + }, + + allWallets: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await api.get>( + `events/${eventId}/cashless/wallets` + queryParamsHelper.buildQueryString(pagination), + ); + return response.data; + }, + findWalletById: async (eventId: IdParam, walletId: IdParam) => { + const response = await api.get>( + `events/${eventId}/cashless/wallets/${walletId}`, + ); + return response.data; + }, + lookupWallet: async (eventId: IdParam, attendeePublicId: string) => { + const response = await api.post>( + `events/${eventId}/cashless/wallets/lookup`, {attendee_public_id: attendeePublicId}, + ); + return response.data; + }, + topUpWallet: async (eventId: IdParam, walletId: IdParam, topup: CreateCashlessTopupRequest) => { + const response = await api.post>( + `events/${eventId}/cashless/wallets/${walletId}/topups`, topup, + ); + return response.data; + }, + updateWalletStatus: async (eventId: IdParam, walletId: IdParam, status: CashlessWalletStatus) => { + const response = await api.patch>( + `events/${eventId}/cashless/wallets/${walletId}/status`, {status}, + ); + return response.data; + }, + refundWallet: async (eventId: IdParam, walletId: IdParam, method: 'ORIGINAL_PAYMENT' | 'CASH') => { + const response = await api.post>( + `events/${eventId}/cashless/wallets/${walletId}/refund`, {method}, + ); + return response.data; + }, + + allTransactions: async (eventId: IdParam, pagination: QueryFilters) => { + const response = await api.get>( + `events/${eventId}/cashless/transactions` + queryParamsHelper.buildQueryString(pagination), + ); + return response.data; + }, + reverseTransaction: async (eventId: IdParam, transactionId: IdParam, notes?: string) => { + const response = await api.post>( + `events/${eventId}/cashless/transactions/${transactionId}/reverse`, {notes}, + ); + return response.data; + }, + exportTransactions: async (eventId: IdParam): Promise => { + const response = await api.post(`events/${eventId}/cashless/transactions/export`, {}, { + responseType: 'blob', + }); + return response.data; + }, +}; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5ffb2d7484..c90ebe7175 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -23,6 +23,7 @@ const ALLOWED_UNAUTHENTICATED_PATHS = [ 'widget', '/product/', 'check-in', + '/cashless/', '/events/', 'my-tickets', ]; diff --git a/frontend/src/components/common/CashlessSalesPointTable/CashlessSalesPointTable.module.scss b/frontend/src/components/common/CashlessSalesPointTable/CashlessSalesPointTable.module.scss new file mode 100644 index 0000000000..4680a060d3 --- /dev/null +++ b/frontend/src/components/common/CashlessSalesPointTable/CashlessSalesPointTable.module.scss @@ -0,0 +1,3 @@ +.link { + word-break: break-all; +} diff --git a/frontend/src/components/common/CashlessSalesPointTable/index.tsx b/frontend/src/components/common/CashlessSalesPointTable/index.tsx new file mode 100644 index 0000000000..13dac7e004 --- /dev/null +++ b/frontend/src/components/common/CashlessSalesPointTable/index.tsx @@ -0,0 +1,179 @@ +import {t} from "@lingui/macro"; +import {Badge, Button, Table as MantineTable, Text} from "@mantine/core"; +import {IconArrowsExchange, IconCopy, IconPencil, IconPlus, IconQrcode, IconTrash} from "@tabler/icons-react"; +import {useClipboard, useDisclosure} from "@mantine/hooks"; +import {useState} from "react"; +import {useNavigate, useParams} from "react-router"; +import {CashlessSalesPoint, IdParam} from "../../../types.ts"; +import {NoResultsSplash} from "../NoResultsSplash"; +import {Table, TableHead} from "../Table"; +import {ActionMenu} from "../ActionMenu"; +import {ShareModal} from "../../modals/ShareModal"; +import {EditCashlessSalesPointModal} from "../../modals/EditCashlessSalesPointModal"; +import {useDeleteCashlessSalesPoint} from "../../../mutations/useDeleteCashlessSalesPoint.ts"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; +import {formatCurrency} from "../../../utilites/currency.ts"; +import {cashlessTransactionsPath} from "../../../utilites/cashlessLinks.ts"; +import classes from "./CashlessSalesPointTable.module.scss"; + +interface CashlessSalesPointTableProps { + salesPoints: CashlessSalesPoint[]; + currency: string; + openCreateModal: () => void; +} + +const salesPointUrl = (shortId: string) => + typeof window === 'undefined' ? '' : `${window.location.origin}/cashless/pos/${shortId}`; + +export const CashlessSalesPointTable = ({salesPoints, currency, openCreateModal}: CashlessSalesPointTableProps) => { + const {eventId} = useParams(); + const navigate = useNavigate(); + const copy = useClipboard(); + const [selectedSalesPoint, setSelectedSalesPoint] = useState(); + const [editOpen, {open: openEdit, close: closeEdit}] = useDisclosure(false); + const [shareOpen, {open: openShare, close: closeShare}] = useDisclosure(false); + const deleteMutation = useDeleteCashlessSalesPoint(); + + const handleDelete = (salesPointId: IdParam) => { + confirmationDialog( + t`Delete this sales point? Its link will stop working immediately. Past transactions are kept.`, + () => { + deleteMutation.mutate({eventId, salesPointId}, { + onSuccess: () => showSuccess(t`Sales point deleted`), + onError: (error: any) => showError( + error?.response?.data?.message || t`Failed to delete this sales point` + ), + }); + }, + ); + }; + + if (salesPoints.length === 0) { + return ( + +

+ {t`A sales point is a bar or stand. Give your team its link and they can scan tickets to take payment.`} +

+ + + )} + /> + ); + } + + return ( + <> + + + + {t`Name`} + {t`Products`} + {t`Sales`} + {t`Security`} + + + + + {salesPoints.map((salesPoint) => ( + + + {salesPoint.name} + + {salesPointUrl(salesPoint.short_id)} + + + + + {salesPoint.products?.map((product) => product.title).join(', ')} + + + + {formatCurrency(salesPoint.sales_total ?? 0, currency)} + + {t`${salesPoint.transaction_count ?? 0} transactions`} + + + + + {salesPoint.has_access_pin ? t`PIN protected` : t`Link only`} + + + + , + onClick: () => navigate(cashlessTransactionsPath(eventId, {salesPointId: salesPoint.id})), + dataTestId: 'cashless-sales-point-transactions-menu-item', + }, + { + label: t`Share with staff`, + icon: , + onClick: () => { + setSelectedSalesPoint(salesPoint); + openShare(); + }, + }, + { + label: t`Copy link`, + icon: , + onClick: () => { + copy.copy(salesPointUrl(salesPoint.short_id)); + showSuccess(t`Copied to clipboard`); + }, + }, + { + label: t`Edit`, + icon: , + onClick: () => { + setSelectedSalesPoint(salesPoint); + openEdit(); + }, + dataTestId: 'cashless-sales-point-edit-menu-item', + }, + { + label: t`Delete`, + icon: , + color: 'red', + onClick: () => handleDelete(salesPoint.id), + }, + ], + }]} + /> + + + ))} + +
+ + {editOpen && selectedSalesPoint && ( + + )} + + {shareOpen && selectedSalesPoint && ( + + )} + + ); +}; diff --git a/frontend/src/components/common/CashlessTransactionTable/CashlessTransactionTable.module.scss b/frontend/src/components/common/CashlessTransactionTable/CashlessTransactionTable.module.scss new file mode 100644 index 0000000000..e93d205935 --- /dev/null +++ b/frontend/src/components/common/CashlessTransactionTable/CashlessTransactionTable.module.scss @@ -0,0 +1,3 @@ +.ticketId { + font-family: var(--mantine-font-family-monospace); +} diff --git a/frontend/src/components/common/CashlessTransactionTable/index.tsx b/frontend/src/components/common/CashlessTransactionTable/index.tsx new file mode 100644 index 0000000000..d1053304f8 --- /dev/null +++ b/frontend/src/components/common/CashlessTransactionTable/index.tsx @@ -0,0 +1,137 @@ +import {t} from "@lingui/macro"; +import {Badge, Table as MantineTable, Text} from "@mantine/core"; +import {IconArrowBackUp} from "@tabler/icons-react"; +import {useParams} from "react-router"; +import {CashlessTransaction, CashlessTransactionType, IdParam} from "../../../types.ts"; +import {NoResultsSplash} from "../NoResultsSplash"; +import {Table, TableHead} from "../Table"; +import {ActionMenu} from "../ActionMenu"; +import {formatCurrency} from "../../../utilites/currency.ts"; +import {prettyDate} from "../../../utilites/dates.ts"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; +import {useReverseCashlessTransaction} from "../../../mutations/useReverseCashlessTransaction.ts"; +import classes from "./CashlessTransactionTable.module.scss"; + +interface CashlessTransactionTableProps { + transactions: CashlessTransaction[]; + currency: string; + timezone: string; +} + +const typeColour: Record = { + TOPUP_ONLINE: 'green', + TOPUP_STAFF: 'teal', + PURCHASE: 'blue', + REVERSAL: 'orange', + REFUND_REMAINING: 'grape', + CLOSURE: 'gray', +}; + +export const typeLabel = (type: CashlessTransactionType): string => ({ + TOPUP_ONLINE: t`Online top-up`, + TOPUP_STAFF: t`Staff top-up`, + PURCHASE: t`Purchase`, + REVERSAL: t`Reversal`, + REFUND_REMAINING: t`Balance refund`, + CLOSURE: t`Balance closure`, +}[type]); + +export const CashlessTransactionTable = ({transactions, currency, timezone}: CashlessTransactionTableProps) => { + const {eventId} = useParams(); + const reverseMutation = useReverseCashlessTransaction(); + + const handleReverse = (transactionId: IdParam) => { + confirmationDialog( + t`Reverse this transaction? The amount goes straight back onto the attendee's balance.`, + () => { + reverseMutation.mutate({eventId, transactionId}, { + onSuccess: () => showSuccess(t`Transaction reversed`), + onError: (error: any) => showError( + error?.response?.data?.message || t`This transaction could not be reversed` + ), + }); + }, + ); + }; + + if (transactions.length === 0) { + return ( + {t`Top-ups and purchases will appear here as they happen.`}

} + /> + ); + } + + return ( + + + + {t`When`} + {t`Type`} + {t`Attendee`} + {t`Details`} + {t`Amount`} + {t`Balance after`} + + + + + {transactions.map((transaction) => ( + + {prettyDate(transaction.created_at, timezone)} + + + {typeLabel(transaction.type)} + + + + {transaction.attendee_name} + + {transaction.attendee_public_id} + + + + {transaction.sales_point_name && ( + {transaction.sales_point_name} + )} + {!!transaction.items?.length && ( + + {transaction.items.map((item) => `${item.quantity} × ${item.product_title}`).join(', ')} + + )} + {transaction.notes && ( + {transaction.notes} + )} + + + + {formatCurrency(transaction.amount, currency)} + + + {formatCurrency(transaction.balance_after, currency)} + + , + color: 'red', + visible: transaction.type !== 'REVERSAL' && transaction.type !== 'CLOSURE' && !!transaction.id, + onClick: () => handleReverse(transaction.id!), + dataTestId: 'cashless-transaction-reverse-menu-item', + }, + ], + }]} + /> + + + ))} + +
+ ); +}; diff --git a/frontend/src/components/common/CashlessWalletTable/CashlessWalletTable.module.scss b/frontend/src/components/common/CashlessWalletTable/CashlessWalletTable.module.scss new file mode 100644 index 0000000000..fb3a335629 --- /dev/null +++ b/frontend/src/components/common/CashlessWalletTable/CashlessWalletTable.module.scss @@ -0,0 +1,5 @@ +.ticketId { + font-family: var(--mantine-font-family-monospace); + font-size: 0.85rem; + letter-spacing: 0.02em; +} diff --git a/frontend/src/components/common/CashlessWalletTable/index.tsx b/frontend/src/components/common/CashlessWalletTable/index.tsx new file mode 100644 index 0000000000..2e050b1270 --- /dev/null +++ b/frontend/src/components/common/CashlessWalletTable/index.tsx @@ -0,0 +1,155 @@ +import {t} from "@lingui/macro"; +import {Badge, Table as MantineTable, Text} from "@mantine/core"; +import {IconArrowsExchange, IconCoin, IconLock, IconLockOpen, IconReceiptRefund} from "@tabler/icons-react"; +import {useState} from "react"; +import {useDisclosure} from "@mantine/hooks"; +import {useNavigate, useParams} from "react-router"; +import {CashlessWallet} from "../../../types.ts"; +import {NoResultsSplash} from "../NoResultsSplash"; +import {Table, TableHead} from "../Table"; +import {ActionMenu} from "../ActionMenu"; +import {formatCurrency} from "../../../utilites/currency.ts"; +import {cashlessTransactionsPath} from "../../../utilites/cashlessLinks.ts"; +import {showError, showSuccess} from "../../../utilites/notifications.tsx"; +import {confirmationDialog} from "../../../utilites/confirmationDialog.tsx"; +import {useUpdateCashlessWalletStatus} from "../../../mutations/useUpdateCashlessWalletStatus.ts"; +import {CashlessTopupModal} from "../../modals/CashlessTopupModal"; +import {CashlessRefundModal} from "../../modals/CashlessRefundModal"; +import classes from "./CashlessWalletTable.module.scss"; + +interface CashlessWalletTableProps { + wallets: CashlessWallet[]; +} + +const statusColour: Record = { + ACTIVE: 'green', + FROZEN: 'orange', + CLOSED: 'gray', +}; + +export const CashlessWalletTable = ({wallets}: CashlessWalletTableProps) => { + const {eventId} = useParams(); + const navigate = useNavigate(); + const [selectedWallet, setSelectedWallet] = useState(); + const [topupOpen, {open: openTopup, close: closeTopup}] = useDisclosure(false); + const [refundOpen, {open: openRefund, close: closeRefund}] = useDisclosure(false); + const statusMutation = useUpdateCashlessWalletStatus(); + + const handleStatusToggle = (wallet: CashlessWallet) => { + const nextStatus = wallet.status === 'FROZEN' ? 'ACTIVE' : 'FROZEN'; + const message = nextStatus === 'FROZEN' + ? t`Freezing this balance blocks any further spending. Continue?` + : t`Unfreeze this balance so it can be spent again?`; + + confirmationDialog(message, () => { + statusMutation.mutate({eventId, walletId: wallet.id, status: nextStatus}, { + onSuccess: () => showSuccess(t`Balance updated`), + onError: (error: any) => showError(error?.response?.data?.message || t`Something went wrong. Please try again.`), + }); + }); + }; + + if (wallets.length === 0) { + return ( + + {t`A balance is created the first time an attendee tops up, or the first time your staff loads their ticket at a sales point.`} +

+ )} + /> + ); + } + + return ( + <> + + + + {t`Attendee`} + {t`Ticket ID`} + {t`Balance`} + {t`Topped up`} + {t`Spent`} + {t`Status`} + + + + + {wallets.map((wallet) => ( + + + {wallet.attendee_first_name} {wallet.attendee_last_name} + {wallet.attendee_email} + + + {wallet.attendee_public_id} + + + {formatCurrency(wallet.balance, wallet.currency)} + + {formatCurrency(wallet.total_topped_up, wallet.currency)} + {formatCurrency(wallet.total_spent, wallet.currency)} + + + {wallet.status} + + + + , + onClick: () => navigate(cashlessTransactionsPath(eventId, {walletId: wallet.id})), + dataTestId: 'cashless-wallet-transactions-menu-item', + }, + { + label: t`Top up`, + icon: , + visible: wallet.status !== 'CLOSED', + onClick: () => { + setSelectedWallet(wallet); + openTopup(); + }, + dataTestId: 'cashless-wallet-topup-menu-item', + }, + { + label: t`Refund balance`, + icon: , + visible: wallet.status !== 'CLOSED', + onClick: () => { + setSelectedWallet(wallet); + openRefund(); + }, + dataTestId: 'cashless-wallet-refund-menu-item', + }, + { + label: wallet.status === 'FROZEN' ? t`Unfreeze` : t`Freeze`, + icon: wallet.status === 'FROZEN' ? : + , + visible: wallet.status !== 'CLOSED', + onClick: () => handleStatusToggle(wallet), + }, + ], + }]} + /> + + + ))} + +
+ + {topupOpen && selectedWallet && ( + + )} + {refundOpen && selectedWallet && ( + + )} + + ); +}; diff --git a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx index 6b2f5234ba..a8262e475c 100644 --- a/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx +++ b/frontend/src/components/common/EmailTemplateEditor/EmailTemplateEditor.tsx @@ -35,6 +35,13 @@ export const EmailTemplateEditor = ({ }: EmailTemplateEditorProps) => { const [activeTab, setActiveTab] = useState('editor'); + const defaultCtaLabels: Record = { + 'order_confirmation': t`View Order`, + 'attendee_ticket': t`View Ticket`, + 'occurrence_cancellation': t`View Event`, + 'cashless_topup': t`View my balance`, + }; + const form = useForm({ initialValues: { subject: template?.subject || defaultTemplate?.subject || '', @@ -54,10 +61,7 @@ export const EmailTemplateEditor = ({ if (!template && defaultTemplate && defaultTemplate.subject && defaultTemplate.body) { form.setFieldValue('subject', defaultTemplate.subject); form.setFieldValue('body', defaultTemplate.body); - const defaultCtaLabel = templateType === 'order_confirmation' ? t`View Order` - : templateType === 'occurrence_cancellation' ? t`View Event` - : t`View Ticket`; - form.setFieldValue('ctaLabel', defaultCtaLabel); + form.setFieldValue('ctaLabel', defaultCtaLabels[templateType]); form.setFieldValue('isActive', true); } }, [defaultTemplate, template]); @@ -69,7 +73,7 @@ export const EmailTemplateEditor = ({ subject: form.values.subject, body: form.values.body, template_type: templateType, - ctaLabel: form.values.ctaLabel || (templateType === 'order_confirmation' ? t`View Order` : templateType === 'occurrence_cancellation' ? t`View Event` : t`View Ticket`), + ctaLabel: form.values.ctaLabel || defaultCtaLabels[templateType], }); } }; @@ -97,6 +101,7 @@ export const EmailTemplateEditor = ({ 'order_confirmation': t`Order Confirmation`, 'attendee_ticket': t`Attendee Ticket`, 'occurrence_cancellation': t`Date Cancellation`, + 'cashless_topup': t`Cashless Top-up`, }; return ( diff --git a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx index fb71ba665b..379f167767 100644 --- a/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx +++ b/frontend/src/components/common/EmailTemplateSettings/EmailTemplateSettingsBase.tsx @@ -57,6 +57,7 @@ interface EmailTemplateSettingsBaseProps { onDeleteSuccess?: () => void; onError?: (error: any, message: string) => void; eventType?: EventType; + types?: EmailTemplateType[]; } export const EmailTemplateSettingsBase = ({ @@ -73,7 +74,8 @@ export const EmailTemplateSettingsBase = ({ onSaveSuccess, onDeleteSuccess, onError, - eventType + eventType, + types }: EmailTemplateSettingsBaseProps) => { const [editorOpened, {open: openEditor, close: closeEditor}] = useDisclosure(false); const [editingTemplate, setEditingTemplate] = useState(null); @@ -88,9 +90,13 @@ export const EmailTemplateSettingsBase = ({ const accountRequiresManualVerification = isAccountFetched && account?.requires_manual_verification; const isModifyDisabled = !isAccountVerified || accountRequiresManualVerification; - const orderConfirmationTemplate = templates.find(t => t.template_type === 'order_confirmation'); - const attendeeTicketTemplate = templates.find(t => t.template_type === 'attendee_ticket'); - const occurrenceCancellationTemplate = templates.find(t => t.template_type === 'occurrence_cancellation'); + const isOrganizer = contextType === 'organizer'; + const visibleTypes: EmailTemplateType[] = types ?? [ + 'order_confirmation', + 'attendee_ticket', + ...(isOrganizer || eventType === EventType.RECURRING ? ['occurrence_cancellation' as const] : []), + ...(isOrganizer ? ['cashless_topup' as const] : []), + ]; const handleCreateTemplate = (type: EmailTemplateType) => { setEditingTemplate(null); @@ -210,12 +216,14 @@ export const EmailTemplateSettingsBase = ({ 'order_confirmation': t`Order Confirmation`, 'attendee_ticket': t`Attendee Ticket`, 'occurrence_cancellation': t`Date Cancellation`, + 'cashless_topup': t`Cashless Top-up`, }; const templateDescriptions: Record = { 'order_confirmation': t`Sent to customers when they place an order`, 'attendee_ticket': t`Sent to each attendee with their ticket details`, 'occurrence_cancellation': t`Sent to attendees when a scheduled date is cancelled`, + 'cashless_topup': t`Sent to attendees when their cashless balance is topped up online`, }; const getTemplateStatusBadge = (template?: EmailTemplate) => { @@ -382,28 +390,15 @@ export const EmailTemplateSettingsBase = ({ - - - - - {(contextType === 'organizer' || eventType === EventType.RECURRING) && ( + {visibleTypes.map((type) => ( template.template_type === type)} + label={templateTypeLabels[type]} + description={templateDescriptions[type]} /> - )} + ))} diff --git a/frontend/src/components/layouts/CheckIn/BottomNav.module.scss b/frontend/src/components/common/FloatingTabBar/FloatingTabBar.module.scss similarity index 100% rename from frontend/src/components/layouts/CheckIn/BottomNav.module.scss rename to frontend/src/components/common/FloatingTabBar/FloatingTabBar.module.scss diff --git a/frontend/src/components/common/FloatingTabBar/index.tsx b/frontend/src/components/common/FloatingTabBar/index.tsx new file mode 100644 index 0000000000..17b774d70e --- /dev/null +++ b/frontend/src/components/common/FloatingTabBar/index.tsx @@ -0,0 +1,36 @@ +import {ReactNode} from "react"; +import classes from "./FloatingTabBar.module.scss"; + +export interface FloatingTabBarItem { + id: T; + label: string; + icon: ReactNode; +} + +interface FloatingTabBarProps { + ariaLabel: string; + active: T; + items: FloatingTabBarItem[]; + onChange: (id: T) => void; +} + +export const FloatingTabBar = ({ariaLabel, active, items, onChange}: FloatingTabBarProps) => { + return ( + + ); +}; diff --git a/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.module.scss b/frontend/src/components/common/InlineCameraScanner/InlineCameraScanner.module.scss similarity index 100% rename from frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.module.scss rename to frontend/src/components/common/InlineCameraScanner/InlineCameraScanner.module.scss diff --git a/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.tsx b/frontend/src/components/common/InlineCameraScanner/index.tsx similarity index 95% rename from frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.tsx rename to frontend/src/components/common/InlineCameraScanner/index.tsx index 7ec1adecb4..d221660f01 100644 --- a/frontend/src/components/layouts/CheckIn/tabs/InlineCameraScanner.tsx +++ b/frontend/src/components/common/InlineCameraScanner/index.tsx @@ -8,9 +8,10 @@ import classes from "./InlineCameraScanner.module.scss"; interface Props { onAttendeeScanned: (attendeePublicId: string) => void; + clearHandledCodesToken?: string | number; } -export const InlineCameraScanner = ({onAttendeeScanned}: Props) => { +export const InlineCameraScanner = ({onAttendeeScanned, clearHandledCodesToken}: Props) => { const videoRef = useRef(null); const qrScannerRef = useRef(null); const [permissionDenied, setPermissionDenied] = useState(false); @@ -27,6 +28,12 @@ export const InlineCameraScanner = ({onAttendeeScanned}: Props) => { latestProcessedRef.current = processed; }, [processed]); + useEffect(() => { + if (clearHandledCodesToken === undefined) return; + setProcessed([]); + setCurrentId(null); + }, [clearHandledCodesToken]); + const startScanner = async () => { try { await navigator.mediaDevices.getUserMedia({video: true}); diff --git a/frontend/src/components/common/KpiGrid/KpiGrid.module.scss b/frontend/src/components/common/KpiGrid/KpiGrid.module.scss index ab9804c46f..272db2ffad 100644 --- a/frontend/src/components/common/KpiGrid/KpiGrid.module.scss +++ b/frontend/src/components/common/KpiGrid/KpiGrid.module.scss @@ -6,8 +6,24 @@ border-radius: var(--hi-radius-card); overflow: hidden; display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.5px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1px; + + @media (min-resolution: 2dppx) { + gap: 0.5px; + } + + @include mixins.respond-below(lg) { + grid-template-columns: repeat(2, 1fr); + } + + @include mixins.respond-below(sm) { + grid-template-columns: 1fr; + } +} + +.fourColumns { + grid-template-columns: repeat(4, minmax(0, 1fr)); @include mixins.respond-below(lg) { grid-template-columns: repeat(2, 1fr); diff --git a/frontend/src/components/common/KpiGrid/index.tsx b/frontend/src/components/common/KpiGrid/index.tsx index e435861c53..51b66c7d79 100644 --- a/frontend/src/components/common/KpiGrid/index.tsx +++ b/frontend/src/components/common/KpiGrid/index.tsx @@ -8,11 +8,12 @@ import classes from "./KpiGrid.module.scss"; interface KpiGridProps { children: ReactNode; className?: string; + columns?: 3 | 4; } -export const KpiGrid = ({children, className = ''}: KpiGridProps) => { +export const KpiGrid = ({children, className = '', columns = 3}: KpiGridProps) => { return ( -
+
{children}
); diff --git a/frontend/src/components/common/StatsCharts/index.tsx b/frontend/src/components/common/StatsCharts/index.tsx index d9bb4cb690..688763217a 100644 --- a/frontend/src/components/common/StatsCharts/index.tsx +++ b/frontend/src/components/common/StatsCharts/index.tsx @@ -2,7 +2,7 @@ import {ReactNode} from "react"; import {AreaChart} from "@mantine/charts"; import {t} from "@lingui/macro"; import {Card} from "../Card"; -import {EventDailyStats} from "../../../types.ts"; +import {CashlessDailyStats, EventDailyStats} from "../../../types.ts"; import {formatCurrency} from "../../../utilites/currency.ts"; import {formatDateWithLocale} from "../../../utilites/dates.ts"; import classes from "./StatsCharts.module.scss"; @@ -85,3 +85,31 @@ export const RevenueChartCard = ({dailyStats, timezone, dateRangeLabel, syncId, /> ); + +export const CashlessRevenueChartCard = ({dailyStats, timezone, dateRangeLabel, syncId, currency}: { + dailyStats: CashlessDailyStats[] | undefined; + timezone: string; + dateRangeLabel: string; + syncId: string; + currency: string; +}) => ( + + ({ + date: formatDateWithLocale(stat.date, 'chartDate', timezone), + topped_up: stat.topped_up, + spent: stat.spent, + refunded: stat.refunded, + })) || []} + valueFormatter={(value) => formatCurrency(value, currency)} + series={[ + {name: 'topped_up', label: t`Topped up`, color: 'teal.6'}, + {name: 'spent', label: t`Spent at sales points`, color: 'grape.5'}, + {name: 'refunded', label: t`Refunded`, color: 'red.6'}, + ]} + curveType="natural" + areaChartProps={{syncId}} + /> + +); diff --git a/frontend/src/components/common/TicketScanZone/TicketScanZone.module.scss b/frontend/src/components/common/TicketScanZone/TicketScanZone.module.scss new file mode 100644 index 0000000000..fd0fd06162 --- /dev/null +++ b/frontend/src/components/common/TicketScanZone/TicketScanZone.module.scss @@ -0,0 +1,151 @@ +.scanArea { + margin-top: 14px; + flex: 0 0 auto; + height: min(46vh, 380px); + border-radius: 14px; + overflow: hidden; + background: #1a1625; + border: 1px solid var(--hi-color-gray-2); + position: relative; +} + +.scanAreaGutter { + margin-left: 16px; + margin-right: 16px; +} + +.usbPane { + height: 100%; + width: 100%; + background: #fff; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + gap: 14px; +} + +.usbStatusRow { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + border-radius: 999px; + background: var(--hi-color-gray); +} + +.statusDot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.dotActive { + background: var(--hi-color-money-green); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--hi-color-money-green) 25%, transparent); +} + +.dotPaused { + background: #f59f00; +} + +.statusText { + font-size: 13px; + font-weight: 600; + color: var(--hi-text); + letter-spacing: 0.02em; +} + +.usbInstruction { + font-size: 15px; + font-weight: 600; + color: var(--hi-text); + text-align: center; + letter-spacing: -0.01em; + max-width: 340px; +} + +.buffer { + margin-top: 4px; + min-width: 240px; + max-width: 100%; + padding: 10px 14px; + border-radius: 10px; + background: var(--hi-color-gray); + border: 1px dashed var(--hi-color-gray-2); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 14px; + letter-spacing: 0.08em; + color: var(--hi-text); + text-align: center; + min-height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.bufferText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.bufferPlaceholder { + color: var(--hi-color-gray-dark); + font-family: inherit; + letter-spacing: 0.02em; + font-style: italic; +} + +.toolbar { + margin-top: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.toolbarGutter { + margin-left: 16px; + margin-right: 16px; +} + +.modeToggle { + display: flex; + background: #fff; + border: 1px solid var(--hi-color-gray-2); + border-radius: 999px; + padding: 3px; + flex: 0 0 auto; +} + +.modeBtn { + border: none; + background: transparent; + color: var(--hi-color-gray-dark); + padding: 7px 14px; + border-radius: 999px; + display: flex; + align-items: center; + gap: 6px; + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 140ms ease, color 140ms ease, transform 120ms ease; + + &:active { + transform: scale(0.96); + } +} + +.modeActive { + background: var(--hi-primary); + color: #fff; +} + +.soundBtn { + margin-left: auto; +} diff --git a/frontend/src/components/common/TicketScanZone/index.tsx b/frontend/src/components/common/TicketScanZone/index.tsx new file mode 100644 index 0000000000..57686db651 --- /dev/null +++ b/frontend/src/components/common/TicketScanZone/index.tsx @@ -0,0 +1,97 @@ +import {t} from "@lingui/macro"; +import {IconCamera, IconScan, IconVolume, IconVolumeOff} from "@tabler/icons-react"; +import {ActionIcon} from "@mantine/core"; +import {InlineCameraScanner} from "../InlineCameraScanner"; +import classes from "./TicketScanZone.module.scss"; + +export type ScanMode = "usb" | "camera"; + +interface TicketScanZoneProps { + mode: ScanMode; + onModeChange: (mode: ScanMode) => void; + hidPageHasFocus: boolean; + hidBuffer: string; + isSoundOn: boolean; + onSoundToggle: () => void; + onCodeScanned: (code: string) => void; + scannerResetToken?: number; + listeningLabel: string; + pausedLabel: string; + withSideGutter?: boolean; +} + +export const TicketScanZone = ({ + mode, + onModeChange, + hidPageHasFocus, + hidBuffer, + isSoundOn, + onSoundToggle, + onCodeScanned, + scannerResetToken, + listeningLabel, + pausedLabel, + withSideGutter, + }: TicketScanZoneProps) => { + return ( + <> +
+ {mode === "camera" ? ( + + ) : ( +
+
+ + + {hidPageHasFocus ? t`USB scanner listening` : t`USB scanner paused`} + +
+
+ {hidPageHasFocus ? listeningLabel : pausedLabel} +
+
+ {hidBuffer + ? {hidBuffer} + : {t`Waiting for scan…`}} +
+
+ )} +
+ +
+
+ + +
+ + {isSoundOn ? : } + +
+ + ); +}; diff --git a/frontend/src/components/forms/CashlessSalesPointForm/index.tsx b/frontend/src/components/forms/CashlessSalesPointForm/index.tsx new file mode 100644 index 0000000000..df49c7e44c --- /dev/null +++ b/frontend/src/components/forms/CashlessSalesPointForm/index.tsx @@ -0,0 +1,86 @@ +import {t} from "@lingui/macro"; +import {PasswordInput, Switch, Text, Textarea, TextInput} from "@mantine/core"; +import {UseFormReturnType} from "@mantine/form"; +import {useState} from "react"; +import {IconBeer} from "@tabler/icons-react"; +import {ProductCategory, ProductType, UpsertCashlessSalesPointRequest} from "../../../types.ts"; +import {ProductSelector} from "../../common/ProductSelector"; +import {InputGroup} from "../../common/InputGroup"; +import {AdvancedOptions} from "../../common/AdvancedOptions"; + +interface CashlessSalesPointFormProps { + form: UseFormReturnType; + productCategories: ProductCategory[]; + pinHelpText: string; +} + +export const CashlessSalesPointForm = ({form, productCategories, pinHelpText}: CashlessSalesPointFormProps) => { + const [advancedOpen, setAdvancedOpen] = useState( + () => !!(form.values.description || form.values.activates_at || form.values.expires_at), + ); + + return ( + <> + + + } + productCategories={productCategories} + form={form} + productFieldName="product_ids" + includedProductTypes={[ProductType.General]} + noProductsMessage={t`Create a general product, like a drink, to sell it here`} + /> + + + {t`Leave this empty for a top-up desk that only loads balances and sells nothing.`} + + + + + setAdvancedOpen((open) => !open)} + dataTestId="cashless-sales-point-advanced-toggle" + > + + +