diff --git a/packages/billing/core/src/services/billing-strategy-provider.service.ts b/packages/billing/core/src/services/billing-strategy-provider.service.ts index a56c29a96..1529a174a 100644 --- a/packages/billing/core/src/services/billing-strategy-provider.service.ts +++ b/packages/billing/core/src/services/billing-strategy-provider.service.ts @@ -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; } diff --git a/packages/billing/core/src/services/billing-webhook-handler.service.ts b/packages/billing/core/src/services/billing-webhook-handler.service.ts index 7a3ec3172..60ffc0ea1 100644 --- a/packages/billing/core/src/services/billing-webhook-handler.service.ts +++ b/packages/billing/core/src/services/billing-webhook-handler.service.ts @@ -43,7 +43,7 @@ export abstract class BillingWebhookHandlerService { onPaymentFailed: (sessionId: string) => Promise; // generic handler for any event - onEvent?: (data: Data) => Promise; + onEvent?: (data: unknown) => Promise; }, ): Promise; } diff --git a/packages/billing/core/src/types/index.ts b/packages/billing/core/src/types/index.ts index cb67f1a80..f62f12819 100644 --- a/packages/billing/core/src/types/index.ts +++ b/packages/billing/core/src/types/index.ts @@ -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; + }; + +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']; \ No newline at end of file diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts index 4e4a99aaa..7da20387e 100644 --- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts +++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler.service.ts @@ -26,7 +26,7 @@ interface CustomHandlersParams { ) => Promise; onPaymentSucceeded: (sessionId: string) => Promise; onPaymentFailed: (sessionId: string) => Promise; - onEvent?: (data: Data) => Promise; + onEvent(event: unknown): Promise; } /** diff --git a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts index b02e1c041..8662f3b37 100644 --- a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts +++ b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts @@ -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); } } diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts index 8db513deb..ac55fb253 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts @@ -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 diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-subscription-payload-builder.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-subscription-payload-builder.service.ts new file mode 100644 index 000000000..cf10823f1 --- /dev/null +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-subscription-payload-builder.service.ts @@ -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; +} diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts index 472768109..5989a8740 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-webhook-handler.service.ts @@ -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; + }; 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) { diff --git a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts index 7fb8781be..582a1f4bc 100644 --- a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts +++ b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts @@ -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 { return createStripeClient(); } diff --git a/packages/billing/stripe/src/services/stripe-subscription-payload-builder.service.ts b/packages/billing/stripe/src/services/stripe-subscription-payload-builder.service.ts new file mode 100644 index 000000000..5d68789cc --- /dev/null +++ b/packages/billing/stripe/src/services/stripe-subscription-payload-builder.service.ts @@ -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; +} diff --git a/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts b/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts index 4690b46e4..0f8ff66e9 100644 --- a/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts +++ b/packages/billing/stripe/src/services/stripe-webhook-handler.service.ts @@ -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; + }; + +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; -} diff --git a/packages/supabase/src/auth-callback.service.ts b/packages/supabase/src/auth-callback.service.ts index c9f336d6c..d0b6e59bc 100644 --- a/packages/supabase/src/auth-callback.service.ts +++ b/packages/supabase/src/auth-callback.service.ts @@ -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) {