UNPKG

@wepublish/api

Version:
478 lines 23 kB
"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