@wepublish/api
Version:
API core for we.publish.
478 lines • 23 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PeriodicJobService = void 0;
const tslib_1 = require("tslib");
const common_1 = require("@nestjs/common");
const client_1 = require("@prisma/client");
const api_1 = require("../../../../mail-api/src");
const api_2 = require("../../../../payment-api/src");
const date_fns_1 = require("date-fns");
const util_1 = require("util");
const subscription_event_dictionary_1 = require("../subscription-event-dictionary/subscription-event-dictionary");
const subscription_service_1 = require("../subscription/subscription.service");
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
/**
* Controller responsible for performing periodic jobs. A new controller
* instance must be created for every run.
*/
let PeriodicJobService = exports.PeriodicJobService = class PeriodicJobService {
constructor(prismaService, mailContext, subscriptionController, payments) {
this.prismaService = prismaService;
this.mailContext = mailContext;
this.subscriptionController = subscriptionController;
this.payments = payments;
this.subscriptionEventDictionary = new subscription_event_dictionary_1.SubscriptionEventDictionary(this.prismaService);
this.logger = new common_1.Logger('PeriodicJobService');
this.randomNumberRangeForConcurrency = FIVE_MINUTES_IN_MS;
}
getJobLog(take, skip) {
return this.prismaService.periodicJob.findMany({
skip,
take: Math.min(take, 100),
orderBy: {
date: 'desc'
}
});
}
/**
* Run the periodic jobs. This makes sure that no two instances of the same
* controller run their jobs at the same time and returns if they are.
* @returns void
*/
concurrentExecute() {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
yield this.sleepForRandomIntervalToEnsureConcurrency();
if (yield this.isAlreadyAJobRunning()) {
this.logger.log('Periodic job already running on an other instance. skipping...');
return;
}
yield this.execute();
});
}
/**
* Runs all outstanding {@link getOutstandingRuns} runs by doing the following for each run:
* - send custom mails
* - create invoices
* - charge due invoices
* - deactivate overdue subscriptions
* If any of the tasks fail, the entire job is marked as failed.
*/
execute(customRunDate = new Date()) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
for (const periodicJobRunObject of yield this.getOutstandingRuns(customRunDate)) {
if (periodicJobRunObject.isRetry) {
yield this.retryFailedJob(periodicJobRunObject.date);
}
else {
yield this.markJobStarted(periodicJobRunObject.date);
}
try {
this.logger.log('Executing periodic job...');
this.logger.log('Processing custom mails...');
yield this.findAndSendCustomMails(periodicJobRunObject);
this.logger.log('Processing invoice creation...');
yield this.findAndCreateInvoices(periodicJobRunObject);
this.logger.log('Processing charge of invoices...');
yield this.findAndChargeDueInvoices(periodicJobRunObject);
this.logger.log('Processing deactivation of subscriptions with unpaid invoice...');
yield this.findAndDeactivateSubscriptions(periodicJobRunObject);
this.logger.log('Processing deactivation of subscriptions with which are not auto renewed...');
yield this.findAndDeactivateExpiredNotAutoRenewSubscription(periodicJobRunObject);
this.logger.log('Periodic job successfully finished.');
}
catch (e) {
yield this.markJobFailed((0, util_1.inspect)(e));
throw new Error((0, util_1.inspect)(e));
}
yield this.markJobSuccessful();
}
});
}
findAndDeactivateExpiredNotAutoRenewSubscription(periodicJobRunObject) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const subscriptionsToDeactivate = yield this.subscriptionController.getExpiredNotAutoRenewSubscriptionsToDeactivate(periodicJobRunObject.date);
const promises = subscriptionsToDeactivate.map(subscription => this.prismaService.subscription.update({
where: {
id: subscription.id
},
data: {
deactivation: {
create: {
date: new Date(),
reason: client_1.SubscriptionDeactivationReason.userSelfDeactivated
}
},
invoices: {
updateMany: {
where: {
canceledAt: null,
paidAt: null
},
data: {
canceledAt: new Date()
}
}
}
}
}));
this.prismaService.$transaction(promises);
});
}
findAndDeactivateSubscriptions(periodicJobRunObject) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const unpaidInvoices = yield this.subscriptionController.getSubscriptionsToDeactivate(periodicJobRunObject.date);
for (const unpaidInvoice of unpaidInvoices) {
yield this.deactivateSubscription(periodicJobRunObject, unpaidInvoice);
}
});
}
findAndChargeDueInvoices(periodicJobRunObject) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const invoicesToCharge = yield this.subscriptionController.getInvoicesToCharge((0, date_fns_1.endOfDay)(periodicJobRunObject.date));
for (const invoiceToCharge of invoicesToCharge) {
yield this.chargeInvoice(periodicJobRunObject, invoiceToCharge);
}
});
}
findAndCreateInvoices(periodicJobRunObject) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const subscriptionsToCreateInvoice = yield this.subscriptionController.getSubscriptionsForInvoiceCreation(periodicJobRunObject.date, yield this.subscriptionEventDictionary.getEarliestInvoiceCreationDate(periodicJobRunObject.date));
for (const subscriptionToCreateInvoice of subscriptionsToCreateInvoice) {
yield this.createInvoice(periodicJobRunObject, subscriptionToCreateInvoice);
}
});
}
findAndSendCustomMails(periodicJobRunObject) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const subscriptionsWithEvents = yield this.prismaService.subscription.findMany({
where: {
OR: (yield this.subscriptionEventDictionary.getDatesWithCustomEvent(periodicJobRunObject.date)).map(date => ({
paidUntil: {
gte: date,
lte: (0, date_fns_1.subMinutes)((0, date_fns_1.set)(date, { hours: 23, minutes: 59, seconds: 59, milliseconds: 999 }), date.getTimezoneOffset())
}
})),
// Don't send custom mails for deactivated subscriptions
deactivation: {
is: null
}
},
include: {
user: true,
deactivation: true
}
});
for (const subscriptionsWithEvent of subscriptionsWithEvents) {
yield this.sendCustomMails(periodicJobRunObject, subscriptionsWithEvent);
}
});
}
sendCustomMails(periodicJobRunObject, subscriptionsWithEvent) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const daysAwayFromEnding = (0, date_fns_1.differenceInDays)(periodicJobRunObject.date, (0, date_fns_1.startOfDay)(subscriptionsWithEvent.paidUntil));
const subscriptionDictionary = yield this.subscriptionEventDictionary.getActionsForSubscriptions({
memberplanId: subscriptionsWithEvent.memberPlanID,
paymentMethodId: subscriptionsWithEvent.paymentMethodID,
periodicity: subscriptionsWithEvent.paymentPeriodicity,
autorenwal: subscriptionsWithEvent.autoRenew,
daysAwayFromEnding
});
const invoices = yield this.prismaService.invoice.findMany({
where: { subscriptionID: subscriptionsWithEvent.id },
orderBy: { createdAt: 'desc' },
take: 2
});
for (const event of subscriptionDictionary) {
if (event.type === client_1.SubscriptionEvent.CUSTOM) {
yield this.sendTemplateMail(event, subscriptionsWithEvent.user, periodicJobRunObject.isRetry, { subscription: subscriptionsWithEvent, invoices }, periodicJobRunObject.date);
}
}
});
}
createInvoice(periodicJobRunObject, subscriptionToCreateInvoice) {
var _a;
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const eventInvoiceCreation = yield this.subscriptionEventDictionary.getActionsForSubscriptions({
memberplanId: subscriptionToCreateInvoice.memberPlanID,
paymentMethodId: subscriptionToCreateInvoice.paymentMethodID,
periodicity: subscriptionToCreateInvoice.paymentPeriodicity,
autorenwal: subscriptionToCreateInvoice.autoRenew,
events: [client_1.SubscriptionEvent.INVOICE_CREATION, client_1.SubscriptionEvent.DEACTIVATION_UNPAID]
});
const creationEvent = eventInvoiceCreation.find(e => e.type === client_1.SubscriptionEvent.INVOICE_CREATION);
if (!creationEvent) {
throw new common_1.NotFoundException('No invoice creation found!');
}
const deactivationEvent = eventInvoiceCreation.find(e => e.type === client_1.SubscriptionEvent.DEACTIVATION_UNPAID);
if (!deactivationEvent) {
throw new common_1.NotFoundException('No invoice deactivation event found!');
}
if (subscriptionToCreateInvoice.paidUntil &&
(0, date_fns_1.add)((0, date_fns_1.startOfDay)(subscriptionToCreateInvoice.paidUntil), {
days: (_a = creationEvent.daysAwayFromEnding) !== null && _a !== void 0 ? _a : undefined
}) > periodicJobRunObject.date) {
return false;
}
const invoice = yield this.subscriptionController.createInvoice(subscriptionToCreateInvoice, deactivationEvent);
const paymentProvider = yield this.payments.findPaymentProviderByPaymentMethodeId(subscriptionToCreateInvoice.paymentMethodID);
if (paymentProvider) {
const subscription = yield this.prismaService.subscription.findUnique({
where: {
id: subscriptionToCreateInvoice.id
},
include: {
properties: true
}
});
if (subscription) {
yield paymentProvider.createRemoteInvoice({
subscription,
invoice
});
}
}
yield this.sendTemplateMail(creationEvent, subscriptionToCreateInvoice.user, periodicJobRunObject.isRetry, { subscriptionToCreateInvoice, invoice }, periodicJobRunObject.date);
return true;
});
}
chargeInvoice(periodicJobRunObject, invoiceToCharge) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
if (!invoiceToCharge.subscription) {
throw new Error(`Invoice ${invoiceToCharge.id} has no subscription assigned!`);
}
const eventsRenewal = yield this.subscriptionEventDictionary.getActionsForSubscriptions({
memberplanId: invoiceToCharge.subscription.memberPlanID,
paymentMethodId: invoiceToCharge.subscription.paymentMethodID,
periodicity: invoiceToCharge.subscription.paymentPeriodicity,
autorenwal: invoiceToCharge.subscription.autoRenew,
events: [client_1.SubscriptionEvent.RENEWAL_SUCCESS, client_1.SubscriptionEvent.RENEWAL_FAILED]
});
const mailAction = yield this.subscriptionController.chargeInvoice(invoiceToCharge, eventsRenewal);
if (mailAction.action) {
const user = Object.assign({}, invoiceToCharge.subscription.user);
const { subscription, items, subscriptionPeriods } = invoiceToCharge, invoice = tslib_1.__rest(invoiceToCharge, ["subscription", "items", "subscriptionPeriods"]);
yield this.sendTemplateMail(mailAction.action, user, periodicJobRunObject.isRetry, {
errorCode: mailAction.errorCode,
invoice,
subscriptionPeriods,
items,
subscription
}, periodicJobRunObject.date);
}
});
}
deactivateSubscription(periodicJobRunObject, unpaidInvoice) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
if (!unpaidInvoice.subscription) {
throw new common_1.BadRequestException(`Invoice ${unpaidInvoice.id} has no subscription assigned!`);
}
const eventDeactivationUnpaid = yield this.subscriptionEventDictionary.getActionsForSubscriptions({
memberplanId: unpaidInvoice.subscription.memberPlanID,
paymentMethodId: unpaidInvoice.subscription.paymentMethodID,
periodicity: unpaidInvoice.subscription.paymentPeriodicity,
autorenwal: unpaidInvoice.subscription.autoRenew,
events: [client_1.SubscriptionEvent.DEACTIVATION_UNPAID]
});
if (!eventDeactivationUnpaid[0]) {
throw new common_1.NotFoundException('No subscription deactivation found!');
}
const paymentProvider = yield this.payments.findPaymentProviderByPaymentMethodeId(unpaidInvoice.subscription.paymentMethodID);
if (paymentProvider) {
const subscription = yield this.prismaService.subscription.findUnique({
where: {
id: unpaidInvoice.subscription.id
},
include: {
properties: true
}
});
if (subscription) {
yield paymentProvider.cancelRemoteSubscription({
subscription
});
}
}
yield this.subscriptionController.deactivateSubscription(unpaidInvoice);
const { subscription } = unpaidInvoice, invoice = tslib_1.__rest(unpaidInvoice, ["subscription"]);
yield this.sendTemplateMail(eventDeactivationUnpaid[0], unpaidInvoice.subscription.user, periodicJobRunObject.isRetry, { subscription, invoice }, periodicJobRunObject.date);
});
}
/**
* Mark a job as re-trying at the current date.
* @param runDate The original date of the job run.
*/
retryFailedJob(runDate) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
this.runningJob = yield this.prismaService.periodicJob.update({
where: {
date: runDate
},
data: {
executionTime: new Date()
}
});
this.logger.warn('Retry failed job!');
});
}
/**
* Mark a job as started at the current date.
* @param runDate the original date of the job run.
*/
markJobStarted(runDate) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
this.runningJob = yield this.prismaService.periodicJob.create({
data: {
date: runDate,
executionTime: new Date()
}
});
});
}
/**
* Check if any job is already being processed.
* @returns if there are any jobs running.
*/
isAlreadyAJobRunning() {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const runLimit = (0, date_fns_1.sub)(new Date(), { hours: 2 });
const runs = yield this.prismaService.periodicJob.findMany({
where: {
executionTime: {
gte: runLimit
}
}
});
return runs.length > 0;
});
}
/**
* Mark a job as completed in the database.
*/
markJobSuccessful() {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
if (!this.runningJob) {
throw new Error('Try to make a job as successful while none is running!');
}
yield this.prismaService.periodicJob.update({
where: {
id: this.runningJob.id
},
data: {
successfullyFinished: new Date(),
tries: ++this.runningJob.tries
}
});
this.runningJob = undefined;
});
}
/**
* Sleep for a random time between 0 and 300 seconds to ensure that two parallel processes
* are not starting to process the queue at the same time.
* @returns void
*/
sleepForRandomIntervalToEnsureConcurrency() {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const randomSleepTimeout = Math.floor(Math.random() * this.randomNumberRangeForConcurrency);
this.logger.log(`To ensure concurrent execution in multi instance environment choosing random number between 0 and ${this.randomNumberRangeForConcurrency}... sleeping for ${randomSleepTimeout}ms`);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
yield sleep(randomSleepTimeout);
return randomSleepTimeout;
});
}
/**
* Mark a job as failed in the database by incrementing the `tries` count and updating the failure timestamp.
* @param error a description of the error
*/
markJobFailed(error) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
if (!this.runningJob) {
throw new Error('Try to make a job as failed while none is running!');
}
yield this.prismaService.periodicJob.update({
where: {
id: this.runningJob.id
},
data: {
finishedWithError: new Date(),
tries: ++this.runningJob.tries,
error
}
});
this.runningJob = undefined;
});
}
/**
* Calculate the runs in the past that have not completed yet.
* - If the Controller is run for the first time, this returns just todays run.
* - If the last run had an error, it returns the run when the error happened.
* - If there was an execution pause, it returns all runs between the last successful run and the current day.
* @returns An array of pending runs.
*/
getOutstandingRuns(customRunDate) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
const today = customRunDate || new Date();
const runDates = [];
const latestRun = yield this.prismaService.periodicJob.findFirst({
orderBy: {
date: 'desc'
}
});
if (!latestRun) {
this.logger.debug('Periodic job first run');
return [{ isRetry: false, date: (0, date_fns_1.startOfDay)(today) }];
}
if (latestRun.finishedWithError && !latestRun.successfullyFinished) {
this.logger.warn('Last run had errors retrying....');
runDates.push({ isRetry: true, date: (0, date_fns_1.startOfDay)(latestRun.date) });
}
return runDates.concat(this.generateDateArray(latestRun.date, today));
});
}
/**
* Generate an array of dates between the two bounds
* @param startDate The beginning date (inclusive)
* @param endDate The ending date (exclusive)
* @returns An array of Date objects
*/
generateDateArray(startDate, endDate) {
const dateArray = [];
const lastDate = (0, date_fns_1.startOfDay)(endDate);
let inputDate = (0, date_fns_1.startOfDay)(startDate);
while (inputDate < lastDate) {
inputDate = (0, date_fns_1.addDays)(inputDate, 1);
dateArray.push({ isRetry: false, date: inputDate });
}
return dateArray;
}
/**
* Send an email and store it in the Mail Log
* @param action the event and template to send
* @param user the recipient
* @param isRetry whether this is a retried delivery
* @param optionalData unknown
* @param periodicJobRunDate the current date for the delivery
*/
sendTemplateMail(action, user, isRetry, optionalData, periodicJobRunDate) {
return tslib_1.__awaiter(this, void 0, void 0, function* () {
if (action.externalMailTemplate && user) {
yield new api_1.MailController(this.prismaService, this.mailContext, {
daysAwayFromEnding: action.daysAwayFromEnding,
externalMailTemplateId: action.externalMailTemplate,
recipient: user,
isRetry,
optionalData,
periodicJobRunDate,
mailType: api_1.mailLogType.SubscriptionFlow
}).sendMail();
}
});
}
};
exports.PeriodicJobService = PeriodicJobService = tslib_1.__decorate([
(0, common_1.Injectable)(),
tslib_1.__metadata("design:paramtypes", [client_1.PrismaClient,
api_1.MailContext,
subscription_service_1.SubscriptionService,
api_2.PaymentsService])
], PeriodicJobService);
//# sourceMappingURL=periodic-job.service.js.map