Billing get subscription enhancement (#36)
* Filter out metered line items from billing schema This update refines the process of creating a billing schema by filtering out metered line items. The change is necessary as metered line items can be shared across different plans, potentially causing conflicts or duplicates in the schema. * Update packages to newer versions This update upgrades several packages across multiple project files to their latest version. These packages include "supabase-js", "react-query", "react-hook-form", and "pnpm". The commit ensures the project is up-to-date with recent package versions, potentially benefiting from bug fixes, new features, and performance improvements. * Add subscription retrieval in billing services Added a function to retrieve subscription info in both Stripe and LemonSqueezy billing services. To accomplish this, new methods were added to related services and types. This allows querying specific subscription data based on its id, and throws an error if it fails. Furthermore, PayloadBuilder classes were created to systematically build the subscription payload. * Remove account ID retrieval from Lemon Squeezy billing service The code that was querying the database to fetch the accountId has been removed from lemon-squeezy-billing-strategy.service.ts. It was unnecessary as the Lemon Squeezy API does not provide account ID and therefore it is always left empty. Also, adjustments have been made in billing-strategy-provider.service.ts to reflect that the target account ID can be optional. * Extract 'next' parameter from callback URL The update allows for the extraction of the 'next' parameter from the callback URL. If such a parameter is available, it is subsequently added to the search parameters. The enhancement improves URL parameter handling in the authentication callback service. * Refactor URL redirection in auth-callback service The update simplifies the redirection logic in the authentication callback service. This is achieved by setting the url pathname directly to the redirect path, instead of first setting it to the callback parameter. Moreover, the code handling the 'next' path has also been streamlined, setting the url pathname to the next path when available.
This commit is contained in:
committed by
GitHub
parent
fbe7ca4c9e
commit
6a339a4b02
@@ -54,9 +54,9 @@
|
||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.3",
|
||||
"@marsidev/react-turnstile": "^0.7.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@tanstack/react-query": "5.45.0",
|
||||
"@tanstack/react-query-next-experimental": "^5.45.0",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"@tanstack/react-query-next-experimental": "^5.45.1",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.395.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"next-themes": "0.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"recharts": "^2.12.7",
|
||||
"sonner": "^1.5.0",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@manypkg/cli": "^0.21.4",
|
||||
"@turbo/gen": "^2.0.4",
|
||||
"cross-env": "^7.0.3",
|
||||
"pnpm": "^9.3.0",
|
||||
"pnpm": "^9.4.0",
|
||||
"prettier": "^3.3.2",
|
||||
"turbo": "2.0.4",
|
||||
"typescript": "^5.4.5",
|
||||
|
||||
@@ -173,7 +173,12 @@ export const PlanSchema = z
|
||||
)
|
||||
.refine(
|
||||
(item) => {
|
||||
const ids = item.lineItems.map((item) => item.id);
|
||||
// metered line items can be shared across plans
|
||||
const lineItems = item.lineItems.filter(
|
||||
(item) => item.type !== LineItemType.Metered,
|
||||
);
|
||||
|
||||
const ids = lineItems.map((item) => item.id);
|
||||
|
||||
return ids.length === new Set(ids).size;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
@@ -27,13 +27,13 @@
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:^",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.395.0",
|
||||
"next": "14.2.4",
|
||||
"react": "18.3.1",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
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) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^2.7.1",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
"stripe": "^15.11.0"
|
||||
"stripe": "^15.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/billing": "workspace:^",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"./route-handler": "./src/keystatic-route-handler.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@keystatic/core": "0.5.19",
|
||||
"@keystatic/core": "0.5.20",
|
||||
"@keystatic/next": "^5.0.1",
|
||||
"@markdoc/markdoc": "^0.4.0"
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/team-accounts": "workspace:^",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:^",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@tanstack/react-query": "5.45.0",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"lucide-react": "^0.395.0",
|
||||
@@ -42,7 +42,7 @@
|
||||
"next-themes": "0.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"sonner": "^1.5.0",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
"@kit/ui": "workspace:^",
|
||||
"@makerkit/data-loader-supabase-core": "^0.0.8",
|
||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.3",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@tanstack/react-query": "5.45.0",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@types/react": "^18.3.3",
|
||||
"lucide-react": "^0.395.0",
|
||||
"next": "14.2.4",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
"@kit/ui": "workspace:^",
|
||||
"@marsidev/react-turnstile": "^0.7.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@tanstack/react-query": "5.45.0",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"lucide-react": "^0.395.0",
|
||||
"next": "14.2.4",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"sonner": "^1.5.0",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@tanstack/react-query": "5.45.0",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"lucide-react": "^0.395.0",
|
||||
"react": "18.3.1",
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:^",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@tanstack/react-query": "5.45.0",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
@@ -42,7 +42,7 @@
|
||||
"next": "14.2.4",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"sonner": "^1.5.0",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@kit/shared": "workspace:^",
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@tanstack/react-query": "5.45.0",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"react-i18next": "^14.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@kit/supabase": "workspace:^",
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"next": "14.2.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
|
||||
@@ -26,10 +26,10 @@
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/gotrue-js": "2.62.2",
|
||||
"@supabase/gotrue-js": "2.64.3",
|
||||
"@supabase/ssr": "^0.3.0",
|
||||
"@supabase/supabase-js": "^2.43.4",
|
||||
"@tanstack/react-query": "5.45.0",
|
||||
"@supabase/supabase-js": "^2.43.5",
|
||||
"@tanstack/react-query": "5.45.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"next": "14.2.4",
|
||||
"react": "18.3.1",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"next-themes": "0.3.0",
|
||||
"prettier": "^3.3.2",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-hook-form": "^7.51.5",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwindcss": "3.4.4",
|
||||
|
||||
417
pnpm-lock.yaml
generated
417
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.2.1",
|
||||
"next": "14.2.4",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.4"
|
||||
"prettier-plugin-tailwindcss": "^0.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/tsconfig": "workspace:^",
|
||||
|
||||
Reference in New Issue
Block a user