Merge remote-tracking branch 'origin/main'

This commit is contained in:
gbuomprisco
2024-06-19 23:05:27 +08:00
12 changed files with 494 additions and 214 deletions

View File

@@ -9,6 +9,7 @@ import {
RetrieveCheckoutSessionSchema,
UpdateSubscriptionParamsSchema,
} from '../schema';
import { UpsertSubscriptionParams } from '../types';
export abstract class BillingStrategyProviderService {
abstract createBillingPortalSession(
@@ -65,4 +66,12 @@ export abstract class BillingStrategyProviderService {
interval: string;
amount: number;
}>;
abstract getSubscription(
subscriptionId: string,
): Promise<UpsertSubscriptionParams & {
// we can't always guarantee that the target account id will be present
// so we need to make it optional and let the consumer handle it
target_account_id: string | undefined;
}>;
}

View File

@@ -43,7 +43,7 @@ export abstract class BillingWebhookHandlerService {
onPaymentFailed: (sessionId: string) => Promise<unknown>;
// generic handler for any event
onEvent?: <Data>(data: Data) => Promise<unknown>;
onEvent?: (data: unknown) => Promise<unknown>;
},
): Promise<unknown>;
}

View File

@@ -1,7 +1,22 @@
import { Database } from '@kit/supabase/database';
export type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
Database['public']['Functions']['upsert_subscription']['Args'] & {
line_items: Array<LineItem>;
};
interface LineItem {
id: string;
quantity: number;
subscription_id: string;
subscription_item_id: string;
product_id: string;
variant_id: string;
price_amount: number | null | undefined;
interval: string;
interval_count: number;
type: 'flat' | 'metered' | 'per_seat' | undefined;
}
export type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];

View File

@@ -26,7 +26,7 @@ interface CustomHandlersParams {
) => Promise<unknown>;
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
onPaymentFailed: (sessionId: string) => Promise<unknown>;
onEvent?: <Data>(data: Data) => Promise<unknown>;
onEvent(event: unknown): Promise<unknown>;
}
/**

View File

@@ -127,7 +127,17 @@ class BillingGatewayService {
return strategy.updateSubscriptionItem(payload);
}
getStrategy() {
/**
* Retrieves a subscription from the provider.
* @param subscriptionId
*/
async getSubscription(subscriptionId: string) {
const strategy = await this.getStrategy();
return strategy.getSubscription(subscriptionId);
}
private getStrategy() {
return BillingGatewayFactoryService.GetProviderStrategy(this.provider);
}
}

View File

