diff --git a/backend/app/Console/Commands/BootstrapDevDataCommand.php b/backend/app/Console/Commands/BootstrapDevDataCommand.php
index 90c38ed654..861129366f 100644
--- a/backend/app/Console/Commands/BootstrapDevDataCommand.php
+++ b/backend/app/Console/Commands/BootstrapDevDataCommand.php
@@ -56,6 +56,8 @@ public function handle(
return self::FAILURE;
}
+ $this->seedCurrencyDefaultConfigurations();
+
$email = $this->option('email') ?: 'agent+'.now()->format('YmdHis').'@dev.test';
$password = $this->option('password');
@@ -208,4 +210,38 @@ private function createProduct(
'price_id' => DB::table('product_prices')->where('product_id', $product->getId())->value('id'),
];
}
+
+ private function seedCurrencyDefaultConfigurations(): void
+ {
+ $fees = [
+ 'USD' => 0.60,
+ 'EUR' => 0.50,
+ 'GBP' => 0.45,
+ 'AUD' => 0.85,
+ ];
+
+ foreach ($fees as $currency => $fixedFee) {
+ $exists = DB::table('organizer_configurations')
+ ->where('default_for_currency', $currency)
+ ->whereNull('deleted_at')
+ ->exists();
+
+ if ($exists) {
+ continue;
+ }
+
+ DB::table('organizer_configurations')->insert([
+ 'name' => "Standard ($currency)",
+ 'is_system_default' => false,
+ 'application_fees' => json_encode([
+ 'percentage' => 1.25,
+ 'fixed' => $fixedFee,
+ 'currency' => $currency,
+ ], JSON_THROW_ON_ERROR),
+ 'default_for_currency' => $currency,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+ }
}
diff --git a/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php
index 229df661c5..c6da506d24 100644
--- a/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php
+++ b/backend/app/DomainObjects/Generated/OrganizerConfigurationDomainObjectAbstract.php
@@ -19,6 +19,7 @@ abstract class OrganizerConfigurationDomainObjectAbstract extends \HiEvents\Doma
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
final public const DELETED_AT = 'deleted_at';
+ final public const DEFAULT_FOR_CURRENCY = 'default_for_currency';
protected int $id;
protected string $name;
@@ -29,6 +30,7 @@ abstract class OrganizerConfigurationDomainObjectAbstract extends \HiEvents\Doma
protected ?string $created_at = null;
protected ?string $updated_at = null;
protected ?string $deleted_at = null;
+ protected ?string $default_for_currency = null;
public function toArray(): array
{
@@ -42,6 +44,7 @@ public function toArray(): array
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
'deleted_at' => $this->deleted_at ?? null,
+ 'default_for_currency' => $this->default_for_currency ?? null,
];
}
@@ -143,4 +146,15 @@ public function getDeletedAt(): ?string
{
return $this->deleted_at;
}
+
+ public function setDefaultForCurrency(?string $default_for_currency): self
+ {
+ $this->default_for_currency = $default_for_currency;
+ return $this;
+ }
+
+ public function getDefaultForCurrency(): ?string
+ {
+ return $this->default_for_currency;
+ }
}
diff --git a/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php b/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php
index 01e85760ac..af56494c7b 100644
--- a/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php
+++ b/backend/app/DomainObjects/OrganizerConfigurationDomainObject.php
@@ -18,4 +18,9 @@ public function getApplicationFeeCurrency(): string
{
return $this->getApplicationFees()['currency'] ?? 'USD';
}
+
+ public function isDefault(): bool
+ {
+ return $this->getIsSystemDefault() || $this->getDefaultForCurrency() !== null;
+ }
}
diff --git a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php
index d95e7cf227..3d3da734c1 100644
--- a/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php
+++ b/backend/app/Http/Actions/Admin/Configurations/UpdateConfigurationAction.php
@@ -10,6 +10,7 @@
use HiEvents\Resources\Organizer\OrganizerConfigurationResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Validation\ValidationException;
class UpdateConfigurationAction extends BaseAction
{
@@ -30,6 +31,18 @@ public function __invoke(Request $request, int $configurationId): JsonResponse
'bypass_application_fees' => 'sometimes|boolean',
]);
+ $existingConfiguration = $this->repository->findById($configurationId);
+ $defaultForCurrency = $existingConfiguration->getDefaultForCurrency();
+ $feeCurrency = $validated['application_fees']['currency'] ?? null;
+
+ if ($defaultForCurrency !== null && $feeCurrency !== null && $feeCurrency !== $defaultForCurrency) {
+ throw ValidationException::withMessages([
+ 'application_fees.currency' => __('The fee currency of the :currency default configuration must remain :currency.', [
+ 'currency' => $defaultForCurrency,
+ ]),
+ ]);
+ }
+
$configuration = $this->repository->updateFromArray(
id: $configurationId,
attributes: [
diff --git a/backend/app/Resources/Account/AdminAccountDetailResource.php b/backend/app/Resources/Account/AdminAccountDetailResource.php
index 98b8b15ffa..0d22e82035 100644
--- a/backend/app/Resources/Account/AdminAccountDetailResource.php
+++ b/backend/app/Resources/Account/AdminAccountDetailResource.php
@@ -38,6 +38,7 @@ public function toArray(Request $request): array
'id' => $configuration->id,
'name' => $configuration->name,
'is_system_default' => $configuration->is_system_default,
+ 'default_for_currency' => $configuration->default_for_currency,
'application_fees' => $configuration->application_fees ?? [
'percentage' => 0,
'fixed' => 0,
diff --git a/backend/app/Resources/Organizer/OrganizerConfigurationResource.php b/backend/app/Resources/Organizer/OrganizerConfigurationResource.php
index 4521c10c89..2c20539f69 100644
--- a/backend/app/Resources/Organizer/OrganizerConfigurationResource.php
+++ b/backend/app/Resources/Organizer/OrganizerConfigurationResource.php
@@ -16,6 +16,7 @@ public function toArray($request): array
'id' => $this->getId(),
'name' => $this->getName(),
'is_system_default' => $this->getIsSystemDefault(),
+ 'default_for_currency' => $this->getDefaultForCurrency(),
'application_fees' => [
'percentage' => $this->getPercentageApplicationFee(),
'fixed' => $this->getFixedApplicationFee(),
diff --git a/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
index c70f926e86..9cf7091fdf 100644
--- a/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/DeleteConfigurationHandler.php
@@ -22,9 +22,9 @@ public function handle(int $configurationId): void
{
$configuration = $this->repository->findById($configurationId);
- if ($configuration->getIsSystemDefault()) {
+ if ($configuration->isDefault()) {
throw new CannotDeleteEntityException(
- __('The system default configuration cannot be deleted.')
+ __('Default configurations cannot be deleted.')
);
}
diff --git a/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php
index 8830f480cc..2b93132f05 100644
--- a/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php
+++ b/backend/app/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandler.php
@@ -27,7 +27,7 @@ public function handle(UpdateOrganizerConfigurationDTO $dto): OrganizerConfigura
$currentConfiguration = $organizer->getOrganizerConfiguration();
if ($currentConfiguration !== null
- && ! $currentConfiguration->getIsSystemDefault()
+ && ! $currentConfiguration->isDefault()
&& $this->isConfigurationDedicatedTo($currentConfiguration->getId(), $organizer->getId())
) {
return $this->configurationRepository->updateFromArray(
diff --git a/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php
index 3cb962e0bb..19cb5376ff 100644
--- a/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php
+++ b/backend/app/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandler.php
@@ -11,6 +11,7 @@
use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
use HiEvents\Services\Application\Handlers\Organizer\Payment\Stripe\DTO\CopyStripeConnectAccountDTO;
use HiEvents\Services\Application\Handlers\Organizer\Payment\Stripe\DTO\CreateStripeConnectAccountResponse;
+use HiEvents\Services\Domain\Organizer\AssignCurrencyDefaultOrganizerConfigurationService;
use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService;
use Illuminate\Config\Repository;
use Illuminate\Database\DatabaseManager;
@@ -22,6 +23,7 @@ public function __construct(
private readonly OrganizerRepositoryInterface $organizerRepository,
private readonly OrganizerStripePlatformRepositoryInterface $organizerStripePlatformRepository,
private readonly StripeAccountSyncService $stripeAccountSyncService,
+ private readonly AssignCurrencyDefaultOrganizerConfigurationService $assignCurrencyDefaultOrganizerConfigurationService,
private readonly DatabaseManager $databaseManager,
private readonly Repository $config,
) {}
@@ -114,6 +116,11 @@ private function copy(CopyStripeConnectAccountDTO $command): CreateStripeConnect
stripeAccountId: $sourcePlatform->getStripeAccountId(),
);
+ $this->assignCurrencyDefaultOrganizerConfigurationService->assignForCountry(
+ organizerId: (int) $target->getId(),
+ countryCode: $sourceDetails['country'] ?? null,
+ );
+
return new CreateStripeConnectAccountResponse(
stripeConnectAccountType: $sourcePlatform->getStripeConnectAccountType() ?? '',
stripeAccountId: $sourcePlatform->getStripeAccountId() ?? '',
diff --git a/backend/app/Services/Domain/Organizer/AssignCurrencyDefaultOrganizerConfigurationService.php b/backend/app/Services/Domain/Organizer/AssignCurrencyDefaultOrganizerConfigurationService.php
new file mode 100644
index 0000000000..062728bada
--- /dev/null
+++ b/backend/app/Services/Domain/Organizer/AssignCurrencyDefaultOrganizerConfigurationService.php
@@ -0,0 +1,137 @@
+ 'USD',
+ 'GB' => 'GBP',
+ 'AU' => 'AUD',
+ ];
+
+ private const string FALLBACK_CURRENCY = 'EUR';
+
+ public function __construct(
+ private readonly OrganizerRepositoryInterface $organizerRepository,
+ private readonly OrganizerConfigurationRepositoryInterface $organizerConfigurationRepository,
+ private readonly Repository $config,
+ private readonly LoggerInterface $logger,
+ ) {}
+
+ public function assignForCountry(int $organizerId, ?string $countryCode): void
+ {
+ if (! $this->config->get('app.saas_mode_enabled')) {
+ return;
+ }
+
+ try {
+ $this->assign($organizerId, $countryCode);
+ } catch (Throwable $exception) {
+ $this->logger->error('Failed to assign currency default organizer configuration', [
+ 'organizer_id' => $organizerId,
+ 'country_code' => $countryCode,
+ 'error' => $exception->getMessage(),
+ ]);
+ }
+ }
+
+ private function assign(int $organizerId, ?string $countryCode): void
+ {
+ if ($countryCode === null || trim($countryCode) === '') {
+ $this->logger->info('No Stripe account country available, skipping configuration assignment', [
+ 'organizer_id' => $organizerId,
+ ]);
+
+ return;
+ }
+
+ $currency = self::CURRENCY_BY_COUNTRY[strtoupper(trim($countryCode))] ?? self::FALLBACK_CURRENCY;
+
+ if (! $this->currencyDefaultConfigurationsExist()) {
+ return;
+ }
+
+ /** @var OrganizerDomainObject|null $organizer */
+ $organizer = $this->organizerRepository->findFirstWhere(['id' => $organizerId]);
+ if ($organizer === null) {
+ return;
+ }
+
+ if (! $this->isOrganizerOnDefaultConfiguration($organizer)) {
+ return;
+ }
+
+ /** @var OrganizerConfigurationDomainObject|null $target */
+ $target = $this->organizerConfigurationRepository->findFirstWhere([
+ OrganizerConfigurationDomainObjectAbstract::DEFAULT_FOR_CURRENCY => $currency,
+ ]);
+
+ if ($target === null) {
+ $this->logger->warning('No default organizer configuration found for currency', [
+ 'organizer_id' => $organizerId,
+ 'country_code' => $countryCode,
+ 'currency' => $currency,
+ ]);
+
+ return;
+ }
+
+ if ($target->getId() === $organizer->getOrganizerConfigurationId()) {
+ return;
+ }
+
+ $this->organizerRepository->updateWhere(
+ attributes: [
+ OrganizerDomainObjectAbstract::ORGANIZER_CONFIGURATION_ID => $target->getId(),
+ ],
+ where: [
+ 'id' => $organizerId,
+ ],
+ );
+
+ $this->logger->info('Assigned currency default configuration to organizer', [
+ 'organizer_id' => $organizerId,
+ 'country_code' => $countryCode,
+ 'currency' => $currency,
+ 'organizer_configuration_id' => $target->getId(),
+ ]);
+ }
+
+ private function currencyDefaultConfigurationsExist(): bool
+ {
+ return $this->organizerConfigurationRepository->countWhere([
+ [OrganizerConfigurationDomainObjectAbstract::DEFAULT_FOR_CURRENCY, 'not null', null],
+ ]) > 0;
+ }
+
+ private function isOrganizerOnDefaultConfiguration(OrganizerDomainObject $organizer): bool
+ {
+ if ($organizer->getOrganizerConfigurationId() === null) {
+ return true;
+ }
+
+ /** @var OrganizerConfigurationDomainObject|null $currentConfiguration */
+ $currentConfiguration = $this->organizerConfigurationRepository->findFirstWhere([
+ 'id' => $organizer->getOrganizerConfigurationId(),
+ ]);
+
+ if ($currentConfiguration === null) {
+ return true;
+ }
+
+ return $currentConfiguration->isDefault();
+ }
+}
diff --git a/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php
index ac8cda3606..cba4e23161 100644
--- a/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php
+++ b/backend/app/Services/Domain/Payment/Stripe/StripeAccountSyncService.php
@@ -11,6 +11,7 @@
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
+use HiEvents\Services\Domain\Organizer\AssignCurrencyDefaultOrganizerConfigurationService;
use Illuminate\Config\Repository;
use Psr\Log\LoggerInterface;
use Stripe\Account;
@@ -26,6 +27,7 @@ public function __construct(
private readonly OrganizerStripePlatformRepositoryInterface $organizerStripePlatformRepository,
private readonly OrganizerVatSettingRepositoryInterface $vatSettingRepository,
private readonly Repository $config,
+ private readonly AssignCurrencyDefaultOrganizerConfigurationService $assignCurrencyDefaultOrganizerConfigurationService,
) {}
public function isStripeAccountComplete(Account $stripeAccount): bool
@@ -99,6 +101,10 @@ public function syncStripeAccountStatusByAccountId(Account $stripeAccount): void
stripeAccountId: $stripeAccount->id,
organizerStripePlatformId: $organizerRow->getId(),
);
+ $this->assignCurrencyDefaultOrganizerConfigurationService->assignForCountry(
+ organizerId: $organizerRow->getOrganizerId(),
+ countryCode: $stripeAccount->country,
+ );
}
}
@@ -129,6 +135,10 @@ public function markAccountAsCompleteForOrganizer(
stripeAccountId: $stripeAccount->id,
organizerStripePlatformId: $organizerStripePlatform->getId(),
);
+ $this->assignCurrencyDefaultOrganizerConfigurationService->assignForCountry(
+ organizerId: $organizerStripePlatform->getOrganizerId(),
+ countryCode: $stripeAccount->country,
+ );
}
public function seedVatSettingForOrganizerIfMissing(
diff --git a/backend/database/migrations/2026_08_27_000000_add_default_for_currency_to_organizer_configurations_table.php b/backend/database/migrations/2026_08_27_000000_add_default_for_currency_to_organizer_configurations_table.php
new file mode 100644
index 0000000000..b6b4b8e1a2
--- /dev/null
+++ b/backend/database/migrations/2026_08_27_000000_add_default_for_currency_to_organizer_configurations_table.php
@@ -0,0 +1,31 @@
+string('default_for_currency', 3)->nullable();
+ });
+
+ DB::statement('
+ CREATE UNIQUE INDEX IF NOT EXISTS organizer_configurations_default_per_currency
+ ON organizer_configurations (default_for_currency)
+ WHERE default_for_currency IS NOT NULL AND deleted_at IS NULL
+ ');
+ }
+
+ public function down(): void
+ {
+ DB::statement('DROP INDEX IF EXISTS organizer_configurations_default_per_currency');
+
+ Schema::table('organizer_configurations', static function (Blueprint $table) {
+ $table->dropColumn('default_for_currency');
+ });
+ }
+};
diff --git a/backend/lang/de.json b/backend/lang/de.json
index 7a36ded5c4..576f6d812f 100644
--- a/backend/lang/de.json
+++ b/backend/lang/de.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "Antwort von :organizerName",
"Your Tickets": "Ihre Tickets",
"Invalid VAT number format": "Ungültiges Format der USt-IdNr.",
- "The system default configuration cannot be deleted.": "Die Systemstandardkonfiguration kann nicht gelöscht werden.",
+ "Default configurations cannot be deleted.": "Standardkonfigurationen können nicht gelöscht werden.",
"User does not belong to this account": "Der Benutzer gehört nicht zu diesem Konto",
"Impersonation not allowed": "Identitätsübernahme nicht erlaubt",
"Email template not found": "E-Mail-Vorlage nicht gefunden",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "Für diese Veranstaltung sind keine Pässe verfügbar",
"Get Passes": "Pässe sichern",
"This message is from :organizer, not from :platform.": "Diese Nachricht stammt von :organizer, nicht von :platform.",
- "the event organizer": "dem Veranstalter der Veranstaltung"
+ "the event organizer": "dem Veranstalter der Veranstaltung",
+ "The fee currency of the :currency default configuration must remain :currency.": "Die Gebührenwährung der :currency-Standardkonfiguration muss :currency bleiben."
}
diff --git a/backend/lang/el.json b/backend/lang/el.json
index c11aa6c361..142ea84c03 100644
--- a/backend/lang/el.json
+++ b/backend/lang/el.json
@@ -542,7 +542,7 @@
"Validation failed after multiple attempts: :error": "Η επικύρωση απέτυχε μετά από πολλαπλές προσπάθειες: :error",
"Response from :organizerName": "Απάντηση από :organizerName",
"Invalid VAT number format": "Μη έγκυρη μορφή αριθμού ΦΠΑ",
- "The system default configuration cannot be deleted.": "Η προεπιλεγμένη ρύθμιση συστήματος δεν μπορεί να διαγραφεί.",
+ "Default configurations cannot be deleted.": "Οι προεπιλεγμένες διαμορφώσεις δεν μπορούν να διαγραφούν.",
"User does not belong to this account": "Ο χρήστης δεν ανήκει σε αυτόν τον λογαριασμό",
"Impersonation not allowed": "Η σύνδεση ως άλλος χρήστης δεν επιτρέπεται",
"Email template not found": "Το πρότυπο email δεν βρέθηκε",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "Δεν υπάρχουν διαθέσιμα πάσα για αυτήν την εκδήλωση",
"Get Passes": "Αποκτήστε πάσα",
"This message is from :organizer, not from :platform.": "Αυτό το μήνυμα προέρχεται από :organizer, όχι από :platform.",
- "the event organizer": "τον διοργανωτή της εκδήλωσης"
+ "the event organizer": "τον διοργανωτή της εκδήλωσης",
+ "The fee currency of the :currency default configuration must remain :currency.": "Το νόμισμα χρεώσεων της προεπιλεγμένης διαμόρφωσης :currency πρέπει να παραμείνει :currency."
}
diff --git a/backend/lang/es.json b/backend/lang/es.json
index 61f1b1aa7e..940f71d64a 100644
--- a/backend/lang/es.json
+++ b/backend/lang/es.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "Respuesta de :organizerName",
"Your Tickets": "Tus entradas",
"Invalid VAT number format": "Formato de número de IVA no válido",
- "The system default configuration cannot be deleted.": "La configuración predeterminada del sistema no se puede eliminar.",
+ "Default configurations cannot be deleted.": "Las configuraciones predeterminadas no se pueden eliminar.",
"User does not belong to this account": "El usuario no pertenece a esta cuenta",
"Impersonation not allowed": "Suplantación no permitida",
"Email template not found": "Plantilla de correo electrónico no encontrada",
@@ -746,5 +746,6 @@
"There are no passes available for this event": "No hay pases disponibles para este evento",
"Get Passes": "Obtener pases",
"This message is from :organizer, not from :platform.": "Este mensaje es de :organizer, no de :platform.",
- "the event organizer": "el organizador del evento"
+ "the event organizer": "el organizador del evento",
+ "The fee currency of the :currency default configuration must remain :currency.": "La moneda de las tarifas de la configuración predeterminada de :currency debe seguir siendo :currency."
}
diff --git a/backend/lang/fr.json b/backend/lang/fr.json
index 1a7cca727a..f36dbe6c1b 100644
--- a/backend/lang/fr.json
+++ b/backend/lang/fr.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "Réponse de :organizerName",
"Your Tickets": "Vos billets",
"Invalid VAT number format": "Format de numéro de TVA non valide",
- "The system default configuration cannot be deleted.": "La configuration par défaut du système ne peut pas être supprimée.",
+ "Default configurations cannot be deleted.": "Les configurations par défaut ne peuvent pas être supprimées.",
"User does not belong to this account": "L'utilisateur n'appartient pas à ce compte",
"Impersonation not allowed": "Usurpation d'identité non autorisée",
"Email template not found": "Modèle d'e-mail introuvable",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "Aucun pass disponible pour cet événement",
"Get Passes": "Obtenir un pass",
"This message is from :organizer, not from :platform.": "Ce message provient de :organizer, et non de :platform.",
- "the event organizer": "l'organisateur de l'événement"
+ "the event organizer": "l'organisateur de l'événement",
+ "The fee currency of the :currency default configuration must remain :currency.": "La devise des frais de la configuration par défaut :currency doit rester :currency."
}
diff --git a/backend/lang/hu.json b/backend/lang/hu.json
index aa8c4b8739..88043349ee 100644
--- a/backend/lang/hu.json
+++ b/backend/lang/hu.json
@@ -512,7 +512,7 @@
"Response from :organizerName": "Válasz tőle: :organizerName",
"Your Tickets": "Az Ön jegyei",
"Invalid VAT number format": "Érvénytelen adószámformátum",
- "The system default configuration cannot be deleted.": "A rendszer alapértelmezett konfigurációja nem törölhető.",
+ "Default configurations cannot be deleted.": "Az alapértelmezett konfigurációk nem törölhetők.",
"User does not belong to this account": "A felhasználó nem tartozik ehhez a fiókhoz",
"Impersonation not allowed": "A megszemélyesítés nem engedélyezett",
"An affiliate with this code already exists for this event": "Ehhez az eseményhez már létezik partner ezzel a kóddal",
@@ -706,5 +706,6 @@
"Need help?": "Segítségre van szüksége?",
"Contact Support": "Ügyfélszolgálat",
"This message is from :organizer, not from :platform.": "Ezt az üzenetet :organizer küldte, nem a :platform.",
- "the event organizer": "a rendezvény szervezője"
+ "the event organizer": "a rendezvény szervezője",
+ "The fee currency of the :currency default configuration must remain :currency.": "A(z) :currency alapértelmezett konfiguráció díjainak pénzneme :currency kell maradjon."
}
diff --git a/backend/lang/it.json b/backend/lang/it.json
index 1c7e13da98..a38591dd18 100644
--- a/backend/lang/it.json
+++ b/backend/lang/it.json
@@ -536,7 +536,7 @@
"Response from :organizerName": "Risposta da :organizerName",
"Your Tickets": "I tuoi biglietti",
"Invalid VAT number format": "Formato della partita IVA non valido",
- "The system default configuration cannot be deleted.": "La configurazione predefinita di sistema non può essere eliminata.",
+ "Default configurations cannot be deleted.": "Le configurazioni predefinite non possono essere eliminate.",
"User does not belong to this account": "L'utente non appartiene a questo account",
"Impersonation not allowed": "Impersonificazione non consentita",
"Email template not found": "Modello email non trovato",
@@ -702,5 +702,6 @@
"There are no passes available for this event": "Non ci sono pass disponibili per questo evento",
"Get Passes": "Ottieni pass",
"This message is from :organizer, not from :platform.": "Questo messaggio è stato inviato da :organizer, non da :platform.",
- "the event organizer": "l'organizzatore dell'evento"
+ "the event organizer": "l'organizzatore dell'evento",
+ "The fee currency of the :currency default configuration must remain :currency.": "La valuta delle commissioni della configurazione predefinita :currency deve rimanere :currency."
}
diff --git a/backend/lang/nl.json b/backend/lang/nl.json
index 07294af671..4c61d0baee 100644
--- a/backend/lang/nl.json
+++ b/backend/lang/nl.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "Reactie van :organizerName",
"Your Tickets": "Je tickets",
"Invalid VAT number format": "Ongeldige indeling van btw-nummer",
- "The system default configuration cannot be deleted.": "De standaardconfiguratie van het systeem kan niet worden verwijderd.",
+ "Default configurations cannot be deleted.": "Standaardconfiguraties kunnen niet worden verwijderd.",
"User does not belong to this account": "De gebruiker hoort niet bij dit account",
"Impersonation not allowed": "Impersonatie niet toegestaan",
"Email template not found": "E-mailsjabloon niet gevonden",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "Er zijn geen passen beschikbaar voor dit evenement",
"Get Passes": "Passen kopen",
"This message is from :organizer, not from :platform.": "Dit bericht is afkomstig van :organizer, niet van :platform.",
- "the event organizer": "de organisator van het evenement"
+ "the event organizer": "de organisator van het evenement",
+ "The fee currency of the :currency default configuration must remain :currency.": "De tarievenvaluta van de standaardconfiguratie voor :currency moet :currency blijven."
}
diff --git a/backend/lang/pl.json b/backend/lang/pl.json
index 7fbe094286..c79e0184e4 100644
--- a/backend/lang/pl.json
+++ b/backend/lang/pl.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "Odpowiedź od :organizerName",
"Your Tickets": "Twoje bilety",
"Invalid VAT number format": "Nieprawidłowy format numeru VAT",
- "The system default configuration cannot be deleted.": "Domyślna konfiguracja systemu nie może zostać usunięta.",
+ "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",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "Brak dostępnych karnetów dla tego wydarzenia",
"Get Passes": "Kup karnet",
"This message is from :organizer, not from :platform.": "Ta wiadomość pochodzi od :organizer, a nie od :platform.",
- "the event organizer": "organizatora wydarzenia"
+ "the event organizer": "organizatora wydarzenia",
+ "The fee currency of the :currency default configuration must remain :currency.": "Waluta opłat domyślnej konfiguracji :currency musi pozostać :currency."
}
diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json
index f193dea66e..b9b542b429 100644
--- a/backend/lang/pt-br.json
+++ b/backend/lang/pt-br.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "Resposta de :organizerName",
"Your Tickets": "Seus ingressos",
"Invalid VAT number format": "Formato de número de IVA inválido",
- "The system default configuration cannot be deleted.": "A configuração padrão do sistema não pode ser excluída.",
+ "Default configurations cannot be deleted.": "As configurações padrão não podem ser excluídas.",
"User does not belong to this account": "O usuário não pertence a esta conta",
"Impersonation not allowed": "Personificação não permitida",
"Email template not found": "Modelo de e-mail não encontrado",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "Não há passes disponíveis para este evento",
"Get Passes": "Obter passes",
"This message is from :organizer, not from :platform.": "Esta mensagem é de :organizer, não de :platform.",
- "the event organizer": "o organizador do evento"
+ "the event organizer": "o organizador do evento",
+ "The fee currency of the :currency default configuration must remain :currency.": "A moeda das taxas da configuração padrão de :currency deve permanecer :currency."
}
diff --git a/backend/lang/pt.json b/backend/lang/pt.json
index 0b99937c7d..007d8cd7c8 100644
--- a/backend/lang/pt.json
+++ b/backend/lang/pt.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "Resposta de :organizerName",
"Your Tickets": "Os seus bilhetes",
"Invalid VAT number format": "Formato de número de IVA inválido",
- "The system default configuration cannot be deleted.": "A configuração predefinida do sistema não pode ser eliminada.",
+ "Default configurations cannot be deleted.": "As configurações predefinidas não podem ser eliminadas.",
"User does not belong to this account": "O utilizador não pertence a esta conta",
"Impersonation not allowed": "Personificação não permitida",
"Email template not found": "Modelo de e-mail não encontrado",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "Não há passes disponíveis para este evento",
"Get Passes": "Obter passes",
"This message is from :organizer, not from :platform.": "Esta mensagem é de :organizer, não de :platform.",
- "the event organizer": "o organizador do evento"
+ "the event organizer": "o organizador do evento",
+ "The fee currency of the :currency default configuration must remain :currency.": "A moeda das taxas da configuração predefinida de :currency deve permanecer :currency."
}
diff --git a/backend/lang/ru.json b/backend/lang/ru.json
index f412660ef2..28cdaa234a 100644
--- a/backend/lang/ru.json
+++ b/backend/lang/ru.json
@@ -550,7 +550,7 @@
"Response from :organizerName": "",
"Your Tickets": "",
"Invalid VAT number format": "",
- "The system default configuration cannot be deleted.": "",
+ "Default configurations cannot be deleted.": "Конфигурации по умолчанию нельзя удалить.",
"User does not belong to this account": "",
"Impersonation not allowed": "",
"Email template not found": "",
@@ -689,5 +689,6 @@
"There are no passes available for this event": "Для этого мероприятия нет доступных пропусков",
"Get Passes": "Получить пропуск",
"This message is from :organizer, not from :platform.": "Это сообщение — от :organizer, а не от :platform.",
- "the event organizer": "организатора мероприятия"
+ "the event organizer": "организатора мероприятия",
+ "The fee currency of the :currency default configuration must remain :currency.": "Валюта комиссии конфигурации по умолчанию :currency должна оставаться :currency."
}
diff --git a/backend/lang/se.json b/backend/lang/se.json
index 82d90d188f..544b44c7b4 100644
--- a/backend/lang/se.json
+++ b/backend/lang/se.json
@@ -572,7 +572,7 @@
"Response from :organizerName": "Svar från :organizerName",
"Your Tickets": "Dina biljetter",
"Invalid VAT number format": "Ogiltigt format på momsregistreringsnummer",
- "The system default configuration cannot be deleted.": "Systemets standardkonfiguration kan inte tas bort.",
+ "Default configurations cannot be deleted.": "Standardkonfigurationer kan inte tas bort.",
"User does not belong to this account": "Användaren tillhör inte det här kontot",
"Impersonation not allowed": "Personifiering är inte tillåten",
"Email template not found": "E-postmallen hittades inte",
@@ -701,5 +701,6 @@
"Event was created less than 24 hours ago": "Evenemanget skapades för mindre än 24 timmar sedan",
"Review Message": "Granska meddelandet",
"This message is from :organizer, not from :platform.": "Det här meddelandet kommer från :organizer, inte från :platform.",
- "the event organizer": "evenemangets arrangör"
+ "the event organizer": "evenemangets arrangör",
+ "The fee currency of the :currency default configuration must remain :currency.": "Avgiftsvalutan för standardkonfigurationen :currency måste förbli :currency."
}
diff --git a/backend/lang/sk.json b/backend/lang/sk.json
index 356fc0f884..38b3231af5 100644
--- a/backend/lang/sk.json
+++ b/backend/lang/sk.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "Odpoveď od :organizerName",
"Your Tickets": "Vaše lístky",
"Invalid VAT number format": "Neplatný formát DIČ",
- "The system default configuration cannot be deleted.": "Predvolenú systémovú konfiguráciu nie je možné vymazať.",
+ "Default configurations cannot be deleted.": "Predvolené konfigurácie nie je možné odstrániť.",
"User does not belong to this account": "Používateľ nepatrí k tomuto účtu",
"Impersonation not allowed": "Zosobnenie nie je povolené",
"Email template not found": "E-mailová šablóna nebola nájdená",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "Pre toto podujatie nie sú dostupné žiadne vstupenky",
"Get Passes": "Získať vstupenky",
"This message is from :organizer, not from :platform.": "Táto správa je od :organizer, nie od :platform.",
- "the event organizer": "organizátora podujatia"
+ "the event organizer": "organizátora podujatia",
+ "The fee currency of the :currency default configuration must remain :currency.": "Mena poplatkov predvolenej konfigurácie :currency musí zostať :currency."
}
diff --git a/backend/lang/tr.json b/backend/lang/tr.json
index 096f3f8b8b..62995b874e 100644
--- a/backend/lang/tr.json
+++ b/backend/lang/tr.json
@@ -550,7 +550,7 @@
"Response from :organizerName": ":organizerName tarafından yanıt",
"Your Tickets": "Biletleriniz",
"Invalid VAT number format": "Geçersiz KDV numarası biçimi",
- "The system default configuration cannot be deleted.": "Sistem varsayılan yapılandırması silinemez.",
+ "Default configurations cannot be deleted.": "Varsayılan yapılandırmalar silinemez.",
"User does not belong to this account": "Kullanıcı bu hesaba ait değil",
"Impersonation not allowed": "Kimliğe bürünmeye izin verilmiyor",
"Email template not found": "E-posta şablonu bulunamadı",
@@ -716,5 +716,6 @@
"There are no passes available for this event": "Bu etkinlik için uygun bilet bulunmamaktadır",
"Get Passes": "Bilet Al",
"This message is from :organizer, not from :platform.": "Bu mesaj :platform tarafından değil, :organizer tarafından gönderilmiştir.",
- "the event organizer": "etkinlik organizatörü"
+ "the event organizer": "etkinlik organizatörü",
+ "The fee currency of the :currency default configuration must remain :currency.": ":currency varsayılan yapılandırmasının ücret para birimi :currency olarak kalmalıdır."
}
diff --git a/backend/lang/vi.json b/backend/lang/vi.json
index e3a9de726d..6c706f377c 100644
--- a/backend/lang/vi.json
+++ b/backend/lang/vi.json
@@ -498,7 +498,7 @@
"Response from :organizerName": "Phản hồi từ :organizerName",
"Your Tickets": "Vé của bạn",
"Invalid VAT number format": "Định dạng mã số VAT không hợp lệ",
- "The system default configuration cannot be deleted.": "Không thể xóa cấu hình mặc định của hệ thống.",
+ "Default configurations cannot be deleted.": "Không thể xóa các cấu hình mặc định.",
"User does not belong to this account": "Người dùng không thuộc tài khoản này",
"Impersonation not allowed": "Không được phép mạo nhận danh tính",
"Email template not found": "Không tìm thấy mẫu email",
@@ -701,5 +701,6 @@
"Attendee :attendee_name\\'s product is cancelled": "Sản phẩm của người tham dự :attendee_name đã bị hủy",
"There are no tickets available for this event.": "Sự kiện này không còn vé nào.",
"This message is from :organizer, not from :platform.": "Tin nhắn này là từ :organizer, không phải từ :platform.",
- "the event organizer": "ban tổ chức sự kiện"
+ "the event organizer": "ban tổ chức sự kiện",
+ "The fee currency of the :currency default configuration must remain :currency.": "Đơn vị tiền tệ phí của cấu hình mặc định :currency phải giữ nguyên là :currency."
}
diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json
index 660a75ec55..7ff34023d9 100644
--- a/backend/lang/zh-cn.json
+++ b/backend/lang/zh-cn.json
@@ -535,7 +535,7 @@
"Response from :organizerName": "来自 :organizerName 的回复",
"Your Tickets": "你的门票",
"Invalid VAT number format": "增值税号格式无效",
- "The system default configuration cannot be deleted.": "系统默认配置不可删除。",
+ "Default configurations cannot be deleted.": "默认配置无法删除。",
"User does not belong to this account": "该用户不属于此账户",
"Impersonation not allowed": "不允许模拟身份",
"Email template not found": "未找到邮件模板",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "该活动暂无可用通行证",
"Get Passes": "获取通行证",
"This message is from :organizer, not from :platform.": "此消息来自 :organizer,而非 :platform。",
- "the event organizer": "活动主办方"
+ "the event organizer": "活动主办方",
+ "The fee currency of the :currency default configuration must remain :currency.": "“:currency 默认配置”的费用货币必须保持为 :currency。"
}
diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json
index b96f0534b7..4286f9e3d9 100644
--- a/backend/lang/zh-hk.json
+++ b/backend/lang/zh-hk.json
@@ -535,7 +535,7 @@
"Response from :organizerName": ":organizerName 的回覆",
"Your Tickets": "你的門票",
"Invalid VAT number format": "增值稅號碼格式無效",
- "The system default configuration cannot be deleted.": "系統預設設定組合不可刪除。",
+ "Default configurations cannot be deleted.": "預設配置無法刪除。",
"User does not belong to this account": "此使用者不屬於這個帳戶",
"Impersonation not allowed": "不允許模擬身分",
"Email template not found": "找不到電郵範本",
@@ -701,5 +701,6 @@
"There are no passes available for this event": "此活動暫無可用通行證",
"Get Passes": "領取通行證",
"This message is from :organizer, not from :platform.": "此訊息來自 :organizer,而非 :platform。",
- "the event organizer": "活動主辦方"
+ "the event organizer": "活動主辦方",
+ "The fee currency of the :currency default configuration must remain :currency.": "「:currency 預設配置」的費用貨幣必須保持為 :currency。"
}
diff --git a/backend/tests/Feature/Http/Actions/Admin/Configurations/UpdateConfigurationActionTest.php b/backend/tests/Feature/Http/Actions/Admin/Configurations/UpdateConfigurationActionTest.php
new file mode 100644
index 0000000000..87cccd66a1
--- /dev/null
+++ b/backend/tests/Feature/Http/Actions/Admin/Configurations/UpdateConfigurationActionTest.php
@@ -0,0 +1,110 @@
+ 1], [
+ 'id' => 1,
+ 'name' => 'Default',
+ 'is_system_default' => true,
+ 'application_fees' => ['percentage' => 1.5, 'fixed' => 0],
+ ]);
+
+ $user = User::factory()->withAccount()->create();
+ $accountId = $user->accounts()->first()->id;
+
+ DB::table('account_users')->where('user_id', $user->id)->update(['role' => 'SUPERADMIN']);
+
+ $this->authToken = JWTAuth::claims(['account_id' => $accountId])->fromUser($user);
+ }
+
+ public function test_currency_default_configuration_rejects_mismatched_fee_currency(): void
+ {
+ $configurationId = $this->insertConfiguration(defaultForCurrency: 'USD');
+
+ $response = $this->putJson(
+ "/admin/configurations/{$configurationId}",
+ $this->payload(currency: 'EUR'),
+ $this->authHeaders(),
+ );
+
+ $response->assertStatus(ResponseCodes::HTTP_UNPROCESSABLE_ENTITY);
+ $response->assertJsonValidationErrors(['application_fees.currency']);
+ }
+
+ public function test_currency_default_configuration_accepts_matching_fee_currency(): void
+ {
+ $configurationId = $this->insertConfiguration(defaultForCurrency: 'USD');
+
+ $response = $this->putJson(
+ "/admin/configurations/{$configurationId}",
+ $this->payload(currency: 'USD'),
+ $this->authHeaders(),
+ );
+
+ $response->assertStatus(ResponseCodes::HTTP_OK);
+ $this->assertSame('USD', $response->json('data.application_fees.currency'));
+ }
+
+ public function test_regular_configuration_allows_any_fee_currency(): void
+ {
+ $configurationId = $this->insertConfiguration(defaultForCurrency: null);
+
+ $response = $this->putJson(
+ "/admin/configurations/{$configurationId}",
+ $this->payload(currency: 'EUR'),
+ $this->authHeaders(),
+ );
+
+ $response->assertStatus(ResponseCodes::HTTP_OK);
+ $this->assertSame('EUR', $response->json('data.application_fees.currency'));
+ }
+
+ private function insertConfiguration(?string $defaultForCurrency): int
+ {
+ return DB::table('organizer_configurations')->insertGetId([
+ 'name' => 'Configuration Under Test',
+ 'is_system_default' => false,
+ 'application_fees' => json_encode(['percentage' => 1.25, 'fixed' => 0.60, 'currency' => 'USD']),
+ 'default_for_currency' => $defaultForCurrency,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ private function payload(string $currency): array
+ {
+ return [
+ 'name' => 'Configuration Under Test',
+ 'application_fees' => [
+ 'fixed' => 0.60,
+ 'percentage' => 1.25,
+ 'currency' => $currency,
+ ],
+ ];
+ }
+
+ private function authHeaders(): array
+ {
+ $this->app['auth']->forgetGuards();
+
+ return ['Authorization' => 'Bearer '.$this->authToken];
+ }
+}
diff --git a/backend/tests/Feature/Http/Actions/Webhooks/StripeAccountUpdatedWebhookTest.php b/backend/tests/Feature/Http/Actions/Webhooks/StripeAccountUpdatedWebhookTest.php
new file mode 100644
index 0000000000..1ba46e4f1b
--- /dev/null
+++ b/backend/tests/Feature/Http/Actions/Webhooks/StripeAccountUpdatedWebhookTest.php
@@ -0,0 +1,166 @@
+ true,
+ 'services.stripe.webhook_secret' => self::WEBHOOK_SECRET,
+ ]);
+
+ AccountConfiguration::firstOrCreate(['id' => 1], [
+ 'id' => 1,
+ 'name' => 'Default',
+ 'is_system_default' => true,
+ 'application_fees' => ['percentage' => 1.5, 'fixed' => 0],
+ ]);
+
+ $this->systemDefaultConfigId = DB::table('organizer_configurations')
+ ->where('is_system_default', true)
+ ->whereNull('deleted_at')
+ ->value('id');
+
+ $this->usdConfigId = DB::table('organizer_configurations')->insertGetId([
+ 'name' => 'Standard (USD)',
+ 'is_system_default' => false,
+ 'application_fees' => json_encode(['percentage' => 1.25, 'fixed' => 0.60, 'currency' => 'USD']),
+ 'default_for_currency' => 'USD',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $user = User::factory()->withAccount()->create();
+ $accountId = $user->accounts()->first()->id;
+
+ $this->organizerId = DB::table('organizers')->insertGetId([
+ 'account_id' => $accountId,
+ 'name' => 'Webhook Test Organizer',
+ 'email' => 'organizer-'.uniqid().'@test.com',
+ 'currency' => 'EUR',
+ 'timezone' => 'UTC',
+ 'organizer_configuration_id' => $this->systemDefaultConfigId,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ $this->stripeAccountId = 'acct_feature_'.uniqid();
+
+ DB::table('organizer_stripe_platforms')->insert([
+ 'organizer_id' => $this->organizerId,
+ 'stripe_account_id' => $this->stripeAccountId,
+ 'stripe_connect_account_type' => 'standard',
+ 'stripe_connect_platform' => 'ie',
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ public function test_account_updated_webhook_assigns_currency_default_configuration(): void
+ {
+ $this->deliverAccountUpdatedWebhook(country: 'US');
+
+ $this->assertSame(
+ $this->usdConfigId,
+ DB::table('organizers')->where('id', $this->organizerId)->value('organizer_configuration_id'),
+ );
+
+ $this->assertNotNull(
+ DB::table('organizer_stripe_platforms')
+ ->where('stripe_account_id', $this->stripeAccountId)
+ ->value('stripe_setup_completed_at'),
+ );
+ }
+
+ public function test_account_updated_webhook_leaves_custom_configurations_untouched(): void
+ {
+ $customConfigId = DB::table('organizer_configurations')->insertGetId([
+ 'name' => 'Negotiated Fees',
+ 'is_system_default' => false,
+ 'application_fees' => json_encode(['percentage' => 0.5, 'fixed' => 0, 'currency' => 'USD']),
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+
+ DB::table('organizers')
+ ->where('id', $this->organizerId)
+ ->update(['organizer_configuration_id' => $customConfigId]);
+
+ $this->deliverAccountUpdatedWebhook(country: 'US');
+
+ $this->assertSame(
+ $customConfigId,
+ DB::table('organizers')->where('id', $this->organizerId)->value('organizer_configuration_id'),
+ );
+ }
+
+ private function deliverAccountUpdatedWebhook(string $country): void
+ {
+ $timestamp = time();
+
+ $payload = json_encode([
+ 'id' => 'evt_feature_'.uniqid(),
+ 'object' => 'event',
+ 'api_version' => '2024-06-20',
+ 'created' => $timestamp,
+ 'type' => 'account.updated',
+ 'data' => [
+ 'object' => [
+ 'id' => $this->stripeAccountId,
+ 'object' => 'account',
+ 'country' => $country,
+ 'charges_enabled' => true,
+ 'payouts_enabled' => true,
+ 'type' => 'standard',
+ 'business_type' => 'individual',
+ 'capabilities' => [],
+ 'requirements' => [
+ 'currently_due' => [],
+ 'eventually_due' => [],
+ 'past_due' => [],
+ 'pending_verification' => [],
+ ],
+ ],
+ ],
+ 'livemode' => false,
+ 'pending_webhooks' => 1,
+ 'request' => ['id' => null, 'idempotency_key' => null],
+ ], JSON_THROW_ON_ERROR);
+
+ $signature = hash_hmac('sha256', $timestamp.'.'.$payload, self::WEBHOOK_SECRET);
+
+ $response = $this->call(
+ method: 'POST',
+ uri: '/public/webhooks/stripe',
+ server: [
+ 'HTTP_STRIPE_SIGNATURE' => sprintf('t=%d,v1=%s', $timestamp, $signature),
+ 'CONTENT_TYPE' => 'application/json',
+ ],
+ content: $payload,
+ );
+
+ $response->assertNoContent();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php
new file mode 100644
index 0000000000..c4b159f0ad
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Admin/DeleteConfigurationHandlerTest.php
@@ -0,0 +1,104 @@
+repository = m::mock(OrganizerConfigurationRepositoryInterface::class);
+ $this->organizerRepository = m::mock(OrganizerRepositoryInterface::class);
+
+ $this->handler = new DeleteConfigurationHandler(
+ $this->repository,
+ $this->organizerRepository,
+ );
+ }
+
+ public function test_it_refuses_to_delete_the_system_default_configuration(): void
+ {
+ $this->givenConfiguration(isSystemDefault: true, defaultForCurrency: null);
+
+ $this->expectException(CannotDeleteEntityException::class);
+
+ $this->handler->handle(1);
+ }
+
+ public function test_it_refuses_to_delete_a_currency_default_configuration(): void
+ {
+ $this->givenConfiguration(isSystemDefault: false, defaultForCurrency: 'AUD');
+
+ $this->expectException(CannotDeleteEntityException::class);
+
+ $this->handler->handle(1);
+ }
+
+ public function test_it_refuses_to_delete_a_configuration_with_assigned_organizers(): void
+ {
+ $this->givenConfiguration(isSystemDefault: false, defaultForCurrency: null);
+ $this->organizerRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['organizer_configuration_id' => 1])
+ ->andReturn(3);
+
+ $this->expectException(CannotDeleteEntityException::class);
+
+ $this->handler->handle(1);
+ }
+
+ public function test_it_deletes_an_unassigned_custom_configuration(): void
+ {
+ $this->givenConfiguration(isSystemDefault: false, defaultForCurrency: null);
+ $this->organizerRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['organizer_configuration_id' => 1])
+ ->andReturn(0);
+
+ $this->repository
+ ->shouldReceive('deleteById')
+ ->once()
+ ->with(1);
+
+ $this->handler->handle(1);
+
+ $this->addToAssertionCount(1);
+ }
+
+ private function givenConfiguration(bool $isSystemDefault, ?string $defaultForCurrency): void
+ {
+ $configuration = (new OrganizerConfigurationDomainObject)
+ ->setId(1)
+ ->setIsSystemDefault($isSystemDefault)
+ ->setDefaultForCurrency($defaultForCurrency);
+
+ $this->repository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(1)
+ ->andReturn($configuration);
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandlerTest.php
new file mode 100644
index 0000000000..7d7e1d0ab7
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/Admin/Organizer/UpdateOrganizerConfigurationHandlerTest.php
@@ -0,0 +1,143 @@
+ 2.5, 'fixed' => 1.0, 'currency' => 'USD'];
+
+ private UpdateOrganizerConfigurationHandler $handler;
+
+ private OrganizerConfigurationRepositoryInterface $configurationRepository;
+
+ private OrganizerRepositoryInterface $organizerRepository;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->configurationRepository = m::mock(OrganizerConfigurationRepositoryInterface::class);
+ $this->organizerRepository = m::mock(OrganizerRepositoryInterface::class);
+
+ $this->handler = new UpdateOrganizerConfigurationHandler(
+ $this->configurationRepository,
+ $this->organizerRepository,
+ );
+ }
+
+ public function test_it_updates_a_dedicated_custom_configuration_in_place(): void
+ {
+ $this->givenOrganizerWithConfiguration(isSystemDefault: false, defaultForCurrency: null);
+
+ $this->organizerRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['organizer_configuration_id' => 5])
+ ->andReturn(1);
+
+ $this->organizerRepository
+ ->shouldReceive('countWhere')
+ ->once()
+ ->with(['organizer_configuration_id' => 5, 'id' => 1])
+ ->andReturn(1);
+
+ $updated = (new OrganizerConfigurationDomainObject)->setId(5);
+
+ $this->configurationRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(5, ['application_fees' => self::APPLICATION_FEES])
+ ->andReturn($updated);
+
+ $result = $this->handler->handle(new UpdateOrganizerConfigurationDTO(
+ organizerId: 1,
+ applicationFees: self::APPLICATION_FEES,
+ ));
+
+ $this->assertSame(5, $result->getId());
+ }
+
+ public function test_it_clones_instead_of_mutating_a_currency_default_configuration(): void
+ {
+ $this->givenOrganizerWithConfiguration(isSystemDefault: false, defaultForCurrency: 'AUD');
+
+ $this->expectClone();
+
+ $this->handler->handle(new UpdateOrganizerConfigurationDTO(
+ organizerId: 1,
+ applicationFees: self::APPLICATION_FEES,
+ ));
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_it_clones_instead_of_mutating_the_system_default_configuration(): void
+ {
+ $this->givenOrganizerWithConfiguration(isSystemDefault: true, defaultForCurrency: null);
+
+ $this->expectClone();
+
+ $this->handler->handle(new UpdateOrganizerConfigurationDTO(
+ organizerId: 1,
+ applicationFees: self::APPLICATION_FEES,
+ ));
+
+ $this->addToAssertionCount(1);
+ }
+
+ private function givenOrganizerWithConfiguration(bool $isSystemDefault, ?string $defaultForCurrency): void
+ {
+ $configuration = (new OrganizerConfigurationDomainObject)
+ ->setId(5)
+ ->setIsSystemDefault($isSystemDefault)
+ ->setDefaultForCurrency($defaultForCurrency);
+
+ $organizer = (new OrganizerDomainObject)
+ ->setId(1)
+ ->setName('Acme Events');
+ $organizer->setOrganizerConfiguration($configuration);
+
+ $this->organizerRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->organizerRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(1)
+ ->andReturn($organizer);
+ }
+
+ private function expectClone(): void
+ {
+ $clone = (new OrganizerConfigurationDomainObject)->setId(6);
+
+ $this->configurationRepository
+ ->shouldReceive('create')
+ ->once()
+ ->with([
+ 'name' => 'Acme Events (#1) - Custom Fees',
+ 'is_system_default' => false,
+ 'application_fees' => self::APPLICATION_FEES,
+ ])
+ ->andReturn($clone);
+
+ $this->organizerRepository
+ ->shouldReceive('updateFromArray')
+ ->once()
+ ->with(1, ['organizer_configuration_id' => 6])
+ ->andReturn(m::mock(OrganizerDomainObject::class));
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php
index be703ddcd0..fdaa0319a9 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Organizer/Payment/Stripe/CopyStripeConnectAccountHandlerTest.php
@@ -13,6 +13,7 @@
use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
use HiEvents\Services\Application\Handlers\Organizer\Payment\Stripe\CopyStripeConnectAccountHandler;
use HiEvents\Services\Application\Handlers\Organizer\Payment\Stripe\DTO\CopyStripeConnectAccountDTO;
+use HiEvents\Services\Domain\Organizer\AssignCurrencyDefaultOrganizerConfigurationService;
use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService;
use Illuminate\Config\Repository;
use Illuminate\Database\DatabaseManager;
@@ -28,6 +29,8 @@ class CopyStripeConnectAccountHandlerTest extends TestCase
private StripeAccountSyncService $stripeAccountSyncService;
+ private AssignCurrencyDefaultOrganizerConfigurationService $assignCurrencyDefaultOrganizerConfigurationService;
+
private DatabaseManager $databaseManager;
private Repository $config;
@@ -40,6 +43,8 @@ protected function setUp(): void
$this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class);
$this->stripeAccountSyncService = m::mock(StripeAccountSyncService::class);
$this->stripeAccountSyncService->shouldReceive('seedVatSettingForOrganizerIfMissing')->byDefault();
+ $this->assignCurrencyDefaultOrganizerConfigurationService = m::mock(AssignCurrencyDefaultOrganizerConfigurationService::class);
+ $this->assignCurrencyDefaultOrganizerConfigurationService->shouldReceive('assignForCountry')->byDefault();
$this->databaseManager = m::mock(DatabaseManager::class);
$this->config = m::mock(Repository::class);
}
@@ -99,6 +104,11 @@ public function test_copies_connection_when_saas_mode_enabled_and_source_complet
}))
->andReturn(m::mock(OrganizerStripePlatformDomainObject::class));
+ $this->assignCurrencyDefaultOrganizerConfigurationService
+ ->shouldReceive('assignForCountry')
+ ->once()
+ ->with(1, 'CA');
+
$handler = $this->makeHandler();
$response = $handler->handle(new CopyStripeConnectAccountDTO(
@@ -186,6 +196,7 @@ private function makeHandler(): CopyStripeConnectAccountHandler
$this->organizerRepository,
$this->organizerStripePlatformRepository,
$this->stripeAccountSyncService,
+ $this->assignCurrencyDefaultOrganizerConfigurationService,
$this->databaseManager,
$this->config,
);
diff --git a/backend/tests/Unit/Services/Domain/Organizer/AssignCurrencyDefaultOrganizerConfigurationServiceTest.php b/backend/tests/Unit/Services/Domain/Organizer/AssignCurrencyDefaultOrganizerConfigurationServiceTest.php
new file mode 100644
index 0000000000..196c13c94f
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/Organizer/AssignCurrencyDefaultOrganizerConfigurationServiceTest.php
@@ -0,0 +1,259 @@
+organizerRepository = m::mock(OrganizerRepositoryInterface::class);
+ $this->organizerConfigurationRepository = m::mock(OrganizerConfigurationRepositoryInterface::class);
+ $this->config = m::mock(Repository::class);
+ $this->logger = m::mock(LoggerInterface::class);
+
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(true)->byDefault();
+ $this->logger->shouldReceive('info')->byDefault();
+ $this->organizerConfigurationRepository->shouldReceive('countWhere')->andReturn(4)->byDefault();
+
+ $this->service = new AssignCurrencyDefaultOrganizerConfigurationService(
+ $this->organizerRepository,
+ $this->organizerConfigurationRepository,
+ $this->config,
+ $this->logger,
+ );
+ }
+
+ public function test_it_does_nothing_when_saas_mode_is_disabled(): void
+ {
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false);
+
+ $this->service->assignForCountry(1, 'US');
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_it_does_nothing_when_country_is_missing(): void
+ {
+ $this->service->assignForCountry(1, null);
+ $this->service->assignForCountry(1, ' ');
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_it_stays_silent_when_no_currency_default_configurations_exist(): void
+ {
+ $this->organizerConfigurationRepository->shouldReceive('countWhere')->once()->andReturn(0);
+
+ $this->service->assignForCountry(1, 'US');
+
+ $this->addToAssertionCount(1);
+ }
+
+ #[DataProvider('countryToCurrencyProvider')]
+ public function test_it_maps_country_to_currency_default_configuration(string $country, string $expectedCurrency): void
+ {
+ $this->givenOrganizer(1, currentConfigurationId: null);
+ $this->givenCurrencyDefaultConfiguration($expectedCurrency, configurationId: 99);
+ $this->expectAssignment(organizerId: 1, configurationId: 99);
+
+ $this->service->assignForCountry(1, $country);
+ }
+
+ public static function countryToCurrencyProvider(): array
+ {
+ return [
+ 'US maps to USD' => ['US', 'USD'],
+ 'GB maps to GBP' => ['GB', 'GBP'],
+ 'AU maps to AUD' => ['AU', 'AUD'],
+ 'lowercase us is normalized' => ['us', 'USD'],
+ 'DE falls back to EUR' => ['DE', 'EUR'],
+ 'BR falls back to EUR' => ['BR', 'EUR'],
+ 'unknown code falls back to EUR' => ['XX', 'EUR'],
+ ];
+ }
+
+ public function test_it_reassigns_organizer_on_system_default_configuration(): void
+ {
+ $this->givenOrganizer(1, currentConfigurationId: 5);
+ $this->givenCurrentConfiguration(5, isSystemDefault: true, defaultForCurrency: null);
+ $this->givenCurrencyDefaultConfiguration('USD', configurationId: 99);
+ $this->expectAssignment(organizerId: 1, configurationId: 99);
+
+ $this->service->assignForCountry(1, 'US');
+ }
+
+ public function test_it_remaps_organizer_on_another_currency_default_configuration(): void
+ {
+ $this->givenOrganizer(1, currentConfigurationId: 7);
+ $this->givenCurrentConfiguration(7, isSystemDefault: false, defaultForCurrency: 'USD');
+ $this->givenCurrencyDefaultConfiguration('EUR', configurationId: 88);
+ $this->expectAssignment(organizerId: 1, configurationId: 88);
+
+ $this->service->assignForCountry(1, 'DE');
+ }
+
+ public function test_it_does_not_touch_organizer_on_custom_configuration(): void
+ {
+ $this->givenOrganizer(1, currentConfigurationId: 12);
+ $this->givenCurrentConfiguration(12, isSystemDefault: false, defaultForCurrency: null);
+
+ $this->service->assignForCountry(1, 'US');
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_it_assigns_when_current_configuration_is_soft_deleted(): void
+ {
+ $this->givenOrganizer(1, currentConfigurationId: 12);
+ $this->organizerConfigurationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 12])
+ ->andReturnNull();
+ $this->givenCurrencyDefaultConfiguration('USD', configurationId: 99);
+ $this->expectAssignment(organizerId: 1, configurationId: 99);
+
+ $this->service->assignForCountry(1, 'US');
+ }
+
+ public function test_it_short_circuits_when_already_on_target_configuration(): void
+ {
+ $this->givenOrganizer(1, currentConfigurationId: 99);
+ $this->givenCurrentConfiguration(99, isSystemDefault: false, defaultForCurrency: 'USD');
+ $this->givenCurrencyDefaultConfiguration('USD', configurationId: 99);
+
+ $this->service->assignForCountry(1, 'US');
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_it_logs_warning_when_the_currency_default_configuration_is_missing(): void
+ {
+ $this->givenOrganizer(1, currentConfigurationId: null);
+ $this->organizerConfigurationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([OrganizerConfigurationDomainObjectAbstract::DEFAULT_FOR_CURRENCY => 'USD'])
+ ->andReturnNull();
+
+ $this->logger->shouldReceive('warning')->once();
+
+ $this->service->assignForCountry(1, 'US');
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_it_does_nothing_when_organizer_is_not_found(): void
+ {
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => 1])
+ ->andReturnNull();
+
+ $this->service->assignForCountry(1, 'US');
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_it_catches_and_logs_repository_exceptions(): void
+ {
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->andThrow(new RuntimeException('db down'));
+
+ $this->logger->shouldReceive('error')->once();
+
+ $this->service->assignForCountry(1, 'US');
+
+ $this->addToAssertionCount(1);
+ }
+
+ private function givenOrganizer(int $organizerId, ?int $currentConfigurationId): void
+ {
+ $organizer = (new OrganizerDomainObject)->setId($organizerId);
+
+ if ($currentConfigurationId !== null) {
+ $organizer->setOrganizerConfigurationId($currentConfigurationId);
+ }
+
+ $this->organizerRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => $organizerId])
+ ->andReturn($organizer);
+ }
+
+ private function givenCurrentConfiguration(int $configurationId, bool $isSystemDefault, ?string $defaultForCurrency): void
+ {
+ $configuration = (new OrganizerConfigurationDomainObject)
+ ->setId($configurationId)
+ ->setIsSystemDefault($isSystemDefault)
+ ->setDefaultForCurrency($defaultForCurrency);
+
+ $this->organizerConfigurationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(['id' => $configurationId])
+ ->andReturn($configuration);
+ }
+
+ private function givenCurrencyDefaultConfiguration(string $currency, int $configurationId): void
+ {
+ $configuration = (new OrganizerConfigurationDomainObject)
+ ->setId($configurationId)
+ ->setIsSystemDefault(false)
+ ->setDefaultForCurrency($currency);
+
+ $this->organizerConfigurationRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with([OrganizerConfigurationDomainObjectAbstract::DEFAULT_FOR_CURRENCY => $currency])
+ ->andReturn($configuration);
+ }
+
+ private function expectAssignment(int $organizerId, int $configurationId): void
+ {
+ $this->organizerRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->with(
+ [OrganizerDomainObjectAbstract::ORGANIZER_CONFIGURATION_ID => $configurationId],
+ ['id' => $organizerId],
+ )
+ ->andReturn(1);
+ }
+
+ protected function tearDown(): void
+ {
+ m::close();
+ parent::tearDown();
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php
index 0015a25e0c..f4404f4c4c 100644
--- a/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php
+++ b/backend/tests/Unit/Services/Domain/Payment/Stripe/StripeAccountSyncServiceTest.php
@@ -2,11 +2,15 @@
namespace Tests\Unit\Services\Domain\Payment\Stripe;
+use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\DomainObjects\Generated\OrganizerStripePlatformDomainObjectAbstract;
+use HiEvents\DomainObjects\OrganizerDomainObject;
+use HiEvents\DomainObjects\OrganizerStripePlatformDomainObject;
use HiEvents\Repository\Interfaces\AccountRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerStripePlatformRepositoryInterface;
use HiEvents\Repository\Interfaces\OrganizerVatSettingRepositoryInterface;
+use HiEvents\Services\Domain\Organizer\AssignCurrencyDefaultOrganizerConfigurationService;
use HiEvents\Services\Domain\Payment\Stripe\StripeAccountSyncService;
use Illuminate\Config\Repository;
use Mockery as m;
@@ -30,6 +34,8 @@ class StripeAccountSyncServiceTest extends TestCase
private Repository $config;
+ private AssignCurrencyDefaultOrganizerConfigurationService $assignCurrencyDefaultOrganizerConfigurationService;
+
protected function setUp(): void
{
parent::setUp();
@@ -40,6 +46,7 @@ protected function setUp(): void
$this->organizerStripePlatformRepository = m::mock(OrganizerStripePlatformRepositoryInterface::class);
$this->vatSettingRepository = m::mock(OrganizerVatSettingRepositoryInterface::class);
$this->config = m::mock(Repository::class);
+ $this->assignCurrencyDefaultOrganizerConfigurationService = m::mock(AssignCurrencyDefaultOrganizerConfigurationService::class);
$this->service = new StripeAccountSyncService(
$this->logger,
@@ -48,6 +55,7 @@ protected function setUp(): void
$this->organizerStripePlatformRepository,
$this->vatSettingRepository,
$this->config,
+ $this->assignCurrencyDefaultOrganizerConfigurationService,
);
}
@@ -103,6 +111,132 @@ public function test_sync_by_account_id_updates_all_organizer_rows_and_stops_if_
$this->addToAssertionCount(1);
}
+ public function test_sync_by_account_id_assigns_currency_default_configuration_per_organizer_row(): void
+ {
+ $stripeAccount = Account::constructFrom([
+ 'id' => 'acct_123',
+ 'charges_enabled' => true,
+ 'payouts_enabled' => true,
+ 'country' => 'US',
+ 'type' => 'standard',
+ 'business_type' => 'individual',
+ 'capabilities' => [],
+ 'requirements' => [
+ 'currently_due' => [],
+ 'eventually_due' => [],
+ 'past_due' => [],
+ 'pending_verification' => [],
+ ],
+ ]);
+
+ $this->organizerStripePlatformRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->andReturn(2);
+
+ $rows = collect([
+ (new OrganizerStripePlatformDomainObject)->setId(1)->setOrganizerId(10),
+ (new OrganizerStripePlatformDomainObject)->setId(2)->setOrganizerId(20),
+ ]);
+
+ $this->organizerStripePlatformRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with([OrganizerStripePlatformDomainObjectAbstract::STRIPE_ACCOUNT_ID => 'acct_123'])
+ ->andReturn($rows);
+
+ foreach ([10 => 100, 20 => 200] as $organizerId => $accountId) {
+ $this->organizerRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with($organizerId)
+ ->andReturn((new OrganizerDomainObject)->setId($organizerId)->setAccountId($accountId));
+
+ $this->accountRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with($accountId)
+ ->andReturn(
+ (new AccountDomainObject)
+ ->setId($accountId)
+ ->setCountry('US')
+ ->setIsManuallyVerified(true)
+ );
+ }
+
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false);
+
+ $this->assignCurrencyDefaultOrganizerConfigurationService
+ ->shouldReceive('assignForCountry')
+ ->once()
+ ->with(10, 'US');
+
+ $this->assignCurrencyDefaultOrganizerConfigurationService
+ ->shouldReceive('assignForCountry')
+ ->once()
+ ->with(20, 'US');
+
+ $this->service->syncStripeAccountStatusByAccountId($stripeAccount);
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_mark_account_as_complete_assigns_currency_default_configuration(): void
+ {
+ $stripeAccount = Account::constructFrom([
+ 'id' => 'acct_456',
+ 'charges_enabled' => true,
+ 'payouts_enabled' => true,
+ 'country' => 'GB',
+ 'type' => 'standard',
+ 'business_type' => 'individual',
+ 'capabilities' => [],
+ 'requirements' => [
+ 'currently_due' => [],
+ 'eventually_due' => [],
+ 'past_due' => [],
+ 'pending_verification' => [],
+ ],
+ ]);
+
+ $platform = (new OrganizerStripePlatformDomainObject)->setId(5)->setOrganizerId(50);
+
+ $this->logger->shouldReceive('info')->once();
+
+ $this->organizerStripePlatformRepository
+ ->shouldReceive('updateWhere')
+ ->once()
+ ->andReturn(1);
+
+ $this->organizerRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(50)
+ ->andReturn((new OrganizerDomainObject)->setId(50)->setAccountId(500));
+
+ $this->accountRepository
+ ->shouldReceive('findById')
+ ->once()
+ ->with(500)
+ ->andReturn(
+ (new AccountDomainObject)
+ ->setId(500)
+ ->setCountry('GB')
+ ->setIsManuallyVerified(true)
+ );
+
+ $this->config->shouldReceive('get')->with('app.saas_mode_enabled')->andReturn(false);
+
+ $this->assignCurrencyDefaultOrganizerConfigurationService
+ ->shouldReceive('assignForCountry')
+ ->once()
+ ->with(50, 'GB');
+
+ $this->service->markAccountAsCompleteForOrganizer($platform, $stripeAccount);
+
+ $this->addToAssertionCount(1);
+ }
+
protected function tearDown(): void
{
m::close();
diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts
index 84bf642505..1387128b54 100644
--- a/e2e/api/api-client.ts
+++ b/e2e/api/api-client.ts
@@ -382,6 +382,10 @@ export class AdminApiClient {
return match.id;
}
+ listConfigurations(): Promise<{ id: number; name: string; is_system_default: boolean; default_for_currency: string | null }[]> {
+ return unwrap(this.request.get('admin/configurations', { headers: jsonHeaders }));
+ }
+
createAnnouncement(payload: UpsertAnnouncementPayload): Promise<{ id: number }> {
return unwrap(this.request.post('admin/announcements', { headers: jsonHeaders, data: payload }));
}
diff --git a/e2e/tests/admin/configurations.spec.ts b/e2e/tests/admin/configurations.spec.ts
index 852a164855..5f5ed01c1d 100644
--- a/e2e/tests/admin/configurations.spec.ts
+++ b/e2e/tests/admin/configurations.spec.ts
@@ -64,3 +64,26 @@ test.describe('admin configurations', () => {
await expect(card).toHaveCount(0);
});
});
+
+test.describe('currency default configurations', () => {
+ test(
+ 'currency defaults show a badge and cannot be deleted',
+ { tag: '@admin' },
+ async ({ superAdminPage, adminApi }, testInfo) => {
+ const configurations = await adminApi.listConfigurations();
+ const usdDefault = configurations.find((config) => config.default_for_currency === 'USD');
+
+ testInfo.skip(
+ !usdDefault,
+ 'No currency default configurations seeded — re-run `php artisan dev:bootstrap` against this stack.',
+ );
+
+ await gotoConfigurations(superAdminPage);
+
+ const card = configCard(superAdminPage, usdDefault!.name);
+ await expect(card).toBeVisible();
+ await expect(card.getByText('USD Default')).toBeVisible();
+ await expect(card.getByRole('button').last()).toBeDisabled();
+ },
+ );
+});
diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts
index c3a8bd1a04..a24e07833f 100644
--- a/frontend/src/api/admin.client.ts
+++ b/frontend/src/api/admin.client.ts
@@ -48,6 +48,7 @@ export interface AccountConfiguration {
id: number;
name: string;
is_system_default: boolean;
+ default_for_currency: string | null;
application_fees: {
fixed: number;
percentage: number;
@@ -56,6 +57,9 @@ export interface AccountConfiguration {
bypass_application_fees: boolean;
}
+export const isDefaultConfiguration = (config: AccountConfiguration): boolean =>
+ config.is_system_default || Boolean(config.default_for_currency);
+
export interface CreateConfigurationData {
name: string;
application_fees: {
diff --git a/frontend/src/components/routes/admin/Accounts/AccountDetail/OrganizerAdminModal.tsx b/frontend/src/components/routes/admin/Accounts/AccountDetail/OrganizerAdminModal.tsx
index 60ba794094..c27a680cdf 100644
--- a/frontend/src/components/routes/admin/Accounts/AccountDetail/OrganizerAdminModal.tsx
+++ b/frontend/src/components/routes/admin/Accounts/AccountDetail/OrganizerAdminModal.tsx
@@ -90,7 +90,11 @@ export const OrganizerAdminModal = ({organizer, onClose}: Props) => {
const configOptions = configurations.map((c) => ({
value: String(c.id),
- label: c.is_system_default ? `${c.name} (default)` : c.name,
+ label: c.is_system_default
+ ? `${c.name} (default)`
+ : c.default_for_currency
+ ? `${c.name} (${c.default_for_currency} default)`
+ : c.name,
}));
return (
diff --git a/frontend/src/components/routes/admin/Configurations/index.tsx b/frontend/src/components/routes/admin/Configurations/index.tsx
index 4a7e29cd45..a1aa3659f7 100644
--- a/frontend/src/components/routes/admin/Configurations/index.tsx
+++ b/frontend/src/components/routes/admin/Configurations/index.tsx
@@ -10,7 +10,7 @@ import {useState} from "react";
import {Modal} from "../../../common/Modal";
import {useForm} from "@mantine/form";
import {showSuccess, showError} from "../../../../utilites/notifications";
-import {AccountConfiguration} from "../../../../api/admin.client";
+import {AccountConfiguration, isDefaultConfiguration} from "../../../../api/admin.client";
import {currenciesMap} from "../../../../../data/currencies";
import {getCurrencySymbol} from "../../../../utilites/currency";
import classes from "./Configurations.module.scss";
@@ -32,11 +32,6 @@ const Configurations = () => {
const configurations = configurationsData?.data || [];
const handleDelete = (config: AccountConfiguration) => {
- if (config.is_system_default) {
- showError(t`Cannot delete the system default configuration`);
- return;
- }
-
if (window.confirm(t`Are you sure you want to delete this configuration? This may affect accounts using it.`)) {
deleteMutation.mutate(config.id, {
onSuccess: () => showSuccess(t`Configuration deleted successfully`),
@@ -85,6 +80,9 @@ const Configurations = () => {
{config.is_system_default && (