mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-17 05:10:31 +00:00
feat: Payment system with voucher cards.
feat: Design with inversion dependency injection architecture. feat: Prettier http middleware. feat: Re-write items categories with preferred accounts.
This commit is contained in:
@@ -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',
|
||||
}
|
||||
]);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
export default class NotAllowedChangeSubscriptionPlan extends Error{
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "NotAllowedChangeSubscriptionPlan";
|
||||
}
|
||||
}
|
||||
5
server/src/exceptions/index.ts
Normal file
5
server/src/exceptions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan';
|
||||
|
||||
export {
|
||||
NotAllowedChangeSubscriptionPlan,
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
};
|
||||
437
server/src/http/controllers/ItemCategories.ts
Normal file
437
server/src/http/controllers/ItemCategories.ts
Normal file
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
29
server/src/http/controllers/Subscription/PaymentMethod.ts
Normal file
29
server/src/http/controllers/Subscription/PaymentMethod.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
118
server/src/http/controllers/Subscription/PaymentViaVoucher.ts
Normal file
118
server/src/http/controllers/Subscription/PaymentViaVoucher.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
261
server/src/http/controllers/Subscription/Vouchers.ts
Normal file
261
server/src/http/controllers/Subscription/Vouchers.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
22
server/src/http/controllers/Subscription/index.ts
Normal file
22
server/src/http/controllers/Subscription/index.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
26
server/src/http/middleware/SubscriptionMiddleware.js
Normal file
26
server/src/http/middleware/SubscriptionMiddleware.js
Normal file
@@ -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();
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
|
||||
|
||||
|
||||
const subscriptionObserver = (req, res, next) => {
|
||||
|
||||
};
|
||||
|
||||
export default subscriptionObserver;
|
||||
@@ -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' &&
|
||||
|
||||
13
server/src/http/middleware/TenantDependencyInjection.ts
Normal file
13
server/src/http/middleware/TenantDependencyInjection.ts
Normal file
@@ -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();
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
30
server/src/interfaces/ItemCategory.ts
Normal file
30
server/src/interfaces/ItemCategory.ts
Normal file
@@ -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,
|
||||
};
|
||||
20
server/src/interfaces/Payment.ts
Normal file
20
server/src/interfaces/Payment.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
|
||||
|
||||
export interface IPaymentModel {}
|
||||
|
||||
export interface IVoucherPaymentModel extends IPaymentModel {
|
||||
voucherCode: string;
|
||||
}
|
||||
|
||||
export interface IPaymentMethod {
|
||||
makePayment(paymentModel: IPaymentModel): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface IVoucherPaymentMethod {
|
||||
makePayment(paymentModel: IVoucherPaymentModel): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface IPaymentContext<PaymentModel> {
|
||||
paymentMethod: IPaymentMethod;
|
||||
makePayment(paymentModel: PaymentModel): Promise<boolean>;
|
||||
}
|
||||
17
server/src/interfaces/Voucher.ts
Normal file
17
server/src/interfaces/Voucher.ts
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
22
server/src/jobs/SendVoucherEmail.ts
Normal file
22
server/src/jobs/SendVoucherEmail.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
server/src/jobs/SendVoucherPhone.ts
Normal file
22
server/src/jobs/SendVoucherPhone.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
9
server/src/loaders/smsClient.ts
Normal file
9
server/src/loaders/smsClient.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
6
server/src/services/Payment/PaymentMethod.ts
Normal file
6
server/src/services/Payment/PaymentMethod.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import moment from 'moment';
|
||||
import { IPaymentModel } from '@/interfaces';
|
||||
|
||||
export default class PaymentMethod implements IPaymentModel {
|
||||
|
||||
}
|
||||
78
server/src/services/Payment/Voucher.ts
Normal file
78
server/src/services/Payment/Voucher.ts
Normal file
@@ -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<IVoucher>}
|
||||
*/
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
36
server/src/services/Payment/VoucherMailMessages.ts
Normal file
36
server/src/services/Payment/VoucherMailMessages.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
14
server/src/services/Payment/VoucherPaymentMethod.ts
Normal file
14
server/src/services/Payment/VoucherPaymentMethod.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
17
server/src/services/Payment/VoucherSMSMessages.ts
Normal file
17
server/src/services/Payment/VoucherSMSMessages.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
21
server/src/services/Payment/index.ts
Normal file
21
server/src/services/Payment/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { IPaymentMethod, IPaymentContext } from "@/interfaces";
|
||||
|
||||
export default class PaymentContext<PaymentModel> implements IPaymentContext{
|
||||
paymentMethod: IPaymentMethod;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
* @param {IPaymentMethod} paymentMethod
|
||||
*/
|
||||
constructor(paymentMethod: IPaymentMethod) {
|
||||
this.paymentMethod = paymentMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {<PaymentModel>} paymentModel
|
||||
*/
|
||||
makePayment(paymentModel: PaymentModel) {
|
||||
this.paymentMethod.makePayment(paymentModel);
|
||||
}
|
||||
}
|
||||
27
server/src/services/SMSClient/EasySmsClient.ts
Normal file
27
server/src/services/SMSClient/EasySmsClient.ts
Normal file
@@ -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) });
|
||||
});
|
||||
}
|
||||
}
|
||||
13
server/src/services/SMSClient/SMSAPI.ts
Normal file
13
server/src/services/SMSClient/SMSAPI.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
5
server/src/services/SMSClient/SMSClientInterface.ts
Normal file
5
server/src/services/SMSClient/SMSClientInterface.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
export default interface SMSClientInterface {
|
||||
clientName: string;
|
||||
send(to: string, message: string): boolean;
|
||||
}
|
||||
3
server/src/services/SMSClient/index.ts
Normal file
3
server/src/services/SMSClient/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import SMSAPI from './SMSAPI';
|
||||
|
||||
export default SMSAPI;
|
||||
@@ -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,
|
||||
|
||||
48
server/src/services/Subscription/Subscription.ts
Normal file
48
server/src/services/Subscription/Subscription.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Tenant, Plan } from '@/system/models';
|
||||
import { IPaymentContext } from '@/interfaces';
|
||||
import { NotAllowedChangeSubscriptionPlan } from '@/exceptions';
|
||||
|
||||
export default class Subscription<PaymentModel> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
server/src/services/Subscription/SubscriptionPeriod.ts
Normal file
41
server/src/services/Subscription/SubscriptionPeriod.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
36
server/src/services/Subscription/SubscriptionService.ts
Normal file
36
server/src/services/Subscription/SubscriptionService.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
|
||||
|
||||
export default (Model) => {
|
||||
return class UserSubscription extends Model{
|
||||
|
||||
onTrial() {
|
||||
|
||||
}
|
||||
|
||||
getSubscription() {
|
||||
|
||||
}
|
||||
|
||||
newSubscription() {
|
||||
|
||||
}
|
||||
|
||||
isSubcribedTo(plan) {
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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')
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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() {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import SystemModel from '@/system/models/SystemModel';
|
||||
|
||||
export default class SubscriptionUsage extends SystemModel {
|
||||
/**
|
||||
* Table name
|
||||
*/
|
||||
static get tableName() {
|
||||
return 'subscriptions_usage';
|
||||
}
|
||||
}
|
||||
94
server/src/system/models/Subscriptions/Plan.js
Normal file
94
server/src/system/models/Subscriptions/Plan.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
36
server/src/system/models/Subscriptions/PlanFeature.js
Normal file
36
server/src/system/models/Subscriptions/PlanFeature.js
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
170
server/src/system/models/Subscriptions/PlanSubscription.js
Normal file
170
server/src/system/models/Subscriptions/PlanSubscription.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
141
server/src/system/models/Subscriptions/Voucher.ts
Normal file
141
server/src/system/models/Subscriptions/Voucher.ts
Normal file
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
14
server/src/system/models/index.js
Normal file
14
server/src/system/models/index.js
Normal file
@@ -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,
|
||||
}
|
||||
26
server/src/system/seeds/seed_subscriptions_plans.js
Normal file
26
server/src/system/seeds/seed_subscriptions_plans.js
Normal file
@@ -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,
|
||||
}
|
||||
]);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user