From 4d616e92870e5a952c580d9fe2a1cf81c0ea549e Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 13 Apr 2024 10:17:48 +0200 Subject: [PATCH] Revert "feat(server): deprecated the subscription module." This reverts commit 44fc26b156b219a49cd95c4800a3ab2130e6a92e. --- .../src/api/controllers/Organization.ts | 6 +- .../api/controllers/Subscription/Licenses.ts | 250 ++++++++++++++++++ .../controllers/Subscription/PaymentMethod.ts | 31 +++ .../Subscription/PaymentViaLicense.ts | 125 +++++++++ .../src/api/controllers/Subscription/index.ts | 49 ++++ packages/server/src/api/index.ts | 6 + .../api/middleware/SubscriptionMiddleware.ts | 41 +++ ...091642_create_subscriptions_plans_table.js | 22 ++ .../20200823234134_create_plans_table.js | 30 +++ ...234434_create_subscription_plan_feature.js | 15 ++ ...6_create_subscription_plan_subscription.js | 22 ++ ...5339_create_subscription_licenses_table.js | 22 ++ 12 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/api/controllers/Subscription/Licenses.ts create mode 100644 packages/server/src/api/controllers/Subscription/PaymentMethod.ts create mode 100644 packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts create mode 100644 packages/server/src/api/controllers/Subscription/index.ts create mode 100644 packages/server/src/api/middleware/SubscriptionMiddleware.ts create mode 100644 packages/server/src/system/migrations/20200527091642_create_subscriptions_plans_table.js create mode 100644 packages/server/src/system/migrations/20200823234134_create_plans_table.js create mode 100644 packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js create mode 100644 packages/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js create mode 100644 packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js diff --git a/packages/server/src/api/controllers/Organization.ts b/packages/server/src/api/controllers/Organization.ts index 746e4f5be..c50f10fef 100644 --- a/packages/server/src/api/controllers/Organization.ts +++ b/packages/server/src/api/controllers/Organization.ts @@ -6,6 +6,7 @@ import { check, ValidationChain } from 'express-validator'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import JWTAuth from '@/api/middleware/jwtAuth'; import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; +import SubscriptionMiddleware from '@/api/middleware/SubscriptionMiddleware'; import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; import OrganizationService from '@/services/Organization/OrganizationService'; import { MONTHS, ACCEPTED_LOCALES } from '@/services/Organization/constants'; @@ -17,7 +18,7 @@ import BaseController from '@/api/controllers/BaseController'; @Service() export default class OrganizationController extends BaseController { @Inject() - private organizationService: OrganizationService; + organizationService: OrganizationService; /** * Router constructor. @@ -25,10 +26,13 @@ export default class OrganizationController extends BaseController { router() { const router = Router(); + // Should before build tenant database the user be authorized and + // most important than that, should be subscribed to any plan. router.use(JWTAuth); router.use(AttachCurrentTenantUser); router.use(TenancyMiddleware); + router.use('/build', SubscriptionMiddleware('main')); router.post( '/build', this.buildOrganizationValidationSchema, diff --git a/packages/server/src/api/controllers/Subscription/Licenses.ts b/packages/server/src/api/controllers/Subscription/Licenses.ts new file mode 100644 index 000000000..cf483f1a1 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/Licenses.ts @@ -0,0 +1,250 @@ +import { Service, Inject } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import { check, oneOf, ValidationChain } from 'express-validator'; +import basicAuth from 'express-basic-auth'; +import config from '@/config'; +import { License } from '@/system/models'; +import { ServiceError } from '@/exceptions'; +import BaseController from '@/api/controllers/BaseController'; +import LicenseService from '@/services/Payment/License'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import { ILicensesFilter, ISendLicenseDTO } from '@/interfaces'; + +@Service() +export default class LicensesController extends BaseController { + @Inject() + licenseService: LicenseService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use( + basicAuth({ + users: { + [config.licensesAuth.user]: config.licensesAuth.password, + }, + challenge: true, + }) + ); + router.post( + '/generate', + this.generateLicenseSchema, + this.validationResult, + asyncMiddleware(this.generateLicense.bind(this)), + this.catchServiceErrors, + ); + router.post( + '/disable/:licenseId', + this.validationResult, + asyncMiddleware(this.disableLicense.bind(this)), + this.catchServiceErrors, + ); + router.post( + '/send', + this.sendLicenseSchemaValidation, + this.validationResult, + asyncMiddleware(this.sendLicense.bind(this)), + this.catchServiceErrors, + ); + router.delete( + '/:licenseId', + asyncMiddleware(this.deleteLicense.bind(this)), + this.catchServiceErrors, + ); + router.get('/', asyncMiddleware(this.listLicenses.bind(this))); + return router; + } + + /** + * Generate license validation schema. + */ + get generateLicenseSchema(): ValidationChain[] { + return [ + check('loop').exists().isNumeric().toInt(), + check('period').exists().isNumeric().toInt(), + check('period_interval') + .exists() + .isIn(['month', 'months', 'year', 'years', 'day', 'days']), + check('plan_slug').exists().trim().escape(), + ]; + } + + /** + * Specific license validation schema. + */ + get specificLicenseSchema(): ValidationChain[] { + return [ + oneOf( + [check('license_id').exists().isNumeric().toInt()], + [check('license_code').exists().isNumeric().toInt()] + ), + ]; + } + + /** + * Send license validation schema. + */ + get sendLicenseSchemaValidation(): ValidationChain[] { + return [ + check('period').exists().isNumeric(), + check('period_interval').exists().trim().escape(), + check('plan_slug').exists().trim().escape(), + oneOf([ + check('phone_number').exists().trim().escape(), + check('email').exists().trim().escape(), + ]), + ]; + } + + /** + * Generate licenses codes with given period in bulk. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async generateLicense(req: Request, res: Response, next: Function) { + const { loop = 10, period, periodInterval, planSlug } = this.matchedBodyData( + req + ); + + try { + await this.licenseService.generateLicenses( + loop, + period, + periodInterval, + planSlug + ); + return res.status(200).send({ + code: 100, + type: 'LICENSEES.GENERATED.SUCCESSFULLY', + message: 'The licenses have been generated successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Disable the given license on the storage. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async disableLicense(req: Request, res: Response, next: Function) { + const { licenseId } = req.params; + + try { + await this.licenseService.disableLicense(licenseId); + + return res.status(200).send({ license_id: licenseId }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the given license code on the storage. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async deleteLicense(req: Request, res: Response, next: Function) { + const { licenseId } = req.params; + + try { + await this.licenseService.deleteLicense(licenseId); + + return res.status(200).send({ license_id: licenseId }); + } catch (error) { + next(error) + } + } + + /** + * Send license code in the given period to the customer via email or phone number + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async sendLicense(req: Request, res: Response, next: Function) { + const sendLicenseDTO: ISendLicenseDTO = this.matchedBodyData(req); + + try { + await this.licenseService.sendLicenseToCustomer(sendLicenseDTO); + + return res.status(200).send({ + status: 100, + code: 'LICENSE.CODE.SENT', + message: 'The license has been sent to the given customer.', + }); + } catch (error) { + next(error); + } + } + + /** + * Listing licenses. + * @param {Request} req + * @param {Response} res + */ + async listLicenses(req: Request, res: Response) { + const filter: ILicensesFilter = { + disabled: false, + used: false, + sent: false, + active: false, + ...req.query, + }; + const licenses = await License.query().onBuild((builder) => { + builder.modify('filter', filter); + builder.orderBy('createdAt', 'ASC'); + }); + return res.status(200).send({ licenses }); + } + + /** + * Catches all service errors. + */ + catchServiceErrors(error, req: Request, res: Response, next: NextFunction) { + if (error instanceof ServiceError) { + if (error.errorType === 'PLAN_NOT_FOUND') { + return res.status(400).send({ + errors: [{ + type: 'PLAN.NOT.FOUND', + code: 100, + message: 'The given plan not found.', + }], + }); + } + if (error.errorType === 'LICENSE_NOT_FOUND') { + return res.status(400).send({ + errors: [{ + type: 'LICENSE_NOT_FOUND', + code: 200, + message: 'The given license id not found.' + }], + }); + } + if (error.errorType === 'LICENSE_ALREADY_DISABLED') { + return res.status(400).send({ + errors: [{ + type: 'LICENSE.ALREADY.DISABLED', + code: 200, + message: 'License is already disabled.' + }], + }); + } + if (error.errorType === 'NO_AVALIABLE_LICENSE_CODE') { + return res.status(400).send({ + status: 110, + message: 'There is no licenses availiable right now with the given period and plan.', + code: 'NO.AVALIABLE.LICENSE.CODE', + }); + } + } + next(error); + } +} diff --git a/packages/server/src/api/controllers/Subscription/PaymentMethod.ts b/packages/server/src/api/controllers/Subscription/PaymentMethod.ts new file mode 100644 index 000000000..2c954c307 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/PaymentMethod.ts @@ -0,0 +1,31 @@ +import { Inject } from 'typedi'; +import { Request, Response } from 'express'; +import { Plan } from '@/system/models'; +import BaseController from '@/api/controllers/BaseController'; +import SubscriptionService from '@/services/Subscription/SubscriptionService'; + +export default class PaymentMethodController extends BaseController { + @Inject() + subscriptionService: SubscriptionService; + + /** + * Validate the given plan slug exists on the storage. + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * + * @return {Response|void} + */ + async validatePlanSlugExistance(req: Request, res: Response, next: Function) { + const { planSlug } = this.matchedBodyData(req); + const foundPlan = await Plan.query().where('slug', planSlug).first(); + + if (!foundPlan) { + return res.status(400).send({ + errors: [{ type: 'PLAN.SLUG.NOT.EXISTS', code: 110 }], + }); + } + next(); + } +} \ No newline at end of file diff --git a/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts b/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts new file mode 100644 index 000000000..7cf07c656 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/PaymentViaLicense.ts @@ -0,0 +1,125 @@ +import { Inject, Service } from 'typedi'; +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'; +import { + NotAllowedChangeSubscriptionPlan, + NoPaymentModelWithPricedPlan, + PaymentAmountInvalidWithPlan, + PaymentInputInvalid, + VoucherCodeRequired, +} from '@/exceptions'; +import { ILicensePaymentModel } from '@/interfaces'; +import instance from 'tsyringe/dist/typings/dependency-container'; + +@Service() +export default class PaymentViaLicenseController extends PaymentMethodController { + @Inject('logger') + logger: any; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post( + '/payment', + this.paymentViaLicenseSchema, + this.validationResult, + asyncMiddleware(this.validatePlanSlugExistance.bind(this)), + asyncMiddleware(this.paymentViaLicense.bind(this)), + this.handleErrors, + ); + return router; + } + + /** + * Payment via license validation schema. + */ + get paymentViaLicenseSchema() { + return [ + check('plan_slug').exists().trim().escape(), + check('license_code').exists().trim().escape(), + ]; + } + + /** + * Handle the subscription payment via license code. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async paymentViaLicense(req: Request, res: Response, next: Function) { + const { planSlug, licenseCode } = this.matchedBodyData(req); + const { tenant } = req; + + try { + const licenseModel: ILicensePaymentModel = { licenseCode }; + + await this.subscriptionService.subscriptionViaLicense( + tenant.id, + planSlug, + licenseModel + ); + + return res.status(200).send({ + type: 'success', + code: 'PAYMENT.SUCCESSFULLY.MADE', + message: 'Payment via license has been made successfully.', + }); + } catch (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); + } +} diff --git a/packages/server/src/api/controllers/Subscription/index.ts b/packages/server/src/api/controllers/Subscription/index.ts new file mode 100644 index 000000000..6145e7551 --- /dev/null +++ b/packages/server/src/api/controllers/Subscription/index.ts @@ -0,0 +1,49 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import { Container, Service, Inject } from 'typedi'; +import JWTAuth from '@/api/middleware/jwtAuth'; +import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; +import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; +import PaymentViaLicenseController from '@/api/controllers/Subscription/PaymentViaLicense'; +import SubscriptionService from '@/services/Subscription/SubscriptionService'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; + +@Service() +export default class SubscriptionController { + @Inject() + subscriptionService: SubscriptionService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use(JWTAuth); + router.use(AttachCurrentTenantUser); + router.use(TenancyMiddleware); + + router.use('/license', Container.get(PaymentViaLicenseController).router()); + router.get('/', asyncMiddleware(this.getSubscriptions.bind(this))); + + return router; + } + + /** + * Retrieve all subscriptions of the authenticated user's tenant. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async getSubscriptions(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + + try { + const subscriptions = await this.subscriptionService.getSubscriptions( + tenantId + ); + return res.status(200).send({ subscriptions }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/index.ts b/packages/server/src/api/index.ts index cc1206aee..f60de22d2 100644 --- a/packages/server/src/api/index.ts +++ b/packages/server/src/api/index.ts @@ -4,6 +4,7 @@ import { Container } from 'typedi'; // Middlewares import JWTAuth from '@/api/middleware/jwtAuth'; import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; +import SubscriptionMiddleware from '@/api/middleware/SubscriptionMiddleware'; import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; import EnsureTenantIsInitialized from '@/api/middleware/EnsureTenantIsInitialized'; import SettingsMiddleware from '@/api/middleware/SettingsMiddleware'; @@ -36,6 +37,8 @@ import Resources from './controllers/Resources'; import ExchangeRates from '@/api/controllers/ExchangeRates'; import Media from '@/api/controllers/Media'; import Ping from '@/api/controllers/Ping'; +import Subscription from '@/api/controllers/Subscription'; +import Licenses from '@/api/controllers/Subscription/Licenses'; import InventoryAdjustments from '@/api/controllers/Inventory/InventoryAdjustments'; import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware'; import Jobs from './controllers/Jobs'; @@ -70,6 +73,8 @@ export default () => { app.use('/auth', Container.get(Authentication).router()); app.use('/invite', Container.get(InviteUsers).nonAuthRouter()); + app.use('/licenses', Container.get(Licenses).router()); + app.use('/subscription', Container.get(Subscription).router()); app.use('/organization', Container.get(Organization).router()); app.use('/ping', Container.get(Ping).router()); app.use('/jobs', Container.get(Jobs).router()); @@ -83,6 +88,7 @@ export default () => { dashboard.use(JWTAuth); dashboard.use(AttachCurrentTenantUser); dashboard.use(TenancyMiddleware); + dashboard.use(SubscriptionMiddleware('main')); dashboard.use(EnsureTenantIsInitialized); dashboard.use(SettingsMiddleware); dashboard.use(I18nAuthenticatedMiddlware); diff --git a/packages/server/src/api/middleware/SubscriptionMiddleware.ts b/packages/server/src/api/middleware/SubscriptionMiddleware.ts new file mode 100644 index 000000000..ce7d45258 --- /dev/null +++ b/packages/server/src/api/middleware/SubscriptionMiddleware.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from 'express'; +import { Container } from 'typedi'; + +export default (subscriptionSlug = 'main') => async ( + req: Request, + res: Response, + next: NextFunction +) => { + const { tenant, tenantId } = req; + const Logger = Container.get('logger'); + const { subscriptionRepository } = Container.get('repositories'); + + if (!tenant) { + throw new Error('Should load `TenancyMiddlware` before this middleware.'); + } + Logger.info('[subscription_middleware] trying get tenant main subscription.'); + const subscription = await subscriptionRepository.getBySlugInTenant( + subscriptionSlug, + tenantId + ); + // Validate in case there is no any already subscription. + if (!subscription) { + Logger.info('[subscription_middleware] tenant has no subscription.', { + tenantId, + }); + return res.boom.badRequest('Tenant has no subscription.', { + errors: [{ type: 'TENANT.HAS.NO.SUBSCRIPTION' }], + }); + } + // Validate in case the subscription is inactive. + else if (subscription.inactive()) { + Logger.info( + '[subscription_middleware] tenant main subscription is expired.', + { tenantId } + ); + return res.boom.badRequest(null, { + errors: [{ type: 'ORGANIZATION.SUBSCRIPTION.INACTIVE' }], + }); + } + next(); +}; diff --git a/packages/server/src/system/migrations/20200527091642_create_subscriptions_plans_table.js b/packages/server/src/system/migrations/20200527091642_create_subscriptions_plans_table.js new file mode 100644 index 000000000..09d890648 --- /dev/null +++ b/packages/server/src/system/migrations/20200527091642_create_subscriptions_plans_table.js @@ -0,0 +1,22 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscriptions_plans', table => { + table.increments(); + + table.string('name'); + table.string('description'); + table.decimal('price'); + table.string('currency', 3); + + table.integer('trial_period'); + table.string('trial_interval'); + + table.integer('invoice_period'); + table.string('invoice_interval'); + table.timestamps(); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscriptions_plans') +}; diff --git a/packages/server/src/system/migrations/20200823234134_create_plans_table.js b/packages/server/src/system/migrations/20200823234134_create_plans_table.js new file mode 100644 index 000000000..2fc61a43a --- /dev/null +++ b/packages/server/src/system/migrations/20200823234134_create_plans_table.js @@ -0,0 +1,30 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_plans', table => { + table.increments(); + table.string('slug'); + table.string('name'); + table.string('desc'); + table.boolean('active'); + + table.decimal('price').unsigned(); + table.string('currency', 3); + + table.decimal('trial_period').nullable(); + table.string('trial_interval').nullable(); + + table.decimal('invoice_period').nullable(); + table.string('invoice_interval').nullable(); + + table.integer('index').unsigned(); + table.timestamps(); + }).then(() => { + return knex.seed.run({ + specific: 'seed_subscriptions_plans.js', + }); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_plans') +}; diff --git a/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js b/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js new file mode 100644 index 000000000..43fea2798 --- /dev/null +++ b/packages/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js @@ -0,0 +1,15 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_plan_features', table => { + table.increments(); + table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); + table.string('slug'); + table.string('name'); + table.string('description'); + table.timestamps(); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_plan_features'); +}; diff --git a/packages/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js b/packages/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js new file mode 100644 index 000000000..267be4614 --- /dev/null +++ b/packages/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js @@ -0,0 +1,22 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_plan_subscriptions', table => { + table.increments('id'); + table.string('slug'); + + table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); + table.bigInteger('tenant_id').unsigned().index().references('id').inTable('tenants'); + + table.dateTime('starts_at').nullable(); + table.dateTime('ends_at').nullable(); + + table.dateTime('cancels_at').nullable(); + table.dateTime('canceled_at').nullable(); + + table.timestamps(); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_plan_subscriptions'); +}; diff --git a/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js b/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js new file mode 100644 index 000000000..6babd6f03 --- /dev/null +++ b/packages/server/src/system/migrations/20200823235339_create_subscription_licenses_table.js @@ -0,0 +1,22 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_licenses', (table) => { + table.increments(); + + table.string('license_code').unique().index(); + table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans'); + + table.integer('license_period').unsigned(); + table.string('period_interval'); + + table.dateTime('sent_at').index(); + table.dateTime('disabled_at').index(); + table.dateTime('used_at').index(); + + table.timestamps(); + }) +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_licenses'); +};