@@ -4,6 +4,7 @@ import {
cancelSubscription,
createUsageRecord,
getCheckout,
getSubscription,
getVariant,
listUsageRecords,
updateSubscriptionItem,
@@ -24,6 +25,7 @@ import { getLogger } from '@kit/shared/logger';
import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-billing-portal-session';
import { createLemonSqueezyCheckout } from './create-lemon-squeezy-checkout';
import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service';
export class LemonSqueezyBillingStrategyService
implements BillingStrategyProviderService
@@ -340,6 +342,91 @@ export class LemonSqueezyBillingStrategyService
return { success: true };
}
async getSubscription(subscriptionId: string) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionId,
};
logger.info(ctx, 'Retrieving subscription...');
const { error, data } = await getSubscription(subscriptionId);
if (error) {
logger.error(
{
...ctx,
error,
},
'Failed to retrieve subscription',
);
throw new Error('Failed to retrieve subscription');
}
if (!data) {
logger.error(
{
...ctx,
},
'Subscription not found',
);
throw new Error('Subscription not found');
}
logger.info(ctx, 'Subscription retrieved successfully');
const payloadBuilderService =
createLemonSqueezySubscriptionPayloadBuilderService();
const subscription = data.data.attributes;
const customerId = subscription.customer_id.toString();
const status = subscription.status;
const variantId = subscription.variant_id;
const productId = subscription.product_id;
const createdAt = subscription.created_at;
const endsAt = subscription.ends_at;
const renewsAt = subscription.renews_at;
const trialEndsAt = subscription.trial_ends_at;
const intervalCount = subscription.billing_anchor;
const interval = intervalCount === 1 ? 'month' : 'year';
const subscriptionItemId =
data.data.attributes.first_subscription_item?.id.toString() as string;
const lineItems = [
{
id: subscriptionItemId.toString(),
product: productId.toString(),
variant: variantId.toString(),
quantity: subscription.first_subscription_item?.quantity ?? 1,
// not anywhere in the API
priceAmount: 0,
},
];
return payloadBuilderService.build({
customerId,
id: subscriptionId,
// not in the API
accountId: '',
lineItems,
status,
interval,
intervalCount,
// not in the API
currency: '',
periodStartsAt: new Date(createdAt).getTime(),
periodEndsAt: new Date(renewsAt ?? endsAt).getTime(),
cancelAtPeriodEnd: subscription.cancelled,
trialStartsAt: trialEndsAt ? new Date(createdAt).getTime() : null,
trialEndsAt: trialEndsAt ? new Date(trialEndsAt).getTime() : null,
});
}
/**
* @name queryUsage
* @description Queries the usage of the metered billing

View File

@@ -0,0 +1,141 @@
import { BillingConfig, getLineItemTypeById } from '@kit/billing';
import { UpsertSubscriptionParams } from '@kit/billing/types';
type SubscriptionStatus =
| 'on_trial'
| 'active'
| 'cancelled'
| 'paused'
| 'expired'
| 'unpaid'
| 'past_due';
/**
* @name createLemonSqueezySubscriptionPayloadBuilderService
* @description Create a new instance of the `LemonSqueezySubscriptionPayloadBuilderService` class
*/
export function createLemonSqueezySubscriptionPayloadBuilderService() {
return new LemonSqueezySubscriptionPayloadBuilderService();
}
/**
* @name LemonSqueezySubscriptionPayloadBuilderService
* @description This class is used to build the subscription payload for Lemon Squeezy
*/
class LemonSqueezySubscriptionPayloadBuilderService {
private config?: BillingConfig;
/**
* @name withBillingConfig
* @description Set the billing config for the subscription payload
* @param config
*/
withBillingConfig(config: BillingConfig) {
this.config = config;
return this;
}
/**
* @name build
* @description Build the subscription payload for Lemon Squeezy
* @param params
*/
build<
LineItem extends {
id: string;
quantity: number;
product: string;
variant: string;
priceAmount: number;
},
>(params: {
id: string;
accountId: string;
customerId: string;
lineItems: LineItem[];
interval: string;
intervalCount: number;
status: string;
currency: string;
cancelAtPeriodEnd: boolean;
periodStartsAt: number;
periodEndsAt: number;
trialStartsAt: number | null;
trialEndsAt: number | null;
}): UpsertSubscriptionParams {
const canceledAtPeriodEnd =
params.status === 'cancelled' && params.cancelAtPeriodEnd;
const active =
params.status === 'active' ||
params.status === 'trialing' ||
canceledAtPeriodEnd;
const lineItems = params.lineItems.map((item) => {
const quantity = item.quantity ?? 1;
return {
id: item.id,
subscription_item_id: item.id,
quantity,
interval: params.interval,
interval_count: params.intervalCount,
subscription_id: params.id,
product_id: item.product,
variant_id: item.variant,
price_amount: item.priceAmount,
type: this.config
? getLineItemTypeById(this.config, item.variant)
: undefined,
};
});
// otherwise we are updating a subscription
// and we only need to return the update payload
return {
target_subscription_id: params.id,
target_account_id: params.accountId,
target_customer_id: params.customerId,
billing_provider: 'lemon-squeezy',
status: this.getSubscriptionStatus(params.status as SubscriptionStatus),
line_items: lineItems,
active,
currency: params.currency,
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: params.trialStartsAt
? getISOString(params.trialStartsAt)
: undefined,
trial_ends_at: params.trialEndsAt
? getISOString(params.trialEndsAt)
: undefined,
};
}
private getSubscriptionStatus(status: SubscriptionStatus) {
switch (status) {
case 'active':
return 'active';
case 'cancelled':
return 'canceled';
case 'paused':
return 'paused';
case 'on_trial':
return 'trialing';
case 'past_due':
return 'past_due';
case 'unpaid':
return 'unpaid';
case 'expired':
return 'past_due';
default:
return 'active';
}
}
}
function getISOString(date: number | null) {
return date ? new Date(date).toISOString() : undefined;
}

