Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Listen to payment webhooks #623

Merged
merged 4 commits into from
Aug 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export class SubscriptionController extends BaseController {
const { tenantId } = req;

try {
await this.subscriptionApp.cancelSubscription(tenantId, '455610');
await this.subscriptionApp.cancelSubscription(tenantId);

return res.status(200).send({
status: 200,
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/api/controllers/Webhooks/Webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class Webhooks extends BaseController {
*/
public async lemonWebhooks(req: Request, res: Response, next: NextFunction) {
const data = req.body;
const signature = req.headers['x-signature'] ?? '';
const signature = req.headers['x-signature'] as string ?? '';
const rawBody = req.rawBody;

try {
Expand Down
8 changes: 8 additions & 0 deletions packages/server/src/interfaces/Subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface SubscriptionPayload {
lemonSqueezyId?: string;
}

export enum SubscriptionPaymentStatus {
Succeed = 'succeed',
Failed = 'failed',
}
1 change: 1 addition & 0 deletions packages/server/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export * from './Times';
export * from './ProjectProfitabilitySummary';
export * from './TaxRate';
export * from './Plaid';
export * from './Subscription';

export interface I18nService {
__: (input: string) => string;
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/loaders/eventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAcco
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting';
import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData';
import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange';

export default () => {
return new EventPublisher();
Expand Down Expand Up @@ -247,8 +248,10 @@ export const susbcribers = () => {
DeleteCashflowTransactionOnUncategorize,
PreventDeleteTransactionOnDelete,

// Subscription
SubscribeFreeOnSignupCommunity,
SendVerfiyMailOnSignUp,
TriggerInvalidateCacheOnSubscriptionChange,

// Attachments
AttachmentsOnSaleInvoiceCreated,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PlanSubscription } from '@/system/models';
import { ServiceError } from '@/exceptions';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { ERRORS, IOrganizationSubscriptionCanceled } from './types';
import { ERRORS, IOrganizationSubscriptionCancelled } from './types';

@Service()
export class LemonCancelSubscription {
Expand All @@ -18,12 +18,15 @@ export class LemonCancelSubscription {
* @param {number} subscriptionId
* @returns {Promise<void>}
*/
public async cancelSubscription(tenantId: number) {
public async cancelSubscription(
tenantId: number,
subscriptionSlug: string = 'main'
) {
configureLemonSqueezy();

const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
slug: subscriptionSlug,
});
if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
Expand All @@ -35,13 +38,10 @@ export class LemonCancelSubscription {
if (cancelledSub.error) {
throw new Error(cancelledSub.error.message);
}
await PlanSubscription.query().findById(subscriptionId).patch({
canceledAt: new Date(),
});
// Triggers `onSubscriptionCanceled` event.
// Triggers `onSubscriptionCancelled` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionCanceled,
{ tenantId, subscriptionId } as IOrganizationSubscriptionCanceled
events.subscription.onSubscriptionCancel,
{ tenantId, subscriptionId } as IOrganizationSubscriptionCancelled
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,30 @@ export class LemonChangeSubscriptionPlan {
* @param {number} newVariantId - New variant id.
* @returns {Promise<void>}
*/
public async changeSubscriptionPlan(tenantId: number, newVariantId: number) {
public async changeSubscriptionPlan(
tenantId: number,
newVariantId: number,
subscriptionSlug: string = 'main'
) {
configureLemonSqueezy();

const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
slug: subscriptionSlug,
});
const lemonSubscriptionId = subscription.lemonSubscriptionId;

// Send request to Lemon Squeezy to change the subscription.
const updatedSub = await updateSubscription(lemonSubscriptionId, {
variantId: newVariantId,
invoiceImmediately: true,
});
if (updatedSub.error) {
throw new ServiceError('SOMETHING_WENT_WRONG');
}
// Triggers `onSubscriptionPlanChanged` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionPlanChanged,
events.subscription.onSubscriptionPlanChange,
{
tenantId,
lemonSubscriptionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ export class LemonResumeSubscription {

/**
* Resumes the main subscription of the given tenant.
* @param {number} tenantId -
* @param {number} tenantId - Tenant id.
* @param {string} subscriptionSlug - Subscription slug by default main subscription.
* @returns {Promise<void>}
*/
public async resumeSubscription(tenantId: number) {
public async resumeSubscription(tenantId: number, subscriptionSlug: string = 'main') {
configureLemonSqueezy();

const subscription = await PlanSubscription.query().findOne({
tenantId,
slug: 'main',
slug: subscriptionSlug,
});
if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
Expand All @@ -33,15 +34,11 @@ export class LemonResumeSubscription {
cancelled: false,
});
if (returnedSub.error) {
throw new ServiceError('');
throw new ServiceError(ٌٌُERRORS.SOMETHING_WENT_WRONG_WITH_LS);
}
// Update the subscription of the organization.
await PlanSubscription.query().findById(subscriptionId).patch({
canceledAt: null,
});
// Triggers `onSubscriptionCanceled` event.
// Triggers `onSubscriptionResume` event.
await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionResumed,
events.subscription.onSubscriptionResume,
{ tenantId, subscriptionId } as IOrganizationSubscriptionResumed
);
}
Expand Down
53 changes: 45 additions & 8 deletions packages/server/src/services/Subscription/LemonSqueezyWebhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,25 @@ export class LemonSqueezyWebhooks {

const userId = eventBody.meta.custom_data?.user_id;
const tenantId = eventBody.meta.custom_data?.tenant_id;
const subscriptionSlug = 'main';

if (!webhookHasMeta(eventBody)) {
throw new Error("Event body is missing the 'meta' property.");
} else if (webhookHasData(eventBody)) {
if (webhookEvent.startsWith('subscription_payment_')) {
// Marks the main subscription payment as succeed.
if (webhookEvent === 'subscription_payment_success') {
await this.subscriptionService.markSubscriptionPaymentSucceed(
tenantId,
subscriptionSlug
);
// Marks the main subscription payment as failed.
} else if (webhookEvent === 'subscription_payment_failed') {
await this.subscriptionService.markSubscriptionPaymentFailed(
tenantId,
subscriptionSlug
);
}
// Save subscription invoices; eventBody is a SubscriptionInvoice
// Not implemented.
} else if (webhookEvent.startsWith('subscription_')) {
Expand All @@ -74,16 +88,39 @@ export class LemonSqueezyWebhooks {
// We assume that the Plan table is up to date.
const plan = await Plan.query().findOne('lemonVariantId', variantId);

// Update the subscription in the database.
const priceId = attributes.first_subscription_item.price_id;
const subscriptionId = eventBody.data.id;

// Throw error early if the given lemon variant id is not associated to any plan.
if (!plan) {
throw new Error(`Plan with variantId ${variantId} not found.`);
} else {
// Update the subscription in the database.
const priceId = attributes.first_subscription_item.price_id;

// Create a new subscription of the tenant.
if (webhookEvent === 'subscription_created') {
await this.subscriptionService.newSubscribtion(tenantId, plan.slug);
}
}
// Create a new subscription of the tenant.
if (webhookEvent === 'subscription_created') {
await this.subscriptionService.newSubscribtion(
tenantId,
plan.slug,
subscriptionSlug,
{ lemonSqueezyId: subscriptionId }
);
// Cancel the given subscription of the organization.
} else if (webhookEvent === 'subscription_cancelled') {
await this.subscriptionService.cancelSubscription(
tenantId,
subscriptionSlug
);
} else if (webhookEvent === 'subscription_plan_changed') {
await this.subscriptionService.subscriptionPlanChanged(
tenantId,
plan.slug,
subscriptionSlug
);
} else if (webhookEvent === 'subscription_resumed') {
await this.subscriptionService.resumeSubscription(
tenantId,
subscriptionSlug
);
}
} else if (webhookEvent.startsWith('order_')) {
// Save orders; eventBody is a "Order"
Expand Down
Loading
Loading