Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
RetrieveCheckoutSessionSchema,
|
RetrieveCheckoutSessionSchema,
|
||||||
UpdateSubscriptionParamsSchema,
|
UpdateSubscriptionParamsSchema,
|
||||||
} from '../schema';
|
} from '../schema';
|
||||||
|
import { UpsertSubscriptionParams } from '../types';
|
||||||
|
|
||||||
export abstract class BillingStrategyProviderService {
|
export abstract class BillingStrategyProviderService {
|
||||||
abstract createBillingPortalSession(
|
abstract createBillingPortalSession(
|
||||||
@@ -65,4 +66,12 @@ export abstract class BillingStrategyProviderService {
|
|||||||
interval: string;
|
interval: string;
|
||||||
amount: number;
|
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;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export abstract class BillingWebhookHandlerService {
|
|||||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||||
|
|
||||||
// generic handler for any event
|
// generic handler for any event
|
||||||
onEvent?: <Data>(data: Data) => Promise<unknown>;
|
onEvent?: (data: unknown) => Promise<unknown>;
|
||||||
},
|
},
|
||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
export type UpsertSubscriptionParams =
|
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 =
|
export type UpsertOrderParams =
|
||||||
Database['public']['Functions']['upsert_order']['Args'];
|
Database['public']['Functions']['upsert_order']['Args'];
|
||||||
@@ -26,7 +26,7 @@ interface CustomHandlersParams {
|
|||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||||
onEvent?: <Data>(data: Data) => Promise<unknown>;
|
onEvent(event: unknown): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -127,7 +127,17 @@ class BillingGatewayService {
|
|||||||
return strategy.updateSubscriptionItem(payload);
|
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);
|
return BillingGatewayFactoryService.GetProviderStrategy(this.provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
cancelSubscription,
|
cancelSubscription,
|
||||||
createUsageRecord,
|
createUsageRecord,
|
||||||
getCheckout,
|
getCheckout,
|
||||||
|
getSubscription,
|
||||||
getVariant,
|
getVariant,
|
||||||
listUsageRecords,
|
listUsageRecords,
|
||||||
updateSubscriptionItem,
|
updateSubscriptionItem,
|
||||||
@@ -24,6 +25,7 @@ import { getLogger } from '@kit/shared/logger';
|
|||||||
|
|
||||||
import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-billing-portal-session';
|
import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-billing-portal-session';
|
||||||
import { createLemonSqueezyCheckout } from './create-lemon-squeezy-checkout';
|
import { createLemonSqueezyCheckout } from './create-lemon-squeezy-checkout';
|
||||||
|
import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service';
|
||||||
|
|
||||||
export class LemonSqueezyBillingStrategyService
|
export class LemonSqueezyBillingStrategyService
|
||||||
implements BillingStrategyProviderService
|
implements BillingStrategyProviderService
|
||||||
@@ -340,6 +342,91 @@ export class LemonSqueezyBillingStrategyService
|
|||||||
return { success: true };
|
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
|
* @name queryUsage
|
||||||
* @description Queries the usage of the metered billing
|
* @description Queries the usage of the metered billing
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { getOrder, getVariant } from '@lemonsqueezy/lemonsqueezy.js';
|
import { getOrder, getVariant } from '@lemonsqueezy/lemonsqueezy.js';
|
||||||
|
|
||||||
import {
|
import { BillingConfig, BillingWebhookHandlerService } from '@kit/billing';
|
||||||
BillingConfig,
|
|
||||||
BillingWebhookHandlerService,
|
|
||||||
getLineItemTypeById,
|
|
||||||
} from '@kit/billing';
|
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { Database } from '@kit/supabase/database';
|
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 { OrderWebhook } from '../types/order-webhook';
|
||||||
import { SubscriptionWebhook } from '../types/subscription-webhook';
|
import { SubscriptionWebhook } from '../types/subscription-webhook';
|
||||||
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||||
|
import { createLemonSqueezySubscriptionPayloadBuilderService } from './lemon-squeezy-subscription-payload-builder.service';
|
||||||
import { createHmac } from './verify-hmac';
|
import { createHmac } from './verify-hmac';
|
||||||
|
|
||||||
type UpsertSubscriptionParams =
|
type UpsertSubscriptionParams =
|
||||||
Database['public']['Functions']['upsert_subscription']['Args'];
|
Database['public']['Functions']['upsert_subscription']['Args'] & {
|
||||||
|
line_items: Array<LineItem>;
|
||||||
|
};
|
||||||
|
|
||||||
type UpsertOrderParams =
|
type UpsertOrderParams =
|
||||||
Database['public']['Functions']['upsert_order']['Args'];
|
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 =
|
type OrderStatus = 'pending' | 'failed' | 'paid' | 'refunded';
|
||||||
| 'on_trial'
|
|
||||||
| 'active'
|
|
||||||
| 'cancelled'
|
|
||||||
| 'paused'
|
|
||||||
| 'expired'
|
|
||||||
| 'unpaid'
|
|
||||||
| 'past_due';
|
|
||||||
|
|
||||||
export class LemonSqueezyWebhookHandlerService
|
export class LemonSqueezyWebhookHandlerService
|
||||||
implements BillingWebhookHandlerService
|
implements BillingWebhookHandlerService
|
||||||
@@ -252,7 +255,10 @@ export class LemonSqueezyWebhookHandlerService
|
|||||||
|
|
||||||
const interval = intervalCount === 1 ? 'month' : 'year';
|
const interval = intervalCount === 1 ? 'month' : 'year';
|
||||||
|
|
||||||
const payload = this.buildSubscriptionPayload({
|
const payloadBuilderService =
|
||||||
|
createLemonSqueezySubscriptionPayloadBuilderService();
|
||||||
|
|
||||||
|
const payload = payloadBuilderService.withBillingConfig(this.config).build({
|
||||||
customerId,
|
customerId,
|
||||||
id: subscriptionId,
|
id: subscriptionId,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -292,76 +298,6 @@ export class LemonSqueezyWebhookHandlerService
|
|||||||
return onSubscriptionDeletedCallback(subscription.data.id);
|
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) {
|
private getOrderStatus(status: OrderStatus) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'paid':
|
case 'paid':
|
||||||
@@ -376,31 +312,6 @@ export class LemonSqueezyWebhookHandlerService
|
|||||||
return 'pending';
|
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) {
|
async function isSigningSecretValid(rawBody: string, signatureHeader: string) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { getLogger } from '@kit/shared/logger';
|
|||||||
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
|
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
|
||||||
import { createStripeCheckout } from './create-stripe-checkout';
|
import { createStripeCheckout } from './create-stripe-checkout';
|
||||||
import { createStripeClient } from './stripe-sdk';
|
import { createStripeClient } from './stripe-sdk';
|
||||||
|
import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name StripeBillingStrategyService
|
* @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> {
|
private async stripeProvider(): Promise<Stripe> {
|
||||||
return createStripeClient();
|
return createStripeClient();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,18 +1,30 @@
|
|||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
import {
|
import { BillingConfig, BillingWebhookHandlerService } from '@kit/billing';
|
||||||
BillingConfig,
|
|
||||||
BillingWebhookHandlerService,
|
|
||||||
getLineItemTypeById,
|
|
||||||
} from '@kit/billing';
|
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
|
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
|
||||||
import { createStripeClient } from './stripe-sdk';
|
import { createStripeClient } from './stripe-sdk';
|
||||||
|
import { createStripeSubscriptionPayloadBuilderService } from './stripe-subscription-payload-builder.service';
|
||||||
|
|
||||||
type UpsertSubscriptionParams =
|
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 =
|
type UpsertOrderParams =
|
||||||
Database['public']['Functions']['upsert_order']['Args'];
|
Database['public']['Functions']['upsert_order']['Args'];
|
||||||
@@ -149,22 +161,27 @@ export class StripeWebhookHandlerService
|
|||||||
// if it's a subscription, we need to retrieve the subscription
|
// if it's a subscription, we need to retrieve the subscription
|
||||||
// and build the payload for the subscription
|
// and build the payload for the subscription
|
||||||
if (isSubscription) {
|
if (isSubscription) {
|
||||||
|
const subscriptionPayloadBuilderService =
|
||||||
|
createStripeSubscriptionPayloadBuilderService();
|
||||||
|
|
||||||
const subscriptionId = session.subscription as string;
|
const subscriptionId = session.subscription as string;
|
||||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||||
|
|
||||||
const payload = this.buildSubscriptionPayload({
|
const payload = subscriptionPayloadBuilderService
|
||||||
accountId,
|
.withBillingConfig(this.config)
|
||||||
customerId,
|
.build({
|
||||||
id: subscription.id,
|
accountId,
|
||||||
lineItems: subscription.items.data,
|
customerId,
|
||||||
status: subscription.status,
|
id: subscription.id,
|
||||||
currency: subscription.currency,
|
lineItems: subscription.items.data,
|
||||||
periodStartsAt: subscription.current_period_start,
|
status: subscription.status,
|
||||||
periodEndsAt: subscription.current_period_end,
|
currency: subscription.currency,
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
periodStartsAt: subscription.current_period_start,
|
||||||
trialStartsAt: subscription.trial_start,
|
periodEndsAt: subscription.current_period_end,
|
||||||
trialEndsAt: subscription.trial_end,
|
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||||
});
|
trialStartsAt: subscription.trial_start,
|
||||||
|
trialEndsAt: subscription.trial_end,
|
||||||
|
});
|
||||||
|
|
||||||
return onCheckoutCompletedCallback(payload);
|
return onCheckoutCompletedCallback(payload);
|
||||||
} else {
|
} else {
|
||||||
@@ -237,19 +254,24 @@ export class StripeWebhookHandlerService
|
|||||||
const subscriptionId = subscription.id;
|
const subscriptionId = subscription.id;
|
||||||
const accountId = subscription.metadata.accountId as string;
|
const accountId = subscription.metadata.accountId as string;
|
||||||
|
|
||||||
const payload = this.buildSubscriptionPayload({
|
const subscriptionPayloadBuilderService =
|
||||||
customerId: subscription.customer as string,
|
createStripeSubscriptionPayloadBuilderService();
|
||||||
id: subscriptionId,
|
|
||||||
accountId,
|
const payload = subscriptionPayloadBuilderService
|
||||||
lineItems: subscription.items.data,
|
.withBillingConfig(this.config)
|
||||||
status: subscription.status,
|
.build({
|
||||||
currency: subscription.currency,
|
customerId: subscription.customer as string,
|
||||||
periodStartsAt: subscription.current_period_start,
|
id: subscriptionId,
|
||||||
periodEndsAt: subscription.current_period_end,
|
accountId,
|
||||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
lineItems: subscription.items.data,
|
||||||
trialStartsAt: subscription.trial_start,
|
status: subscription.status,
|
||||||
trialEndsAt: subscription.trial_end,
|
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);
|
return onSubscriptionUpdatedCallback(payload);
|
||||||
}
|
}
|
||||||
@@ -263,64 +285,6 @@ export class StripeWebhookHandlerService
|
|||||||
return onSubscriptionDeletedCallback(event.data.object.id);
|
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() {
|
private async loadStripe() {
|
||||||
if (!this.stripe) {
|
if (!this.stripe) {
|
||||||
this.stripe = await createStripeClient();
|
this.stripe = await createStripeClient();
|
||||||
@@ -329,7 +293,3 @@ export class StripeWebhookHandlerService
|
|||||||
return this.stripe;
|
return this.stripe;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getISOString(date: number | null) {
|
|
||||||
return date ? new Date(date * 1000).toISOString() : undefined;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,15 +44,14 @@ class AuthCallbackService {
|
|||||||
url.port = '';
|
url.port = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
url.pathname = params.redirectPath;
|
||||||
|
|
||||||
const token_hash = searchParams.get('token_hash');
|
const token_hash = searchParams.get('token_hash');
|
||||||
const type = searchParams.get('type') as EmailOtpType | null;
|
const type = searchParams.get('type') as EmailOtpType | null;
|
||||||
const callbackParam = searchParams.get('callback');
|
const callbackParam = searchParams.get('callback');
|
||||||
|
|
||||||
const next = callbackParam
|
|
||||||
? new URL(callbackParam).pathname
|
|
||||||
: params.redirectPath;
|
|
||||||
|
|
||||||
const callbackUrl = callbackParam ? new URL(callbackParam) : null;
|
const callbackUrl = callbackParam ? new URL(callbackParam) : null;
|
||||||
|
const nextPath = callbackUrl ? callbackUrl.searchParams.get('next') : null;
|
||||||
const inviteToken = callbackUrl?.searchParams.get('invite_token');
|
const inviteToken = callbackUrl?.searchParams.get('invite_token');
|
||||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||||
|
|
||||||
@@ -62,7 +61,10 @@ class AuthCallbackService {
|
|||||||
searchParams.delete('next');
|
searchParams.delete('next');
|
||||||
searchParams.delete('callback');
|
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 we have an invite token, we append it to the redirect url
|
||||||
if (inviteToken) {
|
if (inviteToken) {
|
||||||
|
|||||||
Reference in New Issue
Block a user