fix(biling): Subscription billing system.

fix(GeneralLedger): running balance.
This commit is contained in:
a.bouhuolia
2021-03-13 15:36:03 +02:00
parent 30cd6c8a61
commit dbb4e4de47
12 changed files with 310 additions and 216 deletions

View File

@@ -1,13 +1,14 @@
import { Service, Inject } from 'typedi';
import { Router, Request, Response } from 'express';
import { Router, Request, Response, NextFunction } from 'express';
import { check, oneOf, ValidationChain } from 'express-validator';
import basicAuth from 'express-basic-auth';
import config from 'config';
import { License, Plan } from 'system/models';
import { License } from 'system/models';
import { ServiceError } from 'exceptions';
import BaseController from 'api/controllers/BaseController';
import LicenseService from 'services/Payment/License';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import { ILicensesFilter } from 'interfaces';
import { ILicensesFilter, ISendLicenseDTO } from 'interfaces';
@Service()
export default class LicensesController extends BaseController {
@@ -32,26 +33,26 @@ export default class LicensesController extends BaseController {
'/generate',
this.generateLicenseSchema,
this.validationResult,
asyncMiddleware(this.validatePlanExistance.bind(this)),
asyncMiddleware(this.generateLicense.bind(this))
asyncMiddleware(this.generateLicense.bind(this)),
this.catchServiceErrors,
);
router.post(
'/disable/:licenseId',
this.validationResult,
asyncMiddleware(this.validateLicenseExistance.bind(this)),
asyncMiddleware(this.validateNotDisabledLicense.bind(this)),
asyncMiddleware(this.disableLicense.bind(this))
asyncMiddleware(this.disableLicense.bind(this)),
this.catchServiceErrors,
);
router.post(
'/send',
this.sendLicenseSchemaValidation,
this.validationResult,
asyncMiddleware(this.sendLicense.bind(this))
asyncMiddleware(this.sendLicense.bind(this)),
this.catchServiceErrors,
);
router.delete(
'/:licenseId',
asyncMiddleware(this.validateLicenseExistance.bind(this)),
asyncMiddleware(this.deleteLicense.bind(this))
asyncMiddleware(this.deleteLicense.bind(this)),
this.catchServiceErrors,
);
router.get('/', asyncMiddleware(this.listLicenses.bind(this)));
return router;
@@ -67,7 +68,7 @@ export default class LicensesController extends BaseController {
check('period_interval')
.exists()
.isIn(['month', 'months', 'year', 'years', 'day', 'days']),
check('plan_id').exists().isNumeric().toInt(),
check('plan_slug').exists().trim().escape(),
];
}
@@ -90,7 +91,7 @@ export default class LicensesController extends BaseController {
return [
check('period').exists().isNumeric(),
check('period_interval').exists().trim().escape(),
check('plan_id').exists().isNumeric().toInt(),
check('plan_slug').exists().trim().escape(),
oneOf([
check('phone_number').exists().trim().escape(),
check('email').exists().trim().escape(),
@@ -98,67 +99,6 @@ export default class LicensesController extends BaseController {
];
}
/**
* 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 body = this.matchedBodyData(req);
const planId: number = 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 license existance on the storage.
* @param {Request} req
* @param {Response} res
* @param {Function}
*/
async validateLicenseExistance(req: Request, res: Response, next: Function) {
const body = this.matchedBodyData(req);
const licenseId = body.licenseId || req.params.licenseId;
const foundLicense = await License.query().findById(licenseId);
if (!foundLicense) {
return res.status(400).send({
errors: [{ type: 'LICENSE.NOT.FOUND', code: 200 }],
});
}
next();
}
/**
* Validates whether the license id is disabled.
* @param {Request} req
* @param {Response} res
* @param {Function} next
*/
async validateNotDisabledLicense(
req: Request,
res: Response,
next: Function
) {
const licenseId = req.params.licenseId || req.query.licenseId;
const foundLicense = await License.query().findById(licenseId);
if (foundLicense.disabled) {
return res.status(400).send({
errors: [{ type: 'LICENSE.ALREADY.DISABLED', code: 200 }],
});
}
next();
}
/**
* Generate licenses codes with given period in bulk.
* @param {Request} req
@@ -166,7 +106,7 @@ export default class LicensesController extends BaseController {
* @return {Response}
*/
async generateLicense(req: Request, res: Response, next: Function) {
const { loop = 10, period, periodInterval, planId } = this.matchedBodyData(
const { loop = 10, period, periodInterval, planSlug } = this.matchedBodyData(
req
);
@@ -175,7 +115,7 @@ export default class LicensesController extends BaseController {
loop,
period,
periodInterval,
planId
planSlug
);
return res.status(200).send({
code: 100,
@@ -193,12 +133,16 @@ export default class LicensesController extends BaseController {
* @param {Response} res
* @return {Response}
*/
async disableLicense(req: Request, res: Response) {
async disableLicense(req: Request, res: Response, next: Function) {
const { licenseId } = req.params;
await this.licenseService.disableLicense(licenseId);
try {
await this.licenseService.disableLicense(licenseId);
return res.status(200).send({ license_id: licenseId });
return res.status(200).send({ license_id: licenseId });
} catch (error) {
next(error);
}
}
/**
@@ -207,12 +151,16 @@ export default class LicensesController extends BaseController {
* @param {Response} res
* @return {Response}
*/
async deleteLicense(req: Request, res: Response) {
async deleteLicense(req: Request, res: Response, next: Function) {
const { licenseId } = req.params;
await this.licenseService.deleteLicense(licenseId);
try {
await this.licenseService.deleteLicense(licenseId);
return res.status(200).send({ license_id: licenseId });
return res.status(200).send({ license_id: licenseId });
} catch (error) {
next(error)
}
}
/**
@@ -221,40 +169,20 @@ export default class LicensesController extends BaseController {
* @param {Response} res
* @return {Response}
*/
async sendLicense(req: Request, res: Response) {
const {
phoneNumber,
email,
period,
periodInterval,
planId,
} = this.matchedBodyData(req);
async sendLicense(req: Request, res: Response, next: Function) {
const sendLicenseDTO: ISendLicenseDTO = this.matchedBodyData(req);
const license = await License.query()
.modify('filterActiveLicense')
.where('license_period', period)
.where('period_interval', periodInterval)
.where('plan_id', planId)
.first();
try {
await this.licenseService.sendLicenseToCustomer(sendLicenseDTO);
if (!license) {
return res.status(400).send({
status: 110,
message:
'There is no licenses availiable right now with the given period and plan.',
code: 'NO.AVALIABLE.LICENSE.CODE',
return res.status(200).send({
status: 100,
code: 'LICENSE.CODE.SENT',
message: 'The license has been sent to the given customer.',
});
} catch (error) {
next(error);
}
await this.licenseService.sendLicenseToCustomer(
license.licenseCode,
phoneNumber,
email
);
return res.status(200).send({
status: 100,
code: 'LICENSE.CODE.SENT',
message: 'The license has been sent to the given customer.',
});
}
/**
@@ -276,4 +204,47 @@ export default class LicensesController extends BaseController {
});
return res.status(200).send({ licenses });
}
/**
* Catches all service errors.
*/
catchServiceErrors(error, req: Request, res: Response, next: NextFunction) {
if (error instanceof ServiceError) {
if (error.errorType === 'PLAN_NOT_FOUND') {
return res.status(400).send({
errors: [{
type: 'PLAN.NOT.FOUND',
code: 100,
message: 'The given plan not found.',
}],
});
}
if (error.errorType === 'LICENSE_NOT_FOUND') {
return res.status(400).send({
errors: [{
type: 'LICENSE_NOT_FOUND',
code: 200,
message: 'The given license id not found.'
}],
});
}
if (error.errorType === 'LICENSE_ALREADY_DISABLED') {
return res.status(400).send({
errors: [{
type: 'LICENSE.ALREADY.DISABLED',
code: 200,
message: 'License is already disabled.'
}],
});
}
if (error.errorType === 'NO_AVALIABLE_LICENSE_CODE') {
return res.status(400).send({
status: 110,
message: 'There is no licenses availiable right now with the given period and plan.',
code: 'NO.AVALIABLE.LICENSE.CODE',
});
}
}
next(error);
}
}

View File

@@ -1,4 +1,5 @@
import { Inject } from 'typedi';
import { Request, Response } from 'express';
import { Plan } from 'system/models';
import BaseController from 'api/controllers/BaseController';
import SubscriptionService from 'services/Subscription/SubscriptionService';

View File

@@ -1,4 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express'
import { Router, Request, Response, NextFunction } from 'express';
import { Container, Service, Inject } from 'typedi';
import JWTAuth from 'api/middleware/jwtAuth';
import TenancyMiddleware from 'api/middleware/TenancyMiddleware';
@@ -9,43 +9,41 @@ import asyncMiddleware from 'api/middleware/asyncMiddleware';
@Service()
export default class SubscriptionController {
@Inject()
subscriptionService: SubscriptionService;
@Inject()
subscriptionService: SubscriptionService;
/**
* Router constructor.
*/
router() {
const router = Router();
/**
* Router constructor.
*/
router() {
const router = Router();
router.use(JWTAuth);
router.use(AttachCurrentTenantUser);
router.use(TenancyMiddleware);
router.use(JWTAuth);
router.use(AttachCurrentTenantUser);
router.use(TenancyMiddleware);
router.use(
'/license',
Container.get(PaymentViaLicenseController).router()
);
router.get('/',
asyncMiddleware(this.getSubscriptions.bind(this))
);
return router;
}
router.use('/license', Container.get(PaymentViaLicenseController).router());
router.get('/', asyncMiddleware(this.getSubscriptions.bind(this)));
/**
* Retrieve all subscriptions of the authenticated user's tenant.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
async getSubscriptions(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
return router;
}
try {
const subscriptions = await this.subscriptionService.getSubscriptions(tenantId);
return res.status(200).send({ subscriptions });
} catch (error) {
next(error);
}
}
/**
* Retrieve all subscriptions of the authenticated user's tenant.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
async getSubscriptions(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
try {
const subscriptions = await this.subscriptionService.getSubscriptions(
tenantId
);
return res.status(200).send({ subscriptions });
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,6 @@
export default class VoucherCodeRequired {
constructor() {
this.name = 'VoucherCodeRequired';
}
}

View File

@@ -8,6 +8,7 @@ import TenantAlreadyInitialized from './TenantAlreadyInitialized';
import TenantAlreadySeeded from './TenantAlreadySeeded';
import TenantDBAlreadyExists from './TenantDBAlreadyExists';
import TenantDatabaseNotBuilt from './TenantDatabaseNotBuilt';
import VoucherCodeRequired from './VoucherCodeRequired';
export {
NotAllowedChangeSubscriptionPlan,
@@ -20,4 +21,5 @@ export {
TenantAlreadySeeded,
TenantDBAlreadyExists,
TenantDatabaseNotBuilt,
VoucherCodeRequired,
};

View File

@@ -14,4 +14,12 @@ export interface ILicensesFilter {
disabld: boolean,
used: boolean,
sent: boolean,
};
export interface ISendLicenseDTO {
phoneNumber: string,
email: string,
period: string,
periodInterval: string,
planSlug: string,
};

View File

@@ -1,4 +1,4 @@
import { pick, get, last } from 'lodash';
import { isEmpty, get, last } from 'lodash';
import {
IGeneralLedgerSheetQuery,
IGeneralLedgerSheetAccount,
@@ -73,10 +73,9 @@ export default class GeneralLedgerSheet extends FinancialSheet {
entryReducer(
entries: IGeneralLedgerSheetAccountTransaction[],
entry: IJournalEntry,
index: number
openingBalance: number
): IGeneralLedgerSheetAccountTransaction[] {
const lastEntry = last(entries);
const openingBalance = 0;
const contact = this.contactsMap.get(entry.contactId);
const amount = this.getAmount(
@@ -85,11 +84,7 @@ export default class GeneralLedgerSheet extends FinancialSheet {
entry.accountNormal
);
const runningBalance =
(entries.length === 0
? openingBalance
: lastEntry
? lastEntry.runningBalance
: 0) + amount;
amount + (!isEmpty(entries) ? lastEntry.runningBalance : openingBalance);
const newEntry = {
date: entry.date,
@@ -182,9 +177,7 @@ export default class GeneralLedgerSheet extends FinancialSheet {
* @param {IAccount} account
* @return {IGeneralLedgerSheetAccount}
*/
private accountMapper(
account: IAccount
): IGeneralLedgerSheetAccount {
private accountMapper(account: IAccount): IGeneralLedgerSheetAccount {
const openingBalance = this.accountOpeningBalance(account);
const closingBalance = this.accountClosingBalance(account);
@@ -208,14 +201,10 @@ export default class GeneralLedgerSheet extends FinancialSheet {
* @param {IAccount[]} accounts -
* @return {IGeneralLedgerSheetAccount[]}
*/
private accountsWalker(
accounts: IAccount[]
): IGeneralLedgerSheetAccount[] {
private accountsWalker(accounts: IAccount[]): IGeneralLedgerSheetAccount[] {
return (
accounts
.map((account: IAccount) =>
this.accountMapper(account)
)
.map((account: IAccount) => this.accountMapper(account))
// Filter general ledger accounts that have no transactions
// when`noneTransactions` is on.
.filter(

View File

@@ -1,10 +1,18 @@
import { Service, Container, Inject } from 'typedi';
import cryptoRandomString from 'crypto-random-string';
import { times } from 'lodash';
import { License } from "system/models";
import { ILicense } from 'interfaces';
import { License, Plan } from 'system/models';
import { ILicense, ISendLicenseDTO } from 'interfaces';
import LicenseMailMessages from 'services/Payment/LicenseMailMessages';
import LicenseSMSMessages from 'services/Payment/LicenseSMSMessages';
import { ServiceError } from 'exceptions';
const ERRORS = {
PLAN_NOT_FOUND: 'PLAN_NOT_FOUND',
LICENSE_NOT_FOUND: 'LICENSE_NOT_FOUND',
LICENSE_ALREADY_DISABLED: 'LICENSE_ALREADY_DISABLED',
NO_AVALIABLE_LICENSE_CODE: 'NO_AVALIABLE_LICENSE_CODE',
};
@Service()
export default class LicenseService {
@@ -14,49 +22,99 @@ export default class LicenseService {
@Inject()
mailMessages: LicenseMailMessages;
/**
* Validate the plan existance on the storage.
* @param {number} tenantId -
* @param {string} planSlug - Plan slug.
*/
private async getPlanOrThrowError(planSlug: string) {
const foundPlan = await Plan.query().where('slug', planSlug).first();
if (!foundPlan) {
throw new ServiceError(ERRORS.PLAN_NOT_FOUND);
}
return foundPlan;
}
/**
* Valdiate the license existance on the storage.
* @param {number} licenseId - License id.
*/
private async getLicenseOrThrowError(licenseId: number) {
const foundLicense = await License.query().findById(licenseId);
if (!foundLicense) {
throw new ServiceError(ERRORS.LICENSE_NOT_FOUND);
}
return foundLicense;
}
/**
* Validates whether the license id is disabled.
* @param {ILicense} license
*/
private validateNotDisabledLicense(license: ILicense) {
if (license.disabledAt) {
throw new ServiceError(ERRORS.LICENSE_ALREADY_DISABLED);
}
}
/**
* Generates the license code in the given period.
* @param {number} licensePeriod
* @param {number} licensePeriod
* @return {Promise<ILicense>}
*/
async generateLicense(
public async generateLicense(
licensePeriod: number,
periodInterval: string = 'days',
planId: number,
planSlug: string
): ILicense {
let licenseCode: string;
let repeat: boolean = true;
while(repeat) {
licenseCode = cryptoRandomString({ length: 10, type: 'numeric' });
const foundLicenses = await License.query().where('license_code', licenseCode);
// Retrieve plan or throw not found error.
const plan = await this.getPlanOrThrowError(planSlug);
if (foundLicenses.length === 0) {
repeat = false;
while (repeat) {
licenseCode = cryptoRandomString({ length: 10, type: 'numeric' });
const foundLicenses = await License.query().where(
'license_code',
licenseCode
);
if (foundLicenses.length === 0) {
repeat = false;
}
}
return License.query().insert({
licenseCode, licensePeriod, periodInterval, planId,
licenseCode,
licensePeriod,
periodInterval,
planId: plan.id,
});
}
/**
*
* @param {number} loop
* @param {number} licensePeriod
* @param {string} periodInterval
* @param {number} planId
* Generates licenses.
* @param {number} loop
* @param {number} licensePeriod
* @param {string} periodInterval
* @param {number} planId
*/
async generateLicenses(
public async generateLicenses(
loop = 1,
licensePeriod: number,
periodInterval: string = 'days',
planId: number,
planSlug: string
) {
const asyncOpers: Promise<any>[] = [];
times(loop, () => {
const generateOper = this.generateLicense(licensePeriod, periodInterval, planId);
const generateOper = this.generateLicense(
licensePeriod,
periodInterval,
planSlug
);
asyncOpers.push(generateOper);
});
return Promise.all(asyncOpers);
@@ -64,38 +122,64 @@ export default class LicenseService {
/**
* Disables the given license id on the storage.
* @param {number} licenseId
* @param {string} licenseSlug - License slug.
* @return {Promise}
*/
async disableLicense(licenseId: number) {
return License.markLicenseAsDisabled(licenseId, 'id');
public async disableLicense(licenseId: number) {
const license = await this.getLicenseOrThrowError(licenseId);
this.validateNotDisabledLicense(license);
return License.markLicenseAsDisabled(license.id, 'id');
}
/**
* Deletes the given license id from the storage.
* @param licenseId
* @param licenseSlug {string} - License slug.
*/
async deleteLicense(licenseId: number) {
return License.query().where('id', licenseId).delete();
public async deleteLicense(licenseSlug: string) {
const license = await this.getPlanOrThrowError(licenseSlug);
return License.query().where('id', license.id).delete();
}
/**
* Sends license code to the given customer via SMS or mail message.
* @param {string} licenseCode - License code
* @param {string} phoneNumber - Phone number
* @param {string} licenseCode - License code.
* @param {string} phoneNumber - Phone number.
* @param {string} email - Email address.
*/
async sendLicenseToCustomer(licenseCode: string, phoneNumber: string, email: string) {
public async sendLicenseToCustomer(sendLicense: ISendLicenseDTO) {
const agenda = Container.get('agenda');
const { phoneNumber, email, period, periodInterval } = sendLicense;
// Retreive plan details byt the given plan slug.
const plan = await this.getPlanOrThrowError(sendLicense.planSlug);
const license = await License.query()
.modify('filterActiveLicense')
.where('license_period', period)
.where('period_interval', periodInterval)
.where('plan_id', plan.id)
.first();
if (!license) {
throw new ServiceError(ERRORS.NO_AVALIABLE_LICENSE_CODE)
}
// Mark the license as used.
await License.markLicenseAsSent(licenseCode);
await License.markLicenseAsSent(license.licenseCode);
if (email) {
await agenda.schedule('1 second', 'send-license-via-email', { licenseCode, email });
if (sendLicense.email) {
await agenda.schedule('1 second', 'send-license-via-email', {
licenseCode: license.licenseCode,
email,
});
}
if (phoneNumber) {
await agenda.schedule('1 second', 'send-license-via-phone', { licenseCode, phoneNumber });
await agenda.schedule('1 second', 'send-license-via-phone', {
licenseCode: license.licenseCode,
phoneNumber,
});
}
}
}
}

View File

@@ -1,16 +1,24 @@
import { License } from "system/models";
import { License } from 'system/models';
import PaymentMethod from 'services/Payment/PaymentMethod';
import { Plan } from 'system/models';
import { IPaymentMethod, ILicensePaymentModel } from 'interfaces';
import { ILicensePaymentModel } from "interfaces";
import { PaymentInputInvalid, PaymentAmountInvalidWithPlan } from 'exceptions';
import { ILicensePaymentModel } from 'interfaces';
import {
PaymentInputInvalid,
PaymentAmountInvalidWithPlan,
VoucherCodeRequired,
} from 'exceptions';
export default class LicensePaymentMethod extends PaymentMethod implements IPaymentMethod {
export default class LicensePaymentMethod
extends PaymentMethod
implements IPaymentMethod {
/**
* Payment subscription of organization via license code.
* @param {ILicensePaymentModel} licensePaymentModel -
*/
async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) {
this.validateLicensePaymentModel(licensePaymentModel);
const license = await this.getLicenseOrThrowInvalid(licensePaymentModel);
this.validatePaymentAmountWithPlan(license, plan);
@@ -36,12 +44,22 @@ export default class LicensePaymentMethod extends PaymentMethod implements IPaym
/**
* Validates the payment amount with given plan price.
* @param {License} license
* @param {Plan} plan
* @param {License} license
* @param {Plan} plan
*/
validatePaymentAmountWithPlan(license: License, plan: Plan) {
if (license.planId !== plan.id) {
throw new PaymentAmountInvalidWithPlan();
}
}
}
/**
* Validate voucher payload.
* @param {ILicensePaymentModel} licenseModel -
*/
validateLicensePaymentModel(licenseModel: ILicensePaymentModel) {
if (!licenseModel || !licenseModel.licenseCode) {
throw new VoucherCodeRequired();
}
}
}

View File

@@ -31,10 +31,8 @@ export default class Subscription<PaymentModel> {
) {
this.validateIfPlanHasPriceNoPayment(plan, paymentModel);
// @todo
if (plan.price > 0) {
await this.paymentContext.makePayment(paymentModel, plan);
}
await this.paymentContext.makePayment(paymentModel, plan);
const subscription = await tenant.$relatedQuery('subscriptions')
.modify('subscriptionBySlug', subscriptionSlug)
.first();

View File

@@ -6,7 +6,6 @@ exports.up = function(knex) {
table.string('name');
table.string('description');
table.decimal('price');
table.decimal('signup_fee');
table.string('currency', 3);
table.integer('trial_period');
@@ -14,7 +13,6 @@ exports.up = function(knex) {
table.integer('invoice_period');
table.string('invoice_interval');
table.timestamps();
});
};

View File

@@ -6,21 +6,42 @@ exports.seed = (knex) => {
// Inserts seed entries
return knex('subscription_plans').insert([
{
id: 1,
name: 'free',
name: 'Free',
slug: 'free',
price: 0,
active: true,
currency: 'LYD',
trial_period: 15,
trial_period: 7,
trial_interval: 'days',
invoice_period: 3,
index: 1,
voucher_required: true,
},
{
name: 'Starter',
slug: 'starter',
price: 500,
active: true,
currency: 'LYD',
invoice_period: 12,
invoice_interval: 'month',
index: 1,
}
index: 2,
},
{
name: 'Growth',
slug: 'growth',
price: 1000,
active: true,
currency: 'LYD',
invoice_period: 12,
invoice_interval: 'month',
index: 3,
},
]);
});
};