View File

@@ -1,10 +1,6 @@
import { getOrder, getVariant } from '@lemonsqueezy/lemonsqueezy.js';
import {
BillingConfig,
BillingWebhookHandlerService,
getLineItemTypeById,
} from '@kit/billing';
import { BillingConfig, BillingWebhookHandlerService } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -12,24 +8,31 @@ import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
import { OrderWebhook } from '../types/order-webhook';
import { SubscriptionWebhook } from '../types/subscription-webhook';
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service';
import { createHmac } from './verify-hmac';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
Database['public']['Functions']['upsert_subscription']['Args'] & {
line_items: Array<LineItem>;
};
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
type OrderStatus = 'pending' | 'failed' | 'paid' | 'refunded';
interface LineItem {
id: string;
quantity: number;
subscription_id: string;
subscription_item_id: string;
product_id: string;
variant_id: string;
price_amount: number | null | undefined;
interval: string;
interval_count: number;
type: 'flat' | 'metered' | 'per_seat' | undefined;
}
type SubscriptionStatus =
| 'on_trial'
| 'active'
| 'cancelled'
| 'paused'
| 'expired'
| 'unpaid'
| 'past_due';
type OrderStatus = 'pending' | 'failed' | 'paid' | 'refunded';
export class LemonSqueezyWebhookHandlerService
implements BillingWebhookHandlerService
@@ -252,7 +255,10 @@ export class LemonSqueezyWebhookHandlerService
const interval = intervalCount === 1 ? 'month' : 'year';
const payload = this.buildSubscriptionPayload({
const payloadBuilderService =
createLemonSqueezySubscriptionPayloadBuilderService();
const payload = payloadBuilderService.withBillingConfig(this.config).build({
customerId,
id: subscriptionId,
accountId,
@@ -292,76 +298,6 @@ export class LemonSqueezyWebhookHandlerService
return onSubscriptionDeletedCallback(subscription.data.id);
}
private buildSubscriptionPayload<
LineItem extends {
id: string;
quantity: number;
product: string;
variant: string;
priceAmount: number;
},
>(params: {
id: string;
accountId: string;
customerId: string;
lineItems: LineItem[];
interval: string;
intervalCount: number;
status: string;
currency: string;
cancelAtPeriodEnd: boolean;
periodStartsAt: number;
periodEndsAt: number;
trialStartsAt: number | null;
trialEndsAt: number | null;
}): UpsertSubscriptionParams {
const canceledAtPeriodEnd =
params.status === 'cancelled' && params.cancelAtPeriodEnd;
const active =
params.status === 'active' ||
params.status === 'trialing' ||
canceledAtPeriodEnd;
const lineItems = params.lineItems.map((item) => {
const quantity = item.quantity ?? 1;
return {
id: item.id,
quantity,
interval: params.interval,
interval_count: params.intervalCount,
subscription_id: params.id,
product_id: item.product,
variant_id: item.variant,
price_amount: item.priceAmount,
type: getLineItemTypeById(this.config, item.variant),
};
});
// otherwise we are updating a subscription
// and we only need to return the update payload
return {
target_subscription_id: params.id,
target_account_id: params.accountId,
target_customer_id: params.customerId,
billing_provider: this.provider,
status: this.getSubscriptionStatus(params.status as SubscriptionStatus),
line_items: lineItems,
active,
currency: params.currency,
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: params.trialStartsAt
? getISOString(params.trialStartsAt)
: undefined,
trial_ends_at: params.trialEndsAt
? getISOString(params.trialEndsAt)
: undefined,
};
}
private getOrderStatus(status: OrderStatus) {
switch (status) {
case 'paid':
@@ -376,31 +312,6 @@ export class LemonSqueezyWebhookHandlerService
return 'pending';
}
}
private getSubscriptionStatus(status: SubscriptionStatus) {
switch (status) {
case 'active':
return 'active';
case 'cancelled':
return 'canceled';
case 'paused':
return 'paused';
case 'on_trial':
return 'trialing';
case 'past_due':
return 'past_due';
case 'unpaid':
return 'unpaid';
case 'expired':
return 'past_due';
default:
return 'active';
}
}
}
function getISOString(date: number | null) {
return date ? new Date(date).toISOString() : undefined;
}
async function isSigningSecretValid(rawBody: string, signatureHeader: string) {

View File

@@ -18,6 +18,7 @@ import { getLogger } from '@kit/shared/logger';
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
import { createStripeCheckout } from './create-stripe-checkout';
import { createStripeClient } from './stripe-sdk';
import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service';
/**
* @name StripeBillingStrategyService
@@ -357,6 +358,50 @@ export class StripeBillingStrategyService
}
}
async getSubscription(subscriptionId: string) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
const ctx = {
name: this.namespace,
subscriptionId,
};
logger.info(ctx, 'Retrieving subscription...');
const subscriptionPayloadBuilder =
createStripeSubscriptionPayloadBuilderService();
try {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['line_items'],
});
logger.info(ctx, 'Subscription retrieved successfully');
const customer = subscription.customer as string;
const accountId = subscription.metadata?.accountId as string;
return subscriptionPayloadBuilder.build({
customerId: customer,
accountId,
id: subscription.id,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to retrieve subscription');
throw new Error('Failed to retrieve subscription');
}
}
private async stripeProvider(): Promise<Stripe> {
return createStripeClient();
}

View File

@@ -0,0 +1,100 @@
import Stripe from 'stripe';
import { BillingConfig, getLineItemTypeById } from '@kit/billing';
import { UpsertSubscriptionParams } from '@kit/billing/types';
/**
* @name createStripeSubscriptionPayloadBuilderService
* @description Create a new instance of the `StripeSubscriptionPayloadBuilderService` class
*/
export function createStripeSubscriptionPayloadBuilderService() {
return new StripeSubscriptionPayloadBuilderService();
}
/**
* @name StripeSubscriptionPayloadBuilderService
* @description This class is used to build the subscription payload for Stripe
*/
class StripeSubscriptionPayloadBuilderService {
private config?: BillingConfig;
/**
* @name withBillingConfig
* @description Set the billing config for the subscription payload
* @param config
*/
withBillingConfig(config: BillingConfig) {
this.config = config;
return this;
}
/**
* @name build
* @description Build the subscription payload for Stripe
* @param params
*/
build<
LineItem extends {
id: string;
quantity?: number;
price?: Stripe.Price;
},
>(params: {
id: string;
accountId: string;
customerId: string;
lineItems: LineItem[];
status: Stripe.Subscription.Status;
currency: string;
cancelAtPeriodEnd: boolean;
periodStartsAt: number;
periodEndsAt: number;
trialStartsAt: number | null;
trialEndsAt: number | null;
}): UpsertSubscriptionParams {
const active = params.status === 'active' || params.status === 'trialing';
const lineItems = params.lineItems.map((item) => {
const quantity = item.quantity ?? 1;
const variantId = item.price?.id as string;
return {
id: item.id,
quantity,
subscription_id: params.id,
subscription_item_id: item.id,
product_id: item.price?.product as string,
variant_id: variantId,
price_amount: item.price?.unit_amount,
interval: item.price?.recurring?.interval as string,
interval_count: item.price?.recurring?.interval_count as number,
type: this.config
? getLineItemTypeById(this.config, variantId)
: undefined,
};
});
// otherwise we are updating a subscription
// and we only need to return the update payload
return {
target_subscription_id: params.id,
target_account_id: params.accountId,
target_customer_id: params.customerId,
billing_provider: 'stripe',
status: params.status,
line_items: lineItems,
active,
currency: params.currency,
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: getISOString(params.trialStartsAt),
trial_ends_at: getISOString(params.trialEndsAt),
};
}
}
function getISOString(date: number | null) {
return date ? new Date(date * 1000).toISOString() : undefined;
}

View File

@@ -1,18 +1,30 @@
import Stripe from 'stripe';
import {
BillingConfig,
BillingWebhookHandlerService,
getLineItemTypeById,
} from '@kit/billing';
import { BillingConfig, BillingWebhookHandlerService } from '@kit/billing';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
import { createStripeClient } from './stripe-sdk';
import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
Database['public']['Functions']['upsert_subscription']['Args'] & {
line_items: Array<LineItem>;
};
interface LineItem {
id: string;
quantity: number;
subscription_id: string;
subscription_item_id: string;
product_id: string;
variant_id: string;
price_amount: number | null | undefined;
interval: string;
interval_count: number;
type: 'flat' | 'metered' | 'per_seat' | undefined;
}
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
@@ -149,22 +161,27 @@ export class StripeWebhookHandlerService
// if it's a subscription, we need to retrieve the subscription
// and build the payload for the subscription
if (isSubscription) {
const subscriptionPayloadBuilderService =
createStripeSubscriptionPayloadBuilderService();
const subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const payload = this.buildSubscriptionPayload({
accountId,
customerId,
id: subscription.id,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
const payload = subscriptionPayloadBuilderService
.withBillingConfig(this.config)
.build({
accountId,
customerId,
id: subscription.id,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
return onCheckoutCompletedCallback(payload);
} else {
@@ -237,19 +254,24 @@ export class StripeWebhookHandlerService
const subscriptionId = subscription.id;
const accountId = subscription.metadata.accountId as string;
const payload = this.buildSubscriptionPayload({
customerId: subscription.customer as string,
id: subscriptionId,
accountId,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
const subscriptionPayloadBuilderService =
createStripeSubscriptionPayloadBuilderService();
const payload = subscriptionPayloadBuilderService
.withBillingConfig(this.config)
.build({
customerId: subscription.customer as string,
id: subscriptionId,
accountId,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
return onSubscriptionUpdatedCallback(payload);
}
@@ -263,64 +285,6 @@ export class StripeWebhookHandlerService
return onSubscriptionDeletedCallback(event.data.object.id);
}
private buildSubscriptionPayload<
LineItem extends {
id: string;
quantity?: number;
price?: Stripe.Price;
},
>(params: {
id: string;
accountId: string;
customerId: string;
lineItems: LineItem[];
status: Stripe.Subscription.Status;
currency: string;
cancelAtPeriodEnd: boolean;
periodStartsAt: number;
periodEndsAt: number;
trialStartsAt: number | null;
trialEndsAt: number | null;
}): UpsertSubscriptionParams {
const active = params.status === 'active' || params.status === 'trialing';
const lineItems = params.lineItems.map((item) => {
const quantity = item.quantity ?? 1;
const variantId = item.price?.id as string;
return {
id: item.id,
quantity,
subscription_id: params.id,
subscription_item_id: item.id,
product_id: item.price?.product as string,
variant_id: variantId,
price_amount: item.price?.unit_amount,
interval: item.price?.recurring?.interval as string,
interval_count: item.price?.recurring?.interval_count as number,
type: getLineItemTypeById(this.config, variantId),
};
});
// otherwise we are updating a subscription
// and we only need to return the update payload
return {
target_subscription_id: params.id,
target_account_id: params.accountId,
target_customer_id: params.customerId,
billing_provider: this.provider,
status: params.status,
line_items: lineItems,
active,
currency: params.currency,
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: getISOString(params.trialStartsAt),
trial_ends_at: getISOString(params.trialEndsAt),
};
}
private async loadStripe() {
if (!this.stripe) {
this.stripe = await createStripeClient();
@@ -329,7 +293,3 @@ export class StripeWebhookHandlerService
return this.stripe;
}
}
function getISOString(date: number | null) {
return date ? new Date(date * 1000).toISOString() : undefined;
}

View File

@@ -44,15 +44,14 @@ class AuthCallbackService {
url.port = '';
}
url.pathname = params.redirectPath;
const token_hash = searchParams.get('token_hash');
const type = searchParams.get('type') as EmailOtpType | null;
const callbackParam = searchParams.get('callback');
const next = callbackParam
? new URL(callbackParam).pathname
: params.redirectPath;
const callbackUrl = callbackParam ? new URL(callbackParam) : null;
const nextPath = callbackUrl ? callbackUrl.searchParams.get('next') : null;
const inviteToken = callbackUrl?.searchParams.get('invite_token');
const errorPath = params.errorPath ?? '/auth/callback/error';
@@ -62,7 +61,10 @@ class AuthCallbackService {
searchParams.delete('next');
searchParams.delete('callback');
url.pathname = next;
// if we have a next path, we redirect to that path
if (nextPath) {
url.pathname = nextPath;
}
// if we have an invite token, we append it to the redirect url
if (inviteToken) {