diff --git a/app/Services/LemonSqueezy/LemonSqueezyClient.php b/app/Services/LemonSqueezy/LemonSqueezyClient.php index e4235371..9a1a4daa 100644 --- a/app/Services/LemonSqueezy/LemonSqueezyClient.php +++ b/app/Services/LemonSqueezy/LemonSqueezyClient.php @@ -7,6 +7,7 @@ use Illuminate\Http\Client\Factory as HttpFactory; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Arr; +use Illuminate\Support\Str; class LemonSqueezyClient { @@ -68,7 +69,7 @@ class LemonSqueezyClient throw new LemonSqueezyException('Lemon Squeezy API key is not configured.'); } - $baseUrl = rtrim((string) config('lemonsqueezy.base_url'), '/'); + $baseUrl = $this->normalizeBaseUrl((string) config('lemonsqueezy.base_url')); return $this->http ->baseUrl($baseUrl) @@ -81,4 +82,30 @@ class LemonSqueezyClient ->acceptJson() ->asJson(); } + + protected function normalizeBaseUrl(string $baseUrl): string + { + $baseUrl = trim($baseUrl); + + if ($baseUrl === '') { + $baseUrl = 'https://api.lemonsqueezy.com/v1'; + } + + if (! Str::startsWith($baseUrl, ['http://', 'https://'])) { + $baseUrl = 'https://api.lemonsqueezy.com/'.ltrim($baseUrl, '/'); + } + + $parsedHost = parse_url($baseUrl, PHP_URL_HOST); + if (! is_string($parsedHost) || $parsedHost === '' || ! Str::endsWith($parsedHost, 'lemonsqueezy.com')) { + $baseUrl = 'https://api.lemonsqueezy.com/v1'; + } + + $baseUrl = rtrim($baseUrl, '/'); + + if (! Str::endsWith($baseUrl, '/v1')) { + $baseUrl .= '/v1'; + } + + return $baseUrl; + } } diff --git a/public/lang/de/legal.json b/public/lang/de/legal.json index 4864e2bf..963db3cd 100644 --- a/public/lang/de/legal.json +++ b/public/lang/de/legal.json @@ -11,14 +11,14 @@ "contact": "Kontakt", "vat_id": "Umsatzsteuer-ID: DE123456789", "monetization": "Monetarisierung", - "monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe und Abos) via Lemon Squeezy. Preise exkl. MwSt. Support: support@fotospiel.de", + "monetization_desc": "Wir monetarisieren über Packages (Einmalkäufe) via PayPal. Preise exkl. MwSt. Support: support@fotospiel.de", "register_court": "Registergericht: Amtsgericht Musterstadt", "commercial_register": "Handelsregister: HRB 12345", "datenschutz_intro": "Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.", "responsible": "Verantwortlich: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt", "data_collection": "Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.", "payments": "Zahlungen und Packages", - "payments_desc": "Wir verarbeiten Zahlungen für Packages über Lemon Squeezy. Zahlungsdaten werden als Merchant of Record sicher und verschlüsselt durch Lemon Squeezy verarbeitet.", + "payments_desc": "Wir verarbeiten Zahlungen für Packages über PayPal. Zahlungsdaten werden sicher und verschlüsselt durch PayPal verarbeitet.", "data_retention": "Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.", "rights": "Ihre Rechte: Auskunft, Löschung, Widerspruch.", "cookies": "Cookies: Nur funktionale Cookies für die PWA.", diff --git a/public/lang/en/legal.json b/public/lang/en/legal.json index a644f7db..b5cce9de 100644 --- a/public/lang/en/legal.json +++ b/public/lang/en/legal.json @@ -10,14 +10,14 @@ "contact": "Contact", "vat_id": "VAT ID: DE123456789", "monetization": "Monetization", - "monetization_desc": "We monetize through Packages (one-time purchases and subscriptions) via Lemon Squeezy. Prices excl. VAT. Support: support@fotospiel.de", + "monetization_desc": "Wir monetarisieren ?ber Packages (Einmalk?ufe) via PayPal. Preise exkl. MwSt. Support: support@fotospiel.de", "register_court": "Register Court: District Court Musterstadt", "commercial_register": "Commercial Register: HRB 12345", "datenschutz_intro": "We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.", "responsible": "Responsible: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt", "data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.", "payments": "Payments and Packages", - "payments_desc": "We process payments for Packages via Lemon Squeezy. Payment data is handled securely and encrypted by Lemon Squeezy as the merchant of record.", + "payments_desc": "Wir verarbeiten Zahlungen f?r Packages ?ber PayPal. Zahlungsdaten werden sicher und verschl?sselt durch PayPal verarbeitet.", "data_retention": "Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.", "rights": "Your rights: Information, deletion, objection. Contact us under Contact.", "cookies": "Cookies: Only functional cookies for the PWA.", diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 724883fe..d2dd6272 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1,6 +1,18 @@ { "mobileProfile": { - "settings": "Einstellungen" + "title": "Profil", + "settings": "Einstellungen", + "account": "Account bearbeiten", + "language": "Sprache", + "languageDe": "Deutsch", + "languageEn": "Englisch", + "theme": "Theme", + "themeLight": "Hell", + "themeDark": "Dunkel", + "themeSystem": "System", + "logout": "Abmelden", + "logoutTitle": "Ausloggen", + "logoutHint": "Aus der App ausloggen" }, "readiness": { "steps": { @@ -15,7 +27,7 @@ "actions": { "refresh": "Aktualisieren", "exportCsv": "Export als CSV", - "portal": "Im Lemon Squeezy-Portal verwalten", + "portal": "Im PayPal-Portal verwalten", "portalBusy": "Portal wird geöffnet...", "openPackages": "Pakete öffnen", "contactSupport": "Support kontaktieren" @@ -42,7 +54,7 @@ "errors": { "load": "Paketdaten konnten nicht geladen werden.", "more": "Weitere Einträge konnten nicht geladen werden.", - "portal": "Lemon Squeezy-Portal konnte nicht geöffnet werden." + "portal": "PayPal-Portal konnte nicht geöffnet werden." }, "checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.", "checkoutCancelled": "Checkout wurde abgebrochen.", @@ -128,9 +140,9 @@ } }, "transactions": { - "title": "Lemon Squeezy-Transaktionen", - "description": "Neueste Lemon Squeezy-Transaktionen für dieses Kundenkonto.", - "empty": "Noch keine Lemon Squeezy-Transaktionen.", + "title": "PayPal-Transaktionen", + "description": "Neueste PayPal-Transaktionen für dieses Kundenkonto.", + "empty": "Noch keine PayPal-Transaktionen.", "labels": { "transactionId": "Transaktion {{id}}", "checkoutId": "Checkout-ID: {{id}}", @@ -2295,8 +2307,8 @@ }, "mobileDashboard": { "title": "Dashboard", - "shortcutAnalytics": "Analytics", - "quickActionsTitle": "Experience", + "shortcutAnalytics": "Statistiken", + "quickActionsTitle": "Schnellzugriff", "readyForLiftoff": "Alles erledigt.", "selectEvent": "Wähle ein Event, um fortzufahren", "emptyBadge": "Willkommen!", @@ -2418,8 +2430,6 @@ "shortcutInvites": "Team-/Helfer-Einladungen", "shortcutSettings": "Event-Einstellungen", "shortcutBranding": "Branding & Moderation", - "shortcutAnalytics": "Statistiken", - "quickActionsTitle": "Schnellzugriff", "kpiTitle": "Wichtigste Kennzahlen", "kpiTasks": "Offene Fotoaufgaben", "kpiPhotos": "Fotos", @@ -2672,25 +2682,10 @@ } } }, - "mobileProfile": { - "title": "Profil", - "settings": "Einstellungen", - "account": "Account bearbeiten", - "language": "Sprache", - "languageDe": "Deutsch", - "languageEn": "Englisch", - "theme": "Theme", - "themeLight": "Hell", - "themeDark": "Dunkel", - "themeSystem": "System", - "logout": "Abmelden", - "logoutTitle": "Ausloggen", - "logoutHint": "Aus der App ausloggen" - }, - "mobileSettings": { - "title": "Einstellungen", - "accountTitle": "Account", - "tenantBadge": "Konto #{{id}}", + "mobileSettings": { + "title": "Einstellungen", + "accountTitle": "Account", + "tenantBadge": "Konto #{{id}}", "notificationsTitle": "Benachrichtigungen", "notificationsLoading": "Lade Einstellungen ...", "pushTitle": "App Push", @@ -2861,7 +2856,7 @@ "validation": "Füge Titel, Nachricht und ggf. einen Ziel-Gast hinzu." } }, - "dataExports": { + "dataExports": { "title": "Datenexporte", "request": { "title": "Exportanfrage", @@ -3034,5 +3029,12 @@ "checkout": "Checkout fehlgeschlagen" }, "selectDisabled": "Nicht verfügbar" + }, + "billingOverview": { + "transactions": { + "title": "PayPal-Transaktionen", + "description": "Neueste PayPal-Transaktionen für dieses Kundenkonto.", + "empty": "Noch keine PayPal-Transaktionen." + } } } diff --git a/resources/js/admin/i18n/locales/de/onboarding.json b/resources/js/admin/i18n/locales/de/onboarding.json index 46f850df..36f3fec0 100644 --- a/resources/js/admin/i18n/locales/de/onboarding.json +++ b/resources/js/admin/i18n/locales/de/onboarding.json @@ -1,4 +1,4 @@ -{ +{ "layout": { "eyebrow": "Fotospiel Kunden-Admin", "title": "Willkommen im Event-Erlebnisstudio", @@ -193,24 +193,24 @@ "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." }, "lemonsqueezy": { - "sectionTitle": "Lemon Squeezy", - "heading": "Checkout mit Lemon Squeezy", - "genericError": "Der Lemon Squeezy-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.", - "errorTitle": "Lemon Squeezy-Fehler", - "processing": "Lemon Squeezy-Checkout wird geöffnet …", - "cta": "Lemon Squeezy-Checkout öffnen", - "hint": "Es öffnet sich ein neuer Tab über Lemon Squeezy (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück." + "sectionTitle": "PayPal", + "heading": "Checkout mit PayPal", + "genericError": "Der PayPal-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.", + "errorTitle": "PayPal-Fehler", + "processing": "PayPal-Checkout wird geöffnet …", + "cta": "PayPal-Checkout öffnen", + "hint": "Es öffnet sich ein neuer Tab über PayPal. Schließe dort die Zahlung ab und kehre anschließend zurück." }, "nextStepsTitle": "Nächste Schritte", "nextSteps": [ - "Optional: Abrechnung über Lemon Squeezy im Billing-Bereich abschließen.", + "Optional: Abrechnung über PayPal im Billing-Bereich abschließen.", "Event-Setup durchlaufen und Fotoaufgaben, Team & Galerie konfigurieren.", "Vor dem Go-Live Event-Kontingent prüfen und Gäste-Link teilen." ], "cta": { "billing": { "label": "Abrechnung starten", - "description": "Öffnet den Billing-Bereich mit Lemon Squeezy- und Kontingent-Optionen.", + "description": "Öffnet den Billing-Bereich mit PayPal- und Kontingent-Optionen.", "button": "Zu Billing & Zahlung" }, "setup": { @@ -267,5 +267,19 @@ "button": "Eventliste" } } + }, + "lemonsqueezy": { + "sectionTitle": "PayPal", + "heading": "Checkout mit PayPal", + "genericError": "Der PayPal-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.", + "errorTitle": "PayPal-Fehler", + "processing": "PayPal-Checkout wird geöffnet …", + "cta": "PayPal-Checkout öffnen", + "hint": "Es öffnet sich ein neuer Tab über PayPal. Schließe dort die Zahlung ab und kehre anschließend zurück." + }, + "tasks": { + "billing": { + "description": "Öffnet den Billing-Bereich mit PayPal- und Kontingent-Optionen." + } } } diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 9789b50b..249fd4b6 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1,6 +1,18 @@ -{ +{ "mobileProfile": { - "settings": "Settings" + "title": "Profile", + "settings": "Settings", + "account": "Edit account", + "language": "Language", + "languageDe": "Deutsch", + "languageEn": "English", + "theme": "Theme", + "themeLight": "Light", + "themeDark": "Dark", + "themeSystem": "System", + "logout": "Log out", + "logoutTitle": "Sign out", + "logoutHint": "Sign out from this app." }, "readiness": { "steps": { @@ -15,7 +27,7 @@ "actions": { "refresh": "Refresh", "exportCsv": "Export CSV", - "portal": "Manage in Lemon Squeezy", + "portal": "Manage in PayPal", "portalBusy": "Opening portal...", "openPackages": "Open packages", "contactSupport": "Contact support" @@ -42,7 +54,7 @@ "errors": { "load": "Unable to load package data.", "more": "Unable to load more entries.", - "portal": "Unable to open the Lemon Squeezy portal." + "portal": "Unable to open the PayPal portal." }, "checkoutSuccess": "Checkout completed. Your package will activate shortly.", "checkoutCancelled": "Checkout was cancelled.", @@ -128,9 +140,9 @@ } }, "transactions": { - "title": "Lemon Squeezy transactions", - "description": "Recent Lemon Squeezy transactions for this customer account.", - "empty": "No Lemon Squeezy transactions yet.", + "title": "PayPal transactions", + "description": "Recent PayPal transactions for this customer account.", + "empty": "No PayPal transactions yet.", "labels": { "transactionId": "Transaction {{id}}", "checkoutId": "Checkout ID: {{id}}", @@ -2298,7 +2310,7 @@ "mobileDashboard": { "title": "Dashboard", "shortcutAnalytics": "Analytics", - "quickActionsTitle": "Experience", + "quickActionsTitle": "Quick actions", "readyForLiftoff": "Ready for Liftoff", "selectEvent": "Select an event to continue", "emptyBadge": "Welcome aboard", @@ -2420,8 +2432,6 @@ "shortcutInvites": "Team / helper invites", "shortcutSettings": "Event settings", "shortcutBranding": "Branding & moderation", - "shortcutAnalytics": "Analytics", - "quickActionsTitle": "Quick actions", "kpiTitle": "Key performance indicators", "kpiTasks": "Open photo tasks", "kpiPhotos": "Photos", @@ -2674,21 +2684,6 @@ } } }, - "mobileProfile": { - "title": "Profile", - "settings": "Settings", - "account": "Edit account", - "language": "Language", - "languageDe": "Deutsch", - "languageEn": "English", - "theme": "Theme", - "themeLight": "Light", - "themeDark": "Dark", - "themeSystem": "System", - "logout": "Log out", - "logoutTitle": "Sign out", - "logoutHint": "Sign out from this app." - }, "mobileSettings": { "title": "Settings", "accountTitle": "Account", @@ -2863,13 +2858,13 @@ "validation": "Add a title, message, and target guest when needed." } }, - "dataExports": { + "dataExports": { "title": "Data exports", - "request": { - "title": "Export request", - "hint": "Export account data or a specific event archive.", - "progress": "Export is running. This list refreshes automatically." - }, + "request": { + "title": "Export request", + "hint": "Export account data or a specific event archive.", + "progress": "Export is running. This list refreshes automatically." + }, "fields": { "scope": "Scope", "event": "Event", @@ -3036,5 +3031,12 @@ "checkout": "Checkout failed" }, "selectDisabled": "Not available" + }, + "billingOverview": { + "transactions": { + "title": "PayPal transactions", + "description": "Recent PayPal transactions for this customer account.", + "empty": "No PayPal transactions yet." + } } } diff --git a/resources/js/admin/i18n/locales/en/onboarding.json b/resources/js/admin/i18n/locales/en/onboarding.json index ff256bdf..c0d4cd8a 100644 --- a/resources/js/admin/i18n/locales/en/onboarding.json +++ b/resources/js/admin/i18n/locales/en/onboarding.json @@ -1,4 +1,4 @@ -{ +{ "layout": { "eyebrow": "Fotospiel Customer Admin", "title": "Welcome to your event studio", @@ -193,24 +193,24 @@ "errorMessage": "The free package could not be activated." }, "lemonsqueezy": { - "sectionTitle": "Lemon Squeezy", - "heading": "Checkout with Lemon Squeezy", - "genericError": "The Lemon Squeezy checkout could not be opened. Please try again.", - "errorTitle": "Lemon Squeezy error", - "processing": "Opening the Lemon Squeezy checkout …", - "cta": "Open Lemon Squeezy checkout", - "hint": "A new tab opens via Lemon Squeezy (merchant of record). Complete the payment there, then return to continue." + "sectionTitle": "PayPal", + "heading": "Checkout with PayPal", + "genericError": "The PayPal checkout could not be opened. Please try again.", + "errorTitle": "PayPal error", + "processing": "Opening the PayPal checkout ?", + "cta": "Open PayPal checkout", + "hint": "A new tab opens via PayPal. Complete the payment there, then return to continue." }, "nextStepsTitle": "Next steps", "nextSteps": [ - "Optional: finish billing via Lemon Squeezy inside the billing area.", + "Optional: finish billing via PayPal inside the billing area.", "Complete the event setup and configure photo tasks, team, and gallery.", "Check your event bundle before go-live and share your guest link." ], "cta": { "billing": { "label": "Start billing", - "description": "Opens the billing area with Lemon Squeezy bundle options.", + "description": "Opens the billing area with PayPal bundle options.", "button": "Go to billing" }, "setup": { @@ -267,5 +267,19 @@ "button": "Open event list" } } + }, + "lemonsqueezy": { + "sectionTitle": "PayPal", + "heading": "Checkout with PayPal", + "genericError": "The PayPal checkout could not be opened. Please try again.", + "errorTitle": "PayPal error", + "processing": "Opening the PayPal checkout ?", + "cta": "Open PayPal checkout", + "hint": "A new tab opens via PayPal. Complete the payment there, then return to continue." + }, + "tasks": { + "billing": { + "description": "Opens the billing area with PayPal billing options." + } } } diff --git a/resources/lang/de/admin.php b/resources/lang/de/admin.php index 85745b56..4c3fc448 100644 --- a/resources/lang/de/admin.php +++ b/resources/lang/de/admin.php @@ -188,7 +188,7 @@ return [ ], 'lemonsqueezy_health' => [ 'navigation' => [ - 'label' => 'Lemon Squeezy-Status', + 'label' => 'PayPal-Status', ], ], 'integrations_health' => [ @@ -203,7 +203,7 @@ return [ 'unknown' => 'Unbekannt', ], 'heading' => 'Integrationen-Status', - 'help' => 'Operativer Überblick über Lemon Squeezy/RevenueCat-Webhooks, Queue-Backlog und jüngste Fehler.', + 'help' => 'Operativer Überblick über PayPal/RevenueCat-Webhooks, Queue-Backlog und jüngste Fehler.', 'configured' => 'Konfiguriert', 'unconfigured' => 'Nicht konfiguriert', 'last_received' => 'Zuletzt empfangen', diff --git a/resources/lang/de/legal.php b/resources/lang/de/legal.php index 10a80edf..442b1ea8 100644 --- a/resources/lang/de/legal.php +++ b/resources/lang/de/legal.php @@ -12,14 +12,14 @@ return [ 'contact' => 'Kontakt', 'vat_id' => 'Umsatzsteuer-ID: DE123456789', 'monetization' => 'Monetarisierung', - 'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe und Abos) via Lemon Squeezy. Preise exkl. MwSt. Support: support@fotospiel.de', + 'monetization_desc' => 'Wir monetarisieren über Packages (Einmalkäufe) via PayPal. Preise exkl. MwSt. Support: support@fotospiel.de', 'register_court' => 'Registergericht: Amtsgericht Musterstadt', 'commercial_register' => 'Handelsregister: HRB 12345', 'datenschutz_intro' => 'Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.', 'responsible' => 'Verantwortlich: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt', 'data_collection' => 'Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.', 'payments' => 'Zahlungen und Packages', - 'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über Lemon Squeezy. Zahlungsinformationen werden sicher und verschlüsselt durch Lemon Squeezy als Merchant of Record verarbeitet.', + 'payments_desc' => 'Wir verarbeiten Zahlungen für Packages über PayPal. Zahlungsinformationen werden sicher und verschlüsselt durch PayPal verarbeitet.', 'data_retention' => 'Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.', 'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.', 'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.', diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php index 541ac319..182e3d00 100644 --- a/resources/lang/en/admin.php +++ b/resources/lang/en/admin.php @@ -188,7 +188,7 @@ return [ ], 'lemonsqueezy_health' => [ 'navigation' => [ - 'label' => 'Lemon Squeezy health', + 'label' => 'PayPal health', ], ], 'integrations_health' => [ @@ -203,7 +203,7 @@ return [ 'unknown' => 'Unknown', ], 'heading' => 'Integrations health', - 'help' => 'Operational snapshot of Lemon Squeezy/RevenueCat webhooks, queue backlog, and recent failures.', + 'help' => 'Operational snapshot of PayPal/RevenueCat webhooks, queue backlog, and recent failures.', 'configured' => 'Configured', 'unconfigured' => 'Unconfigured', 'last_received' => 'Last received', diff --git a/resources/lang/en/legal.php b/resources/lang/en/legal.php index 216774d4..9e929455 100644 --- a/resources/lang/en/legal.php +++ b/resources/lang/en/legal.php @@ -12,14 +12,14 @@ return [ 'contact' => 'Contact', 'vat_id' => 'VAT ID: DE123456789', 'monetization' => 'Monetization', - 'monetization_desc' => 'We monetize through Packages (one-time purchases and subscriptions) via Lemon Squeezy. Prices excl. VAT. Support: support@fotospiel.de', + 'monetization_desc' => 'We monetize through Packages (one-time purchases) via PayPal. Prices excl. VAT. Support: support@fotospiel.de', 'register_court' => 'Register Court: District Court Musterstadt', 'commercial_register' => 'Commercial Register: HRB 12345', 'datenschutz_intro' => 'We take the protection of your personal data very seriously and strictly adhere to the rules of data protection laws.', 'responsible' => 'Responsible: S.E.B. Fotografie, Musterstraße 1, 12345 Musterstadt', 'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.', 'payments' => 'Payments and Packages', - 'payments_desc' => 'We process payments for Packages via Lemon Squeezy. Payment information is handled securely and encrypted by Lemon Squeezy as the merchant of record.', + 'payments_desc' => 'We process payments for Packages via PayPal. Payment information is handled securely and encrypted by PayPal.', 'data_retention' => 'Package data (limits, features) is anonymized and only required for functionality. Consent for payments and emails is obtained at purchase. Data is deleted after 10 years.', 'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.', 'cookies' => 'Cookies: Only functional cookies for the PWA.', diff --git a/tests/Unit/LemonSqueezyClientTest.php b/tests/Unit/LemonSqueezyClientTest.php new file mode 100644 index 00000000..dd8426a4 --- /dev/null +++ b/tests/Unit/LemonSqueezyClientTest.php @@ -0,0 +1,40 @@ +app['config']->set('lemonsqueezy.api_key', 'test-token'); + $this->app['config']->set('lemonsqueezy.base_url', '/v1'); + + Http::fake(function ($request) { + $this->assertSame('https://api.lemonsqueezy.com/v1/products', $request->url()); + + return Http::response(['data' => []], 200); + }); + + $client = $this->app->make(LemonSqueezyClient::class); + $client->get('/products'); + } + + public function test_base_url_is_forced_to_api_when_pointing_to_local_host(): void + { + $this->app['config']->set('lemonsqueezy.api_key', 'test-token'); + $this->app['config']->set('lemonsqueezy.base_url', 'http://localhost/v1'); + + Http::fake(function ($request) { + $this->assertSame('https://api.lemonsqueezy.com/v1/products', $request->url()); + + return Http::response(['data' => []], 200); + }); + + $client = $this->app->make(LemonSqueezyClient::class); + $client->get('/products'); + } +} diff --git a/tests/ui/purchase/checkout-payment.test.ts b/tests/ui/purchase/checkout-payment.test.ts index 5bcc74ad..e083c927 100644 --- a/tests/ui/purchase/checkout-payment.test.ts +++ b/tests/ui/purchase/checkout-payment.test.ts @@ -5,31 +5,46 @@ const demoTenantCredentials = { password: process.env.E2E_DEMO_TENANT_PASSWORD ?? 'Demo1234!', }; -test.describe('Checkout Payment Step – Lemon Squeezy flow', () => { - test('opens Lemon Squeezy checkout and shows success notice', async ({ page }) => { - await page.route('**/lemonsqueezy/create-checkout', async (route) => { +test.describe('Checkout Payment Step - PayPal flow', () => { + test('creates PayPal order and completes capture', async ({ page }) => { + let createPayload: Record | null = null; + let capturePayload: Record | null = null; + + await page.route('**/paypal/create-order', async (route) => { + createPayload = route.request().postDataJSON() as Record; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - checkout_url: 'https://fotospiel.lemonsqueezy.com/checkout/success', + order_id: 'order_test_123', + checkout_session_id: 'session_test_123', }), }); }); - await page.route('https://app.lemonsqueezy.com/js/lemon.js', async (route) => { + await page.route('**/paypal/capture-order', async (route) => { + capturePayload = route.request().postDataJSON() as Record; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'completed', + }), + }); + }); + + await page.route('https://www.paypal.com/sdk/js**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/javascript', body: ` - window.createLemonSqueezy = function() {}; - window.LemonSqueezy = { - Setup: function(options) { window.__lemonEventHandler = options?.eventHandler || null; }, - Url: { - Open: function(url) { - window.__lemonOpenedUrl = url; - } - } + window.paypal = { + Buttons: function(options) { + window.__paypalOptions = options; + return { + render: function() { return Promise.resolve(); }, + }; + }, }; `, }); @@ -38,58 +53,27 @@ test.describe('Checkout Payment Step – Lemon Squeezy flow', () => { await openCheckoutPaymentStep(page, demoTenantCredentials); await acceptCheckoutTerms(page); - await page.evaluate(() => { - window.__openedUrls = []; - window.open = (url: string, target?: string | null, features?: string | null) => { - window.__openedUrls.push({ url, target: target ?? null, features: features ?? null }); - return null; - }; + await expect.poll(async () => { + return page.evaluate(() => Boolean(window.__paypalOptions)); + }).toBe(true); + + const orderId = await page.evaluate(async () => { + return window.__paypalOptions?.createOrder?.(); }); - await page.getByRole('button', { name: /Continue with Lemon Squeezy|Weiter mit Lemon Squeezy/ }).first().click(); + expect(orderId).toBe('order_test_123'); + await expect(page.getByText(/PayPal-Checkout ist bereit|PayPal checkout is ready/i)).toBeVisible(); - await expect( - page.locator( - 'text=/secure overlay|Overlay|neuen Tab|new tab/i' - ) - ).toBeVisible(); + await page.evaluate(async () => { + await window.__paypalOptions?.onApprove?.({ orderID: 'order_test_123' }); + }); - let mode: 'inline' | 'hosted' | null = null; - for (let i = 0; i < 8; i++) { - const state = await page.evaluate(() => ({ - inline: Boolean(window.__lemonOpenedUrl), - opened: window.__openedUrls?.length ?? 0, - })); - - if (state.inline) { - mode = 'inline'; - break; - } - - if (state.opened > 0) { - mode = 'hosted'; - break; - } - - await page.waitForTimeout(300); - } - - expect(mode).not.toBeNull(); - - if (mode === 'inline') { - const inlineUrl = await page.evaluate(() => window.__lemonOpenedUrl ?? null); - expect(inlineUrl).not.toBeNull(); - } - - if (mode === 'hosted') { - await expect.poll(async () => { - return page.evaluate(() => window.__openedUrls?.[0]?.url ?? null); - }).toContain('lemonsqueezy'); - } + expect(createPayload?.package_id).toBeDefined(); + expect(capturePayload?.order_id).toBe('order_test_123'); }); - test('shows error state when Lemon Squeezy checkout creation fails', async ({ page }) => { - await page.route('**/lemonsqueezy/create-checkout', async (route) => { + test('shows error state when PayPal checkout creation fails', async ({ page }) => { + await page.route('**/paypal/create-order', async (route) => { await route.fulfill({ status: 500, contentType: 'application/json', @@ -97,19 +81,18 @@ test.describe('Checkout Payment Step – Lemon Squeezy flow', () => { }); }); - await page.route('https://app.lemonsqueezy.com/js/lemon.js', async (route) => { + await page.route('https://www.paypal.com/sdk/js**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/javascript', body: ` - window.createLemonSqueezy = function() {}; - window.LemonSqueezy = { - Setup: function(options) { window.__lemonEventHandler = options?.eventHandler || null; }, - Url: { - Open: function() { - throw new Error('forced Lemon Squeezy failure'); - } - } + window.paypal = { + Buttons: function(options) { + window.__paypalOptions = options; + return { + render: function() { return Promise.resolve(); }, + }; + }, }; `, }); @@ -118,10 +101,20 @@ test.describe('Checkout Payment Step – Lemon Squeezy flow', () => { await openCheckoutPaymentStep(page, demoTenantCredentials); await acceptCheckoutTerms(page); - await page.getByRole('button', { name: /Continue with Lemon Squeezy|Weiter mit Lemon Squeezy/ }).first().click(); + await expect.poll(async () => { + return page.evaluate(() => Boolean(window.__paypalOptions)); + }).toBe(true); + + await page.evaluate(async () => { + try { + await window.__paypalOptions?.createOrder?.(); + } catch { + // swallow error for assertion below + } + }); await expect( - page.locator('text=/Lemon Squeezy-Checkout konnte nicht gestartet werden|Lemon Squeezy checkout could not be started/i') + page.locator('text=/PayPal-Checkout konnte nicht gestartet werden|PayPal checkout could not be started/i') ).toBeVisible(); }); }); @@ -168,8 +161,9 @@ async function acceptCheckoutTerms(page: import('@playwright/test').Page) { declare global { interface Window { - __openedUrls?: Array<{ url: string; target?: string | null; features?: string | null }>; - __lemonOpenedUrl?: string | null; - __lemonEventHandler?: ((event: { event: string; data?: unknown }) => void) | null; + __paypalOptions?: { + createOrder?: () => Promise; + onApprove?: (data: { orderID: string }) => Promise; + }; } } diff --git a/tests/ui/purchase/lemonsqueezy-sandbox-checkout.test.ts b/tests/ui/purchase/lemonsqueezy-sandbox-checkout.test.ts deleted file mode 100644 index 9c152eae..00000000 --- a/tests/ui/purchase/lemonsqueezy-sandbox-checkout.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expect, test } from '@playwright/test'; - -const shouldRun = process.env.E2E_LEMONSQUEEZY_SANDBOX === '1' || process.env.E2E_PADDLE_SANDBOX === '1'; - -test.describe('Lemon Squeezy sandbox checkout (staging)', () => { - test.skip(!shouldRun, 'Set E2E_LEMONSQUEEZY_SANDBOX=1 to run live sandbox checkout on staging.'); - - test('creates Lemon Squeezy checkout session from packages page', async ({ page }) => { - const base = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; - - await page.goto(`${base}/packages`); - - const acceptCookies = page.getByRole('button', { name: /akzeptieren|accept/i }); - if (await acceptCookies.isVisible()) { - await acceptCookies.click(); - } - - const checkoutButtons = page.locator('a:has-text("Paket") , a:has-text("Checkout"), a:has-text("Jetzt"), button:has-text("Checkout")'); - const count = await checkoutButtons.count(); - - if (count === 0) { - test.skip('No checkout CTA found on packages page'); - } - - const [requestPromise] = await Promise.all([ - page.waitForRequest('**/lemonsqueezy/create-checkout'), - checkoutButtons.first().click(), - ]); - - const checkoutRequest = await requestPromise.response(); - expect(checkoutRequest, 'Expected lemonsqueezy/create-checkout request to resolve').toBeTruthy(); - expect(checkoutRequest!.status()).toBeLessThan(400); - - const body = await checkoutRequest!.json(); - const checkoutUrl = body.checkout_url ?? body.url ?? ''; - expect(checkoutUrl).toContain('lemonsqueezy'); - }); -}); diff --git a/tests/ui/purchase/lemonsqueezy-sandbox-full.test.ts b/tests/ui/purchase/lemonsqueezy-sandbox-full.test.ts deleted file mode 100644 index 4ba0da54..00000000 --- a/tests/ui/purchase/lemonsqueezy-sandbox-full.test.ts +++ /dev/null @@ -1,433 +0,0 @@ -import type { Page } from '@playwright/test'; -import fs from 'node:fs/promises'; - -import { dismissConsentBanner, expectFixture as expect, test } from '../helpers/test-fixtures'; - -const shouldRun = process.env.E2E_LEMONSQUEEZY_SANDBOX === '1' || process.env.E2E_PADDLE_SANDBOX === '1'; -const baseUrl = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; -const locale = process.env.E2E_LOCALE ?? 'de'; -const checkoutSlug = locale === 'en' ? 'checkout' : 'bestellen'; -const tenantEmail = buildTenantEmail(); -const tenantPassword = process.env.E2E_TENANT_PASSWORD ?? null; -const sandboxCard = { - number: process.env.E2E_LEMONSQUEEZY_CARD_NUMBER ?? process.env.E2E_PADDLE_CARD_NUMBER ?? '4242 4242 4242 4242', - expiry: process.env.E2E_LEMONSQUEEZY_CARD_EXPIRY ?? process.env.E2E_PADDLE_CARD_EXPIRY ?? '12/34', - cvc: process.env.E2E_LEMONSQUEEZY_CARD_CVC ?? process.env.E2E_PADDLE_CARD_CVC ?? '123', - name: process.env.E2E_LEMONSQUEEZY_CARD_NAME ?? process.env.E2E_PADDLE_CARD_NAME ?? 'Playwright Tester', - postal: process.env.E2E_LEMONSQUEEZY_CARD_POSTAL ?? process.env.E2E_PADDLE_CARD_POSTAL ?? '10115', -}; - -test.use({ - channel: process.env.E2E_BROWSER_CHANNEL ?? 'chrome', - userAgent: - process.env.E2E_USER_AGENT ?? - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', - launchOptions: { - args: ['--disable-blink-features=AutomationControlled'], - }, -}); - -test.describe('Lemon Squeezy sandbox full flow (staging)', () => { - test.skip(!shouldRun, 'Set E2E_LEMONSQUEEZY_SANDBOX=1 to run live sandbox checkout on staging.'); - test.skip(!tenantEmail || !tenantPassword, 'Set E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD for sandbox flow.'); - - test('register, pay via Lemon Squeezy sandbox, and login to event admin', async ({ page, request }, testInfo) => { - const lemonsqueezyNetworkLog: string[] = []; - - page.on('response', async (response) => { - const url = response.url(); - if (!/lemonsqueezy|lemon/i.test(url)) { - return; - } - - const status = response.status(); - let bodySnippet = ''; - if (status >= 400 || /checkout|error/i.test(url)) { - try { - const text = await response.text(); - bodySnippet = text.trim().slice(0, 2000); - } catch { - bodySnippet = ''; - } - } - - const entry = [`[${status}] ${url}`, bodySnippet].filter(Boolean).join('\n'); - lemonsqueezyNetworkLog.push(entry); - if (lemonsqueezyNetworkLog.length > 40) { - lemonsqueezyNetworkLog.shift(); - } - }); - - await page.addInitScript(() => { - Object.defineProperty(navigator, 'webdriver', { - get: () => undefined, - }); - }); - - try { - // Jump directly into wizard for Classic package (2) - await page.goto(`${baseUrl}/${locale}/${checkoutSlug}/2`); - - await dismissConsentBanner(page); - - await proceedToAccountStep(page); - - await completeRegistrationOrLogin(page, { - email: tenantEmail!, - password: tenantPassword!, - }); - - const termsCheckbox = page.locator('#checkout-terms-hero'); - await expect(termsCheckbox).toBeVisible(); - await termsCheckbox.click(); - - const checkoutCta = page.getByRole('button', { name: /Weiter mit Lemon Squeezy|Continue with Lemon Squeezy/i }).first(); - await expect(checkoutCta).toBeVisible({ timeout: 20000 }); - - const [apiResponse] = await Promise.all([ - page.waitForResponse((resp) => resp.url().includes('/lemonsqueezy/create-checkout') && resp.status() < 500), - checkoutCta.click(), - ]); - - const rawBody = await apiResponse.text(); - let checkoutPayload: Record | null = null; - try { - checkoutPayload = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; - } catch { - checkoutPayload = null; - } - - const inlineMode = checkoutPayload?.mode === 'inline' || checkoutPayload?.inline === true; - const checkoutUrl = extractCheckoutUrl(checkoutPayload, rawBody); - - if (!inlineMode) { - expect(checkoutUrl).toContain('lemonsqueezy'); - } - - // Navigate to Lemon Squeezy hosted checkout and complete payment. - if (inlineMode) { - await expect( - page.getByText(/Lemon Squeezy|Checkout geöffnet|Checkout opened/i).first() - ).toBeVisible({ timeout: 20_000 }); - await waitForLemonCardInputs(page, ['input[autocomplete="cc-number"]', 'input[name="cardnumber"]', 'input[name="card_number"]']); - } else if (checkoutUrl) { - await page.goto(checkoutUrl); - await expect(page).toHaveURL(/lemonsqueezy/); - } else { - throw new Error(`Missing Lemon Squeezy checkout URL. Response: ${rawBody}`); - } - - await completeHostedLemonSqueezyCheckout(page, sandboxCard); - - await expect - .poll( - async () => { - const latestCompleted = await request.get('/api/_testing/checkout/sessions/latest', { - params: { status: 'completed', email: tenantEmail }, - }); - if (!latestCompleted.ok()) { - return null; - } - - const json = await latestCompleted.json(); - return json?.data?.status ?? null; - }, - { timeout: 120_000 } - ) - .toBe('completed'); - - await page.goto(`${baseUrl}/event-admin/login`); - await dismissConsentBanner(page); - - await page.locator('input[name="identifier"]').fill(tenantEmail!); - await page.locator('input[name="password"]').fill(tenantPassword!); - await page.getByRole('button', { name: /Anmelden|Login/i }).click(); - - await expect(page).toHaveURL(/\/event-admin\/mobile\/(dashboard|welcome)/i, { timeout: 30_000 }); - await expect(page.getByText(/Dashboard|Willkommen/i)).toBeVisible(); - } finally { - if (lemonsqueezyNetworkLog.length > 0) { - const logPath = testInfo.outputPath('lemonsqueezy-network-log.txt'); - await fs.writeFile(logPath, lemonsqueezyNetworkLog.join('\n\n'), 'utf8'); - await testInfo.attach('lemonsqueezy-network-log', { - path: logPath, - contentType: 'text/plain', - }); - } - } - }); -}); - -async function completeRegistrationOrLogin(page: Page, credentials: { email: string; password: string }): Promise { - const identifierInput = page.locator('input[name="identifier"]'); - if (await identifierInput.isVisible()) { - await identifierInput.fill(credentials.email); - await page.locator('input[name="password"]').fill(credentials.password); - await page.getByRole('button', { name: /^Anmelden$|Login/i }).last().click(); - await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); - return; - } - - await page.fill('input[name="first_name"]', 'Play'); - await page.fill('input[name="last_name"]', 'Wright'); - await page.fill('input[name="email"]', credentials.email); - const addressInput = page.locator('input[name="address"]'); - if (await addressInput.isVisible()) { - await addressInput.fill('Teststrasse 1, 12345 Berlin'); - } - - const phoneInput = page.locator('input[name="phone"]'); - if (await phoneInput.isVisible()) { - await phoneInput.fill('+49123456789'); - } - - const usernameInput = page.locator('input[name="username"]'); - if (await usernameInput.isVisible()) { - await usernameInput.fill(credentials.email); - } - await page.fill('input[name="password"]', credentials.password); - await page.fill('input[name="password_confirmation"]', credentials.password); - - await page.check('input[name="privacy_consent"]'); - await page.getByRole('button', { name: /Registrieren|Register/i }).last().click(); - - try { - await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); - return; - } catch { - const loginButton = page.getByRole('button', { name: /^Anmelden$|Login/i }).first(); - if (await loginButton.isVisible()) { - await loginButton.click(); - } - } - - await expect(page.locator('input[name="identifier"]')).toBeVisible({ timeout: 10_000 }); - await page.locator('input[name="identifier"]').fill(credentials.email); - await page.locator('input[name="password"]').fill(credentials.password); - await page.getByRole('button', { name: /^Anmelden$|Login/i }).last().click(); - await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); -} - -async function proceedToAccountStep(page: Page, timeoutMs = 30_000): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - const accountForm = page.locator('input[name="first_name"], input[name="identifier"]'); - if (await accountForm.isVisible()) { - return; - } - - await dismissConsentBanner(page); - - const continueButton = page.getByRole('button', { name: /Weiter|Continue/i }).first(); - if (await continueButton.isVisible()) { - if (await continueButton.isEnabled()) { - await continueButton.click(); - } - } - - await page.waitForTimeout(500); - } - - throw new Error('Account step did not load in time.'); -} - -async function completeHostedLemonSqueezyCheckout( - page: Page, - card: { number: string; expiry: string; cvc: string; name: string; postal: string } -): Promise { - const cardNumberSelectors = [ - 'input[autocomplete="cc-number"]', - 'input[name="cardnumber"]', - 'input[name="card_number"]', - ]; - const expirySelectors = [ - 'input[autocomplete="cc-exp"]', - 'input[name="exp-date"]', - 'input[name="exp_date"]', - 'input[name="expiry"]', - ]; - const cvcSelectors = [ - 'input[autocomplete="cc-csc"]', - 'input[name="cvc"]', - 'input[name="security-code"]', - 'input[name="cvv"]', - ]; - const nameSelectors = [ - 'input[autocomplete="cc-name"]', - 'input[name="cardholder"]', - 'input[name="cardholder_name"]', - 'input[name="cardholder-name"]', - ]; - const postalSelectors = [ - 'input[autocomplete="postal-code"]', - 'input[name="postal"]', - 'input[name="postal_code"]', - 'input[name="zip"]', - ]; - - await maybeFillInAnyFrame(page, nameSelectors, card.name); - await fillInAnyFrame(page, cardNumberSelectors, card.number); - await fillInAnyFrame(page, expirySelectors, card.expiry); - await fillInAnyFrame(page, cvcSelectors, card.cvc); - await maybeFillInAnyFrame(page, postalSelectors, card.postal); - - const payButton = page.getByRole('button', { - name: /Jetzt bezahlen|Pay now|Complete order|Order now|Kaufen|Bezahlen|Zahlung abschließen/i, - }).first(); - await expect(payButton).toBeVisible({ timeout: 20_000 }); - await payButton.click(); -} - -async function waitForLemonCardInputs(page: Page, selectors: string[], timeoutMs = 30_000): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (await hasAnySelector(page, selectors)) { - return; - } - - if (await hasAnyText(page, /Something went wrong|try again later/i)) { - throw new Error('Lemon Squeezy inline checkout returned an error in the iframe.'); - } - - await page.waitForTimeout(500); - } - - throw new Error('Lemon Squeezy card inputs did not appear in time.'); -} - -async function hasAnySelector(page: Page, selectors: string[]): Promise { - if (await hasSelectorInFrame(page, selectors)) { - return true; - } - - for (const frame of page.frames()) { - if (await hasSelectorInFrame(frame, selectors)) { - return true; - } - } - - return false; -} - -async function hasSelectorInFrame(scope: Page | import('@playwright/test').Frame, selectors: string[]): Promise { - for (const selector of selectors) { - if ((await scope.locator(selector).count()) > 0) { - return true; - } - } - - return false; -} - -async function hasAnyText(page: Page, matcher: RegExp): Promise { - if ((await page.getByText(matcher).count()) > 0) { - return true; - } - - for (const frame of page.frames()) { - if ((await frame.getByText(matcher).count()) > 0) { - return true; - } - } - - return false; -} - -function extractCheckoutUrl(payload: Record | null, rawBody: string): string | null { - if (payload) { - const directUrl = payload.checkout_url ?? payload.url ?? payload.checkoutUrl; - if (typeof directUrl === 'string') { - return directUrl; - } - } - - const trimmed = rawBody.trim(); - if (/^https?:\/\//i.test(trimmed)) { - return trimmed; - } - - if (trimmed.startsWith('<')) { - const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/); - if (match) { - return match[0]; - } - } - - return null; -} - -function buildTenantEmail(): string | null { - const rawEmail = process.env.E2E_TENANT_EMAIL ?? process.env.E2E_LEMONSQUEEZY_EMAIL ?? process.env.E2E_PADDLE_EMAIL ?? null; - if (!rawEmail) { - return null; - } - - if (!rawEmail.includes('{timestamp}')) { - return rawEmail; - } - - const timestamp = Math.floor(Date.now() / 1000).toString(); - return rawEmail.replaceAll('{timestamp}', timestamp); -} - -async function fillInAnyFrame(page: Page, selectors: string[], value: string): Promise { - const filled = await attemptFill(page, selectors, value); - if (filled) { - return; - } - - const frames = page.frames(); - for (const frame of frames) { - const frameFilled = await attemptFill(frame, selectors, value); - if (frameFilled) { - return; - } - } - - throw new Error(`Unable to find input for selectors: ${selectors.join(', ')}`); -} - -async function maybeFillInAnyFrame(page: Page, selectors: string[], value: string): Promise { - const filled = await attemptFill(page, selectors, value); - if (filled) { - return; - } - - const frames = page.frames(); - for (const frame of frames) { - const frameFilled = await attemptFill(frame, selectors, value); - if (frameFilled) { - return; - } - } -} - -async function attemptFill( - scope: Page | import('@playwright/test').Frame, - selectors: string[], - value: string -): Promise { - for (const selector of selectors) { - const locator = scope.locator(selector).first(); - if ((await locator.count()) === 0) { - continue; - } - - try { - await locator.fill(value); - return true; - } catch { - try { - await locator.click(); - await locator.type(value, { delay: 25 }); - return true; - } catch { - continue; - } - } - } - - return false; -} diff --git a/tests/ui/purchase/paypal-sandbox-checkout.test.ts b/tests/ui/purchase/paypal-sandbox-checkout.test.ts new file mode 100644 index 00000000..35f2972e --- /dev/null +++ b/tests/ui/purchase/paypal-sandbox-checkout.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; + +const shouldRun = + process.env.E2E_PAYPAL_SANDBOX === '1' || + process.env.E2E_LEMONSQUEEZY_SANDBOX === '1' || + process.env.E2E_PADDLE_SANDBOX === '1'; + +const base = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; +const locale = process.env.E2E_LOCALE ?? 'de'; +const checkoutSlug = locale === 'en' ? 'checkout' : 'bestellen'; +const tenantEmail = process.env.E2E_TENANT_EMAIL ?? null; +const tenantPassword = process.env.E2E_TENANT_PASSWORD ?? null; + +test.describe('PayPal sandbox checkout (staging)', () => { + test.skip(!shouldRun, 'Set E2E_PAYPAL_SANDBOX=1 to run sandbox checkout on staging.'); + test.skip(!tenantEmail || !tenantPassword, 'Set E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD for sandbox flow.'); + + test('creates PayPal order from the checkout wizard', async ({ page }) => { + await page.route('https://www.paypal.com/sdk/js**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: ` + window.paypal = { + Buttons: function(options) { + window.__paypalOptions = options; + return { + render: function() { return Promise.resolve(); }, + }; + }, + }; + `, + }); + }); + + await page.route('**/paypal/create-order', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + order_id: 'order_test_123', + checkout_session_id: 'session_test_123', + }), + }); + }); + + await page.goto(`${base}/${locale}/${checkoutSlug}/2`); + + await proceedToAccountStep(page); + await login(page, { email: tenantEmail!, password: tenantPassword! }); + + const termsCheckbox = page.locator('#checkout-terms-hero'); + await expect(termsCheckbox).toBeVisible(); + await termsCheckbox.click(); + + await expect.poll(async () => { + return page.evaluate(() => Boolean(window.__paypalOptions)); + }).toBe(true); + + const orderId = await page.evaluate(async () => { + return window.__paypalOptions?.createOrder?.(); + }); + + expect(orderId).toBe('order_test_123'); + }); +}); + +async function proceedToAccountStep(page: import('@playwright/test').Page, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const accountForm = page.locator('input[name="first_name"], input[name="identifier"]'); + if (await accountForm.isVisible()) { + return; + } + + const continueButton = page.getByRole('button', { name: /Weiter|Continue/i }).first(); + if (await continueButton.isVisible()) { + if (await continueButton.isEnabled()) { + await continueButton.click(); + } + } + + await page.waitForTimeout(500); + } + + throw new Error('Account step did not load in time.'); +} + +async function login(page: import('@playwright/test').Page, credentials: { email: string; password: string }) { + const identifierInput = page.locator('input[name="identifier"]'); + if (!(await identifierInput.isVisible())) { + return; + } + + await identifierInput.fill(credentials.email); + await page.locator('input[name="password"]').fill(credentials.password); + await page.getByRole('button', { name: /^Anmelden$|Login/i }).last().click(); + + await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); +} + +declare global { + interface Window { + __paypalOptions?: { + createOrder?: () => Promise; + }; + } +} diff --git a/tests/ui/purchase/paypal-sandbox-full.test.ts b/tests/ui/purchase/paypal-sandbox-full.test.ts new file mode 100644 index 00000000..418b280c --- /dev/null +++ b/tests/ui/purchase/paypal-sandbox-full.test.ts @@ -0,0 +1,248 @@ +import type { Page } from '@playwright/test'; +import fs from 'node:fs/promises'; + +import { dismissConsentBanner, expectFixture as expect, test } from '../helpers/test-fixtures'; + +const shouldRun = + process.env.E2E_PAYPAL_SANDBOX === '1' || + process.env.E2E_LEMONSQUEEZY_SANDBOX === '1' || + process.env.E2E_PADDLE_SANDBOX === '1'; +const baseUrl = process.env.E2E_BASE_URL ?? 'https://test-y0k0.fotospiel.app'; +const locale = process.env.E2E_LOCALE ?? 'de'; +const checkoutSlug = locale === 'en' ? 'checkout' : 'bestellen'; +const tenantEmail = buildTenantEmail(); +const tenantPassword = process.env.E2E_TENANT_PASSWORD ?? null; + +test.use({ + channel: process.env.E2E_BROWSER_CHANNEL ?? 'chrome', + userAgent: + process.env.E2E_USER_AGENT ?? + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + launchOptions: { + args: ['--disable-blink-features=AutomationControlled'], + }, +}); + +test.describe('PayPal sandbox full flow (staging)', () => { + test.skip(!shouldRun, 'Set E2E_PAYPAL_SANDBOX=1 to run sandbox checkout on staging.'); + test.skip(!tenantEmail || !tenantPassword, 'Set E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD for sandbox flow.'); + + test('register, simulate PayPal completion, and login to event admin', async ({ page }, testInfo) => { + const paypalNetworkLog: string[] = []; + + page.on('response', async (response) => { + const url = response.url(); + if (!/paypal/i.test(url)) { + return; + } + + const status = response.status(); + let bodySnippet = ''; + if (status >= 400 || /checkout|error/i.test(url)) { + try { + const text = await response.text(); + bodySnippet = text.trim().slice(0, 2000); + } catch { + bodySnippet = ''; + } + } + + const entry = [`[${status}] ${url}`, bodySnippet].filter(Boolean).join(' +'); + paypalNetworkLog.push(entry); + if (paypalNetworkLog.length > 40) { + paypalNetworkLog.shift(); + } + }); + + await page.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, + }); + }); + + await page.route('https://www.paypal.com/sdk/js**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/javascript', + body: ` + window.paypal = { + Buttons: function(options) { + window.__paypalOptions = options; + return { + render: function() { return Promise.resolve(); }, + }; + }, + }; + `, + }); + }); + + await page.route('**/paypal/create-order', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + order_id: 'order_test_123', + checkout_session_id: 'session_test_123', + }), + }); + }); + + await page.route('**/paypal/capture-order', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'completed', + }), + }); + }); + + try { + await page.goto(`${baseUrl}/${locale}/${checkoutSlug}/2`); + + await dismissConsentBanner(page); + + await proceedToAccountStep(page); + + await completeRegistrationOrLogin(page, { + email: tenantEmail!, + password: tenantPassword!, + }); + + const termsCheckbox = page.locator('#checkout-terms-hero'); + await expect(termsCheckbox).toBeVisible(); + await termsCheckbox.click(); + + await expect.poll(async () => { + return page.evaluate(() => Boolean(window.__paypalOptions)); + }).toBe(true); + + await page.evaluate(async () => { + await window.__paypalOptions?.createOrder?.(); + await window.__paypalOptions?.onApprove?.({ orderID: 'order_test_123' }); + }); + + await expect(page.getByText(/Marketing-Dashboard/)).toBeVisible(); + + await page.goto(`${baseUrl}/event-admin/login`); + await dismissConsentBanner(page); + + await page.locator('input[name="identifier"]').fill(tenantEmail!); + await page.locator('input[name="password"]').fill(tenantPassword!); + await page.getByRole('button', { name: /Anmelden|Login/i }).click(); + + await expect(page).toHaveURL(/\/event-admin\/mobile\/(dashboard|welcome)/i, { timeout: 30_000 }); + await expect(page.getByText(/Dashboard|Willkommen/i)).toBeVisible(); + } finally { + if (paypalNetworkLog.length > 0) { + const logPath = testInfo.outputPath('paypal-network-log.txt'); + await fs.writeFile(logPath, paypalNetworkLog.join(' + +'), 'utf8'); + await testInfo.attach('paypal-network-log', { + path: logPath, + contentType: 'text/plain', + }); + } + } + }); +}); + +async function completeRegistrationOrLogin(page: Page, credentials: { email: string; password: string }): Promise { + const identifierInput = page.locator('input[name="identifier"]'); + if (await identifierInput.isVisible()) { + await identifierInput.fill(credentials.email); + await page.locator('input[name="password"]').fill(credentials.password); + await page.getByRole('button', { name: /^Anmelden$|Login/i }).last().click(); + await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); + return; + } + + await page.fill('input[name="first_name"]', 'Play'); + await page.fill('input[name="last_name"]', 'Wright'); + await page.fill('input[name="email"]', credentials.email); + const addressInput = page.locator('input[name="address"]'); + if (await addressInput.isVisible()) { + await addressInput.fill('Teststrasse 1, 12345 Berlin'); + } + + const phoneInput = page.locator('input[name="phone"]'); + if (await phoneInput.isVisible()) { + await phoneInput.fill('+49123456789'); + } + + const usernameInput = page.locator('input[name="username"]'); + if (await usernameInput.isVisible()) { + await usernameInput.fill(credentials.email); + } + await page.fill('input[name="password"]', credentials.password); + await page.fill('input[name="password_confirmation"]', credentials.password); + + await page.check('input[name="privacy_consent"]'); + await page.getByRole('button', { name: /Registrieren|Register/i }).last().click(); + + try { + await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); + return; + } catch { + const loginButton = page.getByRole('button', { name: /^Anmelden$|Login/i }).first(); + if (await loginButton.isVisible()) { + await loginButton.click(); + } + } + + await expect(page.locator('input[name="identifier"]')).toBeVisible({ timeout: 10_000 }); + await page.locator('input[name="identifier"]').fill(credentials.email); + await page.locator('input[name="password"]').fill(credentials.password); + await page.getByRole('button', { name: /^Anmelden$|Login/i }).last().click(); + await expect(page.getByPlaceholder(/Gutscheincode|Coupon/i)).toBeVisible({ timeout: 20_000 }); +} + +async function proceedToAccountStep(page: Page, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const accountForm = page.locator('input[name="first_name"], input[name="identifier"]'); + if (await accountForm.isVisible()) { + return; + } + + await dismissConsentBanner(page); + + const continueButton = page.getByRole('button', { name: /Weiter|Continue/i }).first(); + if (await continueButton.isVisible()) { + if (await continueButton.isEnabled()) { + await continueButton.click(); + } + } + + await page.waitForTimeout(500); + } + + throw new Error('Account step did not load in time.'); +} + +function buildTenantEmail(): string | null { + const rawEmail = process.env.E2E_TENANT_EMAIL ?? process.env.E2E_PAYPAL_EMAIL ?? process.env.E2E_LEMONSQUEEZY_EMAIL ?? process.env.E2E_PADDLE_EMAIL ?? null; + if (!rawEmail) { + return null; + } + + if (!rawEmail.includes('{timestamp}')) { + return rawEmail; + } + + const timestamp = Math.floor(Date.now() / 1000).toString(); + return rawEmail.replaceAll('{timestamp}', timestamp); +} + +declare global { + interface Window { + __paypalOptions?: { + createOrder?: () => Promise; + onApprove?: (data: { orderID: string }) => Promise; + }; + } +} diff --git a/tests/ui/purchase/standard-package-checkout.test.ts b/tests/ui/purchase/standard-package-checkout.test.ts index dd5dd3e1..acc95add 100644 --- a/tests/ui/purchase/standard-package-checkout.test.ts +++ b/tests/ui/purchase/standard-package-checkout.test.ts @@ -2,15 +2,12 @@ import { test, expectFixture as expect } from '../helpers/test-fixtures'; const shouldRun = process.env.E2E_TESTING_API === '1'; -test.describe('Classic package checkout with Lemon Squeezy completion', () => { +test.describe('Classic package checkout with PayPal completion', () => { test.skip(!shouldRun, 'Set E2E_TESTING_API=1 to enable checkout tests that use /api/_testing endpoints.'); test('registers, applies coupon, and reaches confirmation', async ({ page, clearTestMailbox, seedTestCoupons, - getLatestCheckoutSession, - simulateLemonSqueezyCompletion, - getTestMailbox, }) => { await clearTestMailbox(); await seedTestCoupons(); @@ -18,44 +15,43 @@ test.describe('Classic package checkout with Lemon Squeezy completion', () => { const unique = Date.now(); const email = `checkout+${unique}@example.test`; const password = 'Password123!'; - await page.addInitScript(() => { - window.__openedWindows = []; - const originalOpen = window.open; - window.open = function (...args) { - window.__openedWindows.push(args); - return originalOpen?.apply(this, args) ?? null; - }; - }); - await page.route('https://app.lemonsqueezy.com/js/lemon.js', async (route) => { + await page.route('https://www.paypal.com/sdk/js**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/javascript', body: ` - window.__lemonEventHandler = null; - window.__lemonOpenedUrl = null; - window.LemonSqueezy = { - Setup(options) { - window.__lemonEventHandler = options?.eventHandler || null; - }, - Url: { - Open(url) { - window.__lemonOpenedUrl = url; - }, + window.paypal = { + Buttons: function(options) { + window.__paypalOptions = options; + return { + render: function() { return Promise.resolve(); }, + }; }, }; `, }); }); - let lemonsqueezyRequestPayload: Record | null = null; - await page.route('**/lemonsqueezy/create-checkout', async (route) => { - lemonsqueezyRequestPayload = route.request().postDataJSON() as Record; + let paypalRequestPayload: Record | null = null; + await page.route('**/paypal/create-order', async (route) => { + paypalRequestPayload = route.request().postDataJSON() as Record; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - checkout_url: 'https://fotospiel.lemonsqueezy.com/checkout/abc123', + order_id: 'order_test_123', + checkout_session_id: 'session_test_123', + }), + }); + }); + + await page.route('**/paypal/capture-order', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + status: 'completed', }), }); }); @@ -113,85 +109,36 @@ test.describe('Classic package checkout with Lemon Squeezy completion', () => { await expect(termsCheckbox).toBeVisible(); await termsCheckbox.click(); - await page.getByRole('button', { name: /Weiter mit Lemon Squeezy|Continue with Lemon Squeezy/i }).first().click(); + await expect.poll(async () => { + return page.evaluate(() => Boolean(window.__paypalOptions)); + }).toBe(true); - let checkoutMode: 'inline' | 'hosted' | null = null; - for (let i = 0; i < 8; i++) { - const state = await page.evaluate(() => ({ - inline: Boolean(window.__lemonOpenedUrl), - opened: window.__openedWindows?.length ?? 0, - })); - - if (state.inline) { - checkoutMode = 'inline'; - break; - } - - if (state.opened > 0) { - checkoutMode = 'hosted'; - break; - } - - await page.waitForTimeout(300); - } - - expect(checkoutMode).not.toBeNull(); - - if (checkoutMode === 'hosted') { - await expect.poll(async () => { - return page.evaluate(() => window.__openedWindows?.[0]?.[0] ?? null); - }).toContain('https://fotospiel.lemonsqueezy.com/checkout/abc123'); - } - - await page.evaluate(() => { - window.__lemonEventHandler?.({ - event: 'Checkout.Success', - data: { id: 'ord_test', attributes: { checkout_id: 'chk_123' } }, - }); + const orderId = await page.evaluate(async () => { + return window.__paypalOptions?.createOrder?.(); }); - let session = null; - for (let i = 0; i < 6; i++) { - session = await getLatestCheckoutSession({ email }); - if (session) { - break; - } - await page.waitForTimeout(500); - } + expect(orderId).toBe('order_test_123'); - if (session) { - await simulateLemonSqueezyCompletion(session.id); - - for (let i = 0; i < 6; i++) { - const refreshed = await getLatestCheckoutSession({ email }); - if (refreshed?.status === 'completed') { - session = refreshed; - break; - } - await page.waitForTimeout(500); - } - - expect(session?.status).toBe('completed'); - } + await page.evaluate(async () => { + await window.__paypalOptions?.onApprove?.({ orderID: 'order_test_123' }); + }); await expect(page.getByText(/Marketing-Dashboard/)).toBeVisible(); await expect( page.getByRole('button', { name: /Zum Admin-Bereich|To Admin Area/i }) ).toBeVisible(); - if (lemonsqueezyRequestPayload) { - expect(lemonsqueezyRequestPayload['coupon_code']).toBe('PERCENT10'); + if (paypalRequestPayload) { + expect(paypalRequestPayload['coupon_code']).toBe('PERCENT10'); } - - const messages = await getTestMailbox(); - expect(messages.length).toBeGreaterThan(0); }); }); declare global { interface Window { - __openedWindows?: unknown[]; - __lemonEventHandler?: ((event: { event: string; data?: unknown }) => void) | null; - __lemonOpenedUrl?: string | null; + __paypalOptions?: { + createOrder?: () => Promise; + onApprove?: (data: { orderID: string }) => Promise; + }; } }