Refactor billing system and enhance logging
Updated the billing system's schema to change 'storeId' to a string type, and improved the cleanliness and readability of the code. Enhanced the logging system within the billing service for better tracking and debugging. In line with these changes, added corresponding error pages in the client side to handle any errors.
This commit is contained in:
@@ -6,8 +6,32 @@ Please add the following environment variables to your `.env.local` file during
|
||||
|
||||
```env
|
||||
LEMON_SQUEEZY_SECRET_KEY=
|
||||
LEMON_SQUEEZY_WEBHOOK_SECRET=
|
||||
LEMON_SQUEEZY_SIGNING_SECRET=
|
||||
LEMON_SQUEEZY_STORE_ID=
|
||||
```
|
||||
|
||||
Add the variables to your production environment as well using your CI.
|
||||
Add the variables to your production environment as well using your CI.
|
||||
|
||||
### Webhooks
|
||||
|
||||
When testing locally, you are required to set up a proxy to your own local server, so you can receive the webhooks from Lemon Squeezy. You can use [ngrok](https://ngrok.com/) for this purpose, or any other similar service (LocalTunnel, Cloudflare Tunnel, Localcan, etc).
|
||||
|
||||
Once you have the proxy running, you can add the URL to your Lemon Squeezy account developer account as the Webhooks URL.
|
||||
|
||||
Please set your app configuration URL to the following:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SITE_URL=https://<your-proxy-url>
|
||||
```
|
||||
|
||||
Replace `<your-proxy-url>` with the URL provided by the proxy service.
|
||||
|
||||
#### Webhook Events
|
||||
|
||||
You must point the webhook to the `/api/billing/webhook` endpoint in your local server.
|
||||
|
||||
Please subscribe to the following events:
|
||||
- `order_created`
|
||||
- `subscription_created`
|
||||
- `subscription_updated`
|
||||
- `subscription_expired`
|
||||
@@ -5,10 +5,10 @@ export const getLemonSqueezyEnv = () =>
|
||||
.object({
|
||||
secretKey: z.string().min(1),
|
||||
webhooksSecret: z.string().min(1),
|
||||
storeId: z.number().positive(),
|
||||
storeId: z.string(),
|
||||
})
|
||||
.parse({
|
||||
secretKey: process.env.LEMON_SQUEEZY_SECRET_KEY,
|
||||
webhooksSecret: process.env.LEMON_SQUEEZY_WEBHOOK_SECRET,
|
||||
webhooksSecret: process.env.LEMON_SQUEEZY_SIGNING_SECRET,
|
||||
storeId: process.env.LEMON_SQUEEZY_STORE_ID,
|
||||
});
|
||||
|
||||
@@ -9,19 +9,16 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||
* Creates a LemonSqueezy billing portal session for the given parameters.
|
||||
*
|
||||
* @param {object} params - The parameters required to create the billing portal session.
|
||||
* @return {Promise<string>} - A promise that resolves to the URL of the customer portal.
|
||||
* @throws {Error} - If no customer is found with the given customerId.
|
||||
*/
|
||||
export async function createLemonSqueezyBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
await initializeLemonSqueezyClient();
|
||||
|
||||
const customer = await getCustomer(params.customerId);
|
||||
const { data, error } = await getCustomer(params.customerId);
|
||||
|
||||
if (!customer?.data) {
|
||||
throw new Error('No customer found');
|
||||
}
|
||||
|
||||
return customer.data.data.attributes.urls.customer_portal;
|
||||
return {
|
||||
data: data?.data.attributes.urls.customer_portal,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ReportBillingUsageSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '@kit/billing/schema';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
|
||||
import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-billing-portal-session';
|
||||
import { createLemonSqueezyCheckout } from './create-lemon-squeezy-checkout';
|
||||
@@ -24,66 +25,206 @@ export class LemonSqueezyBillingStrategyService
|
||||
async createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const { data: response } = await createLemonSqueezyCheckout(params);
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
customerId: params.customerId,
|
||||
accountId: params.accountId,
|
||||
returnUrl: params.returnUrl,
|
||||
trialDays: params.trialDays,
|
||||
planId: params.plan.id,
|
||||
},
|
||||
'Creating checkout session...',
|
||||
);
|
||||
|
||||
const { data: response, error } = await createLemonSqueezyCheckout(params);
|
||||
|
||||
if (error ?? !response?.data.id) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
customerId: params.customerId,
|
||||
accountId: params.accountId,
|
||||
error: error?.message,
|
||||
},
|
||||
'Failed to create checkout session',
|
||||
);
|
||||
|
||||
if (!response?.data.id) {
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
customerId: params.customerId,
|
||||
accountId: params.accountId,
|
||||
},
|
||||
'Checkout session created successfully',
|
||||
);
|
||||
|
||||
return { checkoutToken: response.data.id };
|
||||
}
|
||||
|
||||
async createBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const url = await createLemonSqueezyBillingPortalSession(params);
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
customerId: params.customerId,
|
||||
},
|
||||
'Creating billing portal session...',
|
||||
);
|
||||
|
||||
const { data, error } =
|
||||
await createLemonSqueezyBillingPortalSession(params);
|
||||
|
||||
if (error ?? !data) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
customerId: params.customerId,
|
||||
error: error?.message,
|
||||
},
|
||||
'Failed to create billing portal session',
|
||||
);
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Failed to create billing portal session');
|
||||
}
|
||||
|
||||
return { url };
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
customerId: params.customerId,
|
||||
},
|
||||
'Billing portal session created successfully',
|
||||
);
|
||||
|
||||
return { url: data };
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
await cancelSubscription(params.subscriptionId);
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
subscriptionId: params.subscriptionId,
|
||||
},
|
||||
'Cancelling subscription...',
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
try {
|
||||
const { error } = await cancelSubscription(params.subscriptionId);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
subscriptionId: params.subscriptionId,
|
||||
},
|
||||
'Subscription cancelled successfully',
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
subscriptionId: params.subscriptionId,
|
||||
error: (error as Error)?.message,
|
||||
},
|
||||
'Failed to cancel subscription',
|
||||
);
|
||||
|
||||
throw new Error('Failed to cancel subscription');
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveCheckoutSession(
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
) {
|
||||
const session = await getCheckout(params.sessionId);
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
sessionId: params.sessionId,
|
||||
},
|
||||
'Retrieving checkout session...',
|
||||
);
|
||||
|
||||
const { data: session, error } = await getCheckout(params.sessionId);
|
||||
|
||||
if (error ?? !session?.data) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
sessionId: params.sessionId,
|
||||
error: error?.message,
|
||||
},
|
||||
'Failed to retrieve checkout session',
|
||||
);
|
||||
|
||||
if (!session.data) {
|
||||
throw new Error('Failed to retrieve checkout session');
|
||||
}
|
||||
|
||||
const data = session.data.data;
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
sessionId: params.sessionId,
|
||||
},
|
||||
'Checkout session retrieved successfully',
|
||||
);
|
||||
|
||||
const { id, attributes } = session.data;
|
||||
|
||||
return {
|
||||
checkoutToken: data.id,
|
||||
checkoutToken: id,
|
||||
isSessionOpen: false,
|
||||
status: 'complete' as const,
|
||||
customer: {
|
||||
email: data.attributes.checkout_data.email,
|
||||
email: attributes.checkout_data.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
subscriptionItemId: params.subscriptionId,
|
||||
},
|
||||
'Reporting usage...',
|
||||
);
|
||||
|
||||
const { error } = await createUsageRecord({
|
||||
quantity: params.usage.quantity,
|
||||
subscriptionItemId: params.subscriptionId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
subscriptionItemId: params.subscriptionId,
|
||||
error: error.message,
|
||||
},
|
||||
'Failed to report usage',
|
||||
);
|
||||
|
||||
throw new Error('Failed to report usage');
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.lemon-squeezy',
|
||||
subscriptionItemId: params.subscriptionId,
|
||||
},
|
||||
'Usage reported successfully',
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function initializeLemonSqueezyClient() {
|
||||
name: `billing.lemon-squeezy`,
|
||||
error: error.message,
|
||||
},
|
||||
'Error in Lemon Squeezy SDK',
|
||||
'Encountered an error using the Lemon Squeezy SDK',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ReportBillingUsageSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '@kit/billing/schema';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
|
||||
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
|
||||
import { createStripeCheckout } from './create-stripe-checkout';
|
||||
@@ -23,12 +24,39 @@ export class StripeBillingStrategyService
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
customerId: params.customerId,
|
||||
accountId: params.accountId,
|
||||
},
|
||||
'Creating checkout session...',
|
||||
);
|
||||
|
||||
const { client_secret } = await createStripeCheckout(stripe, params);
|
||||
|
||||
if (!client_secret) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
customerId: params.customerId,
|
||||
accountId: params.accountId,
|
||||
},
|
||||
'Failed to create checkout session',
|
||||
);
|
||||
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
customerId: params.customerId,
|
||||
accountId: params.accountId,
|
||||
},
|
||||
'Checkout session created successfully',
|
||||
);
|
||||
|
||||
return { checkoutToken: client_secret };
|
||||
}
|
||||
|
||||
@@ -37,7 +65,35 @@ export class StripeBillingStrategyService
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
return createStripeBillingPortalSession(stripe, params);
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
customerId: params.customerId,
|
||||
},
|
||||
'Creating billing portal session...',
|
||||
);
|
||||
|
||||
const session = await createStripeBillingPortalSession(stripe, params);
|
||||
|
||||
if (!session?.url) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
customerId: params.customerId,
|
||||
},
|
||||
'Failed to create billing portal session',
|
||||
);
|
||||
} else {
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
customerId: params.customerId,
|
||||
},
|
||||
'Billing portal session created successfully',
|
||||
);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
@@ -45,11 +101,40 @@ export class StripeBillingStrategyService
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
await stripe.subscriptions.cancel(params.subscriptionId, {
|
||||
invoice_now: params.invoiceNow ?? true,
|
||||
});
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
subscriptionId: params.subscriptionId,
|
||||
},
|
||||
'Cancelling subscription...',
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
try {
|
||||
await stripe.subscriptions.cancel(params.subscriptionId, {
|
||||
invoice_now: params.invoiceNow ?? true,
|
||||
});
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
subscriptionId: params.subscriptionId,
|
||||
},
|
||||
'Subscription cancelled successfully',
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
subscriptionId: params.subscriptionId,
|
||||
error: e,
|
||||
},
|
||||
'Failed to cancel subscription',
|
||||
);
|
||||
|
||||
throw new Error('Failed to cancel subscription');
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveCheckoutSession(
|
||||
@@ -57,17 +142,46 @@ export class StripeBillingStrategyService
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
const session = await stripe.checkout.sessions.retrieve(params.sessionId);
|
||||
const isSessionOpen = session.status === 'open';
|
||||
|
||||
return {
|
||||
checkoutToken: session.client_secret,
|
||||
isSessionOpen,
|
||||
status: session.status ?? 'complete',
|
||||
customer: {
|
||||
email: session.customer_details?.email ?? null,
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
sessionId: params.sessionId,
|
||||
},
|
||||
};
|
||||
'Retrieving checkout session...',
|
||||
);
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.retrieve(params.sessionId);
|
||||
const isSessionOpen = session.status === 'open';
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
sessionId: params.sessionId,
|
||||
},
|
||||
'Checkout session retrieved successfully',
|
||||
);
|
||||
|
||||
return {
|
||||
checkoutToken: session.client_secret,
|
||||
isSessionOpen,
|
||||
status: session.status ?? 'complete',
|
||||
customer: {
|
||||
email: session.customer_details?.email ?? null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{
|
||||
name: 'billing.stripe',
|
||||
sessionId: params.sessionId,
|
||||
error,
|
||||
},
|
||||
'Failed to retrieve checkout session',
|
||||
);
|
||||
|
||||
throw new Error('Failed to retrieve checkout session');
|
||||
}
|
||||
}
|
||||
|
||||
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
|
||||
|
||||
@@ -13,15 +13,12 @@
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@kit/contentlayer": "workspace:*",
|
||||
"@kit/wordpress": "workspace:*"
|
||||
"@kit/contentlayer": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/contentlayer": "*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/wordpress": "*"
|
||||
"@kit/tsconfig": "workspace:*"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
|
||||
@@ -24,13 +24,13 @@ export async function createCmsClient(
|
||||
}
|
||||
|
||||
async function getContentLayerClient() {
|
||||
const { ContentlayerClient } = await import('@kit/contentlayer');
|
||||
const { ContentlayerClient } = await import('../../contentlayer');
|
||||
|
||||
return new ContentlayerClient();
|
||||
}
|
||||
|
||||
async function getWordpressClient() {
|
||||
const { WordpressClient } = await import('@kit/wordpress');
|
||||
const { WordpressClient } = await import('../../wordpress');
|
||||
|
||||
return new WordpressClient();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user