diff --git a/server/config/config.js b/server/config/config.js index 48260db25..d0fa748a9 100644 --- a/server/config/config.js +++ b/server/config/config.js @@ -7,6 +7,7 @@ module.exports = { db_password: 'root', db_name: 'bigcapital_system', migrations_dir: './src/system/migrations', + seeds_dir: './src/system/seeds', }, tenant: { db_client: 'mysql', @@ -51,4 +52,20 @@ module.exports = { user: 'agendash', password: '123456' }, + + /** + * Subscription config. + */ + subscription: { + user: 'root', + password: 'root', + }, + + SMSGateway: { + type: '', + endpoint: '', + }, + easySMSGateway: { + api_key: 'b0JDZW56RnV6aEthb0RGPXVEcUI' + } }; diff --git a/server/config/systemKnexfile.js b/server/config/systemKnexfile.js index 9fef0a0f0..7df56e7cc 100644 --- a/server/config/systemKnexfile.js +++ b/server/config/systemKnexfile.js @@ -12,6 +12,9 @@ const configEnv = { migrations: { directory: config.system.migrations_dir, }, + seeds: { + directory: config.system.seeds_dir, + }, pool: { min: 0, max: 7 }, }; diff --git a/server/package.json b/server/package.json index b29facc3b..f1e37ce8b 100644 --- a/server/package.json +++ b/server/package.json @@ -21,12 +21,14 @@ "agenda": "^3.1.0", "agendash": "^1.0.0", "app-root-path": "^3.0.0", + "axios": "^0.20.0", "bcryptjs": "^2.4.3", "bookshelf": "^0.15.1", "bookshelf-cascade-delete": "^2.0.1", "bookshelf-json-columns": "^2.1.1", "bookshelf-modelbase": "^2.10.4", "bookshelf-paranoia": "^0.13.1", + "crypto-random-string": "^3.2.0", "csurf": "^1.10.0", "dotenv": "^8.1.0", "errorhandler": "^1.5.1", @@ -55,6 +57,8 @@ "nodemailer": "^6.3.0", "nodemon": "^1.19.1", "objection": "^2.0.10", + "reflect-metadata": "^0.1.13", + "tsyringe": "^4.3.0", "uniqid": "^5.2.0", "winston": "^3.2.1" }, diff --git a/server/src/database/seeds/seed_subscriptions_plans.js b/server/src/database/seeds/seed_subscriptions_plans.js deleted file mode 100644 index 66963ea8d..000000000 --- a/server/src/database/seeds/seed_subscriptions_plans.js +++ /dev/null @@ -1,22 +0,0 @@ - -exports.seed = (knex) => { - // Deletes ALL existing entries - return knex('subscriptions_plans').del() - .then(() => { - // Inserts seed entries - return knex('subscriptions_plans').insert([ - { - id: 1, - name: 'basic', - price: 80, - signup_fee: 0, - currency: 'LYD', - trial_period: 0, - trial_interval: '', - - invoice_period: 1, - invoice_interval: 'month', - } - ]); - }); -}; diff --git a/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts b/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts new file mode 100644 index 000000000..4a016dc85 --- /dev/null +++ b/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts @@ -0,0 +1,9 @@ + + +export default class NotAllowedChangeSubscriptionPlan extends Error{ + + constructor(message: string) { + super(message); + this.name = "NotAllowedChangeSubscriptionPlan"; + } +} \ No newline at end of file diff --git a/server/src/exceptions/index.ts b/server/src/exceptions/index.ts new file mode 100644 index 000000000..28cbb1a4a --- /dev/null +++ b/server/src/exceptions/index.ts @@ -0,0 +1,5 @@ +import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan'; + +export { + NotAllowedChangeSubscriptionPlan, +}; \ No newline at end of file diff --git a/server/src/http/controllers/ItemCategories.js b/server/src/http/controllers/ItemCategories.js deleted file mode 100644 index 77e7ed11d..000000000 --- a/server/src/http/controllers/ItemCategories.js +++ /dev/null @@ -1,327 +0,0 @@ -import express from 'express'; -import { - check, - param, - validationResult, - query, -} from 'express-validator'; -import { difference } from 'lodash'; -import asyncMiddleware from '../middleware/asyncMiddleware'; -import { - DynamicFilter, - DynamicFilterSortBy, - DynamicFilterFilterRoles, -} from '@/lib/DynamicFilter'; -import { - mapFilterRolesToDynamicFilter, -} from '@/lib/ViewRolesBuilder'; - - -export default { - /** - * Router constructor method. - */ - router() { - const router = express.Router(); - // const permit = Authorization('items_categories'); - - router.post('/:id', - this.editCategory.validation, - asyncMiddleware(this.editCategory.handler)); - - router.post('/', - this.newCategory.validation, - asyncMiddleware(this.newCategory.handler)); - - router.delete('/bulk', - this.bulkDeleteCategories.validation, - asyncMiddleware(this.bulkDeleteCategories.handler)); - - router.delete('/:id', - this.deleteItem.validation, - asyncMiddleware(this.deleteItem.handler)); - - router.get('/:id', - this.getCategory.validation, - asyncMiddleware(this.getCategory.handler)); - - router.get('/', - this.getList.validation, - asyncMiddleware(this.getList.handler)); - - - - return router; - }, - - /** - * Creates a new item category. - */ - newCategory: { - validation: [ - check('name').exists().trim().escape(), - check('parent_category_id') - .optional({ nullable: true, checkFalsy: true }) - .isNumeric() - .toInt(), - check('description') - .optional() - .trim() - .escape(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const { user } = req; - const form = { ...req.body }; - const { ItemCategory } = req.models; - - if (form.parent_category_id) { - const foundParentCategory = await ItemCategory.query() - .where('id', form.parent_category_id) - .first(); - - if (!foundParentCategory) { - return res.boom.notFound('The parent category ID is not found.', { - errors: [{ type: 'PARENT_CATEGORY_NOT_FOUND', code: 100 }], - }); - } - } - const category = await ItemCategory.query().insert({ - ...form, - user_id: user.id, - }); - return res.status(200).send({ category }); - }, - }, - - /** - * Edit details of the given category item. - */ - editCategory: { - validation: [ - param('id').toInt(), - check('name').exists().trim().escape(), - check('parent_category_id') - .optional({ nullable: true, checkFalsy: true }) - .isNumeric() - .toInt(), - check('description').optional().trim().escape(), - ], - async handler(req, res) { - const { id } = req.params; - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - - const form = { ...req.body }; - const { ItemCategory } = req.models; - const itemCategory = await ItemCategory.query() - .where('id', id) - .first(); - - if (!itemCategory) { - return res.boom.notFound({ - errors: [{ type: 'ITEM_CATEGORY.NOT.FOUND', code: 100 }], - }); - } - if ( - form.parent_category_id - && form.parent_category_id !== itemCategory.parent_category_id - ) { - const foundParentCategory = await ItemCategory.query() - .where('id', form.parent_category_id) - .first(); - - if (!foundParentCategory) { - return res.boom.notFound('The parent category ID is not found.', { - errors: [{ type: 'PARENT_CATEGORY_NOT_FOUND', code: 100 }], - }); - } - } - const updateItemCategory = await ItemCategory.query() - .where('id', id) - .update({ ...form }); - - return res.status(200).send({ id }); - }, - }, - - /** - * Delete the give item category. - */ - deleteItem: { - validation: [ - param('id').exists().toInt(), - ], - async handler(req, res) { - const { id } = req.params; - const { ItemCategory } = req.models; - const itemCategory = await ItemCategory.query() - .where('id', id) - .first(); - - if (!itemCategory) { - return res.boom.notFound(); - } - await ItemCategory.query() - .where('id', itemCategory.id) - .delete(); - - return res.status(200).send(); - }, - }, - - /** - * Retrieve the list of items. - */ - getList: { - validation: [ - query('column_sort_order').optional().trim().escape(), - query('sort_order').optional().trim().escape().isIn(['desc', 'asc']), - query('stringified_filter_roles').optional().isJSON(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - - const { Resource, ItemCategory } = req.models; - const categoriesResource = await Resource.query() - .where('name', 'items_categories') - .withGraphFetched('fields') - .first(); - - if (!categoriesResource) { - return res.status(400).send({ - errors: [{ type: 'ITEMS.CATEGORIES.RESOURCE.NOT.FOUND', code: 200 }], - }); - } - - const filter = { - column_sort_order: '', - sort_order: '', - filter_roles: [], - ...req.query, - }; - if (filter.stringified_filter_roles) { - filter.filter_roles = JSON.parse(filter.stringified_filter_roles); - } - const errorReasons = []; - const resourceFieldsKeys = categoriesResource.fields.map((c) => c.key); - const dynamicFilter = new DynamicFilter(ItemCategory.tableName); - - // Dynamic filter with filter roles. - if (filter.filter_roles.length > 0) { - // Validate the accounts resource fields. - const filterRoles = new DynamicFilterFilterRoles( - mapFilterRolesToDynamicFilter(filter.filter_roles), - categoriesResource.fields, - ); - categoriesResource.setFilter(filterRoles); - - if (filterRoles.validateFilterRoles().length > 0) { - errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 }); - } - } - - // Dynamic filter with column sort order. - if (filter.column_sort_order) { - if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) { - errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 }); - } - const sortByFilter = new DynamicFilterSortBy( - filter.column_sort_order, - filter.sort_order, - ); - dynamicFilter.setFilter(sortByFilter); - } - if (errorReasons.length > 0) { - return res.status(400).send({ errors: errorReasons }); - } - - const categories = await ItemCategory.query().onBuild((builder) => { - dynamicFilter.buildQuery()(builder); - - builder.select([ - '*', - ItemCategory.relatedQuery('items').count().as('count'), - ]); - }); - - return res.status(200).send({ categories }); - }, - }, - - /** - * Retrieve details of the given category. - */ - getCategory: { - validation: [param('category_id').toInt()], - async handler(req, res) { - const { category_id: categoryId } = req.params; - const { ItemCategory } = req.models; - const item = await ItemCategory.where('id', categoryId).fetch(); - - if (!item) { - return res.boom.notFound(null, { - errors: [{ type: 'CATEGORY_NOT_FOUND', code: 100 }], - }); - } - return res.status(200).send({ category: item.toJSON() }); - }, - }, - - - /** - * Bulk delete the given item categories. - */ - bulkDeleteCategories: { - validation: [ - query('ids').isArray({ min: 2 }), - query('ids.*').isNumeric().toInt(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const filter = { - ids: [], - ...req.query, - }; - const { ItemCategory } = req.models; - - const itemCategories = await ItemCategory.query().whereIn('id', filter.ids); - const itemCategoriesIds = itemCategories.map((category) => category.id); - const notFoundCategories = difference(filter.ids, itemCategoriesIds); - - if (notFoundCategories.length > 0) { - return res.status(400).send({ - errors: [{ type: 'ITEM.CATEGORIES.IDS.NOT.FOUND', code: 200 }], - }); - } - - await ItemCategory.query().whereIn('id', filter.ids).delete(); - - return res.status(200).send({ ids: filter.ids }); - }, - }, -}; diff --git a/server/src/http/controllers/ItemCategories.ts b/server/src/http/controllers/ItemCategories.ts new file mode 100644 index 000000000..0c080e64c --- /dev/null +++ b/server/src/http/controllers/ItemCategories.ts @@ -0,0 +1,437 @@ +import express from 'express'; +import { + check, + param, + query, +} from 'express-validator'; +import { difference } from 'lodash'; +import { Service } from 'typedi'; +import asyncMiddleware from '@/http/middleware/asyncMiddleware'; +import validateMiddleware from '@/http/middleware/validateMiddleware'; +import { + DynamicFilter, + DynamicFilterSortBy, + DynamicFilterFilterRoles, +} from '@/lib/DynamicFilter'; +import { + mapFilterRolesToDynamicFilter, +} from '@/lib/ViewRolesBuilder'; +import { IItemCategory, IItemCategoryOTD } from '@/interfaces'; +import PrettierMiddleware from '@/http/middleware/PrettierMiddleware'; + +@Service() +export default class ItemsCategoriesController { + /** + * Router constructor method. + */ + constructor() { + const router = express.Router(); + + router.post('/:id', [ + ...this.categoryValidationSchema, + ...this.specificCategoryValidationSchema, + ], + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.validateParentCategoryExistance), + asyncMiddleware(this.validateSellAccountExistance), + asyncMiddleware(this.validateCostAccountExistance), + asyncMiddleware(this.validateInventoryAccountExistance), + asyncMiddleware(this.editCategory) + ); + router.post('/', + this.categoryValidationSchema, + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.validateParentCategoryExistance), + asyncMiddleware(this.validateSellAccountExistance), + asyncMiddleware(this.validateCostAccountExistance), + asyncMiddleware(this.validateInventoryAccountExistance), + asyncMiddleware(this.newCategory), + ); + router.delete('/bulk', + this.categoriesBulkValidationSchema, + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.validateCategoriesIdsExistance), + asyncMiddleware(this.bulkDeleteCategories), + ); + router.delete('/:id', + this.specificCategoryValidationSchema, + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.validateItemCategoryExistance), + asyncMiddleware(this.deleteItem), + ); + router.get('/:id', + this.specificCategoryValidationSchema, + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.validateItemCategoryExistance), + asyncMiddleware(this.getCategory) + ); + router.get('/', + this.categoriesListValidationSchema, + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.getList) + ); + return router; + } + + /** + * Item category validation schema. + */ + get categoryValidationSchema() { + return [ + check('name').exists().trim().escape(), + check('parent_category_id') + .optional({ nullable: true, checkFalsy: true }) + .isNumeric() + .toInt(), + check('description') + .optional() + .trim() + .escape(), + check('sell_account_id') + .optional({ nullable: true, checkFalsy: true }) + .isNumeric() + .toInt(), + check('cost_account_id') + .optional() + .isNumeric() + .toInt(), + check('inventory_account_id') + .optional() + .isNumeric() + .toInt(), + ] + } + + /** + * Validate items categories bulk actions. + */ + get categoriesBulkValidationSchema() { + return [ + query('ids').isArray({ min: 2 }), + query('ids.*').isNumeric().toInt(), + ]; + } + + /** + * Validate items categories schema. + */ + get categoriesListValidationSchema() { + return [ + query('column_sort_order').optional().trim().escape(), + query('sort_order').optional().trim().escape().isIn(['desc', 'asc']), + query('stringified_filter_roles').optional().isJSON(), + ]; + } + + /** + * Validate specific item category schema. + */ + get specificCategoryValidationSchema() { + return [ + param('id').exists().toInt(), + ]; + } + + /** + * Validate the item category existance. + * @param {Request} req + * @param {Response} res + */ + async validateItemCategoryExistance(req: Request, res: Response, next: Function) { + const categoryId: number = req.params.id; + const { ItemCategory } = req.models; + const category = await ItemCategory.query().findById(categoryId); + + if (!category) { + return res.boom.notFound(null, { + errors: [{ type: 'ITEM_CATEGORY_NOT_FOUND', code: 100 }], + }); + } + next(); + } + + /** + * Validate wether the given cost account exists on the storage. + * @param {Request} req + * @param {Response} res + * @param {Function} next + */ + async validateCostAccountExistance(req: Request, res: Response, next: Function) { + const { Account, AccountType } = req.models; + const category: IItemCategoryOTD = { ...req.body }; + + if (category.costAccountId) { + const COGSType = await AccountType.query().findOne('key', 'cost_of_goods_sold'); + const foundAccount = await Account.query().findById(category.costAccountId) + + if (!foundAccount) { + return res.status(400).send({ + errors: [{ type: 'COST.ACCOUNT.NOT.FOUND', code: 120 }], + }); + } else if (foundAccount.accountTypeId !== COGSType.id) { + return res.status(400).send({ + errors: [{ type: 'COST.ACCOUNT.NOT.COGS.TYPE', code: 220 }], + }); + } + } + next(); + } + + /** + * Validate wether the given sell account exists on the storage. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async validateSellAccountExistance(req: Request, res: Response, next: Function) { + const { Account, AccountType } = req.models; + const category: IItemCategoryOTD = { ...req.body }; + + if (category.sellAccountId) { + const incomeType = await AccountType.query().findOne('key', 'income'); + const foundAccount = await Account.query().findById(category.sellAccountId); + + if (!foundAccount) { + return res.status(400).send({ + errors: [{ type: 'SELL.ACCOUNT.NOT.FOUND', code: 130 }], + }); + } else if (foundAccount.accountTypeId !== incomeType.id) { + return res.status(400).send({ + errors: [{ type: 'SELL.ACCOUNT.NOT.INCOME.TYPE', code: 230 }], + }) + } + } + next(); + } + + /** + * Validates wether the given inventory account exists on the storage. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + async validateInventoryAccountExistance(req: Request, res: Response, next: Function) { + const { Account, AccountType } = req.models; + const category: IItemCategoryOTD = { ...req.body }; + + if (category.inventoryAccountId) { + const otherAsset = await AccountType.query().findOne('key', 'other_asset'); + const foundAccount = await Account.query().findById(category.inventoryAccountId); + + if (!foundAccount) { + return res.status(400).send({ + errors: [{ type: 'INVENTORY.ACCOUNT.NOT.FOUND', code: 200}], + }); + } else if (otherAsset.id !== foundAccount.accountTypeId) { + return res.status(400).send({ + errors: [{ type: 'INVENTORY.ACCOUNT.NOT.CURRENT.ASSET', code: 300 }], + }); + } + } + next(); + } + + /** + * Validate the item category parent category whether exists on the storage. + * @param {Request} req + * @param {Response} res + * @param {Function} next + */ + async validateParentCategoryExistance(req: Request, res: Response, next: Function) { + const category: IItemCategory = { ...req.body }; + const { ItemCategory } = req.models; + + if (category.parentCategoryId) { + const foundParentCategory = await ItemCategory.query() + .where('id', category.parentCategoryId) + .first(); + + if (!foundParentCategory) { + return res.boom.notFound('The parent category ID is not found.', { + errors: [{ type: 'PARENT_CATEGORY_NOT_FOUND', code: 100 }], + }); + } + } + next(); + } + + /** + * Validate item categories ids existance. + * @param {Request} req + * @param {Response} res + * @param {Function} next + */ + async validateCategoriesIdsExistance(req: Request, res: Response, next: Function) { + const ids: number[] = (req.query?.ids || []); + const { ItemCategory } = req.models; + + const itemCategories = await ItemCategory.query().whereIn('id', ids); + const itemCategoriesIds = itemCategories.map((category: IItemCategory) => category.id); + const notFoundCategories = difference(ids, itemCategoriesIds); + + if (notFoundCategories.length > 0) { + return res.status(400).send({ + errors: [{ type: 'ITEM.CATEGORIES.IDS.NOT.FOUND', code: 200 }], + }); + } + next(); + } + + /** + * Creates a new item category. + * @param {Request} req + * @param {Response} res + */ + async newCategory(req: Request, res: Response) { + const { user } = req; + const category: IItemCategory = { ...req.body }; + const { ItemCategory } = req.models; + + const storedCategory = await ItemCategory.query().insert({ + ...category, + user_id: user.id, + }); + return res.status(200).send({ category: storedCategory }); + } + + /** + * Edit details of the given category item. + * @param {Request} req - + * @param {Response} res - + * @return {Response} + */ + async editCategory(req: Request, res: Response) { + const { id } = req.params; + const category: IItemCategory = { ...req.body }; + const { ItemCategory } = req.models; + + const updateItemCategory = await ItemCategory.query() + .where('id', id) + .update({ ...category }); + + return res.status(200).send({ id }); + } + + /** + * Delete the give item category. + * @param {Request} req - + * @param {Response} res - + * @return {Response} + */ + async deleteItem(req: Request, res: Response) { + const { id } = req.params; + const { ItemCategory } = req.models; + + await ItemCategory.query() + .where('id', id) + .delete(); + + return res.status(200).send({ id }); + } + + /** + * Retrieve the list of items. + * @param {Request} req - + * @param {Response} res - + * @return {Response} + */ + async getList(req: Request, res: Response) { + const { Resource, ItemCategory } = req.models; + const categoriesResource = await Resource.query() + .where('name', 'items_categories') + .withGraphFetched('fields') + .first(); + + if (!categoriesResource) { + return res.status(400).send({ + errors: [{ type: 'ITEMS.CATEGORIES.RESOURCE.NOT.FOUND', code: 200 }], + }); + } + const filter = { + column_sort_order: '', + sort_order: '', + filter_roles: [], + ...req.query, + }; + if (filter.stringified_filter_roles) { + filter.filter_roles = JSON.parse(filter.stringified_filter_roles); + } + const errorReasons = []; + const resourceFieldsKeys = categoriesResource.fields.map((c) => c.key); + const dynamicFilter = new DynamicFilter(ItemCategory.tableName); + + // Dynamic filter with filter roles. + if (filter.filter_roles.length > 0) { + // Validate the accounts resource fields. + const filterRoles = new DynamicFilterFilterRoles( + mapFilterRolesToDynamicFilter(filter.filter_roles), + categoriesResource.fields, + ); + categoriesResource.setFilter(filterRoles); + + if (filterRoles.validateFilterRoles().length > 0) { + errorReasons.push({ type: 'ITEMS.RESOURCE.HAS.NO.FIELDS', code: 500 }); + } + } + // Dynamic filter with column sort order. + if (filter.column_sort_order) { + if (resourceFieldsKeys.indexOf(filter.column_sort_order) === -1) { + errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 }); + } + const sortByFilter = new DynamicFilterSortBy( + filter.column_sort_order, + filter.sort_order, + ); + dynamicFilter.setFilter(sortByFilter); + } + if (errorReasons.length > 0) { + return res.status(400).send({ errors: errorReasons }); + } + const categories = await ItemCategory.query().onBuild((builder) => { + dynamicFilter.buildQuery()(builder); + + builder.select([ + '*', + ItemCategory.relatedQuery('items').count().as('count'), + ]); + }); + + return res.status(200).send({ categories }); + } + + /** + * Retrieve details of the given category. + * @param {Request} req - + * @param {Response} res - + * @return {Response} + */ + async getCategory(req: Request, res: Response) { + const itemCategoryId: number = req.params.id; + const { ItemCategory } = req.models; + + const itemCategory = await ItemCategory.query().findById(itemCategoryId); + + return res.status(200).send({ category: itemCategory }); + } + + /** + * Bulk delete the given item categories. + * @param {Request} req - + * @param {Response} res - + * @return {Response} + */ + async bulkDeleteCategories(req: Request, res: Response) { + const ids = req.query.ids; + const { ItemCategory } = req.models; + + await ItemCategory.query().whereIn('id', ids).delete(); + + return res.status(200).send({ ids: filter.ids }); + } +}; diff --git a/server/src/http/controllers/Ping.ts b/server/src/http/controllers/Ping.ts index e6a593259..0cbb6ec08 100644 --- a/server/src/http/controllers/Ping.ts +++ b/server/src/http/controllers/Ping.ts @@ -1,11 +1,11 @@ +import 'reflect-metadata'; import { Router, Request, Response } from 'express'; -import { Container } from 'typedi'; export default class Ping { /** * Router constur */ - static router() { + router() { const router = Router(); router.get( @@ -20,7 +20,7 @@ export default class Ping { * @param {Request} req * @param {Response} res */ - static async ping(req: Request, res: Response) + async ping(req: Request, res: Response) { return res.status(200).send({ server: true, diff --git a/server/src/http/controllers/Subscription/PaymentMethod.ts b/server/src/http/controllers/Subscription/PaymentMethod.ts new file mode 100644 index 000000000..810fe73fe --- /dev/null +++ b/server/src/http/controllers/Subscription/PaymentMethod.ts @@ -0,0 +1,29 @@ +import { Inject } from 'typedi'; +import { Plan } from '@/system/models'; +import SubscriptionService from '@/services/Subscription/SubscriptionService'; + +export default class PaymentMethodController { + @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 } = req.body; + 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/server/src/http/controllers/Subscription/PaymentViaVoucher.ts b/server/src/http/controllers/Subscription/PaymentViaVoucher.ts new file mode 100644 index 000000000..106b392a2 --- /dev/null +++ b/server/src/http/controllers/Subscription/PaymentViaVoucher.ts @@ -0,0 +1,118 @@ +import { Container, Service } from 'typedi'; +import { Router, Request, Response } from 'express'; +import { check, param, query, ValidationSchema } from 'express-validator'; +import { Voucher, Plan } from '@/system/models'; +import validateMiddleware from '@/http/middleware/validateMiddleware'; +import asyncMiddleware from '@/http/middleware/asyncMiddleware'; +import PaymentMethodController from '@/http/controllers/Subscription/PaymentMethod'; +import PrettierMiddleware from '@/http/middleware/PrettierMiddleware'; +import { + NotAllowedChangeSubscriptionPlan +} from '@/exceptions'; + +@Service() +export default class PaymentViaVoucherController extends PaymentMethodController { + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post( + '/payment', + this.paymentViaVoucherSchema, + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.validateVoucherCodeExistance.bind(this)), + asyncMiddleware(this.validatePlanSlugExistance.bind(this)), + asyncMiddleware(this.validateVoucherAndPlan.bind(this)), + asyncMiddleware(this.paymentViaVoucher.bind(this)), + ); + return router; + } + + /** + * Payment via voucher validation schema. + */ + get paymentViaVoucherSchema() { + return [ + check('plan_slug').exists().trim().escape(), + check('voucher_code').exists().trim().escape(), + ]; + } + + /** + * Validate the given voucher code exists on the storage. + * @async + * @param {Request} req + * @param {Response} res + */ + async validateVoucherCodeExistance(req: Request, res: Response, next: Function) { + const { voucherCode } = req.body; + + const foundVoucher = await Voucher.query() + .modify('filterActiveVoucher') + .where('voucher_code', voucherCode) + .first(); + + if (!foundVoucher) { + return res.status(400).send({ + errors: [{ type: 'VOUCHER.CODE.IS.INVALID', code: 120 }], + }); + } + next(); + } + + /** + * Validate the voucher period and plan period. + * @param {Request} req + * @param {Response} res + * @param {Function} next + */ + async validateVoucherAndPlan(req: Request, res: Response, next: Function) { + const { planSlug, voucherCode } = req.body; + + const voucher = await Voucher.query().findOne('voucher_code', voucherCode); + const plan = await Plan.query().findOne('slug', planSlug); + + if (voucher.planId !== plan.id) { + return res.status(400).send({ + errors: [{ type: 'VOUCHER.NOT.FOR.GIVEN.PLAN' }], + }); + } + next(); + } + + /** + * Handle the subscription payment via voucher code. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async paymentViaVoucher(req: Request, res: Response, next: Function) { + const { planSlug, voucherCode } = req.body; + const { tenant } = req; + + try { + await this.subscriptionService.subscriptionViaVoucher(tenant.id, planSlug, voucherCode); + + return res.status(200).send({ + type: 'PAYMENT.SUCCESSFULLY.MADE', + code: 100, + }); + } catch (exception) { + const errorReasons = []; + + if (exception.name === 'NotAllowedChangeSubscriptionPlan') { + errorReasons.push({ + type: 'NOT.ALLOWED.RENEW.SUBSCRIPTION.WHILE.ACTIVE', + code: 120, + }); + } + if (errorReasons.length > 0) { + return res.status(400).send({ errors: errorReasons }); + } + next(exception); + } + } +} \ No newline at end of file diff --git a/server/src/http/controllers/Subscription/Vouchers.ts b/server/src/http/controllers/Subscription/Vouchers.ts new file mode 100644 index 000000000..20b06cc83 --- /dev/null +++ b/server/src/http/controllers/Subscription/Vouchers.ts @@ -0,0 +1,261 @@ +import { Router, Request, Response } from 'express' +import { repeat, times, orderBy } from 'lodash'; +import { check, oneOf, param, query, ValidationChain } from 'express-validator'; +import { Container, Service, Inject } from 'typedi'; +import { Voucher, Plan } from '@/system/models'; +import VoucherService from '@/services/Payment/Voucher'; +import validateMiddleware from '@/http/middleware/validateMiddleware'; +import asyncMiddleware from '@/http/middleware/asyncMiddleware'; +import PrettierMiddleware from '@/http/middleware/prettierMiddleware'; +import { IVouchersFilter } from '@/interfaces'; + +@Service() +export default class VouchersController { + @Inject() + voucherService: VoucherService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.post( + '/generate', + this.generateVoucherSchema, + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.validatePlanExistance), + asyncMiddleware(this.generateVoucher.bind(this)), + ); + router.post( + '/disable/:voucherId', + PrettierMiddleware, + asyncMiddleware(this.validateVoucherExistance), + asyncMiddleware(this.validateNotDisabledVoucher), + asyncMiddleware(this.disableVoucher.bind(this)), + ); + router.post( + '/send', + this.sendVoucherSchemaValidation, + validateMiddleware, + PrettierMiddleware, + asyncMiddleware(this.sendVoucher.bind(this)), + ); + router.delete( + '/:voucherId', + PrettierMiddleware, + asyncMiddleware(this.validateVoucherExistance), + asyncMiddleware(this.deleteVoucher.bind(this)), + ); + router.get( + '/', + PrettierMiddleware, + asyncMiddleware(this.listVouchers.bind(this)), + ); + return router; + } + + /** + * Generate voucher validation schema. + */ + get generateVoucherSchema(): 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_id').exists().isNumeric().toInt(), + ]; + } + + /** + * Specific voucher validation schema. + */ + get specificVoucherSchema(): ValidationChain[] { + return [ + oneOf([ + check('voucher_id').exists().isNumeric().toInt(), + ], [ + check('voucher_code').exists().isNumeric().toInt(), + ]) + ] + } + + /** + * Send voucher validation schema. + */ + get sendVoucherSchemaValidation(): ValidationChain[] { + return [ + check('period').exists().isNumeric(), + check('period_interval').exists().trim().escape(), + check('plan_id').exists().isNumeric().toInt(), + oneOf([ + check('phone_number').exists().trim().escape(), + check('email').exists().trim().escape(), + ]), + ]; + } + + /** + * Validate the plan existance on the storage. + * @param {Request} req + * @param {Response} res + * @param {Function} next + */ + async validatePlanExistance(req: Request, res: Response, next: Function) { + const planId: number = req.body.planId || req.params.planId; + const foundPlan = await Plan.query().findById(planId); + + if (!foundPlan) { + return res.status(400).send({ + erorrs: [{ type: 'PLAN.NOT.FOUND', code: 100 }], + }); + } + next(); + } + + /** + * Valdiate the voucher existance on the storage. + * @param {Request} req + * @param {Response} res + * @param {Function} + */ + async validateVoucherExistance(req: Request, res: Response, next: Function) { + const voucherId = req.body.voucherId || req.params.voucherId; + const foundVoucher = await Voucher.query().findById(voucherId); + + if (!foundVoucher) { + return res.status(400).send({ + errors: [{ type: 'VOUCHER.NOT.FOUND', code: 200 }], + }); + } + next(); + } + + /** + * Validates whether the voucher id is disabled. + * @param {Request} req + * @param {Response} res + * @param {Function} next + */ + async validateNotDisabledVoucher(req: Request, res: Response, next: Function) { + const voucherId = req.params.voucherId || req.query.voucherId; + const foundVoucher = await Voucher.query().findById(voucherId); + + if (foundVoucher.disabled) { + return res.status(400).send({ + errors: [{ type: 'VOUCHER.ALREADY.DISABLED', code: 200 }], + }); + } + next(); + } + + /** + * Generate vouchers codes with given period in bulk. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async generateVoucher(req: Request, res: Response) { + const { loop = 10, period, periodInterval, planId } = req.body; + const generatedVouchers: string[] = []; + const asyncOpers = []; + + times(loop, () => { + const generateOper = this.voucherService + .generateVoucher(period, periodInterval, planId) + .then((generatedVoucher: any) => { + generatedVouchers.push(generatedVoucher) + }); + asyncOpers.push(generateOper); + }); + + return res.status(200).send({ + vouchers: generatedVouchers, + }); + } + + /** + * Disable the given voucher on the storage. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async disableVoucher(req: Request, res: Response) { + const { voucherId } = req.params; + + await this.voucherService.disableVoucher(voucherId); + + return res.status(200).send({ voucher_id: voucherId }); + } + + /** + * Deletes the given voucher code on the storage. + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async deleteVoucher(req: Request, res: Response) { + const { voucherId } = req.params; + + await this.voucherService.deleteVoucher(voucherId); + + return res.status(200).send({ voucher_id: voucherId }); + } + + /** + * Send voucher code in the given period to the customer via email or phone number + * @param {Request} req + * @param {Response} res + * @return {Response} + */ + async sendVoucher(req: Request, res: Response) { + const { phoneNumber, email, period, periodInterval, planId } = req.body; + + const voucher = await Voucher.query() + .modify('filterActiveVoucher') + .where('voucher_period', period) + .where('period_interval', periodInterval) + .where('plan_id', planId) + .first(); + + if (!voucher) { + return res.status(400).send({ + status: 110, + message: 'There is no vouchers availiable right now with the given period and plan.', + code: 'NO.AVALIABLE.VOUCHER.CODE', + }); + } + await this.voucherService.sendVoucherToCustomer( + voucher.voucherCode, phoneNumber, email, + ); + return res.status(200).send({ + status: 100, + code: 'VOUCHER.CODE.SENT', + message: 'The voucher has been sent to the given customer.', + }); + } + + /** + * Listing vouchers. + * @param {Request} req + * @param {Response} res + */ + async listVouchers(req: Request, res: Response) { + const filter: IVouchersFilter = { + disabled: false, + used: false, + sent: false, + active: false, + ...req.query, + }; + const vouchers = await Voucher.query() + .onBuild((builder) => { + builder.modify('filter', filter); + builder.orderBy('createdAt', 'ASC'); + }); + return res.status(200).send({ vouchers }); + } +} \ No newline at end of file diff --git a/server/src/http/controllers/Subscription/index.ts b/server/src/http/controllers/Subscription/index.ts new file mode 100644 index 000000000..f744b5749 --- /dev/null +++ b/server/src/http/controllers/Subscription/index.ts @@ -0,0 +1,22 @@ +import { Router } from 'express' +import { Container, Service } from 'typedi'; +import JWTAuth from '@/http/middleware/jwtAuth'; +import TenancyMiddleware from '@/http/middleware/TenancyMiddleware'; +import PaymentViaVoucherController from '@/http/controllers/Subscription/PaymentViaVoucher'; + +@Service() +export default class SubscriptionController { + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use(JWTAuth); + router.use(TenancyMiddleware); + + router.use('/voucher', Container.get(PaymentViaVoucherController).router()); + + return router; + } +} diff --git a/server/src/http/index.js b/server/src/http/index.js index 3322cb83b..1fe16e957 100644 --- a/server/src/http/index.js +++ b/server/src/http/index.js @@ -1,5 +1,5 @@ -// import OAuth2 from '@/http/controllers/OAuth2'; import express from 'express'; +import { Container } from 'typedi'; import Authentication from '@/http/controllers/Authentication'; import InviteUsers from '@/http/controllers/InviteUsers'; import Users from '@/http/controllers/Users'; @@ -24,15 +24,24 @@ import JWTAuth from '@/http/middleware/jwtAuth'; import TenancyMiddleware from '@/http/middleware/TenancyMiddleware'; import Ping from '@/http/controllers/Ping'; import Agendash from '@/http/controllers/Agendash'; +import Subscription from '@/http/controllers/Subscription'; +import VouchersController from '@/http/controllers/Subscription/Vouchers'; + +import TenantDependencyInjection from '@/http/middleware/TenantDependencyInjection'; +import SubscriptionMiddleware from '@/http/middleware/SubscriptionMiddleware'; export default (app) => { app.use('/api/auth', Authentication.router()); app.use('/api/invite', InviteUsers.router()); - + app.use('/api/vouchers', Container.get(VouchersController).router()); + app.use('/api/subscription', Container.get(Subscription).router()); + app.use('/api/ping', Container.get(Ping).router()); + const dashboard = express.Router(); dashboard.use(JWTAuth); dashboard.use(TenancyMiddleware); + dashboard.use(SubscriptionMiddleware('main')); dashboard.use('/api/currencies', Currencies.router()); dashboard.use('/api/users', Users.router()); @@ -41,7 +50,7 @@ export default (app) => { dashboard.use('/api/accounting', Accounting.router()); dashboard.use('/api/views', Views.router()); dashboard.use('/api/items', Items.router()); - dashboard.use('/api/item_categories', ItemCategories.router()); + dashboard.use('/api/item_categories', Container.get(ItemCategories)); dashboard.use('/api/expenses', Expenses.router()); dashboard.use('/api/financial_statements', FinancialStatements.router()); dashboard.use('/api/options', Options.router()); @@ -52,8 +61,7 @@ export default (app) => { dashboard.use('/api/resources', Resources.router()); dashboard.use('/api/exchange_rates', ExchangeRates.router()); dashboard.use('/api/media', Media.router()); - dashboard.use('/api/ping', Ping.router()); - + app.use('/agendash', Agendash.router()); app.use('/', dashboard); }; diff --git a/server/src/http/middleware/SubscriptionFeatureMiddleware.js b/server/src/http/middleware/SubscriptionFeatureMiddleware.js new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/http/middleware/SubscriptionMiddleware.js b/server/src/http/middleware/SubscriptionMiddleware.js new file mode 100644 index 000000000..1f38bc83d --- /dev/null +++ b/server/src/http/middleware/SubscriptionMiddleware.js @@ -0,0 +1,26 @@ + +export default (subscriptionSlug = 'main') => async (req, res, next) => { + const { tenant } = req; + + if (!tenant) { + throw new Error('Should load `TenancyMiddlware` before this middleware.'); + } + const subscription = await tenant + .$relatedQuery('subscriptions') + .modify('subscriptionBySlug', subscriptionSlug) + .first(); + + // Validate in case there is no any already subscription. + if (!subscription) { + return res.status(400).send({ + errors: [{ type: 'TENANT.HAS.NO.SUBSCRIPTION' }], + }); + } + // Validate in case the subscription is inactive. + else if (subscription.inactive()) { + return res.status(400).send({ + errors: [{ type: 'ORGANIZATION.SUBSCRIPTION.INACTIVE' }], + }); + } + next(); +}; \ No newline at end of file diff --git a/server/src/http/middleware/SubscriptionObserver.js b/server/src/http/middleware/SubscriptionObserver.js deleted file mode 100644 index 10bcb5ef9..000000000 --- a/server/src/http/middleware/SubscriptionObserver.js +++ /dev/null @@ -1,8 +0,0 @@ - - - -const subscriptionObserver = (req, res, next) => { - -}; - -export default subscriptionObserver; diff --git a/server/src/http/middleware/TenancyMiddleware.js b/server/src/http/middleware/TenancyMiddleware.js index 650b4cd3a..c57a20319 100644 --- a/server/src/http/middleware/TenancyMiddleware.js +++ b/server/src/http/middleware/TenancyMiddleware.js @@ -44,6 +44,7 @@ export default async (req, res, next) => { req.knex = knex; req.organizationId = organizationId; + req.tenant = tenant; req.models = { ...Object.values(models).reduce((acc, model) => { if (typeof model.resource.default !== 'undefined' && diff --git a/server/src/http/middleware/TenantDependencyInjection.ts b/server/src/http/middleware/TenantDependencyInjection.ts new file mode 100644 index 000000000..37a725919 --- /dev/null +++ b/server/src/http/middleware/TenantDependencyInjection.ts @@ -0,0 +1,13 @@ +import { Request, Response } from 'express'; +import { Container } from 'typedi'; + +export default async (req: Request, res: Response, next: Function) => { + const { organizationId, knex } = req; + + if (!organizationId || !knex) { + throw new Error('Should load `TenancyMiddleware` before this middleware.'); + } + Container.of(`tenant-${organizationId}`).set('knex', knex); + + next(); +}; \ No newline at end of file diff --git a/server/src/http/middleware/prettierMiddleware.ts b/server/src/http/middleware/prettierMiddleware.ts index c7a6b7814..3daf6ee2c 100644 --- a/server/src/http/middleware/prettierMiddleware.ts +++ b/server/src/http/middleware/prettierMiddleware.ts @@ -1,35 +1,34 @@ -import { camelCase, snakeCase } from 'lodash'; +import { Request, Response } from 'express'; +import { camelCase, snakeCase, mapKeys } from 'lodash'; /** * create a middleware to change json format from snake case to camelcase in request * then change back to snake case in response * */ -export default function createMiddleware() { - return function (req, res, next) { - /** - * camelize req.body - */ - if (req.body && typeof req.body === 'object') { - req.body = camelCase(req.body); - } - - /** - * camelize req.query - */ - if (req.query && typeof req.query === 'object') { - req.query = camelCase(req.query); - } - - /** - * wrap res.json() - */ - const sendJson = res.json; - - res.json = (data) => { - return sendJson.call(res, snakeCase(data)); - } - - return next(); +export default (req: Request, res: Response, next: Function) => { + /** + * camelize `req.body` + */ + if (req.body && typeof req.body === 'object') { + req.body = mapKeys(req.body, (value: any, key: string) => camelCase(key)); } -} \ No newline at end of file + + /** + * camelize `req.query` + */ + if (req.query && typeof req.query === 'object') { + req.query = mapKeys(req.query, (value: any, key: string) => camelCase(key)); + } + + /** + * wrap `res.json()` + */ + const sendJson = res.json; + + res.json = (data: any) => { + const mapped = mapKeys(data, (value: any, key: string) => snakeCase(key)); + return sendJson.call(res, mapped); + }; + return next(); +}; \ No newline at end of file diff --git a/server/src/interfaces/IItem.ts b/server/src/interfaces/Item.ts similarity index 100% rename from server/src/interfaces/IItem.ts rename to server/src/interfaces/Item.ts diff --git a/server/src/interfaces/ItemCategory.ts b/server/src/interfaces/ItemCategory.ts new file mode 100644 index 000000000..a47655a23 --- /dev/null +++ b/server/src/interfaces/ItemCategory.ts @@ -0,0 +1,30 @@ + + + +export interface IItemCategory { + name: string, + + parentCategoryId?: number, + description?: string, + userId: number, + + costAccountId?: number, + sellAccountId?: number, + inventoryAccountId?: number, + + costMethod?: string, +}; + +export interface IItemCategoryOTD { + name: string, + + parentCategoryId?: number, + description?: string, + userId: number, + + costAccountId?: number, + sellAccountId?: number, + inventoryAccountId?: number, + + costMethod?: string, +}; \ No newline at end of file diff --git a/server/src/interfaces/Payment.ts b/server/src/interfaces/Payment.ts new file mode 100644 index 000000000..5c0e04a8e --- /dev/null +++ b/server/src/interfaces/Payment.ts @@ -0,0 +1,20 @@ + + +export interface IPaymentModel {} + +export interface IVoucherPaymentModel extends IPaymentModel { + voucherCode: string; +} + +export interface IPaymentMethod { + makePayment(paymentModel: IPaymentModel): Promise; +} + +export interface IVoucherPaymentMethod { + makePayment(paymentModel: IVoucherPaymentModel): Promise; +} + +export interface IPaymentContext { + paymentMethod: IPaymentMethod; + makePayment(paymentModel: PaymentModel): Promise; +} \ No newline at end of file diff --git a/server/src/interfaces/Voucher.ts b/server/src/interfaces/Voucher.ts new file mode 100644 index 000000000..eb5359361 --- /dev/null +++ b/server/src/interfaces/Voucher.ts @@ -0,0 +1,17 @@ + + +export interface IVoucher { + id?: number, + voucherCode: string, + voucherPeriod: number, + sent: boolean, + disabled: boolean, + used: boolean, +}; + +export interface IVouchersFilter { + active: boolean, + disabld: boolean, + used: boolean, + sent: boolean, +}; \ No newline at end of file diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 6a6edb126..aa7ff0ee1 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -1,8 +1,17 @@ import { IInventoryTransaction, IInventoryLotCost } from './InventoryTransaction'; import { IBillPaymentEntry, IBillPayment } from './BillPayment'; -import { IInventoryCostMethod } from './IInventoryCostMethod'; +import { IInventoryCostMethod } from './InventoryCostMethod'; import { IItemEntry } from './ItemEntry'; import { IItem } from './Item'; +import { IVoucher, IVouchersFilter } from './Voucher'; +import { IItemCategory, IItemCategoryOTD } from './ItemCategory'; +import { + IPaymentModel, + IVoucherPaymentModel, + IPaymentMethod, + IVoucherPaymentMethod, + IPaymentContext, +} from './Payment'; export { IBillPaymentEntry, @@ -10,6 +19,16 @@ export { IInventoryTransaction, IInventoryLotCost, IInventoryCostMethod, - IItemEntry + IItemEntry, IItem, + IVoucher, + IVouchersFilter, + IItemCategory, + IItemCategoryOTD, + + IPaymentModel, + IPaymentMethod, + IPaymentContext, + IVoucherPaymentModel, + IVoucherPaymentMethod, }; \ No newline at end of file diff --git a/server/src/jobs/SendVoucherEmail.ts b/server/src/jobs/SendVoucherEmail.ts new file mode 100644 index 000000000..cb0fc2bcc --- /dev/null +++ b/server/src/jobs/SendVoucherEmail.ts @@ -0,0 +1,22 @@ +import { Container } from 'typedi'; +import VoucherService from '@/services/Payment/Voucher'; + +export default class SendVoucherViaEmailJob { + public async handler(job, done: Function): Promise { + const Logger = Container.get('logger'); + const voucherService = Container.get(VoucherService); + const { email, voucherCode } = job.attrs.data; + + Logger.debug(`Send voucher via email - started: ${job.attrs.data}`); + + try { + await voucherService.mailMessages.sendMailVoucher(voucherCode, email); + Logger.debug(`Send voucher via email - completed: ${job.attrs.data}`); + done(); + } catch(e) { + console.log(e); + Logger.error(`Send voucher via email: ${job.attrs.data}, error: ${e}`); + done(e); + } + } +} diff --git a/server/src/jobs/SendVoucherPhone.ts b/server/src/jobs/SendVoucherPhone.ts new file mode 100644 index 000000000..cebac228a --- /dev/null +++ b/server/src/jobs/SendVoucherPhone.ts @@ -0,0 +1,22 @@ +import { Container } from 'typedi'; +import VoucherService from '@/services/Payment/Voucher'; + +export default class SendVoucherViaPhoneJob { + public async handler(job, done: Function): Promise { + const Logger = Container.get('logger'); + const voucherService = Container.get(VoucherService); + const { phoneNumber, voucherCode } = job.attrs.data; + + Logger.debug(`Send voucher via phone number - started: ${job.attrs.data}`); + + try { + await voucherService.smsMessages.sendVoucherSMSMessage(phoneNumber, voucherCode); + Logger.debug(`Send voucher via phone number - completed: ${job.attrs.data}`); + done(); + } catch(e) { + console.log(e); + Logger.error(`Send voucher via phone number: ${job.attrs.data}, error: ${e}`); + done(e); + } + } +} diff --git a/server/src/loaders/dependencyInjector.ts b/server/src/loaders/dependencyInjector.ts index 37d3e74b6..39c5e00f7 100644 --- a/server/src/loaders/dependencyInjector.ts +++ b/server/src/loaders/dependencyInjector.ts @@ -1,17 +1,25 @@ import { Container } from 'typedi'; import LoggerInstance from '@/services/Logger'; import agendaFactory from '@/loaders/agenda'; +import SmsClientLoader from '@/loaders/smsClient'; export default ({ mongoConnection, knex }) => { - try {; + try { const agendaInstance = agendaFactory({ mongoConnection }); + const smsClientInstance = SmsClientLoader(); Container.set('agenda', agendaInstance); - Container.set('logger', LoggerInstance) - Container.set('knex', knex); - LoggerInstance.info('Agenda has been injected into container'); + Container.set('logger', LoggerInstance) + LoggerInstance.info('Logger instance has been injected into container'); + + Container.set('knex', knex); + LoggerInstance.info('Knex instance has been injected into container'); + + Container.set('SMSClient', smsClientInstance); + LoggerInstance.info('SMS client has been injected into container'); + return { agenda: agendaInstance }; } catch (e) { LoggerInstance.error('Error on dependency injector loader: %o', e); diff --git a/server/src/loaders/jobs.ts b/server/src/loaders/jobs.ts index a50f48361..c8e9e44bd 100644 --- a/server/src/loaders/jobs.ts +++ b/server/src/loaders/jobs.ts @@ -2,6 +2,8 @@ import Agenda from 'agenda'; import WelcomeEmailJob from '@/Jobs/welcomeEmail'; import ComputeItemCost from '@/Jobs/ComputeItemCost'; import RewriteInvoicesJournalEntries from '@/jobs/writeInvoicesJEntries'; +import SendVoucherViaPhoneJob from '@/jobs/SendVoucherPhone'; +import SendVoucherViaEmailJob from '@/jobs/SendVoucherEmail'; export default ({ agenda }: { agenda: Agenda }) => { agenda.define( @@ -19,5 +21,15 @@ export default ({ agenda }: { agenda: Agenda }) => { { priority: 'normal', concurrency: 1, }, new RewriteInvoicesJournalEntries().handler, ); + agenda.define( + 'send-voucher-via-phone', + { priority: 'high', concurrency: 1, }, + new SendVoucherViaPhoneJob().handler, + ); + agenda.define( + 'send-voucher-via-email', + { priority: 'high', concurrency: 1, }, + new SendVoucherViaEmailJob().handler, + ) agenda.start(); }; diff --git a/server/src/loaders/smsClient.ts b/server/src/loaders/smsClient.ts new file mode 100644 index 000000000..bbb8b0895 --- /dev/null +++ b/server/src/loaders/smsClient.ts @@ -0,0 +1,9 @@ +import SMSClient from '@/services/SMSClient'; +import EasySMSGateway from '@/services/SMSClient/EasySMSClient'; + +export default () => { + const easySmsGateway = new EasySMSGateway(); + const smsClient = new SMSClient(easySmsGateway); + + return smsClient; +}; diff --git a/server/src/models/PaymentReceiveEntry.js b/server/src/models/PaymentReceiveEntry.js index 8e9d311b6..72a74883f 100644 --- a/server/src/models/PaymentReceiveEntry.js +++ b/server/src/models/PaymentReceiveEntry.js @@ -33,6 +33,9 @@ export default class PaymentReceiveEntry extends mixin(TenantModel, [CachableMod const SaleInvoice = require('@/models/SaleInvoice'); return { + /** + * + */ entries: { relation: Model.HasManyRelation, modelClass: this.relationBindKnex(PaymentReceive.default), @@ -42,6 +45,9 @@ export default class PaymentReceiveEntry extends mixin(TenantModel, [CachableMod }, }, + /** + * The payment receive entry have have sale invoice. + */ invoice: { relation: Model.BelongsToOneRelation, modelClass: this.relationBindKnex(SaleInvoice.default), diff --git a/server/src/server.js b/server/src/server.js index d4b8be0ec..a49034724 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -1,7 +1,14 @@ +import 'reflect-metadata'; // We need this in order to use @Decorators + import express from 'express'; import rootPath from 'app-root-path'; import loadersFactory from '@/loaders'; import '../config'; +import moment from 'moment'; + +moment.prototype.toMySqlDateTime = function () { + return this.format('YYYY-MM-DD HH:mm:ss'); +}; global.rootPath = rootPath.path; diff --git a/server/src/services/Payment/PaymentMethod.ts b/server/src/services/Payment/PaymentMethod.ts new file mode 100644 index 000000000..a0c1072f0 --- /dev/null +++ b/server/src/services/Payment/PaymentMethod.ts @@ -0,0 +1,6 @@ +import moment from 'moment'; +import { IPaymentModel } from '@/interfaces'; + +export default class PaymentMethod implements IPaymentModel { + +} \ No newline at end of file diff --git a/server/src/services/Payment/Voucher.ts b/server/src/services/Payment/Voucher.ts new file mode 100644 index 000000000..ad30e1932 --- /dev/null +++ b/server/src/services/Payment/Voucher.ts @@ -0,0 +1,78 @@ +import { Service, Container, Inject } from 'typedi'; +import cryptoRandomString from 'crypto-random-string'; +import { Voucher } from "@/system/models"; +import { IVoucher } from '@/interfaces'; +import VoucherMailMessages from '@/services/Payment/VoucherMailMessages'; +import VoucherSMSMessages from '@/services/Payment/VoucherSMSMessages'; + +@Service() +export default class VoucherService { + @Inject() + smsMessages: VoucherSMSMessages; + + @Inject() + mailMessages: VoucherMailMessages; + + /** + * Generates the voucher code in the given period. + * @param {number} voucherPeriod + * @return {Promise} + */ + async generateVoucher( + voucherPeriod: number, + periodInterval: string = 'days', + planId: number, + ): IVoucher { + let voucherCode: string; + let repeat: boolean = true; + + while(repeat) { + voucherCode = cryptoRandomString({ length: 10, type: 'numeric' }); + const foundVouchers = await Voucher.query().where('voucher_code', voucherCode); + + if (foundVouchers.length === 0) { + repeat = false; + } + } + return Voucher.query().insert({ + voucherCode, voucherPeriod, periodInterval, planId, + }); + } + + /** + * Disables the given voucher id on the storage. + * @param {number} voucherId + * @return {Promise} + */ + async disableVoucher(voucherId: number) { + return Voucher.markVoucherAsDisabled(voucherId, 'id'); + } + + /** + * Deletes the given voucher id from the storage. + * @param voucherId + */ + async deleteVoucher(voucherId: number) { + return Voucher.query().where('id', voucherId).delete(); + } + + /** + * Sends voucher code to the given customer via SMS or mail message. + * @param {string} voucherCode - Voucher code + * @param {string} phoneNumber - Phone number + * @param {string} email - Email address. + */ + async sendVoucherToCustomer(voucherCode: string, phoneNumber: string, email: string) { + const agenda = Container.get('agenda'); + + // Mark the voucher as used. + await Voucher.markVoucherAsSent(voucherCode); + + if (email) { + await agenda.schedule('1 second', 'send-voucher-via-email', { voucherCode, email }); + } + if (phoneNumber) { + await agenda.schedule('1 second', 'send-voucher-via-phone', { voucherCode, phoneNumber }); + } + } +} \ No newline at end of file diff --git a/server/src/services/Payment/VoucherMailMessages.ts b/server/src/services/Payment/VoucherMailMessages.ts new file mode 100644 index 000000000..14fc2a3bc --- /dev/null +++ b/server/src/services/Payment/VoucherMailMessages.ts @@ -0,0 +1,36 @@ +import fs from 'fs'; +import path from 'path'; +import Mustache from 'mustache'; +import { Container } from 'typedi'; +import mail from '@/services/mail'; + +export default class SubscriptionMailMessages { + /** + * Send voucher code to the given mail address. + * @param {string} voucherCode + * @param {email} email + */ + public async sendMailVoucher(voucherCode: string, email: string) { + const logger = Container.get('logger'); + + const filePath = path.join(global.rootPath, 'views/mail/VoucherReceive.html'); + const template = fs.readFileSync(filePath, 'utf8'); + const rendered = Mustache.render(template, { voucherCode }); + + const mailOptions = { + to: email, + from: `${process.env.MAIL_FROM_NAME} ${process.env.MAIL_FROM_ADDRESS}`, + subject: 'Bigcapital Voucher', + html: rendered, + }; + return new Promise((resolve, reject) => { + mail.sendMail(mailOptions, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } +} \ No newline at end of file diff --git a/server/src/services/Payment/VoucherPaymentMethod.ts b/server/src/services/Payment/VoucherPaymentMethod.ts new file mode 100644 index 000000000..15e878c99 --- /dev/null +++ b/server/src/services/Payment/VoucherPaymentMethod.ts @@ -0,0 +1,14 @@ +import { Voucher } from "@/system/models"; +import PaymentMethod from '@/services/Payment/PaymentMethod'; +import { IPaymentMethod, IVoucherPaymentModel } from '@/interfaces'; + +export default class VocuherPaymentMethod extends PaymentMethod implements IPaymentMethod { + /** + * Payment subscription of organization via voucher code. + * @param {IVoucherPaymentModel} + */ + async payment(voucherPaymentModel: IVoucherPaymentModel) { + // Mark the voucher code as used. + return Voucher.markVoucherAsUsed(voucherPaymentModel.voucherCode); + } +} \ No newline at end of file diff --git a/server/src/services/Payment/VoucherSMSMessages.ts b/server/src/services/Payment/VoucherSMSMessages.ts new file mode 100644 index 000000000..d35bf2250 --- /dev/null +++ b/server/src/services/Payment/VoucherSMSMessages.ts @@ -0,0 +1,17 @@ +import { Container, Inject } from 'typedi'; +import SMSClient from '@/services/SMSClient'; + +export default class SubscriptionSMSMessages { + @Inject('SMSClient') + smsClient: SMSClient; + + /** + * Sends voucher code to the given phone number via SMS message. + * @param {string} phoneNumber + * @param {string} voucherCode + */ + public async sendVoucherSMSMessage(phoneNumber: string, voucherCode: string) { + const message: string = `Your voucher card number: ${voucherCode}.`; + return this.smsClient.sendMessage(phoneNumber, message); + } +} \ No newline at end of file diff --git a/server/src/services/Payment/index.ts b/server/src/services/Payment/index.ts new file mode 100644 index 000000000..6e72f3992 --- /dev/null +++ b/server/src/services/Payment/index.ts @@ -0,0 +1,21 @@ +import { IPaymentMethod, IPaymentContext } from "@/interfaces"; + +export default class PaymentContext implements IPaymentContext{ + paymentMethod: IPaymentMethod; + + /** + * Constructor method. + * @param {IPaymentMethod} paymentMethod + */ + constructor(paymentMethod: IPaymentMethod) { + this.paymentMethod = paymentMethod; + } + + /** + * + * @param {} paymentModel + */ + makePayment(paymentModel: PaymentModel) { + this.paymentMethod.makePayment(paymentModel); + } +} \ No newline at end of file diff --git a/server/src/services/SMSClient/EasySmsClient.ts b/server/src/services/SMSClient/EasySmsClient.ts new file mode 100644 index 000000000..ec77ed8c5 --- /dev/null +++ b/server/src/services/SMSClient/EasySmsClient.ts @@ -0,0 +1,27 @@ +import axios from 'axios'; +import SMSClientInterface from '@/services/SMSClient/SMSClientInterfaces'; +import config from '@/../config/config'; + +export default class EasySMSClient implements SMSClientInterface { + clientName: string = 'easysms'; + + /** + * Send message to given phone number via easy SMS client. + * @param {string} to + * @param {string} message + */ + send(to: string, message: string) { + console.log(config); + const API_KEY = config.easySMSGateway.api_key; + const params = `action=send-sms&api_key=${API_KEY}=&to=${to}&sms=${message}&unicode=1`; + + return new Promise((resolve, reject) => { + axios.get(`https://easysms.devs.ly/sms/api?${params}`) + .then((response) => { + if (response.code === 'ok') { resolve(); } + else { reject(); } + }) + .catch((error) => { reject(error) }); + }); + } +} \ No newline at end of file diff --git a/server/src/services/SMSClient/SMSAPI.ts b/server/src/services/SMSClient/SMSAPI.ts new file mode 100644 index 000000000..9a57cc595 --- /dev/null +++ b/server/src/services/SMSClient/SMSAPI.ts @@ -0,0 +1,13 @@ +import SMSClientInterface from '@/services/SMSClient/SMSClientInterface'; + +export default class SMSAPI { + smsClient: SMSClientInterface; + + constructor(smsClient: SMSClientInterface){ + this.smsClient = smsClient; + } + + sendMessage(to: string, message: string, extraParams: [], extraHeaders: []) { + return this.smsClient.send(to, message); + } +} \ No newline at end of file diff --git a/server/src/services/SMSClient/SMSClientInterface.ts b/server/src/services/SMSClient/SMSClientInterface.ts new file mode 100644 index 000000000..8e1c0978b --- /dev/null +++ b/server/src/services/SMSClient/SMSClientInterface.ts @@ -0,0 +1,5 @@ + +export default interface SMSClientInterface { + clientName: string; + send(to: string, message: string): boolean; +} \ No newline at end of file diff --git a/server/src/services/SMSClient/index.ts b/server/src/services/SMSClient/index.ts new file mode 100644 index 000000000..ae92b9bbc --- /dev/null +++ b/server/src/services/SMSClient/index.ts @@ -0,0 +1,3 @@ +import SMSAPI from './SMSAPI'; + +export default SMSAPI; \ No newline at end of file diff --git a/server/src/services/Sales/SalesInvoices.ts b/server/src/services/Sales/SalesInvoices.ts index 095d11701..8653d57a4 100644 --- a/server/src/services/Sales/SalesInvoices.ts +++ b/server/src/services/Sales/SalesInvoices.ts @@ -30,7 +30,7 @@ export default class SaleInvoicesService extends SalesInvoicesCost { const balance = sumBy(saleInvoiceDTO.entries, 'amount'); const invLotNumber = await InventoryService.nextLotNumber(); const saleInvoice = { - ...formatDateFields(saleInvoiceDTO, ['invoide_date', 'due_date']), + ...formatDateFields(saleInvoiceDTO, ['invoice_date', 'due_date']), balance, paymentAmount: 0, invLotNumber, diff --git a/server/src/services/Subscription/Subscription.ts b/server/src/services/Subscription/Subscription.ts new file mode 100644 index 000000000..c63861a25 --- /dev/null +++ b/server/src/services/Subscription/Subscription.ts @@ -0,0 +1,48 @@ +import { Tenant, Plan } from '@/system/models'; +import { IPaymentContext } from '@/interfaces'; +import { NotAllowedChangeSubscriptionPlan } from '@/exceptions'; + +export default class Subscription { + paymentContext: IPaymentContext|null; + + /** + * Constructor method. + * @param {IPaymentContext} + */ + constructor(payment?: IPaymentContext) { + this.paymentContext = payment; + } + + /** + * Subscripe to the given plan. + * @param {Plan} plan + * @throws {NotAllowedChangeSubscriptionPlan} + */ + async subscribe( + tenant: Tenant, + plan: Plan, + paymentModel?: PaymentModel, + subscriptionSlug: string = 'main', + ) { + if (plan.price < 0) { + await this.paymentContext.makePayment(paymentModel); + } + 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; + + // In case there is already subscription associated to the given tenant. + // renew it. + } else if(subscription && subscription.inactive()) { + await subscription.renew(plan); + + // No stored past tenant subscriptions create new one. + } else { + await tenant.newSubscription(subscriptionSlug, plan); + } + } +} \ No newline at end of file diff --git a/server/src/services/Subscription/SubscriptionPeriod.ts b/server/src/services/Subscription/SubscriptionPeriod.ts new file mode 100644 index 000000000..c1d2e4a8b --- /dev/null +++ b/server/src/services/Subscription/SubscriptionPeriod.ts @@ -0,0 +1,41 @@ +import moment from 'moment'; + +export default class SubscriptionPeriod { + start: Date; + end: Date; + interval: string; + count: number; + + /** + * Constructor method. + * @param {string} interval - + * @param {number} count - + * @param {Date} start - + */ + constructor(interval: string = 'month', count: number, start?: Date) { + this.interval = interval; + this.count = count; + this.start = start; + + if (!start) { + this.start = moment().toDate(); + } + this.end = moment(start).add(count, interval).toDate(); + } + + getStartDate() { + return this.start; + } + + getEndDate() { + return this.end; + } + + getInterval() { + return this.interval; + } + + getIntervalCount() { + return this.interval; + } +} \ No newline at end of file diff --git a/server/src/services/Subscription/SubscriptionService.ts b/server/src/services/Subscription/SubscriptionService.ts new file mode 100644 index 000000000..397582fb0 --- /dev/null +++ b/server/src/services/Subscription/SubscriptionService.ts @@ -0,0 +1,36 @@ +import { Service } from 'typedi'; +import { Plan, Tenant, Voucher } from '@/system/models'; +import Subscription from '@/services/Subscription/Subscription'; +import VocuherPaymentMethod from '@/services/Payment/VoucherPaymentMethod'; +import PaymentContext from '@/services/Payment'; + +@Service() +export default class SubscriptionService { + /** + * Handles the payment process via voucher code and than subscribe to + * the given tenant. + * + * @param {number} tenantId + * @param {String} planSlug + * @param {string} voucherCode + * + * @return {Promise} + */ + async subscriptionViaVoucher( + tenantId: number, + planSlug: string, + voucherCode: string, + subscriptionSlug: string = 'main', + ) { + const plan = await Plan.query().findOne('slug', planSlug); + const tenant = await Tenant.query().findById(tenantId); + const voucherModel = await Voucher.query().findOne('voucher_code', voucherCode); + + const paymentViaVoucher = new VocuherPaymentMethod(); + const paymentContext = new PaymentContext(paymentViaVoucher); + + const subscription = new Subscription(paymentContext); + + return subscription.subscribe(tenant, plan, voucherModel, subscriptionSlug); + } +} \ No newline at end of file diff --git a/server/src/services/Subscription/UserSubscription.js b/server/src/services/Subscription/UserSubscription.js deleted file mode 100644 index e9f2d993f..000000000 --- a/server/src/services/Subscription/UserSubscription.js +++ /dev/null @@ -1,22 +0,0 @@ - - -export default (Model) => { - return class UserSubscription extends Model{ - - onTrial() { - - } - - getSubscription() { - - } - - newSubscription() { - - } - - isSubcribedTo(plan) { - - } - } -}; \ No newline at end of file diff --git a/server/src/system/migrations/20200823234134_create_plans_table.js b/server/src/system/migrations/20200823234134_create_plans_table.js new file mode 100644 index 000000000..80392db62 --- /dev/null +++ b/server/src/system/migrations/20200823234134_create_plans_table.js @@ -0,0 +1,31 @@ + +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/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js b/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js new file mode 100644 index 000000000..78aaf3356 --- /dev/null +++ b/server/src/system/migrations/20200823234434_create_subscription_plan_feature.js @@ -0,0 +1,17 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_plan_features', table => { + table.increments(); + + table.integer('plan_id').unsigned(); + 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/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js b/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js new file mode 100644 index 000000000..a8b7e2621 --- /dev/null +++ b/server/src/system/migrations/20200823234636_create_subscription_plan_subscription.js @@ -0,0 +1,25 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_plan_subscriptions', table => { + table.increments('id'); + table.string('slug'); + + table.integer('plan_id').unsigned(); + table.integer('tenant_id').unsigned(); + + table.dateTime('trial_started_at').nullable(); + table.dateTime('trial_ends_at').nullable(); + + 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/server/src/system/migrations/20200823235339_create_subscription_vouchers_table.js b/server/src/system/migrations/20200823235339_create_subscription_vouchers_table.js new file mode 100644 index 000000000..2c5f68afe --- /dev/null +++ b/server/src/system/migrations/20200823235339_create_subscription_vouchers_table.js @@ -0,0 +1,26 @@ + +exports.up = function(knex) { + return knex.schema.createTable('subscription_vouchers', table => { + table.increments(); + + table.string('voucher_code').unique(); + table.integer('plan_id').unsigned(); + + table.integer('voucher_period').unsigned(); + table.string('period_interval'); + + table.boolean('sent').defaultTo(false); + table.boolean('disabled').defaultTo(false); + table.boolean('used').defaultTo(false); + + table.dateTime('sent_at'); + table.dateTime('disabled_at'); + table.dateTime('used_at'); + + table.timestamps(); + }) +}; + +exports.down = function(knex) { + return knex.schema.dropTableIfExists('subscription_vouchers'); +}; diff --git a/server/src/system/models/SubscriptionLicense.js b/server/src/system/models/SubscriptionLicense.js deleted file mode 100644 index c07eaed75..000000000 --- a/server/src/system/models/SubscriptionLicense.js +++ /dev/null @@ -1,18 +0,0 @@ -import { Model, mixin } from 'objection'; -import SystemModel from '@/system/models/SystemModel'; -import DateSession from '@/models/DateSession'; -import UserSubscription from '@/services/Subscription/UserSubscription'; - - -export default class SubscriptionLicense extends mixin(SystemModel, [DateSession, UserSubscription]) { - /** - * Table name. - */ - static get tableName() { - return 'subscription_licences'; - } - - markAsUsed() { - - } -} diff --git a/server/src/system/models/SubscriptionUsage.js b/server/src/system/models/SubscriptionUsage.js deleted file mode 100644 index c612b84dd..000000000 --- a/server/src/system/models/SubscriptionUsage.js +++ /dev/null @@ -1,10 +0,0 @@ -import SystemModel from '@/system/models/SystemModel'; - -export default class SubscriptionUsage extends SystemModel { - /** - * Table name - */ - static get tableName() { - return 'subscriptions_usage'; - } -} diff --git a/server/src/system/models/Subscriptions/Plan.js b/server/src/system/models/Subscriptions/Plan.js new file mode 100644 index 000000000..9d707b0b8 --- /dev/null +++ b/server/src/system/models/Subscriptions/Plan.js @@ -0,0 +1,94 @@ +import { Model, mixin } from 'objection'; +import SystemModel from '@/system/models/SystemModel'; +import { PlanSubscription } from '..'; + +export default class Plan extends mixin(SystemModel) { + /** + * Table name. + */ + static get tableName() { + return 'subscription_plans'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Defined virtual attributes. + */ + static get virtualAttributes() { + return ['isFree', 'hasTrial']; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + getFeatureBySlug(builder, featureSlug) { + builder.where('slug', featureSlug); + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const PlanFeature = require('@/system/models/Subscriptions/PlanFeature'); + + return { + /** + * The plan may have many features. + */ + features: { + relation: Model.BelongsToOneRelation, + modelClass: PlanFeature.default, + join: { + from: 'subscriptions_plans.id', + to: 'subscriptions_plan_features.planId', + }, + }, + + /** + * The plan may have many subscriptions. + */ + subscriptions: { + relation: Model.HasManyRelation, + modelClass: PlanSubscription.default, + join: { + from: 'subscription_plans.id', + to: 'subscription_plans.planId', + }, + } + }; + } + + /** + * Check if plan is free. + * @return {boolean} + */ + isFree() { + return this.price <= 0; + } + + /** + * Check if plan is paid. + * @return {boolean} + */ + isPaid() { + return !this.isFree(); + } + + /** + * Check if plan has trial. + * @return {boolean} + */ + hasTrial() { + return this.trialPeriod && this.trialInterval; + } +} diff --git a/server/src/system/models/Subscriptions/PlanFeature.js b/server/src/system/models/Subscriptions/PlanFeature.js new file mode 100644 index 000000000..0fe03bcbc --- /dev/null +++ b/server/src/system/models/Subscriptions/PlanFeature.js @@ -0,0 +1,36 @@ +import { Model, mixin } from 'objection'; +import SystemModel from '@/system/models/SystemModel'; + +export default class PlanFeature extends mixin(SystemModel) { + /** + * Table name. + */ + static get tableName() { + return 'subscriptions.plan_features'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Plan = require('@/system/models/Subscriptions/Plan'); + + return { + plan: { + relation: Model.BelongsToOneRelation, + modelClass: Plan.default, + join: { + from: 'subscriptions.plan_features.planId', + to: 'subscriptions.plans.id', + }, + }, + }; + } +} diff --git a/server/src/system/models/Subscriptions/PlanSubscription.js b/server/src/system/models/Subscriptions/PlanSubscription.js new file mode 100644 index 000000000..18946a585 --- /dev/null +++ b/server/src/system/models/Subscriptions/PlanSubscription.js @@ -0,0 +1,170 @@ +import { Model, mixin } from 'objection'; +import SystemModel from '@/system/models/SystemModel'; +import moment from 'moment'; +import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; + +export default class PlanSubscription extends mixin(SystemModel) { + /** + * Table name. + */ + static get tableName() { + return 'subscription_plan_subscriptions'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Defined virtual attributes. + */ + static get virtualAttributes() { + return ['active', 'inactive', 'ended', 'onTrial']; + } + + /** + * Modifiers queries. + */ + static get modifiers() { + return { + activeSubscriptions(builder) { + const dateFormat = 'YYYY-MM-DD HH:mm:ss'; + const now = moment().format(dateFormat); + + builder.where('ends_at', '>', now); + builder.where('trial_ends_at', '>', now); + }, + + inactiveSubscriptions() { + builder.modify('endedTrial'); + builder.modify('endedPeriod'); + }, + + subscriptionBySlug(builder, subscriptionSlug) { + builder.where('slug', subscriptionSlug); + }, + + endedTrial(builder) { + const dateFormat = 'YYYY-MM-DD HH:mm:ss'; + const endDate = moment().format(dateFormat); + + builder.where('ends_at', '<=', endDate); + }, + + endedPeriod(builder) { + const dateFormat = 'YYYY-MM-DD HH:mm:ss'; + const endDate = moment().format(dateFormat); + + builder.where('trial_ends_at', '<=', endDate); + } + }; + } + + /** + * Relations mappings. + */ + static get relationMappings() { + const Tenant = require('@/system/Models/Tenant'); + const Plan = require('@/system/Models/Subscriptions/Plan'); + + return { + /** + * Plan subscription belongs to tenant. + */ + tenant: { + relation: Model.BelongsToOneRelation, + modelClass: this.relationBindKnex(Tenant.default), + join: { + from: 'subscription_plan_subscriptions.tenantId', + to: 'tenants.id' + }, + }, + + /** + * Plan description belongs to plan. + */ + plan: { + relation: Model.BelongsToOneRelation, + modelClass: this.relationBindKnex(Plan.default), + join: { + from: 'subscription_plan_subscriptions.planId', + to: 'subscription_plans.id', + }, + }, + }; + } + + /** + * Check if subscription is active. + * @return {Boolean} + */ + active() { + return !this.ended() || this.onTrial(); + } + + /** + * Check if subscription is inactive. + * @return {Boolean} + */ + inactive() { + return !this.active(); + } + + /** + * Check if subscription period has ended. + * @return {Boolean} + */ + ended() { + return this.endsAt ? moment().isAfter(this.endsAt) : false; + } + + /** + * Check if subscription is currently on trial. + * @return {Boolean} + */ + onTrial() { + return this.trailEndsAt ? moment().isAfter(this.trailEndsAt) : false; + } + + /** + * Set new period from the given details. + * @param {string} invoiceInterval + * @param {number} invoicePeriod + * @param {string} start + * + * @return {Object} + */ + setNewPeriod(invoiceInterval, invoicePeriod, start) { + let _invoiceInterval = invoiceInterval; + let _invoicePeriod = invoicePeriod; + + if (!invoiceInterval) { + _invoiceInterval = this.plan.invoiceInterval; + } + if (!invoicePeriod) { + _invoicePeriod = this.plan.invoicePeriod; + } + const period = new SubscriptionPeriod(_invoiceInterval, _invoicePeriod, start); + + const startsAt = period.getStartDate(); + const endsAt = period.getEndDate(); + + return { startsAt, endsAt }; + } + + /** + * Renews subscription period. + * @Promise + */ + renew(plan) { + const { invoicePeriod, invoiceInterval } = plan; + const patch = { ...this.setNewPeriod(invoiceInterval, invoicePeriod) }; + patch.cancelsAt = null; + patch.planId = plan.id; + + return this.$query().patch(patch); + } +} diff --git a/server/src/system/models/Subscriptions/Voucher.ts b/server/src/system/models/Subscriptions/Voucher.ts new file mode 100644 index 000000000..4e0a8f6d2 --- /dev/null +++ b/server/src/system/models/Subscriptions/Voucher.ts @@ -0,0 +1,141 @@ +import { Model, mixin } from 'objection'; +import moment from 'moment'; +import SystemModel from '@/system/models/SystemModel'; +import { IVouchersFilter } from '@/interfaces'; + +export default class Voucher extends mixin(SystemModel) { + /** + * Table name. + */ + static get tableName() { + return 'subscription_vouchers'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + // Filters active vouchers. + filterActiveVoucher(query) { + query.where('disabled', false); + query.where('used', false); + query.where('sent', false); + }, + + // Find voucher by its code or id. + findByCodeOrId(query, id, code) { + if (id) { + query.where('id', id); + } + if (code) { + query.where('voucher_code', code); + } + }, + + // Filters vouchers list. + filter(builder, vouchersFilter: IVouchersFilter) { + if (vouchersFilter.active) { + builder.modify('filterActiveVoucher') + } + if (vouchersFilter.disabled) { + builder.where('disabled', true); + } + if (vouchersFilter.used) { + builder.where('used', true); + } + if (vouchersFilter.sent) { + builder.where('sent', true); + } + } + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Plan = require('@/system/models/Subscriptions/Plan'); + + return { + plan: { + relation: Model.BelongsToOneRelation, + modelClass: Plan.default, + join: { + from: 'subscription_vouchers.planId', + to: 'subscriptions_plans.id', + }, + }, + }; + } + + /** + * Deletes the given voucher code from the storage. + * @param {string} voucherCode + * @return {Promise} + */ + static deleteVoucher(voucherCode: string, viaAttribute: string = 'voucher_code') { + return this.query() + .where(viaAttribute, voucherCode) + .delete(); + } + + /** + * Marks the given voucher code as disabled on the storage. + * @param {string} voucherCode + * @return {Promise} + */ + static markVoucherAsDisabled(voucherCode: string, viaAttribute: string = 'voucher_code') { + return this.query() + .where(viaAttribute, voucherCode) + .patch({ + disabled: true, + disabled_at: moment().toMySqlDateTime(), + }); + } + + /** + * Marks the given voucher code as sent on the storage. + * @param {string} voucherCode + */ + static markVoucherAsSent(voucherCode: string, viaAttribute: string = 'voucher_code') { + return this.query() + .where(viaAttribute, voucherCode) + .patch({ + sent: true, + sent_at: moment().toMySqlDateTime(), + }); + } + + /** + * Marks the given voucher code as used on the storage. + * @param {string} voucherCode + * @return {Promise} + */ + static markVoucherAsUsed(voucherCode: string, viaAttribute: string = 'voucher_code') { + return this.query() + .where(viaAttribute, voucherCode) + .patch({ + used: true, + used_at: moment().toMySqlDateTime() + }); + } + + /** + * + * @param {IIPlan} plan + * @return {boolean} + */ + isEqualPlanPeriod(plan) { + return (this.invoicePeriod === plan.invoiceInterval && + voucher.voucherPeriod === voucher.periodInterval); + } + +} diff --git a/server/src/system/models/SystemUser.js b/server/src/system/models/SystemUser.js index 38fef312b..1857aed98 100644 --- a/server/src/system/models/SystemUser.js +++ b/server/src/system/models/SystemUser.js @@ -1,10 +1,9 @@ import { Model, mixin } from 'objection'; import bcrypt from 'bcryptjs'; import SystemModel from '@/system/models/SystemModel'; -import UserSubscription from '@/services/Subscription/UserSubscription'; -export default class SystemUser extends mixin(SystemModel, [UserSubscription]) { +export default class SystemUser extends mixin(SystemModel) { /** * Table name. */ @@ -24,7 +23,6 @@ export default class SystemUser extends mixin(SystemModel, [UserSubscription]) { */ static get relationMappings() { const Tenant = require('@/system/models/Tenant'); - const SubscriptionUsage = require('@/system/models/SubscriptionUsage'); return { tenant: { @@ -35,15 +33,6 @@ export default class SystemUser extends mixin(SystemModel, [UserSubscription]) { to: 'tenants.id', }, }, - - subscriptionUsage: { - relation: Model.BelongsToOneRelation, - modelClass: SubscriptionUsage.default, - join: { - from: 'users.id', - to: 'subscriptions_usage.user_id', - } - }, }; } diff --git a/server/src/system/models/Tenant.js b/server/src/system/models/Tenant.js index 1c8b3fe58..3a248e23b 100644 --- a/server/src/system/models/Tenant.js +++ b/server/src/system/models/Tenant.js @@ -1,4 +1,6 @@ import BaseModel from '@/models/Model'; +import { Model } from 'objection'; +import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod'; export default class Tenant extends BaseModel { /** @@ -7,4 +9,63 @@ export default class Tenant extends BaseModel { static get tableName() { return 'tenants'; } + + /** + * Query modifiers. + */ + static modifiers() { + return { + subscriptions(builder) { + builder.withGraphFetched('subscriptions'); + }, + }; + } + + /** + * Relations mappings. + */ + static get relationMappings() { + const PlanSubscription = require('./Subscriptions/PlanSubscription'); + + return { + subscriptions: { + relation: Model.HasManyRelation, + modelClass: this.relationBindKnex(PlanSubscription.default), + join: { + from: 'tenants.id', + to: 'subscription_plan_subscriptions.tenantId', + } + }, + } + } + + /** + * Retrieve the subscribed plans ids. + * @return {number[]} + */ + async subscribedPlansIds() { + const { subscriptions } = this; + return chain(subscriptions).map('planId').unq(); + } + + /** + * Records a new subscription for the associated tenant. + * @param {string} subscriptionSlug + * @param {IPlan} plan + */ + newSubscription(subscriptionSlug, plan) { + const trial = new SubscriptionPeriod(plan.trialInterval, plan.trialPeriod) + const period = new SubscriptionPeriod(plan.invoiceInterval, plan.invoicePeriod, trial.getEndDate()); + + return this.$relatedQuery('subscriptions').insert({ + slug: subscriptionSlug, + planId: plan.id, + + trialStartedAt: trial.getStartDate(), + trialEndsAt: trial.getEndDate(), + + startsAt: period.getStartDate(), + endsAt: period.getEndDate(), + }); + } } diff --git a/server/src/system/models/index.js b/server/src/system/models/index.js new file mode 100644 index 000000000..55cc3e359 --- /dev/null +++ b/server/src/system/models/index.js @@ -0,0 +1,14 @@ + +import Plan from './Subscriptions/Plan'; +import PlanFeature from './Subscriptions/PlanFeature'; +import PlanSubscription from './Subscriptions/PlanSubscription'; +import Voucher from './Subscriptions/Voucher'; +import Tenant from './Tenant'; + +export { + Plan, + PlanFeature, + PlanSubscription, + Voucher, + Tenant, +} \ No newline at end of file diff --git a/server/src/system/seeds/seed_subscriptions_plans.js b/server/src/system/seeds/seed_subscriptions_plans.js new file mode 100644 index 000000000..1bedcab87 --- /dev/null +++ b/server/src/system/seeds/seed_subscriptions_plans.js @@ -0,0 +1,26 @@ + +exports.seed = (knex) => { + // Deletes ALL existing entries + return knex('subscription_plans').del() + .then(() => { + // Inserts seed entries + return knex('subscription_plans').insert([ + { + id: 1, + name: 'free', + slug: 'free', + price: 0, + active: true, + currency: 'LYD', + + trial_period: 15, + trial_interval: 'days', + + invoice_period: 3, + invoice_interval: 'month', + + index: 1, + } + ]); + }); +}; diff --git a/server/tsconfig.json b/server/tsconfig.json index f2280a902..f4c8d08ab 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -8,5 +8,7 @@ "jsx": "react", "allowJs": true, "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, } } \ No newline at end of file diff --git a/server/views/mail/VoucherReceive.html b/server/views/mail/VoucherReceive.html new file mode 100644 index 000000000..0778505d5 --- /dev/null +++ b/server/views/mail/VoucherReceive.html @@ -0,0 +1,411 @@ + + + + + + Bigcapital | Reset your password + + + + This is preheader text. Some clients will show this text as a preview. + + + + + + + + +