Update PayPal references and tests

This commit is contained in:
Codex Agent
2026-02-04 12:43:40 +01:00
parent fc5dfb272c
commit 239f55f9c5
18 changed files with 655 additions and 729 deletions

View File

@@ -7,6 +7,7 @@ use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException; use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class LemonSqueezyClient class LemonSqueezyClient
{ {
@@ -68,7 +69,7 @@ class LemonSqueezyClient
throw new LemonSqueezyException('Lemon Squeezy API key is not configured.'); 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 return $this->http
->baseUrl($baseUrl) ->baseUrl($baseUrl)
@@ -81,4 +82,30 @@ class LemonSqueezyClient
->acceptJson() ->acceptJson()
->asJson(); ->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;
}
} }

View File

@@ -11,14 +11,14 @@
"contact": "Kontakt", "contact": "Kontakt",
"vat_id": "Umsatzsteuer-ID: DE123456789", "vat_id": "Umsatzsteuer-ID: DE123456789",
"monetization": "Monetarisierung", "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", "register_court": "Registergericht: Amtsgericht Musterstadt",
"commercial_register": "Handelsregister: HRB 12345", "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.", "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", "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.", "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": "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.", "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.", "rights": "Ihre Rechte: Auskunft, Löschung, Widerspruch.",
"cookies": "Cookies: Nur funktionale Cookies für die PWA.", "cookies": "Cookies: Nur funktionale Cookies für die PWA.",

View File

@@ -10,14 +10,14 @@
"contact": "Contact", "contact": "Contact",
"vat_id": "VAT ID: DE123456789", "vat_id": "VAT ID: DE123456789",
"monetization": "Monetization", "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", "register_court": "Register Court: District Court Musterstadt",
"commercial_register": "Commercial Register: HRB 12345", "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.", "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", "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.", "data_collection": "Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.",
"payments": "Payments and Packages", "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.", "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.", "rights": "Your rights: Information, deletion, objection. Contact us under Contact.",
"cookies": "Cookies: Only functional cookies for the PWA.", "cookies": "Cookies: Only functional cookies for the PWA.",

View File

