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 { 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);
}
}
}
/**
* 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 {
constructor(message: string) {
constructor() {
this.name = "NotAllowedChangeSubscriptionPlan";
}
}

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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<PaymentModel> {
paymentContext: IPaymentContext|null;
paymentContext: IPaymentContext | null;
@Inject('logger')
logger: any;
@@ -19,46 +18,63 @@ export default class Subscription<PaymentModel> {
}
/**
* 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
);
}
}
}

View File

@@ -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;
}
}
}

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()
export default class UsersService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;

View File

@@ -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
);
}
}

View File

@@ -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(),
});