diff --git a/README.de.md b/README.de.md
index 901a478c04..0ad1d95efe 100644
--- a/README.de.md
+++ b/README.de.md
@@ -22,7 +22,7 @@ Self-hosted oder Cloud. Ihre Events, Ihre Marke, Ihre Daten.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.el.md b/README.el.md
index 841d8a201d..8618ec3616 100644
--- a/README.el.md
+++ b/README.el.md
@@ -22,7 +22,7 @@ Self-hosted ή Cloud. Οι εκδηλώσεις σας, η μάρκα σας, τ
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.es.md b/README.es.md
index e4f1e4c730..bc63290386 100644
--- a/README.es.md
+++ b/README.es.md
@@ -22,7 +22,7 @@ Autohospedado o en la nube. Tus eventos, tu marca, tus datos.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.fi.md b/README.fi.md
new file mode 100644
index 0000000000..3605e8f46b
--- /dev/null
+++ b/README.fi.md
@@ -0,0 +1,169 @@
+
+
+

+
+# Hi.Events
+
+### Avoimen lähdekoodin tapahtumalippu- ja tapahtumanhallinta-alusta
+
+Myy lippuja verkossa konferensseihin, yöelämätapahtumiin, konsertteihin, klubitapahtumiin, työpajoihin ja festivaaleille.
+Itse ylläpidettävä tai pilvipalvelu. Tapahtumasi, brändisi, tietosi.
+
+[Kokeile pilvipalvelua →](https://app.hi.events/auth/register?utm_source=gh-readme) · [Live-esittely](https://app.hi.events/event/2/hievents-conference-2030?utm_source=gh-readme) · [Dokumentaatio](https://hi.events/docs?utm_source=gh-readme) · [Verkkosivusto](https://hi.events?utm_source=gh-readme)
+
+[](https://github.com/HiEventsDev/Hi.Events/blob/develop/LICENCE)
+[](https://github.com/HiEventsDev/hi.events/releases)
+[](https://github.com/HiEventsDev/hi.events/stargazers)
+[](https://hub.docker.com/r/daveearley/hi.events-all-in-one)
+[](https://github.com/HiEventsDev/hi.events/actions/workflows/e2e.yml)
+
+
+
+
+
+
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+
+
+
+
+
+
+## Miksi Hi.Events?
+
+Useimmat lippualustat veloittavat maksun jokaisesta lipusta ja sitovat tietosi omaan ekosysteemiinsä. **Hi.Events on
+nykyaikainen, avoimen lähdekoodin vaihtoehto Eventbritelle, Tickettailorille, Dice.fm:lle ja muille lippualustoille**
+järjestäjille, jotka haluavat täyden hallinnan brändistä, kassasta, tiedoista ja infrastruktuurista.
+
+Tuhannet tapahtumajärjestäjät ympäri maailman luottavat siihen — yöelämän promoottoreista ja festivaaleista
+tapahtumapaikkoihin, yhteisöryhmiin ja konferenssijärjestäjiin. Ylläpidä sitä itse tai anna meidän ylläpitää sitä
+puolestasi Hi.Events Cloudissa.
+
+
+
+
+
+
+
+## Ominaisuudet
+
+**🎟️ Lipunmyynti ja myynti** — maksuttomat, maksulliset, lahjoitus- ja porrastetut liput · toistuvat ja usean päivän
+tapahtumat · loppuunmyytyjen lippujen jonotuslistat · tarjouskoodit, mukaan lukien tarjouksella avattavat ja piilotetut
+liput · tuotteiden lisäosat ja kategoriat · verojen, maksujen ja kapasiteetin hallinta
+
+**🎨 Brändäys ja mukauttaminen** — tapahtuman etusivun suunnittelutyökalu kansikuvalle, väreille ja typografialle ·
+brändätty järjestäjän etusivu · mukautettavat PDF-liput · upotettava lippuwidget · SEO-metatietojen hallinta
+
+**👥 Osallistujien hallinta** — mukautetut kassakysymykset · tarkennettu haku, suodatus ja CSV/XLSX-vienti · täydet ja
+osittaiset hyvitykset · joukkoviestit · QR-kirjautuminen tarkistuslokeilla ja käyttöoikeuksin rajatuilla tarkistuslistoilla
+
+**📊 Analytiikka ja kasvu** — myynnin hallintapaneeli · kumppaniseuranta · päivämyynnin, tuotemyynnin, tarjouskoodien,
+tulojen ja verojen raportit · lähtevät webhookit
+
+**⚙️ Toiminnot** — monen käyttäjän roolit · Stripe Connect -maksut · offline-maksutavat · automaattinen laskutus ·
+verkko- ja lähitapahtumat · monikielisyys · täydellinen REST API ja
+[interaktiivinen OpenAPI-dokumentaatio](#rest-rajapinta)
+
+
+
+## Pika-aloitus
+
+Rakennettu teknologioilla **Laravel 13** (PHP >=8.3) · **React 19** SSR-tuella · **TypeScript** · **PostgreSQL** · **Redis** · **Docker**.
+
+### Käyttöönotto yhdellä napsautuksella
+
+[](https://github.com/HiEventsDev/hi.events-digitalocean)
+[](https://github.com/HiEventsDev/hi.events-render.com)
+[](https://railway.app/template/8CGKmu?referralCode=KvSr11)
+[](https://zeabur.com/templates/8DIRY6)
+
+### Docker
+
+```bash
+git clone git@github.com:HiEventsDev/hi.events.git
+cd hi.events/docker/all-in-one
+
+# Luo avaimet (Linux/macOS)
+echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env
+echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
+
+docker compose up -d
+```
+
+> [!TIP]
+> **Windows-käyttäjät:** Katso avainten luontiohjeet tiedostosta `./docker/all-in-one/README.md`.
+
+Avaa `http://localhost:8123` ja luo käyttäjätilisi.
+
+📖 [Täydellinen asennusopas](https://hi.events/docs/getting-started?utm_source=gh-readme)
+
+### REST-rajapinta
+
+Hi.Events sisältää dokumentoidun REST-rajapinnan. Aseta `.env`-tiedostossa `API_DOCS_ENABLED=true`, jotta interaktiivinen
+OpenAPI-dokumentaatio on saatavilla oman instanssisi osoitteessa `/docs/api`, tai vie määritys komennolla:
+
+```bash
+php artisan scramble:export
+```
+
+
+
+## Hi.Events Cloud
+
+Etkö halua ylläpitää palvelua itse? **[Hi.Events Cloud](https://app.hi.events/auth/register?utm_source=gh-readme)** on
+tämän repositorion täysin hallinnoitu versio — ei asennusta, automaattiset päivitykset ja hallinnoitu infrastruktuuri,
+Hi.Eventsiä kehittävän tiimin ylläpitämänä.
+
+[Aloita käyttö →](https://app.hi.events/auth/register?utm_source=gh-readme)
+
+
+
+## Lisensointi
+
+Hi.Events on lisensoitu **AGPL-3.0-lisenssillä lisäehdoin**. Lisäehdot edellyttävät, että ohjelmiston luomilla sivuilla
+ja sähköposteissa säilytetään "Powered by Hi.Events" -maininta — tarkka sanamuoto löytyy [LICENCE](LICENCE)-tiedostosta.
+
+**Kaupallisia lisenssejä on saatavilla**, jos haluat poistaa maininnan tai tarvitset white label -käyttöönottoon sopivat
+ehdot. [Lisenssivaihtoehdot](https://hi.events/licensing?utm_source=gh-readme) ·
+[hello@hi.events](mailto:hello@hi.events)
+
+
+
+## Osallistuminen kehitykseen
+
+Osallistuminen on tervetullutta — tutustu aloittamiseen [osallistumisoppaassa](CONTRIBUTING.md). Avaa issue tai keskustelu
+ennen merkittävän työn aloittamista, jotta voimme sopia lähestymistavasta. Osallistujat allekirjoittavat
+[CLA-sopimuksen](CLA.md).
+
+
+
+
+
+
+
+## Tietoturva
+
+Löysitkö haavoittuvuuden? Ilmoita siitä yksityisesti osoitteeseen [security@hi.events](mailto:security@hi.events) sen
+sijaan, että avaisit julkisen issuen. Tutustu [tietoturvakäytäntöömme](SECURITY.md).
+
+
+
+## Tuki
+
+📖 [Documentation](https://hi.events/docs?utm_source=gh-readme) ·
+🐛 [GitHub Issues](https://github.com/HiEventsDev/hi.events/issues) ·
+💬 [Discussions](https://github.com/HiEventsDev/hi.events/discussions) ·
+📧 [hello@hi.events](mailto:hello@hi.events)
+
+Uudet ominaisuudet ja parannukset on lueteltu
+[julkaisusivulla](https://github.com/HiEventsDev/hi.events/releases).
+
+
+
+
+
+**[Verkkosivusto](https://hi.events)** · **[Dokumentaatio](https://hi.events/docs)** · **[Pilvipalvelu](https://app.hi.events/auth/register?utm_source=gh-readme)** · **[Lisensointi](https://hi.events/licensing)**
+
+Tehty ☘️ Irlannissa
+
+
diff --git a/README.fr.md b/README.fr.md
index f84f92dd66..3b93d58800 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -22,7 +22,7 @@ Auto-hébergé ou cloud. Vos événements, votre marque, vos données.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.hu.md b/README.hu.md
index d4ba0f481a..b6f440d496 100644
--- a/README.hu.md
+++ b/README.hu.md
@@ -22,7 +22,7 @@ Saját szerveren vagy felhőben. Az Ön eseményei, az Ön márkája, az Ön ada
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.it.md b/README.it.md
index 4e6e75e8cf..79122d32c8 100644
--- a/README.it.md
+++ b/README.it.md
@@ -22,7 +22,7 @@ Self-hosted o cloud. I tuoi eventi, il tuo brand, i tuoi dati.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.ja.md b/README.ja.md
index 62841b459e..73e14a54ed 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -22,7 +22,7 @@
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.md b/README.md
index 38c836bd17..6e66024ca4 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ Self-hosted or cloud. Your events, your brand, your data.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.nl.md b/README.nl.md
index fbc5e958d6..fe44155d70 100644
--- a/README.nl.md
+++ b/README.nl.md
@@ -22,7 +22,7 @@ Zelf-gehost of cloud. Jouw evenementen, jouw merk, jouw data.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.pl.md b/README.pl.md
index 90c517888a..2e890e82c0 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -22,7 +22,7 @@ Samodzielnie hostowana lub w chmurze. Twoje wydarzenia, Twoja marka, Twoje dane.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.pt.md b/README.pt.md
index 04ee015538..9b5aaeda61 100644
--- a/README.pt.md
+++ b/README.pt.md
@@ -22,7 +22,7 @@ Autohospedado ou na nuvem. Seus eventos, sua marca, seus dados.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.sk.md b/README.sk.md
index 4ee75b5b74..257df2e20f 100644
--- a/README.sk.md
+++ b/README.sk.md
@@ -22,7 +22,7 @@ Self-hosted alebo v cloude. Vaše udalosti, vaša značka, vaše dáta.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.tr.md b/README.tr.md
index 86ed56d5a2..daabc022fd 100644
--- a/README.tr.md
+++ b/README.tr.md
@@ -22,7 +22,7 @@ Kendi sunucunuzda veya bulutta. Etkinlikleriniz, markanız, verileriniz.
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.vi.md b/README.vi.md
index ccdf525e90..b409e2b0be 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -22,7 +22,7 @@ Tự lưu trữ hoặc đám mây. Sự kiện của bạn, thương hiệu củ
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.zh-cn.md b/README.zh-cn.md
index 856285c22c..c8fc90cb21 100644
--- a/README.zh-cn.md
+++ b/README.zh-cn.md
@@ -22,7 +22,7 @@
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/README.zh-hk.md b/README.zh-hk.md
index 5e11e495bb..05120e7f5b 100644
--- a/README.zh-hk.md
+++ b/README.zh-hk.md
@@ -22,7 +22,7 @@
-Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά
+Deutsch · Português · Français · Italiano · Nederlands · Español · 中文 · 繁體中文 · 日本語 · Tiếng Việt · Türkçe · Magyar · Polski · Slovenčina · Ελληνικά · Suomi
diff --git a/backend/app/DomainObjects/Enums/ProductQuantityAppliesTo.php b/backend/app/DomainObjects/Enums/ProductQuantityAppliesTo.php
new file mode 100644
index 0000000000..126583974a
--- /dev/null
+++ b/backend/app/DomainObjects/Enums/ProductQuantityAppliesTo.php
@@ -0,0 +1,16 @@
+eventLocation;
}
+
+ public function setBookingLimits(?OccurrenceBookingLimitsDTO $bookingLimits): self
+ {
+ $this->bookingLimits = $bookingLimits;
+
+ return $this;
+ }
+
+ public function getBookingLimits(): ?OccurrenceBookingLimitsDTO
+ {
+ return $this->bookingLimits;
+ }
}
diff --git a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php
index 9788aa66d0..1501a9327c 100644
--- a/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/ProductDomainObjectAbstract.php
@@ -38,6 +38,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
final public const HIGHLIGHT_MESSAGE = 'highlight_message';
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';
protected int $id;
protected int $event_id;
@@ -67,6 +68,7 @@ abstract class ProductDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr
protected ?string $highlight_message = null;
protected ?bool $waitlist_enabled = null;
protected bool $is_addon_only = false;
+ protected bool $sequential_tier_release_enabled = false;
public function toArray(): array
{
@@ -99,6 +101,7 @@ public function toArray(): array
'highlight_message' => $this->highlight_message ?? null,
'waitlist_enabled' => $this->waitlist_enabled ?? null,
'is_addon_only' => $this->is_addon_only ?? null,
+ 'sequential_tier_release_enabled' => $this->sequential_tier_release_enabled ?? null,
];
}
@@ -409,4 +412,15 @@ public function getIsAddonOnly(): bool
{
return $this->is_addon_only;
}
+
+ public function setSequentialTierReleaseEnabled(bool $sequential_tier_release_enabled): self
+ {
+ $this->sequential_tier_release_enabled = $sequential_tier_release_enabled;
+ return $this;
+ }
+
+ public function getSequentialTierReleaseEnabled(): bool
+ {
+ return $this->sequential_tier_release_enabled;
+ }
}
diff --git a/backend/app/DomainObjects/Generated/ProductPriceDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductPriceDomainObjectAbstract.php
index ee1f14577a..ad1df52124 100644
--- a/backend/app/DomainObjects/Generated/ProductPriceDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/ProductPriceDomainObjectAbstract.php
@@ -24,6 +24,7 @@ abstract class ProductPriceDomainObjectAbstract extends \HiEvents\DomainObjects\
final public const IS_HIDDEN = 'is_hidden';
final public const ORDER = 'order';
final public const QUANTITY_AVAILABLE = 'quantity_available';
+ final public const QUANTITY_APPLIES_TO = 'quantity_applies_to';
protected int $id;
protected int $product_id;
@@ -39,6 +40,7 @@ abstract class ProductPriceDomainObjectAbstract extends \HiEvents\DomainObjects\
protected ?bool $is_hidden = false;
protected int $order = 1;
protected ?int $quantity_available = null;
+ protected string $quantity_applies_to = 'OCCURRENCE';
public function toArray(): array
{
@@ -57,6 +59,7 @@ public function toArray(): array
'is_hidden' => $this->is_hidden ?? null,
'order' => $this->order ?? null,
'quantity_available' => $this->quantity_available ?? null,
+ 'quantity_applies_to' => $this->quantity_applies_to ?? null,
];
}
@@ -213,4 +216,15 @@ public function getQuantityAvailable(): ?int
{
return $this->quantity_available;
}
+
+ public function setQuantityAppliesTo(string $quantity_applies_to): self
+ {
+ $this->quantity_applies_to = $quantity_applies_to;
+ return $this;
+ }
+
+ public function getQuantityAppliesTo(): string
+ {
+ return $this->quantity_applies_to;
+ }
}
diff --git a/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php
index 55a86b22b7..19df5c2a6f 100644
--- a/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/ProductPriceOccurrenceOverrideDomainObjectAbstract.php
@@ -16,13 +16,15 @@ abstract class ProductPriceOccurrenceOverrideDomainObjectAbstract extends \HiEve
final public const PRICE = 'price';
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
+ final public const QUANTITY_AVAILABLE = 'quantity_available';
protected int $id;
protected int $event_occurrence_id;
protected int $product_price_id;
- protected float $price;
+ protected ?float $price = null;
protected ?string $created_at = null;
protected ?string $updated_at = null;
+ protected ?int $quantity_available = null;
public function toArray(): array
{
@@ -33,6 +35,7 @@ public function toArray(): array
'price' => $this->price ?? null,
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
+ 'quantity_available' => $this->quantity_available ?? null,
];
}
@@ -69,13 +72,13 @@ public function getProductPriceId(): int
return $this->product_price_id;
}
- public function setPrice(float $price): self
+ public function setPrice(?float $price): self
{
$this->price = $price;
return $this;
}
- public function getPrice(): float
+ public function getPrice(): ?float
{
return $this->price;
}
@@ -101,4 +104,15 @@ public function getUpdatedAt(): ?string
{
return $this->updated_at;
}
+
+ public function setQuantityAvailable(?int $quantity_available): self
+ {
+ $this->quantity_available = $quantity_available;
+ return $this;
+ }
+
+ public function getQuantityAvailable(): ?int
+ {
+ return $this->quantity_available;
+ }
}
diff --git a/backend/app/DomainObjects/ProductDomainObject.php b/backend/app/DomainObjects/ProductDomainObject.php
index ee7180df1a..270bae00de 100644
--- a/backend/app/DomainObjects/ProductDomainObject.php
+++ b/backend/app/DomainObjects/ProductDomainObject.php
@@ -107,9 +107,34 @@ public function isSoldOut(): bool
return $this->getProductPrices()->every(fn (ProductPriceDomainObject $price) => $price->isSoldOut());
}
+ public function markLockedTiers(): void
+ {
+ if (! $this->isTieredType() || ! $this->getSequentialTierReleaseEnabled() || ! $this->getProductPrices()) {
+ return;
+ }
+
+ $earlierTierOpen = false;
+
+ $this->getProductPrices()
+ ->sortBy(fn (ProductPriceDomainObject $price) => $price->getOrder())
+ ->each(function (ProductPriceDomainObject $price) use (&$earlierTierOpen) {
+ if ($price->getIsHidden()) {
+ return;
+ }
+
+ $price->setIsLockedBehindEarlierTier($earlierTierOpen);
+
+ if (! $price->isExhausted()) {
+ $earlierTierOpen = true;
+ }
+ });
+ }
+
public function getQuantityAvailable(): int
{
- $availableCount = $this->getProductPrices()->sum(fn (ProductPriceDomainObject $price) => $price->getQuantityAvailable());
+ $availableCount = $this->getProductPrices()
+ ->reject(fn (ProductPriceDomainObject $price) => $price->isLockedBehindEarlierTier())
+ ->sum(fn (ProductPriceDomainObject $price) => $price->getQuantityAvailable());
if ($this->quantityAvailable !== null) {
return min($availableCount, $this->quantityAvailable);
diff --git a/backend/app/DomainObjects/ProductPriceDomainObject.php b/backend/app/DomainObjects/ProductPriceDomainObject.php
index 3fbc1bffb7..15cec3b059 100644
--- a/backend/app/DomainObjects/ProductPriceDomainObject.php
+++ b/backend/app/DomainObjects/ProductPriceDomainObject.php
@@ -3,6 +3,7 @@
namespace HiEvents\DomainObjects;
use Carbon\Carbon;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\Helper\Currency;
use LogicException;
@@ -20,6 +21,10 @@ class ProductPriceDomainObject extends Generated\ProductPriceDomainObjectAbstrac
private ?string $offSaleReason = null;
+ private int $quantityReserved = 0;
+
+ private bool $isLockedBehindEarlierTier = false;
+
public function getPriceBeforeDiscount(): ?float
{
return $this->priceBeforeDiscount;
@@ -75,22 +80,59 @@ public function isAfterSaleEndDate(): bool
public function isSoldOut(): bool
{
- // todo this is temporary to see why/when this happens
if ($this->getQuantityAvailable() < 0) {
throw new LogicException('Quantity available cannot be less than 0');
}
- if ($this->getQuantityAvailable() !== null && $this->getQuantityAvailable() <= 0) {
- return true;
+ if ($this->getQuantityAvailable() !== null) {
+ return $this->getQuantityAvailable() <= 0;
}
- if ($this->getInitialQuantityAvailable() === null) {
+ if ($this->getInitialQuantityAvailable() === null || $this->isQuantityPerOccurrence()) {
return false;
}
return $this->getQuantitySold() >= $this->getInitialQuantityAvailable();
}
+ public function isQuantityPerOccurrence(): bool
+ {
+ return $this->getQuantityAppliesTo() === ProductQuantityAppliesTo::OCCURRENCE->name;
+ }
+
+ public function isExhausted(): bool
+ {
+ if ($this->isAfterSaleEndDate()) {
+ return true;
+ }
+
+ if ($this->isQuantityPerOccurrence()) {
+ return $this->getQuantityAvailable() !== null && $this->getQuantityAvailable() <= 0;
+ }
+
+ return $this->getInitialQuantityAvailable() !== null
+ && $this->getQuantitySold() + $this->quantityReserved >= $this->getInitialQuantityAvailable();
+ }
+
+ public function setQuantityReserved(int $quantityReserved): self
+ {
+ $this->quantityReserved = $quantityReserved;
+
+ return $this;
+ }
+
+ public function isLockedBehindEarlierTier(): bool
+ {
+ return $this->isLockedBehindEarlierTier;
+ }
+
+ public function setIsLockedBehindEarlierTier(bool $isLocked): self
+ {
+ $this->isLockedBehindEarlierTier = $isLocked;
+
+ return $this;
+ }
+
public function isAvailable(): ?bool
{
return $this->isAvailable;
diff --git a/backend/app/Exceptions/InvalidOccurrenceQuantityOverrideException.php b/backend/app/Exceptions/InvalidOccurrenceQuantityOverrideException.php
new file mode 100644
index 0000000000..b168df9963
--- /dev/null
+++ b/backend/app/Exceptions/InvalidOccurrenceQuantityOverrideException.php
@@ -0,0 +1,5 @@
+isActionAuthorized($eventId, EventDomainObject::class);
+
+ return $this->resourceResponse(
+ resource: OccurrenceProductAvailabilityResource::class,
+ data: $this->handler->handle($eventId, $occurrenceId),
+ );
+ }
+}
diff --git a/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php
index a50a49a8b5..41028b9047 100644
--- a/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php
+++ b/backend/app/Http/Actions/EventOccurrences/UpsertPriceOverrideAction.php
@@ -3,12 +3,14 @@
namespace HiEvents\Http\Actions\EventOccurrences;
use HiEvents\DomainObjects\EventDomainObject;
+use HiEvents\Exceptions\InvalidOccurrenceQuantityOverrideException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\EventOccurrence\UpsertPriceOverrideRequest;
use HiEvents\Resources\EventOccurrence\ProductPriceOccurrenceOverrideResource;
use HiEvents\Services\Application\Handlers\EventOccurrence\PriceOverride\DTO\UpsertPriceOverrideDTO;
use HiEvents\Services\Application\Handlers\EventOccurrence\PriceOverride\UpsertPriceOverrideHandler;
use Illuminate\Http\JsonResponse;
+use Illuminate\Validation\ValidationException;
class UpsertPriceOverrideAction extends BaseAction
{
@@ -16,18 +18,31 @@ public function __construct(
private readonly UpsertPriceOverrideHandler $handler,
) {}
+ /**
+ * @throws ValidationException
+ */
public function __invoke(int $eventId, int $occurrenceId, UpsertPriceOverrideRequest $request): JsonResponse
{
$this->isActionAuthorized($eventId, EventDomainObject::class);
- $override = $this->handler->handle(
- new UpsertPriceOverrideDTO(
- event_id: $eventId,
- event_occurrence_id: $occurrenceId,
- product_price_id: $request->validated('product_price_id'),
- price: (float) $request->validated('price'),
- )
- );
+ $price = $request->validated('price');
+ $quantityAvailable = $request->validated('quantity_available');
+
+ try {
+ $override = $this->handler->handle(
+ new UpsertPriceOverrideDTO(
+ event_id: $eventId,
+ event_occurrence_id: $occurrenceId,
+ product_price_id: $request->validated('product_price_id'),
+ price: $price === null ? null : (float) $price,
+ quantity_available: $quantityAvailable === null ? null : (int) $quantityAvailable,
+ )
+ );
+ } catch (InvalidOccurrenceQuantityOverrideException $exception) {
+ throw ValidationException::withMessages([
+ 'quantity_available' => $exception->getMessage(),
+ ]);
+ }
return $this->resourceResponse(
resource: ProductPriceOccurrenceOverrideResource::class,
diff --git a/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php b/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php
index e7cbdf7efc..df8994cac2 100644
--- a/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php
+++ b/backend/app/Http/Request/EventOccurrence/UpsertPriceOverrideRequest.php
@@ -3,6 +3,7 @@
namespace HiEvents\Http\Request\EventOccurrence;
use HiEvents\Http\Request\BaseRequest;
+use HiEvents\Validators\Rules\RulesHelper;
class UpsertPriceOverrideRequest extends BaseRequest
{
@@ -10,7 +11,8 @@ public function rules(): array
{
return [
'product_price_id' => ['required', 'integer'],
- 'price' => ['required', 'numeric', 'min:0', 'max:100000000'],
+ 'price' => ['nullable', 'required_without:quantity_available', 'numeric', 'min:0', 'max:100000000'],
+ 'quantity_available' => ['nullable', 'required_without:price', ...RulesHelper::INTEGER, 'min:0'],
];
}
}
diff --git a/backend/app/Http/Request/Product/UpsertProductRequest.php b/backend/app/Http/Request/Product/UpsertProductRequest.php
index c53e0cb2fd..2123f27f6d 100644
--- a/backend/app/Http/Request/Product/UpsertProductRequest.php
+++ b/backend/app/Http/Request/Product/UpsertProductRequest.php
@@ -5,10 +5,12 @@
namespace HiEvents\Http\Request\Product;
use HiEvents\DomainObjects\Enums\ProductPriceType;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\Http\Request\BaseRequest;
use HiEvents\Validators\Rules\RulesHelper;
use Illuminate\Validation\Rule;
+use Illuminate\Validation\Validator;
class UpsertProductRequest extends BaseRequest
{
@@ -28,12 +30,14 @@ public function rules(): array
'prices.*.sale_end_date' => 'date|nullable|after:prices.*.sale_start_date',
'prices.*.initial_quantity_available' => [...RulesHelper::INTEGER, 'nullable', 'min:0'],
'prices.*.is_hidden' => ['boolean'],
+ 'prices.*.quantity_applies_to' => ['nullable', Rule::in(ProductQuantityAppliesTo::valuesArray())],
'description' => 'string|nullable',
'min_per_order' => [...RulesHelper::INTEGER, 'nullable'],
'is_hidden' => 'boolean',
'hide_before_sale_start_date' => 'boolean',
'hide_after_sale_end_date' => 'boolean',
'hide_when_sold_out' => 'boolean',
+ 'sequential_tier_release_enabled' => 'boolean',
'start_collapsed' => 'boolean',
'show_quantity_remaining' => 'boolean',
'is_hidden_without_promo_code' => 'boolean',
@@ -50,6 +54,26 @@ public function rules(): array
];
}
+ public function withValidator(Validator $validator): void
+ {
+ $validator->after(function (Validator $validator) {
+ if (! $this->boolean('sequential_tier_release_enabled') || $this->input('type') !== ProductPriceType::TIERED->name) {
+ return;
+ }
+
+ $prices = collect($this->input('prices', []));
+
+ $prices->slice(0, -1)->each(function (array $price, int $index) use ($validator) {
+ if (($price['initial_quantity_available'] ?? null) === null) {
+ $validator->errors()->add(
+ "prices.$index.initial_quantity_available",
+ __('Every tier except the last needs a quantity when tiers are released in order.'),
+ );
+ }
+ });
+ });
+ }
+
public function messages(): array
{
return [
diff --git a/backend/app/Locale.php b/backend/app/Locale.php
index 9c3fd6ca2c..68ebf65bbe 100644
--- a/backend/app/Locale.php
+++ b/backend/app/Locale.php
@@ -10,6 +10,7 @@ enum Locale: string
case EN = 'en';
case DE = 'de';
+ case FI = 'fi';
case FR = 'fr';
case IT = 'it';
case NL = 'nl';
@@ -27,6 +28,7 @@ enum Locale: string
case PL = 'pl';
case SK = 'sk';
case EL = 'el';
+ case KO = 'ko';
public static function getSupportedLocales(): array
{
diff --git a/backend/app/Models/ProductCategory.php b/backend/app/Models/ProductCategory.php
index e40dc76e69..8d91ae3456 100644
--- a/backend/app/Models/ProductCategory.php
+++ b/backend/app/Models/ProductCategory.php
@@ -23,6 +23,6 @@ class ProductCategory extends BaseModel
public function products(): HasMany
{
- return $this->hasMany(Product::class);
+ return $this->hasMany(Product::class)->orderBy('order');
}
}
diff --git a/backend/app/Repository/Eloquent/AttendeeRepository.php b/backend/app/Repository/Eloquent/AttendeeRepository.php
index 1c160ba34d..48bb7f3069 100644
--- a/backend/app/Repository/Eloquent/AttendeeRepository.php
+++ b/backend/app/Repository/Eloquent/AttendeeRepository.php
@@ -171,4 +171,45 @@ public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $pa
limit: min($params->per_page, 250),
);
}
+
+ public function getSoldQuantitiesByPriceForOccurrence(int $occurrenceId): array
+ {
+ return $this->runQuery(fn () => $this->soldAttendeesQuery()
+ ->where('attendees.event_occurrence_id', $occurrenceId)
+ ->groupBy('attendees.product_price_id')
+ ->selectRaw('attendees.product_price_id, COUNT(*) AS quantity')
+ ->pluck('quantity', 'product_price_id')
+ ->map(fn ($quantity) => (int) $quantity)
+ ->all());
+ }
+
+ public function getMaxSoldPerOccurrenceByPrice(array $productPriceIds): array
+ {
+ return $this->runQuery(fn () => $this->soldAttendeesQuery()
+ ->whereIn('attendees.product_price_id', $productPriceIds)
+ ->whereNotNull('attendees.event_occurrence_id')
+ ->groupBy('attendees.product_price_id', 'attendees.event_occurrence_id')
+ ->selectRaw('attendees.product_price_id, COUNT(*) AS quantity')
+ ->get()
+ ->groupBy('product_price_id')
+ ->map(fn ($rows) => (int) $rows->max('quantity'))
+ ->all());
+ }
+
+ private function soldAttendeesQuery(): Builder
+ {
+ return Attendee::query()
+ ->join('orders', 'orders.id', '=', 'attendees.order_id')
+ ->whereNull('attendees.deleted_at')
+ ->whereNull('orders.deleted_at')
+ ->where(function (Builder $query) {
+ $query
+ ->where('attendees.status', AttendeeStatus::ACTIVE->name)
+ ->orWhere(function (Builder $query) {
+ $query
+ ->where('attendees.status', AttendeeStatus::AWAITING_PAYMENT->name)
+ ->where('orders.status', OrderStatus::AWAITING_OFFLINE_PAYMENT->name);
+ });
+ });
+ }
}
diff --git a/backend/app/Repository/Eloquent/OrderItemRepository.php b/backend/app/Repository/Eloquent/OrderItemRepository.php
index 0147150592..b2d8341e03 100644
--- a/backend/app/Repository/Eloquent/OrderItemRepository.php
+++ b/backend/app/Repository/Eloquent/OrderItemRepository.php
@@ -7,6 +7,7 @@
use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Models\OrderItem;
use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface;
+use Illuminate\Database\Eloquent\Builder;
/**
* @extends BaseRepository
@@ -23,15 +24,65 @@ public function getDomainObject(): string
return OrderItemDomainObject::class;
}
- public function getReservedQuantityForOccurrence(int $occurrenceId): int
+ public function getReservedTicketQuantityForOccurrence(int $occurrenceId): int
{
- return $this->runQuery(fn () => (int) OrderItem::query()
- ->join('orders', 'orders.id', '=', 'order_items.order_id')
+ return $this->runQuery(fn () => (int) $this->reservedItemsQuery()
->where('order_items.event_occurrence_id', $occurrenceId)
->where('order_items.product_type', ProductType::TICKET->name)
+ ->sum('order_items.quantity'));
+ }
+
+ public function getReservedQuantitiesByPrice(int $eventId, ?int $occurrenceId = null): array
+ {
+ return $this->runQuery(fn () => $this->reservedItemsQuery()
+ ->where('orders.event_id', $eventId)
+ ->when($occurrenceId !== null, fn (Builder $query) => $query->where('order_items.event_occurrence_id', $occurrenceId))
+ ->groupBy('order_items.product_price_id')
+ ->selectRaw('order_items.product_price_id, SUM(order_items.quantity) AS quantity')
+ ->pluck('quantity', 'product_price_id')
+ ->map(fn ($quantity) => (int) $quantity)
+ ->all());
+ }
+
+ public function getSoldQuantitiesByPriceForOccurrence(int $occurrenceId): array
+ {
+ return $this->runQuery(fn () => $this->soldGeneralItemsQuery()
+ ->where('order_items.event_occurrence_id', $occurrenceId)
+ ->groupBy('order_items.product_price_id')
+ ->selectRaw('order_items.product_price_id, SUM(order_items.quantity) AS quantity')
+ ->pluck('quantity', 'product_price_id')
+ ->map(fn ($quantity) => (int) $quantity)
+ ->all());
+ }
+
+ public function getMaxSoldPerOccurrenceByPrice(array $productPriceIds): array
+ {
+ return $this->runQuery(fn () => $this->soldGeneralItemsQuery()
+ ->whereIn('order_items.product_price_id', $productPriceIds)
+ ->whereNotNull('order_items.event_occurrence_id')
+ ->groupBy('order_items.product_price_id', 'order_items.event_occurrence_id')
+ ->selectRaw('order_items.product_price_id, SUM(order_items.quantity) AS quantity')
+ ->get()
+ ->groupBy('product_price_id')
+ ->map(fn ($rows) => (int) $rows->max('quantity'))
+ ->all());
+ }
+
+ private function reservedItemsQuery(): Builder
+ {
+ return OrderItem::query()
+ ->join('orders', 'orders.id', '=', 'order_items.order_id')
->where('orders.status', OrderStatus::RESERVED->name)
->where('orders.reserved_until', '>', now())
- ->whereNull('orders.deleted_at')
- ->sum('order_items.quantity'));
+ ->whereNull('orders.deleted_at');
+ }
+
+ private function soldGeneralItemsQuery(): Builder
+ {
+ return OrderItem::query()
+ ->join('orders', 'orders.id', '=', 'order_items.order_id')
+ ->where('order_items.product_type', ProductType::GENERAL->name)
+ ->whereIn('orders.status', [OrderStatus::COMPLETED->name, OrderStatus::AWAITING_OFFLINE_PAYMENT->name])
+ ->whereNull('orders.deleted_at');
}
}
diff --git a/backend/app/Repository/Eloquent/ProductRepository.php b/backend/app/Repository/Eloquent/ProductRepository.php
index 3439ee5a9c..0403e5362f 100644
--- a/backend/app/Repository/Eloquent/ProductRepository.php
+++ b/backend/app/Repository/Eloquent/ProductRepository.php
@@ -5,7 +5,6 @@
namespace HiEvents\Repository\Eloquent;
use Exception;
-use HiEvents\Constants;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract;
use HiEvents\DomainObjects\ProductDomainObject;
@@ -20,7 +19,6 @@
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
-use RuntimeException;
use Throwable;
/**
@@ -53,45 +51,6 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
);
}
- public function getQuantityRemainingForProductPrice(int $productId, int $productPriceId): int
- {
- $query = <<<'SQL'
- SELECT
- COALESCE(product_prices.initial_quantity_available, 0) - (
- product_prices.quantity_sold + COALESCE((
- SELECT sum(order_items.quantity)
- FROM orders
- INNER JOIN order_items ON orders.id = order_items.order_id
- WHERE order_items.product_price_id = :productPriceId
- AND orders.status in ('RESERVED')
- AND current_timestamp < orders.reserved_until
- AND orders.deleted_at IS NULL
- AND order_items.deleted_at IS NULL
- ), 0)
- ) AS quantity_remaining,
- product_prices.initial_quantity_available IS NULL AS unlimited_products_available
- FROM product_prices
- WHERE product_prices.id = :productPriceId
- AND product_prices.product_id = :productId
- AND product_prices.deleted_at IS NULL
- SQL;
-
- $result = $this->db->selectOne($query, [
- 'productPriceId' => $productPriceId,
- 'productId' => $productId,
- ]);
-
- if ($result === null) {
- throw new RuntimeException('Product price not found');
- }
-
- if ($result->unlimited_products_available) {
- return Constants::INFINITE;
- }
-
- return (int) $result->quantity_remaining;
- }
-
public function getTaxesByProductId(int $productId): Collection
{
$query = <<<'SQL'
diff --git a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php
index 1ce6e9c538..262cb8df38 100644
--- a/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/AttendeeRepositoryInterface.php
@@ -18,4 +18,14 @@ public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAware
public function findByEventIdForExport(int $eventId, ?int $eventOccurrenceId = null): Collection;
public function getAttendeesByCheckInShortId(string $shortId, QueryParamsDTO $params): Paginator;
+
+ /**
+ * @return array product_price_id => quantity
+ */
+ public function getSoldQuantitiesByPriceForOccurrence(int $occurrenceId): array;
+
+ /**
+ * @return array product_price_id => highest quantity sold on any single occurrence
+ */
+ public function getMaxSoldPerOccurrenceByPrice(array $productPriceIds): array;
}
diff --git a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
index 6c3b623f0d..de4d0aa6e1 100644
--- a/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/OrderItemRepositoryInterface.php
@@ -9,5 +9,20 @@
*/
interface OrderItemRepositoryInterface extends RepositoryInterface
{
- public function getReservedQuantityForOccurrence(int $occurrenceId): int;
+ public function getReservedTicketQuantityForOccurrence(int $occurrenceId): int;
+
+ /**
+ * @return array product_price_id => quantity
+ */
+ public function getReservedQuantitiesByPrice(int $eventId, ?int $occurrenceId = null): array;
+
+ /**
+ * @return array product_price_id => quantity
+ */
+ public function getSoldQuantitiesByPriceForOccurrence(int $occurrenceId): array;
+
+ /**
+ * @return array product_price_id => highest quantity sold on any single occurrence
+ */
+ public function getMaxSoldPerOccurrenceByPrice(array $productPriceIds): array;
}
diff --git a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php
index 1b008ba433..793f6aab8b 100644
--- a/backend/app/Repository/Interfaces/ProductRepositoryInterface.php
+++ b/backend/app/Repository/Interfaces/ProductRepositoryInterface.php
@@ -16,8 +16,6 @@ interface ProductRepositoryInterface extends RepositoryInterface
{
public function findByEventId(int $eventId, QueryParamsDTO $params): LengthAwarePaginator;
- public function getQuantityRemainingForProductPrice(int $productId, int $productPriceId): int;
-
public function getTaxesByProductId(int $productId): Collection;
public function getProductsByTaxId(int $taxId): Collection;
diff --git a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php
index 868c556678..37fbb757ef 100644
--- a/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php
+++ b/backend/app/Resources/EventOccurrence/EventOccurrenceResource.php
@@ -5,6 +5,7 @@
use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\Resources\BaseResource;
use HiEvents\Resources\EventLocation\EventLocationResource;
+use HiEvents\Services\Domain\EventOccurrence\DTO\OccurrenceTierAllocationDTO;
use Illuminate\Http\Request;
/**
@@ -36,6 +37,19 @@ public function toArray(Request $request): array
condition: $this->getEventLocation() !== null,
value: fn () => new EventLocationResource($this->getEventLocation()),
),
+ 'booking_limits' => $this->when($this->getBookingLimits() !== null, fn () => [
+ 'capacity' => $this->getBookingLimits()->capacity,
+ 'allocation_total' => $this->getBookingLimits()->allocation_total,
+ 'sellable' => $this->getBookingLimits()->sellable,
+ 'allocations' => array_map(fn (OccurrenceTierAllocationDTO $allocation) => [
+ 'product_price_id' => $allocation->product_price_id,
+ 'product_title' => $allocation->product_title,
+ 'price_label' => $allocation->price_label,
+ 'quantity' => $allocation->quantity,
+ /** @var 'OCCURRENCE'|'EVENT' */
+ 'applies_to' => $allocation->applies_to,
+ ], $this->getBookingLimits()->allocations),
+ ]),
'statistics' => $this->when($stats !== null, fn () => [
'total_gross_sales' => $stats->getSalesTotalGross() ?? 0,
'total_tax' => $stats->getTotalTax() ?? 0,
diff --git a/backend/app/Resources/EventOccurrence/OccurrenceProductAvailabilityResource.php b/backend/app/Resources/EventOccurrence/OccurrenceProductAvailabilityResource.php
new file mode 100644
index 0000000000..d1923a428b
--- /dev/null
+++ b/backend/app/Resources/EventOccurrence/OccurrenceProductAvailabilityResource.php
@@ -0,0 +1,23 @@
+ $this->product_id,
+ 'product_price_id' => $this->product_price_id,
+ 'quantity_sold' => $this->quantity_sold,
+ 'quantity_available' => $this->quantity_available,
+ ];
+ }
+}
diff --git a/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php b/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php
index d096d6334b..9a44784a7f 100644
--- a/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php
+++ b/backend/app/Resources/EventOccurrence/ProductPriceOccurrenceOverrideResource.php
@@ -18,6 +18,7 @@ public function toArray(Request $request): array
'event_occurrence_id' => $this->getEventOccurrenceId(),
'product_price_id' => $this->getProductPriceId(),
'price' => $this->getPrice(),
+ 'quantity_available' => $this->getQuantityAvailable(),
'created_at' => $this->getCreatedAt(),
'updated_at' => $this->getUpdatedAt(),
];
diff --git a/backend/app/Resources/Product/ProductPriceResource.php b/backend/app/Resources/Product/ProductPriceResource.php
index 3cbd945dc5..166a83a1cb 100644
--- a/backend/app/Resources/Product/ProductPriceResource.php
+++ b/backend/app/Resources/Product/ProductPriceResource.php
@@ -24,7 +24,10 @@ public function toArray(Request $request): array
'is_available' => $this->isAvailable(),
'initial_quantity_available' => $this->getInitialQuantityAvailable(),
'quantity_sold' => $this->getQuantitySold(),
+ /** @var 'OCCURRENCE'|'EVENT' */
+ 'quantity_applies_to' => $this->getQuantityAppliesTo(),
'is_sold_out' => $this->isSoldOut(),
+ 'is_locked_behind_earlier_tier' => $this->isLockedBehindEarlierTier(),
'is_hidden' => $this->getIsHidden(),
'off_sale_reason' => $this->getOffSaleReason(),
'price_including_taxes_and_fees' => $this->getPriceIncludingTaxAndServiceFee(),
diff --git a/backend/app/Resources/Product/ProductPriceResourcePublic.php b/backend/app/Resources/Product/ProductPriceResourcePublic.php
index 45d8ac63df..6e2a5f246a 100644
--- a/backend/app/Resources/Product/ProductPriceResourcePublic.php
+++ b/backend/app/Resources/Product/ProductPriceResourcePublic.php
@@ -30,6 +30,9 @@ public function toArray(Request $request): array
'is_after_sale_end_date' => $this->isAfterSaleEndDate(),
'is_available' => $this->isAvailable(),
'is_sold_out' => $this->isSoldOut(),
+ /** @var 'OCCURRENCE'|'EVENT' */
+ 'quantity_applies_to' => $this->getQuantityAppliesTo(),
+ 'is_locked_behind_earlier_tier' => $this->isLockedBehindEarlierTier(),
$this->mergeWhen($this->getAdditionalDataByKey(self::SHOW_QUANTITY_AVAILABLE), fn () => [
'quantity_remaining' => $this->getQuantityAvailable(),
]),
diff --git a/backend/app/Resources/Product/ProductResource.php b/backend/app/Resources/Product/ProductResource.php
index a545b2bd5a..c5da7dbcd1 100644
--- a/backend/app/Resources/Product/ProductResource.php
+++ b/backend/app/Resources/Product/ProductResource.php
@@ -46,6 +46,7 @@ public function toArray(Request $request): array
'start_collapsed' => $this->getStartCollapsed(),
'show_quantity_remaining' => $this->getShowQuantityRemaining(),
'hide_when_sold_out' => $this->getHideWhenSoldOut(),
+ 'sequential_tier_release_enabled' => $this->getSequentialTierReleaseEnabled(),
'is_hidden_without_promo_code' => $this->getIsHiddenWithoutPromoCode(),
'is_hidden' => $this->getIsHidden(),
'is_before_sale_start_date' => $this->isBeforeSaleStartDate(),
diff --git a/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php
index 2245af1cfe..c47dac8650 100644
--- a/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/CreateAttendeeHandler.php
@@ -31,6 +31,7 @@
use HiEvents\Services\Application\Handlers\Attendee\DTO\CreateAttendeeTaxAndFeeDTO;
use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Order\OrderManagementService;
+use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
use HiEvents\Services\Domain\SelfService\OrderAuditLogService;
use HiEvents\Services\Domain\Tax\TaxAndFeeRollupService;
@@ -58,6 +59,7 @@ public function __construct(
private readonly DomainEventDispatcherService $domainEventDispatcherService,
private readonly OccurrencePurchaseEligibilityService $occurrenceEligibilityService,
private readonly OrderAuditLogService $orderAuditLogService,
+ private readonly AvailableProductQuantitiesFetchService $availableProductQuantitiesFetchService,
) {}
/**
@@ -99,10 +101,14 @@ public function handle(CreateAttendeeDTO $attendeeDTO): AttendeeDomainObject
$productPriceId = $this->getProductPriceId($attendeeDTO, $product);
- $availableQuantity = $this->productRepository->getQuantityRemainingForProductPrice(
- $attendeeDTO->product_id,
- $productPriceId,
- );
+ $availableQuantity = $this->availableProductQuantitiesFetchService
+ ->getAvailableProductQuantities(
+ $attendeeDTO->event_id,
+ ignoreCache: true,
+ eventOccurrenceId: $attendeeDTO->event_occurrence_id,
+ applyOccurrenceLimits: false,
+ )
+ ->getAvailableQuantityForPrice($productPriceId);
if ($availableQuantity <= 0) {
throw new NoTicketsAvailableException(__('There are no tickets available. '.
diff --git a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
index efada53416..5b5e0a59d1 100644
--- a/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
+++ b/backend/app/Services/Application/Handlers/Attendee/EditAttendeeHandler.php
@@ -14,6 +14,7 @@
use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Application\Handlers\Attendee\DTO\EditAttendeeDTO;
+use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
use HiEvents\Services\Infrastructure\DomainEvents\DomainEventDispatcherService;
use HiEvents\Services\Infrastructure\DomainEvents\Enums\DomainEventType;
@@ -30,6 +31,7 @@ public function __construct(
private readonly ProductQuantityUpdateService $productQuantityService,
private readonly DatabaseManager $databaseManager,
private readonly DomainEventDispatcherService $domainEventDispatcherService,
+ private readonly AvailableProductQuantitiesFetchService $availableProductQuantitiesFetchService,
) {}
/**
@@ -121,12 +123,18 @@ private function validateProductId(
return;
}
- $availableQuantity = $this->productRepository->getQuantityRemainingForProductPrice(
- productId: $editAttendeeDTO->product_id,
- productPriceId: $product->getType() === ProductPriceType::TIERED->name
- ? $editAttendeeDTO->product_price_id
- : $product->getProductPrices()->first()->getId(),
- );
+ $availableQuantity = $this->availableProductQuantitiesFetchService
+ ->getAvailableProductQuantities(
+ $editAttendeeDTO->event_id,
+ ignoreCache: true,
+ eventOccurrenceId: $attendee->getEventOccurrenceId(),
+ applyOccurrenceLimits: false,
+ )
+ ->getAvailableQuantityForPrice(
+ $product->getType() === ProductPriceType::TIERED->name
+ ? $editAttendeeDTO->product_price_id
+ : $product->getProductPrices()->first()->getId(),
+ );
if ($availableQuantity <= 0) {
throw new NoTicketsAvailableException(
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/OccurrenceProductAvailabilityDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/OccurrenceProductAvailabilityDTO.php
new file mode 100644
index 0000000000..dd75fd3f59
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/OccurrenceProductAvailabilityDTO.php
@@ -0,0 +1,15 @@
+bookingLimitsService->attachTo(collect([$occurrence]), $eventId);
+
return $occurrence;
}
}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php
index da4cb19f43..c85f7b1217 100644
--- a/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandler.php
@@ -10,12 +10,14 @@
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Repository\Eloquent\Value\Relationship;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
+use HiEvents\Services\Domain\EventOccurrence\OccurrenceBookingLimitsService;
use Illuminate\Pagination\LengthAwarePaginator;
class GetEventOccurrencesHandler
{
public function __construct(
private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
+ private readonly OccurrenceBookingLimitsService $bookingLimitsService,
) {}
public function handle(int $eventId, QueryParamsDTO $queryParams, bool $includeStats = true): LengthAwarePaginator
@@ -29,6 +31,10 @@ public function handle(int $eventId, QueryParamsDTO $queryParams, bool $includeS
$repository = $repository->loadRelation(EventOccurrenceStatisticDomainObject::class);
}
- return $repository->findByEventId($eventId, $queryParams);
+ $occurrences = $repository->findByEventId($eventId, $queryParams);
+
+ $this->bookingLimitsService->attachTo($occurrences->getCollection(), $eventId);
+
+ return $occurrences;
}
}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/GetOccurrenceProductAvailabilityHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/GetOccurrenceProductAvailabilityHandler.php
new file mode 100644
index 0000000000..3ec2cb3599
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/GetOccurrenceProductAvailabilityHandler.php
@@ -0,0 +1,61 @@
+
+ */
+ public function handle(int $eventId, int $occurrenceId): Collection
+ {
+ $occurrence = $this->occurrenceRepository->findFirstWhere([
+ EventOccurrenceDomainObjectAbstract::ID => $occurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ ]);
+
+ if (! $occurrence) {
+ throw new ResourceNotFoundException(
+ __('Occurrence :id not found for this event', ['id' => $occurrenceId])
+ );
+ }
+
+ $quantities = $this->availableProductQuantitiesFetchService
+ ->getAvailableProductQuantities($eventId, ignoreCache: true, eventOccurrenceId: $occurrenceId)
+ ->productQuantities;
+
+ $sold = [];
+ foreach ($quantities->pluck('product_type')->unique() as $productType) {
+ $sold[$productType] = $this->soldAndReservedQuantities->getSoldByPriceForOccurrence(
+ $occurrenceId,
+ ProductType::fromName($productType),
+ );
+ }
+
+ return $quantities->map(fn (AvailableProductQuantitiesDTO $quantity) => new OccurrenceProductAvailabilityDTO(
+ product_id: $quantity->product_id,
+ product_price_id: $quantity->price_id,
+ quantity_sold: $sold[$quantity->product_type][$quantity->price_id] ?? 0,
+ quantity_available: $quantity->quantity_available === Constants::INFINITE ? null : $quantity->quantity_available,
+ ))->values();
+ }
+}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php
index 05dc936a7e..45490e8d98 100644
--- a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/DTO/UpsertPriceOverrideDTO.php
@@ -10,6 +10,7 @@ public function __construct(
public readonly int $event_id,
public readonly int $event_occurrence_id,
public readonly int $product_price_id,
- public readonly float $price,
+ public readonly ?float $price = null,
+ public readonly ?int $quantity_available = null,
) {}
}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php
index 9640e791e6..f67e4dc799 100644
--- a/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandler.php
@@ -4,15 +4,21 @@
namespace HiEvents\Services\Application\Handlers\EventOccurrence\PriceOverride;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract;
use HiEvents\DomainObjects\Generated\ProductPriceOccurrenceOverrideDomainObjectAbstract;
+use HiEvents\DomainObjects\ProductDomainObject;
+use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\ProductPriceOccurrenceOverrideDomainObject;
+use HiEvents\Exceptions\InvalidOccurrenceQuantityOverrideException;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventOccurrence\PriceOverride\DTO\UpsertPriceOverrideDTO;
+use HiEvents\Services\Domain\Product\SoldAndReservedQuantitiesService;
use Illuminate\Database\DatabaseManager;
use Throwable;
@@ -23,11 +29,13 @@ public function __construct(
private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
private readonly ProductPriceRepositoryInterface $productPriceRepository,
private readonly ProductRepositoryInterface $productRepository,
+ private readonly SoldAndReservedQuantitiesService $soldAndReservedQuantities,
private readonly DatabaseManager $databaseManager,
) {}
/**
* @throws Throwable
+ * @throws InvalidOccurrenceQuantityOverrideException
*/
public function handle(UpsertPriceOverrideDTO $dto): ProductPriceOccurrenceOverrideDomainObject
{
@@ -60,6 +68,10 @@ public function handle(UpsertPriceOverrideDTO $dto): ProductPriceOccurrenceOverr
);
}
+ if ($dto->quantity_available !== null) {
+ $this->assertQuantityOverrideAllowed($dto, $productPrice, $product);
+ }
+
return $this->databaseManager->transaction(function () use ($dto) {
$existing = $this->overrideRepository->findFirstWhere([
ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id,
@@ -71,6 +83,7 @@ public function handle(UpsertPriceOverrideDTO $dto): ProductPriceOccurrenceOverr
id: $existing->getId(),
attributes: [
ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => $dto->price,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::QUANTITY_AVAILABLE => $dto->quantity_available,
],
);
}
@@ -79,7 +92,37 @@ public function handle(UpsertPriceOverrideDTO $dto): ProductPriceOccurrenceOverr
ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $dto->event_occurrence_id,
ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => $dto->product_price_id,
ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => $dto->price,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::QUANTITY_AVAILABLE => $dto->quantity_available,
]);
});
}
+
+ /**
+ * @throws InvalidOccurrenceQuantityOverrideException
+ */
+ private function assertQuantityOverrideAllowed(
+ UpsertPriceOverrideDTO $dto,
+ ProductPriceDomainObject $productPrice,
+ ProductDomainObject $product,
+ ): void {
+ if ($productPrice->getQuantityAppliesTo() !== ProductQuantityAppliesTo::OCCURRENCE->name) {
+ throw new InvalidOccurrenceQuantityOverrideException(
+ __('Per-date quantities only apply to tiers whose quantity is per date')
+ );
+ }
+
+ $sold = $this->soldAndReservedQuantities->getSoldByPriceForOccurrence(
+ $dto->event_occurrence_id,
+ ProductType::fromName($product->getProductType()),
+ )[$dto->product_price_id] ?? 0;
+
+ if ($dto->quantity_available < $sold) {
+ throw new InvalidOccurrenceQuantityOverrideException(
+ __('The quantity for :price on this date cannot be less than the number already sold (:sold)', [
+ 'price' => $productPrice->getLabel() ?: __('Default'),
+ 'sold' => $sold,
+ ])
+ );
+ }
+ }
}
diff --git a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php
index f854610a2a..1ba4a089ba 100644
--- a/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php
+++ b/backend/app/Services/Application/Handlers/Product/CreateProductHandler.php
@@ -5,6 +5,7 @@
namespace HiEvents\Services\Application\Handlers\Product;
use HiEvents\DomainObjects\Enums\ProductPriceType;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\Generated\ProductPriceDomainObjectAbstract;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
@@ -32,6 +33,7 @@ public function handle(UpsertProductDTO $productsData): ProductDomainObject
ProductPriceDomainObjectAbstract::SALE_START_DATE => $price->sale_start_date,
ProductPriceDomainObjectAbstract::SALE_END_DATE => $price->sale_end_date,
ProductPriceDomainObjectAbstract::INITIAL_QUANTITY_AVAILABLE => $price->initial_quantity_available,
+ ProductPriceDomainObjectAbstract::QUANTITY_APPLIES_TO => ($price->quantity_applies_to ?? ProductQuantityAppliesTo::defaultFor($productsData->product_type))->name,
ProductPriceDomainObjectAbstract::IS_HIDDEN => $price->is_hidden,
]));
@@ -55,6 +57,7 @@ public function handle(UpsertProductDTO $productsData): ProductDomainObject
->setHideBeforeSaleStartDate($productsData->hide_before_sale_start_date)
->setHideAfterSaleEndDate($productsData->hide_after_sale_end_date)
->setHideWhenSoldOut($productsData->hide_when_sold_out)
+ ->setSequentialTierReleaseEnabled($productsData->type === ProductPriceType::TIERED && $productsData->sequential_tier_release_enabled)
->setShowQuantityRemaining($productsData->show_quantity_remaining)
->setIsHiddenWithoutPromoCode($productsData->is_hidden_without_promo_code)
->setIsHighlighted($productsData->is_highlighted ?? false)
diff --git a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php
index bfbf71ccb5..e69e01679e 100644
--- a/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php
+++ b/backend/app/Services/Application/Handlers/Product/DTO/UpsertProductDTO.php
@@ -33,6 +33,7 @@ public function __construct(
public readonly ?bool $hide_before_sale_start_date = false,
public readonly ?bool $hide_after_sale_end_date = false,
public readonly ?bool $hide_when_sold_out = false,
+ public readonly ?bool $sequential_tier_release_enabled = false,
public readonly ?bool $start_collapsed = false,
public readonly ?bool $show_quantity_remaining = false,
public readonly ?bool $is_hidden_without_promo_code = false,
diff --git a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php
index da94b77806..fdcbc0c1c9 100644
--- a/backend/app/Services/Application/Handlers/Product/EditProductHandler.php
+++ b/backend/app/Services/Application/Handlers/Product/EditProductHandler.php
@@ -6,6 +6,8 @@
use Exception;
use HiEvents\DomainObjects\Enums\CapacityChangeDirection;
+use HiEvents\DomainObjects\Enums\ProductPriceType;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\Interfaces\DomainObjectInterface;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
@@ -127,6 +129,7 @@ private function updateProduct(UpsertProductDTO $productsData, array $where): Pr
'hide_before_sale_start_date' => $productsData->hide_before_sale_start_date,
'hide_after_sale_end_date' => $productsData->hide_after_sale_end_date,
'hide_when_sold_out' => $productsData->hide_when_sold_out,
+ 'sequential_tier_release_enabled' => $productsData->type === ProductPriceType::TIERED && $productsData->sequential_tier_release_enabled,
'show_quantity_remaining' => $productsData->show_quantity_remaining,
'is_hidden_without_promo_code' => $productsData->is_hidden_without_promo_code,
'product_type' => $productsData->product_type->name,
@@ -166,7 +169,10 @@ private function getExistingPriceQuantities(int $productId): Collection
return $product->getProductPrices()
->mapWithKeys(fn (ProductPriceDomainObject $price) => [
- $price->getId() => $price->getInitialQuantityAvailable(),
+ $price->getId() => [
+ 'quantity' => $price->getInitialQuantityAvailable(),
+ 'applies_to' => $price->getQuantityAppliesTo(),
+ ],
]);
}
@@ -183,14 +189,20 @@ private function dispatchCapacityChangedEventIfQuantityChanged(
continue;
}
- $oldQuantity = $oldPriceQuantities->get($price->id);
+ $old = $oldPriceQuantities->get($price->id);
+ $oldQuantity = $old['quantity'] ?? null;
$newQuantity = $price->initial_quantity_available;
+ $scopeChanged = $price->quantity_applies_to !== null
+ && $old !== null
+ && $price->quantity_applies_to->name !== $old['applies_to'];
$direction = match (true) {
($newQuantity === null && $oldQuantity !== null),
- ($newQuantity !== null && $oldQuantity !== null && $newQuantity > $oldQuantity) => CapacityChangeDirection::INCREASED,
+ ($newQuantity !== null && $oldQuantity !== null && $newQuantity > $oldQuantity),
+ ($scopeChanged && $price->quantity_applies_to === ProductQuantityAppliesTo::OCCURRENCE) => CapacityChangeDirection::INCREASED,
($newQuantity !== null && $oldQuantity === null),
- ($newQuantity !== null && $oldQuantity !== null && $newQuantity < $oldQuantity) => CapacityChangeDirection::DECREASED,
+ ($newQuantity !== null && $oldQuantity !== null && $newQuantity < $oldQuantity),
+ $scopeChanged => CapacityChangeDirection::DECREASED,
default => null,
};
diff --git a/backend/app/Services/Domain/Event/DuplicateEventService.php b/backend/app/Services/Domain/Event/DuplicateEventService.php
index d845cd7d81..b93e9c6e9a 100644
--- a/backend/app/Services/Domain/Event/DuplicateEventService.php
+++ b/backend/app/Services/Domain/Event/DuplicateEventService.php
@@ -34,8 +34,8 @@
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\ImageRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
-use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface;
+use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Domain\CapacityAssignment\CreateCapacityAssignmentService;
use HiEvents\Services\Domain\CheckInList\CreateCheckInListService;
use HiEvents\Services\Domain\CreateWebhookService;
@@ -127,7 +127,6 @@ public function duplicateEvent(
event: $event,
newEventId: $newEvent->getId(),
duplicateQuestions: $duplicateQuestions,
- duplicatePromoCodes: $duplicatePromoCodes,
duplicateCapacityAssignments: $duplicateCapacityAssignments,
duplicateCheckInLists: $duplicateCheckInLists,
oldToNewOccurrenceMap: $oldToNewOccurrenceMap,
@@ -136,6 +135,10 @@ public function duplicateEvent(
$this->createProductCategoryService->createDefaultProductCategory($newEvent);
}
+ if ($duplicatePromoCodes) {
+ $this->clonePromoCodes($event, $newEvent->getId(), $oldProductToNewProductMap);
+ }
+
if ($duplicateOccurrences && $duplicateProducts && ! empty($oldToNewOccurrenceMap)) {
$this->cloneOccurrenceProductSettings($oldToNewOccurrenceMap, $oldProductToNewProductMap, $oldPriceToNewPriceMap);
}
@@ -240,7 +243,6 @@ private function cloneExistingProducts(
EventDomainObject $event,
int $newEventId,
bool $duplicateQuestions,
- bool $duplicatePromoCodes,
bool $duplicateCapacityAssignments,
bool $duplicateCheckInLists,
array $oldToNewOccurrenceMap = [],
@@ -285,10 +287,6 @@ private function cloneExistingProducts(
$this->clonePerProductQuestions($event, $newEventId, $oldProductToNewProductMap);
}
- if ($duplicatePromoCodes) {
- $this->clonePromoCodes($event, $newEventId, $oldProductToNewProductMap);
- }
-
if ($duplicateCapacityAssignments) {
$this->cloneCapacityAssignments($event, $newEventId, $oldProductToNewProductMap);
}
@@ -377,14 +375,19 @@ private function clonePerOrderQuestions(EventDomainObject $event, int $newEventI
private function clonePromoCodes(EventDomainObject $event, int $newEventId, array $oldProductToNewProductMap): void
{
foreach ($event->getPromoCodes() as $promoCode) {
+ $mappedProductIds = array_values(array_filter(
+ array_map(
+ static fn ($productId) => $oldProductToNewProductMap[$productId] ?? null,
+ $promoCode->getApplicableProductIds() ?? [],
+ ),
+ static fn ($productId) => $productId !== null,
+ ));
+
$this->createPromoCodeService->createPromoCode(
(new PromoCodeDomainObject)
->setCode($promoCode->getCode())
->setEventId($newEventId)
- ->setApplicableProductIds(array_map(
- static fn ($productId) => $oldProductToNewProductMap[$productId],
- $promoCode->getApplicableProductIds() ?? [],
- ))
+ ->setApplicableProductIds($mappedProductIds)
->setDiscountType($promoCode->getDiscountType())
->setDiscount($promoCode->getDiscount())
->setExpiryDate($promoCode->getExpiryDate())
@@ -602,6 +605,7 @@ private function cloneOccurrenceProductSettings(
'event_occurrence_id' => $oldToNewOccurrenceMap[$override->getEventOccurrenceId()],
'product_price_id' => $newPriceId,
'price' => $override->getPrice(),
+ 'quantity_available' => $override->getQuantityAvailable(),
];
}
if ($priceOverrideInserts !== []) {
diff --git a/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php b/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php
index 61a8c8be1b..4f7486b336 100644
--- a/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php
+++ b/backend/app/Services/Domain/Event/EventOccurrenceGeneratorService.php
@@ -184,6 +184,21 @@ private function getOccurrenceIdsInUse(array $occurrenceIds): Collection
->distinct()
->pluck('event_occurrence_id');
- return $withOrderItems->merge($withAttendees)->unique()->values();
+ $withPriceOverrides = DB::table('product_price_occurrence_overrides')
+ ->whereIn('event_occurrence_id', $occurrenceIds)
+ ->distinct()
+ ->pluck('event_occurrence_id');
+
+ $withVisibility = DB::table('product_occurrence_visibility')
+ ->whereIn('event_occurrence_id', $occurrenceIds)
+ ->distinct()
+ ->pluck('event_occurrence_id');
+
+ return $withOrderItems
+ ->merge($withAttendees)
+ ->merge($withPriceOverrides)
+ ->merge($withVisibility)
+ ->unique()
+ ->values();
}
}
diff --git a/backend/app/Services/Domain/EventOccurrence/DTO/OccurrenceBookingLimitsDTO.php b/backend/app/Services/Domain/EventOccurrence/DTO/OccurrenceBookingLimitsDTO.php
new file mode 100644
index 0000000000..8cd5d6a6d4
--- /dev/null
+++ b/backend/app/Services/Domain/EventOccurrence/DTO/OccurrenceBookingLimitsDTO.php
@@ -0,0 +1,18 @@
+ $occurrences
+ */
+ public function attachTo(Collection $occurrences, int $eventId): void
+ {
+ if ($occurrences->isEmpty()) {
+ return;
+ }
+
+ $products = $this->productRepository
+ ->loadRelation(ProductPriceDomainObject::class)
+ ->findWhere(
+ where: [ProductDomainObjectAbstract::EVENT_ID => $eventId],
+ orderAndDirections: [
+ new OrderAndDirection(ProductDomainObjectAbstract::ORDER),
+ new OrderAndDirection(ProductDomainObjectAbstract::ID),
+ ],
+ );
+
+ $limits = $this->forOccurrences($occurrences, $products);
+
+ $occurrences->each(
+ fn (EventOccurrenceDomainObject $occurrence) => $occurrence->setBookingLimits($limits[$occurrence->getId()] ?? null)
+ );
+ }
+
+ /**
+ * @param Collection $occurrences
+ * @param Collection $products products with prices loaded
+ * @return array keyed by occurrence id
+ */
+ private function forOccurrences(Collection $occurrences, Collection $products): array
+ {
+ $occurrenceIds = $occurrences->map(fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId())->all();
+
+ $overrides = $occurrenceIds === []
+ ? collect()
+ : $this->overrideRepository->findWhereIn('event_occurrence_id', $occurrenceIds);
+
+ $overridesByOccurrence = $overrides->groupBy(
+ fn (ProductPriceOccurrenceOverrideDomainObject $override) => $override->getEventOccurrenceId()
+ );
+
+ $limits = [];
+ foreach ($occurrences as $occurrence) {
+ $limits[$occurrence->getId()] = $this->forOccurrence(
+ $occurrence,
+ $products,
+ $overridesByOccurrence->get($occurrence->getId(), collect())
+ ->keyBy(fn (ProductPriceOccurrenceOverrideDomainObject $override) => $override->getProductPriceId()),
+ );
+ }
+
+ return $limits;
+ }
+
+ private function forOccurrence(
+ EventOccurrenceDomainObject $occurrence,
+ Collection $products,
+ Collection $overridesByPrice,
+ ): OccurrenceBookingLimitsDTO {
+ $allocations = [];
+ $allocationTotal = 0;
+ $uncapped = false;
+
+ /** @var ProductDomainObject $product */
+ foreach ($products as $product) {
+ if ($product->getProductType() !== ProductType::TICKET->name) {
+ continue;
+ }
+
+ $prices = ($product->getProductPrices() ?? collect())
+ ->sortBy(fn (ProductPriceDomainObject $price) => $price->getOrder());
+
+ /** @var ProductPriceDomainObject $price */
+ foreach ($prices as $price) {
+ $quantity = $this->allocationForDate($price, $overridesByPrice->get($price->getId()));
+
+ if ($quantity === null) {
+ $uncapped = true;
+ } else {
+ $allocationTotal += $quantity;
+ }
+
+ $allocations[] = new OccurrenceTierAllocationDTO(
+ product_price_id: $price->getId(),
+ product_title: $product->getTitle(),
+ price_label: $price->getLabel(),
+ quantity: $quantity,
+ applies_to: $price->getQuantityAppliesTo(),
+ );
+ }
+ }
+
+ $allocationTotal = $uncapped || $allocations === [] ? null : $allocationTotal;
+ $capacity = $occurrence->getCapacity();
+ $limits = array_filter([$capacity, $allocationTotal], fn (?int $value) => $value !== null);
+
+ return new OccurrenceBookingLimitsDTO(
+ capacity: $capacity,
+ allocation_total: $allocationTotal,
+ sellable: $limits === [] ? null : min($limits),
+ allocations: $allocations,
+ );
+ }
+
+ private function allocationForDate(ProductPriceDomainObject $price, ?ProductPriceOccurrenceOverrideDomainObject $override): ?int
+ {
+ if (! $price->isQuantityPerOccurrence()) {
+ return null;
+ }
+
+ return $override?->getQuantityAvailable() ?? $price->getInitialQuantityAvailable();
+ }
+}
diff --git a/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php
index ed619bd276..8eed03bb76 100644
--- a/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php
+++ b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php
@@ -6,15 +6,15 @@
use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
-use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
+use HiEvents\Services\Domain\Product\SoldAndReservedQuantitiesService;
use Illuminate\Validation\ValidationException;
class OccurrencePurchaseEligibilityService
{
public function __construct(
private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
- private readonly OrderItemRepositoryInterface $orderItemRepository,
+ private readonly SoldAndReservedQuantitiesService $soldAndReservedQuantities,
private readonly ProductOccurrenceVisibilityRepositoryInterface $productOccurrenceVisibilityRepository,
) {}
@@ -76,7 +76,7 @@ public function assertOccurrencePurchasable(
if ($occurrence->getCapacity() !== null) {
$reservedForOccurrence = $reservedQuantity
- ?? $this->orderItemRepository->getReservedQuantityForOccurrence($occurrenceId);
+ ?? $this->soldAndReservedQuantities->getReservedTicketsForOccurrence($occurrenceId);
$available = $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence;
if ($additionalQuantity > $available) {
diff --git a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php
index deb583c503..5e9c61e1de 100644
--- a/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php
+++ b/backend/app/Services/Domain/Order/OrderCreateRequestValidationService.php
@@ -3,6 +3,7 @@
namespace HiEvents\Services\Domain\Order;
use Exception;
+use HiEvents\Constants;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\Enums\ProductPriceType;
use HiEvents\DomainObjects\Enums\ProductType;
@@ -378,6 +379,8 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro
throw new NotFoundHttpException(sprintf('Product ID %d not found', $productId));
}
+ $this->hydrateReservedQuantitiesAndMarkLockedTiers($product);
+
$this->validateProductEvent(
event: $event,
productId: $productId,
@@ -421,6 +424,27 @@ private function validateSingleProductDetails(EventDomainObject $event, int $pro
);
}
+ private function hydrateReservedQuantitiesAndMarkLockedTiers(ProductDomainObject $product): void
+ {
+ $quantitiesByPriceId = $this->availableProductQuantities
+ ->productQuantities
+ ->keyBy('price_id');
+
+ $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($quantitiesByPriceId) {
+ /** @var AvailableProductQuantitiesDTO|null $quantities */
+ $quantities = $quantitiesByPriceId->get($price->getId());
+ $price->setQuantityReserved($quantities?->quantity_reserved ?? 0);
+
+ if ($quantities !== null && $price->isQuantityPerOccurrence()) {
+ $price->setQuantityAvailable(
+ $quantities->quantity_available === Constants::INFINITE ? null : $quantities->quantity_available
+ );
+ }
+ });
+
+ $product->markLockedTiers();
+ }
+
/**
* @throws NotFoundHttpException
*/
@@ -585,6 +609,12 @@ private function validatePriceIdAndQuantity(int $productIndex, array $productAnd
}
$selectedPrice = $productPrices?->first(fn (ProductPriceDomainObject $price) => $price->getId() === $priceId);
+ if ((int) $quantity > 0 && $selectedPrice?->isLockedBehindEarlierTier()) {
+ $errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('This price is not on sale yet');
+
+ continue;
+ }
+
if ((int) $quantity > 0 && $this->isPriceUnavailable($selectedPrice)) {
$errors["products.$productIndex.quantities.$quantityIndex.price_id"] = __('Invalid price ID');
}
diff --git a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
index eb723fd492..fd7b6fb5e3 100644
--- a/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
+++ b/backend/app/Services/Domain/Product/AvailableProductQuantitiesFetchService.php
@@ -5,15 +5,16 @@
use HiEvents\Constants;
use HiEvents\DomainObjects\CapacityAssignmentDomainObject;
use HiEvents\DomainObjects\Enums\CapacityAssignmentAppliesTo;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
+use HiEvents\DomainObjects\ProductPriceOccurrenceOverrideDomainObject;
use HiEvents\DomainObjects\Status\CapacityAssignmentStatus;
-use HiEvents\DomainObjects\Status\OrderStatus;
use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
-use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface;
+use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
use Illuminate\Config\Repository as Config;
@@ -30,13 +31,15 @@ public function __construct(
private readonly CapacityAssignmentRepositoryInterface $capacityAssignmentRepository,
private readonly EventRepositoryInterface $eventRepository,
private readonly EventOccurrenceRepositoryInterface $occurrenceRepository,
- private readonly OrderItemRepositoryInterface $orderItemRepository,
+ private readonly ProductPriceOccurrenceOverrideRepositoryInterface $priceOverrideRepository,
+ private readonly SoldAndReservedQuantitiesService $soldAndReservedQuantities,
) {}
public function getAvailableProductQuantities(
int $eventId,
bool $ignoreCache = false,
?int $eventOccurrenceId = null,
+ bool $applyOccurrenceLimits = true,
): AvailableProductQuantitiesResponseDTO {
if (! $ignoreCache && $eventOccurrenceId === null && $this->config->get('app.homepage_product_quantities_cache_ttl')) {
$cachedData = $this->getDataFromCache($eventId);
@@ -61,7 +64,7 @@ public function getAvailableProductQuantities(
$productCapacities = $this->calculateProductCapacities($capacities);
- $reservedProductQuantities = $this->fetchReservedProductQuantities($eventId);
+ $reservedProductQuantities = $this->fetchProductQuantities($eventId);
$quantities = $reservedProductQuantities->map(function (AvailableProductQuantitiesDTO $dto) use ($productCapacities) {
$productId = $dto->product_id;
@@ -77,10 +80,17 @@ public function getAvailableProductQuantities(
$occurrenceReserved = null;
if ($eventOccurrenceId !== null) {
$occurrence = $this->occurrenceRepository->findById($eventOccurrenceId);
- if ($this->occurrenceLimitsCapacity($occurrence)) {
- $occurrenceReserved = $this->orderItemRepository->getReservedQuantityForOccurrence($eventOccurrenceId);
+ if ($isRecurring) {
+ $quantities = $this->applyPerOccurrenceQuantities($quantities, $eventId, $eventOccurrenceId);
}
- $quantities = $this->applyOccurrenceCapacity($quantities, $occurrence, $occurrenceReserved);
+ if ($applyOccurrenceLimits) {
+ if ($this->occurrenceLimitsCapacity($occurrence)) {
+ $occurrenceReserved = $this->soldAndReservedQuantities->getReservedTicketsForOccurrence($eventOccurrenceId);
+ }
+ $quantities = $this->applyOccurrenceCapacity($quantities, $occurrence, $occurrenceReserved);
+ }
+ } elseif ($isRecurring) {
+ $quantities = $this->ignorePerOccurrenceQuantities($quantities);
}
$finalData = new AvailableProductQuantitiesResponseDTO(
@@ -97,6 +107,64 @@ public function getAvailableProductQuantities(
return $finalData;
}
+ private function applyPerOccurrenceQuantities(Collection $quantities, int $eventId, int $eventOccurrenceId): Collection
+ {
+ $perOccurrenceRows = $quantities->filter(
+ fn (AvailableProductQuantitiesDTO $dto) => $dto->quantity_applies_to === ProductQuantityAppliesTo::OCCURRENCE->name
+ );
+
+ if ($perOccurrenceRows->isEmpty()) {
+ return $quantities;
+ }
+
+ $overrides = $this->priceOverrideRepository
+ ->findWhere([ProductPriceOccurrenceOverrideDomainObject::EVENT_OCCURRENCE_ID => $eventOccurrenceId])
+ ->keyBy(fn (ProductPriceOccurrenceOverrideDomainObject $override) => $override->getProductPriceId());
+
+ $reserved = $this->soldAndReservedQuantities->getReservedByPrice($eventId, $eventOccurrenceId);
+
+ $sold = [];
+ foreach ($perOccurrenceRows->pluck('product_type')->unique() as $productType) {
+ $sold[$productType] = $this->soldAndReservedQuantities->getSoldByPriceForOccurrence(
+ $eventOccurrenceId,
+ ProductType::fromName($productType),
+ );
+ }
+
+ return $quantities->map(function (AvailableProductQuantitiesDTO $dto) use ($overrides, $reserved, $sold) {
+ if ($dto->quantity_applies_to !== ProductQuantityAppliesTo::OCCURRENCE->name) {
+ return $dto;
+ }
+
+ /** @var ProductPriceOccurrenceOverrideDomainObject|null $override */
+ $override = $overrides->get($dto->price_id);
+ $cap = $override?->getQuantityAvailable() ?? $dto->initial_quantity_available;
+
+ if ($cap === null) {
+ $dto->quantity_available = Constants::INFINITE;
+ $dto->quantity_reserved = $reserved[$dto->price_id] ?? 0;
+
+ return $dto;
+ }
+
+ $dto->quantity_reserved = $reserved[$dto->price_id] ?? 0;
+ $dto->quantity_available = max(0, $cap - ($sold[$dto->product_type][$dto->price_id] ?? 0) - $dto->quantity_reserved);
+
+ return $dto;
+ });
+ }
+
+ private function ignorePerOccurrenceQuantities(Collection $quantities): Collection
+ {
+ return $quantities->map(function (AvailableProductQuantitiesDTO $dto) {
+ if ($dto->quantity_applies_to === ProductQuantityAppliesTo::OCCURRENCE->name) {
+ $dto->quantity_available = Constants::INFINITE;
+ }
+
+ return $dto;
+ });
+ }
+
private function occurrenceLimitsCapacity(?EventOccurrenceDomainObject $occurrence): bool
{
return $occurrence !== null
@@ -139,35 +207,9 @@ private function applyOccurrenceCapacity(
});
}
- private function fetchReservedProductQuantities(int $eventId): Collection
+ private function fetchProductQuantities(int $eventId): Collection
{
- $result = $this->db->select(<<<'SQL'
- WITH reserved_quantities AS (
- SELECT
- products.id AS product_id,
- product_prices.id AS product_price_id,
- SUM(
- CASE
- WHEN orders.status = :reserved
- AND orders.reserved_until > NOW()
- AND orders.deleted_at IS NULL
- THEN order_items.quantity
- ELSE 0
- END
- ) AS quantity_reserved
- FROM products
- JOIN product_prices ON products.id = product_prices.product_id
- LEFT JOIN order_items ON order_items.product_id = products.id
- AND order_items.product_price_id = product_prices.id
- LEFT JOIN orders ON orders.id = order_items.order_id
- AND orders.event_id = products.event_id
- AND orders.deleted_at IS NULL
- WHERE
- products.event_id = :eventId
- AND products.deleted_at IS NULL
- AND product_prices.deleted_at IS NULL
- GROUP BY products.id, product_prices.id
- )
+ $rows = $this->db->select(<<<'SQL'
SELECT
products.id AS product_id,
product_prices.id AS product_price_id,
@@ -176,43 +218,35 @@ private function fetchReservedProductQuantities(int $eventId): Collection
product_prices.label AS price_label,
product_prices.initial_quantity_available,
product_prices.quantity_sold,
- GREATEST(
- COALESCE(
- product_prices.initial_quantity_available
- - product_prices.quantity_sold
- - COALESCE(reserved_quantities.quantity_reserved, 0),
- 0),
- 0) AS quantity_available,
- COALESCE(reserved_quantities.quantity_reserved, 0) AS quantity_reserved,
- CASE WHEN product_prices.initial_quantity_available IS NULL
- THEN TRUE
- ELSE FALSE
- END AS unlimited_quantity_available
+ product_prices.quantity_applies_to
FROM products
JOIN product_prices ON products.id = product_prices.product_id
- LEFT JOIN reserved_quantities ON products.id = reserved_quantities.product_id
- AND product_prices.id = reserved_quantities.product_price_id
WHERE
products.event_id = :eventId
AND products.deleted_at IS NULL
AND product_prices.deleted_at IS NULL
- GROUP BY products.id, product_prices.id, reserved_quantities.quantity_reserved;
- SQL, [
- 'eventId' => $eventId,
- 'reserved' => OrderStatus::RESERVED->name,
- ]);
-
- return collect($result)->map(fn ($row) => AvailableProductQuantitiesDTO::fromArray([
- 'product_id' => $row->product_id,
- 'price_id' => $row->product_price_id,
- 'product_title' => $row->product_title,
- 'product_type' => $row->product_type,
- 'price_label' => $row->price_label,
- 'quantity_available' => $row->unlimited_quantity_available ? Constants::INFINITE : $row->quantity_available,
- 'initial_quantity_available' => $row->initial_quantity_available,
- 'quantity_reserved' => $row->quantity_reserved,
- 'capacities' => new Collection,
- ]));
+ SQL, ['eventId' => $eventId]);
+
+ $reserved = $this->soldAndReservedQuantities->getReservedByPrice($eventId);
+
+ return collect($rows)->map(function ($row) use ($reserved) {
+ $quantityReserved = $reserved[$row->product_price_id] ?? 0;
+
+ return AvailableProductQuantitiesDTO::fromArray([
+ 'product_id' => $row->product_id,
+ 'price_id' => $row->product_price_id,
+ 'product_title' => $row->product_title,
+ 'product_type' => $row->product_type,
+ 'quantity_applies_to' => $row->quantity_applies_to,
+ 'price_label' => $row->price_label,
+ 'quantity_available' => $row->initial_quantity_available === null
+ ? Constants::INFINITE
+ : max(0, $row->initial_quantity_available - $row->quantity_sold - $quantityReserved),
+ 'initial_quantity_available' => $row->initial_quantity_available,
+ 'quantity_reserved' => $quantityReserved,
+ 'capacities' => new Collection,
+ ]);
+ });
}
/**
diff --git a/backend/app/Services/Domain/Product/CreateProductService.php b/backend/app/Services/Domain/Product/CreateProductService.php
index 3a949c406a..e2cce2f0a7 100644
--- a/backend/app/Services/Domain/Product/CreateProductService.php
+++ b/backend/app/Services/Domain/Product/CreateProductService.php
@@ -94,6 +94,7 @@ private function persistProduct(ProductDomainObject $productsData): ProductDomai
'hide_before_sale_start_date' => $productsData->getHideBeforeSaleStartDate(),
'hide_after_sale_end_date' => $productsData->getHideAfterSaleEndDate(),
'hide_when_sold_out' => $productsData->getHideWhenSoldOut(),
+ 'sequential_tier_release_enabled' => $productsData->getSequentialTierReleaseEnabled(),
'show_quantity_remaining' => $productsData->getShowQuantityRemaining(),
'is_hidden_without_promo_code' => $productsData->getIsHiddenWithoutPromoCode(),
'event_id' => $productsData->getEventId(),
diff --git a/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php
index 3cdd198b53..47f8778dbe 100644
--- a/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesDTO.php
@@ -17,6 +17,7 @@ public function __construct(
public int $quantity_reserved,
public ?int $initial_quantity_available,
public string $product_type,
+ public string $quantity_applies_to = 'EVENT',
/** @var Collection */
public ?Collection $capacities = null,
) {}
diff --git a/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesResponseDTO.php b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesResponseDTO.php
index 4913e2c0bc..2aa73af015 100644
--- a/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesResponseDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/AvailableProductQuantitiesResponseDTO.php
@@ -17,4 +17,11 @@ public function __construct(
public ?EventOccurrenceDomainObject $occurrence = null,
public ?int $occurrenceReservedQuantity = null,
) {}
+
+ public function getAvailableQuantityForPrice(int $productPriceId): int
+ {
+ return $this->productQuantities
+ ->first(fn (AvailableProductQuantitiesDTO $dto) => $dto->price_id === $productPriceId)
+ ?->quantity_available ?? 0;
+ }
}
diff --git a/backend/app/Services/Domain/Product/DTO/ProductPriceDTO.php b/backend/app/Services/Domain/Product/DTO/ProductPriceDTO.php
index b2cd175a24..b724f7440f 100644
--- a/backend/app/Services/Domain/Product/DTO/ProductPriceDTO.php
+++ b/backend/app/Services/Domain/Product/DTO/ProductPriceDTO.php
@@ -3,6 +3,7 @@
namespace HiEvents\Services\Domain\Product\DTO;
use HiEvents\DataTransferObjects\BaseDTO;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\Status\ProductStatus;
class ProductPriceDTO extends BaseDTO
@@ -16,5 +17,6 @@ public function __construct(
public readonly ?bool $is_hidden = false,
public readonly ?int $id = null,
public readonly ProductStatus $status = ProductStatus::ACTIVE,
+ public readonly ?ProductQuantityAppliesTo $quantity_applies_to = null,
) {}
}
diff --git a/backend/app/Services/Domain/Product/ProductFilterService.php b/backend/app/Services/Domain/Product/ProductFilterService.php
index fe029663c0..fc99280d4f 100644
--- a/backend/app/Services/Domain/Product/ProductFilterService.php
+++ b/backend/app/Services/Domain/Product/ProductFilterService.php
@@ -176,12 +176,16 @@ private function processProduct(
});
}
- $product->getProductPrices()?->map(function (ProductPriceDomainObject $price) use ($productQuantities) {
- $availableQuantity = $productQuantities->where('price_id', $price->getId())->first()?->quantity_available;
+ $quantitiesByPriceId = $productQuantities->keyBy('price_id');
+
+ $product->getProductPrices()?->each(function (ProductPriceDomainObject $price) use ($quantitiesByPriceId) {
+ $priceQuantities = $quantitiesByPriceId->get($price->getId());
+ $availableQuantity = $priceQuantities?->quantity_available;
$availableQuantity = $availableQuantity === Constants::INFINITE ? null : $availableQuantity;
$price->setQuantityAvailable(
max($availableQuantity, 0)
);
+ $price->setQuantityReserved($priceQuantities?->quantity_reserved ?? 0);
});
$productQuantities->each(function (AvailableProductQuantitiesDTO $quantity) use ($product) {
@@ -321,11 +325,17 @@ private function filterProductPrice(
$hidden = true;
}
+ if ($price->isLockedBehindEarlierTier() && $price->getOffSaleReason() === null) {
+ $price->setOffSaleReason(__('Price is locked until earlier tiers sell out'));
+ }
+
return $hidden && $hideSoldOutProducts;
}
private function processProductPrices(ProductDomainObject $product, bool $hideSoldOutProducts = true): void
{
+ $product->markLockedTiers();
+
$product->setProductPrices(
$product->getProductPrices()
?->each(fn (ProductPriceDomainObject $price) => $this->processProductPrice($product, $price))
@@ -356,7 +366,8 @@ private function getPriceAvailability(ProductPriceDomainObject $price, ProductDo
return ! $price->isSoldOut()
&& ! $price->isBeforeSaleStartDate()
&& ! $price->isAfterSaleEndDate()
- && ! $price->getIsHidden();
+ && ! $price->getIsHidden()
+ && ! $price->isLockedBehindEarlierTier();
}
return ! $product->isSoldOut()
diff --git a/backend/app/Services/Domain/Product/ProductPriceCreateService.php b/backend/app/Services/Domain/Product/ProductPriceCreateService.php
index 8c8759e882..5956945429 100644
--- a/backend/app/Services/Domain/Product/ProductPriceCreateService.php
+++ b/backend/app/Services/Domain/Product/ProductPriceCreateService.php
@@ -2,6 +2,7 @@
namespace HiEvents\Services\Domain\Product;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\Helper\DateHelper;
@@ -30,6 +31,9 @@ public function createPrices(
? DateHelper::convertToUTC($price->getSaleEndDate(), $event->getTimezone())
: null,
'initial_quantity_available' => $price->getInitialQuantityAvailable(),
+ 'quantity_applies_to' => $event->isRecurring()
+ ? $price->getQuantityAppliesTo()
+ : ProductQuantityAppliesTo::EVENT->name,
'is_hidden' => $price->getIsHidden(),
'order' => $index + 1,
])));
diff --git a/backend/app/Services/Domain/Product/ProductPriceService.php b/backend/app/Services/Domain/Product/ProductPriceService.php
index 6b8a8f84dc..975b221e08 100644
--- a/backend/app/Services/Domain/Product/ProductPriceService.php
+++ b/backend/app/Services/Domain/Product/ProductPriceService.php
@@ -112,6 +112,6 @@ private function getOverridePrice(int $priceId, ?int $eventOccurrenceId): ?float
'product_price_id' => $priceId,
]);
- return $override === null ? null : (float) $override->getPrice();
+ return $override?->getPrice();
}
}
diff --git a/backend/app/Services/Domain/Product/ProductPriceUpdateService.php b/backend/app/Services/Domain/Product/ProductPriceUpdateService.php
index bb7db749ee..be8dc2abbb 100644
--- a/backend/app/Services/Domain/Product/ProductPriceUpdateService.php
+++ b/backend/app/Services/Domain/Product/ProductPriceUpdateService.php
@@ -3,6 +3,8 @@
namespace HiEvents\Services\Domain\Product;
use HiEvents\DomainObjects\Enums\ProductPriceType;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
@@ -18,6 +20,7 @@ class ProductPriceUpdateService
{
public function __construct(
private readonly ProductPriceRepository $productPriceRepository,
+ private readonly SoldAndReservedQuantitiesService $soldAndReservedQuantities,
) {}
/**
@@ -31,7 +34,9 @@ public function updatePrices(
Collection $existingPrices,
EventDomainObject $event,
): void {
- $this->validateQuantityAvailable($productsData->prices, $existingPrices);
+ $defaultAppliesTo = $event->isRecurring()
+ ? ProductQuantityAppliesTo::defaultFor($productsData->product_type)
+ : ProductQuantityAppliesTo::EVENT;
if ($productsData->type !== ProductPriceType::TIERED) {
$prices = new Collection([new ProductPriceDTO(
@@ -41,44 +46,39 @@ public function updatePrices(
sale_end_date: null,
initial_quantity_available: $productsData->prices->first()->initial_quantity_available,
id: $existingPrices->first()->getId(),
+ quantity_applies_to: $productsData->prices->first()->quantity_applies_to,
)]);
} else {
$prices = $productsData->prices;
}
+ $this->validateQuantityAvailable($prices, $existingPrices, $productsData->product_type, $event->isRecurring(), $defaultAppliesTo);
+
$order = 1;
foreach ($prices as $price) {
+ $attributes = [
+ 'product_id' => $product->getId(),
+ 'price' => $price->price,
+ 'label' => $price->label,
+ 'sale_start_date' => $price->sale_start_date
+ ? DateHelper::convertToUTC($price->sale_start_date, $event->getTimezone())
+ : null,
+ 'sale_end_date' => $price->sale_end_date
+ ? DateHelper::convertToUTC($price->sale_end_date, $event->getTimezone())
+ : null,
+ 'initial_quantity_available' => $price->initial_quantity_available,
+ 'quantity_applies_to' => $event->isRecurring()
+ ? ($price->quantity_applies_to ?? $defaultAppliesTo)->name
+ : ProductQuantityAppliesTo::EVENT->name,
+ 'is_hidden' => $price->is_hidden,
+ 'order' => $order++,
+ ];
+
if ($price->id === null) {
- $this->productPriceRepository->create([
- 'product_id' => $product->getId(),
- 'price' => $price->price,
- 'label' => $price->label,
- 'sale_start_date' => $price->sale_start_date
- ? DateHelper::convertToUTC($price->sale_start_date, $event->getTimezone())
- : null,
- 'sale_end_date' => $price->sale_end_date
- ? DateHelper::convertToUTC($price->sale_end_date, $event->getTimezone())
- : null,
- 'initial_quantity_available' => $price->initial_quantity_available,
- 'is_hidden' => $price->is_hidden,
- 'order' => $order++,
- ]);
+ $this->productPriceRepository->create($attributes);
} else {
- $this->productPriceRepository->updateWhere([
- 'product_id' => $product->getId(),
- 'price' => $price->price,
- 'label' => $price->label,
- 'sale_start_date' => $price->sale_start_date
- ? DateHelper::convertToUTC($price->sale_start_date, $event->getTimezone())
- : null,
- 'sale_end_date' => $price->sale_end_date
- ? DateHelper::convertToUTC($price->sale_end_date, $event->getTimezone())
- : null,
- 'initial_quantity_available' => $price->initial_quantity_available,
- 'is_hidden' => $price->is_hidden,
- 'order' => $order++,
- ], [
+ $this->productPriceRepository->updateWhere($attributes, [
'id' => $price->id,
]);
}
@@ -90,11 +90,25 @@ public function updatePrices(
/**
* @throws ValidationException
*/
- private function validateQuantityAvailable(?Collection $prices, Collection $existingPrices): void
- {
- if ($prices === null) {
- return;
- }
+ private function validateQuantityAvailable(
+ Collection $prices,
+ Collection $existingPrices,
+ ProductType $productType,
+ bool $isRecurring,
+ ProductQuantityAppliesTo $defaultAppliesTo,
+ ): void {
+ $perOccurrencePriceIds = $prices
+ ->filter(fn (ProductPriceDTO $price) => $isRecurring
+ && $price->id !== null
+ && $price->initial_quantity_available !== null
+ && ($price->quantity_applies_to ?? $defaultAppliesTo) === ProductQuantityAppliesTo::OCCURRENCE)
+ ->map(fn (ProductPriceDTO $price) => $price->id)
+ ->values()
+ ->all();
+
+ $maxSoldPerOccurrence = $perOccurrencePriceIds === []
+ ? []
+ : $this->soldAndReservedQuantities->getMaxSoldOnAnyOccurrenceByPrice($perOccurrencePriceIds, $productType);
foreach ($prices as $index => $price) {
if ($price->id === null || $price->initial_quantity_available === null) {
@@ -108,14 +122,28 @@ private function validateQuantityAvailable(?Collection $prices, Collection $exis
continue;
}
+ $priceLabel = $existingPrice->getLabel() ?: __('Default');
+
+ if (in_array($price->id, $perOccurrencePriceIds, true)) {
+ $sold = $maxSoldPerOccurrence[$price->id] ?? 0;
+
+ if ($price->initial_quantity_available < $sold) {
+ throw ValidationException::withMessages([
+ "prices.$index.initial_quantity_available" => __(
+ 'The quantity per date for :price cannot be less than the number already sold on a single date (:sold)',
+ ['price' => $priceLabel, 'sold' => $sold]
+ ),
+ ]);
+ }
+
+ continue;
+ }
+
if ($price->initial_quantity_available < $existingPrice->getQuantitySold()) {
throw ValidationException::withMessages([
"prices.$index.initial_quantity_available" => __(
'The available quantity for :price cannot be less than the number already sold (:sold)',
- [
- 'price' => $existingPrice->getLabel() ?: __('Default'),
- 'sold' => $existingPrice->getQuantitySold(),
- ]
+ ['price' => $priceLabel, 'sold' => $existingPrice->getQuantitySold()]
),
]);
}
@@ -125,9 +153,9 @@ private function validateQuantityAvailable(?Collection $prices, Collection $exis
/**
* @throws CannotDeleteEntityException
*/
- private function deletePrices(?Collection $prices, Collection $existingPrices): void
+ private function deletePrices(Collection $prices, Collection $existingPrices): void
{
- $pricesIds = $prices?->map(fn ($price) => $price->id)->toArray();
+ $pricesIds = $prices->map(fn ($price) => $price->id)->toArray();
$existingPrices->each(function (ProductPriceDomainObject $price) use ($pricesIds) {
if (in_array($price->getId(), $pricesIds, true)) {
diff --git a/backend/app/Services/Domain/Product/SoldAndReservedQuantitiesService.php b/backend/app/Services/Domain/Product/SoldAndReservedQuantitiesService.php
new file mode 100644
index 0000000000..480b7a2d0f
--- /dev/null
+++ b/backend/app/Services/Domain/Product/SoldAndReservedQuantitiesService.php
@@ -0,0 +1,48 @@
+ product_price_id => quantity held by unexpired reservations
+ */
+ public function getReservedByPrice(int $eventId, ?int $eventOccurrenceId = null): array
+ {
+ return $this->orderItemRepository->getReservedQuantitiesByPrice($eventId, $eventOccurrenceId);
+ }
+
+ public function getReservedTicketsForOccurrence(int $eventOccurrenceId): int
+ {
+ return $this->orderItemRepository->getReservedTicketQuantityForOccurrence($eventOccurrenceId);
+ }
+
+ /**
+ * @return array product_price_id => quantity sold on that occurrence
+ */
+ public function getSoldByPriceForOccurrence(int $eventOccurrenceId, ProductType $productType): array
+ {
+ return $productType === ProductType::TICKET
+ ? $this->attendeeRepository->getSoldQuantitiesByPriceForOccurrence($eventOccurrenceId)
+ : $this->orderItemRepository->getSoldQuantitiesByPriceForOccurrence($eventOccurrenceId);
+ }
+
+ /**
+ * @return array product_price_id => highest quantity sold on any single occurrence
+ */
+ public function getMaxSoldOnAnyOccurrenceByPrice(array $productPriceIds, ProductType $productType): array
+ {
+ return $productType === ProductType::TICKET
+ ? $this->attendeeRepository->getMaxSoldPerOccurrenceByPrice($productPriceIds)
+ : $this->orderItemRepository->getMaxSoldPerOccurrenceByPrice($productPriceIds);
+ }
+}
diff --git a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php
index 2e9ac91429..48ef14a1f7 100644
--- a/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php
+++ b/backend/app/Services/Infrastructure/HtmlPurifier/HtmlPurifierService.php
@@ -8,6 +8,14 @@
class HtmlPurifierService
{
+ private const ENCODED_LIQUID_TOKEN = '/%7B%7B((?:%20|%7C|[A-Za-z0-9_.\-])*)%7D%7D/i';
+
+ private const DECODABLE = [
+ '%20' => ' ',
+ '%7C' => '|',
+ '%7c' => '|',
+ ];
+
private HTMLPurifier_Config $config;
public function __construct(private readonly HTMLPurifier $htmlPurifier)
@@ -28,6 +36,10 @@ public function purify(?string $html): ?string
return null;
}
- return $this->htmlPurifier->purify($html, $this->config);
+ return preg_replace_callback(
+ self::ENCODED_LIQUID_TOKEN,
+ static fn (array $matches): string => '{{'.strtr($matches[1], self::DECODABLE).'}}',
+ $this->htmlPurifier->purify($html, $this->config),
+ );
}
}
diff --git a/backend/database/migrations/2026_09_12_000001_add_quantity_applies_to_to_product_prices.php b/backend/database/migrations/2026_09_12_000001_add_quantity_applies_to_to_product_prices.php
new file mode 100644
index 0000000000..a4775d2958
--- /dev/null
+++ b/backend/database/migrations/2026_09_12_000001_add_quantity_applies_to_to_product_prices.php
@@ -0,0 +1,42 @@
+string('quantity_applies_to', 20)
+ ->default(ProductQuantityAppliesTo::OCCURRENCE->name)
+ ->after('initial_quantity_available');
+ });
+
+ DB::table('product_prices')->update([
+ 'quantity_applies_to' => ProductQuantityAppliesTo::EVENT->name,
+ ]);
+
+ Schema::table('product_price_occurrence_overrides', function (Blueprint $table) {
+ $table->integer('quantity_available')->nullable()->after('price');
+ $table->decimal('price', 14, 2)->nullable()->change();
+ });
+ }
+
+ public function down(): void
+ {
+ DB::table('product_price_occurrence_overrides')->whereNull('price')->delete();
+
+ Schema::table('product_price_occurrence_overrides', function (Blueprint $table) {
+ $table->dropColumn('quantity_available');
+ $table->decimal('price', 14, 2)->nullable(false)->change();
+ });
+
+ Schema::table('product_prices', function (Blueprint $table) {
+ $table->dropColumn('quantity_applies_to');
+ });
+ }
+};
diff --git a/backend/database/migrations/2026_09_12_000002_add_product_price_id_index_to_attendees.php b/backend/database/migrations/2026_09_12_000002_add_product_price_id_index_to_attendees.php
new file mode 100644
index 0000000000..251b8cc4cf
--- /dev/null
+++ b/backend/database/migrations/2026_09_12_000002_add_product_price_id_index_to_attendees.php
@@ -0,0 +1,19 @@
+boolean('sequential_tier_release_enabled')->default(false);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('products', function (Blueprint $table) {
+ $table->dropColumn('sequential_tier_release_enabled');
+ });
+ }
+};
diff --git a/backend/lang/fi.json b/backend/lang/fi.json
new file mode 100644
index 0000000000..9a06255405
--- /dev/null
+++ b/backend/lang/fi.json
@@ -0,0 +1,921 @@
+{
+ "Older First": "Vanhempi ensin",
+ "Newer First": "Uusin ensin",
+ "Recently Updated First": "Viimeksi päivitetty ensin",
+ "Recently Updated Last": "Viimeksi päivitetty viimeisenä",
+ "First Name A-Z": "Etunimi A-Z",
+ "First Name Z-A": "Etunimi Ö-A",
+ "Last Name A-Z": "Sukunimi A-Z",
+ "Last Name Z-A": "Sukunimi Ö-A",
+ "Status A-Z": "Tila A-Z",
+ "Status Z-A": "Tila Z-A",
+ "Closest start date": "Lähin aloituspäivä",
+ "Furthest start date": "Myöhäisin alkamispäivä",
+ "Closest end date": "Lähin lopetuspäivä",
+ "Furthest end date": "Myöhäisin lopetuspäivä",
+ "Newest first": "Uusin",
+ "Oldest first": "Vanhin",
+ "Recently Updated": "Äskettäin päivitetty",
+ "Least Recently Updated": "Vanhin päivitys ensin",
+ "Oldest First": "Vanhin",
+ "Newest First": "Uusin",
+ "Buyer Name A-Z": "Asiakas A-Z",
+ "Buyer Name Z-A": "Asiakas Ö-A",
+ "Amount Ascending": "Summa nouseva",
+ "Amount Descending": "Summa laskeva",
+ "Buyer Email A-Z": "Asiakkaan sähköposti A-Z",
+ "Buyer Email Z-A": "Asiakkaan sähköposti Ö-A",
+ "Order # Ascending": "Tilausnumero nouseva",
+ "Order # Descending": "Tilausnumero laskeva",
+ "Code Name A-Z": "Koodinimi A-Z",
+ "Code Name Z-A": "Koodinimi Ö-A",
+ "Usage Count Ascending": "Käyttökertojen määrä nouseva",
+ "Usage Count Descending": "Käyttökertojen määrä laskeva",
+ "Homepage order": "Aloitussivun järjestys",
+ "Title A-Z": "Otsikko A-Z",
+ "Title Z-A": "Otsikko Ö-A",
+ "Sale start date closest": "Lähin myynnin alkamispäivä",
+ "Sale start date furthest": "Myynnin viimeisin alkamispäivä",
+ "Sale end date closest": "Lähin myynnin päättymispäivä",
+ "Sale end date furthest": "Myöhäisin myynnin päättymispäivä",
+ "Account registration is disabled": "Tilin rekisteröinti on poistettu käytöstä",
+ "The invitation has expired": "Kutsusi on vanhentunut",
+ "The invitation is invalid": "Kutsusi on virheellinen",
+ "Invitation valid, but user not found": "Kelvollinen kutsu, mutta käyttäjää ei löydy",
+ "No user found for this invitation. The invitation may have been revoked.": "Tälle kutsulle ei löytynyt käyttäjää. Kutsu on saatettu peruuttaa.",
+ "Logout Successful": "Uloskirjautuminen onnistui",
+ "Your password has been reset. Please login with your new password.": "Salasanasi on nollattu. Kirjaudu sisään uudella salasanallasi.",
+ "No account ID found in token": "Tunnuksesta ei löytynyt tilin numeroa.",
+ "Event with ID :eventId is not live and user is not authenticated": "Tapahtuma, jonka tunnus on :eventId, ei ole julkaistu eikä käyttäjää ole todennettu",
+ "Sorry, we could not verify your session. Please restart your order.": "Emme voineet vahvistaa istuntoasi. Aloita tilauksesi uudelleen.",
+ "The email confirmation link has expired. Please request a new one.": "Sähköpostivahvistus on vanhentunut. Pyydä uusi.",
+ "The email confirmation link is invalid.": "Sähköpostivahvistuslinkki on virheellinen.",
+ "No invitation found for this user.": "Tälle käyttäjälle ei löytynyt kutsua.",
+ "User status is not Invited": "Käyttäjän tila ei ole Kutsuttu",
+ "Email is required": "Sähköposti on pakollinen",
+ "Email must be a valid email address": "Sähköpostin on oltava kelvollinen sähköpostiosoite",
+ "First name is required": "Etunimi on pakollinen",
+ "Last name is required": "Sukunimi on pakollinen",
+ "Ticket is required": "Lippu on pakollinen",
+ "Ticket price is required": "Lipun hinta on pakollinen",
+ "Please enter a valid hex color code. In the format #000000 or #000.": "Syötä kelvollinen hex-värikoodi. Muodossa #000000 tai #000.",
+ "The maximum timeout is 2 hours.": "Maksimi aikaraja on 2 tuntia.",
+ "The address line 1 field is required": "Osoiterivi 1 on pakollinen",
+ "The city field is required": "Kaupunkikenttä on pakollinen",
+ "The zip or postal code field is required": "Postinumerokenttä on pakollinen",
+ "The country field is required": "Maakenttä on pakollinen",
+ "The country field should be a 2 character ISO 3166 code": "Maakentän on oltava 2-merkkinen ISO 3166 -koodi",
+ "Please select at least one ticket.": "Valitse vähintään yksi lippu.",
+ "The sale end date must be after the sale start date.": "Myynnin päättymispäivän on oltava myynnin alkamispäivän jälkeen.",
+ "The sale end date must be a valid date.": "Myynnin päättymispäivän on oltava kelvollinen päivämäärä.",
+ "The sale start date must be after the ticket sale start date.": "Myynnin alkamispäivän on oltava lipunmyynnin alkamispäivän jälkeen.",
+ "Welcome to :app_name! Please confirm your email address": "Tervetuloa :app_name-palveluun! Vahvista sähköpostiosoitteesi",
+ "🎟️ Your Ticket for :event": "🎟️ Lippusi tapahtumaan :event",
+ "Your order has been cancelled": "Tilauksesi on peruutettu",
+ "Your order wasn\\'t successful": "Tilauksesi epäonnistui",
+ "You\\'ve received a refund": "Olet saanut hyvityksen",
+ "Your Order is Confirmed!": "Tilauksesi on vahvistettu!",
+ "We were unable to process your order": "Emme voineet käsitellä tilaustasi",
+ "New order for :amount for :event 🎉": "Uusi tilaus arvolla :amount tapahtumaan :event 🎉",
+ "New order for :event 🎉": "Uusi tilaus tapahtumaan :event 🎉",
+ "Confirm email change": "Vahvista sähköpostin muutos",
+ "Password reset": "Palauta salasana",
+ "Your password has been reset": "Salasanasi on nollattu",
+ "You\\'ve been invited to join :appName": "Sinut on kutsuttu liittymään :appNameen",
+ "Current account ID is not set": "Tilitunnustasi ei ole asetettu",
+ "User not found in this account": "Käyttäjää ei löydy tästä tilistä",
+ "User not found": "Käyttäjää ei löydy",
+ "Username or Password are incorrect": "Käyttäjätunnus tai salasana on väärä",
+ "Account not found": "Tiliä ei löydy",
+ "Attempt to log in to a non-active account": "Yritä kirjautua ei-aktiiviselle tilille",
+ "User account is not active": "Käyttäjätili ei ole aktiivinen",
+ "Invalid reset token": "Virheellinen palautustunnus",
+ "Reset token has expired": "Palautustunnus on vanhentunut",
+ "Event daily statistics updated for event :event_id with total refunded amount of :amount": "Päivittäiset tilastot kohteelle :event_id päivitetty hyvitetyllä kokonaissummalla :amount",
+ "Event statistics updated for event :event_id with total refunded amount of :amount": "Tilastot kohteelle :event_id päivitettiin hyvitetyllä kokonaissummalla :amount",
+ "This promo code is invalid": "Tämä tarjouskoodi on virheellinen",
+ "You haven\\'t selected any tickets": "Et ole valinnut yhtään lippua",
+ "The maximum number of tickets available for :tickets is :max": "Lippujen enimmäismäärä kohteelle :tickets on :max",
+ "You must order at least :min tickets for :ticket": "Sinun on tilattava vähintään :min lippua lipulle :ticket",
+ "The minimum amount is :price": "Vähimmäismäärä on :price",
+ "The ticket :ticket is sold out": "Lippu :ticket on loppuunmyyty",
+ ":field must be specified": ":field on määritettävä",
+ "Invalid price ID": "Virheellinen hintatunnus",
+ "The maximum number of tickets available for :ticket is :max": "Lippujen enimmäismäärä :ticketille on :max",
+ "Ticket with id :id not found": "Lippua tunnuksella :id ei löytynyt",
+ "Failed to refund stripe charge": "Stripe-maksun palautus epäonnistui",
+ "Payment was successful, but order has expired. Order: :id": "Maksu onnistui, mutta tilaus on vanhentunut. Tilaus: :id",
+ "Order is not awaiting payment. Order: :id": "Tilaus ei odota maksua. Tilaus: :id",
+ "There was an error communicating with the payment provider. Please try again later.": "Virhe yhteydenpidossa maksupalveluntarjoajan kanssa. Yritä myöhemmin uudelleen.",
+ "Stripe Connect account not found for the event organizer": "Tapahtuman järjestäjälle ei löytynyt Stripe Connect -tiliä",
+ "Cannot Refund: Stripe connect account not found and saas_mode_enabled is enabled": "Hyvitys ei onnistu: Stripe Connect -tiliä ei löydy ja saas_mode_enabled on käytössä",
+ "Invalid calculation type": "Virheellinen laskentatyyppi",
+ "One or more tax IDs are invalid": "Yksi tai useampi verotunnus on virheellinen",
+ "Invalid ticket ids: :ids": "Virheelliset lipputunnukset: :ids",
+ "Cannot delete ticket price with id :id because it has sales": "Lipun hintaa ei voi poistaa tunnuksella :id, koska se on myynyt liput",
+ "Order has no order items": "Tilauksessa ei ole tilausrivejä",
+ "There is already an account associated with this email. Please log in instead.": "Tähän sähköpostiin on jo liitetty tili. Ole hyvä ja kirjaudu sisään.",
+ "Stripe Connect Account creation is only available in Saas Mode.": "Stripe Connect -tilin luominen on käytettävissä vain Saas-tilassa.",
+ "There are issues with creating or fetching the Stripe Connect Account. Please try again.": "Stripe Connect -tilin luomisessa tai noutamisessa oli ongelma. Yritä uudelleen.",
+ "There are issues with creating the Stripe Connect Account Link. Please try again.": "Stripe Connect -tilin linkin luomisessa oli ongelma. Yritä uudelleen.",
+ "Cannot check in attendee as they are not active.": "Osallistujaa ei voi kirjata sisään, koska hän ei ole aktiivinen.",
+ "in": "sisään",
+ "out": "ulos",
+ "There are no tickets available. ' .\n 'If you would like to assign a ticket to this attendee,' .\n ' please adjust the ticket\\'s available quantity.": "Lippuja ei ole saatavilla. ' .\n Jos haluat määrittää lipun tälle osallistujalle,' .\n ' muuta lipun käytettävissä olevaa numeroa.",
+ "The ticket price ID is invalid.": "Hintatunnus on virheellinen.",
+ "Ticket ID is not valid": "Lipputunnus ei kelpaa",
+ "There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\\'s available quantity.": "Lippuja ei ole saatavilla. Jos haluat määrittää tämän lipun tälle osallistujalle, muuta lipun käytettävissä olevaa numeroa.",
+ "Attendee ID is not valid": "Osallistujatunnus ei kelpaa",
+ "The invitation does not exist": "Kutsua ei ole olemassa",
+ "The invitation has already been accepted": "Kutsu on jo hyväksytty",
+ "Organizer :id not found": "Järjestäjää :id ei löytynyt",
+ "Continue": "Jatka",
+ "Event :id not found": "Tapahtumaa :id ei löytynyt",
+ "You cannot change the currency of an event that has completed orders": "Et voi muuttaa sellaisen tapahtuman valuuttaa, joka on suorittanut tilauksia",
+ "You must verify your account before you can update an event\\'s status.\n You can resend the confirmation by visiting your profile page.": "Sinun on vahvistettava tilisi ennen kuin voit päivittää tapahtuman tilan.\n Voit lähettää vahvistuksen uudelleen käymällä profiilisivullasi.",
+ "You cannot send messages until your account is verified.": "Et voi lähettää viestejä ennen kuin tilisi on vahvistettu.",
+ "Order not found": "Tilausta ei löydy",
+ "Order already cancelled": "Tilaus on jo peruutettu",
+ "Failed to create attendee": "Osallistujan luominen epäonnistui",
+ "This order is has already been processed": "Tämä tilaus on jo käsitelty",
+ "This order has expired": "Tämä tilaus on vanhentunut",
+ "This order has already been processed": "Tämä tilaus on jo käsitelty",
+ "There is an unexpected ticket price ID in the order": "Tilauksessa on odottamaton hintatunnus",
+ "This event is not live.": "Tätä tapahtumaa ei ole julkaistu.",
+ "Sorry, we could not verify your session. Please create a new order.": "Valitettavasti emme voineet vahvistaa istuntoasi. Luo uusi tilaus.",
+ "There is no Stripe data associated with this order.": "Tähän tilaukseen ei liity Stripe-tietoja.",
+ "There is already a refund pending for this order.\n Please wait for the refund to be processed before requesting another one.": "Tälle tilaukselle on jo hyvitys odottamassa.\n Odota hyvityksen käsittelyä ennen kuin pyydät uutta.",
+ "Promo code :code already exists": "Tarjouskoodi :code on jo olemassa",
+ "The code :code is already in use for this event": "Koodi :code on jo käytössä tässä tapahtumassa",
+ "You cannot delete this question as there as answers associated with it. You can hide the question instead.": "Et voi poistaa tätä kysymystä, koska siihen liittyy vastauksia. Sen sijaan voit piilottaa sen.",
+ "One or more of the ordered question IDs do not exist for the event.": "Yhtä tai useampaa tilatuista kyselytunnuksista ei ole tapahtumassa.",
+ "You cannot delete this ticket because it has orders associated with it. You can hide it instead.": "Et voi poistaa tätä lippua, koska siihen liittyy tilauksia. Sen sijaan voit piilottaa sen.",
+ "Ticket type cannot be changed as tickets have been registered for this type": "Lipputyyppiä ei voi muuttaa, koska tälle tyypille on rekisteröity lippuja",
+ "The ordered ticket IDs must exactly match all tickets for the event without missing or extra IDs.": "Tilattujen lipputunnusten tulee vastata täsmälleen kaikkia tapahtuman lippuja ilman puuttuvia tai ylimääräisiä tunnuksia.",
+ "No email change pending": "Sähköpostin muutos ei ole käynnissä",
+ "The email :email already exists on this account": "Sähköpostiosoite :email on jo olemassa tällä tilillä",
+ "You are not authorized to perform this action.": "Sinulla ei ole valtuuksia suorittaa tätä toimintoa.",
+ "Your account is not active.": "Tilisi ei ole aktiivinen.",
+ "Payload has expired or is invalid.": "Hyötykuorma on vanhentunut tai virheellinen.",
+ "Payload could not be decrypted.": "Hyötykuorman salausta ei voitu purkaa.",
+ "Could not upload image to :disk. Check :disk is configured correctly": "Kuvaa ei voitu ladata levylle :disk. Tarkista, että :disk on määritetty oikein.",
+ "Could not upload image": "Kuvaa ei voitu ladata",
+ "Length must be a positive integer.": "Pituuden on oltava positiivinen kokonaisluku.",
+ "Prefix length exceeds the total desired token length.": "Etuliitepituus ylittää halutun tunnuksen kokonaispituuden.",
+ "A valid email is required": "Kelvollinen sähköpostiosoite vaaditaan",
+ "The title field is required": "Otsikkokenttä on pakollinen",
+ "The attribute name is required": "Attribuutin nimi on pakollinen",
+ "The attribute value is required": "Attribuutin arvo vaaditaan",
+ "The attribute is_public fields is required": "Attribuutti on_julkinen -kenttä on pakollinen",
+ "Required questions have not been answered. You may need to reload the page.": "Pakollisiin kysymyksiin ei ole vastattu. Sinun on ehkä ladattava sivu uudelleen.",
+ "This question is outdated. Please reload the page.": "Tämä kysymys on vanhentunut. Lataa sivu uudelleen.",
+ "Please select an option": "Valitse vaihtoehto",
+ "This field is required.": "Tämä kenttä on pakollinen.",
+ "This field must be less than 255 characters.": "Tämän kentän tulee olla alle 255 merkkiä pitkä.",
+ "This field must be at least 2 characters.": "Tässä kentässä on oltava vähintään 2 merkkiä.",
+ "Hello": "Hei",
+ "You have requested to reset your password for your account on :appName.": "Olet pyytänyt tilisi salasanan vaihtamista osoitteessa :appName.",
+ "Please click the link below to reset your password.": "Napsauta alla olevaa linkkiä vaihtaaksesi salasanasi.",
+ "Reset Password": "Palauta salasana",
+ "If you did not request a password reset, please ignore this email or reply to let us know.": "Jos et pyytänyt salasanan vaihtoa, jätä tämä sähköposti huomiotta tai kerro meille vastaamalla.",
+ "Thank you": "Kiitos",
+ "Your password has been reset for your account on :appName.": "Salasanasi on uusittu osoitteessa :appName.",
+ "If you did not request a password reset, please immediately contact reset your password.": "Jos et pyytänyt salasanan vaihtoa, ota meihin välittömästi yhteyttä salasanan vaihtamiseksi.",
+ "You are receiving this communication because you are registered as an attendee for the following event:": "Saat tämän viestin, koska olet rekisteröitynyt osallistujaksi seuraaviin tapahtumiin:",
+ "If you believe you have received this email in error,": "Jos uskot saavasi tämän sähköpostin erehdyksessä,",
+ "please contact the event organizer at": "ota yhteyttä tapahtuman järjestäjään p",
+ "If you believe this is spam, please report it to": "Jos tämä on mielestäsi roskapostia, ilmoita siitä",
+ "You\\'re going to": "Sinun pitäisi jatkaa",
+ "Please find your ticket details below.": "Löydät lippusi tiedot alta.",
+ "View Ticket": "Näytä lippu",
+ "If you have any questions or need assistance, please reply to this email or contact the event organizer": "Jos sinulla on kysyttävää tai tarvitset apua, vastaa tähän sähköpostiin tai ota yhteyttä tapahtuman järjestäjään",
+ "at": "päällä",
+ "Best regards,": "Ystävällisin terveisin",
+ "Your order for": "Tilauksesi kohteelle",
+ "has been cancelled.": "on peruutettu.",
+ "Order #:": "Tilausnumero:",
+ "If you have any questions or need assistance, please respond to this email.": "Jos sinulla on kysyttävää tai tarvitset apua, vastaa tähän sähköpostiin.",
+ "Your recent order for": "Viimeinen tilauksesi",
+ "was not successful.": "epäonnistunut.",
+ "View Event Homepage": "Katso tapahtuman kotisivu",
+ "If you have any questions or need assistance, feel free to reach out to our support team": "Jos sinulla on kysyttävää tai tarvitset apua, älä epäröi ottaa yhteyttä tukitiimiimme",
+ "Best regards": "Ystävällisin terveisin",
+ "You have received a refund of :refundAmount for the following event: :eventTitle.": "Olet saanut hyvityksen :refundAmount seuraavasta tapahtumasta: :eventTitle.",
+ "You\\'ve received a new order!": "Olet saanut uuden tilauksen!",
+ "Congratulations! You\\'ve received a new order for ": "Onnittelut! Olet saanut uuden tilauksen kohteelle ",
+ "Please find the details below.": "Yksityiskohdat löydät alta.",
+ "Order Amount:": "Tilauksen määrä:",
+ "Order ID:": "Tilaustunnus:",
+ "View Order": "Näytä tilaukset",
+ "Ticket": "Lippu",
+ "Price": "Hinta",
+ "Total": "Yhteensä",
+ "Your recent order for :eventTitle was not successful. The order expired while you were completing the payment. We have issued a refund for the order.": "Viimeisin tilauksesi tapahtumaan :eventTitle epäonnistui. Tilaus vanheni, kun suoritit maksua. Olemme hyvittäneet tilauksesta.",
+ "We apologize for the inconvenience. If you have any questions or need assistance, feel free to reach us at": "Pahoittelemme häiriötä. Jos sinulla on kysyttävää tai tarvitset apua, älä epäröi ottaa meihin yhteyttä osoitteessa",
+ "View Event Page": "Näytä tapahtumasivu",
+ "Hi.Events": "Hei Tapahtumat",
+ "Your Order is Confirmed! ": "Tilauksesi on vahvistettu! ",
+ "Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.": "Onnittelut! Tilauksesi tapahtumalle :eventTitle :eventDate klo :eventTime onnistui. Löydät tilauksesi tiedot alta.",
+ "Event Details": "Tapahtuman tiedot",
+ "Event Name:": "Tapahtuman nimi:",
+ "Date & Time:": "Päivämäärä ja aika:",
+ "Order Summary": "Tilauksen yleiskatsaus",
+ "Order Number:": "Tilausnumero:",
+ "Total Amount:": "Kokonaismäärä:",
+ "View Order Summary & Tickets": "Näytä tilauskatsaus ja liput",
+ "If you have any questions or need assistance, feel free to reach out to our friendly support team at": "Jos sinulla on kysyttävää tai tarvitset apua, älä epäröi ottaa yhteyttä ystävälliseen tukitiimiimme osoitteessa",
+ "What\\'s Next?": "Mitä seuraavaksi tapahtuu?",
+ "Download Tickets:": "Lataa liput:",
+ "Please download your tickets from the order summary page.": "Lataa lippusi tilausyhteenvetosivulta.",
+ "Prepare for the Event:": "Valmistaudu tapahtumaan:",
+ "Make sure to note the event date, time, and location.": "Muista merkitä tapahtuman päivämäärä, aika ja paikka.",
+ "Stay Updated:": "Pysy ajan tasalla:",
+ "Keep an eye on your email for any updates from the event organizer.": "Seuraa sähköpostisi mahdollisia päivityksiä tapahtuman järjestäjältä.",
+ "Hi :name": "Hei :name",
+ "Welcome to :appName! We\\'re excited to have you aboard!": "Tervetuloa :appNameen! Olemme iloisia saadessamme sinut mukaan!",
+ "To get started and activate your account, please click the link below to confirm your email address:": "Aloita ja aktivoi tilisi napsauttamalla alla olevaa linkkiä vahvistaaksesi sähköpostiosoitteesi:",
+ "Confirm Your Email": "Vahvista sähköpostiosoitteesi",
+ "If you did not create an account with us, no further action is required. Your email address will not be used without confirmation.": "Jos et luonut tiliä meille, lisätoimia ei tarvita. Sähköpostiosoitettasi ei käytetä ilman vahvistusta.",
+ "Best Regards,": "Ystävällisin terveisin",
+ "The :appName Team": "Tiimi osoitteessa :appName",
+ "You have requested to change your email address to :pendingEmail. Please click the link below to confirm this change.": "Olet pyytänyt vaihtamaan sähköpostiosoitteesi muotoon :pendingEmail. Napsauta alla olevaa linkkiä vahvistaaksesi tämän muutoksen.",
+ "If you did not request this change, please immediately change your password.": "Jos et pyytänyt tätä muutosta, vaihda salasanasi välittömästi.",
+ "Thanks,": "Kiitos,",
+ "You\\'ve been invited to join :appName.": "Sinut on kutsuttu liittymään :appNameen.",
+ "To accept the invitation, please click the link below:": "Hyväksy kutsu napsauttamalla alla olevaa linkkiä:",
+ "Accept Invitation": "Hyväksy kutsu",
+ "All rights reserved.": "Kaikki oikeudet pidätetään.",
+ "Congratulations 🎉": "Onnittelut 🎉",
+ "Sent Date Oldest": "Lähetyspäivä vanhin",
+ "Sent Date Newest": "Lähetyspäivä Uusin",
+ "Subject A-Z": "Aihe A-Z",
+ "Subject Z-A": "Aihe Ö-A",
+ "Name A-Z": "Nimi A-Z",
+ "Name Z-A": "Nimi Ö-A",
+ "Updated oldest first": "Päivitetty ensin vanhin",
+ "Updated newest first": "Uusin päivitetty ensin",
+ "Most capacity used": "Eniten käytetty kapasiteetti",
+ "Least capacity used": "Pienin käytetty kapasiteetti",
+ "Least capacity": "Vähimmäiskapasiteetti",
+ "Most capacity": "Suurin kapasiteetti",
+ "Sorry, these tickets are sold out": "Valitettavasti nämä liput on myyty loppuun",
+ "The maximum number of tickets available is :max": "Saatavilla olevien lippujen enimmäismäärä on:max",
+ "Ticket is hidden without promo code": "Lippu on piilotettu ilman tarjouskoodia",
+ "Ticket is sold out": "Lippu on myyty loppuun",
+ "Ticket is before sale start date": "Lippu on ennen myynnin alkamispäivää",
+ "Ticket is after sale end date": "Lippu on myynnin päättymispäivän jälkeen",
+ "Ticket is hidden": "Lippu on piilotettu",
+ "Price is before sale start date": "Hinta on ennen myynnin alkamispäivää",
+ "Price is after sale end date": "Hinta on myynnin päättymispäivän jälkeen",
+ "Price is sold out": "Hinta on loppuunmyyty",
+ "Price is hidden": "Hinta on piilotettu",
+ "Expires soonest": "Vanhenee pian",
+ "Expires latest": "Vanhenee viimeistään",
+ "The expiration date must be after the activation date.": "Viimeisen voimassaolopäivän on oltava aktivointipäivän jälkeinen.",
+ "The activation date must be before the expiration date.": "Aktivointipäivän on oltava ennen viimeistä voimassaolopäivää.",
+ "Attendee :attendee_name is not allowed to check in using this check-in list": "Osallistuja :attendee_name ei saa kirjautua sisään tämän sisäänkirjautumisluettelon avulla",
+ "Invalid attendee code detected: :attendees ": "Virheellinen osallistujakoodi havaittu: :attendees ",
+ "Check-in list not found": "Sisäänkirjautumislistaa ei löydy",
+ "Attendee :attendee_name is already checked in": "Osallistuja :attendee_name on jo kirjautunut sisään",
+ "Check-in list has expired": "Sisäänkirjautumislista on vanhentunut",
+ "Check-in list is not active yes": "Sisäänkirjautumislista ei ole vielä aktiivinen",
+ "This attendee is not checked in": "Tämä osallistuja ei ole kirjautunut sisään",
+ "Attendee does not belong to this check-in list": "Osallistuja ei kuulu tähän sisäänkirjautumislistaan",
+ "Attendee :attendee_name\\'s ticket is cancelled": "Osallistujan :attendee_name lippu on peruutettu",
+ "Check-in list is not active yet": "Sisäänkirjautumislista ei ole vielä aktiivinen",
+ "The number of attendees does not match the number of tickets in the order": "Osallistujamäärä ei vastaa tilauksessa olevien lippujen määrää",
+ "Product is required": "Tuote on pakollinen.",
+ "Product price is required": "Tuotteen hinta on pakollinen.",
+ "Please select at least one product.": "Valitse vähintään yksi tuote.",
+ "The sale start date must be after the product sale start date.": "Kampanjan aloituspäivän on oltava tuotteen myynnin alkamispäivän jälkeen.",
+ "You must select a product category.": "Sinun on valittava tuoteluokka.",
+ "Invalid direction. Must be either asc or desc": "Virheellinen suunta. Täytyy olla joko nouseva tai laskeva",
+ "DomainObject must be a valid :interface.": "DomainObjectin on oltava kelvollinen :interface.",
+ "Nested relationships must be an array of Relationship objects.": "Sisäkkäisten suhteiden on oltava joukko Relationship-objekteja.",
+ "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirectionsin on oltava OrderAndDirection-objektien joukko.",
+ "Attendee :attendee_name\\'s product is cancelled": "Osallistujan :attendee_name tuote on peruutettu",
+ "Tickets": "Liput",
+ "There are no tickets available for this event.": "Tähän tapahtumaan ei ole lippuja saatavilla.",
+ "You haven\\'t selected any products": "Et ole valinnut yhtään tuotetta",
+ "The maximum number of products available for :products is :max": "Tuotteelle :products saatavilla olevien tuotteiden enimmäismäärä on :max",
+ "You must order at least :min products for :product": "Sinun tulee tilata vähintään :min tuotteita :product",
+ "The product :product is sold out": "Tuote :product on loppuunmyyty",
+ "The maximum number of products available for :product is :max": "Tuotteelle :product saatavilla olevien tuotteiden enimmäismäärä on :max",
+ "Sorry, these products are sold out": "Nämä tuotteet ovat valitettavasti loppuunmyytyjä",
+ "The maximum number of products available is :max": "Saatavilla olevien tuotteiden enimmäismäärä on:max",
+ "Product with id :id not found": "Tuotetta tunnuksella :id ei löydy",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Et voi poistaa tätä tuotetta, koska siihen liittyy tilauksia tai koska se on varattu meneillään olevaan ostoon. Sen sijaan voit piilottaa sen.",
+ "Invalid product ids: :ids": "Virheelliset tuotetunnukset: :ids",
+ "Product is hidden without promo code": "Tuote on piilotettu ilman tarjouskoodia.",
+ "Product is sold out": "Tuote on loppuunmyyty.",
+ "Product is before sale start date": "Tuote on ennen myynnin alkamispäivää.",
+ "Product is after sale end date": "Tuote on viimeinen myyntipäivä.",
+ "Product is hidden": "Tuote on piilotettu.",
+ "Cannot delete product price with id :id because it has sales": "Tuotteen hintaa ei voi poistaa tunnuksella :id, koska se on myynyt tuotteita",
+ "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Et voi poistaa tätä tuoteluokkaa, koska se sisältää seuraavat tuotteet: :products. Nämä tuotteet on linkitetty olemassa oleviin tilauksiin. Siirrä :product_name toiseen luokkaan ennen kuin yrität poistaa luokkaa.",
+ "products": "Tuotteet",
+ "product": "Tuote",
+ "Product category :productCategoryId has been deleted.": "Tuoteluokka :productCategoryId on poistettu.",
+ "You cannot delete the last product category. Please create another category before deleting this one.": "Et voi poistaa viimeistä tuoteluokkaa. Luo toinen luokka ennen tämän poistamista.",
+ "The product category with ID :id was not found.": "Tuoteluokkaa, jonka tunnus on :id, ei löytynyt.",
+ "Expired": "Vanhentunut",
+ "Limit Reached": "Raja saavutettu",
+ "Deleted": "Poistettu",
+ "Active": "Aktiivinen",
+ "This ticket is invalid": "Tämä lippu on virheellinen",
+ "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Lippuja ei ole saatavilla. Jos haluat määrittää tuotteen tälle osallistujalle, säädä tuotteen käytettävissä olevaa määrää.",
+ "The product price ID is invalid.": "Tuotteen hintatunnus on virheellinen.",
+ "Product ID is not valid": "Tuotetunnus ei kelpaa",
+ "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Tuotteita ei ole saatavilla. Jos haluat määrittää tämän tuotteen tälle osallistujalle, säädä tuotteen käytettävissä olevaa määrää.",
+ "There is an unexpected product price ID in the order": "Tilauksessa on odottamaton tuotteen hintatunnus",
+ "Product type cannot be changed as products have been registered for this type": "Tuotetyyppiä ei voi muuttaa, koska tuotteet on rekisteröity tälle tyypille",
+ "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Tilausluokkatunnusten on vastattava tarkasti tapahtuman kaikkia luokkia ilman puuttuvia tai ylimääräisiä tunnuksia.",
+ "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Tilattujen tuotetunnusten on vastattava tarkasti kaikkia tapahtuman tuotteita ilman puuttuvia tai ylimääräisiä tunnuksia.",
+ "This product is outdated. Please reload the page.": "Tämä tuote on vanhentunut. Lataa sivu uudelleen.",
+ "Reserved": "Varattu",
+ "Cancelled": "Peruutettu",
+ "Completed": "Valmis",
+ "Awaiting offline payment": "Odotetaan offline-maksua",
+ "ID": "ID",
+ "First Name": "Etunimi",
+ "Last Name": "Sukunimi",
+ "Email": "Sähköposti",
+ "Status": "Tila",
+ "Is Checked In": "On kirjattu sisään",
+ "Checked In At": "Kirjautunut sisään klo",
+ "Product ID": "Tuotetunnus",
+ "Product Name": "Tuotteen nimi",
+ "Event ID": "Tapahtuman tunnus",
+ "Public ID": "Julkinen tunnus",
+ "Short ID": "Kortin tunnus",
+ "Created Date": "Luomispäivämäärä",
+ "Last Updated Date": "Viimeisin päivityspäivä",
+ "Notes": "Muistiinpanot",
+ "Total Before Additions": "Yhteensä ennen lisäyksiä",
+ "Total Gross": "Brutto yhteensä",
+ "Total Tax": "Vero yhteensä",
+ "Total Fee": "Kokonaismaksu",
+ "Total Refunded": "Palautettu yhteensä",
+ "Payment Status": "Maksun tila",
+ "Refund Status": "Hyvityksen tila",
+ "Currency": "Valuutta",
+ "Created At": "Luotu",
+ "Payment Gateway": "Maksun yhdyskäytävä",
+ "Is Partially Refunded": "Osittain palautettu",
+ "Is Fully Refunded": "Täysin palautettu",
+ "Is Free Order": "Ilmainen tilaus",
+ "Is Manually Created": "Luotu manuaalisesti",
+ "Billing Address": "Laskutusosoite",
+ "Promo Code": "Tarjouskoodi",
+ "Failed to handle incoming Stripe webhook": "Saapuvan Stripe-webhookin käsittely epäonnistui",
+ "Notes must be less than 2000 characters": "Muistiinpanojen tulee olla alle 2000 merkkiä pitkiä",
+ "Invalid payment provider selected.": "Valittu virheellinen maksupalveluntarjoaja.",
+ "Payment instructions are required when offline payments are enabled.": "Maksuohjeet tarvitaan, kun offline-maksut ovat käytössä.",
+ "The invoice prefix may only contain letters, numbers, and hyphens.": "Laskun numeron etuliite saa sisältää vain kirjaimia, numeroita ja yhdysmerkkejä.",
+ "The organization name is required when invoicing is enabled.": "Organisaation nimi on pakollinen, kun laskutus on käytössä.",
+ "The organization address is required when invoicing is enabled.": "Organisaation osoite on pakollinen, kun laskutus on aktivoitu.",
+ "The invoice start number must be at least 1.": "Laskun aloitusnumeron on oltava vähintään 1.",
+ "There is no default account configuration available": "Tilin oletusasetuksia ei ole saatavilla",
+ "Product price ID is not valid": "Tuotteen hintatunnus ei kelpaa",
+ "Invoice": "Lasku",
+ "Editing order with ID: :id": "Tilausten muokkaaminen ID: :id",
+ "Marking order as paid": "Merkitsee tilauksen maksetuksi",
+ "Received a :event Stripe event, which has no handler": "Vastaanotettiin Stripe-tapahtuma :event, jolle ei ole käsittelijää",
+ "Order is not in the correct status to transition to offline payment": "Tilaus ei ole oikeassa tilassa siirtyäkseen offline-maksuun",
+ "Order reservation has expired": "Tilausvaraus on vanhentunut",
+ "Offline payments are not enabled for this event": "Offline-maksut eivät ole käytössä tässä tapahtumassa",
+ "There are no products available in this category": "Tässä kategoriassa ei ole saatavilla tuotteita",
+ "Webhook not found": "Webhookia ei löydy",
+ "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Ei voi kirjautua sisään, koska osallistujan :attendee_name\\'s tilaus odottaa maksua",
+ "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "Osallistujan :attendee_name\\'s tilausta ei voi merkitä maksetuksi. Tarkista tapahtuman asetukset",
+ "Invoice already exists": "Lasku on jo olemassa",
+ "Invoice not found": "Laskua ei löydy",
+ "Order is not awaiting offline payment": "Tilaus ei odota offline-maksua",
+ "Refund already processed": "Hyvitys on jo käsitelty",
+ "Stripe refund successful": "Stripe-palautus onnistui",
+ "There are no tickets available for this event": "Tässä tapahtumassa ei ole saatavilla lippuja",
+ "Address line 1 is required": "Osoiterivi 1 on pakollinen",
+ "City is required": "Kaupunki on pakollinen",
+ "Zip or postal code is required": "Postinumero on pakollinen",
+ "Country is required": "Maa on pakollinen",
+ "If you did not request a password reset, please immediately reset your password.": "Jos et pyytänyt salasanan palautusta, palauta salasanasi välittömästi.",
+ "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Tilauksesi odottaa maksua. Liput on myönnetty, mutta ne eivät ole voimassa ennen kuin maksu on vastaanotettu.",
+ "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ Tämä tilaus odottaa maksua. Merkitse maksu vastaanotetuksi tilauksen käsittelysivulle, kun maksu vastaanotetaan.",
+ "Order Status:": "Tilauksen tila:",
+ "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Tilauksesi odottaa maksua. Liput on myönnetty, mutta ne eivät ole voimassa ennen kuin maksu on vastaanotettu.",
+ "Payment Instructions": "Maksuohjeet",
+ "Please follow the instructions below to complete your payment.": "Suorita maksu loppuun noudattamalla alla olevia ohjeita.",
+ "Invoice Number": "Laskun numero",
+ "Date Issued": "Laskun päivämäärä",
+ "Due Date": "Eräpäivä",
+ "Amount Due": "Maksamaan",
+ "Billed To": "Laskutettu",
+ "DESCRIPTION": "KUVAUS",
+ "RATE": "HINTA",
+ "QTY": "Määrä",
+ "AMOUNT": "Määrä",
+ "Subtotal": "Välisumma",
+ "Total Discount": "Yhteensä alennus",
+ "Total Service Fee": "Kokonaispalvelumaksu",
+ "Total Amount": "Kokonaismäärä",
+ "For any queries, please contact us at": "Jos sinulla on kysyttävää, ota meihin yhteyttä osoitteessa",
+ "Tax Information": "Verotiedot",
+ "Sales Ascending": "Myynti nouseva",
+ "Sales Descending": "Myynti laskeva",
+ "Revenue Ascending": "Tulot nouseva",
+ "Revenue Descending": "Tulot laskeva",
+ "Social": "Sosiaalinen",
+ "Food & Drink": "Ruoka ja juoma",
+ "Charity": "Hyväntekeväisyys",
+ "Music": "Musiikki",
+ "Art": "Taide",
+ "Comedy": "Komedia",
+ "Theater": "Teatteri",
+ "Business": "Yritys",
+ "Tech": "Tekniikka",
+ "Education": "Koulutus",
+ "Workshop": "Työpaja",
+ "Sports": "Urheilu",
+ "Festival": "Festivaali",
+ "Nightlife": "Yöelämä",
+ "Other": "Sekalaista",
+ "Name": "Nimi",
+ "Code": "Koodi",
+ "Total Sales": "Kokonaismyynti",
+ "Total Sales Gross": "Bruttomyynti yhteensä",
+ "Updated At": "Päivitetty",
+ "Question": "Kysymys",
+ "Answer": "Vastaus",
+ "Order ID": "Tilaustunnus",
+ "Order Email": "Tilaajan sähköposti",
+ "Attendee Name": "Osallistujan nimi",
+ "Attendee Email": "Osallistujan sähköposti",
+ "Product": "Tuote",
+ "Order URL": "Tilauksen URL-osoite",
+ "Attendee Answers": "Osallistujan vastaus",
+ "Order Name": "Tilauksen nimi",
+ "Order Answers": "Tilausvastaus",
+ "Product Answers": "Tuotevastaus",
+ "Payment Provider": "Maksupalveluntarjoaja",
+ "Invalid invite token": "Virheellinen kutsutunnus",
+ "Affiliate not found": "Kumppania ei löydy",
+ "Organizer with ID :organizerId is not live and user is not authenticated": "Järjestäjä, jonka tunnus on :organizerId, ei ole käytössä eikä käyttäjää ole todennettu",
+ "Message sent successfully": "Viesti lähetetty onnistuneesti",
+ "Your email has been successfully verified!": "Sähköpostisi on vahvistettu!",
+ "The image must be at least 600 pixels wide and 50 pixels tall, and no more than 4000 pixels wide and 4000 pixels tall.": "Kuvan tulee olla vähintään 600 pikseliä leveä ja 50 pikseliä korkea ja enintään 4 000 pikseliä leveä ja 4 000 pikseliä korkea.",
+ "The image must be at least :minWidth x :minHeight pixels.": "Kuvan on oltava vähintään :minWidth x :minHeight pikseliä.",
+ "The entity ID is required when type is provided.": "Entiteettitunnus vaaditaan, kun tyyppi on määritetty",
+ "The type is required when entity ID is provided.": "Tyyppi on pakollinen, kun kokonaisuuden tunnus määritetään.",
+ "Your confirmation code for :app_name is :code": "Vahvistuskoodisi sovellukselle :app_name on :code",
+ "New message from :name": "Uusi viesti lähettäjältä :name",
+ "An affiliate with this code already exists for this event": "Tälle tapahtumalle on jo olemassa tällä koodilla varustettu kumppani",
+ "New password must be different from the old password.": "Uuden salasanan on oltava erilainen kuin vanha salasana.",
+ "Due to issues with spam, you must contact us to enable your account for sending messages. ' .\n 'Please contact us at :email": "Roskapostiongelmien vuoksi sinun on otettava meihin yhteyttä aktivoidaksesi tilisi viestien lähettämistä varten. Ota yhteyttä osoitteeseen :email",
+ "Sorry, is expired or not in a valid state.": "Valitettavasti on vanhentunut tai ei ole kelvollisessa tilassa.",
+ "Organizer not found": "Järjestäjää ei löydy",
+ "You must verify your account before you can update an organizer\\'s status.\n You can resend the confirmation by visiting your profile page.": "Sinun on vahvistettava tilisi ennen kuin voit päivittää järjestäjän tilan. Voit lähettää vahvistuksen uudelleen siirtymällä profiilisivullesi.",
+ "Your email address has already been verified.": "Sähköpostiosoitteesi on jo vahvistettu.",
+ "The verification code is invalid or has expired.": "Vahvistuskoodi on virheellinen tai vanhentunut.",
+ "Job not found": "Työtä ei löytynyt",
+ "Export file not found": "Vientitiedostoa ei löydy",
+ "Job completed successfully": "Työ suoritettu onnistuneesti",
+ "Job is still in progress": "Työ on vielä kesken",
+ "Email Subject": "Sähköpostin aihe",
+ "© :year :app_name. All rights reserved.": "© :year :app_name. Kaikki oikeudet pidätetään.",
+ "Need help?": "Tarvitsetko apua?",
+ "Contact Support": "Ota yhteyttä tukeen",
+ "Additional Information": "Lisätietoja",
+ "If you have any questions or need assistance, please contact": "Jos sinulla on kysyttävää tai tarvitset apua, ota yhteyttä",
+ "Hello :name": "Hei :name",
+ "You have received a new message from": "Olet saanut uuden viestin käyttäjältä",
+ "Reply to :name": "Vastaa osoitteeseen :name",
+ "This message was sent via your organizer contact form.": "Tämä viesti on lähetetty järjestäjän yhteydenottolomakkeen kautta.",
+ "Your email confirmation code is:": "Sähköpostisi vahvistuskoodi on:",
+ "(deactivated)": "(pois käytöstä)",
+ "Account deletion has already been requested.": "Tilin poistamista on jo pyydetty.",
+ "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Seuraavat tulevat tapahtumat ovat saaneet tilauksia: :events. Peruuta ja hyvitä nämä tilaukset ennen tilisi poistamista.",
+ "There is no pending deletion request for this account.": "Tälle tilille ei ole vireillä olevaa poistopyyntöä.",
+ "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "Tämä tili on ajoitettu poistettavaksi. Peruuta poistopyyntö jatkaaksesi sen käyttöä.",
+ "Only the account owner can request account deletion.": "Vain tilin omistaja voi pyytää tilin poistamista.",
+ "The confirmation does not match your account name.": "Vahvistus ei vastaa tilisi nimeä.",
+ "Only pending deletion requests can be executed.": "Vain odottavat poistopyynnöt voidaan suorittaa.",
+ "Account deletion has been queued for execution.": "Tilin poisto on asetettu suoritettavaksi.",
+ "Your :app_name account is scheduled for deletion": ":app_name-tilisi on ajoitettu poistettavaksi",
+ "Your :app_name account deletion has been cancelled": ":app_name -tilisi poistaminen on peruutettu",
+ "Reminder: your :app_name account will be deleted on :date": "Muistutus: :app_name -tilisi poistetaan :date",
+ "Your :app_name account has been deleted": ":app_name-tilisi on poistettu",
+ "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": "Olemme vastaanottaneet pyynnön poistaa :appName-tilisi. Tilisi on poistettu käytöstä ja se poistetaan pysyvästi :date.",
+ "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "Kaikki julkaisemasi tapahtumat on peruutettu. Jos peruutat poiston, sinun on julkaistava ne uudelleen manuaalisesti.",
+ "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "Koska tililläsi on tehty tilauksia, tapahtumatiedot (summa, päivämäärä ja laskun tiedot) säilytetään anonymisoidussa muodossa oikeudellisista ja verotuksellisista syistä. Kaikki henkilötiedot poistetaan pysyvästi.",
+ "Your account and all of its data will be permanently deleted.": "Tilisi ja kaikki sen tiedot poistetaan pysyvästi.",
+ "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "Jos et ole pyytänyt tätä tai muutat mieltäsi, voit peruuttaa poiston milloin tahansa ennen :date:",
+ "Cancel Account Deletion": "Peruuta tilin poisto",
+ "The deletion of your :appName account has been cancelled. Your account is active again.": ":appName-tilisi poistaminen on peruutettu. Tilisi on taas aktiivinen.",
+ "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "Huomaa, että tapahtumiasi ei julkaistu, kun niitä pyydettiin. Sinun on julkaistava uudelleen tapahtumat, jotka haluat tehdä julkisiksi.",
+ "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": "Tämä on muistutus siitä, että :appName-tilisi on ajoitettu poistettavaksi pysyvästi :date.",
+ "If you want to keep your account, cancel the deletion before this date:": "Jos haluat säilyttää tilisi, peruuta poisto ennen tätä päivämäärää:",
+ "If you take no action, the deletion will proceed automatically and cannot be undone.": "Jos et tee mitään, poisto tapahtuu automaattisesti eikä sitä voi kumota.",
+ "Your :appName account has been permanently deleted.": ":appName-tilisi on poistettu pysyvästi.",
+ "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "Kaikki henkilötiedot on poistettu. Anonymisoidut tapahtumatiedot (summa, päivämäärä ja laskun tiedot) on säilytetty laki- ja verovaatimusten mukaisesti.",
+ "All of your account data has been permanently removed.": "Kaikki tilitietosi on poistettu pysyvästi.",
+ "Thank you for using :appName. You are welcome back at any time.": "Kiitos, että käytit sovellusta :appName. Olet tervetullut takaisin milloin tahansa.",
+ "Classes": "luokat",
+ "There are no classes available for this event": "Tälle tapahtumalle ei ole saatavilla kursseja",
+ "Book Now": "Varaa nyt",
+ "Registration": "Rekisteröityminen",
+ "Registration is not open for this event": "Ilmoittautuminen tähän tapahtumaan ei ole auki",
+ "Register": "Rekisteröidy",
+ "Passes": "Passi",
+ "There are no passes available for this event": "Tähän tapahtumaan ei ole saatavilla lippuja",
+ "Get Passes": "Hanki passi",
+ "Ticket Name A-Z": "Lipun nimi A-Z",
+ "Ticket Name Z-A": "Lipun nimi Z–A",
+ "Liquid": "Nestemäinen",
+ "Blade": "Terä",
+ "Order Confirmation": "Tilausvahvistus",
+ "Attendee Ticket": "Osallistujan lippu",
+ "Sent to the customer after placing an order": "Lähetetään asiakkaalle, kun tilaus on tehty",
+ "Sent to each attendee with their ticket": "Lähetetään jokaiselle osallistujalle lipun mukana",
+ "Abandoned": "Hylätty",
+ "Check Ins": "Sisäänkirjautumiset",
+ "Unknown": "Tuntematon",
+ "Opted In To Marketing": "Hyväksynyt markkinointiviestit",
+ "Unauthorized": "Luvaton",
+ "Configuration assigned successfully.": "Kokoonpano on määritetty.",
+ "Impersonation started": "Toisena käyttäjänä toimiminen aloitettu",
+ "Not currently impersonating": "Mitään toisena henkilönä esiintymistä ei ole tällä hetkellä käynnissä",
+ "Impersonation ended": "Toisena käyttäjänä toimiminen päättynyt",
+ "No user role found in token": "Tunnuksesta ei löytynyt käyttäjäroolia",
+ "You cannot modify email templates until your account is verified.": "Et voi muuttaa sähköpostimalleja ennen kuin tilisi on vahvistettu.",
+ "Due to issues with spam, you must connect a Stripe account before you can modify email templates.": "Roskapostiongelmien vuoksi sinun on yhdistettävä Stripe-tili ennen kuin voit muuttaa sähköpostimalleja.",
+ "Invalid template type": "Virheellinen mallityyppi",
+ "Superadmin user is viewing non-live event with ID :eventId": "Pääkäyttäjä näyttää julkaisemattoman tapahtuman, jonka tunnus on :eventId",
+ "Invalid report type.": "Virheellinen raporttityyppi.",
+ "Date range must be less than 370 days.": "Ajanjakson on oltava alle 370 päivää.",
+ "Attendee updated successfully": "Osallistuja on päivitetty",
+ "Order updated successfully": "Tilaus on päivitetty",
+ "Ticket resent successfully": "Lippu on taas lähetetty",
+ "Order confirmation resent successfully": "Tilausvahvistus on lähetetty uudelleen",
+ "If you have tickets associated with this email, we will send you an email with the details.": "Jos tähän sähköpostiosoitteeseen on liitetty lippuja, lähetämme tiedot sähköpostilla.",
+ "The footer text may not be greater than 500 characters.": "Alatunnisteen teksti ei saa olla yli 500 merkkiä pitkä.",
+ "The layout type must be default or modern.": "Asettelutyypin on oltava \"oletus\" tai \"moderni\".",
+ "The mode must be light or dark.": "Tilan on oltava \"vaalea\" tai \"tumma\".",
+ "The background type must be COLOR or MIRROR_COVER_IMAGE.": "Taustatyypin on oltava COLOR tai MIRROR_COVER_IMAGE.",
+ "First name must be a string": "Etunimen on oltava tekstimerkkijono",
+ "First name must not exceed 255 characters": "Etunimi saa olla enintään 255 merkkiä pitkä",
+ "Last name must be a string": "Sukunimen on oltava tekstimerkkijono",
+ "Last name must not exceed 255 characters": "Sukunimi saa olla enintään 255 merkkiä pitkä",
+ "Email must not exceed 255 characters": "Sähköpostiosoite saa olla enintään 255 merkkiä pitkä",
+ "Validation failed after multiple attempts: :error": "Vahvistus epäonnistui useiden yritysten jälkeen: :error",
+ "Your Ticket Details Have Been Changed": "Lipussasi olevia tietoja on muutettu",
+ "Your Order Details Have Been Changed": "Tilauksesi tiedot on muutettu",
+ "Response from :organizerName": "Vastaus järjestäjältä :organizerName",
+ "Your Tickets": "Sinun lippusi",
+ "Invalid VAT number format": "Virheellinen ALV-rekisteröintinumeron muoto",
+ "The system default configuration cannot be deleted.": "Järjestelmän oletuskokoonpanoa ei voi poistaa.",
+ "User does not belong to this account": "Käyttäjä ei kuulu tälle tilille",
+ "Impersonation not allowed": "Toisena henkilönä esiintyminen ei ole sallittua",
+ "Email template not found": "Sähköpostimallia ei löydy",
+ "Unable to verify Stripe signature with any platform": "Stripe-allekirjoitusta ei voitu vahvistaa millään alustalla",
+ "Order :id not found for event :eventId": "Tilausta :id ei löydy tapahtumalle :eventId",
+ "Order is not in a valid status to be abandoned": "Tilauksella ei ole tilaa, joka sallisi sen hylkäämisen",
+ "Order has already expired": "Tilaus on jo vanhentunut",
+ "Attendee not found": "Osallistujaa ei löytynyt",
+ "Event not found": "Tapahtumaa ei löytynyt",
+ "Page must be a positive integer": "Sivun on oltava positiivinen kokonaisluku",
+ "Page not found": "Sivua ei löytynyt",
+ "Invalid or expired link. Please request a new one.": "Virheellinen tai vanhentunut linkki. Pyydä uusi.",
+ "This link has expired. Please request a new one.": "Tämä linkki on vanhentunut. Pyydä uusi.",
+ "SUPERADMIN users cannot be created through the application": "SUPERADMIN-käyttäjiä ei voi luoda sovelluksen kautta",
+ "Cannot associate a user with SUPERADMIN role to an account": "Käyttäjää, jolla on SUPERADMIN-rooli, ei voida linkittää tiliin",
+ "View Order & Tickets": "Katso tilaukset ja liput",
+ "Summer Music Festival 2024": "Kesämusiikkifestivaali 2024",
+ "3 Arena, North Wall Quay, Dublin 1, Ireland": "3Arena, North Wall Quay, Dublin 1, Irlanti",
+ "Join us for an unforgettable evening of live music featuring top artists from around the world.": "Liity kanssamme viettämään unohtumaton ilta elävää musiikkia ja huippuartisteja ympäri maailmaa.",
+ "Please transfer the total amount to the following bank account within 5 business days.": "Siirrä koko summa seuraavalle pankkitilille 5 arkipäivän kuluessa.",
+ "Thank you for your purchase! We look forward to seeing you at the event.": "Kiitos ostoksestasi! Odotamme innolla tapaamistasi tapahtumassa.",
+ "Platform Fee": "Alustamaksu",
+ "Processing charge event": "Käsitellään veloitustapahtumaa",
+ "Charge not in succeeded status, skipping": "Veloituksen tila ei ole \"onnistui\", ohittaa",
+ "Stripe payment not found for charge": "Tälle veloitukselle ei löytynyt Stripe-maksua",
+ "Order not found for charge": "Tälle maksulle ei löytynyt tilausta",
+ "Extracting platform fee for order": "Kerää tilauksen alustamaksun",
+ "Retrieving balance transaction from Stripe": "Hakee saldotapahtuman Stripestä",
+ "No balance transaction found for charge": "Tälle veloitukselle ei löytynyt saldotapahtumaa",
+ "Platform fee already stored for this transaction": "Alustamaksu on jo tallennettu tälle tapahtumalle",
+ "Platform fee stored successfully": "Alustamaksu tallennettu onnistuneesti",
+ "Failed to store platform fee": "Alustamaksun tallennus epäonnistui",
+ "Ticket Reference": "Lippuviittaus",
+ "Order Reference": "Tilausviite",
+ "The name of the event": "Tapahtuman nimi",
+ "The event start date": "Tapahtuman alkamispäivä",
+ "The event start time": "Tapahtuman alkamisaika",
+ "The event end date": "Tapahtuman päättymispäivä",
+ "The event end time": "Tapahtuman päättymisaika",
+ "The full event address": "Tapahtuman täydellinen osoite",
+ "The event venue name": "Tapahtumapaikan nimi",
+ "The venue address line 1": "Tapahtumapaikan osoiterivi 1",
+ "The venue address line 2": "Tapahtumapaikan osoiterivi 2",
+ "The venue city": "Tapahtumapaikan kaupunki",
+ "The venue state or region": "Tapahtumapaikan alue tai maakunta",
+ "The venue ZIP or postal code": "Tapahtumapaikan postinumero",
+ "The venue country code": "Tapahtumapaikan maatunnus",
+ "The event description": "Tapahtuman kuvaus",
+ "The organizer\\'s name": "Järjestäjän nimi",
+ "The organizer\\'s email": "Järjestäjän sähköpostiosoite",
+ "The support email address": "Sähköpostiosoite tukea varten",
+ "Instructions for offline payment": "Ohjeet offline-maksuun",
+ "Message shown after checkout": "Viesti näkyy kassan jälkeen",
+ "Link to view the order summary": "Linkki tilausyhteenvetoon",
+ "The order reference number": "Tilauksen viitenumero",
+ "The total order amount": "Tilauksen kokonaissumma",
+ "The order date": "Tilauspäivämäärä",
+ "The first name of the person who placed the order": "Tilauksen tehneen henkilön etunimi",
+ "The last name of the person who placed the order": "Tilauksen tehneen henkilön sukunimi",
+ "The email of the person who placed the order": "Tilauksen tehneen henkilön sähköpostiosoite",
+ "The attendee\\'s full name": "Osallistujan koko nimi",
+ "The attendee\\'s email": "Osallistujan sähköpostiosoite",
+ "The ticket type name": "Lipputyypin nimi",
+ "The ticket price": "Lipun hinta",
+ "Link to view/download the ticket": "Linkki lippujen katseluun/lataukseen",
+ "Stripe secret key not configured for platform: :platform": "Stripe-salaisuutta ei ole määritetty alustalle: :platform",
+ "VIES service returned HTTP :status": "VIES-palvelu vastasi HTTP :status",
+ "VAT number is not valid according to VIES": "ALV-rekisterinumero ei ole voimassa VIES:n mukaan",
+ "Connection error: :error": "Yhteysvirhe: :error",
+ "Validation error: :error": "Vahvistusvirhe: :error",
+ "VIES service is temporarily busy. Validation will be retried.": "VIES-palvelu on tilapäisesti varattu. Vahvistus on uusittu.",
+ "Member State service is temporarily unavailable. Validation will be retried.": "Jäsenvaltion palvelu on tilapäisesti poissa käytöstä. Vahvistus on uusittu.",
+ "VIES service timed out. Validation will be retried.": "VIES-palvelu ei vastannut ajoissa. Vahvistus on uusittu.",
+ "VIES server is busy. Validation will be retried.": "VIES-palvelin on varattu. Vahvistus on uusittu.",
+ "VIES service is unavailable. Validation will be retried.": "VIES-palvelu ei ole käytettävissä. Vahvistus on uusittu.",
+ "VIES service has reached maximum requests. Validation will be retried.": "VIES-palvelu on saavuttanut pyyntöjen enimmäismäärän. Vahvistus on uusittu.",
+ "Invalid VAT number format.": "Virheellinen ALV-rekisteröintinumeron muoto.",
+ "Invalid requester information.": "Virheelliset tiedot kysyjästä.",
+ "VIES validation error: :code": "VIES-vahvistusvirhe: :code",
+ "Please confirm your email address": "Vahvista sähköpostiosoitteesi",
+ "Email addresses do not match": "Sähköpostiosoitteet eivät täsmää",
+ "Please confirm the email address": "Vahvista sähköpostiosoite",
+ "Ticket Details Changed": "Lipputiedot muuttuneet",
+ "The details on your ticket for **:eventName** have been updated.": "Tapahtuman **:eventName** lippusi tiedot on päivitetty.",
+ "What Changed": "Tämä on muutettu",
+ "Security Information": "Turvallisuustiedot",
+ "This change was made from IP address: :ipAddress": "Muutos tehtiin IP-osoitteesta: :ipAddress",
+ "If you did not make this change, please contact the event organizer immediately.": "Jos et tehnyt muutosta, ota välittömästi yhteyttä järjestäjään.",
+ "Event Organizer: :organizerName": "Järjestäjä: :organizerName",
+ "Contact: :email": "Ota yhteyttä: :email",
+ "Order Details Changed": "Tilaustiedot muuttuneet",
+ "The details on your order for **:eventName** have been updated.": "Tilauksesi tiedot tapahtumaan **:eventName** on päivitetty.",
+ "We found :count order(s) associated with :email.": "Löysimme :count tilauksia, jotka on linkitetty osoitteeseen :email.",
+ "Click the button below to view your tickets and order details.": "Napsauta alla olevaa painiketta nähdäksesi lippusi ja tilaustiedot.",
+ "View My Tickets": "Näytä lippuni",
+ "This link will expire in 24 hours.": "Linkki vanhenee 24 tunnin kuluttua.",
+ "If you did not request this, please ignore this email.": "Jos et ole pyytänyt tätä, voit jättää tämän sähköpostin huomioimatta.",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "Olet saavuttanut päivittäisen viestirajan. Yritä myöhemmin uudelleen tai ota yhteyttä tukeen nostaaksesi rajojasi.",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "Vastaanottajien määrä ylittää tilisi rajan. Ota yhteyttä tukeen nostaaksesi rajojasi.",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "Tilisi taso ei salli linkkien käyttöä viesteissä. Ota yhteyttä tukeen aktivoidaksesi toiminnon.",
+ "Deleted :count failed jobs": ":count epäonnistuneet työt poistettu",
+ "Failed job not found": "Epäonnistunutta työtä ei löytynyt",
+ "Queued :count jobs for retry": ":count työt jonossa uudelleenyritettäväksi",
+ "Job queued for retry": "Työ oli jonossa uudelleenyritystä varten",
+ "Message approved and queued for sending": "Viesti hyväksyttiin ja asetettiin lähetysjonoon",
+ "Promo code not found": "Tarjouskoodia ei löydy",
+ "Question not found": "Kysymystä ei löydy",
+ "[Action Required] Message Pending Review - :subject": "[Toimenpide vaaditaan] Viesti odottaa tarkistusta - :subject",
+ "Message not found": "Viestiä ei löytynyt",
+ "Message must be in pending review status to be approved": "Viestin tilan on oltava \"odottaa tarkistusta\", jotta se voidaan hyväksyä",
+ "The :attribute must be a valid URL.": ":attribute on oltava kelvollinen URL-osoite.",
+ "The :attribute must use http or https protocol.": ":attribute on käytettävä http- tai https-protokollaa.",
+ "The :attribute cannot point to localhost or internal addresses.": ":attribute ei saa osoittaa localhost- tai sisäisiin osoitteisiin.",
+ "The :attribute cannot use reserved domain names.": ":attribute ei saa käyttää varattuja verkkotunnuksia.",
+ "The :attribute cannot point to cloud metadata endpoints.": ":attribute ei saa osoittaa pilven metatietojen päätepisteisiin.",
+ "The :attribute cannot point to private or internal IP addresses.": ":attribute ei saa osoittaa yksityisiin tai sisäisiin IP-osoitteisiin.",
+ "A message has been flagged for review due to eligibility check failures.": "Viesti on merkitty tarkistettavaksi, koska valtuutustarkistukset eivät läpäisseet.",
+ "Message Details": "Ilmoituksen tiedot",
+ "Subject": "Aihe",
+ "Account": "Tili",
+ "Event": "Tapahtuma",
+ "Message ID": "Viestin tunnus",
+ "Eligibility Failures": "Valtuutustarkistukset epäonnistuivat",
+ "Stripe payment account not connected": "Stripe-maksutiliä ei ole linkitetty",
+ "No completed paid orders on this account": "Tällä tilillä ei ole suoritettuja maksettuja tilauksia",
+ "Event was created less than 24 hours ago": "Tapahtuma luotiin alle 24 tuntia sitten",
+ "Review Message": "Tarkista viesti",
+ "Date Cancellation": "Päivämäärän peruutus",
+ "Sent to attendees when a scheduled date is cancelled": "Lähetetään osallistujille, kun aikataulutettu päivämäärä perutaan",
+ "Family": "Perhe",
+ "Hobbies": "Harrastukset",
+ "Wellness": "Hyvinvointi",
+ "Spirituality": "Henkisyys",
+ "Outdoors": "Ulkoilu",
+ "Tours": "Kierrokset",
+ "Film": "Elokuva",
+ "Dance": "Tanssi",
+ "Seasonal": "Sesonkiaikainen",
+ "Must be 9-20 digits (e.g., 1234567890)": "Pituuden on oltava 9–20 numeroa (esim. 1234567890)",
+ "Must start with G- followed by 6-20 characters (e.g., G-XXXXXXXXXX)": "On alettava merkeillä G-, joita seuraa 6–20 merkkiä (esim. G-XXXXXXXXXX)",
+ "Must start with GTM- followed by 4-20 characters (e.g., GTM-XXXXXXX)": "On alettava merkeillä GTM-, joita seuraa 4–20 merkkiä (esim. GTM-XXXXXXX)",
+ "Must be 6-30 alphanumeric characters": "Pituuden on oltava 6–30 kirjainta tai numeroa",
+ "Earliest first": "Aikaisin ensin",
+ "Latest first": "Myöhäisin ensin",
+ "Order Ascending": "Järjestys nouseva",
+ "Order Descending": "Järjestys laskeva",
+ "Position ascending": "Sijoitus nouseva",
+ "Position descending": "Sijoitus laskeva",
+ "Occurrence Date": "Tapahtumakerran päivämäärä",
+ "The fee currency of the :currency default configuration must remain :currency.": "Oletuskokoonpanon :currency maksuvaluutan on pysyttävä valuuttana :currency.",
+ "Event approved and published": "Tapahtuma hyväksytty ja julkaistu",
+ "Event confirmed as spam": "Tapahtuma vahvistettu roskapostiksi",
+ "This event has completed orders. Please cancel or refund all orders before deleting.": "Tällä tapahtumalla on suoritettuja tilauksia. Peruuta tai hyvitä kaikki tilaukset ennen poistamista.",
+ "Date range must be less than :days days.": "Aikavälin on oltava alle :days päivää.",
+ "Address suggestions are rate limited. Try again shortly or enter the address manually.": "Osoite-ehdotusten käyttöä on rajoitettu. Yritä hetken kuluttua uudelleen tai syötä osoite manuaalisesti.",
+ "Address suggestions are temporarily unavailable. Try again or enter the address manually.": "Osoite-ehdotukset eivät ole tilapäisesti käytettävissä. Yritä uudelleen tai syötä osoite manuaalisesti.",
+ "Place lookups are rate limited. Try again shortly or enter the address manually.": "Paikkahakujen käyttöä on rajoitettu. Yritä hetken kuluttua uudelleen tai syötä osoite manuaalisesti.",
+ "Place details are temporarily unavailable. Try again or enter the address manually.": "Paikan tiedot eivät ole tilapäisesti saatavilla. Yritä uudelleen tai syötä osoite manuaalisesti.",
+ "Place not found or geo provider unavailable": "Paikkaa ei löytynyt tai paikkatietopalvelu ei ole käytettävissä",
+ "A saved location must be selected for in-person events": "Paikan päällä järjestettäville tapahtumille on valittava tallennettu sijainti",
+ "A saved location must be selected for in-person occurrences": "Paikan päällä järjestettäville tapahtumakerroille on valittava tallennettu sijainti",
+ "Specify at least one occurrence_id, or set apply_to_all to true to update every matching occurrence.": "Määritä vähintään yksi occurrence_id tai aseta apply_to_all arvoon true päivittääksesi kaikki vastaavat tapahtumakerrat.",
+ "Each time of day must be in HH:MM 24-hour format.": "Jokaisen kellonajan on oltava muodossa HH:MM (24 tunnin kello).",
+ "Each time of day object must include a time field.": "Jokaisen kellonaikaobjektin on sisällettävä time-kenttä.",
+ "Each time of day must be a HH:MM string or an object with a time field.": "Jokaisen kellonajan on oltava HH:MM-merkkijono tai objekti, jossa on time-kenttä.",
+ "Select at least one product. To make a date inaccessible, cancel it from the schedule instead.": "Valitse vähintään yksi tuote. Jos haluat estää pääsyn päivämäärälle, peruuta se sen sijaan aikataulusta.",
+ "The selected font is not supported.": "Valittua fonttia ei tueta.",
+ "Provide at least one address field (venue, street, city, or country).": "Anna vähintään yksi osoitekenttä (tapahtumapaikka, katu, kaupunki tai maa).",
+ "Only one of event_occurrence_id or event_occurrence_ids may be provided.": "Vain toinen kentistä event_occurrence_id tai event_occurrence_ids voidaan antaa.",
+ "The order statuses field is required when sending messages to order owners with a specific product.": "Tilausten tilat -kenttä on pakollinen, kun viestejä lähetetään tietyn tuotteen tilaajille.",
+ "The subject must be less than 100 characters.": "Aiheen on oltava alle 100 merkkiä.",
+ "You can only send a message to a maximum of 50 individual attendees at a time. To message more attendees, you can send to attendees with a specific product, or to all event attendees.": "Voit lähettää viestin enintään 50 yksittäiselle osallistujalle kerrallaan. Jos haluat lähettää viestin useammalle osallistujalle, voit lähettää sen tietyn tuotteen ostaneille osallistujille tai kaikille tapahtuman osallistujille.",
+ "You can only target up to 500 occurrences in a single message.": "Voit kohdistaa yhden viestin enintään 500 tapahtumakertaan.",
+ "Google Tag Manager is not available on hosted plans for security reasons.": "Google Tag Manager ei ole turvallisuussyistä käytettävissä isännöidyissä paketeissa.",
+ "You must acknowledge your data controller responsibilities before enabling tracking pixels.": "Sinun on vahvistettava rekisterinpitäjän vastuusi ennen seurantapikselien käyttöönottoa.",
+ "The webhook URL exceeded the maximum number of redirects.": "Webhookin URL-osoite ylitti uudelleenohjausten enimmäismäärän.",
+ "webhook URL": "webhookin URL-osoite",
+ "[Action Required] Event Flagged as Spam - :title": "[Toimenpiteitä vaaditaan] Tapahtuma merkitty roskapostiksi - :title",
+ "Online event": "Verkkotapahtuma",
+ "Your event \":title\" is pending review": "Tapahtumasi \":title\" odottaa tarkistusta",
+ ":event on :date has been cancelled": "Tapahtuman :event ajankohta :date on peruutettu",
+ "New message from your organizer page": "Uusi viesti järjestäjäsivultasi",
+ "You're on the waitlist!": "Olet jonotuslistalla!",
+ "Your waitlist offer has expired": "Jonotuslistatarjouksesi on vanhentunut",
+ "A spot has opened up for :event!": "Paikka on vapautunut tapahtumaan :event!",
+ "No flagged spam check found for this event": "Tälle tapahtumalle ei löytynyt roskapostimerkintää",
+ "Event must be pending manual review to be approved": "Tapahtuman on odotettava manuaalista tarkistusta, jotta se voidaan hyväksyä",
+ "Event must be pending manual review to be confirmed as spam": "Tapahtuman on odotettava manuaalista tarkistusta, jotta se voidaan vahvistaa roskapostiksi",
+ "Default configurations cannot be deleted.": "Oletuskokoonpanoja ei voi poistaa.",
+ "This plan is still assigned to :count organizer(s). Reassign them before deleting it.": "Tämä paketti on yhä käytössä :count järjestäjällä. Siirrä heidät toiseen pakettiin ennen poistamista.",
+ "Announcement not found": "Ilmoitusta ei löydy",
+ "There are no tickets available. '.\n 'If you would like to assign a product to this attendee,'.\n ' please adjust the product\\'s available quantity.": "Lippuja ei ole saatavilla. Jos haluat liittää tuotteen tähän osallistujaan, muuta tuotteen saatavilla olevaa määrää.",
+ "One or more selected taxes or fees could not be found.": "Yhtä tai useampaa valittua veroa tai maksua ei löytynyt.",
+ "An occurrence must be selected for recurring events.": "Toistuville tapahtumille on valittava tapahtumakerta.",
+ "No occurrence found for this event.": "Tälle tapahtumalle ei löytynyt tapahtumakertaa.",
+ "Capacity assignment not found": "Kapasiteetin kohdistusta ei löydy",
+ "The default check-in list can\\'t be deleted.": "Oletussisäänkirjautumislistaa ei voi poistaa.",
+ "You cannot change the currency of an event that has paid orders. To use a different currency, duplicate the event and change the currency on the new event.": "Et voi muuttaa sellaisen tapahtuman valuuttaa, jolla on maksettuja tilauksia. Jos haluat käyttää eri valuuttaa, monista tapahtuma ja vaihda valuutta uuteen tapahtumaan.",
+ "This event is pending manual review and its status cannot be changed until the review is complete.": "Tämä tapahtuma odottaa manuaalista tarkistusta, eikä sen tilaa voi muuttaa ennen kuin tarkistus on valmis.",
+ "This update would make the occurrence starting :start end before it starts. Adjust the time shift or duration.": "Tämä päivitys saisi :start alkavan tapahtumakerran päättymään ennen alkamistaan. Säädä aikasiirtoa tai kestoa.",
+ "Occurrence :id not found for event :eventId": "Tapahtumakertaa :id ei löytynyt tapahtumalle :eventId",
+ "This event has reached the maximum of :max occurrences.": "Tämä tapahtuma on saavuttanut tapahtumakertojen enimmäismäärän (:max).",
+ "Cannot delete an occurrence that has orders. Cancel it instead.": "Tapahtumakertaa, jolla on tilauksia, ei voi poistaa. Peruuta se sen sijaan.",
+ "Cannot delete an occurrence that has attendees. Cancel it instead.": "Tapahtumakertaa, jolla on osallistujia, ei voi poistaa. Peruuta se sen sijaan.",
+ "Occurrence :id not found for this event": "Tapahtumakertaa :id ei löytynyt tälle tapahtumalle",
+ "Both start_date_from and start_date_to are required.": "Sekä start_date_from että start_date_to ovat pakollisia.",
+ "The date range is invalid.": "Aikaväli on virheellinen.",
+ "The date range must be valid and span at most :days days.": "Aikavälin on oltava kelvollinen ja enintään :days päivän pituinen.",
+ "Price override :id not found for occurrence :occurrenceId": "Hintaylikirjoitusta :id ei löytynyt tapahtumakerralle :occurrenceId",
+ "Product price :id not found": "Tuotteen hintaa :id ei löytynyt",
+ "Product price :id does not belong to this event": "Tuotteen hinta :id ei kuulu tähän tapahtumaan",
+ "Only cancelled dates can be reactivated.": "Vain peruutettuja päivämääriä voi aktivoida uudelleen.",
+ "This date had ticket sales that were cancelled with it, so it can\\'t be reactivated automatically. Please create a new date instead.": "Tällä päivämäärällä oli lippumyyntiä, joka peruttiin sen mukana, joten sitä ei voi aktivoida uudelleen automaattisesti. Luo sen sijaan uusi päivämäärä.",
+ "This rule would generate too many occurrences. Please reduce the date range or frequency, or contact support.": "Tämä sääntö loisi liian monta tapahtumakertaa. Lyhennä aikaväliä tai harvenna toistuvuutta tai ota yhteyttä tukeen.",
+ "One or more product IDs do not belong to this event": "Yksi tai useampi tuotetunnus ei kuulu tähän tapahtumaan",
+ "Location not found": "Sijaintia ei löydy",
+ "This location is referenced by one or more events or occurrences and cannot be deleted": "Tähän sijaintiin viittaa yksi tai useampi tapahtuma tai tapahtumakerta, eikä sitä voi poistaa",
+ "Another saved location already uses this place.": "Toinen tallennettu sijainti käyttää jo tätä paikkaa.",
+ "Only scheduled messages can be cancelled": "Vain ajastettuja viestejä voi peruuttaa",
+ "This message can no longer be cancelled": "Tätä viestiä ei voi enää peruuttaa",
+ "Due to issues with spam, you must contact us to enable your account for sending messages. '.\n 'Please contact us at :email": "Roskapostiongelmien vuoksi sinun on otettava meihin yhteyttä, jotta tilisi voidaan ottaa käyttöön viestien lähettämistä varten. Ota yhteyttä osoitteeseen :email",
+ "The scheduled time must be in the future.": "Ajastetun ajan on oltava tulevaisuudessa.",
+ "Not enough products available. Please try again.": "Tuotteita ei ole riittävästi saatavilla. Yritä uudelleen.",
+ "This order was not paid with an offline payment method.": "Tätä tilausta ei maksettu offline-maksutavalla.",
+ "This order is awaiting payment, so there is nothing to refund.": "Tämä tilaus odottaa maksua, joten hyvitettävää ei ole.",
+ "This order has already been fully refunded.": "Tämä tilaus on jo hyvitetty kokonaan.",
+ "The refund amount cannot exceed the amount available to refund.": "Hyvityksen summa ei voi ylittää hyvitettävissä olevaa summaa.",
+ ":item_count item(s) for event: :event_name (Order :order_short_id)": ":item_count tuote(tta) tapahtumaan: :event_name (Tilaus :order_short_id)",
+ "Organizer not found.": "Järjestäjää ei löytynyt.",
+ "The source organizer was not found.": "Lähdejärjestäjää ei löytynyt.",
+ "The selected organizer does not have a connected Stripe account.": "Valitulla järjestäjällä ei ole yhdistettyä Stripe-tiliä.",
+ "Stripe connection not found.": "Stripe-yhteyttä ei löytynyt.",
+ "You cannot archive the last active organizer on your account.": "Et voi arkistoida tilisi viimeistä aktiivista järjestäjää.",
+ "Tickets can\\'t be resent for a cancelled order.": "Peruutetun tilauksen lippuja ei voi lähettää uudelleen.",
+ "This ticket has been cancelled and can\\'t be resent.": "Tämä lippu on peruutettu, eikä sitä voi lähettää uudelleen.",
+ "The session for this ticket has been cancelled and can\\'t be resent.": "Tämän lipun istunto on peruutettu, eikä lippua voi lähettää uudelleen.",
+ "An event date must be selected.": "Tapahtuman päivämäärä on valittava.",
+ "Product not found for this event": "Tuotetta ei löytynyt tälle tapahtumalle",
+ "This event date is no longer available.": "Tämä tapahtuman päivämäärä ei ole enää saatavilla.",
+ "Attendee :attendee_name does not belong to this event": "Osallistuja :attendee_name ei kuulu tähän tapahtumaan",
+ ":attendee_name\\'s ticket is for a different session — check they\\'re on the right check-in list.": "Osallistujan :attendee_name lippu on eri istuntoon — tarkista, että hän on oikealla sisäänkirjautumislistalla.",
+ "View Event": "Näytä tapahtuma",
+ "Default check-in": "Oletussisäänkirjaus",
+ "This event cannot be deleted because it has completed orders. Please cancel or refund all orders first.": "Tätä tapahtumaa ei voi poistaa, koska sillä on suoritettuja tilauksia. Peruuta tai hyvitä ensin kaikki tilaukset.",
+ "Recurrence rule must include a frequency": "Toistosäännön on sisällettävä toistuvuus",
+ "Additional recurrence dates must include a date": "Toiston lisäpäivämäärien on sisällettävä päivämäärä",
+ "Recurrence additional_dates time must be in HH:MM 24-hour format": "Toiston additional_dates-ajan on oltava muodossa HH:MM (24 tunnin kello)",
+ "Recurrence times_of_day entries must be in HH:MM 24-hour format": "Toiston times_of_day-merkintöjen on oltava muodossa HH:MM (24 tunnin kello)",
+ "Recurrence time slots must include a time": "Toiston aikavälien on sisällettävä kellonaika",
+ "Unsupported recurrence frequency": "Toistuvuutta ei tueta",
+ "Event location :id not found for event :event": "Tapahtuman sijaintia :id ei löytynyt tapahtumalle :event",
+ "The specified event occurrence was not found": "Määritettyä tapahtumakertaa ei löytynyt",
+ "This event occurrence has been cancelled": "Tämä tapahtumakerta on peruutettu",
+ "This event has been cancelled": "Tämä tapahtuma on peruutettu",
+ "This event occurrence has already ended": "Tämä tapahtumakerta on jo päättynyt",
+ "This event has already ended": "Tämä tapahtuma on jo päättynyt",
+ "This event occurrence is sold out": "Tämä tapahtumakerta on loppuunmyyty",
+ "This event is sold out": "Tämä tapahtuma on loppuunmyyty",
+ "Not enough capacity available for this occurrence": "Tälle tapahtumakerralle ei ole riittävästi kapasiteettia",
+ "Not enough capacity available for this event": "Tälle tapahtumalle ei ole riittävästi kapasiteettia",
+ "One or more selected products are not available for this occurrence": "Yksi tai useampi valittu tuote ei ole saatavilla tälle tapahtumakerralle",
+ "Location :id not found": "Sijaintia :id ei löytynyt",
+ "This event date has been cancelled": "Tämä tapahtuman päivämäärä on peruutettu",
+ "This event date has already ended": "Tämä tapahtuman päivämäärä on jo päättynyt",
+ ":product is an add-on and can only be purchased with the product it belongs to": ":product on lisätuote, ja sen voi ostaa vain sen tuotteen kanssa, johon se kuuluu",
+ "An event occurrence must be specified": "Tapahtumakerta on määritettävä",
+ ":product is not yet on sale": ":product ei ole vielä myynnissä",
+ "Sales for :product have ended": "Tuotteen :product myynti on päättynyt",
+ "You cannot delete the last organizer on your account.": "Et voi poistaa tilisi viimeistä järjestäjää.",
+ "This organizer has events with completed orders. Please cancel or refund all orders first.": "Tällä järjestäjällä on tapahtumia, joissa on suoritettuja tilauksia. Peruuta tai hyvitä ensin kaikki tilaukset.",
+ "Payment was successful, but the order is no longer valid. Order: :id": "Maksu onnistui, mutta tilaus ei ole enää voimassa. Tilaus: :id",
+ "Payment was successful, but the event date is no longer available. Order: :id": "Maksu onnistui, mutta tapahtuman päivämäärä ei ole enää saatavilla. Tilaus: :id",
+ "A product cannot be an add-on of itself": "Tuote ei voi olla itsensä lisätuote",
+ "Default": "Oletus",
+ "Waitlist entry not found": "Jonotuslistamerkintää ei löydy",
+ "This waitlist entry cannot be cancelled": "Tätä jonotuslistamerkintää ei voi peruuttaa",
+ "Waitlist is not enabled for this product": "Jonotuslista ei ole käytössä tälle tuotteelle",
+ "You are already on the waitlist for this product": "Olet jo tämän tuotteen jonotuslistalla",
+ "No capacity available for the selected waitlist entries.": "Valituille jonotuslistamerkinnöille ei ole kapasiteettia.",
+ "There are no waiting entries for this product": "Tälle tuotteelle ei ole odottavia jonotuslistamerkintöjä",
+ "This waitlist entry cannot be offered in its current status": "Tätä jonotuslistamerkintää ei voi tarjota sen nykyisessä tilassa",
+ "This waitlist entry is no longer linked to a valid event date.": "Tämä jonotuslistamerkintä ei ole enää linkitetty kelvolliseen tapahtuman päivämäärään.",
+ "This event date is no longer available for this product.": "Tämä tapahtuman päivämäärä ei ole enää saatavilla tälle tuotteelle.",
+ "No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product or date.": "Tämän jonotuslistamerkinnän tarjoamiseen ei ole kapasiteettia. Sinun on lisättävä tuotteen tai päivämäärän saatavilla olevaa määrää.",
+ "Waitlist entry is missing an event date.": "Jonotuslistamerkinnältä puuttuu tapahtuman päivämäärä.",
+ "The occurrence start date": "Tapahtumakerran alkamispäivä",
+ "The occurrence start time": "Tapahtumakerran alkamisaika",
+ "The occurrence end date": "Tapahtumakerran päättymispäivä",
+ "The occurrence end time": "Tapahtumakerran päättymisaika",
+ "The occurrence title suffix": "Tapahtumakerran otsikon jälkiliite",
+ "Whether refunds are being processed for this cancellation": "Käsitelläänkö tälle peruutukselle hyvityksiä",
+ "Link to the event homepage": "Linkki tapahtuman kotisivulle",
+ "Job failed": "Työ epäonnistui",
+ "The :attribute could not be resolved to a public address.": "Kenttää :attribute ei voitu muuntaa julkiseksi osoitteeksi.",
+ "An event has been flagged as potential spam and unpublished pending manual review.": "Tapahtuma on merkitty mahdolliseksi roskapostiksi, ja sen julkaisu on peruttu manuaalista tarkistusta odotettaessa.",
+ "Title": "Otsikko",
+ "Organizer": "Järjestäjä",
+ "Verdict": "Päätös",
+ "Confidence": "Varmuus",
+ "Review Event": "Tarkista tapahtuma",
+ "Hi": "Hei",
+ "Your event \":title\" has been temporarily unpublished while it undergoes a routine manual review.": "Tapahtumasi \":title\" julkaisu on tilapäisesti peruttu rutiininomaisen manuaalisen tarkistuksen ajaksi.",
+ "Our team will review your event shortly. Once the review is complete, your event will be published again automatically. No action is needed from you.": "Tiimimme tarkistaa tapahtumasi pian. Kun tarkistus on valmis, tapahtumasi julkaistaan automaattisesti uudelleen. Sinun ei tarvitse tehdä mitään.",
+ "If you have any questions, please reply to this email.": "Jos sinulla on kysyttävää, vastaa tähän sähköpostiin.",
+ "the event organizer": "tapahtuman järjestäjä",
+ "This message is from :organizer, not from :platform.": "Tämän viestin lähetti :organizer, ei :platform.",
+ "We\\'re sorry to let you know that **:event** scheduled for **:date** has been cancelled.": "Valitettavasti joudumme ilmoittamaan, että tapahtuma **:event**, jonka ajankohta oli **:date**, on peruutettu.",
+ "If your order is eligible, a refund will be processed automatically and may take a few business days to appear on your statement. If we are unable to process it automatically, our team will be in touch.": "Jos tilauksesi on hyvityskelpoinen, hyvitys käsitellään automaattisesti, ja sen näkyminen tiliotteellasi voi kestää muutaman arkipäivän. Jos emme voi käsitellä sitä automaattisesti, tiimimme ottaa sinuun yhteyttä.",
+ "If you have any questions about your order, please respond to this email.": "Jos sinulla on kysyttävää tilauksestasi, vastaa tähän sähköpostiin.",
+ "Session:": "Istunto:",
+ "Location:": "Sijainti:",
+ "Ticket:": "Lippu:",
+ "Attendee:": "Osallistuja:",
+ ":date at :time": ":date klo :time",
+ "This message was submitted through your organizer contact form and may contain spam, suspicious links or malicious content. Please exercise caution when opening links, downloading attachments or replying.": "Tämä viesti lähetettiin järjestäjäsivusi yhteydenottolomakkeen kautta, ja se voi sisältää roskapostia, epäilyttäviä linkkejä tai haitallista sisältöä. Ole varovainen avatessasi linkkejä, ladatessasi liitteitä tai vastatessasi.",
+ "You have been added to the waitlist for **:product** on **:date** for the event **:event**.": "Sinut on lisätty jonotuslistalle tuotteelle **:product** päivämäärälle **:date** tapahtumassa **:event**.",
+ "You have been added to the waitlist on **:date** for the event **:event**.": "Sinut on lisätty jonotuslistalle päivämäärälle **:date** tapahtumassa **:event**.",
+ "You have been added to the waitlist for **:product** for the event **:event**.": "Sinut on lisätty jonotuslistalle tuotteelle **:product** tapahtumassa **:event**.",
+ "You have been added to the waitlist for the event **:event**.": "Sinut on lisätty tapahtuman **:event** jonotuslistalle.",
+ "We'll notify you as soon as a spot becomes available.": "Ilmoitamme sinulle heti, kun paikka vapautuu.",
+ "Unfortunately, your waitlist offer for **:product** on **:date** for the event **:event** has expired.": "Valitettavasti jonotuslistatarjouksesi tuotteelle **:product** päivämäärälle **:date** tapahtumassa **:event** on vanhentunut.",
+ "Unfortunately, your waitlist offer on **:date** for the event **:event** has expired.": "Valitettavasti jonotuslistatarjouksesi päivämäärälle **:date** tapahtumassa **:event** on vanhentunut.",
+ "Unfortunately, your waitlist offer for **:product** for the event **:event** has expired.": "Valitettavasti jonotuslistatarjouksesi tuotteelle **:product** tapahtumassa **:event** on vanhentunut.",
+ "Unfortunately, your waitlist offer for the event **:event** has expired.": "Valitettavasti jonotuslistatarjouksesi tapahtumaan **:event** on vanhentunut.",
+ "If you are still interested, you may rejoin the waitlist from the event page.": "Jos olet edelleen kiinnostunut, voit liittyä jonotuslistalle uudelleen tapahtumasivulta.",
+ "A spot has opened up!": "Paikka on vapautunut!",
+ "Great news! A spot has become available for **:product** on **:date** for the event **:event**.": "Hyviä uutisia! Paikka on vapautunut tuotteelle **:product** päivämäärälle **:date** tapahtumassa **:event**.",
+ "Great news! A spot has become available on **:date** for the event **:event**.": "Hyviä uutisia! Paikka on vapautunut päivämäärälle **:date** tapahtumassa **:event**.",
+ "Great news! A spot has become available for **:product** for the event **:event**.": "Hyviä uutisia! Paikka on vapautunut tuotteelle **:product** tapahtumassa **:event**.",
+ "Great news! A spot has become available for the event **:event**.": "Hyviä uutisia! Paikka on vapautunut tapahtumaan **:event**.",
+ "An order has been reserved for you. Click the button below to complete your purchase.": "Sinulle on varattu tilaus. Napsauta alla olevaa painiketta viimeistelläksesi ostoksesi.",
+ "This offer expires on :date. Please complete your order before it expires.": "Tämä tarjous vanhenee :date. Viimeistele tilauksesi ennen sen vanhenemista.",
+ "Complete Your Order": "Viimeistele tilauksesi",
+ "Amount Paid": "Maksettu summa",
+ "Paid": "Maksettu",
+ "Void": "Mitätöity",
+ "Unpaid": "Maksamatta",
+ "Description": "Kuvaus",
+ "Rate": "Yksikköhinta",
+ "Qty": "Määrä",
+ "Amount": "Summa",
+ "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"
+}
diff --git a/backend/lang/ko.json b/backend/lang/ko.json
new file mode 100644
index 0000000000..dc3dfef8fc
--- /dev/null
+++ b/backend/lang/ko.json
@@ -0,0 +1,922 @@
+{
+ "Older First": "오래된순",
+ "Newer First": "최신순",
+ "Recently Updated First": "최근 업데이트순",
+ "Recently Updated Last": "오래된 업데이트순",
+ "First Name A-Z": "이름 A-Z",
+ "First Name Z-A": "이름 Z-A",
+ "Last Name A-Z": "성 A-Z",
+ "Last Name Z-A": "성 Z-A",
+ "Status A-Z": "상태 A-Z",
+ "Status Z-A": "상태 Z-A",
+ "Closest start date": "시작일 임박순",
+ "Furthest start date": "시작일 여유순",
+ "Closest end date": "종료일 임박순",
+ "Furthest end date": "종료일 여유순",
+ "Newest first": "최신순",
+ "Oldest first": "오래된순",
+ "Recently Updated": "최근 업데이트",
+ "Least Recently Updated": "오래된 업데이트순",
+ "Oldest First": "오래된순",
+ "Newest First": "최신순",
+ "Buyer Name A-Z": "구매자 이름 A-Z",
+ "Buyer Name Z-A": "구매자 이름 Z-A",
+ "Amount Ascending": "금액 오름차순",
+ "Amount Descending": "금액 내림차순",
+ "Buyer Email A-Z": "구매자 이메일 A-Z",
+ "Buyer Email Z-A": "구매자 이메일 Z-A",
+ "Order # Ascending": "주문번호 오름차순",
+ "Order # Descending": "주문번호 내림차순",
+ "Code Name A-Z": "코드명 A-Z",
+ "Code Name Z-A": "코드명 Z-A",
+ "Usage Count Ascending": "사용 횟수 오름차순",
+ "Usage Count Descending": "사용 횟수 내림차순",
+ "Homepage order": "홈페이지 순서",
+ "Title A-Z": "제목 A-Z",
+ "Title Z-A": "제목 Z-A",
+ "Sale start date closest": "판매 시작일 임박순",
+ "Sale start date furthest": "판매 시작일 여유순",
+ "Sale end date closest": "판매 종료일 임박순",
+ "Sale end date furthest": "판매 종료일 여유순",
+ "Account registration is disabled": "계정 등록이 비활성화되어 있습니다",
+ "The invitation has expired": "초대가 만료되었습니다",
+ "The invitation is invalid": "초대가 유효하지 않습니다",
+ "Invitation valid, but user not found": "초대는 유효하지만 사용자를 찾을 수 없습니다",
+ "No user found for this invitation. The invitation may have been revoked.": "이 초대에 해당하는 사용자를 찾을 수 없습니다. 초대가 취소되었을 수 있습니다.",
+ "Logout Successful": "로그아웃되었습니다",
+ "Your password has been reset. Please login with your new password.": "비밀번호가 재설정되었습니다. 새 비밀번호로 로그인해 주세요.",
+ "No account ID found in token": "토큰에서 계정 ID를 찾을 수 없습니다",
+ "Event with ID :eventId is not live and user is not authenticated": "ID가 :eventId인 이벤트가 공개되지 않았으며 사용자가 인증되지 않았습니다",
+ "Sorry, we could not verify your session. Please restart your order.": "죄송합니다. 세션을 확인할 수 없습니다. 주문을 다시 시작해 주세요.",
+ "The email confirmation link has expired. Please request a new one.": "이메일 확인 링크가 만료되었습니다. 새 링크를 요청해 주세요.",
+ "The email confirmation link is invalid.": "이메일 확인 링크가 유효하지 않습니다.",
+ "No invitation found for this user.": "이 사용자에 대한 초대를 찾을 수 없습니다.",
+ "User status is not Invited": "사용자 상태가 초대됨이 아닙니다",
+ "Email is required": "이메일을 입력해 주세요",
+ "Email must be a valid email address": "유효한 이메일 주소를 입력해 주세요",
+ "First name is required": "이름을 입력해 주세요",
+ "Last name is required": "성을 입력해 주세요",
+ "Ticket is required": "티켓을 선택해 주세요",
+ "Ticket price is required": "티켓 가격을 입력해 주세요",
+ "Please enter a valid hex color code. In the format #000000 or #000.": "#000000 또는 #000 형식의 유효한 HEX 색상 코드를 입력해 주세요.",
+ "The maximum timeout is 2 hours.": "최대 제한 시간은 2시간입니다.",
+ "The address line 1 field is required": "주소(1)를 입력해 주세요",
+ "The city field is required": "도시를 입력해 주세요",
+ "The zip or postal code field is required": "우편번호를 입력해 주세요",
+ "The country field is required": "국가를 입력해 주세요",
+ "The country field should be a 2 character ISO 3166 code": "국가 코드는 ISO 3166 2자리 코드여야 합니다",
+ "Please select at least one ticket.": "티켓을 하나 이상 선택해 주세요.",
+ "The sale end date must be after the sale start date.": "판매 종료일은 판매 시작일 이후여야 합니다.",
+ "The sale end date must be a valid date.": "판매 종료일은 유효한 날짜여야 합니다.",
+ "The sale start date must be after the ticket sale start date.": "판매 시작일은 티켓 판매 시작일 이후여야 합니다.",
+ "Welcome to :app_name! Please confirm your email address": ":app_name에 오신 것을 환영합니다! 이메일 주소를 확인해 주세요",
+ "🎟️ Your Ticket for :event": "🎟️ :event 티켓",
+ "Your order has been cancelled": "주문이 취소되었습니다",
+ "Your order wasn\\'t successful": "주문이 완료되지 않았습니다",
+ "You\\'ve received a refund": "환불이 완료되었습니다",
+ "Your Order is Confirmed!": "주문이 확정되었습니다!",
+ "We were unable to process your order": "주문을 처리할 수 없습니다",
+ "New order for :amount for :event 🎉": ":event에 대한 :amount 새 주문 🎉",
+ "New order for :event 🎉": ":event 새 주문 🎉",
+ "Confirm email change": "이메일 변경 확인",
+ "Password reset": "비밀번호 재설정",
+ "Your password has been reset": "비밀번호가 재설정되었습니다",
+ "You\\'ve been invited to join :appName": ":appName에 참여하도록 초대되었습니다",
+ "Current account ID is not set": "현재 계정 ID가 설정되지 않았습니다",
+ "User not found in this account": "이 계정에서 사용자를 찾을 수 없습니다",
+ "User not found": "사용자를 찾을 수 없습니다",
+ "Username or Password are incorrect": "아이디 또는 비밀번호가 올바르지 않습니다",
+ "Account not found": "계정을 찾을 수 없습니다",
+ "Attempt to log in to a non-active account": "비활성 계정으로 로그인을 시도했습니다",
+ "User account is not active": "사용자 계정이 활성화되어 있지 않습니다",
+ "Invalid reset token": "유효하지 않은 재설정 토큰입니다",
+ "Reset token has expired": "재설정 토큰이 만료되었습니다",
+ "Event daily statistics updated for event :event_id with total refunded amount of :amount": "이벤트 :event_id의 일일 통계가 총 환불 금액 :amount(으)로 업데이트되었습니다",
+ "Event statistics updated for event :event_id with total refunded amount of :amount": "이벤트 :event_id의 통계가 총 환불 금액 :amount(으)로 업데이트되었습니다",
+ "This promo code is invalid": "유효하지 않은 프로모션 코드입니다",
+ "You haven\\'t selected any tickets": "선택한 티켓이 없습니다",
+ "The maximum number of tickets available for :tickets is :max": ":tickets에 대해 구매 가능한 최대 티켓 수는 :max장입니다",
+ "You must order at least :min tickets for :ticket": ":ticket은(는) 최소 :min장 이상 주문해야 합니다",
+ "The minimum amount is :price": "최소 금액은 :price입니다",
+ "The ticket :ticket is sold out": ":ticket 티켓이 매진되었습니다",
+ ":field must be specified": ":field을(를) 지정해야 합니다",
+ "Invalid price ID": "유효하지 않은 가격 ID입니다",
+ "The maximum number of tickets available for :ticket is :max": ":ticket에 대해 구매 가능한 최대 티켓 수는 :max장입니다",
+ "Ticket with id :id not found": "ID가 :id인 티켓을 찾을 수 없습니다",
+ "Failed to refund stripe charge": "Stripe 결제 환불에 실패했습니다",
+ "Payment was successful, but order has expired. Order: :id": "결제는 완료되었지만 주문이 만료되었습니다. 주문번호: :id",
+ "Order is not awaiting payment. Order: :id": "결제 대기 중인 주문이 아닙니다. 주문번호: :id",
+ "There was an error communicating with the payment provider. Please try again later.": "결제 제공업체와 통신하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
+ "Stripe Connect account not found for the event organizer": "이벤트 주최자의 Stripe Connect 계정을 찾을 수 없습니다",
+ "Cannot Refund: Stripe connect account not found and saas_mode_enabled is enabled": "환불 불가: Stripe Connect 계정을 찾을 수 없으며 saas_mode_enabled가 활성화되어 있습니다",
+ "Invalid calculation type": "유효하지 않은 계산 유형입니다",
+ "One or more tax IDs are invalid": "하나 이상의 세금 ID가 유효하지 않습니다",
+ "Invalid ticket ids: :ids": "유효하지 않은 티켓 ID: :ids",
+ "Cannot delete ticket price with id :id because it has sales": "판매 내역이 있어 ID :id인 티켓 가격을 삭제할 수 없습니다",
+ "Order has no order items": "주문에 주문 항목이 없습니다",
+ "There is already an account associated with this email. Please log in instead.": "이미 이 이메일로 등록된 계정이 있습니다. 로그인해 주세요.",
+ "Stripe Connect Account creation is only available in Saas Mode.": "Stripe Connect 계정 생성은 SaaS 모드에서만 가능합니다.",
+ "There are issues with creating or fetching the Stripe Connect Account. Please try again.": "Stripe Connect 계정을 생성하거나 조회하는 중 문제가 발생했습니다. 다시 시도해 주세요.",
+ "There are issues with creating the Stripe Connect Account Link. Please try again.": "Stripe Connect 계정 링크를 생성하는 중 문제가 발생했습니다. 다시 시도해 주세요.",
+ "Cannot check in attendee as they are not active.": "참가자가 활성 상태가 아니어서 체크인할 수 없습니다.",
+ "in": "입장",
+ "out": "퇴장",
+ "There are no tickets available. ' .\n 'If you would like to assign a ticket to this attendee,' .\n ' please adjust the ticket\\'s available quantity.": "이용 가능한 티켓이 없습니다. 이 참가자에게 티켓을 배정하려면 티켓의 재고 수량을 조정해 주세요.",
+ "The ticket price ID is invalid.": "유효하지 않은 티켓 가격 ID입니다.",
+ "Ticket ID is not valid": "유효하지 않은 티켓 ID입니다",
+ "There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\\'s available quantity.": "이용 가능한 티켓이 없습니다. 이 참가자에게 이 티켓을 배정하려면 티켓의 재고 수량을 조정해 주세요.",
+ "Attendee ID is not valid": "유효하지 않은 참가자 ID입니다",
+ "The invitation does not exist": "초대가 존재하지 않습니다",
+ "The invitation has already been accepted": "초대가 이미 수락되었습니다",
+ "Organizer :id not found": ":id 주최자를 찾을 수 없습니다",
+ "Continue": "계속",
+ "Event :id not found": ":id 이벤트를 찾을 수 없습니다",
+ "You cannot change the currency of an event that has completed orders": "완료된 주문이 있는 이벤트는 통화를 변경할 수 없습니다",
+ "You must verify your account before you can update an event\\'s status.\n You can resend the confirmation by visiting your profile page.": "이벤트 상태를 변경하려면 먼저 계정을 인증해야 합니다.\\n 프로필 페이지에서 인증 메일을 다시 보낼 수 있습니다.",
+ "You cannot send messages until your account is verified.": "계정이 인증될 때까지 메시지를 보낼 수 없습니다.",
+ "Order not found": "주문을 찾을 수 없습니다",
+ "Order already cancelled": "이미 취소된 주문입니다",
+ "Failed to create attendee": "참가자 생성에 실패했습니다",
+ "This order is has already been processed": "이 주문은 이미 처리되었습니다",
+ "This order has expired": "이 주문은 만료되었습니다",
+ "This order has already been processed": "이 주문은 이미 처리되었습니다",
+ "There is an unexpected ticket price ID in the order": "주문에 예상치 못한 티켓 가격 ID가 있습니다",
+ "This event is not live.": "이 이벤트는 공개되지 않았습니다.",
+ "Sorry, we could not verify your session. Please create a new order.": "죄송합니다. 세션을 확인할 수 없습니다. 새 주문을 생성해 주세요.",
+ "There is no Stripe data associated with this order.": "이 주문에 연결된 Stripe 데이터가 없습니다.",
+ "There is already a refund pending for this order.\n Please wait for the refund to be processed before requesting another one.": "이미 이 주문에 대해 대기 중인 환불이 있습니다.\\n 처리가 완료될 때까지 기다린 후 다시 요청해 주세요.",
+ "Promo code :code already exists": "프로모션 코드 :code이(가) 이미 존재합니다",
+ "The code :code is already in use for this event": "코드 :code은(는) 이 이벤트에서 이미 사용 중입니다",
+ "You cannot delete this question as there as answers associated with it. You can hide the question instead.": "이 질문에 연결된 답변이 있어 삭제할 수 없습니다. 대신 질문을 숨길 수 있습니다.",
+ "One or more of the ordered question IDs do not exist for the event.": "정렬된 질문 ID 중 하나 이상이 이 이벤트에 존재하지 않습니다.",
+ "You cannot delete this ticket because it has orders associated with it. You can hide it instead.": "이 티켓에 연결된 주문이 있어 삭제할 수 없습니다. 대신 숨길 수 있습니다.",
+ "Ticket type cannot be changed as tickets have been registered for this type": "이 유형으로 등록된 티켓이 있어 티켓 유형을 변경할 수 없습니다",
+ "The ordered ticket IDs must exactly match all tickets for the event without missing or extra IDs.": "정렬된 티켓 ID는 누락되거나 추가된 ID 없이 이벤트의 모든 티켓과 정확히 일치해야 합니다.",
+ "No email change pending": "대기 중인 이메일 변경이 없습니다",
+ "The email :email already exists on this account": "이메일 :email은(는) 이미 이 계정에 존재합니다",
+ "You are not authorized to perform this action.": "이 작업을 수행할 권한이 없습니다.",
+ "Your account is not active.": "계정이 활성화되어 있지 않습니다.",
+ "Payload has expired or is invalid.": "페이로드가 만료되었거나 유효하지 않습니다.",
+ "Payload could not be decrypted.": "페이로드를 복호화할 수 없습니다.",
+ "Could not upload image to :disk. Check :disk is configured correctly": ":disk에 이미지를 업로드할 수 없습니다. :disk가 올바르게 설정되어 있는지 확인해 주세요",
+ "Could not upload image": "이미지를 업로드할 수 없습니다",
+ "Length must be a positive integer.": "길이는 양의 정수여야 합니다.",
+ "Prefix length exceeds the total desired token length.": "접두사 길이가 원하는 전체 토큰 길이를 초과합니다.",
+ "A valid email is required": "유효한 이메일 주소를 입력해 주세요",
+ "The title field is required": "제목을 입력해 주세요",
+ "The attribute name is required": "속성 이름을 입력해 주세요",
+ "The attribute value is required": "속성 값을 입력해 주세요",
+ "The attribute is_public fields is required": "is_public 속성을 입력해 주세요",
+ "Required questions have not been answered. You may need to reload the page.": "필수 질문에 답변하지 않았습니다. 페이지를 새로고침해야 할 수 있습니다.",
+ "This question is outdated. Please reload the page.": "이 질문은 오래된 정보입니다. 페이지를 새로고침해 주세요.",
+ "Please select an option": "옵션을 선택해 주세요",
+ "This field is required.": "이 항목은 필수입니다.",
+ "This field must be less than 255 characters.": "이 항목은 255자를 초과할 수 없습니다.",
+ "This field must be at least 2 characters.": "이 항목은 최소 2자 이상이어야 합니다.",
+ "Hello": "안녕하세요",
+ "You have requested to reset your password for your account on :appName.": ":appName 계정의 비밀번호 재설정을 요청하셨습니다.",
+ "Please click the link below to reset your password.": "아래 링크를 클릭하여 비밀번호를 재설정해 주세요.",
+ "Reset Password": "비밀번호 재설정",
+ "If you did not request a password reset, please ignore this email or reply to let us know.": "비밀번호 재설정을 요청하지 않으셨다면 이 이메일을 무시하시거나 답장으로 알려주세요.",
+ "Thank you": "감사합니다",
+ "Your password has been reset for your account on :appName.": ":appName 계정의 비밀번호가 재설정되었습니다.",
+ "If you did not request a password reset, please immediately contact reset your password.": "비밀번호 재설정을 요청하지 않으셨다면 즉시 비밀번호를 재설정해 주세요.",
+ "You are receiving this communication because you are registered as an attendee for the following event:": "다음 이벤트의 참가자로 등록되어 있어 이 메일을 받으셨습니다:",
+ "If you believe you have received this email in error,": "이 이메일을 잘못 받으셨다고 생각되시면",
+ "please contact the event organizer at": "다음 연락처로 이벤트 주최자에게 문의해 주세요",
+ "If you believe this is spam, please report it to": "스팸으로 의심되신다면 다음으로 신고해 주세요",
+ "You\\'re going to": "참석 예정 이벤트",
+ "Please find your ticket details below.": "아래에서 티켓 세부 정보를 확인하세요.",
+ "View Ticket": "티켓 보기",
+ "If you have any questions or need assistance, please reply to this email or contact the event organizer": "문의사항이나 도움이 필요하시면 이 이메일에 답장하시거나 이벤트 주최자에게 문의해 주세요",
+ "at": "에서",
+ "Best regards,": "감사합니다,",
+ "Your order for": "주문하신",
+ "has been cancelled.": "이(가) 취소되었습니다.",
+ "Order #:": "주문번호:",
+ "If you have any questions or need assistance, please respond to this email.": "문의사항이나 도움이 필요하시면 이 이메일에 답장해 주세요.",
+ "Your recent order for": "최근 주문하신",
+ "was not successful.": "이(가) 완료되지 않았습니다.",
+ "View Event Homepage": "이벤트 홈페이지 보기",
+ "If you have any questions or need assistance, feel free to reach out to our support team": "문의사항이나 도움이 필요하시면 언제든지 지원팀에 문의해 주세요",
+ "Best regards": "감사합니다",
+ "You have received a refund of :refundAmount for the following event: :eventTitle.": "다음 이벤트에 대해 :refundAmount 환불이 완료되었습니다: :eventTitle.",
+ "You\\'ve received a new order!": "새 주문이 접수되었습니다!",
+ "Congratulations! You\\'ve received a new order for ": "축하합니다! 새 주문이 접수되었습니다:",
+ "Please find the details below.": "아래에서 세부 정보를 확인하세요.",
+ "Order Amount:": "주문 금액:",
+ "Order ID:": "주문 ID:",
+ "View Order": "주문 보기",
+ "Ticket": "티켓",
+ "Price": "가격",
+ "Total": "합계",
+ "Your recent order for :eventTitle was not successful. The order expired while you were completing the payment. We have issued a refund for the order.": ":eventTitle에 대한 최근 주문이 완료되지 않았습니다. 결제 진행 중 주문이 만료되어 환불을 처리했습니다.",
+ "We apologize for the inconvenience. If you have any questions or need assistance, feel free to reach us at": "불편을 드려 죄송합니다. 문의사항이나 도움이 필요하시면 언제든지 다음으로 연락해 주세요",
+ "View Event Page": "이벤트 페이지 보기",
+ "Hi.Events": "Hi.Events",
+ "Your Order is Confirmed! ": "주문이 확정되었습니다!",
+ "Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.": "축하합니다! :eventDate :eventTime :eventTitle 주문이 완료되었습니다. 아래에서 주문 세부 정보를 확인하세요.",
+ "Event Details": "이벤트 상세 정보",
+ "Event Name:": "이벤트명:",
+ "Date & Time:": "날짜 및 시간:",
+ "Order Summary": "주문 요약",
+ "Order Number:": "주문 번호:",
+ "Total Amount:": "총 금액:",
+ "View Order Summary & Tickets": "주문 요약 및 티켓 보기",
+ "If you have any questions or need assistance, feel free to reach out to our friendly support team at": "문의사항이나 도움이 필요하시면 언제든지 친절한 지원팀에 문의해 주세요",
+ "What\\'s Next?": "다음 단계는?",
+ "Download Tickets:": "티켓 다운로드:",
+ "Please download your tickets from the order summary page.": "주문 요약 페이지에서 티켓을 다운로드해 주세요.",
+ "Prepare for the Event:": "이벤트 준비하기:",
+ "Make sure to note the event date, time, and location.": "이벤트 날짜, 시간, 장소를 꼭 확인해 두세요.",
+ "Stay Updated:": "최신 소식 받기:",
+ "Keep an eye on your email for any updates from the event organizer.": "이벤트 주최자로부터 오는 업데이트를 이메일로 확인해 주세요.",
+ "Hi :name": ":name님, 안녕하세요",
+ "Welcome to :appName! We\\'re excited to have you aboard!": ":appName에 오신 것을 환영합니다! 함께하게 되어 기쁩니다!",
+ "To get started and activate your account, please click the link below to confirm your email address:": "시작하려면 아래 링크를 클릭하여 이메일 주소를 확인하고 계정을 활성화해 주세요:",
+ "Confirm Your Email": "이메일 확인하기",
+ "If you did not create an account with us, no further action is required. Your email address will not be used without confirmation.": "계정을 생성하지 않으셨다면 별도의 조치가 필요하지 않습니다. 확인 없이는 이메일 주소가 사용되지 않습니다.",
+ "Best Regards,": "감사합니다,",
+ "The :appName Team": ":appName 팀 드림",
+ "You have requested to change your email address to :pendingEmail. Please click the link below to confirm this change.": "이메일 주소를 :pendingEmail(으)로 변경 요청하셨습니다. 아래 링크를 클릭하여 변경을 확인해 주세요.",
+ "If you did not request this change, please immediately change your password.": "이 변경을 요청하지 않으셨다면 즉시 비밀번호를 변경해 주세요.",
+ "Thanks,": "감사합니다,",
+ "You\\'ve been invited to join :appName.": ":appName에 참여하도록 초대되었습니다.",
+ "To accept the invitation, please click the link below:": "초대를 수락하려면 아래 링크를 클릭해 주세요:",
+ "Accept Invitation": "초대 수락",
+ "All rights reserved.": "모든 권리 보유.",
+ "Congratulations 🎉": "축하합니다 🎉",
+ "Sent Date Oldest": "발송일 오래된순",
+ "Sent Date Newest": "발송일 최신순",
+ "Subject A-Z": "제목 A-Z",
+ "Subject Z-A": "제목 Z-A",
+ "Name A-Z": "이름 A-Z",
+ "Name Z-A": "이름 Z-A",
+ "Updated oldest first": "오래된 업데이트순",
+ "Updated newest first": "최근 업데이트순",
+ "Most capacity used": "사용률 높은순",
+ "Least capacity used": "사용률 낮은순",
+ "Least capacity": "정원 적은순",
+ "Most capacity": "정원 많은순",
+ "Sorry, these tickets are sold out": "죄송합니다, 해당 티켓은 매진되었습니다",
+ "The maximum number of tickets available is :max": "구매 가능한 최대 티켓 수는 :max장입니다",
+ "Ticket is hidden without promo code": "프로모션 코드가 없으면 티켓이 숨겨집니다",
+ "Ticket is sold out": "티켓이 매진되었습니다",
+ "Ticket is before sale start date": "티켓 판매 시작일 이전입니다",
+ "Ticket is after sale end date": "티켓 판매 종료일 이후입니다",
+ "Ticket is hidden": "티켓이 숨겨져 있습니다",
+ "Price is before sale start date": "가격 판매 시작일 이전입니다",
+ "Price is after sale end date": "가격 판매 종료일 이후입니다",
+ "Price is sold out": "매진된 가격입니다",
+ "Price is hidden": "가격이 숨겨져 있습니다",
+ "Expires soonest": "만료 임박순",
+ "Expires latest": "만료 여유순",
+ "The expiration date must be after the activation date.": "만료일은 활성화일 이후여야 합니다.",
+ "The activation date must be before the expiration date.": "활성화일은 만료일 이전이어야 합니다.",
+ "Attendee :attendee_name is not allowed to check in using this check-in list": "참가자 :attendee_name은(는) 이 체크인 목록으로 체크인할 수 없습니다",
+ "Invalid attendee code detected: :attendees ": "유효하지 않은 참가자 코드가 감지되었습니다: :attendees",
+ "Check-in list not found": "체크인 목록을 찾을 수 없습니다",
+ "Attendee :attendee_name is already checked in": "참가자 :attendee_name은(는) 이미 체크인되었습니다",
+ "Check-in list has expired": "체크인 목록이 만료되었습니다",
+ "Check-in list is not active yes": "체크인 목록이 아직 활성화되지 않았습니다",
+ "This attendee is not checked in": "이 참가자는 체크인되지 않았습니다",
+ "Attendee does not belong to this check-in list": "참가자가 이 체크인 목록에 속하지 않습니다",
+ "Attendee :attendee_name\\'s ticket is cancelled": "참가자 :attendee_name의 티켓이 취소되었습니다",
+ "Check-in list is not active yet": "체크인 목록이 아직 활성화되지 않았습니다",
+ "The number of attendees does not match the number of tickets in the order": "참가자 수가 주문의 티켓 수와 일치하지 않습니다",
+ "Product is required": "상품을 선택해 주세요",
+ "Product price is required": "상품 가격을 입력해 주세요",
+ "Please select at least one product.": "상품을 하나 이상 선택해 주세요.",
+ "The sale start date must be after the product sale start date.": "판매 시작일은 상품 판매 시작일 이후여야 합니다.",
+ "You must select a product category.": "상품 카테고리를 선택해야 합니다.",
+ "Invalid direction. Must be either asc or desc": "유효하지 않은 정렬 방향입니다. asc 또는 desc여야 합니다",
+ "DomainObject must be a valid :interface.": "DomainObject는 유효한 :interface여야 합니다.",
+ "Nested relationships must be an array of Relationship objects.": "중첩된 관계는 Relationship 객체의 배열이어야 합니다.",
+ "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections는 OrderAndDirection 객체의 배열이어야 합니다.",
+ "Attendee :attendee_name\\'s product is cancelled": "참가자 :attendee_name의 상품이 취소되었습니다",
+ "Tickets": "티켓",
+ "There are no tickets available for this event.": "이 이벤트에는 이용 가능한 티켓이 없습니다.",
+ "You haven\\'t selected any products": "선택한 상품이 없습니다",
+ "The maximum number of products available for :products is :max": ":products에 대해 구매 가능한 최대 상품 수는 :max개입니다",
+ "You must order at least :min products for :product": ":product은(는) 최소 :min개 이상 주문해야 합니다",
+ "The product :product is sold out": ":product 상품이 매진되었습니다",
+ "The maximum number of products available for :product is :max": ":product에 대해 구매 가능한 최대 상품 수는 :max개입니다",
+ "Sorry, these products are sold out": "죄송합니다, 해당 상품은 매진되었습니다",
+ "The maximum number of products available is :max": "구매 가능한 최대 상품 수는 :max개입니다",
+ "Product with id :id not found": "ID가 :id인 상품을 찾을 수 없습니다",
+ "You cannot delete this product because it has orders associated with it. You can hide it instead.": "이 상품에 연결된 주문이 있어 삭제할 수 없습니다. 대신 숨길 수 있습니다.",
+ "Invalid product ids: :ids": "유효하지 않은 상품 ID: :ids",
+ "Product is hidden without promo code": "프로모션 코드가 없으면 상품이 숨겨집니다",
+ "Product is sold out": "상품이 매진되었습니다",
+ "Product is before sale start date": "상품 판매 시작일 이전입니다",
+ "Product is after sale end date": "상품 판매 종료일 이후입니다",
+ "Product is hidden": "상품이 숨겨져 있습니다",
+ "Cannot delete product price with id :id because it has sales": "판매 내역이 있어 ID :id인 상품 가격을 삭제할 수 없습니다",
+ "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "이 상품 카테고리에는 다음 상품이 포함되어 있어 삭제할 수 없습니다: :products. 이 상품들은 기존 주문에 연결되어 있습니다. 삭제하기 전에 :product_name을(를) 다른 카테고리로 이동해 주세요.",
+ "products": "상품",
+ "product": "상품",
+ "Product category :productCategoryId has been deleted.": "상품 카테고리 :productCategoryId이(가) 삭제되었습니다.",
+ "You cannot delete the last product category. Please create another category before deleting this one.": "마지막 상품 카테고리는 삭제할 수 없습니다. 삭제하기 전에 다른 카테고리를 먼저 생성해 주세요.",
+ "The product category with ID :id was not found.": "ID가 :id인 상품 카테고리를 찾을 수 없습니다.",
+ "Expired": "만료됨",
+ "Limit Reached": "한도 도달",
+ "Deleted": "삭제됨",
+ "Active": "활성",
+ "This ticket is invalid": "유효하지 않은 티켓입니다",
+ "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "이용 가능한 티켓이 없습니다. 이 참가자에게 상품을 배정하려면 상품의 재고 수량을 조정해 주세요.",
+ "The product price ID is invalid.": "유효하지 않은 상품 가격 ID입니다.",
+ "Product ID is not valid": "유효하지 않은 상품 ID입니다",
+ "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "이용 가능한 상품이 없습니다. 이 참가자에게 이 상품을 배정하려면 상품의 재고 수량을 조정해 주세요.",
+ "There is an unexpected product price ID in the order": "주문에 예상치 못한 상품 가격 ID가 있습니다",
+ "Product type cannot be changed as products have been registered for this type": "이 유형으로 등록된 상품이 있어 상품 유형을 변경할 수 없습니다",
+ "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "정렬된 카테고리 ID는 누락되거나 추가된 ID 없이 이벤트의 모든 카테고리와 정확히 일치해야 합니다.",
+ "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "정렬된 상품 ID는 누락되거나 추가된 ID 없이 이벤트의 모든 상품과 정확히 일치해야 합니다.",
+ "This product is outdated. Please reload the page.": "이 상품은 오래된 정보입니다. 페이지를 새로고침해 주세요.",
+ "Reserved": "예약됨",
+ "Cancelled": "취소됨",
+ "Completed": "완료됨",
+ "Awaiting offline payment": "무통장 입금 대기 중",
+ "ID": "ID",
+ "First Name": "이름",
+ "Last Name": "성",
+ "Email": "이메일",
+ "Status": "상태",
+ "Is Checked In": "체크인 여부",
+ "Checked In At": "체크인 시각",
+ "Product ID": "상품 ID",
+ "Product Name": "상품명",
+ "Event ID": "이벤트 ID",
+ "Public ID": "공개 ID",
+ "Short ID": "짧은 ID",
+ "Created Date": "생성일",
+ "Last Updated Date": "최종 수정일",
+ "Notes": "메모",
+ "Total Before Additions": "추가 항목 반영 전 합계",
+ "Total Gross": "총액(세전)",
+ "Total Tax": "총 세금",
+ "Total Fee": "총 수수료",
+ "Total Refunded": "총 환불액",
+ "Payment Status": "결제 상태",
+ "Refund Status": "환불 상태",
+ "Currency": "통화",
+ "Created At": "생성일시",
+ "Payment Gateway": "결제 게이트웨이",
+ "Is Partially Refunded": "부분 환불 여부",
+ "Is Fully Refunded": "전액 환불 여부",
+ "Is Free Order": "무료 주문 여부",
+ "Is Manually Created": "수동 생성 여부",
+ "Billing Address": "청구지 주소",
+ "Promo Code": "프로모션 코드",
+ "Failed to handle incoming Stripe webhook": "수신된 Stripe 웹훅 처리에 실패했습니다",
+ "Notes must be less than 2000 characters": "메모는 2000자를 초과할 수 없습니다",
+ "Invalid payment provider selected.": "유효하지 않은 결제 제공업체가 선택되었습니다.",
+ "Payment instructions are required when offline payments are enabled.": "무통장 입금이 활성화된 경우 결제 안내가 필요합니다.",
+ "The invoice prefix may only contain letters, numbers, and hyphens.": "인보이스 접두사는 문자, 숫자, 하이픈만 사용할 수 있습니다.",
+ "The organization name is required when invoicing is enabled.": "인보이스가 활성화된 경우 조직명이 필요합니다.",
+ "The organization address is required when invoicing is enabled.": "인보이스가 활성화된 경우 조직 주소가 필요합니다.",
+ "The invoice start number must be at least 1.": "인보이스 시작 번호는 1 이상이어야 합니다.",
+ "There is no default account configuration available": "사용 가능한 기본 계정 설정이 없습니다",
+ "Product price ID is not valid": "유효하지 않은 상품 가격 ID입니다",
+ "Invoice": "인보이스",
+ "Editing order with ID: :id": "ID: :id 주문 수정 중",
+ "Marking order as paid": "주문을 결제 완료로 표시 중",
+ "Received a :event Stripe event, which has no handler": "처리기가 없는 :event Stripe 이벤트를 수신했습니다",
+ "Order is not in the correct status to transition to offline payment": "무통장 입금 상태로 전환할 수 있는 주문 상태가 아닙니다",
+ "Order reservation has expired": "주문 예약이 만료되었습니다",
+ "Offline payments are not enabled for this event": "이 이벤트에는 무통장 입금이 활성화되어 있지 않습니다",
+ "There are no products available in this category": "이 카테고리에는 이용 가능한 상품이 없습니다",
+ "Webhook not found": "웹훅을 찾을 수 없습니다",
+ "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "참가자 :attendee_name의 주문이 결제 대기 중이어서 체크인할 수 없습니다",
+ "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "참가자 :attendee_name의 주문을 결제 완료로 표시할 수 없습니다. 이벤트 설정을 확인해 주세요",
+ "Invoice already exists": "인보이스가 이미 존재합니다",
+ "Invoice not found": "인보이스를 찾을 수 없습니다",
+ "Order is not awaiting offline payment": "무통장 입금 대기 중인 주문이 아닙니다",
+ "Refund already processed": "이미 환불이 처리되었습니다",
+ "Stripe refund successful": "Stripe 환불이 완료되었습니다",
+ "There are no tickets available for this event": "이 이벤트에는 이용 가능한 티켓이 없습니다",
+ "Address line 1 is required": "주소(1)를 입력해 주세요",
+ "City is required": "도시를 입력해 주세요",
+ "Zip or postal code is required": "우편번호를 입력해 주세요",
+ "Country is required": "국가를 입력해 주세요",
+ "If you did not request a password reset, please immediately reset your password.": "비밀번호 재설정을 요청하지 않으셨다면 즉시 비밀번호를 재설정해 주세요.",
+ "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ 주문이 결제 대기 중입니다. 티켓은 발급되었지만 결제가 확인될 때까지 유효하지 않습니다.",
+ "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ 이 주문은 결제 대기 중입니다. 결제가 확인되면 주문 관리 페이지에서 결제 완료로 표시해 주세요.",
+ "Order Status:": "주문 상태:",
+ "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "주문이 결제 대기 중입니다. 티켓은 발급되었지만 결제가 확인될 때까지 유효하지 않습니다.",
+ "Payment Instructions": "결제 안내",
+ "Please follow the instructions below to complete your payment.": "아래 안내에 따라 결제를 완료해 주세요.",
+ "Invoice Number": "인보이스 번호",
+ "Date Issued": "발행일",
+ "Due Date": "납부 기한",
+ "Amount Due": "청구 금액",
+ "Billed To": "청구 대상",
+ "DESCRIPTION": "내역",
+ "RATE": "단가",
+ "QTY": "수량",
+ "AMOUNT": "금액",
+ "Subtotal": "소계",
+ "Total Discount": "총 할인액",
+ "Total Service Fee": "총 서비스 수수료",
+ "Total Amount": "총 금액",
+ "For any queries, please contact us at": "문의사항은 다음으로 연락해 주세요",
+ "Tax Information": "세금 정보",
+ "Sales Ascending": "판매량 오름차순",
+ "Sales Descending": "판매량 내림차순",
+ "Revenue Ascending": "매출 오름차순",
+ "Revenue Descending": "매출 내림차순",
+ "Social": "소셜",
+ "Food & Drink": "음식 & 음료",
+ "Charity": "자선",
+ "Music": "음악",
+ "Art": "예술",
+ "Comedy": "코미디",
+ "Theater": "연극",
+ "Business": "비즈니스",
+ "Tech": "기술",
+ "Education": "교육",
+ "Workshop": "워크숍",
+ "Sports": "스포츠",
+ "Festival": "페스티벌",
+ "Nightlife": "나이트라이프",
+ "Other": "기타",
+ "Name": "이름",
+ "Code": "코드",
+ "Total Sales": "총 판매량",
+ "Total Sales Gross": "총 매출(세전)",
+ "Updated At": "수정일시",
+ "Question": "질문",
+ "Answer": "답변",
+ "Order ID": "주문 ID",
+ "Order Email": "주문 이메일",
+ "Attendee Name": "참가자 이름",
+ "Attendee Email": "참가자 이메일",
+ "Product": "상품",
+ "Order URL": "주문 URL",
+ "Attendee Answers": "참가자 답변",
+ "Order Name": "주문자 이름",
+ "Order Answers": "주문 답변",
+ "Product Answers": "상품 답변",
+ "Payment Provider": "결제 제공업체",
+ "Invalid invite token": "유효하지 않은 초대 토큰입니다",
+ "Affiliate not found": "제휴사를 찾을 수 없습니다",
+ "Organizer with ID :organizerId is not live and user is not authenticated": "ID가 :organizerId인 주최자가 공개되지 않았으며 사용자가 인증되지 않았습니다",
+ "Message sent successfully": "메시지가 성공적으로 전송되었습니다",
+ "Your email has been successfully verified!": "이메일이 성공적으로 인증되었습니다!",
+ "The image must be at least 600 pixels wide and 50 pixels tall, and no more than 4000 pixels wide and 4000 pixels tall.": "이미지는 가로 600픽셀, 세로 50픽셀 이상이어야 하며, 가로 4000픽셀, 세로 4000픽셀을 초과할 수 없습니다.",
+ "The image must be at least :minWidth x :minHeight pixels.": "이미지는 최소 :minWidth x :minHeight 픽셀이어야 합니다.",
+ "The entity ID is required when type is provided.": "유형이 제공된 경우 엔티티 ID가 필요합니다.",
+ "The type is required when entity ID is provided.": "엔티티 ID가 제공된 경우 유형이 필요합니다.",
+ "Your confirmation code for :app_name is :code": ":app_name의 인증 코드는 :code입니다",
+ "New message from :name": ":name님으로부터 새 메시지",
+ "An affiliate with this code already exists for this event": "이 코드로 등록된 제휴사가 이미 이 이벤트에 존재합니다",
+ "New password must be different from the old password.": "새 비밀번호는 기존 비밀번호와 달라야 합니다.",
+ "Due to issues with spam, you must contact us to enable your account for sending messages. ' .\n 'Please contact us at :email": "스팸 방지를 위해 메시지 발송 기능을 활성화하려면 저희에게 문의해 주세요. ' .\\n ':email로 연락해 주세요",
+ "Sorry, is expired or not in a valid state.": "죄송합니다. 만료되었거나 유효하지 않은 상태입니다.",
+ "Organizer not found": "주최자를 찾을 수 없습니다",
+ "You must verify your account before you can update an organizer\\'s status.\n You can resend the confirmation by visiting your profile page.": "주최자 상태를 변경하려면 먼저 계정을 인증해야 합니다.\\n 프로필 페이지에서 인증 메일을 다시 보낼 수 있습니다.",
+ "Your email address has already been verified.": "이메일 주소가 이미 인증되었습니다.",
+ "The verification code is invalid or has expired.": "인증 코드가 유효하지 않거나 만료되었습니다.",
+ "Job not found": "작업을 찾을 수 없습니다",
+ "Export file not found": "내보내기 파일을 찾을 수 없습니다",
+ "Job completed successfully": "작업이 성공적으로 완료되었습니다",
+ "Job is still in progress": "작업이 아직 진행 중입니다",
+ "Email Subject": "이메일 제목",
+ "© :year :app_name. All rights reserved.": "© :year :app_name. 모든 권리 보유.",
+ "Need help?": "도움이 필요하신가요?",
+ "Contact Support": "지원팀에 문의",
+ "Additional Information": "추가 정보",
+ "If you have any questions or need assistance, please contact": "문의사항이나 도움이 필요하시면 다음으로 연락해 주세요",
+ "Hello :name": ":name님, 안녕하세요",
+ "You have received a new message from": "새 메시지를 받았습니다:",
+ "Reply to :name": ":name님에게 답장",
+ "This message was sent via your organizer contact form.": "이 메시지는 주최자 문의 양식을 통해 전송되었습니다.",
+ "Your email confirmation code is:": "이메일 인증 코드:",
+ "Ticket Name A-Z": "티켓명 A-Z",
+ "Ticket Name Z-A": "티켓명 Z-A",
+ "Liquid": "Liquid",
+ "Blade": "Blade",
+ "Order Confirmation": "주문 확인",
+ "Attendee Ticket": "참가자 티켓",
+ "Sent to the customer after placing an order": "주문 완료 후 고객에게 발송됨",
+ "Sent to each attendee with their ticket": "각 참가자에게 티켓과 함께 발송됨",
+ "Abandoned": "중단됨",
+ "Check Ins": "체크인",
+ "Unknown": "알 수 없음",
+ "Opted In To Marketing": "마케팅 수신 동의",
+ "Unauthorized": "권한 없음",
+ "Configuration assigned successfully.": "설정이 성공적으로 적용되었습니다.",
+ "Impersonation started": "대리 로그인이 시작되었습니다",
+ "Not currently impersonating": "현재 대리 로그인 중이 아닙니다",
+ "Impersonation ended": "대리 로그인이 종료되었습니다",
+ "No user role found in token": "토큰에서 사용자 역할을 찾을 수 없습니다",
+ "You cannot modify email templates until your account is verified.": "계정이 인증될 때까지 이메일 템플릿을 수정할 수 없습니다.",
+ "Due to issues with spam, you must connect a Stripe account before you can modify email templates.": "스팸 방지를 위해 이메일 템플릿을 수정하려면 먼저 Stripe 계정을 연결해야 합니다.",
+ "Invalid template type": "유효하지 않은 템플릿 유형입니다",
+ "Superadmin user is viewing non-live event with ID :eventId": "최고 관리자가 공개되지 않은 이벤트(ID: :eventId)를 조회 중입니다",
+ "Invalid report type.": "유효하지 않은 보고서 유형입니다.",
+ "Date range must be less than 370 days.": "날짜 범위는 370일을 초과할 수 없습니다.",
+ "Attendee updated successfully": "참가자 정보가 성공적으로 수정되었습니다",
+ "Order updated successfully": "주문이 성공적으로 수정되었습니다",
+ "Ticket resent successfully": "티켓이 성공적으로 재발송되었습니다",
+ "Order confirmation resent successfully": "주문 확인서가 성공적으로 재발송되었습니다",
+ "If you have tickets associated with this email, we will send you an email with the details.": "이 이메일과 연결된 티켓이 있다면 세부 정보를 담은 이메일을 보내드리겠습니다.",
+ "The footer text may not be greater than 500 characters.": "푸터 텍스트는 500자를 초과할 수 없습니다.",
+ "The layout type must be default or modern.": "레이아웃 유형은 default 또는 modern이어야 합니다.",
+ "The mode must be light or dark.": "모드는 light 또는 dark여야 합니다.",
+ "The background type must be COLOR or MIRROR_COVER_IMAGE.": "배경 유형은 COLOR 또는 MIRROR_COVER_IMAGE여야 합니다.",
+ "First name must be a string": "이름은 문자열이어야 합니다",
+ "First name must not exceed 255 characters": "이름은 255자를 초과할 수 없습니다",
+ "Last name must be a string": "성은 문자열이어야 합니다",
+ "Last name must not exceed 255 characters": "성은 255자를 초과할 수 없습니다",
+ "Email must not exceed 255 characters": "이메일은 255자를 초과할 수 없습니다",
+ "Validation failed after multiple attempts: :error": "여러 번 시도했지만 유효성 검사에 실패했습니다: :error",
+ "Your Ticket Details Have Been Changed": "티켓 정보가 변경되었습니다",
+ "Your Order Details Have Been Changed": "주문 정보가 변경되었습니다",
+ "Response from :organizerName": ":organizerName의 답변",
+ "Your Tickets": "내 티켓",
+ "Invalid VAT number format": "유효하지 않은 부가가치세 번호 형식입니다",
+ "The system default configuration cannot be deleted.": "시스템 기본 설정은 삭제할 수 없습니다.",
+ "User does not belong to this account": "사용자가 이 계정에 속하지 않습니다",
+ "Impersonation not allowed": "대리 로그인이 허용되지 않습니다",
+ "Email template not found": "이메일 템플릿을 찾을 수 없습니다",
+ "Unable to verify Stripe signature with any platform": "어떤 플랫폼으로도 Stripe 서명을 확인할 수 없습니다",
+ "Order :id not found for event :eventId": "이벤트 :eventId에서 주문 :id을(를) 찾을 수 없습니다",
+ "Order is not in a valid status to be abandoned": "주문 상태가 중단 처리할 수 있는 상태가 아닙니다",
+ "Order has already expired": "주문이 이미 만료되었습니다",
+ "Attendee not found": "참가자를 찾을 수 없습니다",
+ "Event not found": "이벤트를 찾을 수 없습니다",
+ "Page must be a positive integer": "페이지는 양의 정수여야 합니다",
+ "Page not found": "페이지를 찾을 수 없습니다",
+ "Invalid or expired link. Please request a new one.": "유효하지 않거나 만료된 링크입니다. 새 링크를 요청해 주세요.",
+ "This link has expired. Please request a new one.": "이 링크는 만료되었습니다. 새 링크를 요청해 주세요.",
+ "SUPERADMIN users cannot be created through the application": "SUPERADMIN 사용자는 애플리케이션을 통해 생성할 수 없습니다",
+ "Cannot associate a user with SUPERADMIN role to an account": "SUPERADMIN 역할을 가진 사용자를 계정에 연결할 수 없습니다",
+ "View Order & Tickets": "주문 및 티켓 보기",
+ "Summer Music Festival 2024": "2024 여름 음악 페스티벌",
+ "3 Arena, North Wall Quay, Dublin 1, Ireland": "3 Arena, North Wall Quay, Dublin 1, Ireland",
+ "Join us for an unforgettable evening of live music featuring top artists from around the world.": "전 세계 최고의 아티스트들이 선사하는 잊지 못할 라이브 음악의 밤에 함께해요.",
+ "Please transfer the total amount to the following bank account within 5 business days.": "영업일 기준 5일 이내에 아래 계좌로 전체 금액을 입금해 주세요.",
+ "Thank you for your purchase! We look forward to seeing you at the event.": "구매해 주셔서 감사합니다! 행사장에서 뵙기를 기대하겠습니다.",
+ "Platform Fee": "플랫폼 수수료",
+ "Processing charge event": "결제 이벤트 처리 중",
+ "Charge not in succeeded status, skipping": "결제가 완료 상태가 아니어서 건너뜁니다",
+ "Stripe payment not found for charge": "결제에 대한 Stripe 결제 정보를 찾을 수 없습니다",
+ "Order not found for charge": "결제에 대한 주문을 찾을 수 없습니다",
+ "Extracting platform fee for order": "주문의 플랫폼 수수료 추출 중",
+ "Retrieving balance transaction from Stripe": "Stripe에서 잔액 거래 내역 조회 중",
+ "No balance transaction found for charge": "결제에 대한 잔액 거래 내역을 찾을 수 없습니다",
+ "Platform fee already stored for this transaction": "이 거래에 대한 플랫폼 수수료가 이미 저장되어 있습니다",
+ "Platform fee stored successfully": "플랫폼 수수료가 성공적으로 저장되었습니다",
+ "Failed to store platform fee": "플랫폼 수수료 저장에 실패했습니다",
+ "Ticket Reference": "티켓 참조번호",
+ "Order Reference": "주문 참조번호",
+ "The name of the event": "이벤트명",
+ "The event start date": "이벤트 시작일",
+ "The event start time": "이벤트 시작 시간",
+ "The event end date": "이벤트 종료일",
+ "The event end time": "이벤트 종료 시간",
+ "The full event address": "이벤트 전체 주소",
+ "The event venue name": "이벤트 장소명",
+ "The venue address line 1": "장소 주소(1)",
+ "The venue address line 2": "장소 주소(2)",
+ "The venue city": "장소 도시",
+ "The venue state or region": "장소 주/지역",
+ "The venue ZIP or postal code": "장소 우편번호",
+ "The venue country code": "장소 국가 코드",
+ "The event description": "이벤트 설명",
+ "The organizer\\'s name": "주최자 이름",
+ "The organizer\\'s email": "주최자 이메일",
+ "The support email address": "지원 이메일 주소",
+ "Instructions for offline payment": "무통장 입금 안내",
+ "Message shown after checkout": "결제 완료 후 표시되는 메시지",
+ "Link to view the order summary": "주문 요약 보기 링크",
+ "The order reference number": "주문 참조번호",
+ "The total order amount": "총 주문 금액",
+ "The order date": "주문일",
+ "The first name of the person who placed the order": "주문자 이름",
+ "The last name of the person who placed the order": "주문자 성",
+ "The email of the person who placed the order": "주문자 이메일",
+ "The attendee\\'s full name": "참가자 전체 이름",
+ "The attendee\\'s email": "참가자 이메일",
+ "The ticket type name": "티켓 유형명",
+ "The ticket price": "티켓 가격",
+ "Link to view/download the ticket": "티켓 보기/다운로드 링크",
+ "Stripe secret key not configured for platform: :platform": "플랫폼 :platform에 대한 Stripe 비밀 키가 설정되지 않았습니다",
+ "VIES service returned HTTP :status": "VIES 서비스가 HTTP :status을(를) 반환했습니다",
+ "VAT number is not valid according to VIES": "VIES에 따르면 유효하지 않은 부가가치세 번호입니다",
+ "Connection error: :error": "연결 오류: :error",
+ "Validation error: :error": "유효성 검사 오류: :error",
+ "VIES service is temporarily busy. Validation will be retried.": "VIES 서비스가 일시적으로 사용량이 많습니다. 검증이 재시도됩니다.",
+ "Member State service is temporarily unavailable. Validation will be retried.": "회원국 서비스가 일시적으로 사용할 수 없습니다. 검증이 재시도됩니다.",
+ "VIES service timed out. Validation will be retried.": "VIES 서비스가 시간 초과되었습니다. 검증이 재시도됩니다.",
+ "VIES server is busy. Validation will be retried.": "VIES 서버가 사용량이 많습니다. 검증이 재시도됩니다.",
+ "VIES service is unavailable. Validation will be retried.": "VIES 서비스를 사용할 수 없습니다. 검증이 재시도됩니다.",
+ "VIES service has reached maximum requests. Validation will be retried.": "VIES 서비스가 최대 요청 수에 도달했습니다. 검증이 재시도됩니다.",
+ "Invalid VAT number format.": "유효하지 않은 부가가치세 번호 형식입니다.",
+ "Invalid requester information.": "유효하지 않은 요청자 정보입니다.",
+ "VIES validation error: :code": "VIES 검증 오류: :code",
+ "Please confirm your email address": "이메일 주소를 확인해 주세요",
+ "Email addresses do not match": "이메일 주소가 일치하지 않습니다",
+ "Please confirm the email address": "이메일 주소를 확인해 주세요",
+ "Ticket Details Changed": "티켓 정보 변경됨",
+ "The details on your ticket for **:eventName** have been updated.": "**:eventName**의 티켓 정보가 업데이트되었습니다.",
+ "What Changed": "변경 내용",
+ "Security Information": "보안 정보",
+ "This change was made from IP address: :ipAddress": "이 변경은 IP 주소 :ipAddress에서 이루어졌습니다",
+ "If you did not make this change, please contact the event organizer immediately.": "본인이 변경하지 않았다면 즉시 이벤트 주최자에게 문의해 주세요.",
+ "Event Organizer: :organizerName": "이벤트 주최자: :organizerName",
+ "Contact: :email": "연락처: :email",
+ "Order Details Changed": "주문 정보 변경됨",
+ "The details on your order for **:eventName** have been updated.": "**:eventName**의 주문 정보가 업데이트되었습니다.",
+ "We found :count order(s) associated with :email.": ":email과(와) 연결된 주문 :count건을 찾았습니다.",
+ "Click the button below to view your tickets and order details.": "아래 버튼을 클릭하여 티켓과 주문 세부 정보를 확인하세요.",
+ "View My Tickets": "내 티켓 보기",
+ "This link will expire in 24 hours.": "이 링크는 24시간 후 만료됩니다.",
+ "If you did not request this, please ignore this email.": "요청하지 않으셨다면 이 이메일을 무시해 주세요.",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "일일 메시지 한도에 도달했습니다. 나중에 다시 시도하시거나 한도 증가를 위해 지원팀에 문의해 주세요.",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "수신자 수가 계정 한도를 초과했습니다. 한도 증가를 위해 지원팀에 문의해 주세요.",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "현재 계정 등급에서는 메시지에 링크를 포함할 수 없습니다. 이 기능을 사용하려면 지원팀에 문의해 주세요.",
+ "Deleted :count failed jobs": "실패한 작업 :count건을 삭제했습니다",
+ "Failed job not found": "실패한 작업을 찾을 수 없습니다",
+ "Queued :count jobs for retry": "재시도를 위해 작업 :count건을 대기열에 추가했습니다",
+ "Job queued for retry": "작업이 재시도 대기열에 추가되었습니다",
+ "Message approved and queued for sending": "메시지가 승인되어 발송 대기열에 추가되었습니다",
+ "Promo code not found": "프로모션 코드를 찾을 수 없습니다",
+ "Question not found": "질문을 찾을 수 없습니다",
+ "[Action Required] Message Pending Review - :subject": "[조치 필요] 검토 대기 중인 메시지 - :subject",
+ "Message not found": "메시지를 찾을 수 없습니다",
+ "Message must be in pending review status to be approved": "승인하려면 메시지가 검토 대기 상태여야 합니다",
+ "The :attribute must be a valid URL.": ":attribute은(는) 유효한 URL이어야 합니다.",
+ "The :attribute must use http or https protocol.": ":attribute은(는) http 또는 https 프로토콜을 사용해야 합니다.",
+ "The :attribute cannot point to localhost or internal addresses.": ":attribute은(는) localhost 또는 내부 주소를 가리킬 수 없습니다.",
+ "The :attribute cannot use reserved domain names.": ":attribute은(는) 예약된 도메인 이름을 사용할 수 없습니다.",
+ "The :attribute cannot point to cloud metadata endpoints.": ":attribute은(는) 클라우드 메타데이터 엔드포인트를 가리킬 수 없습니다.",
+ "The :attribute cannot point to private or internal IP addresses.": ":attribute은(는) 사설 또는 내부 IP 주소를 가리킬 수 없습니다.",
+ "A message has been flagged for review due to eligibility check failures.": "자격 확인 실패로 인해 메시지가 검토 대상으로 플래그 처리되었습니다.",
+ "Message Details": "메시지 상세 정보",
+ "Subject": "제목",
+ "Account": "계정",
+ "Event": "이벤트",
+ "Message ID": "메시지 ID",
+ "Eligibility Failures": "자격 확인 실패",
+ "Stripe payment account not connected": "Stripe 결제 계정이 연결되지 않았습니다",
+ "No completed paid orders on this account": "이 계정에 완료된 유료 주문이 없습니다",
+ "Event was created less than 24 hours ago": "이벤트가 생성된 지 24시간이 지나지 않았습니다",
+ "Review Message": "메시지 검토",
+ "A spot has opened up!": "자리가 났습니다!",
+ "An order has been reserved for you. Click the button below to complete your purchase.": "회원님을 위해 주문이 예약되었습니다. 아래 버튼을 클릭하여 구매를 완료해 주세요.",
+ "Complete Your Order": "주문 완료하기",
+ "If you are still interested, you may rejoin the waitlist from the event page.": "아직 관심이 있으시다면 이벤트 페이지에서 다시 대기자 명단에 등록할 수 있습니다.",
+ "View Event": "이벤트 보기",
+ "Your waitlist offer has expired": "대기자 명단 제안이 만료되었습니다",
+ "We'll notify you as soon as a spot becomes available.": "자리가 생기는 즉시 알려드리겠습니다.",
+ "You're on the waitlist!": "대기자 명단에 등록되었습니다!",
+ "Amount": "금액",
+ "Amount Paid": "결제 금액",
+ "Balance Due": "미납 잔액",
+ "Default": "기본값",
+ "Description": "설명",
+ "Google Tag Manager is not available on hosted plans for security reasons.": "보안상의 이유로 호스팅 플랜에서는 Google Tag Manager를 사용할 수 없습니다.",
+ "Must be 6-30 alphanumeric characters": "6~30자의 영숫자여야 합니다",
+ "Must be 9-20 digits (e.g., 1234567890)": "9~20자리 숫자여야 합니다 (예: 1234567890)",
+ "Must start with G- followed by 6-20 characters (e.g., G-XXXXXXXXXX)": "G-로 시작하고 이어서 6~20자가 와야 합니다 (예: G-XXXXXXXXXX)",
+ "Must start with GTM- followed by 4-20 characters (e.g., GTM-XXXXXXX)": "GTM-으로 시작하고 이어서 4~20자가 와야 합니다 (예: GTM-XXXXXXX)",
+ "Not enough products available. Please try again.": "이용 가능한 상품 수량이 부족합니다. 다시 시도해 주세요.",
+ "Only scheduled messages can be cancelled": "예약된 메시지만 취소할 수 있습니다",
+ "Order Ascending": "순서 오름차순",
+ "Order Descending": "순서 내림차순",
+ "Paid": "결제 완료",
+ "Position ascending": "위치 오름차순",
+ "Position descending": "위치 내림차순",
+ "Product not found for this event": "이 이벤트에서 상품을 찾을 수 없습니다",
+ "Qty": "수량",
+ "Rate": "단가",
+ "The scheduled time must be in the future.": "예약 시간은 미래여야 합니다.",
+ "The selected font is not supported.": "선택한 글꼴은 지원되지 않습니다.",
+ "There are no waiting entries for this product": "이 상품에 대한 대기 항목이 없습니다",
+ "This event cannot be deleted because it has completed orders. Please cancel or refund all orders first.": "완료된 주문이 있어 이 이벤트를 삭제할 수 없습니다. 먼저 모든 주문을 취소하거나 환불해 주세요.",
+ "This event has completed orders. Please cancel or refund all orders before deleting.": "이 이벤트에는 완료된 주문이 있습니다. 삭제하기 전에 모든 주문을 취소하거나 환불해 주세요.",
+ "This message can no longer be cancelled": "이 메시지는 더 이상 취소할 수 없습니다",
+ "This organizer has events with completed orders. Please cancel or refund all orders first.": "이 주최자에게는 완료된 주문이 있는 이벤트가 있습니다. 먼저 모든 주문을 취소하거나 환불해 주세요.",
+ "This waitlist entry cannot be cancelled": "이 대기자 명단 항목은 취소할 수 없습니다",
+ "This waitlist entry cannot be offered in its current status": "이 대기자 명단 항목은 현재 상태에서 제안할 수 없습니다",
+ "Unpaid": "미결제",
+ "Void": "무효",
+ "Waitlist entry not found": "대기자 명단 항목을 찾을 수 없습니다",
+ "Waitlist is not enabled for this product": "이 상품에는 대기자 명단이 활성화되어 있지 않습니다",
+ "You are already on the waitlist for this product": "이미 이 상품의 대기자 명단에 등록되어 있습니다",
+ "You cannot archive the last active organizer on your account.": "계정의 마지막 활성 주최자는 보관할 수 없습니다.",
+ "You cannot delete the last organizer on your account.": "계정의 마지막 주최자는 삭제할 수 없습니다.",
+ "You must acknowledge your data controller responsibilities before enabling tracking pixels.": "추적 픽셀을 활성화하기 전에 데이터 관리자 책임에 동의해야 합니다.",
+ "Date Cancellation": "회차 취소",
+ "Sent to attendees when a scheduled date is cancelled": "예정된 회차가 취소되었을 때 참가자에게 발송됩니다",
+ "Family": "가족",
+ "Hobbies": "취미",
+ "Wellness": "웰니스",
+ "Spirituality": "영성",
+ "Outdoors": "아웃도어",
+ "Tours": "투어",
+ "Film": "영화",
+ "Dance": "댄스",
+ "Seasonal": "시즌 행사",
+ "Classes": "클래스",
+ "Registration": "등록",
+ "Passes": "패스",
+ "There are no classes available for this event": "이 이벤트에는 이용 가능한 클래스가 없습니다",
+ "Registration is not open for this event": "이 이벤트는 등록이 열려 있지 않습니다",
+ "There are no passes available for this event": "이 이벤트에는 이용 가능한 패스가 없습니다",
+ "Book Now": "지금 예약",
+ "Register": "등록하기",
+ "Get Passes": "패스 받기",
+ "Earliest first": "빠른 날짜순",
+ "Latest first": "늦은 날짜순",
+ "Occurrence Date": "회차 날짜",
+ "Only the account owner can request account deletion.": "계정 소유자만 계정 삭제를 요청할 수 있습니다.",
+ "The fee currency of the :currency default configuration must remain :currency.": ":currency 기본 설정의 수수료 통화는 :currency(으)로 유지되어야 합니다.",
+ "Only pending deletion requests can be executed.": "대기 중인 삭제 요청만 실행할 수 있습니다.",
+ "Account deletion has been queued for execution.": "계정 삭제가 실행 대기열에 추가되었습니다.",
+ "Event approved and published": "이벤트가 승인되어 게시되었습니다",
+ "Event confirmed as spam": "이벤트가 스팸으로 확정되었습니다",
+ "Date range must be less than :days days.": "날짜 범위는 :days일 미만이어야 합니다.",
+ "Address suggestions are rate limited. Try again shortly or enter the address manually.": "주소 추천 요청이 제한되었습니다. 잠시 후 다시 시도하거나 주소를 직접 입력해 주세요.",
+ "Address suggestions are temporarily unavailable. Try again or enter the address manually.": "주소 추천 기능을 일시적으로 사용할 수 없습니다. 다시 시도하거나 주소를 직접 입력해 주세요.",
+ "Place lookups are rate limited. Try again shortly or enter the address manually.": "장소 조회 요청이 제한되었습니다. 잠시 후 다시 시도하거나 주소를 직접 입력해 주세요.",
+ "Place details are temporarily unavailable. Try again or enter the address manually.": "장소 상세 정보를 일시적으로 사용할 수 없습니다. 다시 시도하거나 주소를 직접 입력해 주세요.",
+ "Place not found or geo provider unavailable": "장소를 찾을 수 없거나 지리 정보 제공자를 사용할 수 없습니다",
+ "This account is scheduled for deletion. Cancel the deletion request to continue using it.": "이 계정은 삭제 예정입니다. 계속 사용하려면 삭제 요청을 취소해 주세요.",
+ "A saved location must be selected for in-person events": "오프라인 이벤트에는 저장된 위치를 선택해야 합니다",
+ "A saved location must be selected for in-person occurrences": "오프라인 회차에는 저장된 위치를 선택해야 합니다",
+ "Specify at least one occurrence_id, or set apply_to_all to true to update every matching occurrence.": "occurrence_id를 하나 이상 지정하거나, 일치하는 모든 회차를 수정하려면 apply_to_all을 true로 설정해 주세요.",
+ "Each time of day must be in HH:MM 24-hour format.": "각 시간은 HH:MM 24시간 형식이어야 합니다.",
+ "Each time of day object must include a time field.": "각 시간 객체에는 time 필드가 포함되어야 합니다.",
+ "Each time of day must be a HH:MM string or an object with a time field.": "각 시간은 HH:MM 문자열이거나 time 필드가 포함된 객체여야 합니다.",
+ "Select at least one product. To make a date inaccessible, cancel it from the schedule instead.": "상품을 하나 이상 선택해 주세요. 날짜를 이용할 수 없게 하려면 대신 일정에서 해당 날짜를 취소해 주세요.",
+ "Provide at least one address field (venue, street, city, or country).": "주소 필드(장소, 도로명, 도시 또는 국가)를 하나 이상 입력해 주세요.",
+ "Only one of event_occurrence_id or event_occurrence_ids may be provided.": "event_occurrence_id와 event_occurrence_ids 중 하나만 제공할 수 있습니다.",
+ "The order statuses field is required when sending messages to order owners with a specific product.": "특정 상품의 주문 소유자에게 메시지를 보낼 때는 주문 상태 필드가 필요합니다.",
+ "The subject must be less than 100 characters.": "제목은 100자 미만이어야 합니다.",
+ "You can only send a message to a maximum of 50 individual attendees at a time. To message more attendees, you can send to attendees with a specific product, or to all event attendees.": "한 번에 최대 50명의 개별 참가자에게만 메시지를 보낼 수 있습니다. 더 많은 참가자에게 메시지를 보내려면 특정 상품을 가진 참가자 또는 이벤트의 모든 참가자에게 보낼 수 있습니다.",
+ "You can only target up to 500 occurrences in a single message.": "하나의 메시지에서 최대 500개의 회차까지만 대상으로 지정할 수 있습니다.",
+ "The webhook URL exceeded the maximum number of redirects.": "웹훅 URL이 최대 리디렉션 횟수를 초과했습니다.",
+ "webhook URL": "웹훅 URL",
+ "Your :app_name account deletion has been cancelled": ":app_name 계정 삭제가 취소되었습니다",
+ "Your :app_name account has been deleted": ":app_name 계정이 삭제되었습니다",
+ "Reminder: your :app_name account will be deleted on :date": "알림: :app_name 계정이 :date에 삭제될 예정입니다",
+ "Your :app_name account is scheduled for deletion": ":app_name 계정이 삭제 예정입니다",
+ "[Action Required] Event Flagged as Spam - :title": "[조치 필요] 이벤트가 스팸으로 신고됨 - :title",
+ "Online event": "온라인 이벤트",
+ "Your event \":title\" is pending review": "\":title\" 이벤트가 검토 대기 중입니다",
+ ":event on :date has been cancelled": ":date의 :event 회차가 취소되었습니다",
+ "New message from your organizer page": "주최자 페이지에서 새 메시지가 도착했습니다",
+ "A spot has opened up for :event!": ":event에 자리가 생겼습니다!",
+ "The confirmation does not match your account name.": "확인 문구가 계정 이름과 일치하지 않습니다.",
+ "No flagged spam check found for this event": "이 이벤트에 대해 신고된 스팸 검사를 찾을 수 없습니다",
+ "Event must be pending manual review to be approved": "이벤트를 승인하려면 수동 검토 대기 상태여야 합니다",
+ "Event must be pending manual review to be confirmed as spam": "이벤트를 스팸으로 확정하려면 수동 검토 대기 상태여야 합니다",
+ "Default configurations cannot be deleted.": "기본 설정은 삭제할 수 없습니다.",
+ "This plan is still assigned to :count organizer(s). Reassign them before deleting it.": "이 요금제는 아직 :count명의 주최자에게 할당되어 있습니다. 삭제하기 전에 해당 주최자를 다른 요금제로 재할당해 주세요.",
+ "Announcement not found": "공지사항을 찾을 수 없습니다",
+ "(deactivated)": "(비활성화됨)",
+ "There are no tickets available. '.\n 'If you would like to assign a product to this attendee,'.\n ' please adjust the product\\'s available quantity.": "이용 가능한 티켓이 없습니다. 이 참가자에게 상품을 배정하려면 상품의 재고 수량을 조정해 주세요.",
+ "One or more selected taxes or fees could not be found.": "선택한 세금 또는 수수료 중 하나 이상을 찾을 수 없습니다.",
+ "An occurrence must be selected for recurring events.": "반복 이벤트에는 회차를 선택해야 합니다.",
+ "No occurrence found for this event.": "이 이벤트에 대한 회차를 찾을 수 없습니다.",
+ "Capacity assignment not found": "정원 할당을 찾을 수 없습니다",
+ "The default check-in list can\\'t be deleted.": "기본 체크인 목록은 삭제할 수 없습니다.",
+ "You cannot change the currency of an event that has paid orders. To use a different currency, duplicate the event and change the currency on the new event.": "유료 주문이 있는 이벤트의 통화는 변경할 수 없습니다. 다른 통화를 사용하려면 이벤트를 복제한 후 새 이벤트에서 통화를 변경해 주세요.",
+ "This event is pending manual review and its status cannot be changed until the review is complete.": "이 이벤트는 수동 검토 대기 중이며 검토가 완료될 때까지 상태를 변경할 수 없습니다.",
+ "This update would make the occurrence starting :start end before it starts. Adjust the time shift or duration.": "이 변경을 적용하면 :start에 시작하는 회차가 시작 전에 종료됩니다. 시간 이동 또는 소요 시간을 조정해 주세요.",
+ "Occurrence :id not found for event :eventId": "이벤트 :eventId에서 회차 :id을(를) 찾을 수 없습니다",
+ "This event has reached the maximum of :max occurrences.": "이 이벤트는 최대 회차 수인 :max개에 도달했습니다.",
+ "Cannot delete an occurrence that has orders. Cancel it instead.": "주문이 있는 회차는 삭제할 수 없습니다. 대신 취소해 주세요.",
+ "Cannot delete an occurrence that has attendees. Cancel it instead.": "참가자가 있는 회차는 삭제할 수 없습니다. 대신 취소해 주세요.",
+ "Occurrence :id not found for this event": "이 이벤트에서 회차 :id을(를) 찾을 수 없습니다",
+ "Both start_date_from and start_date_to are required.": "start_date_from과 start_date_to는 모두 필수입니다.",
+ "The date range is invalid.": "날짜 범위가 유효하지 않습니다.",
+ "The date range must be valid and span at most :days days.": "날짜 범위는 유효해야 하며 최대 :days일까지만 지정할 수 있습니다.",
+ "Price override :id not found for occurrence :occurrenceId": "회차 :occurrenceId에서 가격 재정의 :id을(를) 찾을 수 없습니다",
+ "Product price :id not found": "상품 가격 :id을(를) 찾을 수 없습니다",
+ "Product price :id does not belong to this event": "상품 가격 :id은(는) 이 이벤트에 속하지 않습니다",
+ "Only cancelled dates can be reactivated.": "취소된 날짜만 다시 활성화할 수 있습니다.",
+ "This date had ticket sales that were cancelled with it, so it can\\'t be reactivated automatically. Please create a new date instead.": "이 날짜에는 함께 취소된 티켓 판매 내역이 있어 자동으로 다시 활성화할 수 없습니다. 대신 새 날짜를 생성해 주세요.",
+ "This rule would generate too many occurrences. Please reduce the date range or frequency, or contact support.": "이 규칙은 너무 많은 회차를 생성합니다. 날짜 범위나 반복 주기를 줄이거나 지원팀에 문의해 주세요.",
+ "One or more product IDs do not belong to this event": "하나 이상의 상품 ID가 이 이벤트에 속하지 않습니다",
+ "Location not found": "위치를 찾을 수 없습니다",
+ "This location is referenced by one or more events or occurrences and cannot be deleted": "이 위치는 하나 이상의 이벤트 또는 회차에서 사용 중이므로 삭제할 수 없습니다",
+ "Another saved location already uses this place.": "다른 저장된 위치에서 이미 이 장소를 사용하고 있습니다.",
+ "Due to issues with spam, you must contact us to enable your account for sending messages. '.\n 'Please contact us at :email": "스팸 방지를 위해 메시지 발송 기능을 활성화하려면 저희에게 문의해 주셔야 합니다. :email로 연락해 주세요",
+ "This order was not paid with an offline payment method.": "이 주문은 오프라인 결제 수단으로 결제되지 않았습니다.",
+ "This order is awaiting payment, so there is nothing to refund.": "이 주문은 결제 대기 중이므로 환불할 금액이 없습니다.",
+ "This order has already been fully refunded.": "이 주문은 이미 전액 환불되었습니다.",
+ "The refund amount cannot exceed the amount available to refund.": "환불 금액은 환불 가능한 금액을 초과할 수 없습니다.",
+ ":item_count item(s) for event: :event_name (Order :order_short_id)": "이벤트 :event_name 상품 :item_count개 (주문 :order_short_id)",
+ "Organizer not found.": "주최자를 찾을 수 없습니다.",
+ "The source organizer was not found.": "원본 주최자를 찾을 수 없습니다.",
+ "The selected organizer does not have a connected Stripe account.": "선택한 주최자에게 연결된 Stripe 계정이 없습니다.",
+ "Stripe connection not found.": "Stripe 연결을 찾을 수 없습니다.",
+ "Tickets can\\'t be resent for a cancelled order.": "취소된 주문의 티켓은 재발송할 수 없습니다.",
+ "This ticket has been cancelled and can\\'t be resent.": "이 티켓은 취소되어 재발송할 수 없습니다.",
+ "The session for this ticket has been cancelled and can\\'t be resent.": "이 티켓의 세션이 취소되어 재발송할 수 없습니다.",
+ "An event date must be selected.": "이벤트 날짜를 선택해야 합니다.",
+ "This event date is no longer available.": "이 이벤트 날짜는 더 이상 이용할 수 없습니다.",
+ "Account deletion has already been requested.": "계정 삭제가 이미 요청되었습니다.",
+ "The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "다음 예정된 이벤트에 완료된 주문이 있습니다: :events. 계정을 삭제하기 전에 해당 주문을 취소하고 환불해 주세요.",
+ "There is no pending deletion request for this account.": "이 계정에는 대기 중인 삭제 요청이 없습니다.",
+ "Attendee :attendee_name does not belong to this event": "참가자 :attendee_name은(는) 이 이벤트에 속하지 않습니다",
+ ":attendee_name\\'s ticket is for a different session — check they\\'re on the right check-in list.": ":attendee_name님의 티켓은 다른 세션용입니다. 올바른 체크인 목록에 있는지 확인해 주세요.",
+ "Default check-in": "기본 체크인",
+ "Recurrence rule must include a frequency": "반복 규칙에는 반복 주기가 포함되어야 합니다",
+ "Additional recurrence dates must include a date": "추가 반복 날짜에는 날짜가 포함되어야 합니다",
+ "Recurrence additional_dates time must be in HH:MM 24-hour format": "반복 additional_dates의 시간은 HH:MM 24시간 형식이어야 합니다",
+ "Recurrence times_of_day entries must be in HH:MM 24-hour format": "반복 times_of_day 항목은 HH:MM 24시간 형식이어야 합니다",
+ "Recurrence time slots must include a time": "반복 시간 슬롯에는 시간이 포함되어야 합니다",
+ "Unsupported recurrence frequency": "지원되지 않는 반복 주기입니다",
+ "Event location :id not found for event :event": "이벤트 :event에서 이벤트 위치 :id을(를) 찾을 수 없습니다",
+ "The specified event occurrence was not found": "지정한 이벤트 회차를 찾을 수 없습니다",
+ "This event occurrence has been cancelled": "이 이벤트 회차는 취소되었습니다",
+ "This event has been cancelled": "이 이벤트는 취소되었습니다",
+ "This event occurrence has already ended": "이 이벤트 회차는 이미 종료되었습니다",
+ "This event has already ended": "이 이벤트는 이미 종료되었습니다",
+ "This event occurrence is sold out": "이 이벤트 회차는 매진되었습니다",
+ "This event is sold out": "이 이벤트는 매진되었습니다",
+ "Not enough capacity available for this occurrence": "이 회차에 이용 가능한 정원이 부족합니다",
+ "Not enough capacity available for this event": "이 이벤트에 이용 가능한 정원이 부족합니다",
+ "One or more selected products are not available for this occurrence": "선택한 상품 중 하나 이상을 이 회차에서 이용할 수 없습니다",
+ "Location :id not found": "위치 :id을(를) 찾을 수 없습니다",
+ "This event date has been cancelled": "이 이벤트 날짜는 취소되었습니다",
+ "This event date has already ended": "이 이벤트 날짜는 이미 종료되었습니다",
+ ":product is an add-on and can only be purchased with the product it belongs to": ":product은(는) 추가 상품이므로 해당 상품이 속한 상품과 함께만 구매할 수 있습니다",
+ "An event occurrence must be specified": "이벤트 회차를 지정해야 합니다",
+ ":product is not yet on sale": ":product은(는) 아직 판매가 시작되지 않았습니다",
+ "Sales for :product have ended": ":product의 판매가 종료되었습니다",
+ "Payment was successful, but the order is no longer valid. Order: :id": "결제는 완료되었지만 주문이 더 이상 유효하지 않습니다. 주문번호: :id",
+ "Payment was successful, but the event date is no longer available. Order: :id": "결제는 완료되었지만 이벤트 날짜를 더 이상 이용할 수 없습니다. 주문번호: :id",
+ "You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "이 상품은 연결된 주문이 있거나 진행 중인 결제에서 예약되어 있어 삭제할 수 없습니다. 대신 숨길 수 있습니다.",
+ "A product cannot be an add-on of itself": "상품은 자기 자신의 추가 상품이 될 수 없습니다",
+ "No capacity available for the selected waitlist entries.": "선택한 대기자 명단 항목에 이용 가능한 정원이 없습니다.",
+ "This waitlist entry is no longer linked to a valid event date.": "이 대기자 명단 항목은 더 이상 유효한 이벤트 날짜에 연결되어 있지 않습니다.",
+ "This event date is no longer available for this product.": "이 이벤트 날짜는 이 상품에 대해 더 이상 이용할 수 없습니다.",
+ "No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product or date.": "이 대기자 명단 항목에 제안할 정원이 없습니다. 상품 또는 날짜의 재고 수량을 늘려야 합니다.",
+ "Waitlist entry is missing an event date.": "대기자 명단 항목에 이벤트 날짜가 없습니다.",
+ "The occurrence start date": "회차 시작 날짜",
+ "The occurrence start time": "회차 시작 시간",
+ "The occurrence end date": "회차 종료 날짜",
+ "The occurrence end time": "회차 종료 시간",
+ "The occurrence title suffix": "회차 제목 접미사",
+ "Whether refunds are being processed for this cancellation": "이 취소에 대해 환불이 처리되는지 여부",
+ "Link to the event homepage": "이벤트 홈페이지 링크",
+ "Job failed": "작업 실패",
+ "The :attribute could not be resolved to a public address.": ":attribute을(를) 공개 주소로 확인할 수 없습니다.",
+ "The deletion of your :appName account has been cancelled. Your account is active again.": ":appName 계정 삭제가 취소되었습니다. 계정이 다시 활성화되었습니다.",
+ "Please note that your events were unpublished when the deletion was requested. You will need to republish any events you want to make publicly visible again.": "삭제 요청 시 이벤트가 게시 취소되었음을 참고해 주세요. 다시 공개하려는 이벤트는 직접 다시 게시해야 합니다.",
+ "Your :appName account has been permanently deleted.": ":appName 계정이 영구적으로 삭제되었습니다.",
+ "All personal information has been removed. Anonymized transaction records (amounts, dates, and invoice details) have been retained as required for legal and tax purposes.": "모든 개인 정보가 삭제되었습니다. 익명화된 거래 기록(금액, 날짜, 인보이스 세부 정보)은 법률 및 세무 목적으로 필요한 범위에서 보관됩니다.",
+ "All of your account data has been permanently removed.": "계정의 모든 데이터가 영구적으로 삭제되었습니다.",
+ "Thank you for using :appName. You are welcome back at any time.": ":appName을(를) 이용해 주셔서 감사합니다. 언제든지 다시 찾아 주세요.",
+ "This is a reminder that your :appName account is scheduled for permanent deletion on :date.": ":appName 계정이 :date에 영구적으로 삭제될 예정임을 알려드립니다.",
+ "If you want to keep your account, cancel the deletion before this date:": "계정을 유지하려면 이 날짜 이전에 삭제를 취소해 주세요:",
+ "Cancel Account Deletion": "계정 삭제 취소",
+ "If you take no action, the deletion will proceed automatically and cannot be undone.": "아무 조치도 취하지 않으면 삭제가 자동으로 진행되며 되돌릴 수 없습니다.",
+ "We have received a request to delete your :appName account. Your account has been deactivated and is scheduled for permanent deletion on :date.": ":appName 계정 삭제 요청을 접수했습니다. 계정이 비활성화되었으며 :date에 영구적으로 삭제될 예정입니다.",
+ "All of your published events have been unpublished. If you cancel the deletion, you will need to republish them manually.": "게시된 모든 이벤트가 게시 취소되었습니다. 삭제를 취소하는 경우 직접 다시 게시해야 합니다.",
+ "Because your account has completed orders, transaction records (amounts, dates, and invoice details) will be retained in an anonymized form for legal and tax purposes. All personal information will be permanently removed.": "계정에 완료된 주문이 있으므로 거래 기록(금액, 날짜, 인보이스 세부 정보)은 법률 및 세무 목적으로 익명화된 형태로 보관됩니다. 모든 개인 정보는 영구적으로 삭제됩니다.",
+ "Your account and all of its data will be permanently deleted.": "계정과 계정의 모든 데이터가 영구적으로 삭제됩니다.",
+ "If you did not request this, or you change your mind, you can cancel the deletion at any time before :date:": "본인이 요청하지 않았거나 마음이 바뀌셨다면 :date 이전에 언제든지 삭제를 취소할 수 있습니다:",
+ "An event has been flagged as potential spam and unpublished pending manual review.": "이벤트가 스팸 의심으로 신고되어 수동 검토가 완료될 때까지 게시 취소되었습니다.",
+ "Title": "제목",
+ "Organizer": "주최자",
+ "Verdict": "판정",
+ "Confidence": "신뢰도",
+ "Review Event": "이벤트 검토",
+ "Hi": "안녕하세요",
+ "Your event \":title\" has been temporarily unpublished while it undergoes a routine manual review.": "\":title\" 이벤트가 정기 수동 검토를 진행하는 동안 일시적으로 게시 취소되었습니다.",
+ "Our team will review your event shortly. Once the review is complete, your event will be published again automatically. No action is needed from you.": "저희 팀이 곧 이벤트를 검토할 예정입니다. 검토가 완료되면 이벤트가 자동으로 다시 게시됩니다. 별도의 조치는 필요하지 않습니다.",
+ "If you have any questions, please reply to this email.": "문의사항이 있으시면 이 이메일에 답장해 주세요.",
+ "the event organizer": "이벤트 주최자",
+ "This message is from :organizer, not from :platform.": "이 메시지는 :platform이(가) 아닌 :organizer이(가) 보낸 것입니다.",
+ "We\\'re sorry to let you know that **:event** scheduled for **:date** has been cancelled.": "**:date**에 예정되어 있던 **:event**이(가) 취소되었음을 알려드리게 되어 유감입니다.",
+ "If your order is eligible, a refund will be processed automatically and may take a few business days to appear on your statement. If we are unable to process it automatically, our team will be in touch.": "주문이 환불 대상인 경우 환불이 자동으로 처리되며 명세서에 표시되기까지 영업일 기준 며칠이 걸릴 수 있습니다. 자동으로 처리할 수 없는 경우 저희 팀에서 연락드리겠습니다.",
+ "If you have any questions about your order, please respond to this email.": "주문에 관해 문의사항이 있으시면 이 이메일에 답장해 주세요.",
+ "Session:": "세션:",
+ "Location:": "위치:",
+ "Ticket:": "티켓:",
+ "Attendee:": "참가자:",
+ ":date at :time": ":date :time",
+ "This message was submitted through your organizer contact form and may contain spam, suspicious links or malicious content. Please exercise caution when opening links, downloading attachments or replying.": "이 메시지는 주최자 문의 양식을 통해 제출되었으며 스팸, 의심스러운 링크 또는 악성 콘텐츠가 포함되어 있을 수 있습니다. 링크를 열거나 첨부 파일을 다운로드하거나 답장할 때 주의해 주세요.",
+ "You have been added to the waitlist for **:product** on **:date** for the event **:event**.": "**:event** 이벤트의 **:date** **:product** 대기자 명단에 등록되었습니다.",
+ "You have been added to the waitlist on **:date** for the event **:event**.": "**:event** 이벤트의 **:date** 대기자 명단에 등록되었습니다.",
+ "You have been added to the waitlist for **:product** for the event **:event**.": "**:event** 이벤트의 **:product** 대기자 명단에 등록되었습니다.",
+ "You have been added to the waitlist for the event **:event**.": "**:event** 이벤트의 대기자 명단에 등록되었습니다.",
+ "Unfortunately, your waitlist offer for **:product** on **:date** for the event **:event** has expired.": "안타깝게도 **:event** 이벤트의 **:date** **:product** 대기자 명단 제안이 만료되었습니다.",
+ "Unfortunately, your waitlist offer on **:date** for the event **:event** has expired.": "안타깝게도 **:event** 이벤트의 **:date** 대기자 명단 제안이 만료되었습니다.",
+ "Unfortunately, your waitlist offer for **:product** for the event **:event** has expired.": "안타깝게도 **:event** 이벤트의 **:product** 대기자 명단 제안이 만료되었습니다.",
+ "Unfortunately, your waitlist offer for the event **:event** has expired.": "안타깝게도 **:event** 이벤트의 대기자 명단 제안이 만료되었습니다.",
+ "Great news! A spot has become available for **:product** on **:date** for the event **:event**.": "좋은 소식입니다! **:event** 이벤트의 **:date** **:product**에 자리가 생겼습니다.",
+ "Great news! A spot has become available on **:date** for the event **:event**.": "좋은 소식입니다! **:event** 이벤트의 **:date**에 자리가 생겼습니다.",
+ "Great news! A spot has become available for **:product** for the event **:event**.": "좋은 소식입니다! **:event** 이벤트의 **:product**에 자리가 생겼습니다.",
+ "Great news! A spot has become available for the event **:event**.": "좋은 소식입니다! **:event** 이벤트에 자리가 생겼습니다.",
+ "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": "이전 등급이 매진될 때까지 이 가격은 잠겨 있습니다"
+}
diff --git a/backend/lang/pl.json b/backend/lang/pl.json
index dd6b6f31d0..71231b4d07 100644
--- a/backend/lang/pl.json
+++ b/backend/lang/pl.json
@@ -1,8 +1,8 @@
{
- "Older First": "Starsze najpierw",
- "Newer First": "Nowsze najpierw",
- "Recently Updated First": "Ostatnio zaktualizowane najpierw",
- "Recently Updated Last": "Ostatnio zaktualizowane ostatnio",
+ "Older First": "Od najstarszych",
+ "Newer First": "Od najnowszych",
+ "Recently Updated First": "Ostatnio aktualizowane na początku",
+ "Recently Updated Last": "Ostatnio aktualizowane na końcu",
"First Name A-Z": "Imię A-Z",
"First Name Z-A": "Imię Z-A",
"Last Name A-Z": "Nazwisko A-Z",
@@ -13,25 +13,25 @@
"Furthest start date": "Najdalsza data rozpoczęcia",
"Closest end date": "Najbliższa data zakończenia",
"Furthest end date": "Najdalsza data zakończenia",
- "Newest first": "Najnowsze najpierw",
- "Oldest first": "Najstarsze najpierw",
- "Recently Updated": "Ostatnio zaktualizowane",
- "Least Recently Updated": "Najmniej ostatnio zaktualizowane",
- "Oldest First": "Najstarsze najpierw",
- "Newest First": "Najnowsze najpierw",
- "Buyer Name A-Z": "Nazwa kupującego A-Z",
- "Buyer Name Z-A": "Nazwa kupującego Z-A",
+ "Newest first": "Od najnowszych",
+ "Oldest first": "Od najstarszych",
+ "Recently Updated": "Ostatnio aktualizowane",
+ "Least Recently Updated": "Najdawniej aktualizowane",
+ "Oldest First": "Od najstarszych",
+ "Newest First": "Od najnowszych",
+ "Buyer Name A-Z": "Imię i nazwisko kupującego A-Z",
+ "Buyer Name Z-A": "Imię i nazwisko kupującego Z-A",
"Amount Ascending": "Kwota rosnąco",
"Amount Descending": "Kwota malejąco",
- "Buyer Email A-Z": "Email kupującego A-Z",
- "Buyer Email Z-A": "Email kupującego Z-A",
- "Order # Ascending": "Zamówienie # rosnąco",
- "Order # Descending": "Zamówienie # malejąco",
+ "Buyer Email A-Z": "E-mail kupującego A-Z",
+ "Buyer Email Z-A": "E-mail kupującego Z-A",
+ "Order # Ascending": "Nr zamówienia rosnąco",
+ "Order # Descending": "Nr zamówienia malejąco",
"Code Name A-Z": "Nazwa kodu A-Z",
"Code Name Z-A": "Nazwa kodu Z-A",
"Usage Count Ascending": "Liczba użyć rosnąco",
"Usage Count Descending": "Liczba użyć malejąco",
- "Homepage order": "Zamówienie strony głównej",
+ "Homepage order": "Kolejność na stronie głównej",
"Title A-Z": "Tytuł A-Z",
"Title Z-A": "Tytuł Z-A",
"Sale start date closest": "Najbliższa data rozpoczęcia sprzedaży",
@@ -40,224 +40,224 @@
"Sale end date furthest": "Najdalsza data zakończenia sprzedaży",
"Account registration is disabled": "Rejestracja konta jest wyłączona",
"The invitation has expired": "Zaproszenie wygasło",
- "The invitation is invalid": "Zaproszenie jest nieprawidłowe",
- "Invitation valid, but user not found": "Zaproszenie ważne, ale użytkownik nie znaleziony",
- "No user found for this invitation. The invitation may have been revoked.": "Nie znaleziono użytkownika dla tego zaproszenia. Zaproszenie mogło zostać cofnięte.",
- "Logout Successful": "Wylogowanie pomyślne",
- "Your password has been reset. Please login with your new password.": "Twoje hasło zostało zresetowane. Zaloguj się nowym hasłem.",
+ "The invitation is invalid": "Zaproszenie jest nieważne",
+ "Invitation valid, but user not found": "Zaproszenie ważne, ale nie znaleziono użytkownika",
+ "No user found for this invitation. The invitation may have been revoked.": "Nie znaleziono użytkownika dla tego zaproszenia. Zaproszenie mogło zostać anulowane.",
+ "Logout Successful": "Wylogowano pomyślnie",
+ "Your password has been reset. Please login with your new password.": "Twoje hasło zostało zresetowane. Zaloguj się za pomocą nowego hasła.",
"No account ID found in token": "Nie znaleziono ID konta w tokenie",
- "Event with ID :eventId is not live and user is not authenticated": "Wydarzenie o ID :eventId nie jest aktywne, a użytkownik nie jest uwierzytelniony",
- "Sorry, we could not verify your session. Please restart your order.": "Przepraszamy, nie mogliśmy zweryfikować Twojej sesji. Uruchom ponownie zamówienie.",
- "The email confirmation link has expired. Please request a new one.": "Link potwierdzający email wygasł. Poproś o nowy.",
- "The email confirmation link is invalid.": "Link potwierdzający email jest nieprawidłowy.",
+ "Event with ID :eventId is not live and user is not authenticated": "Wydarzenie o ID :eventId nie jest aktywne, a użytkownik nie jest zalogowany",
+ "Sorry, we could not verify your session. Please restart your order.": "Przepraszamy, nie udało się zweryfikować Twojej sesji. Rozpocznij zamówienie ponownie.",
+ "The email confirmation link has expired. Please request a new one.": "Link do potwierdzenia adresu e-mail wygasł. Poproś o przesłanie nowego.",
+ "The email confirmation link is invalid.": "Link do potwierdzenia e-maila jest nieważny.",
"No invitation found for this user.": "Nie znaleziono zaproszenia dla tego użytkownika.",
- "User status is not Invited": "Status użytkownika nie jest Zaproszony",
- "Email is required": "Email jest wymagany",
- "Email must be a valid email address": "Email musi być prawidłowym adresem email",
+ "User status is not Invited": "Status użytkownika jest inny niż 'Zaproszony'",
+ "Email is required": "E-mail jest wymagany",
+ "Email must be a valid email address": "E-mail musi być poprawnym adresem e-mail",
"First name is required": "Imię jest wymagane",
"Last name is required": "Nazwisko jest wymagane",
"Ticket is required": "Bilet jest wymagany",
"Ticket price is required": "Cena biletu jest wymagana",
"Please enter a valid hex color code. In the format #000000 or #000.": "Wprowadź prawidłowy kod koloru hex. W formacie #000000 lub #000.",
- "The maximum timeout is 2 hours.": "Maksymalny czas oczekiwania to 2 godziny.",
- "The address line 1 field is required": "Pole adresu linia 1 jest wymagane",
- "The city field is required": "Pole miasta jest wymagane",
- "The zip or postal code field is required": "Pole kodu pocztowego jest wymagane",
- "The country field is required": "Pole kraju jest wymagane",
- "The country field should be a 2 character ISO 3166 code": "Pole kraju powinno być 2-znakowym kodem ISO 3166",
+ "The maximum timeout is 2 hours.": "Maksymalny limit czasu to 2 godziny.",
+ "The address line 1 field is required": "Pole adres jest wymagane",
+ "The city field is required": "Pole miasto jest wymagane",
+ "The zip or postal code field is required": "Pole kod pocztowy jest wymagane",
+ "The country field is required": "Pole kraj jest wymagane",
+ "The country field should be a 2 character ISO 3166 code": "Pole kraj powinno być 2-znakowym kodem ISO 3166",
"Please select at least one ticket.": "Wybierz przynajmniej jeden bilet.",
- "The sale end date must be after the sale start date.": "Data zakończenia sprzedaży musi być po dacie rozpoczęcia sprzedaży.",
+ "The sale end date must be after the sale start date.": "Data zakończenia sprzedaży musi być późniejsza niż data rozpoczęcia sprzedaży.",
"The sale end date must be a valid date.": "Data zakończenia sprzedaży musi być prawidłową datą.",
- "The sale start date must be after the ticket sale start date.": "Data rozpoczęcia sprzedaży musi być po dacie rozpoczęcia sprzedaży biletów.",
- "Welcome to :app_name! Please confirm your email address": "Witamy w :app_name! Potwierdź swój adres email",
+ "The sale start date must be after the ticket sale start date.": "Data rozpoczęcia sprzedaży musi następować po dacie rozpoczęcia sprzedaży biletów.",
+ "Welcome to :app_name! Please confirm your email address": "Witamy w :app_name! Potwierdź swój adres e-mail",
"🎟️ Your Ticket for :event": "🎟️ Twój bilet na :event",
"Your order has been cancelled": "Twoje zamówienie zostało anulowane",
"Your order wasn\\'t successful": "Twoje zamówienie nie powiodło się",
- "You\\'ve received a refund": "Otrzymałeś zwrot",
+ "You\\'ve received a refund": "Otrzymałeś zwrot środków",
"Your Order is Confirmed!": "Twoje zamówienie zostało potwierdzone!",
- "We were unable to process your order": "Nie mogliśmy przetworzyć twojego zamówienia",
- "New order for :amount for :event 🎉": "Nowe zamówienie na :amount za :event 🎉",
- "New order for :event 🎉": "Nowe zamówienie za :event 🎉",
- "Confirm email change": "Potwierdź zmianę email",
+ "We were unable to process your order": "Nie udało nam się przetworzyć Twojego zamówienia",
+ "New order for :amount for :event 🎉": "Nowe zamówienie na kwotę :amount na :event 🎉",
+ "New order for :event 🎉": "Nowe zamówienie na :event 🎉",
+ "Confirm email change": "Potwierdź zmianę e-maila",
"Password reset": "Reset hasła",
"Your password has been reset": "Twoje hasło zostało zresetowane",
"You\\'ve been invited to join :appName": "Zostałeś zaproszony do dołączenia do :appName",
- "Current account ID is not set": "Aktualne ID konta nie jest ustawione",
- "User not found in this account": "Użytkownik nie znaleziony na tym koncie",
- "User not found": "Użytkownik nie znaleziony",
+ "Current account ID is not set": "Bieżące ID konta nie jest ustawione",
+ "User not found in this account": "Użytkownik nie został znaleziony na tym koncie",
+ "User not found": "Nie znaleziono użytkownika",
"Username or Password are incorrect": "Nazwa użytkownika lub hasło są nieprawidłowe",
- "Account not found": "Konto nie znalezione",
- "Attempt to log in to a non-active account": "Próba logowania do nieaktywnego konta",
+ "Account not found": "Konto nie zostało znalezione",
+ "Attempt to log in to a non-active account": "Próba logowania na nieaktywne konto",
"User account is not active": "Konto użytkownika nie jest aktywne",
- "Invalid reset token": "Nieprawidłowy token resetowania",
+ "Invalid reset token": "Nieważny token resetowania",
"Reset token has expired": "Token resetowania wygasł",
- "Event daily statistics updated for event :event_id with total refunded amount of :amount": "Statystyki dzienne wydarzenia zaktualizowane dla wydarzenia :event_id z całkowitą kwotą zwrotu :amount",
- "Event statistics updated for event :event_id with total refunded amount of :amount": "Statystyki wydarzenia zaktualizowane dla wydarzenia :event_id z całkowitą kwotą zwrotu :amount",
- "This promo code is invalid": "Ten kod promocyjny jest nieprawidłowy",
+ "Event daily statistics updated for event :event_id with total refunded amount of :amount": "Dzienne statystyki wydarzenia zaktualizowane dla wydarzenia :event_id o łącznej kwocie zwrotów :amount",
+ "Event statistics updated for event :event_id with total refunded amount of :amount": "Statystyki wydarzenia zaktualizowane dla wydarzenia :event_id o łącznej kwocie zwrotów :amount",
+ "This promo code is invalid": "Ten kod promocyjny jest nieważny",
"You haven\\'t selected any tickets": "Nie wybrałeś żadnych biletów",
"The maximum number of tickets available for :tickets is :max": "Maksymalna liczba dostępnych biletów dla :tickets to :max",
- "You must order at least :min tickets for :ticket": "Musisz zamówić przynajmniej :min biletów dla :ticket",
+ "You must order at least :min tickets for :ticket": "Musisz zamówić co najmniej :min biletów na :ticket",
"The minimum amount is :price": "Minimalna kwota to :price",
"The ticket :ticket is sold out": "Bilet :ticket jest wyprzedany",
- ":field must be specified": ":field musi być określony",
- "Invalid price ID": "Nieprawidłowe ID ceny",
+ ":field must be specified": "Pole :field musi być określone",
+ "Invalid price ID": "Nieważne ID ceny",
"The maximum number of tickets available for :ticket is :max": "Maksymalna liczba dostępnych biletów dla :ticket to :max",
- "Ticket with id :id not found": "Bilet o ID :id nie znaleziony",
- "Failed to refund stripe charge": "Nie udało się zwrócić opłaty Stripe",
- "Payment was successful, but order has expired. Order: :id": "Płatność była udana, ale zamówienie wygasło. Zamówienie: :id",
- "Order is not awaiting payment. Order: :id": "Zamówienie nie oczekuje płatności. Zamówienie: :id",
+ "Ticket with id :id not found": "Bilet o ID :id nie został znaleziony",
+ "Failed to refund stripe charge": "Nie udało się zwrócić płatności Stripe",
+ "Payment was successful, but order has expired. Order: :id": "Płatność powiodła się, ale zamówienie wygasło. Zamówienie: :id",
+ "Order is not awaiting payment. Order: :id": "Zamówienie nie oczekuje na płatność. Zamówienie: :id",
"There was an error communicating with the payment provider. Please try again later.": "Wystąpił błąd podczas komunikacji z dostawcą płatności. Spróbuj ponownie później.",
- "Stripe Connect account not found for the event organizer": "Konto Stripe Connect nie znalezione dla organizatora wydarzenia",
- "Cannot Refund: Stripe connect account not found and saas_mode_enabled is enabled": "Nie można zwrócić: Konto Stripe Connect nie znalezione, a saas_mode_enabled jest włączony",
+ "Stripe Connect account not found for the event organizer": "Konto Stripe Connect nie zostało znalezione dla organizatora wydarzenia",
+ "Cannot Refund: Stripe connect account not found and saas_mode_enabled is enabled": "Nie można zwrócić środków: Konto Stripe Connect nie zostało znalezione, a tryb SaaS (saas_mode_enabled) jest włączony",
"Invalid calculation type": "Nieprawidłowy typ obliczeń",
- "One or more tax IDs are invalid": "Jeden lub więcej ID podatków jest nieprawidłowych",
+ "One or more tax IDs are invalid": "Jeden lub więcej identyfikatorów podatkowych (NIP) jest nieważnych",
"Invalid ticket ids: :ids": "Nieprawidłowe ID biletów: :ids",
- "Cannot delete ticket price with id :id because it has sales": "Nie można usunąć ceny biletu o ID :id, ponieważ ma sprzedaż",
- "Order has no order items": "Zamówienie nie ma pozycji zamówienia",
- "There is already an account associated with this email. Please log in instead.": "Istnieje już konto powiązane z tym emailem. Zaloguj się zamiast tego.",
- "Stripe Connect Account creation is only available in Saas Mode.": "Tworzenie konta Stripe Connect jest dostępne tylko w trybie Saas.",
+ "Cannot delete ticket price with id :id because it has sales": "Nie można usunąć ceny biletu o ID :id, istnieją powiązane zamówienia",
+ "Order has no order items": "Zamówienie nie ma pozycji",
+ "There is already an account associated with this email. Please log in instead.": "Istnieje już konto powiązane z tym adresem e-mail. Zaloguj się.",
+ "Stripe Connect Account creation is only available in Saas Mode.": "Tworzenie konta Stripe Connect jest dostępne tylko w trybie SaaS.",
"There are issues with creating or fetching the Stripe Connect Account. Please try again.": "Wystąpiły problemy z tworzeniem lub pobieraniem konta Stripe Connect. Spróbuj ponownie.",
"There are issues with creating the Stripe Connect Account Link. Please try again.": "Wystąpiły problemy z tworzeniem linku do konta Stripe Connect. Spróbuj ponownie.",
- "Cannot check in attendee as they are not active.": "Nie można zameldować uczestnika, ponieważ nie jest aktywny.",
- "in": "w",
- "out": "poza",
- "There are no tickets available. ' .\n 'If you would like to assign a ticket to this attendee,' .\n ' please adjust the ticket\\'s available quantity.": "Nie ma dostępnych biletów. Jeśli chcesz przypisać bilet temu uczestnikowi, dostosuj dostępną ilość biletu.",
- "The ticket price ID is invalid.": "ID ceny biletu jest nieprawidłowe.",
- "Ticket ID is not valid": "ID biletu nie jest prawidłowe",
- "There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\\'s available quantity.": "Nie ma dostępnych biletów. Jeśli chcesz przypisać ten bilet temu uczestnikowi, dostosuj dostępną ilość biletu.",
- "Attendee ID is not valid": "ID uczestnika nie jest prawidłowe",
+ "Cannot check in attendee as they are not active.": "Nie można zweryfikować uczestnika, ponieważ nie jest aktywny.",
+ "in": "wejście",
+ "out": "wyjście",
+ "There are no tickets available. ' .\n 'If you would like to assign a ticket to this attendee,' .\n ' please adjust the ticket\\'s available quantity.": "Brak dostępnych biletów. Jeśli chcesz przypisać bilet temu uczestnikowi, dostosuj dostępną ilość biletów.",
+ "The ticket price ID is invalid.": "ID ceny biletu jest nieważne.",
+ "Ticket ID is not valid": "ID biletu jest nieważne",
+ "There are no tickets available. If you would like to assign this ticket to this attendee, please adjust the ticket\\'s available quantity.": "Brak dostępnych biletów. Jeśli chcesz przypisać ten bilet temu uczestnikowi, dostosuj dostępną ilość biletów.",
+ "Attendee ID is not valid": "ID uczestnika jest nieważne",
"The invitation does not exist": "Zaproszenie nie istnieje",
- "The invitation has already been accepted": "Zaproszenie zostało już przyjęte",
- "Organizer :id not found": "Organizator :id nie znaleziony",
+ "The invitation has already been accepted": "Zaproszenie zostało już zaakceptowane",
+ "Organizer :id not found": "Organizator :id nie został znaleziony",
"Continue": "Kontynuuj",
- "Event :id not found": "Wydarzenie :id nie znalezione",
- "You cannot change the currency of an event that has completed orders": "Nie możesz zmienić waluty wydarzenia, które ma zakończone zamówienia",
- "You must verify your account before you can update an event\\'s status.\n You can resend the confirmation by visiting your profile page.": "Musisz zweryfikować swoje konto, zanim będziesz mógł zaktualizować status wydarzenia. Możesz ponownie wysłać potwierdzenie, odwiedzając stronę profilu.",
+ "Event :id not found": "Wydarzenie :id nie zostało znalezione",
+ "You cannot change the currency of an event that has completed orders": "Nie możesz zmienić waluty wydarzenia, które ma zrealizowane zamówienia",
+ "You must verify your account before you can update an event\\'s status.\n You can resend the confirmation by visiting your profile page.": "Musisz zweryfikować swoje konto przed aktualizacją statusu wydarzenia.\n Możesz ponownie wysłać e-mail z potwierdzeniem, odwiedzając stronę swojego profilu.",
"You cannot send messages until your account is verified.": "Nie możesz wysyłać wiadomości, dopóki Twoje konto nie zostanie zweryfikowane.",
- "Order not found": "Zamówienie nie znalezione",
- "Order already cancelled": "Zamówienie już anulowane",
+ "Order not found": "Zamówienie nie zostało znalezione",
+ "Order already cancelled": "Zamówienie zostało już anulowane",
"Failed to create attendee": "Nie udało się utworzyć uczestnika",
"This order is has already been processed": "To zamówienie zostało już przetworzone",
"This order has expired": "To zamówienie wygasło",
"This order has already been processed": "To zamówienie zostało już przetworzone",
- "There is an unexpected ticket price ID in the order": "W zamówieniu jest nieoczekiwane ID ceny biletu",
- "This event is not live.": "To wydarzenie nie jest aktywne.",
- "Sorry, we could not verify your session. Please create a new order.": "Przepraszamy, nie mogliśmy zweryfikować Twojej sesji. Utwórz nowe zamówienie.",
- "There is no Stripe data associated with this order.": "Nie ma danych Stripe powiązanych z tym zamówieniem.",
- "There is already a refund pending for this order.\n Please wait for the refund to be processed before requesting another one.": "Dla tego zamówienia jest już oczekujący zwrot. Poczekaj, aż zwrot zostanie przetworzony, zanim poprosisz o kolejny.",
+ "There is an unexpected ticket price ID in the order": "W zamówieniu znajduje się nieoczekiwane ID ceny biletu",
+ "This event is not live.": "To wydarzenie nie jest opublikowane.",
+ "Sorry, we could not verify your session. Please create a new order.": "Przepraszamy, nie udało się zweryfikować Twojej sesji. Utwórz nowe zamówienie.",
+ "There is no Stripe data associated with this order.": "Z tym zamówieniem nie są powiązane żadne dane Stripe.",
+ "There is already a refund pending for this order.\n Please wait for the refund to be processed before requesting another one.": "Dla tego zamówienia oczekuje już zwrot środków.\n Poczekaj na przetworzenie zwrotu przed zażądaniem kolejnego.",
"Promo code :code already exists": "Kod promocyjny :code już istnieje",
"The code :code is already in use for this event": "Kod :code jest już używany dla tego wydarzenia",
- "You cannot delete this question as there as answers associated with it. You can hide the question instead.": "Nie możesz usunąć tego pytania, ponieważ są z nim powiązane odpowiedzi. Możesz zamiast tego ukryć pytanie.",
- "One or more of the ordered question IDs do not exist for the event.": "Jeden lub więcej zamówionych ID pytań nie istnieje dla wydarzenia.",
- "You cannot delete this ticket because it has orders associated with it. You can hide it instead.": "Nie możesz usunąć tego biletu, ponieważ ma powiązane zamówienia. Możesz go zamiast tego ukryć.",
- "Ticket type cannot be changed as tickets have been registered for this type": "Typ biletu nie może zostać zmieniony, ponieważ bilety zostały zarejestrowane dla tego typu",
- "The ordered ticket IDs must exactly match all tickets for the event without missing or extra IDs.": "Zamówione ID biletów muszą dokładnie odpowiadać wszystkim biletom dla wydarzenia bez brakujących lub dodatkowych ID.",
- "No email change pending": "Brak oczekującej zmiany email",
- "The email :email already exists on this account": "Email :email już istnieje na tym koncie",
- "You are not authorized to perform this action.": "Nie jesteś upoważniony do wykonania tej akcji.",
+ "You cannot delete this question as there as answers associated with it. You can hide the question instead.": "Nie możesz usunąć tego pytania, ponieważ są do niego przypisane odpowiedzi. Zamiast tego możesz je ukryć.",
+ "One or more of the ordered question IDs do not exist for the event.": "Jedno lub więcej zamówionych ID pytań nie istnieje dla tego wydarzenia.",
+ "You cannot delete this ticket because it has orders associated with it. You can hide it instead.": "Nie możesz usunąć tego biletu, ponieważ są do niego przypisane zamówienia. Zamiast tego możesz go ukryć.",
+ "Ticket type cannot be changed as tickets have been registered for this type": "Nie można zmienić typu biletu, ponieważ dla tego typu zostały już zarejestrowane bilety.",
+ "The ordered ticket IDs must exactly match all tickets for the event without missing or extra IDs.": "Zamówione ID biletów muszą dokładnie pasować do wszystkich biletów na wydarzenie, bez brakujących ani dodatkowych ID.",
+ "No email change pending": "Brak oczekującej zmiany adresu e-mail",
+ "The email :email already exists on this account": "E-mail :email już istnieje na tym koncie",
+ "You are not authorized to perform this action.": "Nie masz uprawnień do wykonania tej operacji.",
"Your account is not active.": "Twoje konto nie jest aktywne.",
- "Payload has expired or is invalid.": "Ładunek wygasł lub jest nieprawidłowy.",
- "Payload could not be decrypted.": "Ładunek nie mógł zostać odszyfrowany.",
- "Could not upload image to :disk. Check :disk is configured correctly": "Nie udało się przesłać obrazu na :disk. Sprawdź, czy :disk jest prawidłowo skonfigurowany",
+ "Payload has expired or is invalid.": "Ładunek danych wygasł lub jest nieprawidłowy.",
+ "Payload could not be decrypted.": "Nie udało się odszyfrować ładunku danych.",
+ "Could not upload image to :disk. Check :disk is configured correctly": "Nie udało się przesłać obrazu na :disk. Sprawdź, czy :disk jest poprawnie skonfigurowany",
"Could not upload image": "Nie udało się przesłać obrazu",
"Length must be a positive integer.": "Długość musi być dodatnią liczbą całkowitą.",
- "Prefix length exceeds the total desired token length.": "Długość prefiksu przekracza całkowitą żądaną długość tokena.",
- "A valid email is required": "Wymagany jest prawidłowy email",
+ "Prefix length exceeds the total desired token length.": "Długość prefiksu przekracza żądaną całkowitą długość tokenu.",
+ "A valid email is required": "Wymagany jest prawidłowy e-mail",
"The title field is required": "Pole tytułu jest wymagane",
- "The attribute name is required": "Nazwa atrybutu jest wymagana",
- "The attribute value is required": "Wartość atrybutu jest wymagana",
- "The attribute is_public fields is required": "Pole is_public atrybutu jest wymagane",
- "Required questions have not been answered. You may need to reload the page.": "Wymagane pytania nie zostały odpowiedziane. Możesz potrzebować przeładować stronę.",
- "This question is outdated. Please reload the page.": "To pytanie jest przestarzałe. Przeładuj stronę.",
+ "The attribute name is required": "Atrybut nazwy jest wymagany",
+ "The attribute value is required": "Atrybut wartości jest wymagany",
+ "The attribute is_public fields is required": "Pola atrybutu is_public są wymagane",
+ "Required questions have not been answered. You may need to reload the page.": "Nie udzielono odpowiedzi na wymagane pytania. Może być konieczne odświeżenie strony.",
+ "This question is outdated. Please reload the page.": "To pytanie jest nieaktualne. Odśwież stronę.",
"Please select an option": "Wybierz opcję",
"This field is required.": "To pole jest wymagane.",
"This field must be less than 255 characters.": "To pole musi mieć mniej niż 255 znaków.",
- "This field must be at least 2 characters.": "To pole musi mieć przynajmniej 2 znaki.",
+ "This field must be at least 2 characters.": "To pole musi mieć co najmniej 2 znaki.",
"Hello": "Cześć",
- "You have requested to reset your password for your account on :appName.": "Poprosiłeś o reset hasła dla swojego konta na :appName.",
- "Please click the link below to reset your password.": "Kliknij poniższy link, aby zresetować hasło.",
+ "You have requested to reset your password for your account on :appName.": "Poprosiłeś o zresetowanie hasła do swojego konta w :appName.",
+ "Please click the link below to reset your password.": "Kliknij poniższy link, aby zresetować swoje hasło.",
"Reset Password": "Zresetuj hasło",
- "If you did not request a password reset, please ignore this email or reply to let us know.": "Jeśli nie prosiłeś o reset hasła, zignoruj ten email lub odpowiedz, aby dać nam znać.",
- "Thank you": "Dziękuję",
- "Your password has been reset for your account on :appName.": "Twoje hasło zostało zresetowane dla Twojego konta na :appName.",
- "If you did not request a password reset, please immediately contact reset your password.": "Jeśli nie prosiłeś o reset hasła, natychmiast skontaktuj się, aby zresetować hasło.",
- "You are receiving this communication because you are registered as an attendee for the following event:": "Otrzymujesz tę komunikację, ponieważ jesteś zarejestrowany jako uczestnik następującego wydarzenia:",
- "If you believe you have received this email in error,": "Jeśli uważasz, że otrzymałeś ten email przez pomyłkę,",
- "please contact the event organizer at": "skontaktuj się z organizatorem wydarzenia pod",
- "If you believe this is spam, please report it to": "Jeśli uważasz, że to spam, zgłoś to do",
- "You\\'re going to": "Idziesz do",
- "Please find your ticket details below.": "Znajdź szczegóły biletu poniżej.",
- "View Ticket": "Zobacz bilet",
- "If you have any questions or need assistance, please reply to this email or contact the event organizer": "Jeśli masz pytania lub potrzebujesz pomocy, odpowiedz na ten email lub skontaktuj się z organizatorem wydarzenia",
- "at": "pod",
- "Best regards,": "Z najlepszymi życzeniami,",
- "Your order for": "Twoje zamówienie za",
+ "If you did not request a password reset, please ignore this email or reply to let us know.": "Jeśli nie prosiłeś o reset hasła, zignoruj ten e-mail lub odpowiedz, aby nas o tym powiadomić.",
+ "Thank you": "Dziękujemy",
+ "Your password has been reset for your account on :appName.": "Twoje hasło do konta w :appName zostało zresetowane.",
+ "If you did not request a password reset, please immediately contact reset your password.": "Jeśli nie prosiłeś o zmianę hasła, natychmiast zresetuj swoje hasło.",
+ "You are receiving this communication because you are registered as an attendee for the following event:": "Otrzymujesz tę wiadomość, ponieważ jesteś zarejestrowany jako uczestnik następującego wydarzenia:",
+ "If you believe you have received this email in error,": "Jeśli uważasz, że otrzymałeś ten e-mail przez pomyłkę,",
+ "please contact the event organizer at": "skontaktuj się z organizatorem wydarzenia na adres",
+ "If you believe this is spam, please report it to": "Jeśli uważasz, że to spam, zgłoś to pod adresem",
+ "You\\'re going to": "Idziesz na",
+ "Please find your ticket details below.": "Poniżej znajdziesz szczegóły swojego biletu.",
+ "View Ticket": "Wyświetl bilet",
+ "If you have any questions or need assistance, please reply to this email or contact the event organizer": "Jeśli masz jakiekolwiek pytania lub potrzebujesz pomocy, odpowiedz na ten e-mail lub skontaktuj się z organizatorem wydarzenia",
+ "at": "pod adresem",
+ "Best regards,": "Pozdrawiamy,",
+ "Your order for": "Twoje zamówienie na",
"has been cancelled.": "zostało anulowane.",
- "Order #:": "Zamówienie #:",
- "If you have any questions or need assistance, please respond to this email.": "Jeśli masz pytania lub potrzebujesz pomocy, odpowiedz na ten email.",
- "Your recent order for": "Twoje ostatnie zamówienie za",
+ "Order #:": "Zamówienie nr:",
+ "If you have any questions or need assistance, please respond to this email.": "Jeśli masz jakiekolwiek pytania lub potrzebujesz pomocy, odpowiedz na ten e-mail.",
+ "Your recent order for": "Twoje ostatnie zamówienie na",
"was not successful.": "nie powiodło się.",
- "View Event Homepage": "Zobacz stronę wydarzenia",
- "If you have any questions or need assistance, feel free to reach out to our support team": "Jeśli masz pytania lub potrzebujesz pomocy, skontaktuj się z naszym zespołem wsparcia",
- "Best regards": "Z najlepszymi życzeniami",
+ "View Event Homepage": "Wyświetl stronę główną wydarzenia",
+ "If you have any questions or need assistance, feel free to reach out to our support team": "Jeśli masz jakiekolwiek pytania lub potrzebujesz pomocy, skontaktuj się z naszym zespołem wsparcia",
+ "Best regards": "Pozdrawiamy",
"You have received a refund of :refundAmount for the following event: :eventTitle.": "Otrzymałeś zwrot w wysokości :refundAmount za następujące wydarzenie: :eventTitle.",
"You\\'ve received a new order!": "Otrzymałeś nowe zamówienie!",
- "Congratulations! You\\'ve received a new order for ": "Gratulacje! Otrzymałeś nowe zamówienie za ",
- "Please find the details below.": "Znajdź szczegóły poniżej.",
+ "Congratulations! You\\'ve received a new order for ": "Gratulacje! Otrzymałeś nowe zamówienie na ",
+ "Please find the details below.": "Szczegóły znajdziesz poniżej.",
"Order Amount:": "Kwota zamówienia:",
"Order ID:": "ID zamówienia:",
- "View Order": "Zobacz zamówienie",
+ "View Order": "Wyświetl zamówienie",
"Ticket": "Bilet",
"Price": "Cena",
"Total": "Razem",
- "Your recent order for :eventTitle was not successful. The order expired while you were completing the payment. We have issued a refund for the order.": "Twoje ostatnie zamówienie za :eventTitle nie powiodło się. Zamówienie wygasło podczas dokonywania płatności. Wydaliśmy zwrot za zamówienie.",
- "We apologize for the inconvenience. If you have any questions or need assistance, feel free to reach us at": "Przepraszamy za niedogodności. Jeśli masz pytania lub potrzebujesz pomocy, skontaktuj się z nami pod",
- "View Event Page": "Zobacz stronę wydarzenia",
+ "Your recent order for :eventTitle was not successful. The order expired while you were completing the payment. We have issued a refund for the order.": "Twoje ostatnie zamówienie na :eventTitle nie powiodło się. Zamówienie wygasło podczas finalizowania płatności. Wydaliśmy zwrot środków za to zamówienie.",
+ "We apologize for the inconvenience. If you have any questions or need assistance, feel free to reach us at": "Przepraszamy za niedogodności. Jeśli masz jakiekolwiek pytania lub potrzebujesz pomocy, skontaktuj się z nami pod adresem",
+ "View Event Page": "Wyświetl stronę wydarzenia",
"Hi.Events": "Hi.Events",
"Your Order is Confirmed! ": "Twoje zamówienie zostało potwierdzone! ",
- "Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.": "Gratulacje! Twoje zamówienie za :eventTitle w dniu :eventDate o :eventTime było udane. Znajdź szczegóły zamówienia poniżej.",
+ "Congratulations! Your order for :eventTitle on :eventDate at :eventTime was successful. Please find your order details below.": "Gratulacje! Twoje zamówienie na :eventTitle z dnia :eventDate o godzinie :eventTime zakończyło się sukcesem. Poniżej znajdują się szczegóły zamówienia.",
"Event Details": "Szczegóły wydarzenia",
"Event Name:": "Nazwa wydarzenia:",
- "Date & Time:": "Data i czas:",
+ "Date & Time:": "Data i godzina:",
"Order Summary": "Podsumowanie zamówienia",
"Order Number:": "Numer zamówienia:",
"Total Amount:": "Całkowita kwota:",
- "View Order Summary & Tickets": "Zobacz podsumowanie zamówienia i bilety",
- "If you have any questions or need assistance, feel free to reach out to our friendly support team at": "Jeśli masz pytania lub potrzebujesz pomocy, skontaktuj się z naszym przyjaznym zespołem wsparcia pod",
+ "View Order Summary & Tickets": "Wyświetl podsumowanie zamówienia i bilety",
+ "If you have any questions or need assistance, feel free to reach out to our friendly support team at": "Jeśli masz jakiekolwiek pytania lub potrzebujesz pomocy, skontaktuj się z naszym przyjaznym zespołem wsparcia pod adresem",
"What\\'s Next?": "Co dalej?",
"Download Tickets:": "Pobierz bilety:",
"Please download your tickets from the order summary page.": "Pobierz swoje bilety ze strony podsumowania zamówienia.",
- "Prepare for the Event:": "Przygotuj się na wydarzenie:",
- "Make sure to note the event date, time, and location.": "Upewnij się, że zanotowałeś datę, czas i miejsce wydarzenia.",
+ "Prepare for the Event:": "Przygotuj się do wydarzenia:",
+ "Make sure to note the event date, time, and location.": "Upewnij się, że zapisałeś datę, godzinę i miejsce wydarzenia.",
"Stay Updated:": "Bądź na bieżąco:",
- "Keep an eye on your email for any updates from the event organizer.": "Śledź swój email na wszelkie aktualizacje od organizatora wydarzenia.",
+ "Keep an eye on your email for any updates from the event organizer.": "Śledź swoją skrzynkę e-mail, aby otrzymywać wszelkie aktualizacje od organizatora wydarzenia.",
"Hi :name": "Cześć :name",
- "Welcome to :appName! We\\'re excited to have you aboard!": "Witamy w :appName! Jesteśmy podekscytowani, że jesteś z nami!",
- "To get started and activate your account, please click the link below to confirm your email address:": "Aby zacząć i aktywować konto, kliknij poniższy link, aby potwierdzić adres email:",
- "Confirm Your Email": "Potwierdź swój email",
- "If you did not create an account with us, no further action is required. Your email address will not be used without confirmation.": "Jeśli nie utworzyłeś konta u nas, nie jest wymagane żadne dalsze działanie. Twój adres email nie będzie używany bez potwierdzenia.",
- "Best Regards,": "Z najlepszymi życzeniami,",
+ "Welcome to :appName! We\\'re excited to have you aboard!": "Witamy w :appName! Cieszymy się, że jesteś z nami!",
+ "To get started and activate your account, please click the link below to confirm your email address:": "Aby rozpocząć i aktywować swoje konto, kliknij poniższy link w celu potwierdzenia adresu e-mail:",
+ "Confirm Your Email": "Potwierdź swój e-mail",
+ "If you did not create an account with us, no further action is required. Your email address will not be used without confirmation.": "Jeśli nie tworzyłeś u nas konta, nie musisz podejmować żadnych działań. Twój e-mail nie zostanie użyty bez potwierdzenia.",
+ "Best Regards,": "Pozdrawiamy,",
"The :appName Team": "Zespół :appName",
- "You have requested to change your email address to :pendingEmail. Please click the link below to confirm this change.": "Poprosiłeś o zmianę adresu email na :pendingEmail. Kliknij poniższy link, aby potwierdzić zmianę.",
- "If you did not request this change, please immediately change your password.": "Jeśli nie prosiłeś o tę zmianę, natychmiast zmień hasło.",
+ "You have requested to change your email address to :pendingEmail. Please click the link below to confirm this change.": "Poprosiłeś o zmianę adresu e-mail na :pendingEmail. Kliknij poniższy link, aby potwierdzić tę zmianę.",
+ "If you did not request this change, please immediately change your password.": "Jeśli nie prosiłeś o tę zmianę, natychmiast zmień swoje hasło.",
"Thanks,": "Dzięki,",
"You\\'ve been invited to join :appName.": "Zostałeś zaproszony do dołączenia do :appName.",
- "To accept the invitation, please click the link below:": "Aby przyjąć zaproszenie, kliknij poniższy link:",
- "Accept Invitation": "Przyjmij zaproszenie",
- "All rights reserved.": "Wszystkie prawa zastrzeżone.",
- "Congratulations 🎉": "Gratulacje 🎉.",
- "Sent Date Oldest": "Data wysłania najstarsza",
- "Sent Date Newest": "Data wysłania najnowsza",
+ "To accept the invitation, please click the link below:": "Aby zaakceptować zaproszenie, kliknij poniższy link:",
+ "Accept Invitation": "Zaakceptuj zaproszenie",
+ "All rights reserved.": "Wszelkie prawa zastrzeżone.",
+ "Congratulations 🎉": "Gratulacje 🎉",
+ "Sent Date Oldest": "Data wysłania - od najstarszych",
+ "Sent Date Newest": "Data wysłania - od najnowszych",
"Subject A-Z": "Temat A-Z",
"Subject Z-A": "Temat Z-A",
"Name A-Z": "Nazwa A-Z",
"Name Z-A": "Nazwa Z-A",
- "Updated oldest first": "Zaktualizowane najstarsze najpierw",
- "Updated newest first": "Zaktualizowane najnowsze najpierw",
- "Most capacity used": "Najwięcej wykorzystanej pojemności",
- "Least capacity used": "Najmniej wykorzystanej pojemności",
- "Least capacity": "Najmniej pojemności",
- "Most capacity": "Najwięcej pojemności",
+ "Updated oldest first": "Aktualizowane - od najstarszych",
+ "Updated newest first": "Aktualizowane - od najnowszych",
+ "Most capacity used": "Największe zużycie pojemności",
+ "Least capacity used": "Najmniejsze zużycie pojemności",
+ "Least capacity": "Najmniejsza pojemność",
+ "Most capacity": "Największa pojemność",
"Sorry, these tickets are sold out": "Przepraszamy, te bilety są wyprzedane",
"The maximum number of tickets available is :max": "Maksymalna liczba dostępnych biletów to :max",
"Ticket is hidden without promo code": "Bilet jest ukryty bez kodu promocyjnego",
@@ -265,84 +265,84 @@
"Ticket is before sale start date": "Bilet jest przed datą rozpoczęcia sprzedaży",
"Ticket is after sale end date": "Bilet jest po dacie zakończenia sprzedaży",
"Ticket is hidden": "Bilet jest ukryty",
- "Price is before sale start date": "Cena jest przed datą rozpoczęcia sprzedaży",
- "Price is after sale end date": "Cena jest po dacie zakończenia sprzedaży",
- "Price is sold out": "Cena jest wyprzedana",
+ "Price is before sale start date": "Cena obowiązuje przed datą rozpoczęcia sprzedaży",
+ "Price is after sale end date": "Cena obowiązuje po dacie zakończenia sprzedaży",
+ "Price is sold out": "Wariant cenowy wyprzedany",
"Price is hidden": "Cena jest ukryta",
- "Expires soonest": "Wygasa najszybciej",
+ "Expires soonest": "Wygasa najwcześniej",
"Expires latest": "Wygasa najpóźniej",
"The expiration date must be after the activation date.": "Data wygaśnięcia musi być po dacie aktywacji.",
"The activation date must be before the expiration date.": "Data aktywacji musi być przed datą wygaśnięcia.",
- "Attendee :attendee_name is not allowed to check in using this check-in list": "Uczestnik :attendee_name nie może się zameldować używając tej listy zameldowań",
+ "Attendee :attendee_name is not allowed to check in using this check-in list": "Uczestnik :attendee_name nie może zostać zweryfikowany przy użyciu tej listy",
"Invalid attendee code detected: :attendees ": "Wykryto nieprawidłowy kod uczestnika: :attendees",
- "Check-in list not found": "Lista zameldowań nie znaleziona",
- "Attendee :attendee_name is already checked in": "Uczestnik :attendee_name jest już zameldowany",
- "Check-in list has expired": "Lista zameldowań wygasła",
- "Check-in list is not active yes": "Lista zameldowań nie jest aktywna tak",
- "This attendee is not checked in": "Ten uczestnik nie jest zameldowany",
- "Attendee does not belong to this check-in list": "Uczestnik nie należy do tej listy zameldowań",
+ "Check-in list not found": "Nie znaleziono listy weryfikacyjnej",
+ "Attendee :attendee_name is already checked in": "Uczestnik :attendee_name już został zweryfikowany",
+ "Check-in list has expired": "Lista weryfikacyjna wygasła",
+ "Check-in list is not active yes": "Lista weyfikacyjna jeszcze nie jest aktywna",
+ "This attendee is not checked in": "Ten uczestnik nie został jeszcze zweryfikowany",
+ "Attendee does not belong to this check-in list": "Uczestnik nie należy do tej listy weryfikacyjnej",
"Attendee :attendee_name\\'s ticket is cancelled": "Bilet uczestnika :attendee_name został anulowany",
- "Check-in list is not active yet": "Lista zameldowań nie jest jeszcze aktywna",
- "The number of attendees does not match the number of tickets in the order": "Liczba uczestników nie odpowiada liczbie biletów w zamówieniu",
+ "Check-in list is not active yet": "Lista weryfikacyjna nie jest jeszcze aktywna",
+ "The number of attendees does not match the number of tickets in the order": "Liczba uczestników nie zgadza się z liczbą biletów w zamówieniu",
"Product is required": "Produkt jest wymagany",
"Product price is required": "Cena produktu jest wymagana",
"Please select at least one product.": "Wybierz przynajmniej jeden produkt.",
"The sale start date must be after the product sale start date.": "Data rozpoczęcia sprzedaży musi być po dacie rozpoczęcia sprzedaży produktu.",
"You must select a product category.": "Musisz wybrać kategorię produktu.",
- "Invalid direction. Must be either asc or desc": "Nieprawidłowy kierunek. Musi być asc lub desc",
- "DomainObject must be a valid :interface.": "DomainObject musi być prawidłowy :interface.",
+ "Invalid direction. Must be either asc or desc": "Nieprawidłowy kierunek. Musi to być asc lub desc.",
+ "DomainObject must be a valid :interface.": "DomainObject musi być prawidłowym :interface.",
"Nested relationships must be an array of Relationship objects.": "Zagnieżdżone relacje muszą być tablicą obiektów Relationship.",
- "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections muszą być tablicą obiektów OrderAndDirection.",
- "Attendee :attendee_name\\'s product is cancelled": "Produkt uczestnika :attendee_name został anulowany",
+ "OrderAndDirections must be an array of OrderAndDirection objects.": "OrderAndDirections musi być tablicą obiektów OrderAndDirection.",
+ "Attendee :attendee_name\\'s product is cancelled": "Produkt uczestnika :attendee_name został anulowany.",
"Tickets": "Bilety",
- "There are no tickets available for this event.": "Nie ma dostępnych biletów dla tego wydarzenia.",
- "You haven\\'t selected any products": "Nie wybrałeś żadnych produktów",
- "The maximum number of products available for :products is :max": "Maksymalna liczba dostępnych produktów dla :products to :max",
- "You must order at least :min products for :product": "Musisz zamówić przynajmniej :min produktów dla :product",
- "The product :product is sold out": "Produkt :product jest wyprzedany",
- "The maximum number of products available for :product is :max": "Maksymalna liczba dostępnych produktów dla :product to :max",
- "Sorry, these products are sold out": "Przepraszamy, te produkty są wyprzedane",
- "The maximum number of products available is :max": "Maksymalna liczba dostępnych produktów to :max",
- "Product with id :id not found": "Produkt o ID :id nie znaleziony",
+ "There are no tickets available for this event.": "Brak dostępnych biletów na to wydarzenie.",
+ "You haven\\'t selected any products": "Nie wybrałeś żadnych produktów.",
+ "The maximum number of products available for :products is :max": "Maksymalna liczba dostępnych produktów dla :products to :max.",
+ "You must order at least :min products for :product": "Musisz zamówić co najmniej :min produktów na :product.",
+ "The product :product is sold out": "Produkt :product jest wyprzedany.",
+ "The maximum number of products available for :product is :max": "Maksymalna liczba dostępnych produktów dla :product to :max.",
+ "Sorry, these products are sold out": "Przepraszamy, te produkty są wyprzedane.",
+ "The maximum number of products available is :max": "Maksymalna liczba dostępnych produktów to :max.",
+ "Product with id :id not found": "Produkt o ID :id nie został znaleziony.",
"You cannot delete this product because it has orders associated with it or is reserved by a checkout in progress. You can hide it instead.": "Nie możesz usunąć tego produktu, ponieważ ma powiązane zamówienia lub jest zarezerwowany przez trwający proces zakupu. Możesz go zamiast tego ukryć.",
- "Invalid product ids: :ids": "Nieprawidłowe ID produktów: :ids",
- "Product is hidden without promo code": "Produkt jest ukryty bez kodu promocyjnego",
- "Product is sold out": "Produkt jest wyprzedany",
- "Product is before sale start date": "Produkt jest przed datą rozpoczęcia sprzedaży",
- "Product is after sale end date": "Produkt jest po dacie zakończenia sprzedaży",
- "Product is hidden": "Produkt jest ukryty",
- "Cannot delete product price with id :id because it has sales": "Nie można usunąć ceny produktu o ID :id, ponieważ ma sprzedaż",
- "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Nie możesz usunąć tej kategorii produktu, ponieważ zawiera następujące produkty: :products. Te produkty są połączone z istniejącymi zamówieniami. Przenieś :product_name do innej kategorii przed próbą usunięcia tej.",
+ "Invalid product ids: :ids": "Nieprawidłowe ID produktów: :ids.",
+ "Product is hidden without promo code": "Produkt jest ukryty bez kodu promocyjnego.",
+ "Product is sold out": "Produkt jest wyprzedany.",
+ "Product is before sale start date": "Produkt jest przed datą rozpoczęcia sprzedaży.",
+ "Product is after sale end date": "Produkt jest po dacie zakończenia sprzedaży.",
+ "Product is hidden": "Produkt jest ukryty.",
+ "Cannot delete product price with id :id because it has sales": "Nie można usunąć ceny produktu o ID :id, ponieważ posiada sprzedaż.",
+ "You cannot delete this product category because it contains the following products: :products. These products are linked to existing orders. Please move the :product_name to another category before attempting to delete this one.": "Nie możesz usunąć tej kategorii produktów, ponieważ zawiera następujące produkty: :products. Te produkty są powiązane z istniejącymi zamówieniami. Przenieś :product_name do innej kategorii przed próbą usunięcia tej.",
"products": "produkty",
"product": "produkt",
- "Product category :productCategoryId has been deleted.": "Kategoria produktu :productCategoryId została usunięta.",
- "You cannot delete the last product category. Please create another category before deleting this one.": "Nie możesz usunąć ostatniej kategorii produktu. Utwórz inną kategorię przed usunięciem tej.",
- "The product category with ID :id was not found.": "Kategoria produktu o ID :id nie została znaleziona.",
- "Expired": "Wygasł",
- "Limit Reached": "Limit osiągnięty",
- "Deleted": "Usunięty",
- "Active": "Aktywny",
- "This ticket is invalid": "Ten bilet jest nieprawidłowy",
- "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Nie ma dostępnych biletów. Jeśli chcesz przypisać produkt temu uczestnikowi, dostosuj dostępną ilość produktu.",
- "The product price ID is invalid.": "ID ceny produktu jest nieprawidłowe.",
- "Product ID is not valid": "ID produktu nie jest prawidłowe",
- "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Nie ma dostępnych produktów. Jeśli chcesz przypisać ten produkt temu uczestnikowi, dostosuj dostępną ilość produktu.",
- "There is an unexpected product price ID in the order": "W zamówieniu jest nieoczekiwane ID ceny produktu",
- "Product type cannot be changed as products have been registered for this type": "Typ produktu nie może zostać zmieniony, ponieważ produkty zostały zarejestrowane dla tego typu",
- "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Zamówione ID kategorii muszą dokładnie odpowiadać wszystkim kategoriom dla wydarzenia bez brakujących lub dodatkowych ID.",
- "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Zamówione ID produktów muszą dokładnie odpowiadać wszystkim produktom dla wydarzenia bez brakujących lub dodatkowych ID.",
- "This product is outdated. Please reload the page.": "Ten produkt jest przestarzały. Przeładuj stronę.",
- "Reserved": "Zarezerwowany",
- "Cancelled": "Anulowany",
- "Completed": "Zakończony",
- "Awaiting offline payment": "Oczekuje płatności offline",
+ "Product category :productCategoryId has been deleted.": "Kategoria produktów :productCategoryId została usunięta.",
+ "You cannot delete the last product category. Please create another category before deleting this one.": "Nie możesz usunąć ostatniej kategorii produktów. Utwórz inną kategorię przed usunięciem tej.",
+ "The product category with ID :id was not found.": "Kategoria produktów o ID :id nie została znaleziona.",
+ "Expired": "Wygasłe",
+ "Limit Reached": "Osiągnięto limit",
+ "Deleted": "Usunięte",
+ "Active": "Aktywne",
+ "This ticket is invalid": "Ten bilet jest nieważny.",
+ "There are no tickets available. ' .\n 'If you would like to assign a product to this attendee,' .\n ' please adjust the product\\'s available quantity.": "Brak dostępnych biletów. Jeśli chcesz przypisać produkt do tego uczestnika, dostosuj dostępną ilość produktu.",
+ "The product price ID is invalid.": "ID ceny produktu jest nieważne.",
+ "Product ID is not valid": "ID produktu jest nieważne.",
+ "There are no products available. If you would like to assign this product to this attendee, please adjust the product\\'s available quantity.": "Brak dostępnych produktów. Jeśli chcesz przypisać ten produkt do tego uczestnika, dostosuj dostępną ilość produktu.",
+ "There is an unexpected product price ID in the order": "W zamówieniu znajduje się nieoczekiwane ID ceny produktu.",
+ "Product type cannot be changed as products have been registered for this type": "Nie można zmienić typu produktu, ponieważ dla tego typu zostały już zarejestrowane produkty.",
+ "The ordered category IDs must exactly match all categories for the event without missing or extra IDs.": "Zamówione ID kategorii muszą dokładnie odpowiadać wszystkim kategoriom dla wydarzenia, bez brakujących lub dodatkowych ID.",
+ "The ordered product IDs must exactly match all products for the event without missing or extra IDs.": "Zamówione ID produktów muszą dokładnie odpowiadać wszystkim produktom na wydarzenie, bez brakujących ani dodatkowych ID.",
+ "This product is outdated. Please reload the page.": "Ten produkt jest nieaktualny. Odśwież stronę.",
+ "Reserved": "Zarezerwowane",
+ "Cancelled": "Anulowane",
+ "Completed": "Zakończone",
+ "Awaiting offline payment": "Oczekuje na płatność offline",
"ID": "ID",
"First Name": "Imię",
"Last Name": "Nazwisko",
- "Email": "Email",
+ "Email": "E-mail",
"Status": "Status",
- "Is Checked In": "Jest zameldowany",
- "Checked In At": "Zameldowany o",
+ "Is Checked In": "Zweryfikowany",
+ "Checked In At": "Zweryfikowano o",
"Product ID": "ID produktu",
"Product Name": "Nazwa produktu",
"Event ID": "ID wydarzenia",
@@ -351,20 +351,20 @@
"Created Date": "Data utworzenia",
"Last Updated Date": "Data ostatniej aktualizacji",
"Notes": "Notatki",
- "Total Before Additions": "Razem przed dodatkami",
- "Total Gross": "Razem brutto",
- "Total Tax": "Razem podatek",
- "Total Fee": "Razem opłata",
- "Total Refunded": "Razem zwrócone",
+ "Total Before Additions": "Suma przed dodatkami",
+ "Total Gross": "Suma brutto",
+ "Total Tax": "Suma podatku",
+ "Total Fee": "Całkowita opłata",
+ "Total Refunded": "Całkowita zwrócona kwota",
"Payment Status": "Status płatności",
"Refund Status": "Status zwrotu",
"Currency": "Waluta",
- "Created At": "Utworzone o",
- "Payment Gateway": "Bramka płatności",
- "Is Partially Refunded": "Jest częściowo zwrócone",
- "Is Fully Refunded": "Jest w pełni zwrócone",
- "Is Free Order": "Jest darmowym zamówieniem",
- "Is Manually Created": "Jest utworzone ręcznie",
+ "Created At": "Utworzono",
+ "Payment Gateway": "Bramka płatnicza",
+ "Is Partially Refunded": "Częściowo zwrócono",
+ "Is Fully Refunded": "W pełni zwrócono",
+ "Is Free Order": "Darmowe zamówienie",
+ "Is Manually Created": "Ręcznie utworzone",
"Billing Address": "Adres rozliczeniowy",
"Promo Code": "Kod promocyjny",
"Failed to handle incoming Stripe webhook": "Nie udało się obsłużyć przychodzącego webhooka Stripe",
@@ -374,42 +374,42 @@
"The invoice prefix may only contain letters, numbers, and hyphens.": "Prefiks faktury może zawierać tylko litery, cyfry i myślniki.",
"The organization name is required when invoicing is enabled.": "Nazwa organizacji jest wymagana, gdy fakturowanie jest włączone.",
"The organization address is required when invoicing is enabled.": "Adres organizacji jest wymagany, gdy fakturowanie jest włączone.",
- "The invoice start number must be at least 1.": "Numer startowy faktury musi wynosić co najmniej 1.",
- "There is no default account configuration available": "Nie ma dostępnej domyślnej konfiguracji konta",
- "Product price ID is not valid": "ID ceny produktu nie jest prawidłowe",
+ "The invoice start number must be at least 1.": "Numer początkowy faktury musi wynosić co najmniej 1.",
+ "There is no default account configuration available": "Brak dostępnej domyślnej konfiguracji konta",
+ "Product price ID is not valid": "ID ceny produktu nie jest ważne",
"Invoice": "Faktura",
"Editing order with ID: :id": "Edycja zamówienia o ID: :id",
"Marking order as paid": "Oznaczanie zamówienia jako opłacone",
- "Received a :event Stripe event, which has no handler": "Otrzymano zdarzenie :event Stripe, które nie ma handlera",
- "Order is not in the correct status to transition to offline payment": "Zamówienie nie ma prawidłowego statusu, aby przejść do płatności offline",
+ "Received a :event Stripe event, which has no handler": "Otrzymano zdarzenie Stripe :event, które nie ma procedury obsługi",
+ "Order is not in the correct status to transition to offline payment": "Zamówienie nie ma odpowiedniego statusu, aby przejść na płatność offline",
"Order reservation has expired": "Rezerwacja zamówienia wygasła",
"Offline payments are not enabled for this event": "Płatności offline nie są włączone dla tego wydarzenia",
- "There are no products available in this category": "Nie ma dostępnych produktów w tej kategorii",
- "Webhook not found": "Webhook nie znaleziony",
- "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Nie można zameldować, ponieważ zamówienie uczestnika :attendee_name oczekuje płatności",
- "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "Zamówienie uczestnika :attendee_name nie może zostać oznaczone jako opłacone. Sprawdź ustawienia wydarzenia",
+ "There are no products available in this category": "Brak dostępnych produktów w tej kategorii",
+ "Webhook not found": "Nie znaleziono webhooka",
+ "Unable to check in as attendee :attendee_name\\'s order is awaiting payment": "Nie można zweryfikować, ponieważ zamówienie uczestnika :attendee_name oczekuje na płatność",
+ "Attendee :attendee_name\\'s order cannot be marked as paid. Please check your event settings": "Zamówienie uczestnika :attendee_name nie może zostać oznaczone jako opłacone. Sprawdź ustawienia wydarzenia.",
"Invoice already exists": "Faktura już istnieje",
- "Invoice not found": "Faktura nie znaleziona",
- "Order is not awaiting offline payment": "Zamówienie nie oczekuje płatności offline",
- "Refund already processed": "Zwrot już przetworzony",
- "Stripe refund successful": "Zwrot Stripe udany",
- "There are no tickets available for this event": "Nie ma dostępnych biletów dla tego wydarzenia",
+ "Invoice not found": "Nie znaleziono faktury",
+ "Order is not awaiting offline payment": "Zamówienie nie oczekuje na płatność offline",
+ "Refund already processed": "Zwrot został już przetworzony",
+ "Stripe refund successful": "Zwrot przez Stripe powiódł się",
+ "There are no tickets available for this event": "Brak dostępnych biletów na to wydarzenie",
"Address line 1 is required": "Linia adresu 1 jest wymagana",
"City is required": "Miasto jest wymagane",
"Zip or postal code is required": "Kod pocztowy jest wymagany",
"Country is required": "Kraj jest wymagany",
- "If you did not request a password reset, please immediately reset your password.": "Jeśli nie prosiłeś o reset hasła, natychmiast zresetuj hasło.",
- "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Twoje zamówienie oczekuje płatności. Bilety zostały wydane, ale nie będą ważne do momentu otrzymania płatności.",
- "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ To zamówienie oczekuje płatności. Oznacz płatność jako otrzymaną na stronie zarządzania zamówieniem po otrzymaniu płatności.",
+ "If you did not request a password reset, please immediately reset your password.": "Jeśli nie prosiłeś o reset hasła, natychmiast zmień swoje hasło.",
+ "ℹ️ Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "ℹ️ Twoje zamówienie oczekuje na płatność. Bilety zostały wydane, ale nie będą ważne, dopóki płatność nie zostanie otrzymana.",
+ "ℹ️ This order is pending payment. Please mark the payment as received on the order management page once payment is received.": "ℹ️ To zamówienie oczekuje na płatność. Oznacz płatność jako otrzymaną na stronie zarządzania zamówieniami, gdy tylko ją otrzymasz.",
"Order Status:": "Status zamówienia:",
- "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Twoje zamówienie oczekuje płatności. Bilety zostały wydane, ale nie będą ważne do momentu otrzymania płatności.",
+ "Your order is pending payment. Tickets have been issued but will not be valid until payment is received.": "Twoje zamówienie oczekuje na płatność. Bilety zostały wydane, ale nie będą ważne, dopóki płatność nie zostanie otrzymana.",
"Payment Instructions": "Instrukcje płatności",
"Please follow the instructions below to complete your payment.": "Postępuj zgodnie z poniższymi instrukcjami, aby dokończyć płatność.",
"Invoice Number": "Numer faktury",
- "Date Issued": "Data wydania",
+ "Date Issued": "Data wystawienia",
"Due Date": "Termin płatności",
"Amount Due": "Kwota do zapłaty",
- "Billed To": "Rozliczone na",
+ "Billed To": "Wystawiono dla",
"DESCRIPTION": "OPIS",
"RATE": "STAWKA",
"QTY": "ILOŚĆ",
@@ -418,15 +418,15 @@
"Total Discount": "Całkowity rabat",
"Total Service Fee": "Całkowita opłata serwisowa",
"Total Amount": "Całkowita kwota",
- "For any queries, please contact us at": "W przypadku pytań, skontaktuj się z nami pod",
+ "For any queries, please contact us at": "W przypadku jakichkolwiek pytań, skontaktuj się z nami pod adresem",
"Tax Information": "Informacje podatkowe",
"Sales Ascending": "Sprzedaż rosnąco",
"Sales Descending": "Sprzedaż malejąco",
- "Revenue Ascending": "Przychody rosnąco",
- "Revenue Descending": "Przychody malejąco",
- "Social": "Społeczny",
+ "Revenue Ascending": "Przychód rosnąco",
+ "Revenue Descending": "Przychód malejąco",
+ "Social": "Społecznościowe",
"Food & Drink": "Jedzenie i napoje",
- "Charity": "Dobroczynność",
+ "Charity": "Charytatywne",
"Music": "Muzyka",
"Art": "Sztuka",
"Comedy": "Komedia",
@@ -434,7 +434,7 @@
"Business": "Biznes",
"Tech": "Technologia",
"Education": "Edukacja",
- "Workshop": "Warsztat",
+ "Workshop": "Warsztaty",
"Sports": "Sport",
"Festival": "Festiwal",
"Nightlife": "Życie nocne",
@@ -443,226 +443,226 @@
"Code": "Kod",
"Total Sales": "Całkowita sprzedaż",
"Total Sales Gross": "Całkowita sprzedaż brutto",
- "Updated At": "Zaktualizowane o",
+ "Updated At": "Zaktualizowano",
"Question": "Pytanie",
"Answer": "Odpowiedź",
"Order ID": "ID zamówienia",
- "Order Email": "Email zamówienia",
- "Attendee Name": "Nazwa uczestnika",
- "Attendee Email": "Email uczestnika",
+ "Order Email": "E-mail zamówienia",
+ "Attendee Name": "Imię i nazwisko uczestnika",
+ "Attendee Email": "E-mail uczestnika",
"Product": "Produkt",
"Order URL": "URL zamówienia",
"Attendee Answers": "Odpowiedzi uczestnika",
"Order Name": "Nazwa zamówienia",
- "Order Answers": "Odpowiedzi zamówienia",
- "Product Answers": "Odpowiedzi produktu",
+ "Order Answers": "Odpowiedzi z zamówienia",
+ "Product Answers": "Odpowiedzi o produkcie",
"Payment Provider": "Dostawca płatności",
- "Invalid invite token": "Nieprawidłowy token zaproszenia",
- "Affiliate not found": "Partner nie znaleziony",
- "Organizer with ID :organizerId is not live and user is not authenticated": "Organizator o ID :organizerId nie jest aktywny, a użytkownik nie jest uwierzytelniony",
- "Message sent successfully": "Wiadomość wysłana pomyślnie",
- "Your email has been successfully verified!": "Twój email został pomyślnie zweryfikowany!",
+ "Invalid invite token": "Nieważny token zaproszenia",
+ "Affiliate not found": "Nie znaleziono partnera",
+ "Organizer with ID :organizerId is not live and user is not authenticated": "Organizator o ID :organizerId nie jest aktywny, a użytkownik nie jest zalogowany",
+ "Message sent successfully": "Wiadomość została wysłana pomyślnie",
+ "Your email has been successfully verified!": "Twój adres e-mail został pomyślnie zweryfikowany!",
"The image must be at least 600 pixels wide and 50 pixels tall, and no more than 4000 pixels wide and 4000 pixels tall.": "Obraz musi mieć co najmniej 600 pikseli szerokości i 50 pikseli wysokości, oraz nie więcej niż 4000 pikseli szerokości i 4000 pikseli wysokości.",
"The image must be at least :minWidth x :minHeight pixels.": "Obraz musi mieć co najmniej :minWidth x :minHeight pikseli.",
- "The entity ID is required when type is provided.": "ID jednostki jest wymagane, gdy typ jest podany.",
- "The type is required when entity ID is provided.": "Typ jest wymagany, gdy ID jednostki jest podane.",
- "Your confirmation code for :app_name is :code": "Twój kod potwierdzający dla :app_name to :code",
+ "The entity ID is required when type is provided.": "ID encji jest wymagane, gdy podano typ.",
+ "The type is required when entity ID is provided.": "Typ jest wymagany, gdy podano ID encji.",
+ "Your confirmation code for :app_name is :code": "Twój kod weryfikacyjny dla :app_name to :code",
"New message from :name": "Nowa wiadomość od :name",
"An affiliate with this code already exists for this event": "Partner z tym kodem już istnieje dla tego wydarzenia",
"New password must be different from the old password.": "Nowe hasło musi różnić się od starego hasła.",
- "Due to issues with spam, you must contact us to enable your account for sending messages. ' .\n 'Please contact us at :email": "Ze względu na problemy ze spamem, musisz skontaktować się z nami, aby włączyć konto do wysyłania wiadomości. Skontaktuj się z nami pod :email",
- "Sorry, is expired or not in a valid state.": "Przepraszamy, wygasł lub nie jest w prawidłowym stanie.",
- "Organizer not found": "Organizator nie znaleziony",
- "You must verify your account before you can update an organizer\\'s status.\n You can resend the confirmation by visiting your profile page.": "Musisz zweryfikować konto, zanim będziesz mógł zaktualizować status organizatora. Możesz ponownie wysłać potwierdzenie, odwiedzając stronę profilu.",
- "Your email address has already been verified.": "Twój adres email został już zweryfikowany.",
+ "Due to issues with spam, you must contact us to enable your account for sending messages. ' .\n 'Please contact us at :email": "Z powodu problemów ze spamem musisz skontaktować się z nami, aby włączyć wysyłanie wiadomości dla Twojego konta. Skontaktuj się z nami na adres :email",
+ "Sorry, is expired or not in a valid state.": "Przepraszamy, wygasło lub ma nieprawidłowy status.",
+ "Organizer not found": "Nie znaleziono organizatora",
+ "You must verify your account before you can update an organizer\\'s status.\n You can resend the confirmation by visiting your profile page.": "Musisz zweryfikować swoje konto przed aktualizacją statusu organizatora. Możesz ponownie wysłać e-mail z potwierdzeniem, odwiedzając stronę swojego profilu.",
+ "Your email address has already been verified.": "Twój adres e-mail został już zweryfikowany.",
"The verification code is invalid or has expired.": "Kod weryfikacyjny jest nieprawidłowy lub wygasł.",
- "Job not found": "Zadanie nie znalezione",
- "Export file not found": "Plik eksportu nie znaleziony",
+ "Job not found": "Nie znaleziono zadania",
+ "Export file not found": "Nie znaleziono pliku eksportu",
"Job completed successfully": "Zadanie zakończone pomyślnie",
- "Job is still in progress": "Zadanie jest nadal w trakcie",
- "Email Subject": "Temat email",
- "© :year :app_name. All rights reserved.": "© :year :app_name. Wszystkie prawa zastrzeżone.",
+ "Job is still in progress": "Zadanie jest w toku",
+ "Email Subject": "Temat wiadomości e-mail",
+ "© :year :app_name. All rights reserved.": "© :year :app_name. Wszelkie prawa zastrzeżone.",
"Need help?": "Potrzebujesz pomocy?",
- "Contact Support": "Skontaktuj się z wsparciem",
+ "Contact Support": "Skontaktuj się z pomocą techniczną",
"Additional Information": "Dodatkowe informacje",
- "If you have any questions or need assistance, please contact": "Jeśli masz pytania lub potrzebujesz pomocy, skontaktuj się",
+ "If you have any questions or need assistance, please contact": "Jeśli masz jakiekolwiek pytania lub potrzebujesz pomocy, skontaktuj się z",
"Hello :name": "Cześć :name",
"You have received a new message from": "Otrzymałeś nową wiadomość od",
- "Reply to :name": "Odpowiedz na :name",
- "This message was sent via your organizer contact form.": "Ta wiadomość została wysłana przez formularz kontaktowy organizatora.",
- "Your email confirmation code is:": "Twój kod potwierdzający email to:",
+ "Reply to :name": "Odpowiedz do :name",
+ "This message was sent via your organizer contact form.": "Ta wiadomość została wysłana przez Twój formularz kontaktowy organizatora.",
+ "Your email confirmation code is:": "Twój kod weryfikacyjny e-mail to:",
"Ticket Name A-Z": "Nazwa biletu A-Z",
"Ticket Name Z-A": "Nazwa biletu Z-A",
- "Liquid": "Płynny",
- "Blade": "Ostrze",
+ "Liquid": "Liquid",
+ "Blade": "Blade",
"Order Confirmation": "Potwierdzenie zamówienia",
"Attendee Ticket": "Bilet uczestnika",
- "Sent to the customer after placing an order": "Wysłane do klienta po złożeniu zamówienia",
- "Sent to each attendee with their ticket": "Wysłane do każdego uczestnika z jego biletem",
- "Abandoned": "Porzucony",
- "Check Ins": "Zameldowania",
+ "Sent to the customer after placing an order": "Wysyłane do klienta po złożeniu zamówienia",
+ "Sent to each attendee with their ticket": "Wysyłane do każdego uczestnika wraz z jego biletem",
+ "Abandoned": "Porzucone",
+ "Check Ins": "Zweryfikowane wejścia",
"Unknown": "Nieznany",
- "Opted In To Marketing": "Zgłosił się do marketingu",
+ "Opted In To Marketing": "Zgoda na marketing",
"Unauthorized": "Nieautoryzowany",
"Configuration assigned successfully.": "Konfiguracja przypisana pomyślnie.",
- "Impersonation started": "Personifikacja rozpoczęta",
- "Not currently impersonating": "Obecnie nie personifikuje",
- "Impersonation ended": "Personifikacja zakończona",
+ "Impersonation started": "Rozpoczęto logowanie jako użytkownik",
+ "Not currently impersonating": "Obecnie nie zalogowano jako inny użytkownik",
+ "Impersonation ended": "Zakończono logowanie jako użytkownik",
"No user role found in token": "Nie znaleziono roli użytkownika w tokenie",
- "You cannot modify email templates until your account is verified.": "Nie możesz modyfikować szablonów email, dopóki konto nie zostanie zweryfikowane.",
- "Due to issues with spam, you must connect a Stripe account before you can modify email templates.": "Ze względu na problemy ze spamem, musisz połączyć konto Stripe, zanim będziesz mógł modyfikować szablony email.",
+ "You cannot modify email templates until your account is verified.": "Nie możesz modyfikować szablonów e-mail, dopóki Twoje konto nie zostanie zweryfikowane.",
+ "Due to issues with spam, you must connect a Stripe account before you can modify email templates.": "Z powodu problemów ze spamem musisz podłączyć konto Stripe, zanim będziesz mógł modyfikować szablony e-mail.",
"Invalid template type": "Nieprawidłowy typ szablonu",
- "Superadmin user is viewing non-live event with ID :eventId": "Użytkownik superadmin przegląda nieaktywne wydarzenie o ID :eventId",
+ "Superadmin user is viewing non-live event with ID :eventId": "Użytkownik Superadmin wyświetla nieaktywne wydarzenie o ID :eventId",
"Invalid report type.": "Nieprawidłowy typ raportu.",
"Date range must be less than 370 days.": "Zakres dat musi być mniejszy niż 370 dni.",
- "Attendee updated successfully": "Uczestnik zaktualizowany pomyślnie",
- "Order updated successfully": "Zamówienie zaktualizowane pomyślnie",
- "Ticket resent successfully": "Bilet wysłany ponownie pomyślnie",
- "Order confirmation resent successfully": "Potwierdzenie zamówienia wysłane ponownie pomyślnie",
+ "Attendee updated successfully": "Pomyślnie zaktualizowano uczestnika",
+ "Order updated successfully": "Pomyślnie zaktualizowano zamówienie",
+ "Ticket resent successfully": "Bilet został pomyślnie wysłany ponownie",
+ "Order confirmation resent successfully": "Potwierdzenie zamówienia zostało pomyślnie wysłane ponownie",
"If you have tickets associated with :email, we will send you an email with the details.": "Jeśli masz bilety powiązane z :email, wyślemy Ci email ze szczegółami.",
- "The footer text may not be greater than 500 characters.": "Tekst stopki nie może być większy niż 500 znaków.",
- "The layout type must be default or modern.": "Typ układu musi być domyślny lub nowoczesny.",
- "The mode must be light or dark.": "Tryb musi być jasny lub ciemny.",
- "The background type must be COLOR or MIRROR_COVER_IMAGE.": "Typ tła musi być COLOR lub MIRROR_COVER_IMAGE.",
+ "The footer text may not be greater than 500 characters.": "Tekst w stopce nie może przekraczać 500 znaków.",
+ "The layout type must be default or modern.": "Typ układu musi być 'default' (domyślny) lub 'modern' (nowoczesny).",
+ "The mode must be light or dark.": "Tryb musi być 'light' (jasny) lub 'dark' (ciemny).",
+ "The background type must be COLOR or MIRROR_COVER_IMAGE.": "Typ tła musi być 'COLOR' (kolor) lub 'MIRROR_COVER_IMAGE' (lustrzany obraz okładki).",
"First name must be a string": "Imię musi być ciągiem znaków",
"First name must not exceed 255 characters": "Imię nie może przekraczać 255 znaków",
"Last name must be a string": "Nazwisko musi być ciągiem znaków",
"Last name must not exceed 255 characters": "Nazwisko nie może przekraczać 255 znaków",
- "Email must not exceed 255 characters": "Email nie może przekraczać 255 znaków",
- "Validation failed after multiple attempts: :error": "Walidacja nie powiodła się po wielokrotnych próbach: :error",
+ "Email must not exceed 255 characters": "Adres e-mail nie może przekraczać 255 znaków",
+ "Validation failed after multiple attempts: :error": "Walidacja nie powiodła się po wielu próbach: :error",
"Your Ticket Details Have Been Changed": "Szczegóły Twojego biletu zostały zmienione",
"Your Order Details Have Been Changed": "Szczegóły Twojego zamówienia zostały zmienione",
"Response from :organizerName": "Odpowiedź od :organizerName",
"Your Tickets": "Twoje bilety",
- "Invalid VAT number format": "Nieprawidłowy format numeru VAT",
+ "Invalid VAT number format": "Nieprawidłowy format numeru VAT (NIP)",
"Default configurations cannot be deleted.": "Konfiguracji domyślnych nie można usunąć.",
"User does not belong to this account": "Użytkownik nie należy do tego konta",
- "Impersonation not allowed": "Personifikacja niedozwolona",
- "Email template not found": "Szablon email nie znaleziony",
- "Unable to verify Stripe signature with any platform": "Nie można zweryfikować podpisu Stripe z żadną platformą",
- "Order :id not found for event :eventId": "Zamówienie :id nie znalezione dla wydarzenia :eventId",
- "Order is not in a valid status to be abandoned": "Zamówienie nie ma prawidłowego statusu, aby zostać porzucone",
+ "Impersonation not allowed": "Logowanie jako inny użytkownik jest niedozwolone",
+ "Email template not found": "Nie znaleziono szablonu e-mail",
+ "Unable to verify Stripe signature with any platform": "Nie można zweryfikować sygnatury Stripe z żadną platformą",
+ "Order :id not found for event :eventId": "Zamówienie :id nie zostało znalezione dla wydarzenia :eventId",
+ "Order is not in a valid status to be abandoned": "Zamówienie nie ma prawidłowego statusu, aby zostało porzucone",
"Order has already expired": "Zamówienie już wygasło",
- "Attendee not found": "Uczestnik nie znaleziony",
- "Event not found": "Wydarzenie nie znalezione",
+ "Attendee not found": "Nie znaleziono uczestnika",
+ "Event not found": "Nie znaleziono wydarzenia",
"Page must be a positive integer": "Strona musi być dodatnią liczbą całkowitą",
- "Page not found": "Strona nie znaleziona",
+ "Page not found": "Nie znaleziono strony",
"Invalid or expired link. Please request a new one.": "Nieprawidłowy lub wygasły link. Poproś o nowy.",
"This link has expired. Please request a new one.": "Ten link wygasł. Poproś o nowy.",
"SUPERADMIN users cannot be created through the application": "Użytkownicy SUPERADMIN nie mogą być tworzeni przez aplikację",
- "Cannot associate a user with SUPERADMIN role to an account": "Nie można powiązać użytkownika z rolą SUPERADMIN z kontem",
- "View Order & Tickets": "Zobacz zamówienie i bilety",
+ "Cannot associate a user with SUPERADMIN role to an account": "Nie można przypisać użytkownika z rolą SUPERADMIN do konta",
+ "View Order & Tickets": "Wyświetl zamówienie i bilety",
"Summer Music Festival 2024": "Letni Festiwal Muzyczny 2024",
"3 Arena, North Wall Quay, Dublin 1, Ireland": "3 Arena, North Wall Quay, Dublin 1, Irlandia",
- "Join us for an unforgettable evening of live music featuring top artists from around the world.": "Dołącz do nas na niezapomniany wieczór muzyki na żywo z najlepszymi artystami z całego świata.",
- "Please transfer the total amount to the following bank account within 5 business days.": "Przelej całkowitą kwotę na następujące konto bankowe w ciągu 5 dni roboczych.",
- "Thank you for your purchase! We look forward to seeing you at the event.": "Dziękujemy za zakup! Czekamy na Ciebie na wydarzeniu.",
+ "Join us for an unforgettable evening of live music featuring top artists from around the world.": "Dołącz do nas na niezapomniany wieczór z muzyką na żywo z udziałem najlepszych artystów z całego świata.",
+ "Please transfer the total amount to the following bank account within 5 business days.": "Prosimy o przelanie całkowitej kwoty na poniższe konto bankowe w ciągu 5 dni roboczych.",
+ "Thank you for your purchase! We look forward to seeing you at the event.": "Dziękujemy za zakup! Nie możemy się doczekać, aby zobaczyć Cię na wydarzeniu.",
"Platform Fee": "Opłata platformy",
- "Processing charge event": "Przetwarzanie opłaty zdarzenia",
- "Charge not in succeeded status, skipping": "Opłata nie w statusie succeeded, pomijanie",
- "Stripe payment not found for charge": "Płatność Stripe nie znaleziona dla opłaty",
- "Order not found for charge": "Zamówienie nie znalezione dla opłaty",
- "Extracting platform fee for order": "Wyciąganie opłaty platformy dla zamówienia",
- "Retrieving balance transaction from Stripe": "Pobieranie transakcji bilansowej ze Stripe",
- "No balance transaction found for charge": "Nie znaleziono transakcji bilansowej dla opłaty",
- "Platform fee already stored for this transaction": "Opłata platformy już zapisana dla tej transakcji",
- "Platform fee stored successfully": "Opłata platformy zapisana pomyślnie",
+ "Processing charge event": "Przetwarzanie zdarzenia obciążenia",
+ "Charge not in succeeded status, skipping": "Płatność nie powiodła się, pomijanie",
+ "Stripe payment not found for charge": "Nie znaleziono płatności Stripe dla tego obciążenia",
+ "Order not found for charge": "Nie znaleziono zamówienia dla tego obciążenia",
+ "Extracting platform fee for order": "Pobieranie opłaty platformy dla zamówienia",
+ "Retrieving balance transaction from Stripe": "Pobieranie transakcji salda ze Stripe",
+ "No balance transaction found for charge": "Nie znaleziono transakcji salda dla tego obciążenia",
+ "Platform fee already stored for this transaction": "Opłata platformy została już zapisana dla tej transakcji",
+ "Platform fee stored successfully": "Opłata platformy została zapisana pomyślnie",
"Failed to store platform fee": "Nie udało się zapisać opłaty platformy",
- "Ticket Reference": "Referencja biletu",
- "Order Reference": "Referencja zamówienia",
+ "Ticket Reference": "Numer referencyjny biletu",
+ "Order Reference": "Numer referencyjny zamówienia",
"The name of the event": "Nazwa wydarzenia",
"The event start date": "Data rozpoczęcia wydarzenia",
- "The event start time": "Czas rozpoczęcia wydarzenia",
+ "The event start time": "Godzina rozpoczęcia wydarzenia",
"The event end date": "Data zakończenia wydarzenia",
- "The event end time": "Czas zakończenia wydarzenia",
+ "The event end time": "Godzina zakończenia wydarzenia",
"The full event address": "Pełny adres wydarzenia",
"The event venue name": "Nazwa miejsca wydarzenia",
- "The venue address line 1": "Linia adresu miejsca 1",
- "The venue address line 2": "Linia adresu miejsca 2",
- "The venue city": "Miasto miejsca",
- "The venue state or region": "Stan lub region miejsca",
- "The venue ZIP or postal code": "Kod pocztowy miejsca",
- "The venue country code": "Kod kraju miejsca",
+ "The venue address line 1": "Adres miejsca (linia 1)",
+ "The venue address line 2": "Adres miejsca (linia 2)",
+ "The venue city": "Miasto miejsca wydarzenia",
+ "The venue state or region": "Stan lub region miejsca wydarzenia",
+ "The venue ZIP or postal code": "Kod pocztowy miejsca wydarzenia",
+ "The venue country code": "Kod kraju miejsca wydarzenia",
"The event description": "Opis wydarzenia",
"The organizer\\'s name": "Nazwa organizatora",
- "The organizer\\'s email": "Email organizatora",
- "The support email address": "Adres email wsparcia",
- "Instructions for offline payment": "Instrukcje dla płatności offline",
- "Message shown after checkout": "Wiadomość wyświetlana po checkout",
- "Link to view the order summary": "Link do wyświetlenia podsumowania zamówienia",
+ "The organizer\\'s email": "E-mail organizatora",
+ "The support email address": "Adres e-mail pomocy technicznej",
+ "Instructions for offline payment": "Instrukcje płatności offline",
+ "Message shown after checkout": "Wiadomość wyświetlana po podsumowaniu zamówienia",
+ "Link to view the order summary": "Link do podsumowania zamówienia",
"The order reference number": "Numer referencyjny zamówienia",
"The total order amount": "Całkowita kwota zamówienia",
"The order date": "Data zamówienia",
"The first name of the person who placed the order": "Imię osoby, która złożyła zamówienie",
"The last name of the person who placed the order": "Nazwisko osoby, która złożyła zamówienie",
- "The email of the person who placed the order": "Email osoby, która złożyła zamówienie",
- "The attendee\\'s full name": "Pełne imię uczestnika",
- "The attendee\\'s email": "Email uczestnika",
+ "The email of the person who placed the order": "E-mail osoby, która złożyła zamówienie",
+ "The attendee\\'s full name": "Imię i nazwisko uczestnika",
+ "The attendee\\'s email": "E-mail uczestnika",
"The ticket type name": "Nazwa typu biletu",
"The ticket price": "Cena biletu",
"Link to view/download the ticket": "Link do wyświetlenia/pobrania biletu",
- "Stripe secret key not configured for platform: :platform": "Sekretny klucz Stripe nie skonfigurowany dla platformy: :platform",
+ "Stripe secret key not configured for platform: :platform": "Tajny klucz Stripe nie został skonfigurowany dla platformy: :platform",
"VIES service returned HTTP :status": "Usługa VIES zwróciła HTTP :status",
- "VAT number is not valid according to VIES": "Numer VAT nie jest prawidłowy zgodnie z VIES",
+ "VAT number is not valid according to VIES": "Numer VAT (NIP) jest nieważny według VIES",
"Connection error: :error": "Błąd połączenia: :error",
"Validation error: :error": "Błąd walidacji: :error",
- "VIES service is temporarily busy. Validation will be retried.": "Usługa VIES jest tymczasowo zajęta. Walidacja zostanie powtórzona.",
- "Member State service is temporarily unavailable. Validation will be retried.": "Usługa państwa członkowskiego jest tymczasowo niedostępna. Walidacja zostanie powtórzona.",
- "VIES service timed out. Validation will be retried.": "Usługa VIES przekroczyła czas. Walidacja zostanie powtórzona.",
- "VIES server is busy. Validation will be retried.": "Serwer VIES jest zajęty. Walidacja zostanie powtórzona.",
- "VIES service is unavailable. Validation will be retried.": "Usługa VIES jest niedostępna. Walidacja zostanie powtórzona.",
- "VIES service has reached maximum requests. Validation will be retried.": "Usługa VIES osiągnęła maksymalną liczbę żądań. Walidacja zostanie powtórzona.",
- "Invalid VAT number format.": "Nieprawidłowy format numeru VAT.",
- "Invalid requester information.": "Nieprawidłowe informacje wnioskodawcy.",
+ "VIES service is temporarily busy. Validation will be retried.": "Usługa VIES jest tymczasowo zajęta. Weryfikacja zostanie ponowiona.",
+ "Member State service is temporarily unavailable. Validation will be retried.": "Usługa państwa członkowskiego jest tymczasowo niedostępna. Weryfikacja zostanie ponowiona.",
+ "VIES service timed out. Validation will be retried.": "Przekroczono czas oczekiwania usługi VIES. Weryfikacja zostanie ponowiona.",
+ "VIES server is busy. Validation will be retried.": "Serwer VIES jest zajęty. Weryfikacja zostanie ponowiona.",
+ "VIES service is unavailable. Validation will be retried.": "Usługa VIES jest niedostępna. Weryfikacja zostanie ponowiona.",
+ "VIES service has reached maximum requests. Validation will be retried.": "Usługa VIES osiągnęła maksymalną liczbę żądań. Weryfikacja zostanie ponowiona.",
+ "Invalid VAT number format.": "Nieprawidłowy format numeru VAT (NIP).",
+ "Invalid requester information.": "Nieprawidłowe informacje o wnioskodawcy.",
"VIES validation error: :code": "Błąd walidacji VIES: :code",
- "Please confirm your email address": "Potwierdź swój adres email",
- "Email addresses do not match": "Adresy email nie pasują",
- "Please confirm the email address": "Potwierdź adres email",
- "Ticket Details Changed": "Szczegóły biletu zmienione",
+ "Please confirm your email address": "Potwierdź swój adres e-mail",
+ "Email addresses do not match": "Adresy e-mail się nie zgadzają",
+ "Please confirm the email address": "Potwierdź adres e-mail",
+ "Ticket Details Changed": "Szczegóły biletu uległy zmianie",
"The details on your ticket for **:eventName** have been updated.": "Szczegóły Twojego biletu na **:eventName** zostały zaktualizowane.",
"What Changed": "Co się zmieniło",
- "Security Information": "Informacje bezpieczeństwa",
- "This change was made from IP address: :ipAddress": "Ta zmiana została dokonana z adresu IP: :ipAddress",
- "If you did not make this change, please contact the event organizer immediately.": "Jeśli nie dokonałeś tej zmiany, natychmiast skontaktuj się z organizatorem wydarzenia.",
+ "Security Information": "Informacje dotyczące bezpieczeństwa",
+ "This change was made from IP address: :ipAddress": "Ta zmiana została wprowadzona z adresu IP: :ipAddress",
+ "If you did not make this change, please contact the event organizer immediately.": "Jeśli to nie Ty dokonałeś tej zmiany, natychmiast skontaktuj się z organizatorem wydarzenia.",
"Event Organizer: :organizerName": "Organizator wydarzenia: :organizerName",
"Contact: :email": "Kontakt: :email",
- "Order Details Changed": "Szczegóły zamówienia zmienione",
+ "Order Details Changed": "Szczegóły zamówienia uległy zmianie",
"The details on your order for **:eventName** have been updated.": "Szczegóły Twojego zamówienia na **:eventName** zostały zaktualizowane.",
"We found :count order(s) associated with :email.": "Znaleźliśmy :count zamówień powiązanych z :email.",
- "Click the button below to view your tickets and order details.": "Kliknij przycisk poniżej, aby zobaczyć swoje bilety i szczegóły zamówienia.",
- "View My Tickets": "Zobacz moje bilety",
+ "Click the button below to view your tickets and order details.": "Kliknij przycisk poniżej, aby wyświetlić bilety i szczegóły zamówienia.",
+ "View My Tickets": "Wyświetl moje bilety",
"This link will expire in 24 hours.": "Ten link wygaśnie za 24 godziny.",
- "If you did not request this, please ignore this email.": "Jeśli nie prosiłeś o to, zignoruj ten email.",
- "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "Osiągnąłeś swój dzienny limit wiadomości. Spróbuj ponownie później lub skontaktuj się z wsparciem, aby zwiększyć limity.",
- "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "Liczba odbiorców przekracza limit Twojego konta. Skontaktuj się z wsparciem, aby zwiększyć limity.",
- "Your account tier does not allow links in messages. Please contact support to enable this feature.": "Twój poziom konta nie pozwala na linki w wiadomościach. Skontaktuj się z wsparciem, aby włączyć tę funkcję.",
+ "If you did not request this, please ignore this email.": "Jeśli to nie Ty wysłałeś to żądanie, zignoruj ten e-mail.",
+ "You have reached your daily message limit. Please try again later or contact support to increase your limits.": "Osiągnąłeś dzienny limit wiadomości. Spróbuj ponownie później lub skontaktuj się z pomocą techniczną, aby zwiększyć limity.",
+ "The number of recipients exceeds your account limit. Please contact support to increase your limits.": "Liczba odbiorców przekracza limit Twojego konta. Skontaktuj się z pomocą techniczną, aby zwiększyć limity.",
+ "Your account tier does not allow links in messages. Please contact support to enable this feature.": "Poziom Twojego konta nie zezwala na umieszczanie linków w wiadomościach. Skontaktuj się z pomocą techniczną, aby włączyć tę funkcję.",
"Deleted :count failed jobs": "Usunięto :count nieudanych zadań",
- "Failed job not found": "Nieudane zadanie nie znalezione",
- "Queued :count jobs for retry": "Zakolejkowano :count zadań do ponowienia",
- "Job queued for retry": "Zadanie zakolejkowane do ponowienia",
- "Message approved and queued for sending": "Wiadomość zatwierdzona i zakolejkowana do wysłania",
- "Promo code not found": "Kod promocyjny nie znaleziony",
- "Question not found": "Pytanie nie znalezione",
- "[Action Required] Message Pending Review - :subject": "[Wymagane działanie] Wiadomość oczekująca na recenzję - :subject",
- "Message not found": "Wiadomość nie znaleziona",
- "Message must be in pending review status to be approved": "Wiadomość musi być w statusie oczekującej na recenzję, aby zostać zatwierdzona",
- "The :attribute must be a valid URL.": ":attribute musi być prawidłowym URL.",
- "The :attribute must use http or https protocol.": ":attribute musi używać protokołu http lub https.",
- "The :attribute cannot point to localhost or internal addresses.": ":attribute nie może wskazywać na localhost lub wewnętrzne adresy.",
- "The :attribute cannot use reserved domain names.": ":attribute nie może używać zarezerwowanych nazw domen.",
- "The :attribute cannot point to cloud metadata endpoints.": ":attribute nie może wskazywać na punkty końcowe metadanych chmury.",
- "The :attribute cannot point to private or internal IP addresses.": ":attribute nie może wskazywać na prywatne lub wewnętrzne adresy IP.",
- "A message has been flagged for review due to eligibility check failures.": "Wiadomość została oznaczona do recenzji z powodu niepowodzeń sprawdzania kwalifikacji.",
+ "Failed job not found": "Nie znaleziono nieudanego zadania",
+ "Queued :count jobs for retry": "Dodano do kolejki :count zadań w celu ponowienia",
+ "Job queued for retry": "Zadanie dodane do kolejki do ponowienia",
+ "Message approved and queued for sending": "Wiadomość zatwierdzona i dodana do kolejki wysyłania",
+ "Promo code not found": "Nie znaleziono kodu promocyjnego",
+ "Question not found": "Nie znaleziono pytania",
+ "[Action Required] Message Pending Review - :subject": "[Wymagana akcja] Wiadomość oczekuje na weryfikację - :subject",
+ "Message not found": "Nie znaleziono wiadomości",
+ "Message must be in pending review status to be approved": "Wiadomość musi mieć status oczekiwania na weryfikację, aby mogła zostać zatwierdzona",
+ "The :attribute must be a valid URL.": "Pole :attribute musi być prawidłowym adresem URL.",
+ "The :attribute must use http or https protocol.": "Pole :attribute musi używać protokołu http lub https.",
+ "The :attribute cannot point to localhost or internal addresses.": "Pole :attribute nie może wskazywać na localhost ani adresy wewnętrzne.",
+ "The :attribute cannot use reserved domain names.": "Pole :attribute nie może używać zarezerwowanych nazw domen.",
+ "The :attribute cannot point to cloud metadata endpoints.": "Pole :attribute nie może wskazywać na punkty końcowe metadanych w chmurze.",
+ "The :attribute cannot point to private or internal IP addresses.": "Pole :attribute nie może wskazywać na prywatne ani wewnętrzne adresy IP.",
+ "A message has been flagged for review due to eligibility check failures.": "Wiadomość została oznaczona do weryfikacji z powodu niepowodzenia sprawdzania uprawnień.",
"Message Details": "Szczegóły wiadomości",
"Subject": "Temat",
"Account": "Konto",
"Event": "Wydarzenie",
"Message ID": "ID wiadomości",
- "Eligibility Failures": "Niepowodzenia kwalifikacji",
- "Stripe payment account not connected": "Konto płatności Stripe nie połączone",
- "No completed paid orders on this account": "Brak zakończonych płatnych zamówień na tym koncie",
+ "Eligibility Failures": "Błędy uprawnień",
+ "Stripe payment account not connected": "Konto płatnicze Stripe nie jest podłączone",
+ "No completed paid orders on this account": "Brak zrealizowanych płatnych zamówień na tym koncie",
"Event was created less than 24 hours ago": "Wydarzenie zostało utworzone mniej niż 24 godziny temu",
- "Review Message": "Przejrzyj wiadomość",
+ "Review Message": "Zweryfikuj wiadomość",
"(deactivated)": "(dezaktywowany)",
"Account deletion has already been requested.": "Usunięcie konta zostało już zgłoszone.",
"The following upcoming events have completed orders: :events. Please cancel and refund these orders before deleting your account.": "Następujące nadchodzące wydarzenia mają zrealizowane zamówienia: :events. Anuluj te zamówienia i zwróć środki przed usunięciem konta.",
@@ -720,5 +720,202 @@
"Event must be pending manual review to be approved": "Wydarzenie musi oczekiwać na ręczną weryfikację, aby mogło zostać zatwierdzone",
"Event must be pending manual review to be confirmed as spam": "Wydarzenie musi oczekiwać na ręczną weryfikację, aby mogło zostać potwierdzone jako spam",
"Event approved and published": "Wydarzenie zatwierdzone i opublikowane",
- "Event confirmed as spam": "Wydarzenie potwierdzone jako spam"
+ "Event confirmed as spam": "Wydarzenie potwierdzone jako spam",
+ "Date Cancellation": "Odwołanie terminu",
+ "Sent to attendees when a scheduled date is cancelled": "Wysyłane do uczestników, gdy zaplanowany termin zostanie odwołany",
+ "Family": "Rodzina",
+ "Hobbies": "Hobby",
+ "Wellness": "Zdrowie i relaks",
+ "Spirituality": "Duchowość",
+ "Outdoors": "Na świeżym powietrzu",
+ "Tours": "Wycieczki",
+ "Film": "Film",
+ "Dance": "Taniec",
+ "Seasonal": "Sezonowe",
+ "Must be 9-20 digits (e.g., 1234567890)": "Musi zawierać od 9 do 20 cyfr (np. 1234567890)",
+ "Must start with G- followed by 6-20 characters (e.g., G-XXXXXXXXXX)": "Musi zaczynać się od G-, po którym następuje od 6 do 20 znaków (np. G-XXXXXXXXXX)",
+ "Must start with GTM- followed by 4-20 characters (e.g., GTM-XXXXXXX)": "Musi zaczynać się od GTM-, po którym następuje od 4 do 20 znaków (np. GTM-XXXXXXX)",
+ "Must be 6-30 alphanumeric characters": "Musi zawierać od 6 do 30 znaków alfanumerycznych",
+ "Earliest first": "Najwcześniejsze na początku",
+ "Latest first": "Najpóźniejsze na początku",
+ "Order Ascending": "Kolejność rosnąco",
+ "Order Descending": "Kolejność malejąco",
+ "Position ascending": "Pozycja rosnąco",
+ "Position descending": "Pozycja malejąco",
+ "Occurrence Date": "Data terminu",
+ "This event has completed orders. Please cancel or refund all orders before deleting.": "To wydarzenie ma zrealizowane zamówienia. Anuluj lub zwróć środki za wszystkie zamówienia przed usunięciem.",
+ "Date range must be less than :days days.": "Zakres dat musi być krótszy niż :days dni.",
+ "Address suggestions are rate limited. Try again shortly or enter the address manually.": "Podpowiedzi adresów są ograniczone limitem zapytań. Spróbuj ponownie za chwilę lub wprowadź adres ręcznie.",
+ "Address suggestions are temporarily unavailable. Try again or enter the address manually.": "Podpowiedzi adresów są tymczasowo niedostępne. Spróbuj ponownie lub wprowadź adres ręcznie.",
+ "Place lookups are rate limited. Try again shortly or enter the address manually.": "Wyszukiwanie miejsc jest ograniczone limitem zapytań. Spróbuj ponownie za chwilę lub wprowadź adres ręcznie.",
+ "Place details are temporarily unavailable. Try again or enter the address manually.": "Szczegóły miejsca są tymczasowo niedostępne. Spróbuj ponownie lub wprowadź adres ręcznie.",
+ "Place not found or geo provider unavailable": "Nie znaleziono miejsca lub dostawca danych geograficznych jest niedostępny",
+ "If you have tickets associated with this email, we will send you an email with the details.": "Jeśli z tym adresem e-mail są powiązane bilety, wyślemy Ci wiadomość e-mail ze szczegółami.",
+ "A saved location must be selected for in-person events": "Dla wydarzeń stacjonarnych należy wybrać zapisaną lokalizację",
+ "A saved location must be selected for in-person occurrences": "Dla terminów stacjonarnych należy wybrać zapisaną lokalizację",
+ "Specify at least one occurrence_id, or set apply_to_all to true to update every matching occurrence.": "Podaj co najmniej jedno occurrence_id lub ustaw apply_to_all na true, aby zaktualizować wszystkie pasujące terminy.",
+ "Each time of day must be in HH:MM 24-hour format.": "Każda godzina musi być w 24-godzinnym formacie HH:MM.",
+ "Each time of day object must include a time field.": "Każdy obiekt godziny musi zawierać pole time.",
+ "Each time of day must be a HH:MM string or an object with a time field.": "Każda godzina musi być ciągiem znaków w formacie HH:MM lub obiektem z polem time.",
+ "Select at least one product. To make a date inaccessible, cancel it from the schedule instead.": "Wybierz co najmniej jeden produkt. Aby termin był niedostępny, odwołaj go w harmonogramie.",
+ "The selected font is not supported.": "Wybrana czcionka nie jest obsługiwana.",
+ "Provide at least one address field (venue, street, city, or country).": "Podaj co najmniej jedno pole adresu (miejsce, ulica, miasto lub kraj).",
+ "Only one of event_occurrence_id or event_occurrence_ids may be provided.": "Można podać tylko jedno z pól: event_occurrence_id lub event_occurrence_ids.",
+ "The order statuses field is required when sending messages to order owners with a specific product.": "Pole statusów zamówień jest wymagane podczas wysyłania wiadomości do właścicieli zamówień z określonym produktem.",
+ "The subject must be less than 100 characters.": "Temat musi mieć mniej niż 100 znaków.",
+ "You can only send a message to a maximum of 50 individual attendees at a time. To message more attendees, you can send to attendees with a specific product, or to all event attendees.": "Jednorazowo możesz wysłać wiadomość do maksymalnie 50 pojedynczych uczestników. Aby wysłać wiadomość do większej liczby uczestników, możesz wysłać ją do uczestników z określonym produktem lub do wszystkich uczestników wydarzenia.",
+ "You can only target up to 500 occurrences in a single message.": "W jednej wiadomości możesz wybrać maksymalnie 500 terminów.",
+ "Google Tag Manager is not available on hosted plans for security reasons.": "Google Tag Manager nie jest dostępny w planach hostowanych ze względów bezpieczeństwa.",
+ "You must acknowledge your data controller responsibilities before enabling tracking pixels.": "Przed włączeniem pikseli śledzących musisz potwierdzić swoje obowiązki administratora danych.",
+ "The webhook URL exceeded the maximum number of redirects.": "Adres URL webhooka przekroczył maksymalną liczbę przekierowań.",
+ "webhook URL": "adres URL webhooka",
+ "Online event": "Wydarzenie online",
+ ":event on :date has been cancelled": ":event w dniu :date zostało odwołane",
+ "New message from your organizer page": "Nowa wiadomość ze strony organizatora",
+ "You're on the waitlist!": "Jesteś na liście oczekujących!",
+ "Your waitlist offer has expired": "Twoja oferta z listy oczekujących wygasła",
+ "A spot has opened up for :event!": "Zwolniło się miejsce na :event!",
+ "This plan is still assigned to :count organizer(s). Reassign them before deleting it.": "Ten plan jest nadal przypisany do :count organizatora(-ów). Przypisz im inny plan przed jego usunięciem.",
+ "Announcement not found": "Nie znaleziono ogłoszenia",
+ "There are no tickets available. '.\n 'If you would like to assign a product to this attendee,'.\n ' please adjust the product\\'s available quantity.": "Brak dostępnych biletów. Jeśli chcesz przypisać produkt temu uczestnikowi, dostosuj dostępną ilość produktu.",
+ "One or more selected taxes or fees could not be found.": "Nie znaleziono co najmniej jednego z wybranych podatków lub opłat.",
+ "An occurrence must be selected for recurring events.": "Dla wydarzeń cyklicznych należy wybrać termin.",
+ "No occurrence found for this event.": "Nie znaleziono terminu dla tego wydarzenia.",
+ "Capacity assignment not found": "Nie znaleziono przypisania pojemności",
+ "The default check-in list can\\'t be deleted.": "Domyślnej listy weryfikacyjnej nie można usunąć.",
+ "You cannot change the currency of an event that has paid orders. To use a different currency, duplicate the event and change the currency on the new event.": "Nie możesz zmienić waluty wydarzenia, które ma opłacone zamówienia. Aby użyć innej waluty, zduplikuj wydarzenie i zmień walutę w nowym wydarzeniu.",
+ "This update would make the occurrence starting :start end before it starts. Adjust the time shift or duration.": "Ta zmiana spowodowałaby, że termin rozpoczynający się :start kończyłby się przed swoim rozpoczęciem. Dostosuj przesunięcie czasu lub czas trwania.",
+ "Occurrence :id not found for event :eventId": "Nie znaleziono terminu :id dla wydarzenia :eventId",
+ "This event has reached the maximum of :max occurrences.": "To wydarzenie osiągnęło maksymalną liczbę :max terminów.",
+ "Cannot delete an occurrence that has orders. Cancel it instead.": "Nie można usunąć terminu, który ma zamówienia. Zamiast tego odwołaj go.",
+ "Cannot delete an occurrence that has attendees. Cancel it instead.": "Nie można usunąć terminu, który ma uczestników. Zamiast tego odwołaj go.",
+ "Occurrence :id not found for this event": "Nie znaleziono terminu :id dla tego wydarzenia",
+ "Both start_date_from and start_date_to are required.": "Wymagane są oba pola: start_date_from i start_date_to.",
+ "The date range is invalid.": "Zakres dat jest nieprawidłowy.",
+ "The date range must be valid and span at most :days days.": "Zakres dat musi być prawidłowy i obejmować maksymalnie :days dni.",
+ "Price override :id not found for occurrence :occurrenceId": "Nie znaleziono nadpisania ceny :id dla terminu :occurrenceId",
+ "Product price :id not found": "Nie znaleziono ceny produktu :id",
+ "Product price :id does not belong to this event": "Cena produktu :id nie należy do tego wydarzenia",
+ "Only cancelled dates can be reactivated.": "Tylko odwołane terminy można ponownie aktywować.",
+ "This date had ticket sales that were cancelled with it, so it can\\'t be reactivated automatically. Please create a new date instead.": "Ten termin miał sprzedane bilety, które zostały anulowane razem z nim, więc nie można go automatycznie ponownie aktywować. Zamiast tego utwórz nowy termin.",
+ "This rule would generate too many occurrences. Please reduce the date range or frequency, or contact support.": "Ta reguła wygenerowałaby zbyt wiele terminów. Zmniejsz zakres dat lub częstotliwość albo skontaktuj się z pomocą techniczną.",
+ "One or more product IDs do not belong to this event": "Co najmniej jedno ID produktu nie należy do tego wydarzenia",
+ "Location not found": "Nie znaleziono lokalizacji",
+ "This location is referenced by one or more events or occurrences and cannot be deleted": "Ta lokalizacja jest używana przez co najmniej jedno wydarzenie lub termin i nie można jej usunąć",
+ "Another saved location already uses this place.": "Inna zapisana lokalizacja już korzysta z tego miejsca.",
+ "Only scheduled messages can be cancelled": "Tylko zaplanowane wiadomości można anulować",
+ "This message can no longer be cancelled": "Tej wiadomości nie można już anulować",
+ "Due to issues with spam, you must contact us to enable your account for sending messages. '.\n 'Please contact us at :email": "Ze względu na problemy ze spamem musisz skontaktować się z nami, aby włączyć możliwość wysyłania wiadomości z Twojego konta. Skontaktuj się z nami pod adresem :email",
+ "The scheduled time must be in the future.": "Zaplanowany czas musi być w przyszłości.",
+ "Not enough products available. Please try again.": "Za mało dostępnych produktów. Spróbuj ponownie.",
+ "This order was not paid with an offline payment method.": "To zamówienie nie zostało opłacone metodą płatności offline.",
+ "This order is awaiting payment, so there is nothing to refund.": "To zamówienie oczekuje na płatność, więc nie ma nic do zwrotu.",
+ "This order has already been fully refunded.": "To zamówienie zostało już w całości zwrócone.",
+ "The refund amount cannot exceed the amount available to refund.": "Kwota zwrotu nie może przekraczać kwoty dostępnej do zwrotu.",
+ ":item_count item(s) for event: :event_name (Order :order_short_id)": ":item_count pozycji na wydarzenie: :event_name (Zamówienie :order_short_id)",
+ "Organizer not found.": "Nie znaleziono organizatora.",
+ "The source organizer was not found.": "Nie znaleziono organizatora źródłowego.",
+ "The selected organizer does not have a connected Stripe account.": "Wybrany organizator nie ma połączonego konta Stripe.",
+ "Stripe connection not found.": "Nie znaleziono połączenia ze Stripe.",
+ "You cannot archive the last active organizer on your account.": "Nie możesz zarchiwizować ostatniego aktywnego organizatora na swoim koncie.",
+ "Tickets can\\'t be resent for a cancelled order.": "Nie można ponownie wysłać biletów dla anulowanego zamówienia.",
+ "This ticket has been cancelled and can\\'t be resent.": "Ten bilet został anulowany i nie można go ponownie wysłać.",
+ "The session for this ticket has been cancelled and can\\'t be resent.": "Sesja dla tego biletu została odwołana i nie można go ponownie wysłać.",
+ "An event date must be selected.": "Należy wybrać termin wydarzenia.",
+ "Product not found for this event": "Nie znaleziono produktu dla tego wydarzenia",
+ "This event date is no longer available.": "Ten termin wydarzenia nie jest już dostępny.",
+ "Attendee :attendee_name does not belong to this event": "Uczestnik :attendee_name nie należy do tego wydarzenia",
+ ":attendee_name\\'s ticket is for a different session — check they\\'re on the right check-in list.": "Bilet uczestnika :attendee_name dotyczy innej sesji — sprawdź, czy znajduje się na właściwej liście weryfikacyjnej.",
+ "View Event": "Wyświetl wydarzenie",
+ "Default check-in": "Domyślna weryfikacja",
+ "This event cannot be deleted because it has completed orders. Please cancel or refund all orders first.": "Nie można usunąć tego wydarzenia, ponieważ ma zrealizowane zamówienia. Najpierw anuluj lub zwróć środki za wszystkie zamówienia.",
+ "Recurrence rule must include a frequency": "Reguła cykliczności musi zawierać częstotliwość",
+ "Additional recurrence dates must include a date": "Dodatkowe daty cykliczności muszą zawierać datę",
+ "Recurrence additional_dates time must be in HH:MM 24-hour format": "Godzina w additional_dates reguły cykliczności musi być w 24-godzinnym formacie HH:MM",
+ "Recurrence times_of_day entries must be in HH:MM 24-hour format": "Wpisy times_of_day reguły cykliczności muszą być w 24-godzinnym formacie HH:MM",
+ "Recurrence time slots must include a time": "Przedziały czasowe cykliczności muszą zawierać godzinę",
+ "Unsupported recurrence frequency": "Nieobsługiwana częstotliwość cykliczności",
+ "Event location :id not found for event :event": "Nie znaleziono lokalizacji wydarzenia :id dla wydarzenia :event",
+ "The specified event occurrence was not found": "Nie znaleziono wskazanego terminu wydarzenia",
+ "This event occurrence has been cancelled": "Ten termin wydarzenia został odwołany",
+ "This event has been cancelled": "To wydarzenie zostało odwołane",
+ "This event occurrence has already ended": "Ten termin wydarzenia już się zakończył",
+ "This event has already ended": "To wydarzenie już się zakończyło",
+ "This event occurrence is sold out": "Ten termin wydarzenia jest wyprzedany",
+ "This event is sold out": "To wydarzenie jest wyprzedane",
+ "Not enough capacity available for this occurrence": "Niewystarczająca dostępna pojemność dla tego terminu",
+ "Not enough capacity available for this event": "Niewystarczająca dostępna pojemność dla tego wydarzenia",
+ "One or more selected products are not available for this occurrence": "Co najmniej jeden z wybranych produktów nie jest dostępny dla tego terminu",
+ "Location :id not found": "Nie znaleziono lokalizacji :id",
+ "This event date has been cancelled": "Ten termin wydarzenia został odwołany",
+ "This event date has already ended": "Ten termin wydarzenia już się zakończył",
+ ":product is an add-on and can only be purchased with the product it belongs to": ":product jest dodatkiem i można go kupić tylko razem z produktem, do którego należy",
+ "An event occurrence must be specified": "Należy wskazać termin wydarzenia",
+ ":product is not yet on sale": "Sprzedaż :product jeszcze się nie rozpoczęła",
+ "Sales for :product have ended": "Sprzedaż :product została zakończona",
+ "You cannot delete the last organizer on your account.": "Nie możesz usunąć ostatniego organizatora na swoim koncie.",
+ "This organizer has events with completed orders. Please cancel or refund all orders first.": "Ten organizator ma wydarzenia ze zrealizowanymi zamówieniami. Najpierw anuluj lub zwróć środki za wszystkie zamówienia.",
+ "Payment was successful, but the order is no longer valid. Order: :id": "Płatność powiodła się, ale zamówienie nie jest już ważne. Zamówienie: :id",
+ "Payment was successful, but the event date is no longer available. Order: :id": "Płatność powiodła się, ale termin wydarzenia nie jest już dostępny. Zamówienie: :id",
+ "A product cannot be an add-on of itself": "Produkt nie może być dodatkiem do samego siebie",
+ "Default": "Domyślny",
+ "Waitlist entry not found": "Nie znaleziono wpisu na liście oczekujących",
+ "This waitlist entry cannot be cancelled": "Tego wpisu na liście oczekujących nie można anulować",
+ "Waitlist is not enabled for this product": "Lista oczekujących nie jest włączona dla tego produktu",
+ "You are already on the waitlist for this product": "Jesteś już na liście oczekujących na ten produkt",
+ "No capacity available for the selected waitlist entries.": "Brak dostępnej pojemności dla wybranych wpisów z listy oczekujących.",
+ "There are no waiting entries for this product": "Brak oczekujących wpisów dla tego produktu",
+ "This waitlist entry cannot be offered in its current status": "Tego wpisu na liście oczekujących nie można zaoferować w jego obecnym statusie",
+ "This waitlist entry is no longer linked to a valid event date.": "Ten wpis na liście oczekujących nie jest już powiązany z prawidłowym terminem wydarzenia.",
+ "This event date is no longer available for this product.": "Ten termin wydarzenia nie jest już dostępny dla tego produktu.",
+ "No capacity available to offer this waitlist entry. You will need to increase the available quantity for the product or date.": "Brak dostępnej pojemności, aby zaoferować ten wpis z listy oczekujących. Musisz zwiększyć dostępną ilość dla produktu lub terminu.",
+ "Waitlist entry is missing an event date.": "We wpisie na liście oczekujących brakuje terminu wydarzenia.",
+ "The occurrence start date": "Data rozpoczęcia terminu",
+ "The occurrence start time": "Godzina rozpoczęcia terminu",
+ "The occurrence end date": "Data zakończenia terminu",
+ "The occurrence end time": "Godzina zakończenia terminu",
+ "The occurrence title suffix": "Przyrostek tytułu terminu",
+ "Whether refunds are being processed for this cancellation": "Czy zwroty środków są przetwarzane dla tego odwołania",
+ "Link to the event homepage": "Link do strony głównej wydarzenia",
+ "Job failed": "Zadanie nie powiodło się",
+ "The :attribute could not be resolved to a public address.": "Nie udało się rozwiązać :attribute na publiczny adres.",
+ "We\\'re sorry to let you know that **:event** scheduled for **:date** has been cancelled.": "Z przykrością informujemy, że **:event** zaplanowane na **:date** zostało odwołane.",
+ "If your order is eligible, a refund will be processed automatically and may take a few business days to appear on your statement. If we are unable to process it automatically, our team will be in touch.": "Jeśli Twoje zamówienie się kwalifikuje, zwrot środków zostanie przetworzony automatycznie i może pojawić się na Twoim wyciągu w ciągu kilku dni roboczych. Jeśli nie będziemy w stanie przetworzyć go automatycznie, nasz zespół skontaktuje się z Tobą.",
+ "If you have any questions about your order, please respond to this email.": "Jeśli masz jakiekolwiek pytania dotyczące swojego zamówienia, odpowiedz na ten e-mail.",
+ "Session:": "Sesja:",
+ "Location:": "Lokalizacja:",
+ "Ticket:": "Bilet:",
+ "Attendee:": "Uczestnik:",
+ ":date at :time": ":date o :time",
+ "This message was submitted through your organizer contact form and may contain spam, suspicious links or malicious content. Please exercise caution when opening links, downloading attachments or replying.": "Ta wiadomość została przesłana przez formularz kontaktowy na Twojej stronie organizatora i może zawierać spam, podejrzane linki lub złośliwą treść. Zachowaj ostrożność podczas otwierania linków, pobierania załączników lub odpowiadania.",
+ "You have been added to the waitlist for **:product** on **:date** for the event **:event**.": "Zostałeś dodany do listy oczekujących na **:product** w dniu **:date** na wydarzenie **:event**.",
+ "You have been added to the waitlist on **:date** for the event **:event**.": "Zostałeś dodany do listy oczekujących w dniu **:date** na wydarzenie **:event**.",
+ "You have been added to the waitlist for **:product** for the event **:event**.": "Zostałeś dodany do listy oczekujących na **:product** na wydarzenie **:event**.",
+ "You have been added to the waitlist for the event **:event**.": "Zostałeś dodany do listy oczekujących na wydarzenie **:event**.",
+ "We'll notify you as soon as a spot becomes available.": "Powiadomimy Cię, gdy tylko zwolni się miejsce.",
+ "Unfortunately, your waitlist offer for **:product** on **:date** for the event **:event** has expired.": "Niestety, Twoja oferta z listy oczekujących na **:product** w dniu **:date** na wydarzenie **:event** wygasła.",
+ "Unfortunately, your waitlist offer on **:date** for the event **:event** has expired.": "Niestety, Twoja oferta z listy oczekujących w dniu **:date** na wydarzenie **:event** wygasła.",
+ "Unfortunately, your waitlist offer for **:product** for the event **:event** has expired.": "Niestety, Twoja oferta z listy oczekujących na **:product** na wydarzenie **:event** wygasła.",
+ "Unfortunately, your waitlist offer for the event **:event** has expired.": "Niestety, Twoja oferta z listy oczekujących na wydarzenie **:event** wygasła.",
+ "If you are still interested, you may rejoin the waitlist from the event page.": "Jeśli nadal jesteś zainteresowany, możesz ponownie dołączyć do listy oczekujących na stronie wydarzenia.",
+ "A spot has opened up!": "Zwolniło się miejsce!",
+ "Great news! A spot has become available for **:product** on **:date** for the event **:event**.": "Świetna wiadomość! Zwolniło się miejsce na **:product** w dniu **:date** na wydarzenie **:event**.",
+ "Great news! A spot has become available on **:date** for the event **:event**.": "Świetna wiadomość! Zwolniło się miejsce w dniu **:date** na wydarzenie **:event**.",
+ "Great news! A spot has become available for **:product** for the event **:event**.": "Świetna wiadomość! Zwolniło się miejsce na **:product** na wydarzenie **:event**.",
+ "Great news! A spot has become available for the event **:event**.": "Świetna wiadomość! Zwolniło się miejsce na wydarzenie **:event**.",
+ "An order has been reserved for you. Click the button below to complete your purchase.": "Zamówienie zostało dla Ciebie zarezerwowane. Kliknij poniższy przycisk, aby dokończyć zakup.",
+ "This offer expires on :date. Please complete your order before it expires.": "Ta oferta wygasa :date. Dokończ zamówienie, zanim wygaśnie.",
+ "Complete Your Order": "Dokończ zamówienie",
+ "Amount Paid": "Kwota zapłacona",
+ "Paid": "Opłacone",
+ "Void": "Unieważniona",
+ "Unpaid": "Nieopłacone",
+ "Description": "Opis",
+ "Rate": "Stawka",
+ "Qty": "Ilość",
+ "Amount": "Kwota",
+ "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."
}
diff --git a/backend/routes/api.php b/backend/routes/api.php
index 34f13f2dfb..bea4da1ff3 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -112,6 +112,7 @@
use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrencesAction;
use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrencesPublicAction;
use HiEvents\Http\Actions\EventOccurrences\GetOccurrenceGenerationStatusAction;
+use HiEvents\Http\Actions\EventOccurrences\GetOccurrenceProductAvailabilityAction;
use HiEvents\Http\Actions\EventOccurrences\GetPriceOverridesAction;
use HiEvents\Http\Actions\EventOccurrences\GetProductVisibilityAction;
use HiEvents\Http\Actions\EventOccurrences\ReactivateOccurrenceAction;
@@ -530,6 +531,7 @@ function (Router $router): void {
$router->get('/events/{event_id}/occurrences/{occurrence_id}/price-overrides', GetPriceOverridesAction::class);
$router->delete('/events/{event_id}/occurrences/{occurrence_id}/price-overrides/{override_id}', DeletePriceOverrideAction::class);
$router->get('/events/{event_id}/occurrences/{occurrence_id}/product-visibility', GetProductVisibilityAction::class);
+ $router->get('/events/{event_id}/occurrences/{occurrence_id}/product-availability', GetOccurrenceProductAvailabilityAction::class);
$router->put('/events/{event_id}/occurrences/{occurrence_id}/product-visibility', UpdateProductVisibilityAction::class);
// Images
diff --git a/backend/tests/Feature/Database/Migrations/AddQuantityAppliesToToProductPricesTest.php b/backend/tests/Feature/Database/Migrations/AddQuantityAppliesToToProductPricesTest.php
new file mode 100644
index 0000000000..91665bee8b
--- /dev/null
+++ b/backend/tests/Feature/Database/Migrations/AddQuantityAppliesToToProductPricesTest.php
@@ -0,0 +1,80 @@
+insertAccountAndOrganizer();
+ $this->eventId = $this->insertEvent();
+ }
+
+ public function test_existing_tiers_are_backfilled_to_event_and_new_tiers_default_to_occurrence(): void
+ {
+ $productId = $this->insertProduct();
+ $existingPriceId = $this->insertPrice($productId, initialQuantity: 10);
+ $occurrenceId = $this->insertOccurrence();
+ DB::table('product_price_occurrence_overrides')->insert([
+ 'event_occurrence_id' => $occurrenceId,
+ 'product_price_id' => $existingPriceId,
+ 'price' => 12.50,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $this->migration()->down();
+ $this->migration()->up();
+
+ $this->assertSame('EVENT', DB::table('product_prices')->where('id', $existingPriceId)->value('quantity_applies_to'));
+
+ $newPriceId = DB::table('product_prices')->insertGetId([
+ 'product_id' => $productId,
+ 'price' => 10.00,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $this->assertSame('OCCURRENCE', DB::table('product_prices')->where('id', $newPriceId)->value('quantity_applies_to'));
+ $this->assertNull(DB::table('product_price_occurrence_overrides')->where('product_price_id', $existingPriceId)->value('quantity_available'));
+ }
+
+ public function test_rolling_back_drops_quantity_only_overrides(): void
+ {
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 10);
+ $occurrenceId = $this->insertOccurrence();
+ DB::table('product_price_occurrence_overrides')->insert([
+ ['event_occurrence_id' => $occurrenceId, 'product_price_id' => $priceId, 'price' => null, 'quantity_available' => 3, 'created_at' => now(), 'updated_at' => now()],
+ ]);
+ $pricedOccurrenceId = $this->insertOccurrence(daysAhead: 2);
+ DB::table('product_price_occurrence_overrides')->insert([
+ ['event_occurrence_id' => $pricedOccurrenceId, 'product_price_id' => $priceId, 'price' => 9.00, 'quantity_available' => 3, 'created_at' => now(), 'updated_at' => now()],
+ ]);
+
+ $this->migration()->down();
+
+ $this->assertSame(1, DB::table('product_price_occurrence_overrides')->where('product_price_id', $priceId)->count());
+
+ $this->migration()->up();
+ }
+
+ private function migration(): Migration
+ {
+ return require self::MIGRATION_PATH;
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/AttendeeRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/AttendeeRepositoryTest.php
new file mode 100644
index 0000000000..a5314c868f
--- /dev/null
+++ b/backend/tests/Feature/Repository/Eloquent/AttendeeRepositoryTest.php
@@ -0,0 +1,74 @@
+repository = $this->app->make(AttendeeRepository::class);
+ $this->insertAccountAndOrganizer();
+ $this->eventId = $this->insertEvent();
+ $this->day1 = $this->insertOccurrence(daysAhead: 1);
+ $this->day2 = $this->insertOccurrence(daysAhead: 2);
+ $this->productId = $this->insertProduct();
+ $this->priceId = $this->insertPrice($this->productId, initialQuantity: 10);
+ }
+
+ public function test_sold_quantities_per_occurrence_are_grouped_by_price(): void
+ {
+ $otherPriceId = $this->insertPrice($this->productId, initialQuantity: 10);
+ $this->sellTickets($this->productId, $this->priceId, $this->day1, 2);
+ $this->sellTickets($this->productId, $otherPriceId, $this->day1, 1);
+ $this->sellTickets($this->productId, $this->priceId, $this->day2, 5);
+
+ $this->assertSame(
+ [$this->priceId => 2, $otherPriceId => 1],
+ $this->repository->getSoldQuantitiesByPriceForOccurrence($this->day1),
+ );
+ }
+
+ public function test_sold_quantities_count_offline_pending_but_not_cancelled_or_deleted(): void
+ {
+ $this->sellTickets($this->productId, $this->priceId, $this->day1, 1, OrderStatus::AWAITING_OFFLINE_PAYMENT->name, AttendeeStatus::AWAITING_PAYMENT->name);
+ $this->sellTickets($this->productId, $this->priceId, $this->day1, 1, OrderStatus::RESERVED->name, AttendeeStatus::AWAITING_PAYMENT->name);
+ $this->sellTickets($this->productId, $this->priceId, $this->day1, 1, OrderStatus::CANCELLED->name, AttendeeStatus::CANCELLED->name);
+ $orderId = $this->sellTickets($this->productId, $this->priceId, $this->day1, 1);
+ $this->insertAttendee($orderId, $this->productId, $this->priceId, $this->day1, deleted: true);
+
+ $this->assertSame([$this->priceId => 2], $this->repository->getSoldQuantitiesByPriceForOccurrence($this->day1));
+ }
+
+ public function test_max_sold_per_occurrence_returns_the_busiest_date_for_each_price(): void
+ {
+ $this->sellTickets($this->productId, $this->priceId, $this->day1, 2);
+ $this->sellTickets($this->productId, $this->priceId, $this->day2, 6);
+
+ $this->assertSame([$this->priceId => 6], $this->repository->getMaxSoldPerOccurrenceByPrice([$this->priceId]));
+ $this->assertSame([], $this->repository->getMaxSoldPerOccurrenceByPrice([$this->priceId + 1000]));
+ }
+}
diff --git a/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php
index 41709a463a..61365fa46b 100644
--- a/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php
+++ b/backend/tests/Feature/Repository/Eloquent/OrderItemRepositoryTest.php
@@ -105,7 +105,7 @@ protected function setUp(): void
public function test_returns_zero_when_no_reservations(): void
{
- $this->assertSame(0, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(0, $this->repository->getReservedTicketQuantityForOccurrence($this->occurrenceId));
}
public function test_sums_active_reservations_for_occurrence(): void
@@ -121,7 +121,7 @@ public function test_sums_active_reservations_for_occurrence(): void
occurrenceQuantities: [$this->occurrenceId => 2],
);
- $this->assertSame(5, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(5, $this->repository->getReservedTicketQuantityForOccurrence($this->occurrenceId));
}
public function test_ignores_expired_reservations(): void
@@ -137,7 +137,7 @@ public function test_ignores_expired_reservations(): void
occurrenceQuantities: [$this->occurrenceId => 4],
);
- $this->assertSame(4, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(4, $this->repository->getReservedTicketQuantityForOccurrence($this->occurrenceId));
}
public function test_ignores_non_reserved_orders(): void
@@ -158,7 +158,7 @@ public function test_ignores_non_reserved_orders(): void
occurrenceQuantities: [$this->occurrenceId => 1],
);
- $this->assertSame(1, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(1, $this->repository->getReservedTicketQuantityForOccurrence($this->occurrenceId));
}
public function test_ignores_soft_deleted_orders(): void
@@ -175,7 +175,7 @@ public function test_ignores_soft_deleted_orders(): void
occurrenceQuantities: [$this->occurrenceId => 2],
);
- $this->assertSame(2, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(2, $this->repository->getReservedTicketQuantityForOccurrence($this->occurrenceId));
}
public function test_scopes_by_occurrence_id(): void
@@ -189,8 +189,8 @@ public function test_scopes_by_occurrence_id(): void
],
);
- $this->assertSame(3, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
- $this->assertSame(7, $this->repository->getReservedQuantityForOccurrence($this->otherOccurrenceId));
+ $this->assertSame(3, $this->repository->getReservedTicketQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(7, $this->repository->getReservedTicketQuantityForOccurrence($this->otherOccurrenceId));
}
public function test_ignores_general_product_order_items(): void
@@ -207,7 +207,7 @@ public function test_ignores_general_product_order_items(): void
occurrenceQuantities: [$this->occurrenceId => 2],
);
- $this->assertSame(2, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(2, $this->repository->getReservedTicketQuantityForOccurrence($this->occurrenceId));
}
public function test_ignores_soft_deleted_order_items(): void
@@ -244,7 +244,56 @@ public function test_ignores_soft_deleted_order_items(): void
'deleted_at' => now(),
]);
- $this->assertSame(4, $this->repository->getReservedQuantityForOccurrence($this->occurrenceId));
+ $this->assertSame(4, $this->repository->getReservedTicketQuantityForOccurrence($this->occurrenceId));
+ }
+
+ public function test_reserved_quantities_by_price_include_general_products(): void
+ {
+ $otherPriceId = DB::table('product_prices')->insertGetId([
+ 'product_id' => $this->productId,
+ 'price' => 10.00,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ $this->insertOrderWithItems(OrderStatus::RESERVED->name, now()->addHour(), [$this->occurrenceId => 3]);
+ $this->insertOrderWithItems(OrderStatus::RESERVED->name, now()->addHour(), [$this->occurrenceId => 2], productType: ProductType::GENERAL->name, productPriceId: $otherPriceId);
+ $this->insertOrderWithItems(OrderStatus::RESERVED->name, now()->subMinute(), [$this->occurrenceId => 9]);
+ $this->insertOrderWithItems(OrderStatus::RESERVED->name, now()->addHour(), [$this->otherOccurrenceId => 4]);
+
+ $this->assertSame(
+ [$this->productPriceId => 3, $otherPriceId => 2],
+ $this->repository->getReservedQuantitiesByPrice($this->eventId, $this->occurrenceId),
+ );
+ }
+
+ public function test_reserved_quantities_by_price_for_the_whole_event_span_occurrences_and_skip_deleted_items(): void
+ {
+ $this->insertOrderWithItems(OrderStatus::RESERVED->name, now()->addHour(), [$this->occurrenceId => 3, $this->otherOccurrenceId => 4]);
+ $this->insertOrderWithItems(OrderStatus::RESERVED->name, now()->subMinute(), [$this->occurrenceId => 9]);
+ $this->insertOrderWithItems(OrderStatus::COMPLETED->name, now(), [$this->occurrenceId => 5]);
+ $deletedItemsOrder = $this->insertOrderWithItems(OrderStatus::RESERVED->name, now()->addHour(), [$this->occurrenceId => 6]);
+ DB::table('order_items')->where('order_id', $deletedItemsOrder)->update(['deleted_at' => now()]);
+
+ $this->assertSame([$this->productPriceId => 7], $this->repository->getReservedQuantitiesByPrice($this->eventId));
+ }
+
+ public function test_sold_general_quantities_by_price_count_completed_and_offline_pending_orders(): void
+ {
+ $this->insertOrderWithItems(OrderStatus::COMPLETED->name, now(), [$this->occurrenceId => 2], productType: ProductType::GENERAL->name);
+ $this->insertOrderWithItems(OrderStatus::AWAITING_OFFLINE_PAYMENT->name, now(), [$this->occurrenceId => 1], productType: ProductType::GENERAL->name);
+ $this->insertOrderWithItems(OrderStatus::CANCELLED->name, now(), [$this->occurrenceId => 5], productType: ProductType::GENERAL->name);
+ $this->insertOrderWithItems(OrderStatus::COMPLETED->name, now(), [$this->occurrenceId => 7]);
+ $this->insertOrderWithItems(OrderStatus::COMPLETED->name, now(), [$this->otherOccurrenceId => 4], productType: ProductType::GENERAL->name);
+
+ $this->assertSame([$this->productPriceId => 3], $this->repository->getSoldQuantitiesByPriceForOccurrence($this->occurrenceId));
+ }
+
+ public function test_max_sold_general_quantity_on_a_single_occurrence(): void
+ {
+ $this->insertOrderWithItems(OrderStatus::COMPLETED->name, now(), [$this->occurrenceId => 2, $this->otherOccurrenceId => 5], productType: ProductType::GENERAL->name);
+ $this->insertOrderWithItems(OrderStatus::COMPLETED->name, now(), [$this->occurrenceId => 1], productType: ProductType::GENERAL->name);
+
+ $this->assertSame([$this->productPriceId => 5], $this->repository->getMaxSoldPerOccurrenceByPrice([$this->productPriceId]));
}
/**
@@ -256,6 +305,7 @@ private function insertOrderWithItems(
array $occurrenceQuantities,
?\DateTimeInterface $deletedAt = null,
string $productType = ProductType::TICKET->name,
+ ?int $productPriceId = null,
): int {
$orderId = DB::table('orders')->insertGetId([
'short_id' => 'ord_'.uniqid(),
@@ -273,7 +323,7 @@ private function insertOrderWithItems(
DB::table('order_items')->insert([
'order_id' => $orderId,
'product_id' => $this->productId,
- 'product_price_id' => $this->productPriceId,
+ 'product_price_id' => $productPriceId ?? $this->productPriceId,
'product_type' => $productType,
'event_occurrence_id' => $occurrenceId,
'quantity' => $quantity,
diff --git a/backend/tests/Feature/Services/Application/Handlers/EmailTemplate/EmailTemplateBodyPurificationTest.php b/backend/tests/Feature/Services/Application/Handlers/EmailTemplate/EmailTemplateBodyPurificationTest.php
new file mode 100644
index 0000000000..86a93027f0
--- /dev/null
+++ b/backend/tests/Feature/Services/Application/Handlers/EmailTemplate/EmailTemplateBodyPurificationTest.php
@@ -0,0 +1,97 @@
+Hi {{ order.first_name }}'
+ .'View your order
'
+ .'{{ \'
\' }}
';
+
+ private int $accountId;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $user = User::factory()->withAccount()->create();
+ $this->accountId = $user->accounts()->first()->id;
+ }
+
+ public function test_creating_a_template_keeps_liquid_tokens_in_links(): void
+ {
+ $template = $this->app->make(CreateEmailTemplateHandler::class)->handle($this->dto());
+
+ $stored = DB::table('email_templates')->where('id', $template->getId())->value('body');
+
+ $this->assertStringContainsString('href="https://example.com/orders?ref={{ order.number }}"', $stored);
+ $this->assertStringNotContainsString('%7B%7B', $stored);
+ }
+
+ public function test_creating_a_template_still_strips_markup_smuggled_through_a_token(): void
+ {
+ $template = $this->app->make(CreateEmailTemplateHandler::class)->handle($this->dto());
+
+ $stored = DB::table('email_templates')->where('id', $template->getId())->value('body');
+
+ $this->assertStringNotContainsString('onerror', $stored);
+ }
+
+ public function test_updating_a_template_keeps_liquid_tokens_in_links(): void
+ {
+ $created = $this->app->make(CreateEmailTemplateHandler::class)->handle($this->dto());
+
+ $updated = $this->app->make(UpdateEmailTemplateHandler::class)->handle($this->dto($created->getId()));
+
+ $stored = DB::table('email_templates')->where('id', $updated->getId())->value('body');
+
+ $this->assertStringContainsString('href="https://example.com/orders?ref={{ order.number }}"', $stored);
+ $this->assertStringNotContainsString('onerror', $stored);
+ }
+
+ public function test_a_stored_token_resolves_to_a_real_value_when_rendered(): void
+ {
+ $template = $this->app->make(CreateEmailTemplateHandler::class)->handle($this->dto());
+
+ $stored = (string) DB::table('email_templates')->where('id', $template->getId())->value('body');
+
+ $rendered = $this->app->make(EmailTemplateService::class)->previewTemplate(
+ 'Your order',
+ $stored,
+ EmailTemplateType::ORDER_CONFIRMATION,
+ )['body'];
+
+ $this->assertMatchesRegularExpression(
+ '/href="https:\/\/example\.com\/orders\?ref=[^"{%]+"/',
+ $rendered,
+ );
+ $this->assertStringNotContainsString('{{', $rendered);
+ $this->assertStringNotContainsString('onerror', $rendered);
+ }
+
+ private function dto(?int $id = null): UpsertEmailTemplateDTO
+ {
+ return new UpsertEmailTemplateDTO(
+ account_id: $this->accountId,
+ template_type: EmailTemplateType::ORDER_CONFIRMATION,
+ subject: 'Your order',
+ body: self::BODY,
+ id: $id,
+ cta: ['label' => 'View order', 'url_token' => 'order.url'],
+ );
+ }
+}
diff --git a/backend/tests/Feature/Services/Domain/Product/AvailableProductQuantitiesFetchServiceTest.php b/backend/tests/Feature/Services/Domain/Product/AvailableProductQuantitiesFetchServiceTest.php
new file mode 100644
index 0000000000..12d08b983f
--- /dev/null
+++ b/backend/tests/Feature/Services/Domain/Product/AvailableProductQuantitiesFetchServiceTest.php
@@ -0,0 +1,219 @@
+ null]);
+ $this->service = $this->app->make(AvailableProductQuantitiesFetchService::class);
+ $this->insertAccountAndOrganizer();
+ $this->eventId = $this->insertEvent();
+ }
+
+ public function test_per_date_tier_cap_comes_from_initial_quantity_for_each_occurrence(): void
+ {
+ $day1 = $this->insertOccurrence(daysAhead: 1);
+ $day2 = $this->insertOccurrence(daysAhead: 2);
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 10, quantitySold: 7);
+ $this->sellTickets($productId, $priceId, $day1, 7);
+
+ $this->assertSame(3, $this->availableFor($priceId, $day1));
+ $this->assertSame(10, $this->availableFor($priceId, $day2));
+ }
+
+ public function test_per_date_override_replaces_the_tier_cap(): void
+ {
+ $day1 = $this->insertOccurrence(daysAhead: 1);
+ $day2 = $this->insertOccurrence(daysAhead: 2);
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 10);
+ DB::table('product_price_occurrence_overrides')->insert([
+ 'event_occurrence_id' => $day1,
+ 'product_price_id' => $priceId,
+ 'price' => null,
+ 'quantity_available' => 2,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $this->assertSame(2, $this->availableFor($priceId, $day1));
+ $this->assertSame(10, $this->availableFor($priceId, $day2));
+ }
+
+ public function test_ticket_sales_count_active_and_offline_pending_attendees_and_reservations(): void
+ {
+ $day1 = $this->insertOccurrence();
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 10);
+
+ $this->sellTickets($productId, $priceId, $day1, 2);
+ $this->sellTickets($productId, $priceId, $day1, 1, OrderStatus::AWAITING_OFFLINE_PAYMENT->name, AttendeeStatus::AWAITING_PAYMENT->name);
+ $this->sellTickets($productId, $priceId, $day1, 4, OrderStatus::CANCELLED->name, AttendeeStatus::CANCELLED->name);
+ $this->reserve($productId, $priceId, $day1, 3);
+ $this->reserve($productId, $priceId, $day1, 5, reservedUntil: now()->subMinute());
+
+ $row = $this->row($priceId, $day1);
+ $this->assertSame(4, $row->quantity_available);
+ $this->assertSame(3, $row->quantity_reserved);
+ }
+
+ public function test_partially_cancelled_order_only_counts_remaining_attendees(): void
+ {
+ $day1 = $this->insertOccurrence();
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 5);
+
+ $orderId = $this->insertOrder(OrderStatus::COMPLETED->name);
+ $this->insertOrderItem($orderId, $productId, $priceId, $day1, 3);
+ $this->insertAttendee($orderId, $productId, $priceId, $day1);
+ $this->insertAttendee($orderId, $productId, $priceId, $day1, AttendeeStatus::CANCELLED->name);
+ $this->insertAttendee($orderId, $productId, $priceId, $day1, deleted: true);
+
+ $this->assertSame(4, $this->availableFor($priceId, $day1));
+ }
+
+ public function test_event_wide_tier_is_not_clamped_by_the_occurrence(): void
+ {
+ $day1 = $this->insertOccurrence();
+ $productId = $this->insertProduct(ProductType::GENERAL->name);
+ $priceId = $this->insertPrice($productId, initialQuantity: 30, appliesTo: ProductQuantityAppliesTo::EVENT->name, quantitySold: 12);
+
+ $this->assertSame(18, $this->availableFor($priceId, $day1));
+ $this->assertSame(18, $this->availableFor($priceId, null));
+ }
+
+ public function test_per_date_general_product_counts_sold_from_order_items(): void
+ {
+ $day1 = $this->insertOccurrence(daysAhead: 1);
+ $day2 = $this->insertOccurrence(daysAhead: 2);
+ $productId = $this->insertProduct(ProductType::GENERAL->name);
+ $priceId = $this->insertPrice($productId, initialQuantity: 6);
+
+ $this->insertOrderItem($this->insertOrder(OrderStatus::COMPLETED->name), $productId, $priceId, $day1, 2, ProductType::GENERAL->name);
+ $this->insertOrderItem($this->insertOrder(OrderStatus::AWAITING_OFFLINE_PAYMENT->name), $productId, $priceId, $day1, 1, ProductType::GENERAL->name);
+ $this->insertOrderItem($this->insertOrder(OrderStatus::CANCELLED->name), $productId, $priceId, $day1, 3, ProductType::GENERAL->name);
+ $this->reserve($productId, $priceId, $day1, 1, ProductType::GENERAL->name);
+
+ $this->assertSame(2, $this->availableFor($priceId, $day1));
+ $this->assertSame(6, $this->availableFor($priceId, $day2));
+ }
+
+ public function test_tiers_of_one_product_can_mix_per_date_and_event_wide(): void
+ {
+ $day1 = $this->insertOccurrence();
+ $productId = $this->insertProduct(priceType: 'TIERED');
+ $earlyBird = $this->insertPrice($productId, initialQuantity: 10, label: 'Early bird');
+ $general = $this->insertPrice($productId, initialQuantity: 200, appliesTo: ProductQuantityAppliesTo::EVENT->name, quantitySold: 50, label: 'General');
+ $this->sellTickets($productId, $earlyBird, $day1, 4);
+
+ $this->assertSame(6, $this->availableFor($earlyBird, $day1));
+ $this->assertSame(150, $this->availableFor($general, $day1));
+ }
+
+ public function test_occurrence_capacity_still_caps_per_date_tickets(): void
+ {
+ $day1 = $this->insertOccurrence(capacity: 5, usedCapacity: 3);
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 10);
+
+ $this->assertSame(2, $this->availableFor($priceId, $day1));
+ }
+
+ public function test_occurrence_limits_can_be_skipped_while_per_date_quantities_still_apply(): void
+ {
+ $day1 = $this->insertOccurrence(capacity: 1, usedCapacity: 1);
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 3);
+ $this->sellTickets($productId, $priceId, $day1, 1);
+
+ $this->assertSame(0, $this->availableFor($priceId, $day1));
+
+ $withoutLimits = $this->service
+ ->getAvailableProductQuantities($this->eventId, ignoreCache: true, eventOccurrenceId: $day1, applyOccurrenceLimits: false)
+ ->getAvailableQuantityForPrice($priceId);
+ $this->assertSame(2, $withoutLimits);
+ }
+
+ public function test_unlimited_per_date_tier_stays_unlimited(): void
+ {
+ $day1 = $this->insertOccurrence();
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: null);
+ $this->sellTickets($productId, $priceId, $day1, 3);
+
+ $this->assertSame(Constants::INFINITE, $this->availableFor($priceId, $day1));
+ }
+
+ public function test_event_wide_fetch_does_not_gate_per_date_tiers_on_the_global_total(): void
+ {
+ $productId = $this->insertProduct();
+ $perDate = $this->insertPrice($productId, initialQuantity: 10, quantitySold: 10);
+ $eventWide = $this->insertPrice($productId, initialQuantity: 10, appliesTo: ProductQuantityAppliesTo::EVENT->name, quantitySold: 10);
+
+ $this->assertSame(Constants::INFINITE, $this->availableFor($perDate, null));
+ $this->assertSame(0, $this->availableFor($eventWide, null));
+ }
+
+ public function test_event_wide_availability_subtracts_reservations_but_not_deleted_items(): void
+ {
+ $day1 = $this->insertOccurrence();
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 10, appliesTo: ProductQuantityAppliesTo::EVENT->name, quantitySold: 2);
+ $this->reserve($productId, $priceId, $day1, 3);
+ $deleted = $this->reserve($productId, $priceId, $day1, 4);
+ DB::table('order_items')->where('order_id', $deleted)->update(['deleted_at' => now()]);
+
+ $row = $this->row($priceId, null);
+ $this->assertSame(5, $row->quantity_available);
+ $this->assertSame(3, $row->quantity_reserved);
+ }
+
+ public function test_single_event_ignores_quantity_applies_to(): void
+ {
+ $this->eventId = $this->insertEvent(EventType::SINGLE->name);
+ $day1 = $this->insertOccurrence();
+ $productId = $this->insertProduct();
+ $priceId = $this->insertPrice($productId, initialQuantity: 10, quantitySold: 4);
+
+ $this->assertSame(6, $this->availableFor($priceId, $day1));
+ $this->assertSame(6, $this->availableFor($priceId, null));
+ }
+
+ private function row(int $priceId, ?int $occurrenceId): AvailableProductQuantitiesDTO
+ {
+ return $this->service
+ ->getAvailableProductQuantities($this->eventId, ignoreCache: true, eventOccurrenceId: $occurrenceId)
+ ->productQuantities
+ ->first(fn (AvailableProductQuantitiesDTO $dto) => $dto->price_id === $priceId);
+ }
+
+ private function availableFor(int $priceId, ?int $occurrenceId): int
+ {
+ return $this->row($priceId, $occurrenceId)->quantity_available;
+ }
+}
diff --git a/backend/tests/Feature/Support/InsertsRecurringEventRows.php b/backend/tests/Feature/Support/InsertsRecurringEventRows.php
new file mode 100644
index 0000000000..dd6fd79ccb
--- /dev/null
+++ b/backend/tests/Feature/Support/InsertsRecurringEventRows.php
@@ -0,0 +1,170 @@
+withAccount()->create();
+ $this->userId = $user->id;
+ $this->accountId = $user->accounts()->first()->id;
+
+ $this->organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $this->accountId,
+ 'name' => 'Recurring Organizer',
+ 'email' => 'org+'.uniqid().'@example.test',
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function insertEvent(string $type = EventType::RECURRING->name): int
+ {
+ return DB::table('events')->insertGetId([
+ 'title' => 'Recurring Event '.uniqid(),
+ 'type' => $type,
+ 'account_id' => $this->accountId,
+ 'user_id' => $this->userId,
+ 'organizer_id' => $this->organizerId,
+ 'currency' => 'USD',
+ 'timezone' => 'UTC',
+ 'short_id' => 'evt_'.uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function insertOccurrence(?int $capacity = null, int $usedCapacity = 0, int $daysAhead = 1): int
+ {
+ return DB::table('event_occurrences')->insertGetId([
+ 'short_id' => 'occ_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'start_date' => now()->addDays($daysAhead)->toDateTimeString(),
+ 'end_date' => now()->addDays($daysAhead)->addHours(2)->toDateTimeString(),
+ 'status' => 'ACTIVE',
+ 'capacity' => $capacity,
+ 'used_capacity' => $usedCapacity,
+ 'is_overridden' => false,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function insertProduct(string $productType = ProductType::TICKET->name, string $priceType = 'PAID'): int
+ {
+ return DB::table('products')->insertGetId([
+ 'title' => ucfirst(strtolower($productType)).' '.uniqid(),
+ 'event_id' => $this->eventId,
+ 'product_type' => $productType,
+ 'type' => $priceType,
+ 'order' => 1,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function insertPrice(
+ int $productId,
+ ?int $initialQuantity,
+ string $appliesTo = ProductQuantityAppliesTo::OCCURRENCE->name,
+ int $quantitySold = 0,
+ ?string $label = null,
+ ): int {
+ return DB::table('product_prices')->insertGetId([
+ 'product_id' => $productId,
+ 'price' => 10.00,
+ 'label' => $label,
+ 'initial_quantity_available' => $initialQuantity,
+ 'quantity_applies_to' => $appliesTo,
+ 'quantity_sold' => $quantitySold,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function insertOrder(string $status, ?\DateTimeInterface $reservedUntil = null): int
+ {
+ return DB::table('orders')->insertGetId([
+ 'short_id' => 'ord_'.uniqid(),
+ 'event_id' => $this->eventId,
+ 'currency' => 'USD',
+ 'status' => $status,
+ 'reserved_until' => $reservedUntil,
+ 'public_id' => 'PUB_'.uniqid(),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function insertOrderItem(int $orderId, int $productId, int $priceId, int $occurrenceId, int $quantity, string $productType = ProductType::TICKET->name): void
+ {
+ DB::table('order_items')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $productId,
+ 'product_price_id' => $priceId,
+ 'product_type' => $productType,
+ 'event_occurrence_id' => $occurrenceId,
+ 'quantity' => $quantity,
+ 'price' => 10.00,
+ 'total_before_additions' => $quantity * 10.00,
+ ]);
+ }
+
+ private function insertAttendee(int $orderId, int $productId, int $priceId, int $occurrenceId, string $status = AttendeeStatus::ACTIVE->name, bool $deleted = false): void
+ {
+ DB::table('attendees')->insert([
+ 'short_id' => 'att_'.uniqid(),
+ 'email' => 'attendee+'.uniqid().'@example.test',
+ 'order_id' => $orderId,
+ 'product_id' => $productId,
+ 'product_price_id' => $priceId,
+ 'event_id' => $this->eventId,
+ 'event_occurrence_id' => $occurrenceId,
+ 'public_id' => 'ATT_'.uniqid(),
+ 'status' => $status,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ 'deleted_at' => $deleted ? now() : null,
+ ]);
+ }
+
+ private function sellTickets(int $productId, int $priceId, int $occurrenceId, int $count, string $orderStatus = OrderStatus::COMPLETED->name, string $attendeeStatus = AttendeeStatus::ACTIVE->name): int
+ {
+ $orderId = $this->insertOrder($orderStatus);
+ $this->insertOrderItem($orderId, $productId, $priceId, $occurrenceId, $count);
+ for ($i = 0; $i < $count; $i++) {
+ $this->insertAttendee($orderId, $productId, $priceId, $occurrenceId, $attendeeStatus);
+ }
+
+ return $orderId;
+ }
+
+ private function reserve(int $productId, int $priceId, int $occurrenceId, int $quantity, string $productType = ProductType::TICKET->name, ?\DateTimeInterface $reservedUntil = null): int
+ {
+ $orderId = $this->insertOrder(OrderStatus::RESERVED->name, $reservedUntil ?? now()->addMinutes(15));
+ $this->insertOrderItem($orderId, $productId, $priceId, $occurrenceId, $quantity, $productType);
+
+ return $orderId;
+ }
+}
diff --git a/backend/tests/Unit/DomainObjects/ProductDomainObjectTest.php b/backend/tests/Unit/DomainObjects/ProductDomainObjectTest.php
new file mode 100644
index 0000000000..97afa1dfa9
--- /dev/null
+++ b/backend/tests/Unit/DomainObjects/ProductDomainObjectTest.php
@@ -0,0 +1,195 @@
+createTieredProduct(sequential: false, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 0),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, false], $this->lockedFlags($product));
+ }
+
+ public function test_locks_nothing_for_non_tiered_products(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 0),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ ])->setType(ProductPriceType::PAID->name);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, false], $this->lockedFlags($product));
+ }
+
+ public function test_locks_later_tiers_while_first_tier_has_stock(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 3),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ $this->createPrice(id: 3, order: 3, initialQuantity: null, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, true, true], $this->lockedFlags($product));
+ }
+
+ public function test_unlocks_next_tier_when_first_tier_sold_out(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 10),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ $this->createPrice(id: 3, order: 3, initialQuantity: null, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, false, true], $this->lockedFlags($product));
+ }
+
+ public function test_reservations_count_towards_exhausting_a_tier(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 7)->setQuantityReserved(3),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, false], $this->lockedFlags($product));
+ }
+
+ public function test_per_date_tier_is_exhausted_from_resolved_availability_not_event_wide_sales(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 40)
+ ->setQuantityAppliesTo(ProductQuantityAppliesTo::OCCURRENCE->name)
+ ->setQuantityAvailable(4),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, true], $this->lockedFlags($product));
+ }
+
+ public function test_per_date_tier_with_no_availability_on_this_date_unlocks_next_tier(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 2)
+ ->setQuantityAppliesTo(ProductQuantityAppliesTo::OCCURRENCE->name)
+ ->setQuantityAvailable(0),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, false], $this->lockedFlags($product));
+ }
+
+ public function test_tier_past_its_sale_end_date_counts_as_exhausted(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 0)
+ ->setSaleEndDate(Carbon::now()->subDay()->toDateTimeString()),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, false], $this->lockedFlags($product));
+ }
+
+ public function test_hidden_tiers_neither_gate_nor_get_locked(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 0)->setIsHidden(true),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 10),
+ $this->createPrice(id: 3, order: 3, initialQuantity: 10, sold: 0)->setIsHidden(true),
+ $this->createPrice(id: 4, order: 4, initialQuantity: 10, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, false, false, false], $this->lockedFlags($product));
+ }
+
+ public function test_unlimited_earlier_tier_keeps_later_tiers_locked(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: null, sold: 500),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame([false, true], $this->lockedFlags($product));
+ }
+
+ public function test_uses_order_column_rather_than_collection_position(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 2, order: 2, initialQuantity: 10, sold: 0),
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 0),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertTrue($product->getPriceById(2)->isLockedBehindEarlierTier());
+ $this->assertFalse($product->getPriceById(1)->isLockedBehindEarlierTier());
+ }
+
+ public function test_quantity_available_excludes_locked_tiers(): void
+ {
+ $product = $this->createTieredProduct(sequential: true, prices: [
+ $this->createPrice(id: 1, order: 1, initialQuantity: 10, sold: 3)->setQuantityAvailable(7),
+ $this->createPrice(id: 2, order: 2, initialQuantity: 100, sold: 0)->setQuantityAvailable(100),
+ ]);
+
+ $product->markLockedTiers();
+
+ $this->assertSame(7, $product->getQuantityAvailable());
+ }
+
+ private function createTieredProduct(bool $sequential, array $prices): ProductDomainObject
+ {
+ return (new ProductDomainObject)
+ ->setId(1)
+ ->setType(ProductPriceType::TIERED->name)
+ ->setSequentialTierReleaseEnabled($sequential)
+ ->setProductPrices(collect($prices));
+ }
+
+ private function createPrice(int $id, int $order, ?int $initialQuantity, int $sold): ProductPriceDomainObject
+ {
+ return (new ProductPriceDomainObject)
+ ->setId($id)
+ ->setOrder($order)
+ ->setPrice(10.00)
+ ->setInitialQuantityAvailable($initialQuantity)
+ ->setQuantityAppliesTo(ProductQuantityAppliesTo::EVENT->name)
+ ->setQuantitySold($sold);
+ }
+
+ private function lockedFlags(ProductDomainObject $product): array
+ {
+ return $product->getProductPrices()
+ ->map(fn (ProductPriceDomainObject $price) => $price->isLockedBehindEarlierTier())
+ ->values()
+ ->all();
+ }
+}
diff --git a/backend/tests/Unit/DomainObjects/ProductPriceDomainObjectSoldOutTest.php b/backend/tests/Unit/DomainObjects/ProductPriceDomainObjectSoldOutTest.php
new file mode 100644
index 0000000000..87aae23466
--- /dev/null
+++ b/backend/tests/Unit/DomainObjects/ProductPriceDomainObjectSoldOutTest.php
@@ -0,0 +1,39 @@
+price(initial: 1, sold: 1, appliesTo: ProductQuantityAppliesTo::OCCURRENCE)->setQuantityAvailable(1);
+ $this->assertFalse($price->isSoldOut());
+
+ $price = $this->price(initial: 10, sold: 0, appliesTo: ProductQuantityAppliesTo::EVENT)->setQuantityAvailable(0);
+ $this->assertTrue($price->isSoldOut());
+ }
+
+ public function test_event_wide_tier_falls_back_to_counters(): void
+ {
+ $this->assertTrue($this->price(initial: 5, sold: 5, appliesTo: ProductQuantityAppliesTo::EVENT)->isSoldOut());
+ $this->assertFalse($this->price(initial: 5, sold: 4, appliesTo: ProductQuantityAppliesTo::EVENT)->isSoldOut());
+ $this->assertFalse($this->price(initial: null, sold: 99, appliesTo: ProductQuantityAppliesTo::EVENT)->isSoldOut());
+ }
+
+ public function test_per_date_tier_never_reports_sold_out_from_global_counters(): void
+ {
+ $this->assertFalse($this->price(initial: 1, sold: 3, appliesTo: ProductQuantityAppliesTo::OCCURRENCE)->isSoldOut());
+ }
+
+ private function price(?int $initial, int $sold, ProductQuantityAppliesTo $appliesTo): ProductPriceDomainObject
+ {
+ return (new ProductPriceDomainObject)
+ ->setInitialQuantityAvailable($initial)
+ ->setQuantitySold($sold)
+ ->setQuantityAppliesTo($appliesTo->name);
+ }
+}
diff --git a/backend/tests/Unit/Http/Request/Product/UpsertProductRequestTest.php b/backend/tests/Unit/Http/Request/Product/UpsertProductRequestTest.php
new file mode 100644
index 0000000000..c52e3dd4ce
--- /dev/null
+++ b/backend/tests/Unit/Http/Request/Product/UpsertProductRequestTest.php
@@ -0,0 +1,86 @@
+validate([
+ 'sequential_tier_release_enabled' => true,
+ 'prices' => [
+ ['price' => 10, 'label' => 'Early bird', 'initial_quantity_available' => null],
+ ['price' => 20, 'label' => 'Regular', 'initial_quantity_available' => 50],
+ ['price' => 30, 'label' => 'Late'],
+ ],
+ ]);
+
+ $this->assertTrue($validator->fails());
+ $this->assertTrue($validator->errors()->has('prices.0.initial_quantity_available'));
+ $this->assertFalse($validator->errors()->has('prices.1.initial_quantity_available'));
+ $this->assertFalse($validator->errors()->has('prices.2.initial_quantity_available'));
+ }
+
+ public function test_sequential_release_passes_when_all_but_last_tier_have_quantity(): void
+ {
+ $validator = $this->validate([
+ 'sequential_tier_release_enabled' => true,
+ 'prices' => [
+ ['price' => 10, 'label' => 'Early bird', 'initial_quantity_available' => 25],
+ ['price' => 20, 'label' => 'Regular'],
+ ],
+ ]);
+
+ $this->assertFalse($validator->fails());
+ }
+
+ public function test_quantity_rule_is_ignored_when_sequential_release_is_off(): void
+ {
+ $validator = $this->validate([
+ 'sequential_tier_release_enabled' => false,
+ 'prices' => [
+ ['price' => 10, 'label' => 'Early bird'],
+ ['price' => 20, 'label' => 'Regular'],
+ ],
+ ]);
+
+ $this->assertFalse($validator->fails());
+ }
+
+ public function test_quantity_rule_is_ignored_for_non_tiered_products(): void
+ {
+ $validator = $this->validate([
+ 'type' => ProductPriceType::PAID->name,
+ 'sequential_tier_release_enabled' => true,
+ 'prices' => [
+ ['price' => 10],
+ ],
+ ]);
+
+ $this->assertFalse($validator->fails());
+ }
+
+ private function validate(array $overrides): \Illuminate\Validation\Validator
+ {
+ $request = new UpsertProductRequest;
+ $request->merge(array_merge([
+ 'title' => 'Ticket',
+ 'type' => ProductPriceType::TIERED->name,
+ 'product_type' => ProductType::TICKET->name,
+ 'product_category_id' => 1,
+ ], $overrides));
+
+ $validator = Validator::make($request->all(), $request->rules(), $request->messages());
+ $request->withValidator($validator);
+
+ return $validator;
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Attendee/CreateAttendeeHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Attendee/CreateAttendeeHandlerTest.php
index 2a66b1c3de..b65bed04d9 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Attendee/CreateAttendeeHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Attendee/CreateAttendeeHandlerTest.php
@@ -31,6 +31,9 @@
use HiEvents\Services\Application\Handlers\Attendee\DTO\CreateAttendeeDTO;
use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
use HiEvents\Services\Domain\Order\OrderManagementService;
+use HiEvents\Services\Domain\Product\AvailableProductQuantitiesFetchService;
+use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
+use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
use HiEvents\Services\Domain\Product\ProductQuantityUpdateService;
use HiEvents\Services\Domain\SelfService\OrderAuditLogService;
use HiEvents\Services\Domain\Tax\TaxAndFeeRollupService;
@@ -64,6 +67,8 @@ class CreateAttendeeHandlerTest extends TestCase
private ProductRepositoryInterface|MockInterface $productRepository;
+ private AvailableProductQuantitiesFetchService|MockInterface $availableProductQuantitiesFetchService;
+
private EventRepositoryInterface|MockInterface $eventRepository;
private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository;
@@ -101,6 +106,8 @@ protected function setUp(): void
$this->productRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->availableProductQuantitiesFetchService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
+
$this->handler = new CreateAttendeeHandler(
$this->attendeeRepository,
$this->orderRepository,
@@ -115,6 +122,7 @@ protected function setUp(): void
$this->domainEventDispatcherService,
$this->occurrenceEligibilityService,
$this->orderAuditLogService,
+ $this->availableProductQuantitiesFetchService,
);
}
@@ -123,8 +131,7 @@ public function test_a_manual_attendee_creates_a_completed_order_and_fires_the_c
$this->givenSingleEvent();
$this->givenOccurrenceIsPurchasable();
$this->givenTicketProduct();
- $this->productRepository->shouldReceive('getQuantityRemainingForProductPrice')
- ->once()->with(self::PRODUCT_ID, self::PRODUCT_PRICE_ID)->andReturn(5);
+ $this->givenPriceAvailability(5);
$this->orderRepository->shouldReceive('create')
->once()
@@ -230,7 +237,7 @@ public function test_no_attendee_is_created_when_the_product_is_sold_out(): void
$this->givenOccurrenceIsPurchasable();
$this->givenTicketProduct();
$this->orderRepository->shouldReceive('create')->once()->andReturn($this->order());
- $this->productRepository->shouldReceive('getQuantityRemainingForProductPrice')->once()->andReturn(0);
+ $this->givenPriceAvailability(0);
$this->attendeeRepository->shouldNotReceive('create');
$this->productQuantityService->shouldNotReceive('increaseQuantitySold');
@@ -309,6 +316,32 @@ private function order(): OrderDomainObject
->setCurrency('USD');
}
+ private function givenPriceAvailability(int $quantityAvailable): void
+ {
+ $this->availableProductQuantitiesFetchService
+ ->shouldReceive('getAvailableProductQuantities')
+ ->once()
+ ->withArgs(fn (int $eventId, bool $ignoreCache, ?int $occurrenceId, bool $applyOccurrenceLimits): bool => $eventId === self::EVENT_ID
+ && $ignoreCache
+ && $occurrenceId === self::OCCURRENCE_ID
+ && ! $applyOccurrenceLimits)
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ AvailableProductQuantitiesDTO::fromArray([
+ 'product_id' => self::PRODUCT_ID,
+ 'price_id' => self::PRODUCT_PRICE_ID,
+ 'product_title' => 'Ticket',
+ 'price_label' => null,
+ 'quantity_available' => $quantityAvailable,
+ 'quantity_reserved' => 0,
+ 'initial_quantity_available' => 10,
+ 'product_type' => ProductType::TICKET->name,
+ 'quantity_applies_to' => 'OCCURRENCE',
+ ]),
+ ]),
+ ));
+ }
+
private function givenSingleEvent(): void
{
$this->eventRepository->shouldReceive('findById')
@@ -350,7 +383,7 @@ private function givenTicketProduct(): void
private function givenHappyPathPersistence(
OrderPaymentStatus $expectedPaymentStatus = OrderPaymentStatus::NO_PAYMENT_REQUIRED,
): void {
- $this->productRepository->shouldReceive('getQuantityRemainingForProductPrice')->once()->andReturn(5);
+ $this->givenPriceAvailability(5);
$this->orderRepository->shouldReceive('create')
->once()
->withArgs(fn (array $attributes): bool => $attributes[OrderDomainObjectAbstract::PAYMENT_STATUS] === $expectedPaymentStatus->name)
diff --git a/backend/tests/Unit/Services/Application/Handlers/Attendee/EditAttendeeHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Attendee/EditAttendeeHandlerTest.php
new file mode 100644
index 0000000000..3feb7f5465
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Attendee/EditAttendeeHandlerTest.php
@@ -0,0 +1,170 @@
+attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->productQuantityService = Mockery::mock(ProductQuantityUpdateService::class);
+ $this->domainEventDispatcherService = Mockery::mock(DomainEventDispatcherService::class);
+ $this->availableProductQuantitiesFetchService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
+
+ $databaseManager = Mockery::mock(DatabaseManager::class);
+ $databaseManager->shouldReceive('transaction')->andReturnUsing(fn (callable $callback) => $callback());
+
+ $this->handler = new EditAttendeeHandler(
+ $this->attendeeRepository,
+ $this->productRepository,
+ $this->productQuantityService,
+ $databaseManager,
+ $this->domainEventDispatcherService,
+ $this->availableProductQuantitiesFetchService,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_changing_to_a_tier_with_no_room_on_the_attendee_date_is_rejected(): void
+ {
+ $this->givenAttendeeAndProduct();
+ $this->givenAvailabilityForNewTier(0);
+ $this->attendeeRepository->shouldNotReceive('updateByIdWhere');
+
+ $this->expectException(NoTicketsAvailableException::class);
+
+ $this->handler->handle($this->dto());
+ }
+
+ public function test_changing_to_a_tier_with_room_on_the_attendee_date_moves_the_sale(): void
+ {
+ $this->givenAttendeeAndProduct();
+ $this->givenAvailabilityForNewTier(1);
+
+ $this->productQuantityService->shouldReceive('decreaseQuantitySold')->once()->with(self::OLD_PRICE_ID, 1, self::OCCURRENCE_ID);
+ $this->productQuantityService->shouldReceive('increaseQuantitySold')->once()->with(self::NEW_PRICE_ID, 1, self::OCCURRENCE_ID);
+
+ $updated = Mockery::mock(AttendeeDomainObject::class);
+ $updated->shouldReceive('getId')->andReturn(self::ATTENDEE_ID);
+ $this->attendeeRepository->shouldReceive('updateByIdWhere')->once()->andReturn($updated);
+ $this->domainEventDispatcherService->shouldReceive('dispatch')->once();
+
+ $this->assertSame($updated, $this->handler->handle($this->dto()));
+ }
+
+ private function dto(): EditAttendeeDTO
+ {
+ return new EditAttendeeDTO(
+ first_name: 'Ada',
+ last_name: 'Lovelace',
+ email: 'ada@example.test',
+ product_id: self::PRODUCT_ID,
+ product_price_id: self::NEW_PRICE_ID,
+ event_id: self::EVENT_ID,
+ attendee_id: self::ATTENDEE_ID,
+ );
+ }
+
+ private function givenAttendeeAndProduct(): void
+ {
+ $attendee = Mockery::mock(AttendeeDomainObject::class);
+ $attendee->shouldReceive('getProductPriceId')->andReturn(self::OLD_PRICE_ID);
+ $attendee->shouldReceive('getProductId')->andReturn(self::PRODUCT_ID);
+ $attendee->shouldReceive('getEventOccurrenceId')->andReturn(self::OCCURRENCE_ID);
+ $this->attendeeRepository->shouldReceive('findFirstWhere')->once()->andReturn($attendee);
+
+ $prices = collect([self::OLD_PRICE_ID, self::NEW_PRICE_ID])->map(function (int $id) {
+ $price = Mockery::mock(ProductPriceDomainObject::class);
+ $price->shouldReceive('getId')->andReturn($id);
+
+ return $price;
+ });
+
+ $product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('getEventId')->andReturn(self::EVENT_ID);
+ $product->shouldReceive('getType')->andReturn(ProductPriceType::TIERED->name);
+ $product->shouldReceive('getProductPrices')->andReturn($prices);
+ $this->productRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->productRepository->shouldReceive('findFirstWhere')->once()->andReturn($product);
+ }
+
+ private function givenAvailabilityForNewTier(int $quantityAvailable): void
+ {
+ $this->availableProductQuantitiesFetchService
+ ->shouldReceive('getAvailableProductQuantities')
+ ->once()
+ ->withArgs(fn (int $eventId, bool $ignoreCache, ?int $occurrenceId, bool $applyOccurrenceLimits): bool => $eventId === self::EVENT_ID
+ && $occurrenceId === self::OCCURRENCE_ID
+ && ! $applyOccurrenceLimits)
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ AvailableProductQuantitiesDTO::fromArray([
+ 'product_id' => self::PRODUCT_ID,
+ 'price_id' => self::NEW_PRICE_ID,
+ 'product_title' => 'Ticket',
+ 'price_label' => 'VIP',
+ 'quantity_available' => $quantityAvailable,
+ 'quantity_reserved' => 0,
+ 'initial_quantity_available' => 1,
+ 'product_type' => ProductType::TICKET->name,
+ 'quantity_applies_to' => 'OCCURRENCE',
+ ]),
+ ]),
+ ));
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php
index 11f3c29038..6d69355add 100644
--- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrenceHandlerTest.php
@@ -7,6 +7,7 @@
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventOccurrence\GetEventOccurrenceHandler;
+use HiEvents\Services\Domain\EventOccurrence\OccurrenceBookingLimitsService;
use Mockery;
use Tests\TestCase;
@@ -14,6 +15,8 @@ class GetEventOccurrenceHandlerTest extends TestCase
{
private EventOccurrenceRepositoryInterface|Mockery\MockInterface $occurrenceRepository;
+ private OccurrenceBookingLimitsService|Mockery\MockInterface $bookingLimitsService;
+
private GetEventOccurrenceHandler $handler;
protected function setUp(): void
@@ -21,7 +24,9 @@ protected function setUp(): void
parent::setUp();
$this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
- $this->handler = new GetEventOccurrenceHandler($this->occurrenceRepository);
+ $this->bookingLimitsService = Mockery::mock(OccurrenceBookingLimitsService::class);
+ $this->bookingLimitsService->shouldReceive('attachTo')->byDefault();
+ $this->handler = new GetEventOccurrenceHandler($this->occurrenceRepository, $this->bookingLimitsService);
}
public function test_handle_returns_occurrence_with_stats(): void
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php
index e7e2f3ef1b..c784a7c52c 100644
--- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetEventOccurrencesHandlerTest.php
@@ -2,9 +2,11 @@
namespace Tests\Unit\Services\Application\Handlers\EventOccurrence;
+use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\Http\DTO\QueryParamsDTO;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventOccurrence\GetEventOccurrencesHandler;
+use HiEvents\Services\Domain\EventOccurrence\OccurrenceBookingLimitsService;
use Illuminate\Pagination\LengthAwarePaginator;
use Mockery;
use Tests\TestCase;
@@ -13,6 +15,8 @@ class GetEventOccurrencesHandlerTest extends TestCase
{
private EventOccurrenceRepositoryInterface|Mockery\MockInterface $occurrenceRepository;
+ private OccurrenceBookingLimitsService|Mockery\MockInterface $bookingLimitsService;
+
private GetEventOccurrencesHandler $handler;
protected function setUp(): void
@@ -20,23 +24,23 @@ protected function setUp(): void
parent::setUp();
$this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
- $this->handler = new GetEventOccurrencesHandler($this->occurrenceRepository);
+ $this->bookingLimitsService = Mockery::mock(OccurrenceBookingLimitsService::class);
+ $this->handler = new GetEventOccurrencesHandler(
+ $this->occurrenceRepository,
+ $this->bookingLimitsService,
+ );
}
- public function test_handle_returns_paginated_occurrences_with_stats(): void
+ public function test_handle_returns_paginated_occurrences_with_booking_limits_attached(): void
{
$queryParams = Mockery::mock(QueryParamsDTO::class);
- $paginator = Mockery::mock(LengthAwarePaginator::class);
-
- $this->occurrenceRepository
- ->shouldReceive('loadRelation')
- ->andReturnSelf();
-
- $this->occurrenceRepository
- ->shouldReceive('findByEventId')
+ $occurrence = (new EventOccurrenceDomainObject)->setId(7);
+ $paginator = new LengthAwarePaginator(collect([$occurrence]), 1, 50);
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('findByEventId')->once()->with(1, $queryParams)->andReturn($paginator);
+ $this->bookingLimitsService->shouldReceive('attachTo')
->once()
- ->with(1, $queryParams)
- ->andReturn($paginator);
+ ->withArgs(fn ($occurrences, int $eventId) => $occurrences->first() === $occurrence && $eventId === 1);
$result = $this->handler->handle(1, $queryParams);
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetOccurrenceProductAvailabilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetOccurrenceProductAvailabilityHandlerTest.php
new file mode 100644
index 0000000000..cfdc76b655
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetOccurrenceProductAvailabilityHandlerTest.php
@@ -0,0 +1,93 @@
+occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
+ $this->fetchService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
+ $this->soldAndReserved = Mockery::mock(SoldAndReservedQuantitiesService::class);
+ $this->handler = new GetOccurrenceProductAvailabilityHandler(
+ $this->occurrenceRepository,
+ $this->fetchService,
+ $this->soldAndReserved,
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_it_pairs_per_date_sold_and_available_for_each_price(): void
+ {
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
+ $this->fetchService->shouldReceive('getAvailableProductQuantities')
+ ->once()
+ ->with(1, true, 10)
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(productQuantities: collect([
+ $this->quantity(100, 11, ProductType::TICKET, 4),
+ $this->quantity(200, 21, ProductType::GENERAL, Constants::INFINITE),
+ ])));
+ $this->soldAndReserved->shouldReceive('getSoldByPriceForOccurrence')->once()->with(10, ProductType::TICKET)->andReturn([11 => 1]);
+ $this->soldAndReserved->shouldReceive('getSoldByPriceForOccurrence')->once()->with(10, ProductType::GENERAL)->andReturn([]);
+
+ $result = $this->handler->handle(1, 10);
+
+ $this->assertSame([
+ ['product_id' => 100, 'product_price_id' => 11, 'quantity_sold' => 1, 'quantity_available' => 4],
+ ['product_id' => 200, 'product_price_id' => 21, 'quantity_sold' => 0, 'quantity_available' => null],
+ ], $result->map(fn ($dto) => $dto->toArray())->all());
+ }
+
+ public function test_it_rejects_an_occurrence_from_another_event(): void
+ {
+ $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+ $this->fetchService->shouldNotReceive('getAvailableProductQuantities');
+
+ $this->expectException(ResourceNotFoundException::class);
+ $this->handler->handle(1, 10);
+ }
+
+ private function quantity(int $productId, int $priceId, ProductType $type, int $available): AvailableProductQuantitiesDTO
+ {
+ return AvailableProductQuantitiesDTO::fromArray([
+ 'product_id' => $productId,
+ 'price_id' => $priceId,
+ 'product_title' => 'P',
+ 'price_label' => null,
+ 'quantity_available' => $available,
+ 'quantity_reserved' => 0,
+ 'initial_quantity_available' => null,
+ 'product_type' => $type->name,
+ 'quantity_applies_to' => 'OCCURRENCE',
+ ]);
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php
index 72a90ddaa4..392c9a048a 100644
--- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/PriceOverride/UpsertPriceOverrideHandlerTest.php
@@ -2,11 +2,14 @@
namespace Tests\Unit\Services\Application\Handlers\EventOccurrence\PriceOverride;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\Generated\ProductPriceOccurrenceOverrideDomainObjectAbstract;
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\ProductPriceDomainObject;
use HiEvents\DomainObjects\ProductPriceOccurrenceOverrideDomainObject;
+use HiEvents\Exceptions\InvalidOccurrenceQuantityOverrideException;
use HiEvents\Exceptions\ResourceNotFoundException;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductPriceOccurrenceOverrideRepositoryInterface;
@@ -14,6 +17,7 @@
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Services\Application\Handlers\EventOccurrence\PriceOverride\DTO\UpsertPriceOverrideDTO;
use HiEvents\Services\Application\Handlers\EventOccurrence\PriceOverride\UpsertPriceOverrideHandler;
+use HiEvents\Services\Domain\Product\SoldAndReservedQuantitiesService;
use Illuminate\Database\DatabaseManager;
use Mockery;
use Mockery\MockInterface;
@@ -29,6 +33,8 @@ class UpsertPriceOverrideHandlerTest extends TestCase
private ProductRepositoryInterface|MockInterface $productRepository;
+ private SoldAndReservedQuantitiesService|MockInterface $soldAndReservedQuantities;
+
private DatabaseManager|MockInterface $databaseManager;
private UpsertPriceOverrideHandler $handler;
@@ -41,6 +47,7 @@ protected function setUp(): void
$this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
$this->productPriceRepository = Mockery::mock(ProductPriceRepositoryInterface::class);
$this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->soldAndReservedQuantities = Mockery::mock(SoldAndReservedQuantitiesService::class);
$this->databaseManager = Mockery::mock(DatabaseManager::class);
$this->databaseManager->shouldReceive('transaction')
@@ -51,25 +58,32 @@ protected function setUp(): void
$this->occurrenceRepository,
$this->productPriceRepository,
$this->productRepository,
+ $this->soldAndReservedQuantities,
$this->databaseManager,
);
}
- private function mockOwnershipChecks(): void
- {
+ private function mockOwnershipChecks(
+ string $quantityAppliesTo = ProductQuantityAppliesTo::OCCURRENCE->name,
+ string $productType = ProductType::TICKET->name,
+ ): void {
$this->occurrenceRepository
->shouldReceive('findFirstWhere')
->andReturn(Mockery::mock(EventOccurrenceDomainObject::class));
$priceMock = Mockery::mock(ProductPriceDomainObject::class);
$priceMock->shouldReceive('getProductId')->andReturn(5);
+ $priceMock->shouldReceive('getQuantityAppliesTo')->andReturn($quantityAppliesTo);
+ $priceMock->shouldReceive('getLabel')->andReturn('Early Bird');
$this->productPriceRepository
->shouldReceive('findFirst')
->andReturn($priceMock);
+ $productMock = Mockery::mock(ProductDomainObject::class);
+ $productMock->shouldReceive('getProductType')->andReturn($productType);
$this->productRepository
->shouldReceive('findFirstWhere')
- ->andReturn(Mockery::mock(ProductDomainObject::class));
+ ->andReturn($productMock);
}
public function test_handle_creates_new_override_when_none_exists(): void
@@ -101,6 +115,7 @@ public function test_handle_creates_new_override_when_none_exists(): void
ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20,
ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => 99.99,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::QUANTITY_AVAILABLE => null,
])
->andReturn($expectedOverride);
@@ -142,6 +157,7 @@ public function test_handle_updates_existing_override(): void
->once()
->with(5, [
ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => 149.99,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::QUANTITY_AVAILABLE => null,
])
->andReturn($updatedOverride);
@@ -326,6 +342,116 @@ public function test_it_throws_when_product_not_belonging_to_event(): void
$this->handler->handle($dto);
}
+ public function test_it_stores_a_quantity_override_for_a_per_date_tier(): void
+ {
+ $this->mockOwnershipChecks();
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getSoldByPriceForOccurrence')
+ ->once()
+ ->with(10, ProductType::TICKET)
+ ->andReturn([20 => 3]);
+
+ $expectedOverride = Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class);
+
+ $this->overrideRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with([
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => 10,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRODUCT_PRICE_ID => 20,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::PRICE => null,
+ ProductPriceOccurrenceOverrideDomainObjectAbstract::QUANTITY_AVAILABLE => 5,
+ ])
+ ->andReturn($expectedOverride);
+
+ $result = $this->handler->handle(new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ quantity_available: 5,
+ ));
+
+ $this->assertSame($expectedOverride, $result);
+ }
+
+ public function test_it_counts_general_product_sales_from_order_items(): void
+ {
+ $this->mockOwnershipChecks(productType: ProductType::GENERAL->name);
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getSoldByPriceForOccurrence')
+ ->once()
+ ->with(10, ProductType::GENERAL)
+ ->andReturn([20 => 2]);
+
+ $this->overrideRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturn(Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class));
+
+ $this->handler->handle(new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ quantity_available: 2,
+ ));
+ }
+
+ public function test_it_rejects_a_quantity_override_for_an_event_wide_tier(): void
+ {
+ $this->mockOwnershipChecks(quantityAppliesTo: ProductQuantityAppliesTo::EVENT->name);
+ $this->overrideRepository->shouldNotReceive('create');
+
+ $this->expectException(InvalidOccurrenceQuantityOverrideException::class);
+
+ $this->handler->handle(new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ quantity_available: 5,
+ ));
+ }
+
+ public function test_it_rejects_a_quantity_below_the_sold_count_for_that_date(): void
+ {
+ $this->mockOwnershipChecks();
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getSoldByPriceForOccurrence')
+ ->once()
+ ->andReturn([20 => 4]);
+ $this->overrideRepository->shouldNotReceive('create');
+
+ $this->expectException(InvalidOccurrenceQuantityOverrideException::class);
+ $this->expectExceptionMessageMatches('/Early Bird.*\(4\)/');
+
+ $this->handler->handle(new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ quantity_available: 3,
+ ));
+ }
+
+ public function test_a_price_only_override_skips_the_quantity_guard(): void
+ {
+ $this->mockOwnershipChecks(quantityAppliesTo: ProductQuantityAppliesTo::EVENT->name);
+ $this->soldAndReservedQuantities->shouldNotReceive('getSoldByPriceForOccurrence');
+
+ $this->overrideRepository->shouldReceive('findFirstWhere')->once()->andReturn(null);
+ $this->overrideRepository
+ ->shouldReceive('create')
+ ->once()
+ ->andReturn(Mockery::mock(ProductPriceOccurrenceOverrideDomainObject::class));
+
+ $this->handler->handle(new UpsertPriceOverrideDTO(
+ event_id: 1,
+ event_occurrence_id: 10,
+ product_price_id: 20,
+ price: 12.50,
+ ));
+ }
+
protected function tearDown(): void
{
Mockery::close();
diff --git a/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php b/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php
index c66216b413..1506fd65c6 100644
--- a/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Event/EventOccurrenceGeneratorServiceTest.php
@@ -66,12 +66,23 @@ private function mockDbBatchQuery(
$attendeesBuilder->shouldReceive('distinct')->andReturnSelf();
$attendeesBuilder->shouldReceive('pluck')->andReturn(collect($occurrenceIdsWithAttendees));
+ $emptyBuilder = Mockery::mock(Builder::class);
+ $emptyBuilder->shouldReceive('whereIn')->andReturnSelf();
+ $emptyBuilder->shouldReceive('distinct')->andReturnSelf();
+ $emptyBuilder->shouldReceive('pluck')->andReturn(collect());
+
DB::shouldReceive('table')
->with('order_items')
->andReturn($orderItemsBuilder);
DB::shouldReceive('table')
->with('attendees')
->andReturn($attendeesBuilder);
+ DB::shouldReceive('table')
+ ->with('product_price_occurrence_overrides')
+ ->andReturn($emptyBuilder);
+ DB::shouldReceive('table')
+ ->with('product_occurrence_visibility')
+ ->andReturn($emptyBuilder);
}
public function test_new_occurrences_are_bulk_inserted_when_none_exist(): void
diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrenceBookingLimitsServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrenceBookingLimitsServiceTest.php
new file mode 100644
index 0000000000..174bea62ee
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrenceBookingLimitsServiceTest.php
@@ -0,0 +1,180 @@
+overrideRepository = Mockery::mock(ProductPriceOccurrenceOverrideRepositoryInterface::class);
+ $this->productRepository = Mockery::mock(ProductRepositoryInterface::class);
+ $this->productRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->service = new OccurrenceBookingLimitsService($this->overrideRepository, $this->productRepository);
+ }
+
+ private function limitsFor(EventOccurrenceDomainObject $occurrence, $products)
+ {
+ $this->productRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(
+ ['event_id' => 1],
+ ['*'],
+ Mockery::on(fn (array $orders) => array_map(fn (OrderAndDirection $o) => $o->getOrder(), $orders) === ['order', 'id']),
+ )
+ ->andReturn($products);
+ $this->service->attachTo(collect([$occurrence]), 1);
+
+ return $occurrence->getBookingLimits();
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_sellable_is_the_lower_of_capacity_and_per_date_allocations(): void
+ {
+ $this->overrideRepository->shouldReceive('findWhereIn')->once()->andReturn(collect());
+ $occurrence = $this->occurrence(1, capacity: 22);
+ $products = collect([
+ $this->product('Workshop', ProductType::TICKET, [
+ [11, 'Early bird', 5, ProductQuantityAppliesTo::OCCURRENCE],
+ [12, 'Standard', 15, ProductQuantityAppliesTo::OCCURRENCE],
+ ]),
+ $this->product('T-shirt', ProductType::GENERAL, [[13, null, 3, ProductQuantityAppliesTo::OCCURRENCE]]),
+ ]);
+
+ $limits = $this->limitsFor($occurrence, $products);
+
+ $this->assertSame(22, $limits->capacity);
+ $this->assertSame(20, $limits->allocation_total);
+ $this->assertSame(20, $limits->sellable);
+ $this->assertSame([11, 12], array_map(fn ($a) => $a->product_price_id, $limits->allocations));
+ $this->assertSame([5, 15], array_map(fn ($a) => $a->quantity, $limits->allocations));
+ }
+
+ public function test_capacity_wins_when_allocations_exceed_it_and_overrides_replace_tier_caps(): void
+ {
+ $override = (new ProductPriceOccurrenceOverrideDomainObject)->setEventOccurrenceId(1)->setProductPriceId(11)->setQuantityAvailable(30);
+ $this->overrideRepository->shouldReceive('findWhereIn')->once()->with('event_occurrence_id', [1])->andReturn(collect([$override]));
+ $occurrence = $this->occurrence(1, capacity: 22);
+ $products = collect([
+ $this->product('Class', ProductType::TICKET, [[11, null, 5, ProductQuantityAppliesTo::OCCURRENCE]]),
+ ]);
+
+ $limits = $this->limitsFor($occurrence, $products);
+
+ $this->assertSame(30, $limits->allocation_total);
+ $this->assertSame(22, $limits->sellable);
+ $this->assertSame(30, $limits->allocations[0]->quantity);
+ }
+
+ public function test_an_unlimited_or_event_wide_tier_leaves_only_capacity_as_the_limit(): void
+ {
+ $this->overrideRepository->shouldReceive('findWhereIn')->once()->andReturn(collect());
+ $products = collect([
+ $this->product('Entry', ProductType::TICKET, [
+ [11, 'Adult', null, ProductQuantityAppliesTo::OCCURRENCE],
+ [12, 'Season pass', 200, ProductQuantityAppliesTo::EVENT],
+ ]),
+ ]);
+
+ $withCapacity = $this->limitsFor($this->occurrence(1, capacity: 100), $products);
+ $this->assertNull($withCapacity->allocation_total);
+ $this->assertSame(100, $withCapacity->sellable);
+ $this->assertSame([[11, null, 'OCCURRENCE'], [12, null, 'EVENT']], array_map(fn ($a) => [$a->product_price_id, $a->quantity, $a->applies_to], $withCapacity->allocations));
+
+ $this->overrideRepository->shouldReceive('findWhereIn')->once()->andReturn(collect());
+ $noLimits = $this->limitsFor($this->occurrence(2, capacity: null), $products);
+ $this->assertNull($noLimits->sellable);
+ }
+
+ public function test_a_capped_tier_is_still_listed_when_another_tier_is_unlimited(): void
+ {
+ $this->overrideRepository->shouldReceive('findWhereIn')->once()->andReturn(collect());
+ $products = collect([
+ $this->product('VIP', ProductType::TICKET, [[11, null, 50, ProductQuantityAppliesTo::OCCURRENCE]]),
+ $this->product('Standard', ProductType::TICKET, [[12, null, null, ProductQuantityAppliesTo::EVENT]]),
+ ]);
+
+ $limits = $this->limitsFor($this->occurrence(1, capacity: null), $products);
+
+ $this->assertNull($limits->allocation_total);
+ $this->assertNull($limits->sellable);
+ $this->assertSame([50, null], array_map(fn ($a) => $a->quantity, $limits->allocations));
+ }
+
+ public function test_tier_allocations_alone_define_the_sellable_total_without_a_capacity(): void
+ {
+ $this->overrideRepository->shouldReceive('findWhereIn')->once()->andReturn(collect());
+ $products = collect([
+ $this->product('Theatre', ProductType::TICKET, [
+ [11, 'Stalls', 100, ProductQuantityAppliesTo::OCCURRENCE],
+ [12, 'Circle', 50, ProductQuantityAppliesTo::OCCURRENCE],
+ [13, 'Balcony', 30, ProductQuantityAppliesTo::OCCURRENCE],
+ ]),
+ ]);
+
+ $limits = $this->limitsFor($this->occurrence(1, capacity: null), $products);
+
+ $this->assertSame(180, $limits->sellable);
+ $this->assertNull($limits->capacity);
+ }
+
+ public function test_tiers_are_listed_in_their_configured_order(): void
+ {
+ $this->overrideRepository->shouldReceive('findWhereIn')->once()->andReturn(collect());
+ $products = collect([
+ $this->product('Theatre', ProductType::TICKET, [
+ [12, 'Circle', 50, ProductQuantityAppliesTo::OCCURRENCE, 2],
+ [11, 'Stalls', 100, ProductQuantityAppliesTo::OCCURRENCE, 1],
+ ]),
+ ]);
+
+ $limits = $this->limitsFor($this->occurrence(1, capacity: null), $products);
+
+ $this->assertSame(['Stalls', 'Circle'], array_map(fn ($allocation) => $allocation->price_label, $limits->allocations));
+ }
+
+ private function occurrence(int $id, ?int $capacity): EventOccurrenceDomainObject
+ {
+ return (new EventOccurrenceDomainObject)->setId($id)->setCapacity($capacity);
+ }
+
+ private function product(string $title, ProductType $type, array $prices): ProductDomainObject
+ {
+ return (new ProductDomainObject)
+ ->setTitle($title)
+ ->setProductType($type->name)
+ ->setProductPrices(collect(array_map(fn (array $p) => (new ProductPriceDomainObject)
+ ->setId($p[0])
+ ->setLabel($p[1])
+ ->setInitialQuantityAvailable($p[2])
+ ->setQuantityAppliesTo($p[3]->name)
+ ->setOrder($p[4] ?? 1), $prices)));
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php
index ab205a6ba5..d7287bc3a0 100644
--- a/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php
@@ -6,9 +6,9 @@
use HiEvents\DomainObjects\ProductOccurrenceVisibilityDomainObject;
use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
-use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
use HiEvents\Services\Domain\EventOccurrence\OccurrencePurchaseEligibilityService;
+use HiEvents\Services\Domain\Product\SoldAndReservedQuantitiesService;
use Illuminate\Validation\ValidationException;
use Mockery;
use Mockery\MockInterface;
@@ -18,7 +18,7 @@ class OccurrencePurchaseEligibilityServiceTest extends TestCase
{
private EventOccurrenceRepositoryInterface|MockInterface $occurrenceRepository;
- private OrderItemRepositoryInterface|MockInterface $orderItemRepository;
+ private SoldAndReservedQuantitiesService|MockInterface $soldAndReservedQuantities;
private ProductOccurrenceVisibilityRepositoryInterface|MockInterface $visibilityRepository;
@@ -29,11 +29,11 @@ protected function setUp(): void
parent::setUp();
$this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
- $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+ $this->soldAndReservedQuantities = Mockery::mock(SoldAndReservedQuantitiesService::class);
$this->visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
- $this->orderItemRepository
- ->shouldReceive('getReservedQuantityForOccurrence')
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getReservedTicketsForOccurrence')
->byDefault()
->andReturn(0);
@@ -44,7 +44,7 @@ protected function setUp(): void
$this->service = new OccurrencePurchaseEligibilityService(
$this->occurrenceRepository,
- $this->orderItemRepository,
+ $this->soldAndReservedQuantities,
$this->visibilityRepository,
);
}
@@ -127,8 +127,8 @@ public function test_rejects_when_capacity_exceeded(): void
{
$occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
$this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
- $this->orderItemRepository
- ->shouldReceive('getReservedQuantityForOccurrence')
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getReservedTicketsForOccurrence')
->with(10)
->andReturn(3);
@@ -142,7 +142,7 @@ public function test_zero_additional_quantity_skips_capacity_check(): void
{
$occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
$this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
- $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+ $this->soldAndReservedQuantities->shouldNotReceive('getReservedTicketsForOccurrence');
$result = $this->service->assertOccurrencePurchasable(
eventId: 1,
@@ -157,7 +157,7 @@ public function test_zero_additional_quantity_skips_sold_out_gate(): void
{
$occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 10);
$this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
- $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+ $this->soldAndReservedQuantities->shouldNotReceive('getReservedTicketsForOccurrence');
$result = $this->service->assertOccurrencePurchasable(
eventId: 1,
@@ -196,7 +196,7 @@ public function test_uses_preloaded_occurrence_and_reserved_quantity_without_que
{
$occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
$this->occurrenceRepository->shouldNotReceive('findFirstWhere');
- $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+ $this->soldAndReservedQuantities->shouldNotReceive('getReservedTicketsForOccurrence');
$result = $this->service->assertOccurrencePurchasable(
eventId: 1,
@@ -213,7 +213,7 @@ public function test_preloaded_reserved_quantity_counts_against_capacity(): void
{
$occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 4);
$this->occurrenceRepository->shouldNotReceive('findFirstWhere');
- $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+ $this->soldAndReservedQuantities->shouldNotReceive('getReservedTicketsForOccurrence');
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('capacity');
@@ -275,7 +275,7 @@ public function test_override_capacity_bypasses_sold_out_status(): void
{
$occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 10, usedCapacity: 10);
$this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
- $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+ $this->soldAndReservedQuantities->shouldNotReceive('getReservedTicketsForOccurrence');
$result = $this->service->assertOccurrencePurchasable(
eventId: 1,
@@ -292,7 +292,7 @@ public function test_override_capacity_allows_exceeding_capacity_for_active_occu
$occurrence = $this->occurrence(EventOccurrenceStatus::ACTIVE->name, capacity: 1, usedCapacity: 5);
$this->occurrenceRepository->shouldReceive('findFirstWhere')->andReturn($occurrence);
- $this->orderItemRepository->shouldNotReceive('getReservedQuantityForOccurrence');
+ $this->soldAndReservedQuantities->shouldNotReceive('getReservedTicketsForOccurrence');
$result = $this->service->assertOccurrencePurchasable(
eventId: 1,
diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
index 27736e399e..7d81f9e930 100644
--- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php
@@ -3,6 +3,8 @@
namespace Tests\Unit\Services\Domain\Order;
use Carbon\Carbon;
+use HiEvents\DomainObjects\Enums\ProductPriceType;
+use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\EventOccurrenceDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
@@ -11,7 +13,6 @@
use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
-use HiEvents\Repository\Interfaces\OrderItemRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface;
use HiEvents\Repository\Interfaces\ProductRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
@@ -21,6 +22,7 @@
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesDTO;
use HiEvents\Services\Domain\Product\DTO\AvailableProductQuantitiesResponseDTO;
use HiEvents\Services\Domain\Product\ProductPriceService;
+use HiEvents\Services\Domain\Product\SoldAndReservedQuantitiesService;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Mockery;
@@ -41,7 +43,7 @@ class OrderCreateRequestValidationServiceTest extends TestCase
private ProductOccurrenceVisibilityRepositoryInterface|MockInterface $visibilityRepository;
- private OrderItemRepositoryInterface|MockInterface $orderItemRepository;
+ private SoldAndReservedQuantitiesService|MockInterface $soldAndReservedQuantities;
private ProductPriceService|MockInterface $productPriceService;
@@ -57,7 +59,7 @@ protected function setUp(): void
$this->availabilityService = Mockery::mock(AvailableProductQuantitiesFetchService::class);
$this->occurrenceRepository = Mockery::mock(EventOccurrenceRepositoryInterface::class);
$this->visibilityRepository = Mockery::mock(ProductOccurrenceVisibilityRepositoryInterface::class);
- $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+ $this->soldAndReservedQuantities = Mockery::mock(SoldAndReservedQuantitiesService::class);
$this->productPriceService = Mockery::mock(ProductPriceService::class);
$this->visibilityRepository
@@ -65,8 +67,8 @@ protected function setUp(): void
->byDefault()
->andReturn(collect());
- $this->orderItemRepository
- ->shouldReceive('getReservedQuantityForOccurrence')
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getReservedTicketsForOccurrence')
->byDefault()
->andReturn(0);
@@ -93,7 +95,7 @@ protected function setUp(): void
$eligibilityService = new OccurrencePurchaseEligibilityService(
$this->occurrenceRepository,
- $this->orderItemRepository,
+ $this->soldAndReservedQuantities,
$this->visibilityRepository,
);
@@ -907,6 +909,77 @@ public function test_accepts_product_inside_its_sale_window(): void
$this->assertTrue(true);
}
+ public function test_rejects_tier_locked_behind_an_earlier_tier_with_stock(): void
+ {
+ $this->setupSequentialTierScenario(tierOneReserved: 0);
+
+ try {
+ $this->service->validateRequestData(1, $this->createRequestData(10, priceId: 101));
+ $this->fail('Expected ValidationException was not thrown');
+ } catch (ValidationException $e) {
+ $this->assertArrayHasKey('products.0.quantities.0.price_id', $e->errors());
+ $this->assertSame('This price is not on sale yet', $e->errors()['products.0.quantities.0.price_id'][0]);
+ }
+ }
+
+ public function test_accepts_next_tier_once_earlier_tier_is_fully_reserved(): void
+ {
+ $this->setupSequentialTierScenario(tierOneReserved: 10);
+
+ $this->service->validateRequestData(1, $this->createRequestData(10, priceId: 101));
+
+ $this->assertTrue(true);
+ }
+
+ private function setupSequentialTierScenario(int $tierOneReserved): void
+ {
+ $this->setupOccurrenceLookup(1, 10, $this->createOccurrence(capacity: 100));
+ $this->setupEventLookup(1);
+
+ $this->availabilityService
+ ->shouldReceive('getAvailableProductQuantities')
+ ->andReturn(new AvailableProductQuantitiesResponseDTO(
+ productQuantities: collect([
+ $this->createQuantityDto(priceId: 100, available: 10 - $tierOneReserved, reserved: $tierOneReserved),
+ $this->createQuantityDto(priceId: 101, available: 100, reserved: 0),
+ ]),
+ capacities: collect(),
+ ));
+
+ $product = (new ProductDomainObject)
+ ->setId(10)
+ ->setEventId(1)
+ ->setTitle('Ticket')
+ ->setType(ProductPriceType::TIERED->name)
+ ->setProductType(ProductType::TICKET->name)
+ ->setSequentialTierReleaseEnabled(true)
+ ->setMaxPerOrder(10)
+ ->setMinPerOrder(1)
+ ->setProductPrices(collect([
+ (new ProductPriceDomainObject)->setId(100)->setOrder(1)->setPrice(10.00)->setInitialQuantityAvailable(10)->setQuantitySold(0),
+ (new ProductPriceDomainObject)->setId(101)->setOrder(2)->setPrice(20.00)->setInitialQuantityAvailable(100)->setQuantitySold(0),
+ ]));
+
+ $this->productRepository
+ ->shouldReceive('findWhereIn')
+ ->andReturn(collect([$product]));
+ }
+
+ private function createQuantityDto(int $priceId, int $available, int $reserved): AvailableProductQuantitiesDTO
+ {
+ return AvailableProductQuantitiesDTO::fromArray([
+ 'product_id' => 10,
+ 'price_id' => $priceId,
+ 'product_title' => 'Ticket',
+ 'product_type' => 'TICKET',
+ 'price_label' => null,
+ 'quantity_available' => $available,
+ 'quantity_reserved' => $reserved,
+ 'initial_quantity_available' => 100,
+ 'capacities' => new Collection,
+ ]);
+ }
+
private function setupSaleWindowScenario(
bool $productBeforeSaleStart = false,
bool $productAfterSaleEnd = false,
@@ -1022,8 +1095,12 @@ private function setupProducts(
$price->shouldReceive('getLabel')->andReturn(null);
$price->shouldReceive('isBeforeSaleStartDate')->andReturn($priceBeforeSaleStart);
$price->shouldReceive('isAfterSaleEndDate')->andReturn($priceAfterSaleEnd);
+ $price->shouldReceive('setQuantityReserved')->andReturnSelf();
+ $price->shouldReceive('isQuantityPerOccurrence')->andReturn(false);
+ $price->shouldReceive('isLockedBehindEarlierTier')->andReturn(false);
$product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('markLockedTiers');
$product->shouldReceive('getId')->andReturn($productId);
$product->shouldReceive('getEventId')->andReturn($eventId);
$product->shouldReceive('getTitle')->andReturn('Test Product');
@@ -1060,8 +1137,12 @@ private function createFullProductMock(
$price->shouldReceive('getLabel')->andReturn(null);
$price->shouldReceive('isBeforeSaleStartDate')->andReturn(false);
$price->shouldReceive('isAfterSaleEndDate')->andReturn(false);
+ $price->shouldReceive('setQuantityReserved')->andReturnSelf();
+ $price->shouldReceive('isQuantityPerOccurrence')->andReturn(false);
+ $price->shouldReceive('isLockedBehindEarlierTier')->andReturn(false);
$product = Mockery::mock(ProductDomainObject::class);
+ $product->shouldReceive('markLockedTiers');
$product->shouldReceive('getId')->andReturn($productId);
$product->shouldReceive('getEventId')->andReturn(1);
$product->shouldReceive('getTitle')->andReturn('Product '.$productId);
diff --git a/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php
index c59b03df63..a6646d7948 100644
--- a/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Product/ProductFilterServiceTest.php
@@ -144,6 +144,61 @@ public function test_filter_products_rejects_hidden_addon_only_products(): void
$this->assertTrue($result->isEmpty());
}
+ public function test_sequential_release_locks_later_tiers_but_keeps_them_visible(): void
+ {
+ $product = $this->createSequentialTieredProduct();
+
+ $this->expectAccountConfigurationLoad();
+ $this->expectQuantities([
+ $this->createQuantityDto(productId: 1, priceId: 100, quantityAvailable: 5),
+ $this->createQuantityDto(productId: 1, priceId: 101, quantityAvailable: 50),
+ ]);
+
+ $result = $this->service->filterProducts(collect([$product]));
+
+ $prices = $result->first()->getProductPrices()->values();
+ $this->assertCount(2, $prices);
+ $this->assertTrue($prices[0]->isAvailable());
+ $this->assertFalse($prices[0]->isLockedBehindEarlierTier());
+ $this->assertFalse($prices[1]->isAvailable());
+ $this->assertTrue($prices[1]->isLockedBehindEarlierTier());
+ $this->assertSame('Price is locked until earlier tiers sell out', $prices[1]->getOffSaleReason());
+ $this->assertSame(5, $result->first()->getQuantityAvailable());
+ }
+
+ public function test_sequential_release_unlocks_next_tier_when_earlier_tier_is_fully_reserved(): void
+ {
+ $product = $this->createSequentialTieredProduct();
+
+ $this->expectAccountConfigurationLoad();
+ $this->expectQuantities([
+ $this->createQuantityDto(productId: 1, priceId: 100, quantityAvailable: 0, quantityReserved: 10),
+ $this->createQuantityDto(productId: 1, priceId: 101, quantityAvailable: 50),
+ ]);
+
+ $result = $this->service->filterProducts(collect([$product]));
+
+ $prices = $result->first()->getProductPrices()->values();
+ $this->assertFalse($prices[0]->isAvailable());
+ $this->assertTrue($prices[0]->isSoldOut());
+ $this->assertTrue($prices[1]->isAvailable());
+ $this->assertFalse($prices[1]->isLockedBehindEarlierTier());
+ }
+
+ private function createSequentialTieredProduct(): ProductDomainObject
+ {
+ return (new ProductDomainObject)
+ ->setId(1)
+ ->setEventId(self::EVENT_ID)
+ ->setProductCategoryId(5)
+ ->setType(ProductPriceType::TIERED->name)
+ ->setSequentialTierReleaseEnabled(true)
+ ->setProductPrices(collect([
+ (new ProductPriceDomainObject)->setId(100)->setOrder(1)->setPrice(0.00)->setInitialQuantityAvailable(10)->setQuantitySold(0),
+ (new ProductPriceDomainObject)->setId(101)->setOrder(2)->setPrice(0.00)->setInitialQuantityAvailable(50)->setQuantitySold(0),
+ ]));
+ }
+
private function createFreeProduct(int $id, int $priceId, int $productCategoryId = 5): ProductDomainObject
{
return (new ProductDomainObject)
@@ -158,7 +213,7 @@ private function createFreeProduct(int $id, int $priceId, int $productCategoryId
]));
}
- private function createQuantityDto(int $productId, int $priceId, int $quantityAvailable): AvailableProductQuantitiesDTO
+ private function createQuantityDto(int $productId, int $priceId, int $quantityAvailable, int $quantityReserved = 0): AvailableProductQuantitiesDTO
{
return new AvailableProductQuantitiesDTO(
product_id: $productId,
@@ -166,7 +221,7 @@ private function createQuantityDto(int $productId, int $priceId, int $quantityAv
product_title: 'Ticket',
price_label: null,
quantity_available: $quantityAvailable,
- quantity_reserved: 0,
+ quantity_reserved: $quantityReserved,
initial_quantity_available: null,
product_type: 'TICKET',
);
diff --git a/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php b/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php
index a988152d81..a4f0a86a57 100644
--- a/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Product/ProductPriceUpdateServiceTest.php
@@ -3,6 +3,7 @@
namespace Tests\Unit\Services\Domain\Product;
use HiEvents\DomainObjects\Enums\ProductPriceType;
+use HiEvents\DomainObjects\Enums\ProductQuantityAppliesTo;
use HiEvents\DomainObjects\Enums\ProductType;
use HiEvents\DomainObjects\EventDomainObject;
use HiEvents\DomainObjects\ProductDomainObject;
@@ -11,6 +12,7 @@
use HiEvents\Services\Application\Handlers\Product\DTO\UpsertProductDTO;
use HiEvents\Services\Domain\Product\DTO\ProductPriceDTO;
use HiEvents\Services\Domain\Product\ProductPriceUpdateService;
+use HiEvents\Services\Domain\Product\SoldAndReservedQuantitiesService;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
use Mockery;
@@ -21,6 +23,8 @@ class ProductPriceUpdateServiceTest extends TestCase
{
private ProductPriceRepository|MockInterface $productPriceRepository;
+ private SoldAndReservedQuantitiesService|MockInterface $soldAndReservedQuantities;
+
private ProductPriceUpdateService $service;
protected function setUp(): void
@@ -28,7 +32,11 @@ protected function setUp(): void
parent::setUp();
$this->productPriceRepository = Mockery::mock(ProductPriceRepository::class);
- $this->service = new ProductPriceUpdateService($this->productPriceRepository);
+ $this->soldAndReservedQuantities = Mockery::mock(SoldAndReservedQuantitiesService::class);
+ $this->service = new ProductPriceUpdateService(
+ $this->productPriceRepository,
+ $this->soldAndReservedQuantities,
+ );
}
protected function tearDown(): void
@@ -104,6 +112,170 @@ public function test_throws_for_correct_tier_in_tiered_product(): void
}
}
+ public function test_per_date_tier_on_recurring_event_is_checked_against_max_sold_on_a_single_date(): void
+ {
+ $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 30, label: 'Early Bird')]);
+ [$product, $event] = $this->createProductAndEvent($existingPrices, isRecurring: true);
+
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getMaxSoldOnAnyOccurrenceByPrice')
+ ->once()
+ ->with([1], ProductType::TICKET)
+ ->andReturn([1 => 8]);
+ $this->productPriceRepository->shouldReceive('updateWhere')->once()->with(
+ Mockery::on(fn (array $attributes) => $attributes['quantity_applies_to'] === 'OCCURRENCE'
+ && $attributes['initial_quantity_available'] === 10),
+ ['id' => 1],
+ );
+
+ $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [
+ new ProductPriceDTO(price: 10.00, initial_quantity_available: 10, id: 1, quantity_applies_to: ProductQuantityAppliesTo::OCCURRENCE),
+ ]);
+
+ $this->service->updatePrices($product, $productsData, $existingPrices, $event);
+ $this->assertTrue(true);
+ }
+
+ public function test_per_date_tier_rejects_quantity_below_max_sold_on_a_single_date(): void
+ {
+ $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 30, label: 'Early Bird')]);
+ [$product, $event] = $this->createProductAndEvent($existingPrices, isRecurring: true);
+
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getMaxSoldOnAnyOccurrenceByPrice')
+ ->once()
+ ->andReturn([1 => 12]);
+
+ $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [
+ new ProductPriceDTO(price: 10.00, initial_quantity_available: 10, id: 1, quantity_applies_to: ProductQuantityAppliesTo::OCCURRENCE),
+ ]);
+
+ try {
+ $this->service->updatePrices($product, $productsData, $existingPrices, $event);
+ $this->fail('Expected ValidationException was not thrown');
+ } catch (ValidationException $e) {
+ $this->assertStringContainsString('single date', $e->errors()['prices.0.initial_quantity_available'][0]);
+ $this->assertStringContainsString('12', $e->errors()['prices.0.initial_quantity_available'][0]);
+ }
+ }
+
+ public function test_general_product_per_date_tier_uses_order_item_sales(): void
+ {
+ $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 30, label: 'Parking')]);
+ [$product, $event] = $this->createProductAndEvent($existingPrices, isRecurring: true);
+
+ $this->soldAndReservedQuantities
+ ->shouldReceive('getMaxSoldOnAnyOccurrenceByPrice')
+ ->once()
+ ->with([1], ProductType::GENERAL)
+ ->andReturn([1 => 2]);
+ $this->productPriceRepository->shouldReceive('updateWhere')->once();
+
+ $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [
+ new ProductPriceDTO(price: 10.00, initial_quantity_available: 5, id: 1, quantity_applies_to: ProductQuantityAppliesTo::OCCURRENCE),
+ ], ProductType::GENERAL);
+
+ $this->service->updatePrices($product, $productsData, $existingPrices, $event);
+ $this->assertTrue(true);
+ }
+
+ public function test_event_wide_tier_on_recurring_event_keeps_the_global_check(): void
+ {
+ $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 30, label: 'Season pass')]);
+ [$product, $event] = $this->createProductAndEvent($existingPrices, isRecurring: true);
+
+ $this->soldAndReservedQuantities->shouldNotReceive('getMaxSoldOnAnyOccurrenceByPrice');
+
+ $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [
+ new ProductPriceDTO(price: 10.00, initial_quantity_available: 10, id: 1, quantity_applies_to: ProductQuantityAppliesTo::EVENT),
+ ]);
+
+ $this->expectException(ValidationException::class);
+ $this->service->updatePrices($product, $productsData, $existingPrices, $event);
+ }
+
+ public function test_single_event_tiers_are_always_event_wide(): void
+ {
+ $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 0, label: 'Default')]);
+ [$product, $event] = $this->createProductAndEvent($existingPrices);
+
+ $this->productPriceRepository->shouldReceive('updateWhere')->once()->with(
+ Mockery::on(fn (array $attributes) => $attributes['quantity_applies_to'] === 'EVENT'),
+ ['id' => 1],
+ );
+
+ $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [
+ new ProductPriceDTO(price: 10.00, id: 1, quantity_applies_to: ProductQuantityAppliesTo::OCCURRENCE),
+ ]);
+
+ $this->service->updatePrices($product, $productsData, $existingPrices, $event);
+ $this->assertTrue(true);
+ }
+
+ public function test_missing_quantity_applies_to_defaults_by_product_type(): void
+ {
+ $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 0, label: 'Default')]);
+ [$product, $event] = $this->createProductAndEvent($existingPrices);
+
+ $this->productPriceRepository->shouldReceive('updateWhere')->once()->with(
+ Mockery::on(fn (array $attributes) => $attributes['quantity_applies_to'] === 'EVENT'),
+ ['id' => 1],
+ );
+
+ $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [
+ new ProductPriceDTO(price: 10.00, id: 1),
+ ], ProductType::GENERAL);
+
+ $this->service->updatePrices($product, $productsData, $existingPrices, $event);
+ $this->assertTrue(true);
+ }
+
+ public function test_missing_quantity_applies_to_on_recurring_ticket_defaults_to_per_date(): void
+ {
+ $existingPrices = new Collection([$this->createExistingPrice(id: 1, quantitySold: 0, label: 'Default')]);
+ [$product, $event] = $this->createProductAndEvent($existingPrices, isRecurring: true);
+
+ $this->productPriceRepository->shouldReceive('updateWhere')->once()->with(
+ Mockery::on(fn (array $attributes) => $attributes['quantity_applies_to'] === 'OCCURRENCE'),
+ ['id' => 1],
+ );
+
+ $productsData = $this->createUpsertDTO(ProductPriceType::PAID, [
+ new ProductPriceDTO(price: 10.00, id: 1),
+ ]);
+
+ $this->service->updatePrices($product, $productsData, $existingPrices, $event);
+ $this->assertTrue(true);
+ }
+
+ public function test_rewrites_order_to_match_submitted_tier_sequence(): void
+ {
+ $existingPrices = new Collection([
+ $this->createExistingPrice(id: 1, quantitySold: 0, label: 'Tier 1'),
+ $this->createExistingPrice(id: 2, quantitySold: 0, label: 'Tier 2'),
+ ]);
+ [$product, $event] = $this->createProductAndEvent($existingPrices);
+
+ $orders = [];
+ $this->productPriceRepository
+ ->shouldReceive('updateWhere')
+ ->twice()
+ ->andReturnUsing(function (array $attributes, array $where) use (&$orders) {
+ $orders[$where['id']] = $attributes['order'];
+
+ return 1;
+ });
+
+ $productsData = $this->createUpsertDTO(ProductPriceType::TIERED, [
+ new ProductPriceDTO(price: 20.00, label: 'Tier 2', id: 2),
+ new ProductPriceDTO(price: 10.00, label: 'Tier 1', id: 1),
+ ]);
+
+ $this->service->updatePrices($product, $productsData, $existingPrices, $event);
+
+ $this->assertSame([2 => 1, 1 => 2], $orders);
+ }
+
private function createExistingPrice(int $id, int $quantitySold, string $label): MockInterface
{
$price = Mockery::mock(ProductPriceDomainObject::class);
@@ -114,7 +286,7 @@ private function createExistingPrice(int $id, int $quantitySold, string $label):
return $price;
}
- private function createProductAndEvent(Collection $existingPrices): array
+ private function createProductAndEvent(Collection $existingPrices, bool $isRecurring = false): array
{
$product = Mockery::mock(ProductDomainObject::class);
$product->shouldReceive('getId')->andReturn(1);
@@ -122,11 +294,12 @@ private function createProductAndEvent(Collection $existingPrices): array
$event = Mockery::mock(EventDomainObject::class);
$event->shouldReceive('getTimezone')->andReturn('UTC');
+ $event->shouldReceive('isRecurring')->andReturn($isRecurring);
return [$product, $event];
}
- private function createUpsertDTO(ProductPriceType $type, array $prices): UpsertProductDTO
+ private function createUpsertDTO(ProductPriceType $type, array $prices, ProductType $productType = ProductType::TICKET): UpsertProductDTO
{
return UpsertProductDTO::fromArray([
'account_id' => 1,
@@ -135,7 +308,7 @@ private function createUpsertDTO(ProductPriceType $type, array $prices): UpsertP
'product_category_id' => 1,
'title' => 'Test',
'type' => $type,
- 'product_type' => ProductType::TICKET,
+ 'product_type' => $productType,
'prices' => new Collection($prices),
]);
}
diff --git a/backend/tests/Unit/Services/Domain/Product/SoldAndReservedQuantitiesServiceTest.php b/backend/tests/Unit/Services/Domain/Product/SoldAndReservedQuantitiesServiceTest.php
new file mode 100644
index 0000000000..6f835dfe21
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Product/SoldAndReservedQuantitiesServiceTest.php
@@ -0,0 +1,64 @@
+attendeeRepository = Mockery::mock(AttendeeRepositoryInterface::class);
+ $this->orderItemRepository = Mockery::mock(OrderItemRepositoryInterface::class);
+ $this->service = new SoldAndReservedQuantitiesService($this->attendeeRepository, $this->orderItemRepository);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+
+ public function test_ticket_sales_come_from_attendees_and_general_sales_from_order_items(): void
+ {
+ $this->attendeeRepository->shouldReceive('getSoldQuantitiesByPriceForOccurrence')->once()->with(5)->andReturn([1 => 2]);
+ $this->orderItemRepository->shouldReceive('getSoldQuantitiesByPriceForOccurrence')->once()->with(5)->andReturn([2 => 3]);
+
+ $this->assertSame([1 => 2], $this->service->getSoldByPriceForOccurrence(5, ProductType::TICKET));
+ $this->assertSame([2 => 3], $this->service->getSoldByPriceForOccurrence(5, ProductType::GENERAL));
+ }
+
+ public function test_busiest_date_lookup_follows_the_same_split(): void
+ {
+ $this->attendeeRepository->shouldReceive('getMaxSoldPerOccurrenceByPrice')->once()->with([1])->andReturn([1 => 9]);
+ $this->orderItemRepository->shouldReceive('getMaxSoldPerOccurrenceByPrice')->once()->with([2])->andReturn([2 => 4]);
+
+ $this->assertSame([1 => 9], $this->service->getMaxSoldOnAnyOccurrenceByPrice([1], ProductType::TICKET));
+ $this->assertSame([2 => 4], $this->service->getMaxSoldOnAnyOccurrenceByPrice([2], ProductType::GENERAL));
+ }
+
+ public function test_reservations_are_read_from_order_items(): void
+ {
+ $this->orderItemRepository->shouldReceive('getReservedQuantitiesByPrice')->once()->with(7, null)->andReturn([1 => 1]);
+ $this->orderItemRepository->shouldReceive('getReservedQuantitiesByPrice')->once()->with(7, 5)->andReturn([1 => 0]);
+ $this->orderItemRepository->shouldReceive('getReservedTicketQuantityForOccurrence')->once()->with(5)->andReturn(6);
+
+ $this->assertSame([1 => 1], $this->service->getReservedByPrice(7));
+ $this->assertSame([1 => 0], $this->service->getReservedByPrice(7, 5));
+ $this->assertSame(6, $this->service->getReservedTicketsForOccurrence(5));
+ }
+}
diff --git a/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php b/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php
index c30c2aa34b..143bdeb471 100644
--- a/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php
+++ b/backend/tests/Unit/Services/Infrastructure/HtmlPurifier/HtmlPurifierServiceTest.php
@@ -5,6 +5,7 @@
namespace Tests\Unit\Services\Infrastructure\HtmlPurifier;
use HiEvents\Services\Infrastructure\HtmlPurifier\HtmlPurifierService;
+use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class HtmlPurifierServiceTest extends TestCase
@@ -39,6 +40,132 @@ public function test_scripts_are_still_removed(): void
$this->assertStringNotContainsString('alert', (string) $this->service->purify('Hi'));
}
+ #[DataProvider('liquidTokenInUriProvider')]
+ public function test_liquid_tokens_survive_purification_inside_uris(string $html, string $expectedHref): void
+ {
+ $purified = (string) $this->service->purify($html);
+
+ $this->assertStringContainsString($expectedHref, $purified);
+ $this->assertStringNotContainsString('%7B%7B', $purified);
+ }
+
+ public static function liquidTokenInUriProvider(): array
+ {
+ return [
+ 'query string' => [
+ 'View',
+ 'href="https://example.com/orders?ref={{ order.number }}"',
+ ],
+ 'path segment' => [
+ 'View',
+ 'href="https://example.com/{{ event.slug }}/tickets"',
+ ],
+ 'fragment' => [
+ 'View',
+ 'href="https://example.com/orders#{{ order.id }}"',
+ ],
+ 'entire href' => [
+ 'View',
+ 'href="{{ order.url }}"',
+ ],
+ 'token with filter' => [
+ 'View',
+ 'href="https://example.com/{{ event.slug | downcase }}"',
+ ],
+ 'multiple tokens' => [
+ 'View',
+ 'href="https://example.com/o?ref={{ order.number }}&t={{ order.id }}"',
+ ],
+ 'token without surrounding spaces' => [
+ 'View',
+ 'href="https://example.com/o?ref={{order.number}}"',
+ ],
+ ];
+ }
+
+ public function test_liquid_tokens_in_text_nodes_are_untouched(): void
+ {
+ $html = 'Hi {{ order.first_name }}
{% if order.is_payment_required %}Due{% endif %}
';
+
+ $this->assertSame($html, $this->service->purify($html));
+ }
+
+ public function test_liquid_href_still_gets_nofollow_and_target_blank(): void
+ {
+ $purified = (string) $this->service->purify('x');
+
+ $this->assertStringContainsString('{{ order.number }}', $purified);
+ $this->assertStringContainsString('rel="nofollow', $purified);
+ $this->assertStringContainsString('target="_blank"', $purified);
+ }
+
+ #[DataProvider('hostileLiquidTokenProvider')]
+ public function test_liquid_tokens_cannot_smuggle_markup_past_the_purifier(string $html, string $mustNotContain): void
+ {
+ $purified = (string) $this->service->purify($html);
+
+ $this->assertStringNotContainsString($mustNotContain, $purified);
+ }
+
+ public static function hostileLiquidTokenProvider(): array
+ {
+ return [
+ 'script tag in a string literal' => [
+ '{{ \'\' }}
',
+ '