@@ -1,6 +1,18 @@
{ {
"mobileProfile": { "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": { "readiness": {
"steps": { "steps": {
@@ -15,7 +27,7 @@
"actions": { "actions": {
"refresh": "Aktualisieren", "refresh": "Aktualisieren",
"exportCsv": "Export als CSV", "exportCsv": "Export als CSV",
"portal": "Im Lemon Squeezy-Portal verwalten", "portal": "Im PayPal-Portal verwalten",
"portalBusy": "Portal wird geöffnet...", "portalBusy": "Portal wird geöffnet...",
"openPackages": "Pakete öffnen", "openPackages": "Pakete öffnen",
"contactSupport": "Support kontaktieren" "contactSupport": "Support kontaktieren"
@@ -42,7 +54,7 @@
"errors": { "errors": {
"load": "Paketdaten konnten nicht geladen werden.", "load": "Paketdaten konnten nicht geladen werden.",
"more": "Weitere Einträge 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.", "checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
"checkoutCancelled": "Checkout wurde abgebrochen.", "checkoutCancelled": "Checkout wurde abgebrochen.",
@@ -128,9 +140,9 @@
} }
}, },
"transactions": { "transactions": {
"title": "Lemon Squeezy-Transaktionen", "title": "PayPal-Transaktionen",
"description": "Neueste Lemon Squeezy-Transaktionen für dieses Kundenkonto.", "description": "Neueste PayPal-Transaktionen für dieses Kundenkonto.",
"empty": "Noch keine Lemon Squeezy-Transaktionen.", "empty": "Noch keine PayPal-Transaktionen.",
"labels": { "labels": {
"transactionId": "Transaktion {{id}}", "transactionId": "Transaktion {{id}}",
"checkoutId": "Checkout-ID: {{id}}", "checkoutId": "Checkout-ID: {{id}}",
@@ -2295,8 +2307,8 @@
}, },
"mobileDashboard": { "mobileDashboard": {
"title": "Dashboard", "title": "Dashboard",
"shortcutAnalytics": "Analytics", "shortcutAnalytics": "Statistiken",
"quickActionsTitle": "Experience", "quickActionsTitle": "Schnellzugriff",
"readyForLiftoff": "Alles erledigt.", "readyForLiftoff": "Alles erledigt.",
"selectEvent": "Wähle ein Event, um fortzufahren", "selectEvent": "Wähle ein Event, um fortzufahren",
"emptyBadge": "Willkommen!", "emptyBadge": "Willkommen!",
@@ -2418,8 +2430,6 @@
"shortcutInvites": "Team-/Helfer-Einladungen", "shortcutInvites": "Team-/Helfer-Einladungen",
"shortcutSettings": "Event-Einstellungen", "shortcutSettings": "Event-Einstellungen",
"shortcutBranding": "Branding & Moderation", "shortcutBranding": "Branding & Moderation",
"shortcutAnalytics": "Statistiken",
"quickActionsTitle": "Schnellzugriff",
"kpiTitle": "Wichtigste Kennzahlen", "kpiTitle": "Wichtigste Kennzahlen",
"kpiTasks": "Offene Fotoaufgaben", "kpiTasks": "Offene Fotoaufgaben",
"kpiPhotos": "Fotos", "kpiPhotos": "Fotos",
@@ -2672,25 +2682,10 @@
} }
} }
}, },
"mobileProfile": { "mobileSettings": {
"title": "Profil", "title": "Einstellungen",
"settings": "Einstellungen", "accountTitle": "Account",
"account": "Account bearbeiten", "tenantBadge": "Konto #{{id}}",
"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}}",
"notificationsTitle": "Benachrichtigungen", "notificationsTitle": "Benachrichtigungen",
"notificationsLoading": "Lade Einstellungen ...", "notificationsLoading": "Lade Einstellungen ...",
"pushTitle": "App Push", "pushTitle": "App Push",
@@ -2861,7 +2856,7 @@
"validation": "Füge Titel, Nachricht und ggf. einen Ziel-Gast hinzu." "validation": "Füge Titel, Nachricht und ggf. einen Ziel-Gast hinzu."
} }
}, },
"dataExports": { "dataExports": {
"title": "Datenexporte", "title": "Datenexporte",
"request": { "request": {
"title": "Exportanfrage", "title": "Exportanfrage",
@@ -3034,5 +3029,12 @@
"checkout": "Checkout fehlgeschlagen" "checkout": "Checkout fehlgeschlagen"
}, },
"selectDisabled": "Nicht verfügbar" "selectDisabled": "Nicht verfügbar"
},
"billingOverview": {
"transactions": {
"title": "PayPal-Transaktionen",
"description": "Neueste PayPal-Transaktionen für dieses Kundenkonto.",
"empty": "Noch keine PayPal-Transaktionen."
}
} }
} }

View File

@@ -1,4 +1,4 @@
{ {
"layout": { "layout": {
"eyebrow": "Fotospiel Kunden-Admin", "eyebrow": "Fotospiel Kunden-Admin",
"title": "Willkommen im Event-Erlebnisstudio", "title": "Willkommen im Event-Erlebnisstudio",
@@ -193,24 +193,24 @@
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden." "errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
}, },
"lemonsqueezy": { "lemonsqueezy": {
"sectionTitle": "Lemon Squeezy", "sectionTitle": "PayPal",
"heading": "Checkout mit Lemon Squeezy", "heading": "Checkout mit PayPal",
"genericError": "Der Lemon Squeezy-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.", "genericError": "Der PayPal-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.",
"errorTitle": "Lemon Squeezy-Fehler", "errorTitle": "PayPal-Fehler",
"processing": "Lemon Squeezy-Checkout wird geöffnet …", "processing": "PayPal-Checkout wird geöffnet …",
"cta": "Lemon Squeezy-Checkout öffnen", "cta": "PayPal-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." "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", "nextStepsTitle": "Nächste Schritte",
"nextSteps": [ "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.", "Event-Setup durchlaufen und Fotoaufgaben, Team & Galerie konfigurieren.",
"Vor dem Go-Live Event-Kontingent prüfen und Gäste-Link teilen." "Vor dem Go-Live Event-Kontingent prüfen und Gäste-Link teilen."
], ],
"cta": { "cta": {
"billing": { "billing": {
"label": "Abrechnung starten", "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" "button": "Zu Billing & Zahlung"
}, },
"setup": { "setup": {
@@ -267,5 +267,19 @@
"button": "Eventliste" "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."
}
} }
} }

View File

@@ -1,6 +1,18 @@
{ {
"mobileProfile": { "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": { "readiness": {
"steps": { "steps": {
@@ -15,7 +27,7 @@
"actions": { "actions": {
"refresh": "Refresh", "refresh": "Refresh",
"exportCsv": "Export CSV", "exportCsv": "Export CSV",
"portal": "Manage in Lemon Squeezy", "portal": "Manage in PayPal",
"portalBusy": "Opening portal...", "portalBusy": "Opening portal...",
"openPackages": "Open packages", "openPackages": "Open packages",
"contactSupport": "Contact support" "contactSupport": "Contact support"
@@ -42,7 +54,7 @@
"errors": { "errors": {
"load": "Unable to load package data.", "load": "Unable to load package data.",
"more": "Unable to load more entries.", "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.", "checkoutSuccess": "Checkout completed. Your package will activate shortly.",
"checkoutCancelled": "Checkout was cancelled.", "checkoutCancelled": "Checkout was cancelled.",
@@ -128,9 +140,9 @@
} }
}, },
"transactions": { "transactions": {
"title": "Lemon Squeezy transactions", "title": "PayPal transactions",
"description": "Recent Lemon Squeezy transactions for this customer account.", "description": "Recent PayPal transactions for this customer account.",
"empty": "No Lemon Squeezy transactions yet.", "empty": "No PayPal transactions yet.",
"labels": { "labels": {
"transactionId": "Transaction {{id}}", "transactionId": "Transaction {{id}}",
"checkoutId": "Checkout ID: {{id}}", "checkoutId": "Checkout ID: {{id}}",
@@ -2298,7 +2310,7 @@
"mobileDashboard": { "mobileDashboard": {
"title": "Dashboard", "title": "Dashboard",
"shortcutAnalytics": "Analytics", "shortcutAnalytics": "Analytics",
"quickActionsTitle": "Experience", "quickActionsTitle": "Quick actions",
"readyForLiftoff": "Ready for Liftoff", "readyForLiftoff": "Ready for Liftoff",
"selectEvent": "Select an event to continue", "selectEvent": "Select an event to continue",
"emptyBadge": "Welcome aboard", "emptyBadge": "Welcome aboard",
@@ -2420,8 +2432,6 @@
"shortcutInvites": "Team / helper invites", "shortcutInvites": "Team / helper invites",
"shortcutSettings": "Event settings", "shortcutSettings": "Event settings",
"shortcutBranding": "Branding & moderation", "shortcutBranding": "Branding & moderation",
"shortcutAnalytics": "Analytics",
"quickActionsTitle": "Quick actions",
"kpiTitle": "Key performance indicators", "kpiTitle": "Key performance indicators",
"kpiTasks": "Open photo tasks", "kpiTasks": "Open photo tasks",
"kpiPhotos": "Photos", "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": { "mobileSettings": {
"title": "Settings", "title": "Settings",
"accountTitle": "Account", "accountTitle": "Account",
@@ -2863,13 +2858,13 @@
"validation": "Add a title, message, and target guest when needed." "validation": "Add a title, message, and target guest when needed."
} }
}, },
"dataExports": { "dataExports": {
"title": "Data exports", "title": "Data exports",
"request": { "request": {
"title": "Export request", "title": "Export request",
"hint": "Export account data or a specific event archive.", "hint": "Export account data or a specific event archive.",
"progress": "Export is running. This list refreshes automatically." "progress": "Export is running. This list refreshes automatically."
}, },
"fields": { "fields": {
"scope": "Scope", "scope": "Scope",
"event": "Event", "event": "Event",
@@ -3036,5 +3031,12 @@
"checkout": "Checkout failed" "checkout": "Checkout failed"
}, },
"selectDisabled": "Not available" "selectDisabled": "Not available"
},
"billingOverview": {
"transactions": {
"title": "PayPal transactions",
"description": "Recent PayPal transactions for this customer account.",
"empty": "No PayPal transactions yet."
}
} }
} }

View File

@@ -1,4 +1,4 @@
{ {
"layout": { "layout": {
"eyebrow": "Fotospiel Customer Admin", "eyebrow": "Fotospiel Customer Admin",
"title": "Welcome to your event studio", "title": "Welcome to your event studio",
@@ -193,24 +193,24 @@
"errorMessage": "The free package could not be activated." "errorMessage": "The free package could not be activated."
}, },
"lemonsqueezy": { "lemonsqueezy": {
"sectionTitle": "Lemon Squeezy", "sectionTitle": "PayPal",
"heading": "Checkout with Lemon Squeezy", "heading": "Checkout with PayPal",
"genericError": "The Lemon Squeezy checkout could not be opened. Please try again.", "genericError": "The PayPal checkout could not be opened. Please try again.",
"errorTitle": "Lemon Squeezy error", "errorTitle": "PayPal error",
"processing": "Opening the Lemon Squeezy checkout ", "processing": "Opening the PayPal checkout ?",
"cta": "Open Lemon Squeezy checkout", "cta": "Open PayPal checkout",
"hint": "A new tab opens via Lemon Squeezy (merchant of record). Complete the payment there, then return to continue." "hint": "A new tab opens via PayPal. Complete the payment there, then return to continue."
}, },
"nextStepsTitle": "Next steps", "nextStepsTitle": "Next steps",
"nextSteps": [ "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.", "Complete the event setup and configure photo tasks, team, and gallery.",
"Check your event bundle before go-live and share your guest link." "Check your event bundle before go-live and share your guest link."
], ],
"cta": { "cta": {
"billing": { "billing": {
"label": "Start 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" "button": "Go to billing"
}, },
"setup": { "setup": {
@@ -267,5 +267,19 @@
"button": "Open event list" "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."
}
} }
} }

View File

@@ -188,7 +188,7 @@ return [
], ],
'lemonsqueezy_health' => [ 'lemonsqueezy_health' => [
'navigation' => [ 'navigation' => [
'label' => 'Lemon Squeezy-Status', 'label' => 'PayPal-Status',
], ],
], ],
'integrations_health' => [ 'integrations_health' => [
@@ -203,7 +203,7 @@ return [
'unknown' => 'Unbekannt', 'unknown' => 'Unbekannt',
], ],
'heading' => 'Integrationen-Status', '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', 'configured' => 'Konfiguriert',
'unconfigured' => 'Nicht konfiguriert', 'unconfigured' => 'Nicht konfiguriert',
'last_received' => 'Zuletzt empfangen', 'last_received' => 'Zuletzt empfangen',

View File

@@ -12,14 +12,14 @@ return [
'contact' => 'Kontakt', 'contact' => 'Kontakt',
'vat_id' => 'Umsatzsteuer-ID: DE123456789', 'vat_id' => 'Umsatzsteuer-ID: DE123456789',
'monetization' => 'Monetarisierung', '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', 'register_court' => 'Registergericht: Amtsgericht Musterstadt',
'commercial_register' => 'Handelsregister: HRB 12345', '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.', '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', '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.', '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' => '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.', '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.', 'rights' => 'Ihre Rechte: Auskunft, Löschung, Widerspruch.',
'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.', 'cookies' => 'Cookies: Nur funktionale Cookies für die PWA.',

View File

@@ -188,7 +188,7 @@ return [
], ],
'lemonsqueezy_health' => [ 'lemonsqueezy_health' => [
'navigation' => [ 'navigation' => [
'label' => 'Lemon Squeezy health', 'label' => 'PayPal health',
], ],
], ],
'integrations_health' => [ 'integrations_health' => [
@@ -203,7 +203,7 @@ return [
'unknown' => 'Unknown', 'unknown' => 'Unknown',
], ],
'heading' => 'Integrations health', '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', 'configured' => 'Configured',
'unconfigured' => 'Unconfigured', 'unconfigured' => 'Unconfigured',
'last_received' => 'Last received', 'last_received' => 'Last received',

View File

@@ -12,14 +12,14 @@ return [
'contact' => 'Contact', 'contact' => 'Contact',
'vat_id' => 'VAT ID: DE123456789', 'vat_id' => 'VAT ID: DE123456789',
'monetization' => 'Monetization', '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', 'register_court' => 'Register Court: District Court Musterstadt',
'commercial_register' => 'Commercial Register: HRB 12345', '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.', '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', '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.', 'data_collection' => 'Data collection: No PII storage, anonymous sessions for guests. Emails are only processed for contact purposes.',
'payments' => 'Payments and Packages', '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.', '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.', 'rights' => 'Your rights: Information, deletion, objection. Contact us under Contact.',
'cookies' => 'Cookies: Only functional cookies for the PWA.', 'cookies' => 'Cookies: Only functional cookies for the PWA.',

View File

@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit;
use App\Services\LemonSqueezy\LemonSqueezyClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class LemonSqueezyClientTest extends TestCase
{
public function test_base_url_is_normalized_when_missing_scheme(): void
{
$this->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');
}
}

View File

@@ -5,31 +5,46 @@ const demoTenantCredentials = {
password: process.env.E2E_DEMO_TENANT_PASSWORD ?? 'Demo1234!', password: process.env.E2E_DEMO_TENANT_PASSWORD ?? 'Demo1234!',
}; };
test.describe('Checkout Payment Step Lemon Squeezy flow', () => { test.describe('Checkout Payment Step - PayPal flow', () => {
test('opens Lemon Squeezy checkout and shows success notice', async ({ page }) => { test('creates PayPal order and completes capture', async ({ page }) => {
await page.route('**/lemonsqueezy/create-checkout', async (route) => { let createPayload: Record<string, unknown> | null = null;
let capturePayload: Record<string, unknown> | null = null;
await page.route('**/paypal/create-order', async (route) => {
createPayload = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ 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<string, unknown>;
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({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/javascript', contentType: 'application/javascript',
body: ` body: `
window.createLemonSqueezy = function() {}; window.paypal = {
window.LemonSqueezy = { Buttons: function(options) {
Setup: function(options) { window.__lemonEventHandler = options?.eventHandler || null; }, window.__paypalOptions = options;
Url: { return {
Open: function(url) { render: function() { return Promise.resolve(); },
window.__lemonOpenedUrl = url; };
} },
}
}; };
`, `,
}); });
@@ -38,58 +53,27 @@ test.describe('Checkout Payment Step Lemon Squeezy flow', () => {
await openCheckoutPaymentStep(page, demoTenantCredentials); await openCheckoutPaymentStep(page, demoTenantCredentials);
await acceptCheckoutTerms(page); await acceptCheckoutTerms(page);
await page.evaluate(() => { await expect.poll(async () => {
window.__openedUrls = []; return page.evaluate(() => Boolean(window.__paypalOptions));
window.open = (url: string, target?: string | null, features?: string | null) => { }).toBe(true);
window.__openedUrls.push({ url, target: target ?? null, features: features ?? null });
return null; 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( await page.evaluate(async () => {
page.locator( await window.__paypalOptions?.onApprove?.({ orderID: 'order_test_123' });
'text=/secure overlay|Overlay|neuen Tab|new tab/i' });
)
).toBeVisible();
let mode: 'inline' | 'hosted' | null = null; expect(createPayload?.package_id).toBeDefined();
for (let i = 0; i < 8; i++) { expect(capturePayload?.order_id).toBe('order_test_123');
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');
}
}); });
test('shows error state when Lemon Squeezy checkout creation fails', async ({ page }) => { test('shows error state when PayPal checkout creation fails', async ({ page }) => {
await page.route('**/lemonsqueezy/create-checkout', async (route) => { await page.route('**/paypal/create-order', async (route) => {
await route.fulfill({ await route.fulfill({
status: 500, status: 500,
contentType: 'application/json', 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({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/javascript', contentType: 'application/javascript',
body: ` body: `
window.createLemonSqueezy = function() {}; window.paypal = {
window.LemonSqueezy = { Buttons: function(options) {
Setup: function(options) { window.__lemonEventHandler = options?.eventHandler || null; }, window.__paypalOptions = options;
Url: { return {
Open: function() { render: function() { return Promise.resolve(); },
throw new Error('forced Lemon Squeezy failure'); };
} },
}
}; };
`, `,
}); });
@@ -118,10 +101,20 @@ test.describe('Checkout Payment Step Lemon Squeezy flow', () => {
await openCheckoutPaymentStep(page, demoTenantCredentials); await openCheckoutPaymentStep(page, demoTenantCredentials);
await acceptCheckoutTerms(page); 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( 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(); ).toBeVisible();
}); });
}); });
@@ -168,8 +161,9 @@ async function acceptCheckoutTerms(page: import('@playwright/test').Page) {
declare global { declare global {
interface Window { interface Window {
__openedUrls?: Array<{ url: string; target?: string | null; features?: string | null }>; __paypalOptions?: {
__lemonOpenedUrl?: string | null; createOrder?: () => Promise<string>;
__lemonEventHandler?: ((event: { event: string; data?: unknown }) => void) | null; onApprove?: (data: { orderID: string }) => Promise<void>;
};
} }
} }

View File

@@ -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');
});
});

View File

@@ -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<string, unknown> | 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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
for (const selector of selectors) {
if ((await scope.locator(selector).count()) > 0) {
return true;
}
}
return false;
}
async function hasAnyText(page: Page, matcher: RegExp): Promise<boolean> {
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<string, unknown> | 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<void> {
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<void> {
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<boolean> {
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;
}

View File

@@ -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<void> {
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<string>;
};
}
}

View File

@@ -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<void> {
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<void> {
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<string>;
onApprove?: (data: { orderID: string }) => Promise<void>;
};
}
}

View File

@@ -2,15 +2,12 @@ import { test, expectFixture as expect } from '../helpers/test-fixtures';
const shouldRun = process.env.E2E_TESTING_API === '1'; 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.skip(!shouldRun, 'Set E2E_TESTING_API=1 to enable checkout tests that use /api/_testing endpoints.');
test('registers, applies coupon, and reaches confirmation', async ({ test('registers, applies coupon, and reaches confirmation', async ({
page, page,
clearTestMailbox, clearTestMailbox,
seedTestCoupons, seedTestCoupons,
getLatestCheckoutSession,
simulateLemonSqueezyCompletion,
getTestMailbox,
}) => { }) => {
await clearTestMailbox(); await clearTestMailbox();
await seedTestCoupons(); await seedTestCoupons();
@@ -18,44 +15,43 @@ test.describe('Classic package checkout with Lemon Squeezy completion', () => {
const unique = Date.now(); const unique = Date.now();
const email = `checkout+${unique}@example.test`; const email = `checkout+${unique}@example.test`;
const password = 'Password123!'; 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({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/javascript', contentType: 'application/javascript',
body: ` body: `
window.__lemonEventHandler = null; window.paypal = {
window.__lemonOpenedUrl = null; Buttons: function(options) {
window.LemonSqueezy = { window.__paypalOptions = options;
Setup(options) { return {
window.__lemonEventHandler = options?.eventHandler || null; render: function() { return Promise.resolve(); },
}, };
Url: {
Open(url) {
window.__lemonOpenedUrl = url;
},
}, },
}; };
`, `,
}); });
}); });
let lemonsqueezyRequestPayload: Record<string, unknown> | null = null; let paypalRequestPayload: Record<string, unknown> | null = null;
await page.route('**/lemonsqueezy/create-checkout', async (route) => { await page.route('**/paypal/create-order', async (route) => {
lemonsqueezyRequestPayload = route.request().postDataJSON() as Record<string, unknown>; paypalRequestPayload = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: 'application/json', contentType: 'application/json',
body: JSON.stringify({ 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 expect(termsCheckbox).toBeVisible();
await termsCheckbox.click(); 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; const orderId = await page.evaluate(async () => {
for (let i = 0; i < 8; i++) { return window.__paypalOptions?.createOrder?.();
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' } },
});
}); });
let session = null; expect(orderId).toBe('order_test_123');
for (let i = 0; i < 6; i++) {
session = await getLatestCheckoutSession({ email });
if (session) {
break;
}
await page.waitForTimeout(500);
}
if (session) { await page.evaluate(async () => {
await simulateLemonSqueezyCompletion(session.id); await window.__paypalOptions?.onApprove?.({ orderID: 'order_test_123' });
});
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 expect(page.getByText(/Marketing-Dashboard/)).toBeVisible(); await expect(page.getByText(/Marketing-Dashboard/)).toBeVisible();
await expect( await expect(
page.getByRole('button', { name: /Zum Admin-Bereich|To Admin Area/i }) page.getByRole('button', { name: /Zum Admin-Bereich|To Admin Area/i })
).toBeVisible(); ).toBeVisible();
if (lemonsqueezyRequestPayload) { if (paypalRequestPayload) {
expect(lemonsqueezyRequestPayload['coupon_code']).toBe('PERCENT10'); expect(paypalRequestPayload['coupon_code']).toBe('PERCENT10');
} }
const messages = await getTestMailbox();
expect(messages.length).toBeGreaterThan(0);
}); });
}); });
declare global { declare global {
interface Window { interface Window {
__openedWindows?: unknown[]; __paypalOptions?: {
__lemonEventHandler?: ((event: { event: string; data?: unknown }) => void) | null; createOrder?: () => Promise<string>;
__lemonOpenedUrl?: string | null; onApprove?: (data: { orderID: string }) => Promise<void>;
};
} }
} }