From 97c3fd9e0caf76f826de47951dc9f86fdf322388 Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Wed, 8 Sep 2021 15:56:18 +0200 Subject: [PATCH] feat: subscription period based on the license code. --- .../Subscription/PaymentViaLicense.ts | 106 +++++++++++------- .../NotAllowedChangeSubscriptionPlan.ts | 2 +- server/src/services/Authentication/index.ts | 4 +- .../services/Payment/LicensePaymentMethod.ts | 14 ++- .../src/services/Subscription/Subscription.ts | 78 ++++++++----- .../Subscription/SubscriptionService.ts | 46 ++++---- .../Subscription/SubscriptionViaLicense.ts | 54 +++++++++ server/src/services/Users/UsersService.ts | 3 - .../system/models/Subscriptions/License.js | 52 ++++----- server/src/system/models/Tenant.js | 39 +++++-- 10 files changed, 252 insertions(+), 146 deletions(-) create mode 100644 server/src/services/Subscription/SubscriptionViaLicense.ts diff --git a/server/src/api/controllers/Subscription/PaymentViaLicense.ts b/server/src/api/controllers/Subscription/PaymentViaLicense.ts index 9e8e083c2..46faaf035 100644 --- a/server/src/api/controllers/Subscription/PaymentViaLicense.ts +++ b/server/src/api/controllers/Subscription/PaymentViaLicense.ts @@ -1,5 +1,5 @@ import { Inject, Service } from 'typedi'; -import { Router, Request, Response } from 'express'; +import { NextFunction, Router, Request, Response } from 'express'; import { check } from 'express-validator'; import asyncMiddleware from 'api/middleware/asyncMiddleware'; import PaymentMethodController from 'api/controllers/Subscription/PaymentMethod'; @@ -8,13 +8,13 @@ import { NoPaymentModelWithPricedPlan, PaymentAmountInvalidWithPlan, PaymentInputInvalid, - VoucherCodeRequired + VoucherCodeRequired, } from 'exceptions'; import { ILicensePaymentModel } from 'interfaces'; import instance from 'tsyringe/dist/typings/dependency-container'; @Service() -export default class PaymentViaLicenseController extends PaymentMethodController { +export default class PaymentViaLicenseController extends PaymentMethodController { @Inject('logger') logger: any; @@ -30,6 +30,7 @@ export default class PaymentViaLicenseController extends PaymentMethodController this.validationResult, asyncMiddleware(this.validatePlanSlugExistance.bind(this)), asyncMiddleware(this.paymentViaLicense.bind(this)), + this.handleErrors, ); return router; } @@ -40,14 +41,14 @@ export default class PaymentViaLicenseController extends PaymentMethodController get paymentViaLicenseSchema() { return [ check('plan_slug').exists().trim().escape(), - check('license_code').optional().trim().escape(), + check('license_code').exists().trim().escape(), ]; } /** * Handle the subscription payment via license code. - * @param {Request} req - * @param {Response} res + * @param {Request} req + * @param {Response} res * @return {Response} */ async paymentViaLicense(req: Request, res: Response, next: Function) { @@ -55,11 +56,13 @@ export default class PaymentViaLicenseController extends PaymentMethodController const { tenant } = req; try { - const licenseModel: ILicensePaymentModel|null = licenseCode - ? { licenseCode } : null; + const licenseModel: ILicensePaymentModel = { licenseCode }; - await this.subscriptionService - .subscriptionViaLicense(tenant.id, planSlug, licenseModel); + await this.subscriptionService.subscriptionViaLicense( + tenant.id, + planSlug, + licenseModel + ); return res.status(200).send({ type: 'success', @@ -67,39 +70,56 @@ export default class PaymentViaLicenseController extends PaymentMethodController message: 'Payment via license has been made successfully.', }); } catch (exception) { - const errorReasons = []; - - if (exception instanceof VoucherCodeRequired) { - errorReasons.push({ - type: 'VOUCHER_CODE_REQUIRED', code: 100, - }); - } - if (exception instanceof NoPaymentModelWithPricedPlan) { - errorReasons.push({ - type: 'NO_PAYMENT_WITH_PRICED_PLAN', - code: 140, - }); - } - if (exception instanceof NotAllowedChangeSubscriptionPlan) { - errorReasons.push({ - type: 'NOT.ALLOWED.RENEW.SUBSCRIPTION.WHILE.ACTIVE', - code: 120, - }); - } - if (errorReasons.length > 0) { - return res.status(400).send({ errors: errorReasons }); - } - if (exception instanceof PaymentInputInvalid) { - return res.status(400).send({ - errors: [{ type: 'LICENSE.CODE.IS.INVALID', code: 120 }], - }); - } - if (exception instanceof PaymentAmountInvalidWithPlan) { - return res.status(400).send({ - errors: [{ type: 'LICENSE.NOT.FOR.GIVEN.PLAN' }], - }); - } next(exception); } } -} \ No newline at end of file + + /** + * Handle service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private handleErrors( + exception: Error, + req: Request, + res: Response, + next: NextFunction + ) { + const errorReasons = []; + + if (exception instanceof VoucherCodeRequired) { + errorReasons.push({ + type: 'VOUCHER_CODE_REQUIRED', + code: 100, + }); + } + if (exception instanceof NoPaymentModelWithPricedPlan) { + errorReasons.push({ + type: 'NO_PAYMENT_WITH_PRICED_PLAN', + code: 140, + }); + } + if (exception instanceof NotAllowedChangeSubscriptionPlan) { + errorReasons.push({ + type: 'NOT.ALLOWED.RENEW.SUBSCRIPTION.WHILE.ACTIVE', + code: 120, + }); + } + if (errorReasons.length > 0) { + return res.status(400).send({ errors: errorReasons }); + } + if (exception instanceof PaymentInputInvalid) { + return res.status(400).send({ + errors: [{ type: 'LICENSE.CODE.IS.INVALID', code: 120 }], + }); + } + if (exception instanceof PaymentAmountInvalidWithPlan) { + return res.status(400).send({ + errors: [{ type: 'LICENSE.NOT.FOR.GIVEN.PLAN' }], + }); + } + next(exception); + } +} diff --git a/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts b/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts index e6c6bac1e..3c5380259 100644 --- a/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts +++ b/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts @@ -2,7 +2,7 @@ export default class NotAllowedChangeSubscriptionPlan { - constructor(message: string) { + constructor() { this.name = "NotAllowedChangeSubscriptionPlan"; } } \ No newline at end of file diff --git a/server/src/services/Authentication/index.ts b/server/src/services/Authentication/index.ts index 9770f767b..15990f863 100644 --- a/server/src/services/Authentication/index.ts +++ b/server/src/services/Authentication/index.ts @@ -7,7 +7,7 @@ import { EventDispatcher, EventDispatcherInterface, } from 'decorators/eventDispatcher'; -import { PasswordReset } from 'system/models'; +import { PasswordReset, Tenant } from 'system/models'; import { IRegisterDTO, ITenant, @@ -117,7 +117,7 @@ export default class AuthenticationService implements IAuthenticationService { password, user, }); - const tenant = await user.$relatedQuery('tenant'); + const tenant = await Tenant.query().findById(user.tenantId).withGraphFetched('metadata'); // Keep the user object immutable. const outputUser = cloneDeep(user); diff --git a/server/src/services/Payment/LicensePaymentMethod.ts b/server/src/services/Payment/LicensePaymentMethod.ts index 376726804..584d9d162 100644 --- a/server/src/services/Payment/LicensePaymentMethod.ts +++ b/server/src/services/Payment/LicensePaymentMethod.ts @@ -2,7 +2,6 @@ import { License } from 'system/models'; import PaymentMethod from 'services/Payment/PaymentMethod'; import { Plan } from 'system/models'; import { IPaymentMethod, ILicensePaymentModel } from 'interfaces'; -import { ILicensePaymentModel } from 'interfaces'; import { PaymentInputInvalid, PaymentAmountInvalidWithPlan, @@ -11,12 +10,13 @@ import { export default class LicensePaymentMethod extends PaymentMethod - implements IPaymentMethod { + implements IPaymentMethod +{ /** * Payment subscription of organization via license code. * @param {ILicensePaymentModel} licensePaymentModel - */ - async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) { + public async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) { this.validateLicensePaymentModel(licensePaymentModel); const license = await this.getLicenseOrThrowInvalid(licensePaymentModel); @@ -30,7 +30,9 @@ export default class LicensePaymentMethod * Validates the license code activation on the storage. * @param {ILicensePaymentModel} licensePaymentModel - */ - async getLicenseOrThrowInvalid(licensePaymentModel: ILicensePaymentModel) { + private async getLicenseOrThrowInvalid( + licensePaymentModel: ILicensePaymentModel + ) { const foundLicense = await License.query() .modify('filterActiveLicense') .where('license_code', licensePaymentModel.licenseCode) @@ -47,7 +49,7 @@ export default class LicensePaymentMethod * @param {License} license * @param {Plan} plan */ - validatePaymentAmountWithPlan(license: License, plan: Plan) { + private validatePaymentAmountWithPlan(license: License, plan: Plan) { if (license.planId !== plan.id) { throw new PaymentAmountInvalidWithPlan(); } @@ -57,7 +59,7 @@ export default class LicensePaymentMethod * Validate voucher payload. * @param {ILicensePaymentModel} licenseModel - */ - validateLicensePaymentModel(licenseModel: ILicensePaymentModel) { + private validateLicensePaymentModel(licenseModel: ILicensePaymentModel) { if (!licenseModel || !licenseModel.licenseCode) { throw new VoucherCodeRequired(); } diff --git a/server/src/services/Subscription/Subscription.ts b/server/src/services/Subscription/Subscription.ts index 4b112f94b..67d186ac4 100644 --- a/server/src/services/Subscription/Subscription.ts +++ b/server/src/services/Subscription/Subscription.ts @@ -1,11 +1,10 @@ -import { Inject, Service } from 'typedi'; +import { Inject } from 'typedi'; import { Tenant, Plan } from 'system/models'; import { IPaymentContext } from 'interfaces'; import { NotAllowedChangeSubscriptionPlan } from 'exceptions'; -import { NoPaymentModelWithPricedPlan } from 'exceptions'; export default class Subscription { - paymentContext: IPaymentContext|null; + paymentContext: IPaymentContext | null; @Inject('logger') logger: any; @@ -19,46 +18,63 @@ export default class Subscription { } /** - * Subscripe to the given plan. - * @param {Plan} plan - * @throws {NotAllowedChangeSubscriptionPlan} + * Give the tenant a new subscription. + * @param {Tenant} tenant + * @param {Plan} plan + * @param {string} invoiceInterval + * @param {number} invoicePeriod + * @param {string} subscriptionSlug */ - async subscribe( - tenant: Tenant, - plan: Plan, - paymentModel?: PaymentModel, - subscriptionSlug: string = 'main', + protected async newSubscribtion( + tenant, + plan, + invoiceInterval: string, + invoicePeriod: number, + subscriptionSlug: string = 'main' ) { - this.validateIfPlanHasPriceNoPayment(plan, paymentModel); - - await this.paymentContext.makePayment(paymentModel, plan); - - const subscription = await tenant.$relatedQuery('subscriptions') + const subscription = await tenant + .$relatedQuery('subscriptions') .modify('subscriptionBySlug', subscriptionSlug) .first(); // No allowed to re-new the the subscription while the subscription is active. if (subscription && subscription.active()) { - throw new NotAllowedChangeSubscriptionPlan; + throw new NotAllowedChangeSubscriptionPlan(); - // In case there is already subscription associated to the given tenant renew it. - } else if(subscription && subscription.inactive()) { - await subscription.renew(plan); + // In case there is already subscription associated to the given tenant renew it. + } else if (subscription && subscription.inactive()) { + await subscription.renew(invoiceInterval, invoicePeriod); - // No stored past tenant subscriptions create new one. + // No stored past tenant subscriptions create new one. } else { - await tenant.newSubscription(subscriptionSlug, plan); - } + await tenant.newSubscription( + plan.id, + invoiceInterval, + invoicePeriod, + subscriptionSlug + ); + } } /** - * Throw error in plan has price and no payment model. - * @param {Plan} plan - - * @param {PaymentModel} paymentModel - payment input. + * Subscripe to the given plan. + * @param {Plan} plan + * @throws {NotAllowedChangeSubscriptionPlan} */ - validateIfPlanHasPriceNoPayment(plan: Plan, paymentModel: PaymentMode) { - if (plan.price > 0 && !paymentModel) { - throw new NoPaymentModelWithPricedPlan(); - } + public async subscribe( + tenant: Tenant, + plan: Plan, + paymentModel?: PaymentModel, + subscriptionSlug: string = 'main' + ) { + await this.paymentContext.makePayment(paymentModel, plan); + + return this.newSubscribtion( + tenant, + plan, + plan.invoiceInterval, + plan.invoicePeriod, + subscriptionSlug + ); } -} \ No newline at end of file +} diff --git a/server/src/services/Subscription/SubscriptionService.ts b/server/src/services/Subscription/SubscriptionService.ts index 50e857cea..1a5788fcd 100644 --- a/server/src/services/Subscription/SubscriptionService.ts +++ b/server/src/services/Subscription/SubscriptionService.ts @@ -1,11 +1,12 @@ import { Service, Inject } from 'typedi'; -import { Plan, PlanSubscription } from 'system/models'; +import { Plan, PlanSubscription, Tenant } from 'system/models'; import Subscription from 'services/Subscription/Subscription'; import LicensePaymentMethod from 'services/Payment/LicensePaymentMethod'; import PaymentContext from 'services/Payment'; import SubscriptionSMSMessages from 'services/Subscription/SMSMessages'; import SubscriptionMailMessages from 'services/Subscription/MailMessages'; import { ILicensePaymentModel } from 'interfaces'; +import SubscriptionViaLicense from './SubscriptionViaLicense'; @Service() export default class SubscriptionService { @@ -22,46 +23,47 @@ export default class SubscriptionService { sysRepositories: any; /** - * Handles the payment process via license code and than subscribe to + * Handles the payment process via license code and than subscribe to * the given tenant. - * @param {number} tenantId - * @param {String} planSlug - * @param {string} licenseCode + * @param {number} tenantId + * @param {String} planSlug + * @param {string} licenseCode * @return {Promise} */ public async subscriptionViaLicense( tenantId: number, planSlug: string, - paymentModel?: ILicensePaymentModel, - subscriptionSlug: string = 'main', + paymentModel: ILicensePaymentModel, + subscriptionSlug: string = 'main' ) { - this.logger.info('[subscription_via_license] try to subscribe via given license.', { - tenantId, paymentModel - }); - const { tenantRepository } = this.sysRepositories; - + // Retrieve plan details. const plan = await Plan.query().findOne('slug', planSlug); - const tenant = await tenantRepository.findOneById(tenantId); + // Retrieve tenant details. + const tenant = await Tenant.query().findById(tenantId); + + // License payment method. const paymentViaLicense = new LicensePaymentMethod(); + + // Payment context. const paymentContext = new PaymentContext(paymentViaLicense); - const subscription = new Subscription(paymentContext); + // Subscription. + const subscription = new SubscriptionViaLicense(paymentContext); + // Subscribe. await subscription.subscribe(tenant, plan, paymentModel, subscriptionSlug); - this.logger.info('[subscription_via_license] payment via license done successfully.', { - tenantId, paymentModel - }); } /** * Retrieve all subscription of the given tenant. - * @param {number} tenantId + * @param {number} tenantId */ public async getSubscriptions(tenantId: number) { - this.logger.info('[subscription] trying to get tenant subscriptions.', { tenantId }); - const subscriptions = await PlanSubscription.query().where('tenant_id', tenantId); - + const subscriptions = await PlanSubscription.query().where( + 'tenant_id', + tenantId + ); return subscriptions; } -} \ No newline at end of file +} diff --git a/server/src/services/Subscription/SubscriptionViaLicense.ts b/server/src/services/Subscription/SubscriptionViaLicense.ts new file mode 100644 index 000000000..ffeeb7b64 --- /dev/null +++ b/server/src/services/Subscription/SubscriptionViaLicense.ts @@ -0,0 +1,54 @@ +import { License, Tenant, Plan } from 'system/models'; +import Subscription from './Subscription'; +import { PaymentModel } from 'interfaces'; + +export default class SubscriptionViaLicense extends Subscription { + /** + * Subscripe to the given plan. + * @param {Plan} plan + * @throws {NotAllowedChangeSubscriptionPlan} + */ + public async subscribe( + tenant: Tenant, + plan: Plan, + paymentModel?: PaymentModel, + subscriptionSlug: string = 'main' + ): Promise { + await this.paymentContext.makePayment(paymentModel, plan); + + return this.newSubscriptionFromLicense( + tenant, + plan, + paymentModel.licenseCode, + subscriptionSlug + ); + } + + /** + * New subscription from the given license. + * @param {Tanant} tenant + * @param {Plab} plan + * @param {string} licenseCode + * @param {string} subscriptionSlug + * @returns {Promise} + */ + private async newSubscriptionFromLicense( + tenant, + plan, + licenseCode: string, + subscriptionSlug: string = 'main' + ): Promise { + // License information. + const licenseInfo = await License.query().findOne( + 'licenseCode', + licenseCode + ); + return this.newSubscribtion( + tenant, + plan, + licenseInfo.periodInterval, + licenseInfo.licensePeriod, + subscriptionSlug + ); + } +} diff --git a/server/src/services/Users/UsersService.ts b/server/src/services/Users/UsersService.ts index 3803c6a69..48a151add 100644 --- a/server/src/services/Users/UsersService.ts +++ b/server/src/services/Users/UsersService.ts @@ -16,9 +16,6 @@ const ERRORS = { @Service() export default class UsersService { - @Inject() - tenancy: TenancyService; - @Inject('logger') logger: any; diff --git a/server/src/system/models/Subscriptions/License.js b/server/src/system/models/Subscriptions/License.js index 7713fbf9d..09ec75947 100644 --- a/server/src/system/models/Subscriptions/License.js +++ b/server/src/system/models/Subscriptions/License.js @@ -41,18 +41,18 @@ export default class License extends SystemModel { // Filters licenses list. filter(builder, licensesFilter) { if (licensesFilter.active) { - builder.modify('filterActiveLicense') + builder.modify('filterActiveLicense'); } if (licensesFilter.disabled) { builder.whereNot('disabled_at', null); } if (licensesFilter.used) { builder.whereNot('used_at', null); - } + } if (licensesFilter.sent) { builder.whereNot('sent_at', null); } - } + }, }; } @@ -76,38 +76,32 @@ export default class License extends SystemModel { /** * Deletes the given license code from the storage. - * @param {string} licenseCode + * @param {string} licenseCode * @return {Promise} */ static deleteLicense(licenseCode, viaAttribute = 'license_code') { - return this.query() - .where(viaAttribute, licenseCode) - .delete(); + return this.query().where(viaAttribute, licenseCode).delete(); } /** * Marks the given license code as disabled on the storage. - * @param {string} licenseCode + * @param {string} licenseCode * @return {Promise} */ static markLicenseAsDisabled(licenseCode, viaAttribute = 'license_code') { - return this.query() - .where(viaAttribute, licenseCode) - .patch({ - disabled_at: moment().toMySqlDateTime(), - }); + return this.query().where(viaAttribute, licenseCode).patch({ + disabled_at: moment().toMySqlDateTime(), + }); } /** * Marks the given license code as sent on the storage. - * @param {string} licenseCode + * @param {string} licenseCode */ static markLicenseAsSent(licenseCode, viaAttribute = 'license_code') { - return this.query() - .where(viaAttribute, licenseCode) - .patch({ - sent_at: moment().toMySqlDateTime(), - }); + return this.query().where(viaAttribute, licenseCode).patch({ + sent_at: moment().toMySqlDateTime(), + }); } /** @@ -116,20 +110,20 @@ export default class License extends SystemModel { * @return {Promise} */ static markLicenseAsUsed(licenseCode, viaAttribute = 'license_code') { - return this.query() - .where(viaAttribute, licenseCode) - .patch({ - used_at: moment().toMySqlDateTime() - }); + return this.query().where(viaAttribute, licenseCode).patch({ + used_at: moment().toMySqlDateTime(), + }); } - + /** - * - * @param {IIPlan} plan + * + * @param {IIPlan} plan * @return {boolean} */ isEqualPlanPeriod(plan) { - return (this.invoicePeriod === plan.invoiceInterval && - license.licensePeriod === license.periodInterval); + return ( + this.invoicePeriod === plan.invoiceInterval && + license.licensePeriod === license.periodInterval + ); } } diff --git a/server/src/system/models/Tenant.js b/server/src/system/models/Tenant.js index 0dc5110b6..cb0718987 100644 --- a/server/src/system/models/Tenant.js +++ b/server/src/system/models/Tenant.js @@ -4,6 +4,7 @@ import uniqid from 'uniqid'; import SubscriptionPeriod from 'services/Subscription/SubscriptionPeriod'; import BaseModel from 'models/Model'; import TenantMetadata from './TenantMetadata'; +import PlanSubscription from './Subscriptions/PlanSubscription'; export default class Tenant extends BaseModel { /** @@ -88,20 +89,40 @@ export default class Tenant extends BaseModel { return chain(subscriptions).map('planId').unq(); } + /** + * + * @param {*} planId + * @param {*} invoiceInterval + * @param {*} invoicePeriod + * @param {*} subscriptionSlug + * @returns + */ + newSubscription(planId, invoiceInterval, invoicePeriod, subscriptionSlug) { + return Tenant.newSubscription( + this.id, + planId, + invoiceInterval, + invoicePeriod, + subscriptionSlug, + ); + } + /** * Records a new subscription for the associated tenant. */ - newSubscription(subscriptionSlug, plan) { - const trial = new SubscriptionPeriod(plan.trialInterval, plan.trialPeriod); - const period = new SubscriptionPeriod( - plan.invoiceInterval, - plan.invoicePeriod, - trial.getEndDate() - ); + static newSubscription( + tenantId, + planId, + invoiceInterval, + invoicePeriod, + subscriptionSlug + ) { + const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod); - return this.$relatedQuery('subscriptions').insert({ + return PlanSubscription.query().insert({ + tenantId, slug: subscriptionSlug, - planId: plan.id, + planId, startsAt: period.getStartDate(), endsAt: period.getEndDate(), });