feat: subscription period based on the license code.

This commit is contained in:
a.bouhuolia
2021-09-08 15:56:18 +02:00
parent 02ef195af0
commit 97c3fd9e0c
10 changed files with 252 additions and 146 deletions

View File

@@ -1,5 +1,5 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { Router, Request, Response } from 'express'; import { NextFunction, Router, Request, Response } from 'express';
import { check } from 'express-validator'; import { check } from 'express-validator';
import asyncMiddleware from 'api/middleware/asyncMiddleware'; import asyncMiddleware from 'api/middleware/asyncMiddleware';
import PaymentMethodController from 'api/controllers/Subscription/PaymentMethod'; import PaymentMethodController from 'api/controllers/Subscription/PaymentMethod';
@@ -8,7 +8,7 @@ import {
NoPaymentModelWithPricedPlan, NoPaymentModelWithPricedPlan,
PaymentAmountInvalidWithPlan, PaymentAmountInvalidWithPlan,
PaymentInputInvalid, PaymentInputInvalid,
VoucherCodeRequired VoucherCodeRequired,
} from 'exceptions'; } from 'exceptions';
import { ILicensePaymentModel } from 'interfaces'; import { ILicensePaymentModel } from 'interfaces';
import instance from 'tsyringe/dist/typings/dependency-container'; import instance from 'tsyringe/dist/typings/dependency-container';
@@ -30,6 +30,7 @@ export default class PaymentViaLicenseController extends PaymentMethodController
this.validationResult, this.validationResult,
asyncMiddleware(this.validatePlanSlugExistance.bind(this)), asyncMiddleware(this.validatePlanSlugExistance.bind(this)),
asyncMiddleware(this.paymentViaLicense.bind(this)), asyncMiddleware(this.paymentViaLicense.bind(this)),
this.handleErrors,
); );
return router; return router;
} }
@@ -40,7 +41,7 @@ export default class PaymentViaLicenseController extends PaymentMethodController
get paymentViaLicenseSchema() { get paymentViaLicenseSchema() {
return [ return [
check('plan_slug').exists().trim().escape(), check('plan_slug').exists().trim().escape(),
check('license_code').optional().trim().escape(), check('license_code').exists().trim().escape(),
]; ];
} }
@@ -55,11 +56,13 @@ export default class PaymentViaLicenseController extends PaymentMethodController
const { tenant } = req; const { tenant } = req;
try { try {
const licenseModel: ILicensePaymentModel|null = licenseCode const licenseModel: ILicensePaymentModel = { licenseCode };
? { licenseCode } : null;
await this.subscriptionService await this.subscriptionService.subscriptionViaLicense(
.subscriptionViaLicense(tenant.id, planSlug, licenseModel); tenant.id,
planSlug,
licenseModel
);
return res.status(200).send({ return res.status(200).send({
type: 'success', type: 'success',
@@ -67,39 +70,56 @@ export default class PaymentViaLicenseController extends PaymentMethodController
message: 'Payment via license has been made successfully.', message: 'Payment via license has been made successfully.',
}); });
} catch (exception) { } 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); next(exception);
} }
} }
/**
* 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);
}
} }

View File

@@ -2,7 +2,7 @@
export default class NotAllowedChangeSubscriptionPlan { export default class NotAllowedChangeSubscriptionPlan {
constructor(message: string) { constructor() {
this.name = "NotAllowedChangeSubscriptionPlan"; this.name = "NotAllowedChangeSubscriptionPlan";
} }
} }

View File

@@ -7,7 +7,7 @@ import {
EventDispatcher, EventDispatcher,
EventDispatcherInterface, EventDispatcherInterface,
} from 'decorators/eventDispatcher'; } from 'decorators/eventDispatcher';
import { PasswordReset } from 'system/models'; import { PasswordReset, Tenant } from 'system/models';
import { import {
IRegisterDTO, IRegisterDTO,
ITenant, ITenant,
@@ -117,7 +117,7 @@ export default class AuthenticationService implements IAuthenticationService {
password, password,
user, user,
}); });
const tenant = await user.$relatedQuery('tenant'); const tenant = await Tenant.query().findById(user.tenantId).withGraphFetched('metadata');
// Keep the user object immutable. // Keep the user object immutable.
const outputUser = cloneDeep(user); const outputUser = cloneDeep(user);

View File

@@ -2,7 +2,6 @@ import { License } from 'system/models';
import PaymentMethod from 'services/Payment/PaymentMethod'; import PaymentMethod from 'services/Payment/PaymentMethod';
import { Plan } from 'system/models'; import { Plan } from 'system/models';
import { IPaymentMethod, ILicensePaymentModel } from 'interfaces'; import { IPaymentMethod, ILicensePaymentModel } from 'interfaces';
import { ILicensePaymentModel } from 'interfaces';
import { import {
PaymentInputInvalid, PaymentInputInvalid,
PaymentAmountInvalidWithPlan, PaymentAmountInvalidWithPlan,
@@ -11,12 +10,13 @@ import {
export default class LicensePaymentMethod export default class LicensePaymentMethod
extends PaymentMethod extends PaymentMethod
implements IPaymentMethod { implements IPaymentMethod
{
/** /**
* Payment subscription of organization via license code. * Payment subscription of organization via license code.
* @param {ILicensePaymentModel} licensePaymentModel - * @param {ILicensePaymentModel} licensePaymentModel -
*/ */
async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) { public async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) {
this.validateLicensePaymentModel(licensePaymentModel); this.validateLicensePaymentModel(licensePaymentModel);
const license = await this.getLicenseOrThrowInvalid(licensePaymentModel); const license = await this.getLicenseOrThrowInvalid(licensePaymentModel);
@@ -30,7 +30,9 @@ export default class LicensePaymentMethod
* Validates the license code activation on the storage. * Validates the license code activation on the storage.
* @param {ILicensePaymentModel} licensePaymentModel - * @param {ILicensePaymentModel} licensePaymentModel -
*/ */
async getLicenseOrThrowInvalid(licensePaymentModel: ILicensePaymentModel) { private async getLicenseOrThrowInvalid(
licensePaymentModel: ILicensePaymentModel
) {
const foundLicense = await License.query() const foundLicense = await License.query()
.modify('filterActiveLicense') .modify('filterActiveLicense')
.where('license_code', licensePaymentModel.licenseCode) .where('license_code', licensePaymentModel.licenseCode)
@@ -47,7 +49,7 @@ export default class LicensePaymentMethod
* @param {License} license * @param {License} license
* @param {Plan} plan * @param {Plan} plan
*/ */
validatePaymentAmountWithPlan(license: License, plan: Plan) { private validatePaymentAmountWithPlan(license: License, plan: Plan) {
if (license.planId !== plan.id) { if (license.planId !== plan.id) {
throw new PaymentAmountInvalidWithPlan(); throw new PaymentAmountInvalidWithPlan();
} }
@@ -57,7 +59,7 @@ export default class LicensePaymentMethod
* Validate voucher payload. * Validate voucher payload.
* @param {ILicensePaymentModel} licenseModel - * @param {ILicensePaymentModel} licenseModel -
*/ */
validateLicensePaymentModel(licenseModel: ILicensePaymentModel) { private validateLicensePaymentModel(licenseModel: ILicensePaymentModel) {
if (!licenseModel || !licenseModel.licenseCode) { if (!licenseModel || !licenseModel.licenseCode) {
throw new VoucherCodeRequired(); throw new VoucherCodeRequired();
} }

View File

@@ -1,11 +1,10 @@
import { Inject, Service } from 'typedi'; import { Inject } from 'typedi';
import { Tenant, Plan } from 'system/models'; import { Tenant, Plan } from 'system/models';
import { IPaymentContext } from 'interfaces'; import { IPaymentContext } from 'interfaces';
import { NotAllowedChangeSubscriptionPlan } from 'exceptions'; import { NotAllowedChangeSubscriptionPlan } from 'exceptions';
import { NoPaymentModelWithPricedPlan } from 'exceptions';
export default class Subscription<PaymentModel> { export default class Subscription<PaymentModel> {
paymentContext: IPaymentContext|null; paymentContext: IPaymentContext | null;
@Inject('logger') @Inject('logger')
logger: any; logger: any;
@@ -19,46 +18,63 @@ export default class Subscription<PaymentModel> {
} }
/** /**
* Subscripe to the given plan. * Give the tenant a new subscription.
* @param {Plan} plan * @param {Tenant} tenant
* @throws {NotAllowedChangeSubscriptionPlan} * @param {Plan} plan
* @param {string} invoiceInterval
* @param {number} invoicePeriod
* @param {string} subscriptionSlug
*/ */
async subscribe( protected async newSubscribtion(
tenant: Tenant, tenant,
plan: Plan, plan,
paymentModel?: PaymentModel, invoiceInterval: string,
subscriptionSlug: string = 'main', invoicePeriod: number,
subscriptionSlug: string = 'main'
) { ) {
this.validateIfPlanHasPriceNoPayment(plan, paymentModel); const subscription = await tenant
.$relatedQuery('subscriptions')
await this.paymentContext.makePayment(paymentModel, plan);
const subscription = await tenant.$relatedQuery('subscriptions')
.modify('subscriptionBySlug', subscriptionSlug) .modify('subscriptionBySlug', subscriptionSlug)
.first(); .first();
// No allowed to re-new the the subscription while the subscription is active. // No allowed to re-new the the subscription while the subscription is active.
if (subscription && subscription.active()) { if (subscription && subscription.active()) {
throw new NotAllowedChangeSubscriptionPlan; throw new NotAllowedChangeSubscriptionPlan();
// In case there is already subscription associated to the given tenant renew it. // In case there is already subscription associated to the given tenant renew it.
} else if(subscription && subscription.inactive()) { } else if (subscription && subscription.inactive()) {
await subscription.renew(plan); await subscription.renew(invoiceInterval, invoicePeriod);
// No stored past tenant subscriptions create new one. // No stored past tenant subscriptions create new one.
} else { } else {
await tenant.newSubscription(subscriptionSlug, plan); await tenant.newSubscription(
plan.id,
invoiceInterval,
invoicePeriod,
subscriptionSlug
);
} }
} }
/** /**
* Throw error in plan has price and no payment model. * Subscripe to the given plan.
* @param {Plan} plan - * @param {Plan} plan
* @param {PaymentModel} paymentModel - payment input. * @throws {NotAllowedChangeSubscriptionPlan}
*/ */
validateIfPlanHasPriceNoPayment(plan: Plan, paymentModel: PaymentMode) { public async subscribe(
if (plan.price > 0 && !paymentModel) { tenant: Tenant,
throw new NoPaymentModelWithPricedPlan(); plan: Plan,
} paymentModel?: PaymentModel,
subscriptionSlug: string = 'main'
) {
await this.paymentContext.makePayment(paymentModel, plan);
return this.newSubscribtion(
tenant,
plan,
plan.invoiceInterval,
plan.invoicePeriod,
subscriptionSlug
);
} }
} }

View File

@@ -1,11 +1,12 @@
import { Service, Inject } from 'typedi'; 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 Subscription from 'services/Subscription/Subscription';
import LicensePaymentMethod from 'services/Payment/LicensePaymentMethod'; import LicensePaymentMethod from 'services/Payment/LicensePaymentMethod';
import PaymentContext from 'services/Payment'; import PaymentContext from 'services/Payment';
import SubscriptionSMSMessages from 'services/Subscription/SMSMessages'; import SubscriptionSMSMessages from 'services/Subscription/SMSMessages';
import SubscriptionMailMessages from 'services/Subscription/MailMessages'; import SubscriptionMailMessages from 'services/Subscription/MailMessages';
import { ILicensePaymentModel } from 'interfaces'; import { ILicensePaymentModel } from 'interfaces';
import SubscriptionViaLicense from './SubscriptionViaLicense';
@Service() @Service()
export default class SubscriptionService { export default class SubscriptionService {
@@ -32,26 +33,26 @@ export default class SubscriptionService {
public async subscriptionViaLicense( public async subscriptionViaLicense(
tenantId: number, tenantId: number,
planSlug: string, planSlug: string,
paymentModel?: ILicensePaymentModel, paymentModel: ILicensePaymentModel,
subscriptionSlug: string = 'main', subscriptionSlug: string = 'main'
) { ) {
this.logger.info('[subscription_via_license] try to subscribe via given license.', { // Retrieve plan details.
tenantId, paymentModel
});
const { tenantRepository } = this.sysRepositories;
const plan = await Plan.query().findOne('slug', planSlug); 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(); const paymentViaLicense = new LicensePaymentMethod();
// Payment context.
const paymentContext = new PaymentContext(paymentViaLicense); const paymentContext = new PaymentContext(paymentViaLicense);
const subscription = new Subscription(paymentContext); // Subscription.
const subscription = new SubscriptionViaLicense(paymentContext);
// Subscribe.
await subscription.subscribe(tenant, plan, paymentModel, subscriptionSlug); await subscription.subscribe(tenant, plan, paymentModel, subscriptionSlug);
this.logger.info('[subscription_via_license] payment via license done successfully.', {
tenantId, paymentModel
});
} }
/** /**
@@ -59,9 +60,10 @@ export default class SubscriptionService {
* @param {number} tenantId * @param {number} tenantId
*/ */
public async getSubscriptions(tenantId: number) { public async getSubscriptions(tenantId: number) {
this.logger.info('[subscription] trying to get tenant subscriptions.', { tenantId }); const subscriptions = await PlanSubscription.query().where(
const subscriptions = await PlanSubscription.query().where('tenant_id', tenantId); 'tenant_id',
tenantId
);
return subscriptions; return subscriptions;
} }
} }

View File

@@ -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<PaymentModel> {
/**
* Subscripe to the given plan.
* @param {Plan} plan
* @throws {NotAllowedChangeSubscriptionPlan}
*/
public async subscribe(
tenant: Tenant,
plan: Plan,
paymentModel?: PaymentModel,
subscriptionSlug: string = 'main'
): Promise<void> {
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<void>}
*/
private async newSubscriptionFromLicense(
tenant,
plan,
licenseCode: string,
subscriptionSlug: string = 'main'
): Promise<void> {
// License information.
const licenseInfo = await License.query().findOne(
'licenseCode',
licenseCode
);
return this.newSubscribtion(
tenant,
plan,
licenseInfo.periodInterval,
licenseInfo.licensePeriod,
subscriptionSlug
);
}
}

View File

@@ -16,9 +16,6 @@ const ERRORS = {
@Service() @Service()
export default class UsersService { export default class UsersService {
@Inject()
tenancy: TenancyService;
@Inject('logger') @Inject('logger')
logger: any; logger: any;

View File

@@ -41,7 +41,7 @@ export default class License extends SystemModel {
// Filters licenses list. // Filters licenses list.
filter(builder, licensesFilter) { filter(builder, licensesFilter) {
if (licensesFilter.active) { if (licensesFilter.active) {
builder.modify('filterActiveLicense') builder.modify('filterActiveLicense');
} }
if (licensesFilter.disabled) { if (licensesFilter.disabled) {
builder.whereNot('disabled_at', null); builder.whereNot('disabled_at', null);
@@ -52,7 +52,7 @@ export default class License extends SystemModel {
if (licensesFilter.sent) { if (licensesFilter.sent) {
builder.whereNot('sent_at', null); builder.whereNot('sent_at', null);
} }
} },
}; };
} }
@@ -80,9 +80,7 @@ export default class License extends SystemModel {
* @return {Promise} * @return {Promise}
*/ */
static deleteLicense(licenseCode, viaAttribute = 'license_code') { static deleteLicense(licenseCode, viaAttribute = 'license_code') {
return this.query() return this.query().where(viaAttribute, licenseCode).delete();
.where(viaAttribute, licenseCode)
.delete();
} }
/** /**
@@ -91,11 +89,9 @@ export default class License extends SystemModel {
* @return {Promise} * @return {Promise}
*/ */
static markLicenseAsDisabled(licenseCode, viaAttribute = 'license_code') { static markLicenseAsDisabled(licenseCode, viaAttribute = 'license_code') {
return this.query() return this.query().where(viaAttribute, licenseCode).patch({
.where(viaAttribute, licenseCode) disabled_at: moment().toMySqlDateTime(),
.patch({ });
disabled_at: moment().toMySqlDateTime(),
});
} }
/** /**
@@ -103,11 +99,9 @@ export default class License extends SystemModel {
* @param {string} licenseCode * @param {string} licenseCode
*/ */
static markLicenseAsSent(licenseCode, viaAttribute = 'license_code') { static markLicenseAsSent(licenseCode, viaAttribute = 'license_code') {
return this.query() return this.query().where(viaAttribute, licenseCode).patch({
.where(viaAttribute, licenseCode) sent_at: moment().toMySqlDateTime(),
.patch({ });
sent_at: moment().toMySqlDateTime(),
});
} }
/** /**
@@ -116,11 +110,9 @@ export default class License extends SystemModel {
* @return {Promise} * @return {Promise}
*/ */
static markLicenseAsUsed(licenseCode, viaAttribute = 'license_code') { static markLicenseAsUsed(licenseCode, viaAttribute = 'license_code') {
return this.query() return this.query().where(viaAttribute, licenseCode).patch({
.where(viaAttribute, licenseCode) used_at: moment().toMySqlDateTime(),
.patch({ });
used_at: moment().toMySqlDateTime()
});
} }
/** /**
@@ -129,7 +121,9 @@ export default class License extends SystemModel {
* @return {boolean} * @return {boolean}
*/ */
isEqualPlanPeriod(plan) { isEqualPlanPeriod(plan) {
return (this.invoicePeriod === plan.invoiceInterval && return (
license.licensePeriod === license.periodInterval); this.invoicePeriod === plan.invoiceInterval &&
license.licensePeriod === license.periodInterval
);
} }
} }

View File

@@ -4,6 +4,7 @@ import uniqid from 'uniqid';
import SubscriptionPeriod from 'services/Subscription/SubscriptionPeriod'; import SubscriptionPeriod from 'services/Subscription/SubscriptionPeriod';
import BaseModel from 'models/Model'; import BaseModel from 'models/Model';
import TenantMetadata from './TenantMetadata'; import TenantMetadata from './TenantMetadata';
import PlanSubscription from './Subscriptions/PlanSubscription';
export default class Tenant extends BaseModel { export default class Tenant extends BaseModel {
/** /**
@@ -88,20 +89,40 @@ export default class Tenant extends BaseModel {
return chain(subscriptions).map('planId').unq(); 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. * Records a new subscription for the associated tenant.
*/ */
newSubscription(subscriptionSlug, plan) { static newSubscription(
const trial = new SubscriptionPeriod(plan.trialInterval, plan.trialPeriod); tenantId,
const period = new SubscriptionPeriod( planId,
plan.invoiceInterval, invoiceInterval,
plan.invoicePeriod, invoicePeriod,
trial.getEndDate() subscriptionSlug
); ) {
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
return this.$relatedQuery('subscriptions').insert({ return PlanSubscription.query().insert({
tenantId,
slug: subscriptionSlug, slug: subscriptionSlug,
planId: plan.id, planId,
startsAt: period.getStartDate(), startsAt: period.getStartDate(),
endsAt: period.getEndDate(), endsAt: period.getEndDate(),
}); });