Migrate billing from Paddle to Lemon Squeezy

This commit is contained in:
Codex Agent
2026-02-03 10:59:54 +01:00
parent 2f4ebfefd4
commit a0ef90e13a
228 changed files with 4369 additions and 4067 deletions

View File

@@ -557,7 +557,7 @@ export type DataExportSummary = {
} | null;
};
export type PaddleTransactionSummary = {
export type LemonSqueezyOrderSummary = {
id: string | null;
status: string | null;
amount: number | null;
@@ -1125,7 +1125,7 @@ export function normalizeTenantPackage(pkg: JsonValue): TenantPackageSummary {
};
}
function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary {
function normalizeLemonSqueezyOrder(entry: JsonValue): LemonSqueezyOrderSummary {
const amountValue = entry.amount ?? entry.grand_total ?? (entry.totals && entry.totals.grand_total);
const taxValue = entry.tax ?? (entry.totals && entry.totals.tax_total);
@@ -2348,8 +2348,8 @@ export type Package = {
gallery_days: number | null;
max_events_per_year?: number | null;
included_package_slug?: string | null;
paddle_price_id?: string | null;
paddle_product_id?: string | null;
lemonsqueezy_variant_id?: string | null;
lemonsqueezy_product_id?: string | null;
branding_allowed?: boolean | null;
watermark_allowed?: boolean | null;
features: string[] | Record<string, boolean> | null;
@@ -2731,8 +2731,8 @@ export async function downloadTenantDataExport(downloadUrl: string): Promise<Blo
return response.blob();
}
export async function getTenantPaddleTransactions(cursor?: string): Promise<{
data: PaddleTransactionSummary[];
export async function getTenantLemonSqueezyTransactions(cursor?: string): Promise<{
data: LemonSqueezyOrderSummary[];
nextCursor: string | null;
hasMore: boolean;
}> {
@@ -2745,8 +2745,8 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to load Paddle transactions', response.status, payload);
throw new Error('Failed to load Paddle transactions');
console.error('[API] Failed to load Lemon Squeezy transactions', response.status, payload);
throw new Error('Failed to load Lemon Squeezy transactions');
}
const payload = await safeJson(response) ?? {};
@@ -2754,17 +2754,17 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
const meta = payload.meta ?? {};
return {
data: entries.map(normalizePaddleTransaction),
data: entries.map(normalizeLemonSqueezyOrder),
nextCursor: typeof meta.next === 'string' ? meta.next : null,
hasMore: Boolean(meta.has_more),
};
}
export async function createTenantPaddleCheckout(
export async function createTenantLemonSqueezyCheckout(
packageId: number,
urls?: { success_url?: string; return_url?: string }
): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
const response = await authorizedFetch('/api/v1/tenant/packages/lemonsqueezy-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -2796,15 +2796,15 @@ export async function createTenantBillingPortalSession(): Promise<{ url: string
if (!response.ok) {
const payload = await safeJson(response);
console.error('[API] Failed to create Paddle portal session', response.status, payload);
throw new Error('Failed to create Paddle portal session');
console.error('[API] Failed to create Lemon Squeezy portal session', response.status, payload);
throw new Error('Failed to create Lemon Squeezy portal session');
}
const payload = await safeJson(response);
const url = payload?.url;
if (typeof url !== 'string' || url.length === 0) {
throw new Error('Paddle portal session missing URL');
throw new Error('Lemon Squeezy portal session missing URL');
}
return { url };
@@ -2848,13 +2848,13 @@ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
export async function completeTenantPackagePurchase(params: {
packageId: number;
paddleTransactionId: string;
orderId: string;
}): Promise<void> {
const { packageId, paddleTransactionId } = params;
const { packageId, orderId } = params;
const payload: Record<string, unknown> = { package_id: packageId };
if (paddleTransactionId) {
payload.paddle_transaction_id = paddleTransactionId;
if (orderId) {
payload.lemonsqueezy_order_id = orderId;
}
const response = await authorizedFetch('/api/v1/tenant/packages/complete', {

View File

@@ -15,7 +15,7 @@
"actions": {
"refresh": "Aktualisieren",
"exportCsv": "Export als CSV",
"portal": "Im Paddle-Portal verwalten",
"portal": "Im Lemon Squeezy-Portal verwalten",
"portalBusy": "Portal wird geöffnet...",
"openPackages": "Pakete öffnen",
"contactSupport": "Support kontaktieren"
@@ -42,7 +42,7 @@
"errors": {
"load": "Paketdaten konnten nicht geladen werden.",
"more": "Weitere Einträge konnten nicht geladen werden.",
"portal": "Paddle-Portal konnte nicht geöffnet werden."
"portal": "Lemon Squeezy-Portal konnte nicht geöffnet werden."
},
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
"checkoutCancelled": "Checkout wurde abgebrochen.",
@@ -62,8 +62,11 @@
"checkoutActionBadge": "Aktion nötig",
"checkoutActionButton": "Checkout fortsetzen",
"checkoutFailureReasons": {
"paddle_failed": "Die Zahlung wurde abgelehnt.",
"paddle_cancelled": "Der Checkout wurde abgebrochen."
"lemonsqueezy_failed": "Die Zahlung wurde abgelehnt.",
"lemonsqueezy_cancelled": "Der Checkout wurde abgebrochen.",
"lemonsqueezy_canceled": "Der Checkout wurde abgebrochen.",
"lemonsqueezy_refunded": "Die Zahlung wurde erstattet.",
"lemonsqueezy_voided": "Die Zahlung wurde storniert."
},
"sections": {
"invoices": {
@@ -125,9 +128,9 @@
}
},
"transactions": {
"title": "Paddle-Transaktionen",
"description": "Neueste Paddle-Transaktionen für dieses Kundenkonto.",
"empty": "Noch keine Paddle-Transaktionen.",
"title": "Lemon Squeezy-Transaktionen",
"description": "Neueste Lemon Squeezy-Transaktionen für dieses Kundenkonto.",
"empty": "Noch keine Lemon Squeezy-Transaktionen.",
"labels": {
"transactionId": "Transaktion {{id}}",
"checkoutId": "Checkout-ID: {{id}}",

View File

@@ -192,25 +192,25 @@
"failureTitle": "Aktivierung fehlgeschlagen",
"errorMessage": "Kostenloses Paket konnte nicht aktiviert werden."
},
"paddle": {
"sectionTitle": "Paddle",
"heading": "Checkout mit Paddle",
"genericError": "Der Paddle-Checkout konnte nicht geöffnet werden. Bitte versuche es erneut.",
"errorTitle": "Paddle-Fehler",
"processing": "Paddle-Checkout wird geöffnet …",
"cta": "Paddle-Checkout öffnen",
"hint": "Es öffnet sich ein neuer Tab über Paddle (Merchant of Record). Schließe dort die Zahlung ab und kehre anschließend zurück."
"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."
},
"nextStepsTitle": "Nächste Schritte",
"nextSteps": [
"Optional: Abrechnung über Paddle im Billing-Bereich abschließen.",
"Optional: Abrechnung über Lemon Squeezy 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 Paddle- und Kontingent-Optionen.",
"description": "Öffnet den Billing-Bereich mit Lemon Squeezy- und Kontingent-Optionen.",
"button": "Zu Billing & Zahlung"
},
"setup": {

View File

@@ -15,7 +15,7 @@
"actions": {
"refresh": "Refresh",
"exportCsv": "Export CSV",
"portal": "Manage in Paddle",
"portal": "Manage in Lemon Squeezy",
"portalBusy": "Opening portal...",
"openPackages": "Open packages",
"contactSupport": "Contact support"
@@ -42,7 +42,7 @@
"errors": {
"load": "Unable to load package data.",
"more": "Unable to load more entries.",
"portal": "Unable to open the Paddle portal."
"portal": "Unable to open the Lemon Squeezy portal."
},
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
"checkoutCancelled": "Checkout was cancelled.",
@@ -62,8 +62,11 @@
"checkoutActionBadge": "Action needed",
"checkoutActionButton": "Continue checkout",
"checkoutFailureReasons": {
"paddle_failed": "The payment was declined.",
"paddle_cancelled": "The checkout was cancelled."
"lemonsqueezy_failed": "The payment was declined.",
"lemonsqueezy_cancelled": "The checkout was cancelled.",
"lemonsqueezy_canceled": "The checkout was cancelled.",
"lemonsqueezy_refunded": "The payment was refunded.",
"lemonsqueezy_voided": "The payment was voided."
},
"sections": {
"invoices": {
@@ -125,9 +128,9 @@
}
},
"transactions": {
"title": "Paddle transactions",
"description": "Recent Paddle transactions for this customer account.",
"empty": "No Paddle transactions yet.",
"title": "Lemon Squeezy transactions",
"description": "Recent Lemon Squeezy transactions for this customer account.",
"empty": "No Lemon Squeezy transactions yet.",
"labels": {
"transactionId": "Transaction {{id}}",
"checkoutId": "Checkout ID: {{id}}",

View File

@@ -192,25 +192,25 @@
"failureTitle": "Activation failed",
"errorMessage": "The free package could not be activated."
},
"paddle": {
"sectionTitle": "Paddle",
"heading": "Checkout with Paddle",
"genericError": "The Paddle checkout could not be opened. Please try again.",
"errorTitle": "Paddle error",
"processing": "Opening the Paddle checkout …",
"cta": "Open Paddle checkout",
"hint": "A new tab opens via Paddle (merchant of record). Complete the payment there, then return to continue."
"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."
},
"nextStepsTitle": "Next steps",
"nextSteps": [
"Optional: finish billing via Paddle inside the billing area.",
"Optional: finish billing via Lemon Squeezy 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 Paddle bundle options.",
"description": "Opens the billing area with Lemon Squeezy bundle options.",
"button": "Go to billing"
},
"setup": {

View File

@@ -4,7 +4,7 @@ import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { TamaguiProvider, Theme } from '@tamagui/core';
import { TamaguiProvider, Theme } from 'tamagui';
import '@tamagui/core/reset.css';
import tamaguiConfig from '../../../tamagui.config';
import { AuthProvider } from './auth/context';

View File

@@ -12,10 +12,10 @@ import { ContextHelpLink } from './components/ContextHelpLink';
import {
createTenantBillingPortalSession,
getTenantPackagesOverview,
getTenantPaddleTransactions,
getTenantLemonSqueezyTransactions,
getTenantPackageCheckoutStatus,
TenantPackageSummary,
PaddleTransactionSummary,
LemonSqueezyOrderSummary,
} from '../api';
import { TenantAddonHistoryEntry, getTenantAddonHistory } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
@@ -52,7 +52,7 @@ export default function MobileBillingPage() {
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
const [transactions, setTransactions] = React.useState<LemonSqueezyOrderSummary[]>([]);
const [addons, setAddons] = React.useState<TenantAddonHistoryEntry[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
@@ -78,7 +78,7 @@ export default function MobileBillingPage() {
try {
const [pkg, trx, addonHistory] = await Promise.all([
getTenantPackagesOverview({ force: true }),
getTenantPaddleTransactions().catch(() => ({ data: [] as PaddleTransactionSummary[] })),
getTenantLemonSqueezyTransactions().catch(() => ({ data: [] as LemonSqueezyOrderSummary[] })),
getTenantAddonHistory().catch(() => ({ data: [] as TenantAddonHistoryEntry[] })),
]);
setPackages(pkg.packages ?? []);
@@ -116,7 +116,7 @@ export default function MobileBillingPage() {
window.open(url, '_blank', 'noopener');
}
} catch (err) {
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Paddle-Portal nicht öffnen.'));
const message = getApiErrorMessage(err, t('billing.errors.portal', 'Konnte das Lemon Squeezy-Portal nicht öffnen.'));
toast.error(message);
} finally {
setPortalBusy(false);
@@ -388,7 +388,7 @@ export default function MobileBillingPage() {
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
</Text>
<CTAButton
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Paddle')}
label={portalBusy ? t('billing.actions.portalBusy', 'Öffne Portal...') : t('billing.actions.portal', 'Manage in Lemon Squeezy')}
onPress={openPortal}
disabled={portalBusy}
/>

View File

@@ -1465,7 +1465,7 @@ function WatermarkPreview({
background: ADMIN_GRADIENTS.softCard,
}}
>
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, ${overlay}, transparent)` }} />
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, rgba(0,0,0,0.4), transparent)` }} />
<div
style={{
position: 'absolute',

View File

@@ -222,7 +222,12 @@ export default function MobileDashboardPage() {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<DashboardCard padding="$0">
<YStack padding="$3" gap="$2">
<YStack
padding="$3"
gap="$2"
animation="bouncy"
enterStyle={{ opacity: 0, scale: 0.9 }}
>
<SectionHeader
title={t('dashboard:overview.title', 'At a glance')}
showSeparator={false}

View File

@@ -224,7 +224,7 @@ function PackageShopCard({
const isResellerCatalog = catalogType === 'reseller';
const statusLabel = getPackageStatusLabel({ t, isActive, owned });
const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive);
const canSelect = isResellerCatalog ? Boolean(pkg.paddle_price_id) : canSelectPackage(isUpgrade, isActive);
const canSelect = isResellerCatalog ? Boolean(pkg.lemonsqueezy_variant_id) : canSelectPackage(isUpgrade, isActive);
const hasManageAction = Boolean(isActive && onManage);
const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null);
const handlePress = isActive ? onManage : canSelect ? onSelect : undefined;
@@ -524,7 +524,7 @@ function PackageShopCompareView({
<YStack width={labelWidth} />
{entries.map((entry) => {
const isResellerCatalog = catalogType === 'reseller';
const canSelect = isResellerCatalog ? Boolean(entry.pkg.paddle_price_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
const canSelect = isResellerCatalog ? Boolean(entry.pkg.lemonsqueezy_variant_id) : canSelectPackage(entry.isUpgrade, entry.isActive);
const label = isResellerCatalog
? canSelect
? t('shop.partner.buy', 'Kaufen')

View File

@@ -1,10 +1,8 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { ChevronLeft, Bell, QrCode, ChevronsUpDown, Search } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { YStack, XStack, SizableText as Text, Image } from 'tamagui';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Image } from '@tamagui/image';
import { useTranslation } from 'react-i18next';
import { useEventContext } from '../../context/EventContext';
import { BottomNav, NavKey } from './BottomNav';

View File

@@ -1,9 +1,6 @@
import React from 'react';
import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Card, YStack, XStack, SizableText as Text, Tabs, Separator } from 'tamagui';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Tabs, Separator } from 'tamagui';
import { useAdminTheme } from '../theme';
import { withAlpha } from './colors';
@@ -16,7 +13,9 @@ export function MobileCard({
const { surface, border, shadow, glassSurface, glassBorder, glassShadow } = useAdminTheme();
return (
<YStack
className={['admin-fade-up', className].filter(Boolean).join(' ')}
className={className}
animation="bouncy"
enterStyle={{ opacity: 0, y: 10, scale: 0.9 }}
backgroundColor={glassSurface ?? surface}
borderRadius={20}
borderWidth={2}
@@ -131,6 +130,8 @@ export function CTAButton({
}}
>
<XStack
animation="quick"
pressStyle={{ scale: 0.97, opacity: 0.9 }}
height={52}
borderRadius={18}
alignItems="center"
@@ -335,7 +336,8 @@ export function ActionTile({
disabled={disabled}
>
<YStack
className="admin-fade-up"
animation="lazy"
pressStyle={{ scale: 0.96, opacity: 0.8 }}
style={tileStyle}
borderRadius={isCluster ? 14 : 16}
padding="$3"

View File

@@ -36,9 +36,6 @@ export function MobileSheet({
const { surface, textStrong, muted, overlay, shadow, border } = useAdminTheme();
const bottomOffset = `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)`;
if (!open) {
return null;
}
return (
<Sheet
modal
@@ -53,29 +50,33 @@ export function MobileSheet({
dismissOnOverlayPress
dismissOnSnapToBottom
zIndex={100000}
animation="lazy"
>
<Sheet.Overlay {...({ backgroundColor: `${overlay}66` } as any)} />
<Sheet.Overlay
animation="lazy"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
backgroundColor={overlay}
/>
<Sheet.Frame
{...({
width: '100%',
maxWidth: 520,
alignSelf: 'center',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
backgroundColor: surface,
padding,
paddingBottom,
shadowColor: shadow,
shadowOpacity: 0.12,
shadowRadius: 18,
shadowOffset: { width: 0, height: -8 },
} as any)}
width="100%"
maxWidth={520}
alignSelf="center"
borderTopLeftRadius={24}
borderTopRightRadius={24}
backgroundColor={surface}
padding={padding}
paddingBottom={paddingBottom}
shadowColor={shadow}
shadowOpacity={0.12}
shadowRadius={18}
shadowOffset={{ width: 0, height: -8 }}
style={{ marginBottom: bottomOffset }}
>
<Sheet.Handle height={5} width={48} backgroundColor={border} borderRadius={999} marginBottom="$3" />
<Sheet.ScrollView
showsVerticalScrollIndicator={false}
{...({ contentContainerStyle: { paddingBottom: 6 } } as any)}
contentContainerStyle={{ paddingBottom: 6 }}
>
<YStack gap={contentSpacing}>
<XStack alignItems="center" justifyContent="space-between">

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { createTenantPaddleCheckout } from '../../api';
import { createTenantLemonSqueezyCheckout } from '../../api';
import { adminPath } from '../../constants';
import { getApiErrorMessage } from '../../lib/apiError';
import { storePendingCheckout } from '../lib/billingCheckout';
@@ -33,7 +33,7 @@ export function usePackageCheckout(): {
cancelUrl.searchParams.set('checkout', 'cancel');
cancelUrl.searchParams.set('package_id', String(packageId));
const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, {
const { checkout_url, checkout_session_id } = await createTenantLemonSqueezyCheckout(packageId, {
success_url: successUrl.toString(),
return_url: cancelUrl.toString(),
});

View File

@@ -161,7 +161,7 @@ export function useAdminTheme() {
infoText: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong),
danger: String(theme.danger?.val ?? ADMIN_COLORS.danger),
backdrop: String(theme.backgroundStrong?.val ?? ADMIN_COLORS.backdrop),
overlay: withAlpha(String(theme.backgroundStrong?.val ?? ADMIN_COLORS.backdrop), 0.6),
overlay: withAlpha(ADMIN_COLORS.backdrop, 0.7),
shadow: String(theme.shadowColor?.val ?? 'rgba(15, 23, 42, 0.08)'),
glassSurface,
glassSurfaceStrong,

View File

@@ -166,7 +166,7 @@ export default function WelcomeSummaryPage() {
{(t('summary.nextSteps', {
returnObjects: true,
defaultValue: [
'Optional: finish billing via Paddle inside the billing area.',
'Optional: finish billing via Lemon Squeezy inside the billing area.',
'Complete the event setup and configure tasks, team, and gallery.',
'Check your event slots before go-live and share your guest link.',
],