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:
Ahmed Bouhuolia
2020-08-27 20:39:55 +02:00
parent e23b8d9947
commit e4270dc039
63 changed files with 2567 additions and 462 deletions

View File

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

View File

@@ -12,6 +12,9 @@ const configEnv = {
migrations: {
directory: config.system.migrations_dir,
},
seeds: {
directory: config.system.seeds_dir,
},
pool: { min: 0, max: 7 },
};

View File

@@ -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"
},

View File

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

View File

@@ -0,0 +1,9 @@
export default class NotAllowedChangeSubscriptionPlan extends Error{
constructor(message: string) {
super(message);
this.name = "NotAllowedChangeSubscriptionPlan";
}
}

View File

@@ -0,0 +1,5 @@
import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan';
export {
NotAllowedChangeSubscriptionPlan,
};

View File

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

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

View File

@@ -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,

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

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

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

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

View File

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

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

View File

@@ -1,8 +0,0 @@
const subscriptionObserver = (req, res, next) => {
};
export default subscriptionObserver;

View File

@@ -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' &&

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

View File

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

View 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,
};

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

View 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,
};

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import moment from 'moment';
import { IPaymentModel } from '@/interfaces';
export default class PaymentMethod implements IPaymentModel {
}

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

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

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
export default interface SMSClientInterface {
clientName: string;
send(to: string, message: string): boolean;
}

View File

@@ -0,0 +1,3 @@
import SMSAPI from './SMSAPI';
export default SMSAPI;

View File

@@ -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,

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

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

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

View File

@@ -1,22 +0,0 @@
export default (Model) => {
return class UserSubscription extends Model{
onTrial() {
}
getSubscription() {
}
newSubscription() {
}
isSubcribedTo(plan) {
}
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +0,0 @@
import SystemModel from '@/system/models/SystemModel';
export default class SubscriptionUsage extends SystemModel {
/**
* Table name
*/
static get tableName() {
return 'subscriptions_usage';
}
}

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

View 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',
},
},
};
}
}

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

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

View File

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

View File

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

View 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,
}

View 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,
}
]);
});
};

View File

@@ -8,5 +8,7 @@
"jsx": "react",
"allowJs": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
}
}

View File

