switched to paddle inline checkout, removed paypal and most of stripe. added product sync between app and paddle.

This commit is contained in:
Codex Agent
2025-10-27 17:26:39 +01:00
parent ecf5a23b28
commit 5432456ffd
117 changed files with 4114 additions and 3639 deletions

View File

@@ -1,176 +1,65 @@
import React from 'react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
StripeCheckoutForm,
PayPalCheckout,
} from '../pages/WelcomeOrderSummaryPage';
import { act, render, screen, waitFor } from '@testing-library/react';
import { PaddleCheckout } from '../pages/WelcomeOrderSummaryPage';
const stripeRef: { current: any } = { current: null };
const elementsRef: { current: any } = { current: null };
const paypalPropsRef: { current: any } = { current: null };
const {
confirmPaymentMock,
completePurchaseMock,
createPayPalOrderMock,
capturePayPalOrderMock,
} = vi.hoisted(() => ({
confirmPaymentMock: vi.fn(),
completePurchaseMock: vi.fn(),
createPayPalOrderMock: vi.fn(),
capturePayPalOrderMock: vi.fn(),
}));
vi.mock('@stripe/react-stripe-js', () => ({
useStripe: () => stripeRef.current,
useElements: () => elementsRef.current,
PaymentElement: () => <div data-testid="stripe-payment-element" />,
Elements: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock('@paypal/react-paypal-js', () => ({
PayPalScriptProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PayPalButtons: (props: any) => {
paypalPropsRef.current = props;
return <button type="button" data-testid="paypal-button">PayPal</button>;
},
const { createPaddleCheckoutMock } = vi.hoisted(() => ({
createPaddleCheckoutMock: vi.fn(),
}));
vi.mock('../../api', () => ({
completeTenantPackagePurchase: completePurchaseMock,
createTenantPackagePaymentIntent: vi.fn(),
assignFreeTenantPackage: vi.fn(),
createTenantPayPalOrder: createPayPalOrderMock,
captureTenantPayPalOrder: capturePayPalOrderMock,
createTenantPaddleCheckout: createPaddleCheckoutMock,
}));
describe('StripeCheckoutForm', () => {
describe('PaddleCheckout', () => {
beforeEach(() => {
confirmPaymentMock.mockReset();
completePurchaseMock.mockReset();
stripeRef.current = { confirmPayment: confirmPaymentMock };
elementsRef.current = {};
createPaddleCheckoutMock.mockReset();
});
const renderStripeForm = (overrides?: Partial<React.ComponentProps<typeof StripeCheckoutForm>>) =>
render(
<StripeCheckoutForm
clientSecret="secret"
packageId={42}
onSuccess={vi.fn()}
t={(key: string) => key}
{...overrides}
/>
);
it('completes the purchase when Stripe reports a successful payment', async () => {
const onSuccess = vi.fn();
confirmPaymentMock.mockResolvedValue({
error: null,
paymentIntent: { payment_method: 'pm_123' },
});
completePurchaseMock.mockResolvedValue(undefined);
const { container } = renderStripeForm({ onSuccess });
const form = container.querySelector('form');
expect(form).toBeTruthy();
fireEvent.submit(form!);
await waitFor(() => {
expect(completePurchaseMock).toHaveBeenCalledWith({
packageId: 42,
paymentMethodId: 'pm_123',
});
});
expect(onSuccess).toHaveBeenCalled();
});
it('shows Stripe errors returned by confirmPayment', async () => {
confirmPaymentMock.mockResolvedValue({
error: { message: 'Card declined' },
});
const { container } = renderStripeForm();
fireEvent.submit(container.querySelector('form')!);
await waitFor(() => {
expect(screen.getByText('Card declined')).toBeInTheDocument();
});
expect(completePurchaseMock).not.toHaveBeenCalled();
});
it('reports missing payment method id', async () => {
confirmPaymentMock.mockResolvedValue({
error: null,
paymentIntent: {},
});
const { container } = renderStripeForm();
fireEvent.submit(container.querySelector('form')!);
await waitFor(() => {
expect(screen.getByText('summary.stripe.missingPaymentId')).toBeInTheDocument();
});
expect(completePurchaseMock).not.toHaveBeenCalled();
});
});
describe('PayPalCheckout', () => {
beforeEach(() => {
paypalPropsRef.current = null;
createPayPalOrderMock.mockReset();
capturePayPalOrderMock.mockReset();
});
it('creates and captures a PayPal order successfully', async () => {
createPayPalOrderMock.mockResolvedValue('ORDER-123');
capturePayPalOrderMock.mockResolvedValue(undefined);
it('opens Paddle checkout when created successfully', async () => {
createPaddleCheckoutMock.mockResolvedValue({ checkout_url: 'https://paddle.example/checkout' });
const onSuccess = vi.fn();
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
render(
<PayPalCheckout
<PaddleCheckout
packageId={99}
onSuccess={onSuccess}
t={(key: string) => key}
/>
);
expect(paypalPropsRef.current).toBeTruthy();
const { createOrder, onApprove } = paypalPropsRef.current;
await act(async () => {
const orderId = await createOrder();
expect(orderId).toBe('ORDER-123');
});
await act(async () => {
await onApprove({ orderID: 'ORDER-123' });
screen.getByRole('button').click();
});
await waitFor(() => {
expect(createPayPalOrderMock).toHaveBeenCalledWith(99);
expect(capturePayPalOrderMock).toHaveBeenCalledWith('ORDER-123');
expect(createPaddleCheckoutMock).toHaveBeenCalledWith(99);
expect(openSpy).toHaveBeenCalledWith('https://paddle.example/checkout', '_blank', 'noopener');
expect(onSuccess).toHaveBeenCalled();
});
openSpy.mockRestore();
});
it('surfaces missing order id errors', async () => {
createPayPalOrderMock.mockResolvedValue('ORDER-123');
it('shows an error message on failure', async () => {
createPaddleCheckoutMock.mockRejectedValue(new Error('boom'));
render(
<PayPalCheckout
<PaddleCheckout
packageId={99}
onSuccess={vi.fn()}
t={(key: string) => key}
/>
);
const { onApprove } = paypalPropsRef.current;
await act(async () => {
await onApprove({ orderID: undefined });
screen.getByRole('button').click();
});
await waitFor(() => {
expect(screen.getByText('summary.paypal.missingOrderId')).toBeInTheDocument();
expect(screen.getByText('summary.paddle.genericError')).toBeInTheDocument();
});
expect(capturePayPalOrderMock).not.toHaveBeenCalled();
});
});

View File

@@ -10,8 +10,6 @@ import {
AlertTriangle,
Loader2,
} from "lucide-react";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { PayPalScriptProvider, PayPalButtons } from "@paypal/react-paypal-js";
import {
TenantWelcomeLayout,
@@ -26,30 +24,15 @@ import { ADMIN_BILLING_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PA
import { useTenantPackages } from "../hooks/useTenantPackages";
import {
assignFreeTenantPackage,
completeTenantPackagePurchase,
createTenantPackagePaymentIntent,
createTenantPayPalOrder,
captureTenantPayPalOrder,
createTenantPaddleCheckout,
} from "../../api";
import { getStripe } from '@/utils/stripe';
const stripePublishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
const paypalClientId = import.meta.env.VITE_PAYPAL_CLIENT_ID ?? "";
type StripeCheckoutProps = {
clientSecret: string;
type PaddleCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
};
type PayPalCheckoutProps = {
packageId: number;
onSuccess: () => void;
t: ReturnType<typeof useTranslation>["t"];
currency?: string;
};
function useLocaleFormats(locale: string) {
const currencyFormatter = React.useMemo(
() =>
@@ -86,175 +69,53 @@ function humanizeFeature(t: ReturnType<typeof useTranslation>["t"], key: string)
.join(" ");
}
function StripeCheckoutForm({ clientSecret, packageId, onSuccess, t }: StripeCheckoutProps) {
const stripe = useStripe();
const elements = useElements();
const [submitting, setSubmitting] = React.useState(false);
function PaddleCheckout({ packageId, onSuccess, t }: PaddleCheckoutProps) {
const [status, setStatus] = React.useState<'idle' | 'processing' | 'success' | 'error'>('idle');
const [error, setError] = React.useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements) {
setError(t("summary.stripe.notReady"));
return;
}
setSubmitting(true);
setError(null);
const result = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.href,
},
redirect: "if_required",
});
if (result.error) {
setError(result.error.message ?? t("summary.stripe.genericError"));
setSubmitting(false);
return;
}
const paymentIntent = result.paymentIntent;
const paymentMethodId =
typeof paymentIntent?.payment_method === "string"
? paymentIntent.payment_method
: typeof paymentIntent?.id === "string"
? paymentIntent.id
: null;
if (!paymentMethodId) {
setError(t("summary.stripe.missingPaymentId"));
setSubmitting(false);
return;
}
const handleCheckout = React.useCallback(async () => {
try {
await completeTenantPackagePurchase({
packageId,
paymentMethodId,
});
setStatus('processing');
setError(null);
const { checkout_url } = await createTenantPaddleCheckout(packageId);
window.open(checkout_url, '_blank', 'noopener');
setStatus('success');
onSuccess();
} catch (purchaseError) {
console.error("[Onboarding] Purchase completion failed", purchaseError);
setError(
purchaseError instanceof Error
? purchaseError.message
: t("summary.stripe.completionFailed")
);
setSubmitting(false);
} catch (err) {
console.error('[Onboarding] Paddle checkout failed', err);
setStatus('error');
setError(err instanceof Error ? err.message : t('summary.paddle.genericError'));
}
};
}, [packageId, onSuccess, t]);
return (
<form onSubmit={handleSubmit} className="space-y-6 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 shadow-brand-primary">
<div className="space-y-3">
<p className="text-sm font-medium text-brand-slate">{t("summary.stripe.heading")}</p>
<PaymentElement id="payment-element" />
</div>
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
<p className="text-sm font-medium text-brand-slate">{t('summary.paddle.heading')}</p>
{error && (
<Alert variant="destructive">
<AlertTitle>{t("summary.stripe.errorTitle")}</AlertTitle>
<AlertTitle>{t('summary.paddle.errorTitle')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
disabled={submitting || !stripe || !elements}
size="lg"
className="w-full rounded-full bg-brand-rose text-white shadow-lg shadow-rose-400/40 hover:bg-[var(--brand-rose-strong)] disabled:opacity-60"
disabled={status === 'processing'}
onClick={handleCheckout}
>
{submitting ? (
{status === 'processing' ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
{t("summary.stripe.submitting")}
{t('summary.paddle.processing')}
</>
) : (
<>
<CreditCard className="mr-2 size-4" />
{t("summary.stripe.submit")}
{t('summary.paddle.cta')}
</>
)}
</Button>
<p className="text-xs text-brand-navy/70">{t("summary.stripe.hint")}</p>
</form>
);
}
function PayPalCheckout({ packageId, onSuccess, t, currency = "EUR" }: PayPalCheckoutProps) {
const [status, setStatus] = React.useState<"idle" | "creating" | "capturing" | "error" | "success">("idle");
const [error, setError] = React.useState<string | null>(null);
const handleCreateOrder = React.useCallback(async () => {
try {
setStatus("creating");
const orderId = await createTenantPayPalOrder(packageId);
setStatus("idle");
setError(null);
return orderId;
} catch (err) {
console.error("[Onboarding] PayPal create order failed", err);
setStatus("error");
setError(
err instanceof Error ? err.message : t("summary.paypal.createFailed")
);
throw err;
}
}, [packageId, t]);
const handleApprove = React.useCallback(
async (orderId: string) => {
try {
setStatus("capturing");
await captureTenantPayPalOrder(orderId);
setStatus("success");
setError(null);
onSuccess();
} catch (err) {
console.error("[Onboarding] PayPal capture failed", err);
setStatus("error");
setError(
err instanceof Error ? err.message : t("summary.paypal.captureFailed")
);
throw err;
}
},
[onSuccess, t]
);
return (
<div className="space-y-3 rounded-2xl border border-brand-rose-soft bg-white/85 p-6 shadow-inner">
<p className="text-sm font-medium text-brand-slate">{t("summary.paypal.heading")}</p>
{error && (
<Alert variant="destructive">
<AlertTitle>{t("summary.paypal.errorTitle")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<PayPalButtons
style={{ layout: "vertical" }}
forceReRender={[packageId, currency]}
createOrder={async () => handleCreateOrder()}
onApprove={async (data) => {
if (!data.orderID) {
setError(t("summary.paypal.missingOrderId"));
setStatus("error");
return;
}
await handleApprove(data.orderID);
}}
onError={(err) => {
console.error("[Onboarding] PayPal onError", err);
setStatus("error");
setError(t("summary.paypal.genericError"));
}}
onCancel={() => {
setStatus("idle");
setError(t("summary.paypal.cancelled"));
}}
disabled={status === "creating" || status === "capturing"}
/>
<p className="text-xs text-brand-navy/70">{t("summary.paypal.hint")}</p>
<p className="text-xs text-brand-navy/70">{t('summary.paddle.hint')}</p>
</div>
);
}
@@ -267,7 +128,6 @@ export default function WelcomeOrderSummaryPage() {
const { t, i18n } = useTranslation("onboarding");
const locale = i18n.language?.startsWith("en") ? "en-GB" : "de-DE";
const { currencyFormatter, dateFormatter } = useLocaleFormats(locale);
const stripePromise = React.useMemo(() => getStripe(stripePublishableKey), [stripePublishableKey]);
const packageIdFromState = typeof location.state === "object" ? (location.state as any)?.packageId : undefined;
const selectedPackageId = progress.selectedPackage?.id ?? packageIdFromState ?? null;
@@ -295,48 +155,9 @@ export default function WelcomeOrderSummaryPage() {
const isSubscription = Boolean(packageDetails?.features?.subscription);
const requiresPayment = Boolean(packageDetails && packageDetails.price > 0);
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
const [intentStatus, setIntentStatus] = React.useState<"idle" | "loading" | "error" | "ready">("idle");
const [intentError, setIntentError] = React.useState<string | null>(null);
const [freeAssignStatus, setFreeAssignStatus] = React.useState<"idle" | "loading" | "success" | "error">("idle");
const [freeAssignError, setFreeAssignError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!requiresPayment || !packageDetails) {
setClientSecret(null);
setIntentStatus("idle");
setIntentError(null);
return;
}
if (!stripePromise) {
setIntentError(t("summary.stripe.missingKey"));
setIntentStatus("error");
return;
}
let cancelled = false;
setIntentStatus("loading");
setIntentError(null);
createTenantPackagePaymentIntent(packageDetails.id)
.then((secret) => {
if (cancelled) return;
setClientSecret(secret);
setIntentStatus("ready");
})
.catch((error) => {
console.error("[Onboarding] Payment intent failed", error);
if (cancelled) return;
setIntentStatus("error");
setIntentError(error instanceof Error ? error.message : t("summary.stripe.intentFailed"));
});
return () => {
cancelled = true;
};
}, [requiresPayment, packageDetails, stripePromise, t]);
const priceText =
progress.selectedPackage?.priceText ??
(packageDetails && typeof packageDetails.price === "number"
@@ -534,63 +355,16 @@ export default function WelcomeOrderSummaryPage() {
)}
{requiresPayment && (
<div className="space-y-6">
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.stripe.sectionTitle")}</h4>
{intentStatus === "loading" && (
<div className="flex items-center gap-3 rounded-2xl border border-brand-rose-soft bg-brand-card p-6 text-sm text-brand-navy/80">
<Loader2 className="size-4 animate-spin text-brand-rose" />
{t("summary.stripe.loading")}
</div>
)}
{intentStatus === "error" && (
<Alert variant="destructive">
<AlertTitle>{t("summary.stripe.unavailableTitle")}</AlertTitle>
<AlertDescription>{intentError ?? t("summary.stripe.unavailableDescription")}</AlertDescription>
</Alert>
)}
{intentStatus === "ready" && clientSecret && stripePromise && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<StripeCheckoutForm
clientSecret={clientSecret}
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
</Elements>
)}
</div>
{paypalClientId ? (
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t("summary.paypal.sectionTitle")}</h4>
<PayPalScriptProvider
options={{
clientId: paypalClientId,
"client-id": paypalClientId,
currency: "EUR",
intent: "CAPTURE",
}}
>
<PayPalCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
</PayPalScriptProvider>
</div>
) : (
<Alert variant="default" className="border-amber-200 bg-amber-50 text-amber-700">
<AlertTitle>{t("summary.paypal.notConfiguredTitle")}</AlertTitle>
<AlertDescription>{t("summary.paypal.notConfiguredDescription")}</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<h4 className="text-lg font-semibold text-brand-slate">{t('summary.paddle.sectionTitle')}</h4>
<PaddleCheckout
packageId={packageDetails.id}
onSuccess={() => {
markStep({ packageSelected: true });
navigate(ADMIN_WELCOME_EVENT_PATH, { replace: true });
}}
t={t}
/>
</div>
)}
@@ -634,4 +408,4 @@ export default function WelcomeOrderSummaryPage() {
);
}
export { StripeCheckoutForm, PayPalCheckout };
export { PaddleCheckout };