diff --git a/server/config/config.js b/server/config/config.js index fbac73223..3528ecc9e 100644 --- a/server/config/config.js +++ b/server/config/config.js @@ -79,5 +79,6 @@ module.exports = { api: { prefix: '/api' - } + }, + resetPasswordSeconds: 600, }; diff --git a/server/package.json b/server/package.json index 8ca84982e..c4f8d0ea4 100644 --- a/server/package.json +++ b/server/package.json @@ -58,6 +58,7 @@ "nodemailer": "^6.3.0", "nodemon": "^1.19.1", "objection": "^2.0.10", + "objection-soft-delete": "^1.0.7", "reflect-metadata": "^0.1.13", "ts-transformer-keys": "^0.4.2", "tsyringe": "^4.3.0", diff --git a/server/src/exceptions/NoPaymentModelWithPricedPlan.ts b/server/src/exceptions/NoPaymentModelWithPricedPlan.ts new file mode 100644 index 000000000..938ec8b4a --- /dev/null +++ b/server/src/exceptions/NoPaymentModelWithPricedPlan.ts @@ -0,0 +1,8 @@ + + +export default class NoPaymentModelWithPricedPlan { + + constructor() { + + } +} \ No newline at end of file diff --git a/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts b/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts index 4a016dc85..e6c6bac1e 100644 --- a/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts +++ b/server/src/exceptions/NotAllowedChangeSubscriptionPlan.ts @@ -1,9 +1,8 @@ -export default class NotAllowedChangeSubscriptionPlan extends Error{ +export default class NotAllowedChangeSubscriptionPlan { constructor(message: string) { - super(message); this.name = "NotAllowedChangeSubscriptionPlan"; } } \ No newline at end of file diff --git a/server/src/exceptions/PaymentAmountInvalidWithPlan.ts b/server/src/exceptions/PaymentAmountInvalidWithPlan.ts new file mode 100644 index 000000000..834e8cbe1 --- /dev/null +++ b/server/src/exceptions/PaymentAmountInvalidWithPlan.ts @@ -0,0 +1,7 @@ + + +export default class PaymentAmountInvalidWithPlan{ + constructor() { + + } +} \ No newline at end of file diff --git a/server/src/exceptions/PaymentInputInvalid.ts b/server/src/exceptions/PaymentInputInvalid.ts new file mode 100644 index 000000000..065bfb3b4 --- /dev/null +++ b/server/src/exceptions/PaymentInputInvalid.ts @@ -0,0 +1,5 @@ + + +export default class PaymentInputInvalid { + constructor() {} +} \ No newline at end of file diff --git a/server/src/exceptions/index.ts b/server/src/exceptions/index.ts index 8343e2fc6..2fe026caa 100644 --- a/server/src/exceptions/index.ts +++ b/server/src/exceptions/index.ts @@ -1,9 +1,15 @@ import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan'; import ServiceError from './ServiceError'; import ServiceErrors from './ServiceErrors'; +import NoPaymentModelWithPricedPlan from './NoPaymentModelWithPricedPlan'; +import PaymentInputInvalid from './PaymentInputInvalid'; +import PaymentAmountInvalidWithPlan from './PaymentAmountInvalidWithPlan'; export { NotAllowedChangeSubscriptionPlan, + NoPaymentModelWithPricedPlan, + PaymentAmountInvalidWithPlan, ServiceError, ServiceErrors, + PaymentInputInvalid }; \ No newline at end of file diff --git a/server/src/http/controllers/Authentication.ts b/server/src/http/controllers/Authentication.ts index 45fc69bd7..1cbbe3cf2 100644 --- a/server/src/http/controllers/Authentication.ts +++ b/server/src/http/controllers/Authentication.ts @@ -204,7 +204,7 @@ export default class AuthenticationController extends BaseController{ }) } catch(error) { if (error instanceof ServiceError) { - if (error.errorType === 'token_invalid') { + if (error.errorType === 'token_invalid' || error.errorType === 'token_expired') { return res.boom.badRequest(null, { errors: [{ type: 'TOKEN_INVALID', code: 100 }], }); diff --git a/server/src/http/controllers/Subscription/PaymentViaLicense.ts b/server/src/http/controllers/Subscription/PaymentViaLicense.ts index 767caaa56..0c99b5505 100644 --- a/server/src/http/controllers/Subscription/PaymentViaLicense.ts +++ b/server/src/http/controllers/Subscription/PaymentViaLicense.ts @@ -1,13 +1,16 @@ import { Inject, Service } from 'typedi'; import { Router, Request, Response } from 'express'; -import { check, param, query, ValidationSchema } from 'express-validator'; -import { License, Plan } from '@/system/models'; +import { check } from 'express-validator'; import validateMiddleware from '@/http/middleware/validateMiddleware'; import asyncMiddleware from '@/http/middleware/asyncMiddleware'; import PaymentMethodController from '@/http/controllers/Subscription/PaymentMethod'; import { - NotAllowedChangeSubscriptionPlan + NotAllowedChangeSubscriptionPlan, + NoPaymentModelWithPricedPlan, + PaymentAmountInvalidWithPlan, + PaymentInputInvalid, } from '@/exceptions'; +import { ILicensePaymentModel } from '@/interfaces'; @Service() export default class PaymentViaLicenseController extends PaymentMethodController { @@ -24,9 +27,7 @@ export default class PaymentViaLicenseController extends PaymentMethodController '/payment', this.paymentViaLicenseSchema, validateMiddleware, - asyncMiddleware(this.validateLicenseCodeExistance.bind(this)), asyncMiddleware(this.validatePlanSlugExistance.bind(this)), - asyncMiddleware(this.validateLicenseAndPlan.bind(this)), asyncMiddleware(this.paymentViaLicense.bind(this)), ); return router; @@ -38,54 +39,10 @@ export default class PaymentViaLicenseController extends PaymentMethodController get paymentViaLicenseSchema() { return [ check('plan_slug').exists().trim().escape(), - check('license_code').exists().trim().escape(), + check('license_code').optional().trim().escape(), ]; } - /** - * Validate the given license code exists on the storage. - * @async - * @param {Request} req - * @param {Response} res - */ - async validateLicenseCodeExistance(req: Request, res: Response, next: Function) { - const { licenseCode } = this.matchedBodyData(req); - this.logger.info('[license_payment] trying to validate license code.', { licenseCode }); - - const foundLicense = await License.query() - .modify('filterActiveLicense') - .where('license_code', licenseCode) - .first(); - - if (!foundLicense) { - return res.status(400).send({ - errors: [{ type: 'LICENSE.CODE.IS.INVALID', code: 120 }], - }); - } - next(); - } - - /** - * Validate the license period and plan period. - * @param {Request} req - * @param {Response} res - * @param {Function} next - */ - async validateLicenseAndPlan(req: Request, res: Response, next: Function) { - const { planSlug, licenseCode } = this.matchedBodyData(req); - this.logger.info('[license_payment] trying to validate license with the plan.', { licenseCode }); - - const license = await License.query().findOne('license_code', licenseCode); - const plan = await Plan.query().findOne('slug', planSlug); - - if (license.planId !== plan.id) { - return res.status(400).send({ - errors: [{ type: 'LICENSE.NOT.FOR.GIVEN.PLAN' }], - }); - } - next(); - } - /** * Handle the subscription payment via license code. * @param {Request} req @@ -97,8 +54,11 @@ export default class PaymentViaLicenseController extends PaymentMethodController const { tenant } = req; try { + const licenseModel: ILicensePaymentModel|null = licenseCode + ? { licenseCode } : null; + await this.subscriptionService - .subscriptionViaLicense(tenant.id, planSlug, licenseCode); + .subscriptionViaLicense(tenant.id, planSlug, licenseModel); return res.status(200).send({ type: 'success', @@ -108,7 +68,13 @@ export default class PaymentViaLicenseController extends PaymentMethodController } catch (exception) { const errorReasons = []; - if (exception.name === 'NotAllowedChangeSubscriptionPlan') { + if (exception instanceof NoPaymentModelWithPricedPlan) { + errorReasons.push({ + type: 'NO_PAYMENT_WITH_PRICED_PLAN', + code: 140, + }); + } + if (exception instanceof NotAllowedChangeSubscriptionPlan) { errorReasons.push({ type: 'NOT.ALLOWED.RENEW.SUBSCRIPTION.WHILE.ACTIVE', code: 120, @@ -117,6 +83,16 @@ export default class PaymentViaLicenseController extends PaymentMethodController if (errorReasons.length > 0) { return res.status(400).send({ errors: errorReasons }); } + if (exception instanceof PaymentInputInvalid) { + return res.status(400).send({ + errors: [{ type: 'LICENSE.CODE.IS.INVALID', code: 120 }], + }); + } + if (exception instanceof PaymentAmountInvalidWithPlan) { + return res.status(400).send({ + errors: [{ type: 'LICENSE.NOT.FOR.GIVEN.PLAN' }], + }); + } next(exception); } } diff --git a/server/src/models/AccountTransaction.js b/server/src/models/AccountTransaction.js index ed599bf8b..8150a7b2a 100644 --- a/server/src/models/AccountTransaction.js +++ b/server/src/models/AccountTransaction.js @@ -32,6 +32,11 @@ export default class AccountTransaction extends mixin(TenantModel, [CachableMode */ static get modifiers() { return { + /** + * Filters accounts by the given ids. + * @param {Query} query + * @param {number[]} accountsIds + */ filterAccounts(query, accountsIds) { if (accountsIds.length > 0) { query.whereIn('account_id', accountsIds); diff --git a/server/src/services/Authentication/index.ts b/server/src/services/Authentication/index.ts index ae94d3f11..66198c4a8 100644 --- a/server/src/services/Authentication/index.ts +++ b/server/src/services/Authentication/index.ts @@ -203,7 +203,7 @@ export default class AuthenticationService { // Delete all stored tokens of reset password that associate to the give email. this.logger.info('[send_reset_password] trying to delete all tokens by email.'); - await PasswordReset.query().where('email', email).delete(); + this.deletePasswordResetToken(email); const token = uniqid(); @@ -230,28 +230,44 @@ export default class AuthenticationService { this.logger.info('[reset_password] token invalid.'); throw new ServiceError('token_invalid'); } + // Different between tokne creation datetime and current time. + if (moment().diff(tokenModel.createdAt, 'seconds') > config.resetPasswordSeconds) { + this.logger.info('[reset_password] token expired.'); + + // Deletes the expired token by expired token email. + await this.deletePasswordResetToken(tokenModel.email); + throw new ServiceError('token_expired'); + } const user = await SystemUser.query().findOne('email', tokenModel.email) if (!user) { throw new ServiceError('user_not_found'); } const hashedPassword = await hashPassword(password); - + this.logger.info('[reset_password] saving a new hashed password.'); await SystemUser.query() .where('email', tokenModel.email) - .update({ - password: hashedPassword, - }); - // Delete the reset password token. - await PasswordReset.query().where('email', user.email).delete(); + .update({ password: hashedPassword }); + + // Deletes the used token. + await this.deletePasswordResetToken(tokenModel.email); // Triggers `onResetPassword` event. this.eventDispatcher.dispatch(events.auth.resetPassword, { user, token, password }); - this.logger.info('[reset_password] reset password success.'); } + /** + * Deletes the password reset token by the given email. + * @param {string} email + * @returns {Promise} + */ + private async deletePasswordResetToken(email: string) { + this.logger.info('[reset_password] trying to delete all tokens by email.'); + return PasswordReset.query().where('email', email).delete(); + } + /** * Generates JWT token for the given user. * @param {ISystemUser} user diff --git a/server/src/services/InviteUsers/index.ts b/server/src/services/InviteUsers/index.ts index ce922cc99..6e1f61939 100644 --- a/server/src/services/InviteUsers/index.ts +++ b/server/src/services/InviteUsers/index.ts @@ -5,7 +5,7 @@ import { EventDispatcher, EventDispatcherInterface, } from '@/decorators/eventDispatcher'; -import { ServiceError, ServiceErrors } from "@/exceptions"; +import { ServiceError } from "@/exceptions"; import { SystemUser, Invite, Tenant } from "@/system/models"; import { Option } from '@/models'; import { hashPassword } from '@/utils'; @@ -49,8 +49,6 @@ export default class InviteUserService { this.logger.info('[aceept_invite] trying to hash the user password.'); const hashedPassword = await hashPassword(inviteUserInput.password); - console.log(inviteToken); - this.logger.info('[accept_invite] trying to update user details.'); const updateUserOper = SystemUser.query() .where('email', inviteToken.email) @@ -60,7 +58,7 @@ export default class InviteUserService { invite_accepted_at: moment().format('YYYY-MM-DD'), password: hashedPassword, }); - + this.logger.info('[accept_invite] trying to delete the given token.'); const deleteInviteTokenOper = Invite.query().where('token', inviteToken.token).delete(); diff --git a/server/src/services/Payment/License.ts b/server/src/services/Payment/License.ts index 253e19223..c2530b8dd 100644 --- a/server/src/services/Payment/License.ts +++ b/server/src/services/Payment/License.ts @@ -42,7 +42,6 @@ export default class LicenseService { }); } - /** * * @param {number} loop @@ -52,7 +51,7 @@ export default class LicenseService { */ async generateLicenses( loop = 1, - licensePeriod: numner, + licensePeriod: number, periodInterval: string = 'days', planId: number, ) { @@ -67,7 +66,7 @@ export default class LicenseService { /** * Disables the given license id on the storage. - * @param {number} licenseId + * @param {number} licenseId * @return {Promise} */ async disableLicense(licenseId: number) { diff --git a/server/src/services/Payment/LicensePaymentMethod.ts b/server/src/services/Payment/LicensePaymentMethod.ts index 170540ea0..101156a53 100644 --- a/server/src/services/Payment/LicensePaymentMethod.ts +++ b/server/src/services/Payment/LicensePaymentMethod.ts @@ -1,14 +1,47 @@ 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'; -export default class VocuherPaymentMethod extends PaymentMethod implements IPaymentMethod { +export default class LicensePaymentMethod extends PaymentMethod implements IPaymentMethod { /** * Payment subscription of organization via license code. - * @param {ILicensePaymentModel} + * @param {ILicensePaymentModel} licensePaymentModel - */ - async payment(licensePaymentModel: ILicensePaymentModel) { + async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) { + const license = await this.getLicenseOrThrowInvalid(licensePaymentModel); + this.validatePaymentAmountWithPlan(license, plan); + // Mark the license code as used. return License.markLicenseAsUsed(licensePaymentModel.licenseCode); } + + /** + * Validates the license code activation on the storage. + * @param {ILicensePaymentModel} licensePaymentModel - + */ + async getLicenseOrThrowInvalid(licensePaymentModel: ILicensePaymentModel) { + const foundLicense = await License.query() + .modify('filterActiveLicense') + .where('license_code', licensePaymentModel.licenseCode) + .first(); + + if (!foundLicense) { + throw new PaymentInputInvalid(); + } + return foundLicense; + } + + /** + * Validates the payment amount with given plan price. + * @param {License} license + * @param {Plan} plan + */ + validatePaymentAmountWithPlan(license: License, plan: Plan) { + if (license.planId !== plan.id) { + throw new PaymentAmountInvalidWithPlan(); + } + } } \ No newline at end of file diff --git a/server/src/services/Payment/index.ts b/server/src/services/Payment/index.ts index 6e72f3992..e963748ed 100644 --- a/server/src/services/Payment/index.ts +++ b/server/src/services/Payment/index.ts @@ -1,4 +1,5 @@ import { IPaymentMethod, IPaymentContext } from "@/interfaces"; +import { Plan } from '@/system/models'; export default class PaymentContext implements IPaymentContext{ paymentMethod: IPaymentMethod; @@ -15,7 +16,7 @@ export default class PaymentContext implements IPaymentContext{ * * @param {} paymentModel */ - makePayment(paymentModel: PaymentModel) { - this.paymentMethod.makePayment(paymentModel); + makePayment(paymentModel: PaymentModel, plan: Plan) { + return this.paymentMethod.payment(paymentModel, plan); } } \ No newline at end of file diff --git a/server/src/services/Subscription/Subscription.ts b/server/src/services/Subscription/Subscription.ts index 8949667d6..2454d76fd 100644 --- a/server/src/services/Subscription/Subscription.ts +++ b/server/src/services/Subscription/Subscription.ts @@ -1,7 +1,8 @@ -import { Inject } from 'typedi'; +import { Inject, Service } from 'typedi'; import { Tenant, Plan } from '@/system/models'; import { IPaymentContext } from '@/interfaces'; import { NotAllowedChangeSubscriptionPlan } from '@/exceptions'; +import { NoPaymentModelWithPricedPlan } from '@/exceptions'; export default class Subscription { paymentContext: IPaymentContext|null; @@ -19,7 +20,7 @@ export default class Subscription { /** * Subscripe to the given plan. - * @param {Plan} plan + * @param {Plan} plan * @throws {NotAllowedChangeSubscriptionPlan} */ async subscribe( @@ -28,8 +29,11 @@ export default class Subscription { paymentModel?: PaymentModel, subscriptionSlug: string = 'main', ) { - if (plan.price < 0) { - await this.paymentContext.makePayment(paymentModel); + this.validateIfPlanHasPriceNoPayment(plan, paymentModel); + + // @todo + if (plan.price > 0) { + await this.paymentContext.makePayment(paymentModel, plan); } const subscription = await tenant.$relatedQuery('subscriptions') .modify('subscriptionBySlug', subscriptionSlug) @@ -39,8 +43,7 @@ export default class Subscription { if (subscription && subscription.active()) { throw new NotAllowedChangeSubscriptionPlan; - // In case there is already subscription associated to the given tenant. - // renew it. + // In case there is already subscription associated to the given tenant renew it. } else if(subscription && subscription.inactive()) { await subscription.renew(plan); @@ -49,4 +52,15 @@ export default class Subscription { await tenant.newSubscription(subscriptionSlug, plan); } } + + /** + * Throw error in plan has price and no payment model. + * @param {Plan} plan - + * @param {PaymentModel} paymentModel - payment input. + */ + validateIfPlanHasPriceNoPayment(plan: Plan, paymentModel: PaymentMode) { + if (plan.price > 0 && !paymentModel) { + throw new NoPaymentModelWithPricedPlan(); + } + } } \ No newline at end of file diff --git a/server/src/services/Subscription/SubscriptionService.ts b/server/src/services/Subscription/SubscriptionService.ts index 7b6b0a282..a300f2506 100644 --- a/server/src/services/Subscription/SubscriptionService.ts +++ b/server/src/services/Subscription/SubscriptionService.ts @@ -1,10 +1,11 @@ import { Service, Inject } from 'typedi'; -import { Plan, Tenant, License } from '@/system/models'; +import { Plan, Tenant } from '@/system/models'; import Subscription from '@/services/Subscription/Subscription'; -import VocuherPaymentMethod from '@/services/Payment/LicensePaymentMethod'; +import LicensePaymentMethod from '@/services/Payment/LicensePaymentMethod'; import PaymentContext from '@/services/Payment'; import SubscriptionSMSMessages from '@/services/Subscription/SMSMessages'; import SubscriptionMailMessages from '@/services/Subscription/MailMessages'; +import { ILicensePaymentModel } from '@/interfaces'; @Service() export default class SubscriptionService { @@ -14,31 +15,37 @@ export default class SubscriptionService { @Inject() mailMessages: SubscriptionMailMessages; + @Inject('logger') + logger: any; + /** * Handles the payment process via license code and than subscribe to * the given tenant. - * * @param {number} tenantId * @param {String} planSlug * @param {string} licenseCode - * * @return {Promise} */ async subscriptionViaLicense( tenantId: number, planSlug: string, - licenseCode: string, + paymentModel?: ILicensePaymentModel, subscriptionSlug: string = 'main', ) { + this.logger.info('[subscription_via_license] try to subscribe via given license.', { + tenantId, paymentModel + }); const plan = await Plan.query().findOne('slug', planSlug); const tenant = await Tenant.query().findById(tenantId); - const licenseModel = await License.query().findOne('license_code', licenseCode); - const paymentViaLicense = new VocuherPaymentMethod(); + const paymentViaLicense = new LicensePaymentMethod(); const paymentContext = new PaymentContext(paymentViaLicense); const subscription = new Subscription(paymentContext); - return subscription.subscribe(tenant, plan, licenseModel, subscriptionSlug); + await subscription.subscribe(tenant, plan, paymentModel, subscriptionSlug); + this.logger.info('[subscription_via_license] payment via license done successfully.', { + tenantId, paymentModel + }); } } \ No newline at end of file diff --git a/server/src/system/migrations/20200422225247_create_user_invites_table.js b/server/src/system/migrations/20200422225247_create_user_invites_table.js index 59caaa32b..21f1e7fa6 100644 --- a/server/src/system/migrations/20200422225247_create_user_invites_table.js +++ b/server/src/system/migrations/20200422225247_create_user_invites_table.js @@ -5,7 +5,7 @@ exports.up = function(knex) { table.string('email'); table.string('token').unique(); table.integer('tenant_id').unsigned(); - table.timestamps(); + table.datetime('created_at'); }); }; diff --git a/server/src/system/models/Invite.js b/server/src/system/models/Invite.js index 243e3e2f2..daaa91ab7 100644 --- a/server/src/system/models/Invite.js +++ b/server/src/system/models/Invite.js @@ -7,4 +7,11 @@ export default class UserInvite extends SystemModel { static get tableName() { return 'user_invites'; } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt']; + } } diff --git a/server/src/system/models/PasswordReset.js b/server/src/system/models/PasswordReset.js index d5a52ffa0..b72b0aeef 100644 --- a/server/src/system/models/PasswordReset.js +++ b/server/src/system/models/PasswordReset.js @@ -7,4 +7,11 @@ export default class PasswordResets extends SystemModel { static get tableName() { return 'password_resets'; } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt']; + } } diff --git a/server/src/system/models/Subscriptions/Plan.js b/server/src/system/models/Subscriptions/Plan.js index 9d707b0b8..5842c1408 100644 --- a/server/src/system/models/Subscriptions/Plan.js +++ b/server/src/system/models/Subscriptions/Plan.js @@ -13,7 +13,7 @@ export default class Plan extends mixin(SystemModel) { /** * Timestamps columns. */ - static get timestamps() { + get timestamps() { return ['createdAt', 'updatedAt']; } diff --git a/server/src/system/models/Subscriptions/PlanSubscription.js b/server/src/system/models/Subscriptions/PlanSubscription.js index 18946a585..638826e74 100644 --- a/server/src/system/models/Subscriptions/PlanSubscription.js +++ b/server/src/system/models/Subscriptions/PlanSubscription.js @@ -14,7 +14,7 @@ export default class PlanSubscription extends mixin(SystemModel) { /** * Timestamps columns. */ - static get timestamps() { + get timestamps() { return ['createdAt', 'updatedAt']; } diff --git a/server/src/system/models/SystemUser.js b/server/src/system/models/SystemUser.js index 1857aed98..2fec496b4 100644 --- a/server/src/system/models/SystemUser.js +++ b/server/src/system/models/SystemUser.js @@ -14,7 +14,7 @@ export default class SystemUser extends mixin(SystemModel) { /** * Timestamps columns. */ - static get timestamps() { + get timestamps() { return ['createdAt', 'updatedAt']; } diff --git a/server/src/system/models/Tenant.js b/server/src/system/models/Tenant.js index 3a248e23b..0d17eac63 100644 --- a/server/src/system/models/Tenant.js +++ b/server/src/system/models/Tenant.js @@ -10,6 +10,13 @@ export default class Tenant extends BaseModel { return 'tenants'; } + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Query modifiers. */