@@ -0,0 +1,411 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Bigcapital | Reset your password</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
/*All the styling goes here*/
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
background-color: #f6f6f6;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%; }
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #f6f6f6;
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 10px;
width: 580px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 580px;
padding: 10px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #ffffff;
border-radius: 3px;
width: 100%;
}
.wrapper {
box-sizing: border-box;
padding: 20px;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
font-size: 12px;
text-align: center;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #000000;
font-family: sans-serif;
font-weight: bold;
line-height: 1.4;
margin: 0;
margin-bottom: 0;
}
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize;
}
p,
ul,
ol {
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
}
a {
color: #3498db;
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
color: #3498db;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #2d95fd;
}
.btn-primary a {
background-color: #2d95fd;
border-color: #2d95fd;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.mb4{
margin-bottom: 4rem;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
hr {
border: 0;
border-bottom: 1px solid #f6f6f6;
margin: 20px 0;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #004dd0 !important;
}
.btn-primary a:hover {
background-color: #004dd0 !important;
border-color: #004dd0 !important;
}
}
[data-icon="bigcapital"] path {
fill: #004dd0;
}
[data-icon='bigcapital'] .path-1,
[data-icon='bigcapital'] .path-13 {
fill: #2d95fd;
}
</style>
</head>
<body class="">
<span class="preheader">This is preheader text. Some clients will show this text as a preview.</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td>
<p class="align-center">
<svg data-icon="bigcapital" class="bigcapital" width="190" height="37" viewBox="0 0 309.09 42.89"><desc>bigcapital</desc><path d="M56,3.16,61.33,8.5,31.94,37.9l-5.35-5.35Z" class="path-1" fill-rule="evenodd"></path><path d="M29.53,6.94l5.35,5.34L5.49,41.67.14,36.33l15.8-15.8Z" class="path-2" fill-rule="evenodd"></path><path d="M94.36,38.87H79.62v-31H94c6.33,0,10.22,3.15,10.22,8V16a7.22,7.22,0,0,1-4.07,6.69c3.58,1.37,5.8,3.45,5.8,7.61v.09C106,36,101.35,38.87,94.36,38.87Zm3.1-21.81c0-2-1.59-3.19-4.47-3.19H86.26v6.55h6.29c3,0,4.91-1,4.91-3.28Zm1.72,12.39c0-2.08-1.54-3.37-5-3.37H86.26V32.9h8.1c3,0,4.82-1.06,4.82-3.36Z" class="path-3" fill-rule="evenodd"></path><path d="M110.56,12.54v-6h7.08v6Zm.18,26.33V15.15h6.72V38.87Z" class="path-4" fill-rule="evenodd"></path><path d="M134,46a22.55,22.55,0,0,1-10.49-2.47l2.3-5a15.52,15.52,0,0,0,8,2.17c4.61,0,6.78-2.21,6.78-6.46V33.08c-2,2.39-4.16,3.85-7.75,3.85-5.53,0-10.53-4-10.53-11.07v-.09c0-7.08,5.09-11.06,10.53-11.06a9.63,9.63,0,0,1,7.66,3.54v-3.1h6.72V33.52C147.2,42.46,142.78,46,134,46Zm6.6-20.27a5.79,5.79,0,0,0-11.56,0v.09a5.42,5.42,0,0,0,5.76,5.49,5.49,5.49,0,0,0,5.8-5.49Z" class="path-5" fill-rule="evenodd"></path><path d="M164,39.41a12.11,12.11,0,0,1-12.35-12.26v-.09a12.18,12.18,0,0,1,12.44-12.35c4.47,0,7.25,1.5,9.47,4l-4.12,4.43a6.93,6.93,0,0,0-5.4-2.61c-3.36,0-5.75,3-5.75,6.46v.09c0,3.63,2.34,6.55,6,6.55,2.26,0,3.8-1,5.44-2.53l3.94,4A12,12,0,0,1,164,39.41Z" class="path-6" fill-rule="evenodd"></path><path d="M191.51,38.87V36.31a9.15,9.15,0,0,1-7.17,3c-4.47,0-8.15-2.57-8.15-7.26V32c0-5.18,3.94-7.57,9.56-7.57a16.74,16.74,0,0,1,5.8,1V25c0-2.79-1.72-4.34-5.09-4.34a17.57,17.57,0,0,0-6.55,1.28l-1.68-5.13a21,21,0,0,1,9.21-1.9c7.34,0,10.57,3.8,10.57,10.22V38.87Zm.13-9.55a10.3,10.3,0,0,0-4.29-.89c-2.88,0-4.65,1.15-4.65,3.27v.09c0,1.82,1.5,2.88,3.67,2.88,3.15,0,5.27-1.73,5.27-4.16Z" class="path-7" fill-rule="evenodd"></path><path d="M217.49,39.32a9.1,9.1,0,0,1-7.39-3.54V46h-6.73V15.15h6.73v3.41a8.7,8.7,0,0,1,7.39-3.85c5.53,0,10.8,4.34,10.8,12.26v.09C228.29,35,223.11,39.32,217.49,39.32ZM221.56,27c0-3.94-2.66-6.55-5.8-6.55S210,23,210,27v.09c0,3.94,2.61,6.55,5.75,6.55s5.8-2.57,5.8-6.55Z" class="path-8" fill-rule="evenodd"></path><path d="M232.93,12.54v-6H240v6Zm.18,26.33V15.15h6.73V38.87Z" class="path-9" fill-rule="evenodd"></path><path d="M253.73,39.27c-4.11,0-6.9-1.63-6.9-7.12V20.91H244V15.15h2.83V9.09h6.73v6.06h5.57v5.76h-5.57V31c0,1.55.66,2.3,2.16,2.3A6.84,6.84,0,0,0,259,32.5v5.4A9.9,9.9,0,0,1,253.73,39.27Z" class="path-10" fill-rule="evenodd"></path><path d="M277.55,38.87V36.31a9.15,9.15,0,0,1-7.18,3c-4.46,0-8.14-2.57-8.14-7.26V32c0-5.18,3.94-7.57,9.56-7.57a16.74,16.74,0,0,1,5.8,1V25c0-2.79-1.73-4.34-5.09-4.34A17.57,17.57,0,0,0,266,21.92l-1.68-5.13a20.94,20.94,0,0,1,9.2-1.9c7.35,0,10.58,3.8,10.58,10.22V38.87Zm.13-9.55a10.31,10.31,0,0,0-4.3-.89c-2.87,0-4.64,1.15-4.64,3.27v.09c0,1.82,1.5,2.88,3.67,2.88,3.14,0,5.27-1.73,5.27-4.16Z" class="path-11" fill-rule="evenodd"></path><path d="M289.72,38.87V6.57h6.72v32.3Z" class="path-12" fill-rule="evenodd"></path><path d="M302.06,38.87V31.79h7.17v7.08Z" class="path-13" fill-rule="evenodd"></path></svg>
</p>
<hr />
<p class="align-center">
<h3>Voucher Code</h3>
</p>
<p class="mgb-1x">Voucher {{ voucherCode }},</p>
<p class="mgb-2-5x">Click On The link blow to reset your password.</p>
<p class="email-note">
This is an automatically generated email please do not reply to
this email. If you face any issues, please contact us at {{ contact_us_email }}</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block powered-by">
Powered by <a href="http://htmlemail.io">Bigcapital.com</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>