mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 13:50:31 +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) {
|
||||
const data = req.body;
|
||||
const signature = req.headers['x-signature'] ?? '';
|
||||
const signature = req.headers['x-signature'] as string ?? '';
|
||||
const rawBody = req.rawBody;
|
||||
|
||||
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 './TaxRate';
|
||||
export * from './Plaid';
|
||||
export * from './Subscription';
|
||||
|
||||
export interface I18nService {
|
||||
__: (input: string) => string;
|
||||
|
||||
@@ -64,6 +64,19 @@ export class LemonSqueezyWebhooks {
|
||||
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,
|
||||
'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
|
||||
// Not implemented.
|
||||
} else if (webhookEvent.startsWith('subscription_')) {
|
||||
@@ -74,16 +87,34 @@ 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,
|
||||
'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_')) {
|
||||
// Save orders; eventBody is a "Order"
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { Service } from 'typedi';
|
||||
import { NotAllowedChangeSubscriptionPlan } from '@/exceptions';
|
||||
import { Plan, Tenant } from '@/system/models';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { NotAllowedChangeSubscriptionPlan, ServiceError } from '@/exceptions';
|
||||
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()
|
||||
export class Subscription {
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Give the tenant a new subscription.
|
||||
* @param {number} tenantId - Tenant id.
|
||||
* @param {string} planSlug - Plan slug.
|
||||
* @param {string} invoiceInterval
|
||||
* @param {number} invoicePeriod
|
||||
* @param {string} subscriptionSlug
|
||||
* @param {string} planSlug - Plan slug of the new subscription.
|
||||
* @param {string} subscriptionSlug - Subscription slug by default takes main subscription
|
||||
* @param {SubscriptionPayload} payload - Subscription payload.
|
||||
*/
|
||||
public async newSubscribtion(
|
||||
tenantId: number,
|
||||
planSlug: string,
|
||||
subscriptionSlug: string = 'main'
|
||||
) {
|
||||
subscriptionSlug: string = 'main',
|
||||
payload?: SubscriptionPayload
|
||||
): Promise<void> {
|
||||
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||
const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound();
|
||||
|
||||
@@ -45,8 +52,127 @@ export class Subscription {
|
||||
plan.id,
|
||||
invoiceInterval,
|
||||
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 = {
|
||||
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 {
|
||||
|
||||
@@ -50,6 +50,10 @@ export default {
|
||||
onSubscriptionResumed: 'onSubscriptionResumed',
|
||||
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
|
||||
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 '..';
|
||||
|
||||
export default class Plan extends mixin(SystemModel) {
|
||||
price: number;
|
||||
invoiceInternal: number;
|
||||
invoicePeriod: string;
|
||||
|
||||
/**
|
||||
* Table name.
|
||||
*/
|
||||
|
||||
@@ -198,14 +198,16 @@ export default class Tenant extends BaseModel {
|
||||
planId,
|
||||
invoiceInterval,
|
||||
invoicePeriod,
|
||||
subscriptionSlug
|
||||
subscriptionSlug,
|
||||
payload?,
|
||||
) {
|
||||
return Tenant.newSubscription(
|
||||
this.id,
|
||||
planId,
|
||||
invoiceInterval,
|
||||
invoicePeriod,
|
||||
subscriptionSlug
|
||||
subscriptionSlug,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,7 +219,8 @@ export default class Tenant extends BaseModel {
|
||||
planId: number,
|
||||
invoiceInterval: 'month' | 'year',
|
||||
invoicePeriod: number,
|
||||
subscriptionSlug: string
|
||||
subscriptionSlug: string,
|
||||
payload?: { lemonSqueezyId: string }
|
||||
) {
|
||||
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
|
||||
|
||||
@@ -227,6 +230,7 @@ export default class Tenant extends BaseModel {
|
||||
planId,
|
||||
startsAt: period.getStartDate(),
|
||||
endsAt: period.getEndDate(),
|
||||
lemonSubscriptionId: payload?.lemonSqueezyId || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user