Update UI, improve E2E tests and modify trial period configuration
The code changes incorporate UI updates for better usability and user experience. E2E test scripts(in `user-billing.spec.ts` and `team-billing.spec.ts`) were also updated for improved efficiency and accuracy, primarily replacing 'Active' status check with 'Trial'. Changes have been made in the trialDays configuration, with the term 'trialPeriod' now replaced by 'trialDays' across different components. Notably, a new error handling case is included in `lemon-squeezy-billing-strategy.service.ts` for failed subscription cancellation attempts.
This commit is contained in:
@@ -13,7 +13,7 @@ test.describe('Team Billing', () => {
|
|||||||
await po.teamAccounts.goToBilling();
|
await po.teamAccounts.goToBilling();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a team can subscribe to a plan', async ({page}) => {
|
test('a team can subscribe to a plan', async () => {
|
||||||
await po.billing.selectPlan(0);
|
await po.billing.selectPlan(0);
|
||||||
await po.billing.proceedToCheckout();
|
await po.billing.proceedToCheckout();
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ test.describe('Team Billing', () => {
|
|||||||
|
|
||||||
await po.teamAccounts.goToBilling();
|
await po.teamAccounts.goToBilling();
|
||||||
|
|
||||||
await expect(await po.billing.getStatus()).toContainText('Active');
|
await expect(await po.billing.getStatus()).toContainText('Trial');
|
||||||
await expect(po.billing.manageBillingButton()).toBeVisible();
|
await expect(po.billing.manageBillingButton()).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -22,15 +22,13 @@ test.describe('User Billing', () => {
|
|||||||
await expect(po.billing.successStatus()).toBeVisible();
|
await expect(po.billing.successStatus()).toBeVisible();
|
||||||
await po.billing.returnToHome();
|
await po.billing.returnToHome();
|
||||||
|
|
||||||
await page.waitForURL('http://localhost:3000/home');
|
|
||||||
|
|
||||||
const link = page.locator('button', {
|
const link = page.locator('button', {
|
||||||
hasText: 'Billing'
|
hasText: 'Billing'
|
||||||
});
|
});
|
||||||
|
|
||||||
await link.click();
|
await link.click();
|
||||||
|
|
||||||
await expect(await po.billing.getStatus()).toContainText('Active');
|
await expect(await po.billing.getStatus()).toContainText('Trial');
|
||||||
await expect(po.billing.manageBillingButton()).toBeVisible();
|
await expect(po.billing.manageBillingButton()).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { expect, Page } from '@playwright/test';
|
import { Page } from '@playwright/test';
|
||||||
import { StripePageObject } from './stripe.po';
|
import { StripePageObject } from './stripe.po';
|
||||||
|
|
||||||
export class BillingPageObject {
|
export class BillingPageObject {
|
||||||
@@ -32,7 +32,7 @@ export class BillingPageObject {
|
|||||||
// wait a bit for the webhook to be processed
|
// wait a bit for the webhook to be processed
|
||||||
await this.page.waitForTimeout(1000);
|
await this.page.waitForTimeout(1000);
|
||||||
|
|
||||||
return this.page.locator('[data-test="checkout-success-back-link"]').click();
|
return this.page.locator('[data-test="checkout-success-back-link"] button').click();
|
||||||
}
|
}
|
||||||
|
|
||||||
proceedToCheckout() {
|
proceedToCheckout() {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
|||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
import { cn } from '@kit/ui/utils';
|
||||||
|
|
||||||
import { createBillingPortalSession } from '~/(dashboard)/home/[account]/billing/server-actions';
|
import { createBillingPortalSession } from '~/(dashboard)/home/[account]/billing/server-actions';
|
||||||
import billingConfig from '~/config/billing.config';
|
import billingConfig from '~/config/billing.config';
|
||||||
@@ -75,13 +76,17 @@ async function TeamAccountBillingPage({ params }: Params) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<div className={'mx-auto flex w-full max-w-2xl flex-col space-y-6'}>
|
<div
|
||||||
|
className={cn(`flex w-full flex-col space-y-6`, {
|
||||||
|
'mx-auto max-w-2xl ': subscription,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<If
|
<If
|
||||||
condition={subscription}
|
condition={subscription}
|
||||||
fallback={
|
fallback={
|
||||||
<>
|
<div>
|
||||||
<Checkout />
|
<Checkout />
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(subscription) => (
|
{(subscription) => (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
@@ -7,17 +8,12 @@ import { requireUser } from '@kit/supabase/require-user';
|
|||||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||||
|
|
||||||
import billingConfig from '~/config/billing.config';
|
import billingConfig from '~/config/billing.config';
|
||||||
import pathsConfig from '~/config/paths.config';
|
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
|
|
||||||
interface SessionPageProps {
|
interface SessionPageProps {
|
||||||
searchParams: {
|
searchParams: {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
params: {
|
|
||||||
account: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LazyEmbeddedCheckout = dynamic(
|
const LazyEmbeddedCheckout = dynamic(
|
||||||
@@ -31,10 +27,7 @@ const LazyEmbeddedCheckout = dynamic(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
async function ReturnCheckoutSessionPage({
|
async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) {
|
||||||
searchParams,
|
|
||||||
params,
|
|
||||||
}: SessionPageProps) {
|
|
||||||
const sessionId = searchParams.session_id;
|
const sessionId = searchParams.session_id;
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -52,17 +45,12 @@ async function ReturnCheckoutSessionPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectPath = pathsConfig.app.accountHome.replace(
|
|
||||||
'[account]',
|
|
||||||
params.account,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={'fixed left-0 top-48 z-50 mx-auto w-full'}>
|
<div className={'fixed left-0 top-48 z-50 mx-auto w-full'}>
|
||||||
<BillingSessionStatus
|
<BillingSessionStatus
|
||||||
|
onRedirect={onRedirect}
|
||||||
customerEmail={customerEmail ?? ''}
|
customerEmail={customerEmail ?? ''}
|
||||||
redirectPath={redirectPath}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,3 +94,15 @@ async function loadCheckoutSession(sessionId: string) {
|
|||||||
checkoutToken,
|
checkoutToken,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
async function onRedirect() {
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
// revalidate the home page to update cached pages
|
||||||
|
// which may have changed due to the billing session
|
||||||
|
revalidatePath('/home', 'layout');
|
||||||
|
|
||||||
|
// redirect back
|
||||||
|
redirect('../');
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default createBillingSchema({
|
|||||||
{
|
{
|
||||||
name: 'Starter Monthly',
|
name: 'Starter Monthly',
|
||||||
id: 'starter-monthly',
|
id: 'starter-monthly',
|
||||||
trialPeriod: 7,
|
trialDays: 7,
|
||||||
paymentType: 'recurring',
|
paymentType: 'recurring',
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
lineItems: [
|
lineItems: [
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const PlanSchema = z
|
|||||||
.min(1),
|
.min(1),
|
||||||
interval: BillingIntervalSchema.optional(),
|
interval: BillingIntervalSchema.optional(),
|
||||||
lineItems: z.array(LineItemSchema),
|
lineItems: z.array(LineItemSchema),
|
||||||
trialPeriod: z
|
trialDays: z
|
||||||
.number({
|
.number({
|
||||||
description:
|
description:
|
||||||
'Number of days for the trial period. Leave empty for no trial.',
|
'Number of days for the trial period. Leave empty for no trial.',
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export const CreateBillingCheckoutSchema = z.object({
|
|||||||
returnUrl: z.string().url(),
|
returnUrl: z.string().url(),
|
||||||
accountId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
plan: PlanSchema,
|
plan: PlanSchema,
|
||||||
trialDays: z.number().optional(),
|
|
||||||
customerId: z.string().optional(),
|
customerId: z.string().optional(),
|
||||||
customerEmail: z.string().email().optional(),
|
customerEmail: z.string().email().optional(),
|
||||||
enableDiscountField: z.boolean().optional(),
|
enableDiscountField: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { Check, ChevronRight } from 'lucide-react';
|
import { Check, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
@@ -12,33 +8,13 @@ import { Trans } from '@kit/ui/trans';
|
|||||||
* Retrieves the session status for a Stripe checkout session.
|
* Retrieves the session status for a Stripe checkout session.
|
||||||
* Since we should only arrive here for a successful checkout, we only check
|
* Since we should only arrive here for a successful checkout, we only check
|
||||||
* for the `paid` status.
|
* for the `paid` status.
|
||||||
*
|
**/
|
||||||
* @param {Stripe.Checkout.Session['status']} status - The status of the Stripe checkout session.
|
|
||||||
* @param {string} customerEmail - The email address of the customer associated with the session.
|
|
||||||
*
|
|
||||||
* @returns {ReactElement} - The component to render based on the session status.
|
|
||||||
*/
|
|
||||||
export function BillingSessionStatus({
|
export function BillingSessionStatus({
|
||||||
customerEmail,
|
customerEmail,
|
||||||
redirectPath,
|
onRedirect,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
customerEmail: string;
|
customerEmail: string;
|
||||||
redirectPath: string;
|
onRedirect: () => void;
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<SuccessSessionStatus
|
|
||||||
redirectPath={redirectPath}
|
|
||||||
customerEmail={customerEmail}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SuccessSessionStatus({
|
|
||||||
customerEmail,
|
|
||||||
redirectPath,
|
|
||||||
}: React.PropsWithChildren<{
|
|
||||||
customerEmail: string;
|
|
||||||
redirectPath: string;
|
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -78,15 +54,15 @@ function SuccessSessionStatus({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link data-test={'checkout-success-back-link'} href={redirectPath}>
|
<form data-test={'checkout-success-back-link'}>
|
||||||
<Button>
|
<Button formAction={onRedirect}>
|
||||||
<span>
|
<span>
|
||||||
<Trans i18nKey={'billing:checkoutSuccessBackButton'} />
|
<Trans i18nKey={'billing:checkoutSuccessBackButton'} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<ChevronRight className={'h-4'} />
|
<ChevronRight className={'h-4'} />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export function PlanPicker(
|
|||||||
>
|
>
|
||||||
<If
|
<If
|
||||||
condition={
|
condition={
|
||||||
plan.trialPeriod && props.canStartTrial
|
plan.trialDays && props.canStartTrial
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -296,7 +296,7 @@ export function PlanPicker(
|
|||||||
<Trans
|
<Trans
|
||||||
i18nKey={`billing:trialPeriod`}
|
i18nKey={`billing:trialPeriod`}
|
||||||
values={{
|
values={{
|
||||||
period: plan.trialPeriod,
|
period: plan.trialDays,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -356,7 +356,7 @@ export function PlanPicker(
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<If
|
<If
|
||||||
condition={selectedPlan?.trialPeriod && props.canStartTrial}
|
condition={selectedPlan?.trialDays && props.canStartTrial}
|
||||||
fallback={t(`proceedToPayment`)}
|
fallback={t(`proceedToPayment`)}
|
||||||
>
|
>
|
||||||
<span>{t(`startTrial`)}</span>
|
<span>{t(`startTrial`)}</span>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export class LemonSqueezyBillingStrategyService
|
|||||||
'Failed to cancel subscription',
|
'Failed to cancel subscription',
|
||||||
);
|
);
|
||||||
|
|
||||||
throw error;
|
throw new Error('Failed to cancel subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(ctx, 'Subscription cancelled successfully');
|
logger.info(ctx, 'Subscription cancelled successfully');
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export async function createStripeCheckout(
|
|||||||
| Stripe.Checkout.SessionCreateParams.SubscriptionData
|
| Stripe.Checkout.SessionCreateParams.SubscriptionData
|
||||||
| undefined = isSubscription
|
| undefined = isSubscription
|
||||||
? {
|
? {
|
||||||
trial_period_days: params.trialDays,
|
trial_period_days: params.plan.trialDays,
|
||||||
metadata: {
|
metadata: {
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -191,7 +191,12 @@ export function AccountSelector({
|
|||||||
data-test={'account-selector-team'}
|
data-test={'account-selector-team'}
|
||||||
data-name={account.label}
|
data-name={account.label}
|
||||||
data-slug={account.value}
|
data-slug={account.value}
|
||||||
className={'group flex space-x-2'}
|
className={cn(
|
||||||
|
'group flex justify-between transition-colors',
|
||||||
|
{
|
||||||
|
['bg-muted']: value === account.value,
|
||||||
|
},
|
||||||
|
)}
|
||||||
key={account.value}
|
key={account.value}
|
||||||
value={account.value ?? ''}
|
value={account.value ?? ''}
|
||||||
onSelect={(currentValue) => {
|
onSelect={(currentValue) => {
|
||||||
@@ -203,23 +208,28 @@ export function AccountSelector({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<div className={'flex items-center'}>
|
||||||
className={cn('h-6 w-6 border border-transparent', {
|
<Avatar
|
||||||
['border-border']: value === account.value,
|
className={cn(
|
||||||
['group-hover:border-border ']:
|
'mr-2 h-6 w-6 border border-transparent',
|
||||||
value !== account.value,
|
{
|
||||||
})}
|
['border-border']: value === account.value,
|
||||||
>
|
['group-hover:border-border ']:
|
||||||
<AvatarImage src={account.image ?? undefined} />
|
value !== account.value,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AvatarImage src={account.image ?? undefined} />
|
||||||
|
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{account.label ? account.label[0] : ''}
|
{account.label ? account.label[0] : ''}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<span className={'mr-2 max-w-[165px] truncate'}>
|
<span className={'mr-2 max-w-[165px] truncate'}>
|
||||||
{account.label}
|
{account.label}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Icon item={account.value ?? ''} />
|
<Icon item={account.value ?? ''} />
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Ellipsis } from 'lucide-react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { DataTable } from '@kit/ui/data-table';
|
import { DataTable } from '@kit/ui/data-table';
|
||||||
import {
|
import {
|
||||||
@@ -121,13 +122,7 @@ function useGetColumns(
|
|||||||
<span>{displayName}</span>
|
<span>{displayName}</span>
|
||||||
|
|
||||||
<If condition={isSelf}>
|
<If condition={isSelf}>
|
||||||
<span
|
<Badge variant={'outline'}>{t('youLabel')}</Badge>
|
||||||
className={
|
|
||||||
'bg-muted rounded-md px-2.5 py-1 text-xs font-medium'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('youLabel')}
|
|
||||||
</span>
|
|
||||||
</If>
|
</If>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user