mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-21 15:20:34 +00:00
fix: Listen to payment webhooks
This commit is contained in:
@@ -35,7 +35,7 @@ export class Webhooks extends BaseController {
|
|||||||
*/
|
*/
|
||||||
public async lemonWebhooks(req: Request, res: Response, next: NextFunction) {
|
public async lemonWebhooks(req: Request, res: Response, next: NextFunction) {
|
||||||
const data = req.body;
|
const data = req.body;
|
||||||
const signature = req.headers['x-signature'] ?? '';
|
const signature = req.headers['x-signature'] as string ?? '';
|
||||||
const rawBody = req.rawBody;
|
const rawBody = req.rawBody;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
8
packages/server/src/interfaces/Subscription.ts
Normal file
8
packages/server/src/interfaces/Subscription.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface SubscriptionPayload {
|
||||||
|
lemonSqueezyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SubscriptionPaymentStatus {
|
||||||
|
Succeed = 'succeed',
|
||||||
|
Failed = 'failed',
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ export * from './Times';
|
|||||||
export * from './ProjectProfitabilitySummary';
|
export * from './ProjectProfitabilitySummary';
|
||||||
export * from './TaxRate';
|
export * from './TaxRate';
|
||||||
export * from './Plaid';
|
export * from './Plaid';
|
||||||
|
export * from './Subscription';
|
||||||
|
|
||||||
export interface I18nService {
|
export interface I18nService {
|
||||||
__: (input: string) => string;
|
__: (input: string) => string;
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ export class LemonSqueezyWebhooks {
|
|||||||
throw new Error("Event body is missing the 'meta' property.");
|
throw new Error("Event body is missing the 'meta' property.");
|
||||||
} else if (webhookHasData(eventBody)) {
|
} else if (webhookHasData(eventBody)) {
|
||||||
if (webhookEvent.startsWith('subscription_payment_')) {
|
if (webhookEvent.startsWith('subscription_payment_')) {
|
||||||
|
// Marks the main subscription payment as succeed.
|
||||||
|
if (webhookEvent === 'subscription_payment_success') {
|
||||||
|
await this.subscriptionService.markSubscriptionPaymentSucceed(
|
||||||
|
tenantId,
|
||||||
|
'main'
|
||||||
|
);
|
||||||
|
// Marks the main subscription payment as failed.
|
||||||
|
} else if (webhookEvent === 'subscription_payment_failed') {
|
||||||
|
await this.subscriptionService.markSubscriptionPaymentFailed(
|
||||||
|
tenantId,
|
||||||
|
'main'
|
||||||
|
);
|
||||||
|
}
|
||||||
// Save subscription invoices; eventBody is a SubscriptionInvoice
|
// Save subscription invoices; eventBody is a SubscriptionInvoice
|
||||||
// Not implemented.
|
// Not implemented.
|
||||||
} else if (webhookEvent.startsWith('subscription_')) {
|
} else if (webhookEvent.startsWith('subscription_')) {
|
||||||
@@ -74,16 +87,34 @@ export class LemonSqueezyWebhooks {
|
|||||||
// We assume that the Plan table is up to date.
|
// We assume that the Plan table is up to date.
|
||||||
const plan = await Plan.query().findOne('lemonVariantId', variantId);
|
const plan = await Plan.query().findOne('lemonVariantId', variantId);
|
||||||
|
|
||||||
if (!plan) {
|
|
||||||
throw new Error(`Plan with variantId ${variantId} not found.`);
|
|
||||||
} else {
|
|
||||||
// Update the subscription in the database.
|
// Update the subscription in the database.
|
||||||
const priceId = attributes.first_subscription_item.price_id;
|
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.`);
|
||||||
|
}
|
||||||
// Create a new subscription of the tenant.
|
// Create a new subscription of the tenant.
|
||||||
if (webhookEvent === 'subscription_created') {
|
if (webhookEvent === 'subscription_created') {
|
||||||
await this.subscriptionService.newSubscribtion(tenantId, plan.slug);
|
await this.subscriptionService.newSubscribtion(
|
||||||
}
|
tenantId,
|
||||||
|
plan.slug,
|
||||||
|
'main',
|
||||||
|
{ lemonSqueezyId: subscriptionId }
|
||||||
|
);
|
||||||
|
// Cancel the given subscription of the organization.
|
||||||
|
} else if (webhookEvent === 'subscription_cancelled') {
|
||||||
|
await this.subscriptionService.cancelSubscription(
|
||||||
|
tenantId,
|
||||||
|
plan.slug
|
||||||
|
);
|
||||||
|
} else if (webhookEvent === 'subscription_plan_changed') {
|
||||||
|
await this.subscriptionService.subscriptionPlanChanged(
|
||||||
|
tenantId,
|
||||||
|
plan.slug,
|
||||||
|
'main'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (webhookEvent.startsWith('order_')) {
|
} else if (webhookEvent.startsWith('order_')) {
|
||||||
// Save orders; eventBody is a "Order"
|
// Save orders; eventBody is a "Order"
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
import { Service } from 'typedi';
|
import { Inject, Service } from 'typedi';
|
||||||
import { NotAllowedChangeSubscriptionPlan } from '@/exceptions';
|
import { NotAllowedChangeSubscriptionPlan, ServiceError } from '@/exceptions';
|
||||||
import { Plan, Tenant } from '@/system/models';
|
import { Plan, PlanSubscription, Tenant } from '@/system/models';
|
||||||
|
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||||
|
import events from '@/subscribers/events';
|
||||||
|
import { SubscriptionPayload, SubscriptionPaymentStatus } from '@/interfaces';
|
||||||
|
import { ERRORS } from './types';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class Subscription {
|
export class Subscription {
|
||||||
|
@Inject()
|
||||||
|
private eventPublisher: EventPublisher;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Give the tenant a new subscription.
|
* Give the tenant a new subscription.
|
||||||
* @param {number} tenantId - Tenant id.
|
* @param {number} tenantId - Tenant id.
|
||||||
* @param {string} planSlug - Plan slug.
|
* @param {string} planSlug - Plan slug of the new subscription.
|
||||||
* @param {string} invoiceInterval
|
* @param {string} subscriptionSlug - Subscription slug by default takes main subscription
|
||||||
* @param {number} invoicePeriod
|
* @param {SubscriptionPayload} payload - Subscription payload.
|
||||||
* @param {string} subscriptionSlug
|
|
||||||
*/
|
*/
|
||||||
public async newSubscribtion(
|
public async newSubscribtion(
|
||||||
tenantId: number,
|
tenantId: number,
|
||||||
planSlug: string,
|
planSlug: string,
|
||||||
subscriptionSlug: string = 'main'
|
subscriptionSlug: string = 'main',
|
||||||
) {
|
payload?: SubscriptionPayload
|
||||||
|
): Promise<void> {
|
||||||
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||||
const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound();
|
const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound();
|
||||||
|
|
||||||
@@ -45,8 +52,127 @@ export class Subscription {
|
|||||||
plan.id,
|
plan.id,
|
||||||
invoiceInterval,
|
invoiceInterval,
|
||||||
invoicePeriod,
|
invoicePeriod,
|
||||||
subscriptionSlug
|
subscriptionSlug,
|
||||||
|
payload
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels the given tenant subscription.
|
||||||
|
* @param {number} tenantId - Tenant id.
|
||||||
|
* @param {string} subscriptionSlug - Subscription slug.
|
||||||
|
*/
|
||||||
|
async cancelSubscription(
|
||||||
|
tenantId: number,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
): Promise<void> {
|
||||||
|
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||||
|
|
||||||
|
const subscription = await PlanSubscription.query().findOne({
|
||||||
|
tenantId,
|
||||||
|
slug: subscriptionSlug,
|
||||||
|
});
|
||||||
|
// Throw error early if the subscription is not exist.
|
||||||
|
if (!subscription) {
|
||||||
|
throw new ServiceError(ERRORS.SUBSCRIPTION_NOT_EXIST);
|
||||||
|
}
|
||||||
|
// Throw error early if the subscription is already canceled.
|
||||||
|
if (subscription.canceled()) {
|
||||||
|
throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_CANCELED);
|
||||||
|
}
|
||||||
|
await subscription.$query().patch({ canceledAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the given subscription payment of the tenant as succeed.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {string} newPlanSlug
|
||||||
|
* @param {string} subscriptionSlug
|
||||||
|
*/
|
||||||
|
async subscriptionPlanChanged(
|
||||||
|
tenantId: number,
|
||||||
|
newPlanSlug: string,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
): Promise<void> {
|
||||||
|
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||||
|
const newPlan = await Plan.query()
|
||||||
|
.findOne('slug', newPlanSlug)
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const subscription = await PlanSubscription.query().findOne({
|
||||||
|
tenantId,
|
||||||
|
slug: subscriptionSlug,
|
||||||
|
});
|
||||||
|
if (subscription.planId === newPlan.id) {
|
||||||
|
throw new ServiceError('');
|
||||||
|
}
|
||||||
|
await subscription.$query().patch({ planId: newPlan.id });
|
||||||
|
|
||||||
|
// Triggers `onSubscriptionPlanChanged` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.subscription.onSubscriptionPlanChanged,
|
||||||
|
{
|
||||||
|
tenantId,
|
||||||
|
newPlanSlug,
|
||||||
|
subscriptionSlug,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the subscription payment as succeed.
|
||||||
|
* @param {number} tenantId - Tenant id.
|
||||||
|
* @param {string} subscriptionSlug - Given subscription slug by default main subscription.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async markSubscriptionPaymentSucceed(
|
||||||
|
tenantId: number,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
): Promise<void> {
|
||||||
|
const subscription = await PlanSubscription.query()
|
||||||
|
.findOne({ tenantId, slug: subscriptionSlug })
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
await subscription
|
||||||
|
.$query()
|
||||||
|
.patch({ paymentStatus: SubscriptionPaymentStatus.Succeed });
|
||||||
|
|
||||||
|
// Triggers `onSubscriptionSucceed` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.subscription.onSubscriptionPaymentSucceed,
|
||||||
|
{
|
||||||
|
tenantId,
|
||||||
|
subscriptionSlug,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the given subscription payment of the tenant as failed.
|
||||||
|
* @param {number} tenantId - Tenant id.
|
||||||
|
* @param {string} subscriptionSlug - Given subscription slug.
|
||||||
|
* @returns {Prmise<void>}
|
||||||
|
*/
|
||||||
|
async markSubscriptionPaymentFailed(
|
||||||
|
tenantId: number,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
): Promise<void> {
|
||||||
|
const subscription = await PlanSubscription.query()
|
||||||
|
.findOne({ tenantId, slug: subscriptionSlug })
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
await subscription
|
||||||
|
.$query()
|
||||||
|
.patch({ paymentStatus: SubscriptionPaymentStatus.Failed });
|
||||||
|
|
||||||
|
// Triggers `onSubscriptionPaymentFailed` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.subscription.onSubscriptionPaymentFailed,
|
||||||
|
{
|
||||||
|
tenantId,
|
||||||
|
subscriptionSlug,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export const ERRORS = {
|
export const ERRORS = {
|
||||||
SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT:
|
SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT:
|
||||||
'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT',
|
'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT',
|
||||||
|
SUBSCRIPTION_NOT_EXIST: 'SUBSCRIPTION_NOT_EXIST',
|
||||||
|
SUBSCRIPTION_ALREADY_CANCELED: 'SUBSCRIPTION_ALREADY_CANCELED',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IOrganizationSubscriptionChanged {
|
export interface IOrganizationSubscriptionChanged {
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export default {
|
|||||||
onSubscriptionResumed: 'onSubscriptionResumed',
|
onSubscriptionResumed: 'onSubscriptionResumed',
|
||||||
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
|
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
|
||||||
onSubscribed: 'onOrganizationSubscribed',
|
onSubscribed: 'onOrganizationSubscribed',
|
||||||
|
|
||||||
|
onSubscriptionCancelled: 'onSubscriptionCancelled',
|
||||||
|
onSubscriptionPaymentSucceed: 'onSubscriptionPaymentSucceed',
|
||||||
|
onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed'
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.up = function (knex) {
|
||||||
|
return knex.schema.table('subscription_plan_subscriptions', (table) => {
|
||||||
|
table.string('payment_status');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { import("knex").Knex } knex
|
||||||
|
* @returns { Promise<void> }
|
||||||
|
*/
|
||||||
|
exports.down = function (knex) {
|
||||||
|
return knex.schema.table('subscription_plan_subscriptions', (table) => {
|
||||||
|
table.dropColumn('payment_status');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -3,6 +3,10 @@ import SystemModel from '@/system/models/SystemModel';
|
|||||||
import { PlanSubscription } from '..';
|
import { PlanSubscription } from '..';
|
||||||
|
|
||||||
export default class Plan extends mixin(SystemModel) {
|
export default class Plan extends mixin(SystemModel) {
|
||||||
|
price: number;
|
||||||
|
invoiceInternal: number;
|
||||||
|
invoicePeriod: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table name.
|
* Table name.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -198,14 +198,16 @@ export default class Tenant extends BaseModel {
|
|||||||
planId,
|
planId,
|
||||||
invoiceInterval,
|
invoiceInterval,
|
||||||
invoicePeriod,
|
invoicePeriod,
|
||||||
subscriptionSlug
|
subscriptionSlug,
|
||||||
|
payload?,
|
||||||
) {
|
) {
|
||||||
return Tenant.newSubscription(
|
return Tenant.newSubscription(
|
||||||
this.id,
|
this.id,
|
||||||
planId,
|
planId,
|
||||||
invoiceInterval,
|
invoiceInterval,
|
||||||
invoicePeriod,
|
invoicePeriod,
|
||||||
subscriptionSlug
|
subscriptionSlug,
|
||||||
|
payload
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +219,8 @@ export default class Tenant extends BaseModel {
|
|||||||
planId: number,
|
planId: number,
|
||||||
invoiceInterval: 'month' | 'year',
|
invoiceInterval: 'month' | 'year',
|
||||||
invoicePeriod: number,
|
invoicePeriod: number,
|
||||||
subscriptionSlug: string
|
subscriptionSlug: string,
|
||||||
|
payload?: { lemonSqueezyId: string }
|
||||||
) {
|
) {
|
||||||
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
|
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
|
||||||
|
|
||||||
@@ -227,6 +230,7 @@ export default class Tenant extends BaseModel {
|
|||||||
planId,
|
planId,
|
||||||
startsAt: period.getStartDate(),
|
startsAt: period.getStartDate(),
|
||||||
endsAt: period.getEndDate(),
|
endsAt: period.getEndDate(),
|
||||||
|
lemonSubscriptionId: payload?.lemonSqueezyId || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user