mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-20 23:00:34 +00:00
Merge pull request #623 from bigcapitalhq/listen-payment-webhooks
fix: Listen to payment webhooks
This commit is contained in:
@@ -113,7 +113,7 @@ export class SubscriptionController extends BaseController {
|
|||||||
const { tenantId } = req;
|
const { tenantId } = req;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.subscriptionApp.cancelSubscription(tenantId, '455610');
|
await this.subscriptionApp.cancelSubscription(tenantId);
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAcco
|
|||||||
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
||||||
import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting';
|
import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting';
|
||||||
import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData';
|
import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData';
|
||||||
|
import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange';
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
return new EventPublisher();
|
return new EventPublisher();
|
||||||
@@ -247,8 +248,10 @@ export const susbcribers = () => {
|
|||||||
DeleteCashflowTransactionOnUncategorize,
|
DeleteCashflowTransactionOnUncategorize,
|
||||||
PreventDeleteTransactionOnDelete,
|
PreventDeleteTransactionOnDelete,
|
||||||
|
|
||||||
|
// Subscription
|
||||||
SubscribeFreeOnSignupCommunity,
|
SubscribeFreeOnSignupCommunity,
|
||||||
SendVerfiyMailOnSignUp,
|
SendVerfiyMailOnSignUp,
|
||||||
|
TriggerInvalidateCacheOnSubscriptionChange,
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
AttachmentsOnSaleInvoiceCreated,
|
AttachmentsOnSaleInvoiceCreated,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PlanSubscription } from '@/system/models';
|
|||||||
import { ServiceError } from '@/exceptions';
|
import { ServiceError } from '@/exceptions';
|
||||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||||
import events from '@/subscribers/events';
|
import events from '@/subscribers/events';
|
||||||
import { ERRORS, IOrganizationSubscriptionCanceled } from './types';
|
import { ERRORS, IOrganizationSubscriptionCancelled } from './types';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class LemonCancelSubscription {
|
export class LemonCancelSubscription {
|
||||||
@@ -18,12 +18,15 @@ export class LemonCancelSubscription {
|
|||||||
* @param {number} subscriptionId
|
* @param {number} subscriptionId
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public async cancelSubscription(tenantId: number) {
|
public async cancelSubscription(
|
||||||
|
tenantId: number,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
) {
|
||||||
configureLemonSqueezy();
|
configureLemonSqueezy();
|
||||||
|
|
||||||
const subscription = await PlanSubscription.query().findOne({
|
const subscription = await PlanSubscription.query().findOne({
|
||||||
tenantId,
|
tenantId,
|
||||||
slug: 'main',
|
slug: subscriptionSlug,
|
||||||
});
|
});
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
|
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
|
||||||
@@ -35,13 +38,10 @@ export class LemonCancelSubscription {
|
|||||||
if (cancelledSub.error) {
|
if (cancelledSub.error) {
|
||||||
throw new Error(cancelledSub.error.message);
|
throw new Error(cancelledSub.error.message);
|
||||||
}
|
}
|
||||||
await PlanSubscription.query().findById(subscriptionId).patch({
|
// Triggers `onSubscriptionCancelled` event.
|
||||||
canceledAt: new Date(),
|
|
||||||
});
|
|
||||||
// Triggers `onSubscriptionCanceled` event.
|
|
||||||
await this.eventPublisher.emitAsync(
|
await this.eventPublisher.emitAsync(
|
||||||
events.subscription.onSubscriptionCanceled,
|
events.subscription.onSubscriptionCancel,
|
||||||
{ tenantId, subscriptionId } as IOrganizationSubscriptionCanceled
|
{ tenantId, subscriptionId } as IOrganizationSubscriptionCancelled
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,25 +18,30 @@ export class LemonChangeSubscriptionPlan {
|
|||||||
* @param {number} newVariantId - New variant id.
|
* @param {number} newVariantId - New variant id.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public async changeSubscriptionPlan(tenantId: number, newVariantId: number) {
|
public async changeSubscriptionPlan(
|
||||||
|
tenantId: number,
|
||||||
|
newVariantId: number,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
) {
|
||||||
configureLemonSqueezy();
|
configureLemonSqueezy();
|
||||||
|
|
||||||
const subscription = await PlanSubscription.query().findOne({
|
const subscription = await PlanSubscription.query().findOne({
|
||||||
tenantId,
|
tenantId,
|
||||||
slug: 'main',
|
slug: subscriptionSlug,
|
||||||
});
|
});
|
||||||
const lemonSubscriptionId = subscription.lemonSubscriptionId;
|
const lemonSubscriptionId = subscription.lemonSubscriptionId;
|
||||||
|
|
||||||
// Send request to Lemon Squeezy to change the subscription.
|
// Send request to Lemon Squeezy to change the subscription.
|
||||||
const updatedSub = await updateSubscription(lemonSubscriptionId, {
|
const updatedSub = await updateSubscription(lemonSubscriptionId, {
|
||||||
variantId: newVariantId,
|
variantId: newVariantId,
|
||||||
|
invoiceImmediately: true,
|
||||||
});
|
});
|
||||||
if (updatedSub.error) {
|
if (updatedSub.error) {
|
||||||
throw new ServiceError('SOMETHING_WENT_WRONG');
|
throw new ServiceError('SOMETHING_WENT_WRONG');
|
||||||
}
|
}
|
||||||
// Triggers `onSubscriptionPlanChanged` event.
|
// Triggers `onSubscriptionPlanChanged` event.
|
||||||
await this.eventPublisher.emitAsync(
|
await this.eventPublisher.emitAsync(
|
||||||
events.subscription.onSubscriptionPlanChanged,
|
events.subscription.onSubscriptionPlanChange,
|
||||||
{
|
{
|
||||||
tenantId,
|
tenantId,
|
||||||
lemonSubscriptionId,
|
lemonSubscriptionId,
|
||||||
|
|||||||
@@ -14,15 +14,16 @@ export class LemonResumeSubscription {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resumes the main subscription of the given tenant.
|
* 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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public async resumeSubscription(tenantId: number) {
|
public async resumeSubscription(tenantId: number, subscriptionSlug: string = 'main') {
|
||||||
configureLemonSqueezy();
|
configureLemonSqueezy();
|
||||||
|
|
||||||
const subscription = await PlanSubscription.query().findOne({
|
const subscription = await PlanSubscription.query().findOne({
|
||||||
tenantId,
|
tenantId,
|
||||||
slug: 'main',
|
slug: subscriptionSlug,
|
||||||
});
|
});
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
|
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
|
||||||
@@ -33,15 +34,11 @@ export class LemonResumeSubscription {
|
|||||||
cancelled: false,
|
cancelled: false,
|
||||||
});
|
});
|
||||||
if (returnedSub.error) {
|
if (returnedSub.error) {
|
||||||
throw new ServiceError('');
|
throw new ServiceError(ٌٌُERRORS.SOMETHING_WENT_WRONG_WITH_LS);
|
||||||
}
|
}
|
||||||
// Update the subscription of the organization.
|
// Triggers `onSubscriptionResume` event.
|
||||||
await PlanSubscription.query().findById(subscriptionId).patch({
|
|
||||||
canceledAt: null,
|
|
||||||
});
|
|
||||||
// Triggers `onSubscriptionCanceled` event.
|
|
||||||
await this.eventPublisher.emitAsync(
|
await this.eventPublisher.emitAsync(
|
||||||
events.subscription.onSubscriptionResumed,
|
events.subscription.onSubscriptionResume,
|
||||||
{ tenantId, subscriptionId } as IOrganizationSubscriptionResumed
|
{ tenantId, subscriptionId } as IOrganizationSubscriptionResumed
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,11 +59,25 @@ export class LemonSqueezyWebhooks {
|
|||||||
|
|
||||||
const userId = eventBody.meta.custom_data?.user_id;
|
const userId = eventBody.meta.custom_data?.user_id;
|
||||||
const tenantId = eventBody.meta.custom_data?.tenant_id;
|
const tenantId = eventBody.meta.custom_data?.tenant_id;
|
||||||
|
const subscriptionSlug = 'main';
|
||||||
|
|
||||||
if (!webhookHasMeta(eventBody)) {
|
if (!webhookHasMeta(eventBody)) {
|
||||||
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,
|
||||||
|
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
|
// Save subscription invoices; eventBody is a SubscriptionInvoice
|
||||||
// Not implemented.
|
// Not implemented.
|
||||||
} else if (webhookEvent.startsWith('subscription_')) {
|
} else if (webhookEvent.startsWith('subscription_')) {
|
||||||
@@ -74,16 +88,39 @@ 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,
|
||||||
|
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_')) {
|
} 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,169 @@ 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() });
|
||||||
|
|
||||||
|
// Triggers `onSubscriptionCancelled` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.subscription.onSubscriptionCancelled,
|
||||||
|
{
|
||||||
|
tenantId,
|
||||||
|
subscriptionSlug,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes the given tenant subscription.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {string} subscriptionSlug - Subscription slug by deafult main subscription.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async resumeSubscription(
|
||||||
|
tenantId: number,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
) {
|
||||||
|
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 not cancelled.
|
||||||
|
if (!subscription.canceled()) {
|
||||||
|
throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_ACTIVE);
|
||||||
|
}
|
||||||
|
await subscription.$query().patch({ canceledAt: null });
|
||||||
|
|
||||||
|
// Triggers `onSubscriptionResumed` event.
|
||||||
|
await this.eventPublisher.emitAsync(
|
||||||
|
events.subscription.onSubscriptionResumed,
|
||||||
|
{ tenantId, subscriptionSlug }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,14 @@ export class SubscriptionApplication {
|
|||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public cancelSubscription(tenantId: number, id: string) {
|
public cancelSubscription(
|
||||||
return this.cancelSubscriptionService.cancelSubscription(tenantId, id);
|
tenantId: number,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
) {
|
||||||
|
return this.cancelSubscriptionService.cancelSubscription(
|
||||||
|
tenantId,
|
||||||
|
subscriptionSlug
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,8 +35,14 @@ export class SubscriptionApplication {
|
|||||||
* @param {number} tenantId
|
* @param {number} tenantId
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
public resumeSubscription(tenantId: number) {
|
public resumeSubscription(
|
||||||
return this.resumeSubscriptionService.resumeSubscription(tenantId);
|
tenantId: number,
|
||||||
|
subscriptionSlug: string = 'main'
|
||||||
|
) {
|
||||||
|
return this.resumeSubscriptionService.resumeSubscription(
|
||||||
|
tenantId,
|
||||||
|
subscriptionSlug
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
import { IAuthSignedUpEventPayload } from '@/interfaces';
|
import { IAuthSignedUpEventPayload } from '@/interfaces';
|
||||||
import events from '@/subscribers/events';
|
import events from '@/subscribers/events';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import { Subscription } from '../Subscription';
|
import { Subscription } from '../Subscription';
|
||||||
import { Inject, Service } from 'typedi';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SubscribeFreeOnSignupCommunity {
|
export class SubscribeFreeOnSignupCommunity {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import events from '@/subscribers/events';
|
||||||
|
import Container from 'typedi';
|
||||||
|
|
||||||
|
export class TriggerInvalidateCacheOnSubscriptionChange {
|
||||||
|
/**
|
||||||
|
* Attaches events with handlers.
|
||||||
|
*/
|
||||||
|
public attach = (bus) => {
|
||||||
|
bus.subscribe(
|
||||||
|
events.subscription.onSubscriptionCancelled,
|
||||||
|
this.triggerInvalidateCache.bind(this)
|
||||||
|
);
|
||||||
|
bus.subscribe(
|
||||||
|
events.subscription.onSubscriptionResumed,
|
||||||
|
this.triggerInvalidateCache.bind(this)
|
||||||
|
);
|
||||||
|
bus.subscribe(
|
||||||
|
events.subscription.onSubscriptionPlanChanged,
|
||||||
|
this.triggerInvalidateCache.bind(this)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
private triggerInvalidateCache() {
|
||||||
|
const io = Container.get('socket');
|
||||||
|
|
||||||
|
// Notify the frontend to reflect the new transactions changes.
|
||||||
|
io.emit('SUBSCRIPTION_CHANGED', { subscriptionSlug: 'main' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
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',
|
||||||
|
SUBSCRIPTION_ALREADY_ACTIVE: 'SUBSCRIPTION_ALREADY_ACTIVE',
|
||||||
|
SOMETHING_WENT_WRONG_WITH_LS: 'SOMETHING_WENT_WRONG_WITH_LS'
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IOrganizationSubscriptionChanged {
|
export interface IOrganizationSubscriptionChanged {
|
||||||
@@ -9,7 +13,7 @@ export interface IOrganizationSubscriptionChanged {
|
|||||||
newVariantId: number;
|
newVariantId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOrganizationSubscriptionCanceled {
|
export interface IOrganizationSubscriptionCancelled {
|
||||||
tenantId: number;
|
tenantId: number;
|
||||||
subscriptionId: string;
|
subscriptionId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,19 @@ export default {
|
|||||||
* Organization subscription.
|
* Organization subscription.
|
||||||
*/
|
*/
|
||||||
subscription: {
|
subscription: {
|
||||||
onSubscriptionCanceled: 'onSubscriptionCanceled',
|
onSubscriptionCancel: 'onSubscriptionCancel',
|
||||||
|
onSubscriptionCancelled: 'onSubscriptionCancelled',
|
||||||
|
|
||||||
|
onSubscriptionResume: 'onSubscriptionResume',
|
||||||
onSubscriptionResumed: 'onSubscriptionResumed',
|
onSubscriptionResumed: 'onSubscriptionResumed',
|
||||||
|
|
||||||
|
onSubscriptionPlanChange: 'onSubscriptionPlanChange',
|
||||||
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
|
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
|
||||||
onSubscribed: 'onOrganizationSubscribed',
|
|
||||||
|
onSubscriptionSubscribed: 'onSubscriptionSubscribed',
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export function DashboardSockets() {
|
|||||||
intent: Intent.SUCCESS,
|
intent: Intent.SUCCESS,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
socket.current.on('SUBSCRIPTION_CHANGED', () => {
|
||||||
|
client.invalidateQueries('GetSubscriptions');
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
socket.current.removeAllListeners();
|
socket.current.removeAllListeners();
|
||||||
socket.current.close();
|
socket.current.close();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import useApiRequest from '../useRequest';
|
|||||||
import { transformToCamelCase } from '@/utils';
|
import { transformToCamelCase } from '@/utils';
|
||||||
|
|
||||||
const QueryKeys = {
|
const QueryKeys = {
|
||||||
Subscriptions: 'Subscriptions',
|
Subscriptions: 'GetSubscriptions',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CancelMainSubscriptionValues {}
|
interface CancelMainSubscriptionValues {}
|
||||||
|
|||||||
Reference in New Issue
Block a user