Merge pull request #1055 from automatisch/subscription-cancelled
feat: add subscription cancelled flow
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import appConfig from '../../config/app';
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!appConfig.isCloud) return;
|
||||||
|
|
||||||
|
return knex.schema.table('subscriptions', (table) => {
|
||||||
|
table.date('cancellation_effective_date');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (!appConfig.isCloud) return;
|
||||||
|
|
||||||
|
return knex.schema.table('subscriptions', (table) => {
|
||||||
|
table.dropColumn('cancellation_effective_date');
|
||||||
|
});
|
||||||
|
}
|
@@ -44,7 +44,9 @@ const paidSubscription = (subscription: Subscription): TSubscription => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
nextBillAmount: {
|
nextBillAmount: {
|
||||||
title: '€' + subscription.nextBillAmount,
|
title: subscription.nextBillAmount
|
||||||
|
? '€' + subscription.nextBillAmount
|
||||||
|
: '---',
|
||||||
action: {
|
action: {
|
||||||
type: 'link',
|
type: 'link',
|
||||||
text: 'Update payment method',
|
text: 'Update payment method',
|
||||||
@@ -52,7 +54,7 @@ const paidSubscription = (subscription: Subscription): TSubscription => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
nextBillDate: {
|
nextBillDate: {
|
||||||
title: subscription.nextBillDate,
|
title: subscription.nextBillDate ? subscription.nextBillDate : '---',
|
||||||
action: {
|
action: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: '(monthly payment)',
|
text: '(monthly payment)',
|
||||||
|
@@ -0,0 +1,22 @@
|
|||||||
|
import appConfig from '../../config/app';
|
||||||
|
import Context from '../../types/express/context';
|
||||||
|
|
||||||
|
const getSubscriptionStatus = async (
|
||||||
|
_parent: unknown,
|
||||||
|
_params: unknown,
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!appConfig.isCloud) return;
|
||||||
|
|
||||||
|
const currentSubscription = await context.currentUser.$relatedQuery(
|
||||||
|
'currentSubscription'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentSubscription?.cancellationEffectiveDate) return;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cancellationEffectiveDate: currentSubscription.cancellationEffectiveDate,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getSubscriptionStatus;
|
@@ -18,6 +18,7 @@ import getBillingAndUsage from './queries/get-billing-and-usage.ee';
|
|||||||
import getInvoices from './queries/get-invoices.ee';
|
import getInvoices from './queries/get-invoices.ee';
|
||||||
import getAutomatischInfo from './queries/get-automatisch-info';
|
import getAutomatischInfo from './queries/get-automatisch-info';
|
||||||
import getTrialStatus from './queries/get-trial-status.ee';
|
import getTrialStatus from './queries/get-trial-status.ee';
|
||||||
|
import getSubscriptionStatus from './queries/get-subscription-status.ee';
|
||||||
import healthcheck from './queries/healthcheck';
|
import healthcheck from './queries/healthcheck';
|
||||||
|
|
||||||
const queryResolvers = {
|
const queryResolvers = {
|
||||||
@@ -41,6 +42,7 @@ const queryResolvers = {
|
|||||||
getInvoices,
|
getInvoices,
|
||||||
getAutomatischInfo,
|
getAutomatischInfo,
|
||||||
getTrialStatus,
|
getTrialStatus,
|
||||||
|
getSubscriptionStatus,
|
||||||
healthcheck,
|
healthcheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -41,6 +41,7 @@ type Query {
|
|||||||
getInvoices: [Invoice]
|
getInvoices: [Invoice]
|
||||||
getAutomatischInfo: GetAutomatischInfo
|
getAutomatischInfo: GetAutomatischInfo
|
||||||
getTrialStatus: GetTrialStatus
|
getTrialStatus: GetTrialStatus
|
||||||
|
getSubscriptionStatus: GetSubscriptionStatus
|
||||||
healthcheck: AppHealth
|
healthcheck: AppHealth
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +488,10 @@ type GetTrialStatus {
|
|||||||
expireAt: String
|
expireAt: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetSubscriptionStatus {
|
||||||
|
cancellationEffectiveDate: String
|
||||||
|
}
|
||||||
|
|
||||||
type GetBillingAndUsage {
|
type GetBillingAndUsage {
|
||||||
subscription: Subscription
|
subscription: Subscription
|
||||||
usage: Usage
|
usage: Usage
|
||||||
|
@@ -3,13 +3,16 @@ import Subscription from '../../models/subscription.ee';
|
|||||||
import Billing from './index.ee';
|
import Billing from './index.ee';
|
||||||
|
|
||||||
const handleSubscriptionCreated = async (request: IRequest) => {
|
const handleSubscriptionCreated = async (request: IRequest) => {
|
||||||
const subscription = await Subscription.query().insertAndFetch(formatSubscription(request));
|
const subscription = await Subscription.query().insertAndFetch(
|
||||||
await subscription.$relatedQuery('usageData').insert(formatUsageData(request));
|
formatSubscription(request)
|
||||||
|
);
|
||||||
|
await subscription
|
||||||
|
.$relatedQuery('usageData')
|
||||||
|
.insert(formatUsageData(request));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscriptionUpdated = async (request: IRequest) => {
|
const handleSubscriptionUpdated = async (request: IRequest) => {
|
||||||
await Subscription
|
await Subscription.query()
|
||||||
.query()
|
|
||||||
.findOne({
|
.findOne({
|
||||||
paddle_subscription_id: request.body.subscription_id,
|
paddle_subscription_id: request.body.subscription_id,
|
||||||
})
|
})
|
||||||
@@ -17,15 +20,11 @@ const handleSubscriptionUpdated = async (request: IRequest) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscriptionCancelled = async (request: IRequest) => {
|
const handleSubscriptionCancelled = async (request: IRequest) => {
|
||||||
const subscription = await Subscription
|
const subscription = await Subscription.query().findOne({
|
||||||
.query()
|
paddle_subscription_id: request.body.subscription_id,
|
||||||
.findOne({
|
});
|
||||||
paddle_subscription_id: request.body.subscription_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await subscription.$query().patchAndFetch(formatSubscription(request));
|
await subscription.$query().patchAndFetch(formatSubscription(request));
|
||||||
|
|
||||||
await subscription.$query().delete();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscriptionPaymentSucceeded = async (request: IRequest) => {
|
const handleSubscriptionPaymentSucceeded = async (request: IRequest) => {
|
||||||
@@ -45,7 +44,9 @@ const handleSubscriptionPaymentSucceeded = async (request: IRequest) => {
|
|||||||
lastBillDate: remoteSubscription.last_payment.date,
|
lastBillDate: remoteSubscription.last_payment.date,
|
||||||
});
|
});
|
||||||
|
|
||||||
await subscription.$relatedQuery('usageData').insert(formatUsageData(request));
|
await subscription
|
||||||
|
.$relatedQuery('usageData')
|
||||||
|
.insert(formatUsageData(request));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatSubscription = (request: IRequest) => {
|
const formatSubscription = (request: IRequest) => {
|
||||||
@@ -58,6 +59,7 @@ const formatSubscription = (request: IRequest) => {
|
|||||||
status: request.body.status,
|
status: request.body.status,
|
||||||
nextBillDate: request.body.next_bill_date,
|
nextBillDate: request.body.next_bill_date,
|
||||||
nextBillAmount: request.body.unit_price,
|
nextBillAmount: request.body.unit_price,
|
||||||
|
cancellationEffectiveDate: request.body.cancellation_effective_date,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -6,6 +6,7 @@ import triggerQueue from '../queues/trigger';
|
|||||||
import actionQueue from '../queues/action';
|
import actionQueue from '../queues/action';
|
||||||
import emailQueue from '../queues/email';
|
import emailQueue from '../queues/email';
|
||||||
import deleteUserQueue from '../queues/delete-user.ee';
|
import deleteUserQueue from '../queues/delete-user.ee';
|
||||||
|
import removeCancelledSubscriptionsQueue from '../queues/remove-cancelled-subscriptions.ee';
|
||||||
import appConfig from '../config/app';
|
import appConfig from '../config/app';
|
||||||
|
|
||||||
const serverAdapter = new ExpressAdapter();
|
const serverAdapter = new ExpressAdapter();
|
||||||
@@ -25,6 +26,7 @@ const createBullBoardHandler = async (serverAdapter: ExpressAdapter) => {
|
|||||||
new BullMQAdapter(actionQueue),
|
new BullMQAdapter(actionQueue),
|
||||||
new BullMQAdapter(emailQueue),
|
new BullMQAdapter(emailQueue),
|
||||||
new BullMQAdapter(deleteUserQueue),
|
new BullMQAdapter(deleteUserQueue),
|
||||||
|
new BullMQAdapter(removeCancelledSubscriptionsQueue),
|
||||||
],
|
],
|
||||||
serverAdapter: serverAdapter,
|
serverAdapter: serverAdapter,
|
||||||
});
|
});
|
||||||
|
@@ -14,6 +14,7 @@ class Subscription extends Base {
|
|||||||
nextBillAmount!: string;
|
nextBillAmount!: string;
|
||||||
nextBillDate!: string;
|
nextBillDate!: string;
|
||||||
lastBillDate?: string;
|
lastBillDate?: string;
|
||||||
|
cancellationEffectiveDate?: string;
|
||||||
usageData?: UsageData[];
|
usageData?: UsageData[];
|
||||||
currentUsageData?: UsageData;
|
currentUsageData?: UsageData;
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ class Subscription extends Base {
|
|||||||
nextBillAmount: { type: 'string' },
|
nextBillAmount: { type: 'string' },
|
||||||
nextBillDate: { type: 'string' },
|
nextBillDate: { type: 'string' },
|
||||||
lastBillDate: { type: 'string' },
|
lastBillDate: { type: 'string' },
|
||||||
|
cancellationEffectiveDate: { type: 'string' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -0,0 +1,35 @@
|
|||||||
|
import process from 'process';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import redisConfig from '../config/redis';
|
||||||
|
import logger from '../helpers/logger';
|
||||||
|
|
||||||
|
const CONNECTION_REFUSED = 'ECONNREFUSED';
|
||||||
|
|
||||||
|
const redisConnection = {
|
||||||
|
connection: redisConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCancelledSubscriptionsQueue = new Queue(
|
||||||
|
'remove-cancelled-subscriptions',
|
||||||
|
redisConnection
|
||||||
|
);
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await removeCancelledSubscriptionsQueue.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
removeCancelledSubscriptionsQueue.on('error', (err) => {
|
||||||
|
if ((err as any).code === CONNECTION_REFUSED) {
|
||||||
|
logger.error('Make sure you have installed Redis and it is running.', err);
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
removeCancelledSubscriptionsQueue.add('remove-cancelled-subscriptions', null, {
|
||||||
|
jobId: 'remove-cancelled-subscriptions',
|
||||||
|
repeat: {
|
||||||
|
pattern: '0 1 * * *',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default removeCancelledSubscriptionsQueue;
|
@@ -9,6 +9,8 @@ import './workers/trigger';
|
|||||||
import './workers/action';
|
import './workers/action';
|
||||||
import './workers/email';
|
import './workers/email';
|
||||||
import './workers/delete-user.ee';
|
import './workers/delete-user.ee';
|
||||||
|
import './workers/remove-cancelled-subscriptions.ee';
|
||||||
|
import './queues/remove-cancelled-subscriptions.ee';
|
||||||
import telemetry from './helpers/telemetry';
|
import telemetry from './helpers/telemetry';
|
||||||
|
|
||||||
telemetry.setServiceType('worker');
|
telemetry.setServiceType('worker');
|
||||||
|
@@ -0,0 +1,46 @@
|
|||||||
|
import { Worker } from 'bullmq';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import * as Sentry from '../helpers/sentry.ee';
|
||||||
|
import redisConfig from '../config/redis';
|
||||||
|
import logger from '../helpers/logger';
|
||||||
|
import Subscription from '../models/subscription.ee';
|
||||||
|
|
||||||
|
export const worker = new Worker(
|
||||||
|
'remove-cancelled-subscriptions',
|
||||||
|
async () => {
|
||||||
|
await Subscription.query()
|
||||||
|
.delete()
|
||||||
|
.where({
|
||||||
|
status: 'deleted',
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
'cancellation_effective_date',
|
||||||
|
'<=',
|
||||||
|
DateTime.now().startOf('day').toISODate()
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ connection: redisConfig }
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('completed', (job) => {
|
||||||
|
logger.info(
|
||||||
|
`JOB ID: ${job.id} - The cancelled subscriptions have been removed!`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job, err) => {
|
||||||
|
const errorMessage = `
|
||||||
|
JOB ID: ${job.id} - ERROR: The cancelled subscriptions can not be removed! ${err.message}
|
||||||
|
\n ${err.stack}
|
||||||
|
`;
|
||||||
|
logger.error(errorMessage);
|
||||||
|
Sentry.captureException(err, {
|
||||||
|
extra: {
|
||||||
|
jobId: job.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
await worker.close();
|
||||||
|
});
|
Reference in New Issue
Block a user