Compare commits

..

1 Commits

Author SHA1 Message Date
a.bouhuolia
a854b42ce5 asdfasdF 2023-02-13 21:29:07 +02:00
100 changed files with 2624 additions and 6202 deletions

3
.gitignore vendored
View File

@@ -1,2 +1 @@
node_modules/ node_modules/
data

View File

@@ -1,34 +0,0 @@
version: '3.3'
services:
mysql:
build:
context: ./docker/mysql
args:
- MYSQL_DATABASE=bigcapital_system
- MYSQL_USER=default_user
- MYSQL_PASSWORD=secret
- MYSQL_ROOT_PASSWORD=root
volumes:
- ./data/mysql/:/var/lib/mysql
expose:
- '3306'
ports:
- '3306:3306'
mongo:
build: ./docker/mongo
expose:
- '27017'
volumes:
- ./data/mongo/:/var/lib/mongodb
ports:
- '27017:27017'
redis:
build:
context: ./docker/redis
expose:
- "6379"
volumes:
- ./data/redis:/data

View File

@@ -1 +0,0 @@
FROM mongo:5.0

View File

@@ -1,18 +0,0 @@
FROM mysql:5.7
ADD my.cnf /etc/mysql/conf.d/my.cnf
RUN chown -R mysql:root /var/lib/mysql/
ARG MYSQL_DATABASE=default_database
ARG MYSQL_USER=default_user
ARG MYSQL_PASSWORD=secret
ARG MYSQL_ROOT_PASSWORD=root
ENV MYSQL_DATABASE=$MYSQL_DATABASE
ENV MYSQL_USER=$MYSQL_USER
ENV MYSQL_PASSWORD=$MYSQL_PASSWORD
ENV MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
CMD ["mysqld"]
EXPOSE 3306

View File

@@ -1,2 +0,0 @@
[mysqld]
bind-address = 0.0.0.0

View File

@@ -1,5 +0,0 @@
FROM redis:4.0
COPY redis.conf /usr/local/etc/redis/redis.conf
CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ]

View File

@@ -1,48 +0,0 @@
daemonize no
pidfile /var/run/redis.pid
port 6379
tcp-backlog 511
timeout 0
tcp-keepalive 0
loglevel notice
logfile ""
databases 16
save 900 1
save 300 10
save 60 10000
stop-writes-on-bgsave-error yes
rdbcompression yes
rdbchecksum yes
dbfilename dump.rdb
slave-serve-stale-data yes
slave-read-only yes
repl-diskless-sync no
repl-diskless-sync-delay 5
repl-disable-tcp-nodelay no
slave-priority 100
appendonly no
appendfilename "appendonly.aof"
appendfsync everysec
no-appendfsync-on-rewrite no
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
aof-load-truncated yes
lua-time-limit 5000
slowlog-log-slower-than 10000
slowlog-max-len 128
latency-monitor-threshold 0
notify-keyspace-events ""
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
list-max-ziplist-entries 512
list-max-ziplist-value 64
set-max-intset-entries 512
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
hll-sparse-max-bytes 3000
activerehashing yes
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit slave 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
hz 10
aof-rewrite-incremental-fsync yes

5697
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ import DynamicListingService from '@/services/DynamicListing/DynamicListService'
import { DATATYPES_LENGTH } from '@/data/DataTypes'; import { DATATYPES_LENGTH } from '@/data/DataTypes';
import CheckPolicies from '@/api/middleware/CheckPolicies'; import CheckPolicies from '@/api/middleware/CheckPolicies';
import { AccountsApplication } from '@/services/Accounts/AccountsApplication'; import { AccountsApplication } from '@/services/Accounts/AccountsApplication';
import { MAX_ACCOUNTS_CHART_DEPTH } from 'services/Accounts/constants';
@Service() @Service()
export default class AccountsController extends BaseController { export default class AccountsController extends BaseController {
@@ -495,22 +494,6 @@ export default class AccountsController extends BaseController {
} }
); );
} }
if (error.errorType === 'PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL') {
return res.boom.badRequest(
'The parent account exceeded the depth level of accounts chart.',
{
errors: [
{
type: 'PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL',
code: 1500,
data: {
maxDepth: MAX_ACCOUNTS_CHART_DEPTH,
},
},
],
}
);
}
} }
next(error); next(error);
} }

View File

@@ -6,6 +6,7 @@ import { check, ValidationChain } from 'express-validator';
import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import JWTAuth from '@/api/middleware/jwtAuth'; import JWTAuth from '@/api/middleware/jwtAuth';
import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; import TenancyMiddleware from '@/api/middleware/TenancyMiddleware';
import SubscriptionMiddleware from '@/api/middleware/SubscriptionMiddleware';
import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser';
import OrganizationService from '@/services/Organization/OrganizationService'; import OrganizationService from '@/services/Organization/OrganizationService';
import { import {
@@ -23,7 +24,7 @@ const ACCEPTED_LOCATIONS = ['libya'];
@Service() @Service()
export default class OrganizationController extends BaseController { export default class OrganizationController extends BaseController {
@Inject() @Inject()
private organizationService: OrganizationService; organizationService: OrganizationService;
/** /**
* Router constructor. * Router constructor.
@@ -31,10 +32,13 @@ export default class OrganizationController extends BaseController {
router() { router() {
const router = Router(); const router = Router();
// Should before build tenant database the user be authorized and
// most important than that, should be subscribed to any plan.
router.use(JWTAuth); router.use(JWTAuth);
router.use(AttachCurrentTenantUser); router.use(AttachCurrentTenantUser);
router.use(TenancyMiddleware); router.use(TenancyMiddleware);
router.use('/build', SubscriptionMiddleware('main'));
router.post( router.post(
'/build', '/build',
this.organizationValidationSchema, this.organizationValidationSchema,

View File

@@ -0,0 +1,102 @@
import { Router, Request, Response, NextFunction } from 'express';
import { check, ValidationChain } from 'express-validator';
import BaseController from './BaseController';
import SetupService from '@/services/Setup/SetupService';
import { Inject, Service } from 'typedi';
import { IOrganizationSetupDTO } from '@/interfaces';
import { ServiceError } from '@/exceptions';
// Middlewares
import JWTAuth from '@/api/middleware/jwtAuth';
import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser';
import SubscriptionMiddleware from '@/api/middleware/SubscriptionMiddleware';
import TenancyMiddleware from '@/api/middleware/TenancyMiddleware';
import EnsureTenantIsInitialized from '@/api/middleware/EnsureTenantIsInitialized';
import SettingsMiddleware from '@/api/middleware/SettingsMiddleware';
@Service()
export default class SetupController extends BaseController {
@Inject()
setupService: SetupService;
router() {
const router = Router('/setup');
router.use(JWTAuth);
router.use(AttachCurrentTenantUser);
router.use(TenancyMiddleware);
router.use(SubscriptionMiddleware('main'));
router.use(EnsureTenantIsInitialized);
router.use(SettingsMiddleware);
router.post(
'/organization',
this.organizationSetupSchema,
this.validationResult,
this.asyncMiddleware(this.organizationSetup.bind(this)),
this.handleServiceErrors
);
return router;
}
/**
* Organization setup schema.
*/
private get organizationSetupSchema(): ValidationChain[] {
return [
check('organization_name').exists().trim(),
check('base_currency').exists(),
check('time_zone').exists(),
check('fiscal_year').exists(),
check('industry').optional(),
];
}
/**
* Organization setup.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
* @returns
*/
async organizationSetup(req: Request, res: Response, next: NextFunction) {
const { tenantId } = req;
const setupDTO: IOrganizationSetupDTO = this.matchedBodyData(req);
try {
await this.setupService.organizationSetup(tenantId, setupDTO);
return res.status(200).send({
message: 'The setup settings set successfully.',
});
} catch (error) {
next(error);
}
}
/**
* Handles service errors.
* @param {Error} error
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
handleServiceErrors(
error: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (error instanceof ServiceError) {
if (error.errorType === 'TENANT_IS_ALREADY_SETUPED') {
return res.status(400).send({
errors: [{ type: 'TENANT_IS_ALREADY_SETUPED', code: 1000 }],
});
}
if (error.errorType === 'BASE_CURRENCY_INVALID') {
return res.status(400).send({
errors: [{ type: 'BASE_CURRENCY_INVALID', code: 110 }],
});
}
}
next(error);
}
}

View File

@@ -0,0 +1,250 @@
import { Service, Inject } from 'typedi';
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 } 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, ISendLicenseDTO } from '@/interfaces';
@Service()
export default class LicensesController extends BaseController {
@Inject()
licenseService: LicenseService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.use(
basicAuth({
users: {
[config.licensesAuth.user]: config.licensesAuth.password,
},
challenge: true,
})
);
router.post(
'/generate',
this.generateLicenseSchema,
this.validationResult,
asyncMiddleware(this.generateLicense.bind(this)),
this.catchServiceErrors,
);
router.post(
'/disable/:licenseId',
this.validationResult,
asyncMiddleware(this.disableLicense.bind(this)),
this.catchServiceErrors,
);
router.post(
'/send',
this.sendLicenseSchemaValidation,
this.validationResult,
asyncMiddleware(this.sendLicense.bind(this)),
this.catchServiceErrors,
);
router.delete(
'/:licenseId',
asyncMiddleware(this.deleteLicense.bind(this)),
this.catchServiceErrors,
);
router.get('/', asyncMiddleware(this.listLicenses.bind(this)));
return router;
}
/**
* Generate license validation schema.
*/
get generateLicenseSchema(): 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_slug').exists().trim().escape(),
];
}
/**
* Specific license validation schema.
*/
get specificLicenseSchema(): ValidationChain[] {
return [
oneOf(
[check('license_id').exists().isNumeric().toInt()],
[check('license_code').exists().isNumeric().toInt()]
),
];
}
/**
* Send license validation schema.
*/
get sendLicenseSchemaValidation(): ValidationChain[] {
return [
check('period').exists().isNumeric(),
check('period_interval').exists().trim().escape(),
check('plan_slug').exists().trim().escape(),
oneOf([
check('phone_number').exists().trim().escape(),
check('email').exists().trim().escape(),
]),
];
}
/**
* Generate licenses codes with given period in bulk.
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async generateLicense(req: Request, res: Response, next: Function) {
const { loop = 10, period, periodInterval, planSlug } = this.matchedBodyData(
req
);
try {
await this.licenseService.generateLicenses(
loop,
period,
periodInterval,
planSlug
);
return res.status(200).send({
code: 100,
type: 'LICENSEES.GENERATED.SUCCESSFULLY',
message: 'The licenses have been generated successfully.',
});
} catch (error) {
next(error);
}
}
/**
* Disable the given license on the storage.
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async disableLicense(req: Request, res: Response, next: Function) {
const { licenseId } = req.params;
try {
await this.licenseService.disableLicense(licenseId);
return res.status(200).send({ license_id: licenseId });
} catch (error) {
next(error);
}
}
/**
* Deletes the given license code on the storage.
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async deleteLicense(req: Request, res: Response, next: Function) {
const { licenseId } = req.params;
try {
await this.licenseService.deleteLicense(licenseId);
return res.status(200).send({ license_id: licenseId });
} catch (error) {
next(error)
}
}
/**
* Send license code in the given period to the customer via email or phone number
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async sendLicense(req: Request, res: Response, next: Function) {
const sendLicenseDTO: ISendLicenseDTO = this.matchedBodyData(req);
try {
await this.licenseService.sendLicenseToCustomer(sendLicenseDTO);
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);
}
}
/**
* Listing licenses.
* @param {Request} req
* @param {Response} res
*/
async listLicenses(req: Request, res: Response) {
const filter: ILicensesFilter = {
disabled: false,
used: false,
sent: false,
active: false,
...req.query,
};
const licenses = await License.query().onBuild((builder) => {
builder.modify('filter', filter);
builder.orderBy('createdAt', 'ASC');
});
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

@@ -0,0 +1,31 @@
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';
export default class PaymentMethodController extends BaseController {
@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 } = this.matchedBodyData(req);
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,125 @@
import { Inject, Service } from 'typedi';
import { NextFunction, Router, Request, Response } from 'express';
import { check } from 'express-validator';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
import PaymentMethodController from '@/api/controllers/Subscription/PaymentMethod';
import {
NotAllowedChangeSubscriptionPlan,
NoPaymentModelWithPricedPlan,
PaymentAmountInvalidWithPlan,
PaymentInputInvalid,
VoucherCodeRequired,
} from '@/exceptions';
import { ILicensePaymentModel } from '@/interfaces';
import instance from 'tsyringe/dist/typings/dependency-container';
@Service()
export default class PaymentViaLicenseController extends PaymentMethodController {
@Inject('logger')
logger: any;
/**
* Router constructor.
*/
router() {
const router = Router();
router.post(
'/payment',
this.paymentViaLicenseSchema,
this.validationResult,
asyncMiddleware(this.validatePlanSlugExistance.bind(this)),
asyncMiddleware(this.paymentViaLicense.bind(this)),
this.handleErrors,
);
return router;
}
/**
* Payment via license validation schema.
*/
get paymentViaLicenseSchema() {
return [
check('plan_slug').exists().trim().escape(),
check('license_code').exists().trim().escape(),
];
}
/**
* Handle the subscription payment via license code.
* @param {Request} req
* @param {Response} res
* @return {Response}
*/
async paymentViaLicense(req: Request, res: Response, next: Function) {
const { planSlug, licenseCode } = this.matchedBodyData(req);
const { tenant } = req;
try {
const licenseModel: ILicensePaymentModel = { licenseCode };
await this.subscriptionService.subscriptionViaLicense(
tenant.id,
planSlug,
licenseModel
);
return res.status(200).send({
type: 'success',
code: 'PAYMENT.SUCCESSFULLY.MADE',
message: 'Payment via license has been made successfully.',
});
} catch (exception) {
next(exception);
}
}
/**
* Handle service errors.
* @param {Error} error
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private handleErrors(
exception: Error,
req: Request,
res: Response,
next: NextFunction
) {
const errorReasons = [];
if (exception instanceof VoucherCodeRequired) {
errorReasons.push({
type: 'VOUCHER_CODE_REQUIRED',
code: 100,
});
}
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,
});
}
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);
}
}

View File

@@ -0,0 +1,49 @@
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';
import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser';
import PaymentViaLicenseController from '@/api/controllers/Subscription/PaymentViaLicense';
import SubscriptionService from '@/services/Subscription/SubscriptionService';
import asyncMiddleware from '@/api/middleware/asyncMiddleware';
@Service()
export default class SubscriptionController {
@Inject()
subscriptionService: SubscriptionService;
/**
* Router constructor.
*/
router() {
const router = Router();
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;
}
/**
* 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

@@ -4,6 +4,7 @@ import { Container } from 'typedi';
// Middlewares // Middlewares
import JWTAuth from '@/api/middleware/jwtAuth'; import JWTAuth from '@/api/middleware/jwtAuth';
import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser'; import AttachCurrentTenantUser from '@/api/middleware/AttachCurrentTenantUser';
import SubscriptionMiddleware from '@/api/middleware/SubscriptionMiddleware';
import TenancyMiddleware from '@/api/middleware/TenancyMiddleware'; import TenancyMiddleware from '@/api/middleware/TenancyMiddleware';
import EnsureTenantIsInitialized from '@/api/middleware/EnsureTenantIsInitialized'; import EnsureTenantIsInitialized from '@/api/middleware/EnsureTenantIsInitialized';
import SettingsMiddleware from '@/api/middleware/SettingsMiddleware'; import SettingsMiddleware from '@/api/middleware/SettingsMiddleware';
@@ -36,6 +37,8 @@ import Resources from './controllers/Resources';
import ExchangeRates from '@/api/controllers/ExchangeRates'; import ExchangeRates from '@/api/controllers/ExchangeRates';
import Media from '@/api/controllers/Media'; import Media from '@/api/controllers/Media';
import Ping from '@/api/controllers/Ping'; import Ping from '@/api/controllers/Ping';
import Subscription from '@/api/controllers/Subscription';
import Licenses from '@/api/controllers/Subscription/Licenses';
import InventoryAdjustments from '@/api/controllers/Inventory/InventoryAdjustments'; import InventoryAdjustments from '@/api/controllers/Inventory/InventoryAdjustments';
import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware'; import asyncRenderMiddleware from './middleware/AsyncRenderMiddleware';
import Jobs from './controllers/Jobs'; import Jobs from './controllers/Jobs';
@@ -66,6 +69,8 @@ export default () => {
app.use('/auth', Container.get(Authentication).router()); app.use('/auth', Container.get(Authentication).router());
app.use('/invite', Container.get(InviteUsers).nonAuthRouter()); app.use('/invite', Container.get(InviteUsers).nonAuthRouter());
app.use('/licenses', Container.get(Licenses).router());
app.use('/subscription', Container.get(Subscription).router());
app.use('/organization', Container.get(Organization).router()); app.use('/organization', Container.get(Organization).router());
app.use('/ping', Container.get(Ping).router()); app.use('/ping', Container.get(Ping).router());
app.use('/jobs', Container.get(Jobs).router()); app.use('/jobs', Container.get(Jobs).router());
@@ -78,6 +83,7 @@ export default () => {
dashboard.use(JWTAuth); dashboard.use(JWTAuth);
dashboard.use(AttachCurrentTenantUser); dashboard.use(AttachCurrentTenantUser);
dashboard.use(TenancyMiddleware); dashboard.use(TenancyMiddleware);
dashboard.use(SubscriptionMiddleware('main'));
dashboard.use(EnsureTenantIsInitialized); dashboard.use(EnsureTenantIsInitialized);
dashboard.use(SettingsMiddleware); dashboard.use(SettingsMiddleware);
dashboard.use(I18nAuthenticatedMiddlware); dashboard.use(I18nAuthenticatedMiddlware);

View File

@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from 'express';
import { Container } from 'typedi';
export default (subscriptionSlug = 'main') => async (
req: Request,
res: Response,
next: NextFunction
) => {
const { tenant, tenantId } = req;
const Logger = Container.get('logger');
const { subscriptionRepository } = Container.get('repositories');
if (!tenant) {
throw new Error('Should load `TenancyMiddlware` before this middleware.');
}
Logger.info('[subscription_middleware] trying get tenant main subscription.');
const subscription = await subscriptionRepository.getBySlugInTenant(
subscriptionSlug,
tenantId
);
// Validate in case there is no any already subscription.
if (!subscription) {
Logger.info('[subscription_middleware] tenant has no subscription.', {
tenantId,
});
return res.boom.badRequest('Tenant has no subscription.', {
errors: [{ type: 'TENANT.HAS.NO.SUBSCRIPTION' }],
});
}
// Validate in case the subscription is inactive.
else if (subscription.inactive()) {
Logger.info(
'[subscription_middleware] tenant main subscription is expired.',
{ tenantId }
);
return res.boom.badRequest(null, {
errors: [{ type: 'ORGANIZATION.SUBSCRIPTION.INACTIVE' }],
});
}
next();
};

View File

@@ -0,0 +1,8 @@
export default class NoPaymentModelWithPricedPlan {
constructor() {
}
}

View File

@@ -0,0 +1,8 @@
export default class NotAllowedChangeSubscriptionPlan {
constructor() {
this.name = "NotAllowedChangeSubscriptionPlan";
}
}

View File

@@ -0,0 +1,7 @@
export default class PaymentAmountInvalidWithPlan{
constructor() {
}
}

View File

@@ -0,0 +1,3 @@
export default class PaymentInputInvalid {
constructor() {}
}

View File

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

View File

@@ -1,15 +1,25 @@
import NotAllowedChangeSubscriptionPlan from './NotAllowedChangeSubscriptionPlan';
import ServiceError from './ServiceError'; import ServiceError from './ServiceError';
import ServiceErrors from './ServiceErrors'; import ServiceErrors from './ServiceErrors';
import NoPaymentModelWithPricedPlan from './NoPaymentModelWithPricedPlan';
import PaymentInputInvalid from './PaymentInputInvalid';
import PaymentAmountInvalidWithPlan from './PaymentAmountInvalidWithPlan';
import TenantAlreadyInitialized from './TenantAlreadyInitialized'; import TenantAlreadyInitialized from './TenantAlreadyInitialized';
import TenantAlreadySeeded from './TenantAlreadySeeded'; import TenantAlreadySeeded from './TenantAlreadySeeded';
import TenantDBAlreadyExists from './TenantDBAlreadyExists'; import TenantDBAlreadyExists from './TenantDBAlreadyExists';
import TenantDatabaseNotBuilt from './TenantDatabaseNotBuilt'; import TenantDatabaseNotBuilt from './TenantDatabaseNotBuilt';
import VoucherCodeRequired from './VoucherCodeRequired';
export { export {
NotAllowedChangeSubscriptionPlan,
NoPaymentModelWithPricedPlan,
PaymentAmountInvalidWithPlan,
ServiceError, ServiceError,
ServiceErrors, ServiceErrors,
PaymentInputInvalid,
TenantAlreadyInitialized, TenantAlreadyInitialized,
TenantAlreadySeeded, TenantAlreadySeeded,
TenantDBAlreadyExists, TenantDBAlreadyExists,
TenantDatabaseNotBuilt, TenantDatabaseNotBuilt,
}; VoucherCodeRequired,
};

View File

@@ -0,0 +1,34 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class MailNotificationSubscribeEnd {
/**
* Job handler.
* @param {Job} job -
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.info(
`Send mail notification subscription end soon - started: ${job.attrs.data}`
);
try {
subscriptionService.mailMessages.sendRemainingTrialPeriod(
phoneNumber,
remainingDays
);
Logger.info(
`Send mail notification subscription end soon - finished: ${job.attrs.data}`
);
} catch (error) {
Logger.info(
`Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`
);
done(e);
}
}
}

View File

@@ -0,0 +1,34 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class MailNotificationTrialEnd {
/**
*
* @param {Job} job -
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.info(
`Send mail notification subscription end soon - started: ${job.attrs.data}`
);
try {
subscriptionService.mailMessages.sendRemainingTrialPeriod(
phoneNumber,
remainingDays
);
Logger.info(
`Send mail notification subscription end soon - finished: ${job.attrs.data}`
);
} catch (error) {
Logger.info(
`Send mail notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`
);
done(e);
}
}
}

View File

@@ -0,0 +1,28 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class SMSNotificationSubscribeEnd {
/**
*
* @param {Job}job
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.info(`Send SMS notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.smsMessages.sendRemainingSubscriptionPeriod(
phoneNumber, remainingDays,
);
Logger.info(`Send SMS notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.info(`Send SMS notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,28 @@
import Container from 'typedi';
import SubscriptionService from '@/services/Subscription/Subscription';
export default class SMSNotificationTrialEnd {
/**
*
* @param {Job}job
*/
handler(job) {
const { tenantId, phoneNumber, remainingDays } = job.attrs.data;
const subscriptionService = Container.get(SubscriptionService);
const Logger = Container.get('logger');
Logger.info(`Send notification subscription end soon - started: ${job.attrs.data}`);
try {
subscriptionService.smsMessages.sendRemainingTrialPeriod(
phoneNumber, remainingDays,
);
Logger.info(`Send notification subscription end soon - finished: ${job.attrs.data}`);
} catch(error) {
Logger.info(`Send notification subscription end soon - failed: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,33 @@
import { Container } from 'typedi';
import LicenseService from '@/services/Payment/License';
export default class SendLicenseViaEmailJob {
/**
* Constructor method.
* @param agenda
*/
constructor(agenda) {
agenda.define(
'send-license-via-email',
{ priority: 'high', concurrency: 1, },
this.handler,
);
}
public async handler(job, done: Function): Promise<void> {
const Logger = Container.get('logger');
const licenseService = Container.get(LicenseService);
const { email, licenseCode } = job.attrs.data;
Logger.info(`[send_license_via_mail] started: ${job.attrs.data}`);
try {
await licenseService.mailMessages.sendMailLicense(licenseCode, email);
Logger.info(`[send_license_via_mail] completed: ${job.attrs.data}`);
done();
} catch(e) {
Logger.error(`[send_license_via_mail] ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -0,0 +1,33 @@
import { Container } from 'typedi';
import LicenseService from '@/services/Payment/License';
export default class SendLicenseViaPhoneJob {
/**
* Constructor method.
*/
constructor(agenda) {
agenda.define(
'send-license-via-phone',
{ priority: 'high', concurrency: 1, },
this.handler,
);
}
public async handler(job, done: Function): Promise<void> {
const { phoneNumber, licenseCode } = job.attrs.data;
const Logger = Container.get('logger');
const licenseService = Container.get(LicenseService);
Logger.debug(`Send license via phone number - started: ${job.attrs.data}`);
try {
await licenseService.smsMessages.sendLicenseSMSMessage(phoneNumber, licenseCode);
Logger.debug(`Send license via phone number - completed: ${job.attrs.data}`);
done();
} catch(e) {
Logger.error(`Send license via phone number: ${job.attrs.data}, error: ${e}`);
done(e);
}
}
}

View File

@@ -4,6 +4,12 @@ import WelcomeSMSJob from 'jobs/WelcomeSMS';
import ResetPasswordMailJob from 'jobs/ResetPasswordMail'; import ResetPasswordMailJob from 'jobs/ResetPasswordMail';
import ComputeItemCost from 'jobs/ComputeItemCost'; import ComputeItemCost from 'jobs/ComputeItemCost';
import RewriteInvoicesJournalEntries from 'jobs/writeInvoicesJEntries'; import RewriteInvoicesJournalEntries from 'jobs/writeInvoicesJEntries';
import SendLicenseViaPhoneJob from 'jobs/SendLicensePhone';
import SendLicenseViaEmailJob from 'jobs/SendLicenseEmail';
import SendSMSNotificationSubscribeEnd from 'jobs/SMSNotificationSubscribeEnd';
import SendSMSNotificationTrialEnd from 'jobs/SMSNotificationTrialEnd';
import SendMailNotificationSubscribeEnd from 'jobs/MailNotificationSubscribeEnd';
import SendMailNotificationTrialEnd from 'jobs/MailNotificationTrialEnd';
import UserInviteMailJob from 'jobs/UserInviteMail'; import UserInviteMailJob from 'jobs/UserInviteMail';
import OrganizationSetupJob from 'jobs/OrganizationSetup'; import OrganizationSetupJob from 'jobs/OrganizationSetup';
import OrganizationUpgrade from 'jobs/OrganizationUpgrade'; import OrganizationUpgrade from 'jobs/OrganizationUpgrade';
@@ -14,11 +20,33 @@ export default ({ agenda }: { agenda: Agenda }) => {
new ResetPasswordMailJob(agenda); new ResetPasswordMailJob(agenda);
new WelcomeSMSJob(agenda); new WelcomeSMSJob(agenda);
new UserInviteMailJob(agenda); new UserInviteMailJob(agenda);
new SendLicenseViaEmailJob(agenda);
new SendLicenseViaPhoneJob(agenda);
new ComputeItemCost(agenda); new ComputeItemCost(agenda);
new RewriteInvoicesJournalEntries(agenda); new RewriteInvoicesJournalEntries(agenda);
new OrganizationSetupJob(agenda); new OrganizationSetupJob(agenda);
new OrganizationUpgrade(agenda); new OrganizationUpgrade(agenda);
new SmsNotification(agenda); new SmsNotification(agenda);
agenda.define(
'send-sms-notification-subscribe-end',
{ priority: 'nromal', concurrency: 1, },
new SendSMSNotificationSubscribeEnd().handler,
);
agenda.define(
'send-sms-notification-trial-end',
{ priority: 'normal', concurrency: 1, },
new SendSMSNotificationTrialEnd().handler,
);
agenda.define(
'send-mail-notification-subscribe-end',
{ priority: 'high', concurrency: 1, },
new SendMailNotificationSubscribeEnd().handler
);
agenda.define(
'send-mail-notification-trial-end',
{ priority: 'high', concurrency: 1, },
new SendMailNotificationTrialEnd().handler
);
agenda.start(); agenda.start();
}; };

View File

@@ -1,6 +1,7 @@
import Container from 'typedi'; import Container from 'typedi';
import { import {
SystemUserRepository, SystemUserRepository,
SubscriptionRepository,
TenantRepository, TenantRepository,
} from '@/system/repositories'; } from '@/system/repositories';
@@ -10,6 +11,7 @@ export default () => {
return { return {
systemUserRepository: new SystemUserRepository(knex, cache), systemUserRepository: new SystemUserRepository(knex, cache),
subscriptionRepository: new SubscriptionRepository(knex, cache),
tenantRepository: new TenantRepository(knex, cache), tenantRepository: new TenantRepository(knex, cache),
}; };
} }

View File

@@ -5,6 +5,9 @@ import './before';
import express from 'express'; import express from 'express';
import loadersFactory from 'loaders'; import loadersFactory from 'loaders';
console.log("asdfasf");
async function startServer() { async function startServer() {
const app = express(); const app = express();

View File

@@ -3,7 +3,7 @@ import TenancyService from '@/services/Tenancy/TenancyService';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { IAccountDTO, IAccount, IAccountCreateDTO } from '@/interfaces'; import { IAccountDTO, IAccount, IAccountCreateDTO } from '@/interfaces';
import AccountTypesUtils from '@/lib/AccountTypes'; import AccountTypesUtils from '@/lib/AccountTypes';
import { ERRORS, MAX_ACCOUNTS_CHART_DEPTH } from './constants'; import { ERRORS } from './constants';
@Service() @Service()
export class CommandAccountValidators { export class CommandAccountValidators {
@@ -154,13 +154,13 @@ export class CommandAccountValidators {
* parent account. * parent account.
* @param {IAccountCreateDTO} accountDTO * @param {IAccountCreateDTO} accountDTO
* @param {IAccount} parentAccount * @param {IAccount} parentAccount
* @param {string} baseCurrency - * @param {string} baseCurrency -
* @throws {ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT)} * @throws {ServiceError(ERRORS.ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT)}
*/ */
public validateCurrentSameParentAccount = ( public validateCurrentSameParentAccount = (
accountDTO: IAccountCreateDTO, accountDTO: IAccountCreateDTO,
parentAccount: IAccount, parentAccount: IAccount,
baseCurrency: string baseCurrency: string,
) => { ) => {
// If the account DTO currency not assigned and the parent account has no base currency. // If the account DTO currency not assigned and the parent account has no base currency.
if ( if (
@@ -208,24 +208,4 @@ export class CommandAccountValidators {
} }
return account; return account;
} }
/**
* Validates the max depth level of accounts chart.
* @param {numebr} tenantId - Tenant id.
* @param {number} parentAccountId - Parent account id.
*/
public async validateMaxParentAccountDepthLevels(
tenantId: number,
parentAccountId: number
) {
const { accountRepository } = this.tenancy.repositories(tenantId);
const accountsGraph = await accountRepository.getDependencyGraph();
const parentDependantsIds = accountsGraph.dependantsOf(parentAccountId);
if (parentDependantsIds.length >= MAX_ACCOUNTS_CHART_DEPTH) {
throw new ServiceError(ERRORS.PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL);
}
}
} }

View File

@@ -70,11 +70,6 @@ export class CreateAccount {
parentAccount, parentAccount,
baseCurrency baseCurrency
); );
// Validates the max depth level of accounts chart.
await this.validator.validateMaxParentAccountDepthLevels(
tenantId,
accountDTO.parentAccountId
);
} }
// Validates the given account type supports the multi-currency. // Validates the given account type supports the multi-currency.
this.validator.validateAccountTypeSupportCurrency(accountDTO, baseCurrency); this.validator.validateAccountTypeSupportCurrency(accountDTO, baseCurrency);

View File

@@ -5,7 +5,6 @@ import TenancyService from '@/services/Tenancy/TenancyService';
import DynamicListingService from '@/services/DynamicListing/DynamicListService'; import DynamicListingService from '@/services/DynamicListing/DynamicListService';
import { AccountTransformer } from './AccountTransform'; import { AccountTransformer } from './AccountTransform';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { flatToNestedArray } from '@/utils';
@Service() @Service()
export class GetAccounts { export class GetAccounts {
@@ -54,17 +53,11 @@ export class GetAccounts {
builder.modify('inactiveMode', filter.inactiveMode); builder.modify('inactiveMode', filter.inactiveMode);
}); });
// Retrievs the formatted accounts collection. // Retrievs the formatted accounts collection.
const preTransformedAccounts = await this.transformer.transform( const transformedAccounts = await this.transformer.transform(
tenantId, tenantId,
accounts, accounts,
new AccountTransformer() new AccountTransformer()
); );
// Transform accounts to nested array.
const transformedAccounts = flatToNestedArray(preTransformedAccounts, {
id: 'id',
parentId: 'parentAccountId',
});
return { return {
accounts: transformedAccounts, accounts: transformedAccounts,
filterMeta: dynamicList.getResponseMeta(), filterMeta: dynamicList.getResponseMeta(),

View File

@@ -13,12 +13,8 @@ export const ERRORS = {
CLOSE_ACCOUNT_AND_TO_ACCOUNT_NOT_SAME_TYPE: CLOSE_ACCOUNT_AND_TO_ACCOUNT_NOT_SAME_TYPE:
'close_account_and_to_account_not_same_type', 'close_account_and_to_account_not_same_type',
ACCOUNTS_NOT_FOUND: 'accounts_not_found', ACCOUNTS_NOT_FOUND: 'accounts_not_found',
ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY: ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY: 'ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY',
'ACCOUNT_TYPE_NOT_SUPPORTS_MULTI_CURRENCY', ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT: 'ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT',
ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT:
'ACCOUNT_CURRENCY_NOT_SAME_PARENT_ACCOUNT',
PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL:
'PARENT_ACCOUNT_EXCEEDED_THE_DEPTH_LEVEL',
}; };
// Default views columns. // Default views columns.
@@ -31,8 +27,6 @@ export const DEFAULT_VIEW_COLUMNS = [
{ key: 'currencyCode', label: 'Currency' }, { key: 'currencyCode', label: 'Currency' },
]; ];
export const MAX_ACCOUNTS_CHART_DEPTH = 5;
// Accounts default views. // Accounts default views.
export const DEFAULT_VIEWS = [ export const DEFAULT_VIEWS = [
{ {
@@ -49,12 +43,7 @@ export const DEFAULT_VIEWS = [
slug: 'liabilities', slug: 'liabilities',
rolesLogicExpression: '1', rolesLogicExpression: '1',
roles: [ roles: [
{ { fieldKey: 'root_type', index: 1, comparator: 'equals', value: 'liability' },
fieldKey: 'root_type',
index: 1,
comparator: 'equals',
value: 'liability',
},
], ],
columns: DEFAULT_VIEW_COLUMNS, columns: DEFAULT_VIEW_COLUMNS,
}, },
@@ -63,12 +52,7 @@ export const DEFAULT_VIEWS = [
slug: 'equity', slug: 'equity',
rolesLogicExpression: '1', rolesLogicExpression: '1',
roles: [ roles: [
{ { fieldKey: 'root_type', index: 1, comparator: 'equals', value: 'equity' },
fieldKey: 'root_type',
index: 1,
comparator: 'equals',
value: 'equity',
},
], ],
columns: DEFAULT_VIEW_COLUMNS, columns: DEFAULT_VIEW_COLUMNS,
}, },
@@ -77,12 +61,7 @@ export const DEFAULT_VIEWS = [
slug: 'income', slug: 'income',
rolesLogicExpression: '1', rolesLogicExpression: '1',
roles: [ roles: [
{ { fieldKey: 'root_type', index: 1, comparator: 'equals', value: 'income' },
fieldKey: 'root_type',
index: 1,
comparator: 'equals',
value: 'income',
},
], ],
columns: DEFAULT_VIEW_COLUMNS, columns: DEFAULT_VIEW_COLUMNS,
}, },
@@ -91,12 +70,7 @@ export const DEFAULT_VIEWS = [
slug: 'expenses', slug: 'expenses',
rolesLogicExpression: '1', rolesLogicExpression: '1',
roles: [ roles: [
{ { fieldKey: 'root_type', index: 1, comparator: 'equals', value: 'expense' },
fieldKey: 'root_type',
index: 1,
comparator: 'equals',
value: 'expense',
},
], ],
columns: DEFAULT_VIEW_COLUMNS, columns: DEFAULT_VIEW_COLUMNS,
}, },

View File

@@ -150,6 +150,7 @@ export default class OrganizationService {
public async currentOrganization(tenantId: number): Promise<ITenant> { public async currentOrganization(tenantId: number): Promise<ITenant> {
const tenant = await Tenant.query() const tenant = await Tenant.query()
.findById(tenantId) .findById(tenantId)
.withGraphFetched('subscriptions')
.withGraphFetched('metadata'); .withGraphFetched('metadata');
this.throwIfTenantNotExists(tenant); this.throwIfTenantNotExists(tenant);

View File

@@ -0,0 +1,185 @@
import { Service, Container, Inject } from 'typedi';
import cryptoRandomString from 'crypto-random-string';
import { times } from 'lodash';
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 {
@Inject()
smsMessages: LicenseSMSMessages;
@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
* @return {Promise<ILicense>}
*/
public async generateLicense(
licensePeriod: number,
periodInterval: string = 'days',
planSlug: string
): ILicense {
let licenseCode: string;
let repeat: boolean = true;
// Retrieve plan or throw not found error.
const plan = await this.getPlanOrThrowError(planSlug);
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: plan.id,
});
}
/**
* Generates licenses.
* @param {number} loop
* @param {number} licensePeriod
* @param {string} periodInterval
* @param {number} planId
*/
public async generateLicenses(
loop = 1,
licensePeriod: number,
periodInterval: string = 'days',
planSlug: string
) {
const asyncOpers: Promise<any>[] = [];
times(loop, () => {
const generateOper = this.generateLicense(
licensePeriod,
periodInterval,
planSlug
);
asyncOpers.push(generateOper);
});
return Promise.all(asyncOpers);
}
/**
* Disables the given license id on the storage.
* @param {string} licenseSlug - License slug.
* @return {Promise}
*/
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 licenseSlug {string} - License slug.
*/
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} email - Email address.
*/
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(license.licenseCode);
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: license.licenseCode,
phoneNumber,
});
}
}
}

View File

@@ -0,0 +1,26 @@
import { Container } from 'typedi';
import Mail from '@/lib/Mail';
import config from '@/config';
export default class SubscriptionMailMessages {
/**
* Send license code to the given mail address.
* @param {string} licenseCode
* @param {email} email
*/
public async sendMailLicense(licenseCode: string, email: string) {
const Logger = Container.get('logger');
const mail = new Mail()
.setView('mail/LicenseReceive.html')
.setSubject('Bigcapital - License code')
.setTo(email)
.setData({
licenseCode,
successEmail: config.customerSuccess.email,
successPhoneNumber: config.customerSuccess.phoneNumber,
});
await mail.send();
Logger.info('[license_mail] sent successfully.');
}
}

View File

@@ -0,0 +1,67 @@
import { License } from '@/system/models';
import PaymentMethod from '@/services/Payment/PaymentMethod';
import { Plan } from '@/system/models';
import { IPaymentMethod, ILicensePaymentModel } from '@/interfaces';
import {
PaymentInputInvalid,
PaymentAmountInvalidWithPlan,
VoucherCodeRequired,
} from '@/exceptions';
export default class LicensePaymentMethod
extends PaymentMethod
implements IPaymentMethod
{
/**
* Payment subscription of organization via license code.
* @param {ILicensePaymentModel} licensePaymentModel -
*/
public async payment(licensePaymentModel: ILicensePaymentModel, plan: Plan) {
this.validateLicensePaymentModel(licensePaymentModel);
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 -
*/
private 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
*/
private validatePaymentAmountWithPlan(license: License, plan: Plan) {
if (license.planId !== plan.id) {
throw new PaymentAmountInvalidWithPlan();
}
}
/**
* Validate voucher payload.
* @param {ILicensePaymentModel} licenseModel -
*/
private validateLicensePaymentModel(licenseModel: ILicensePaymentModel) {
if (!licenseModel || !licenseModel.licenseCode) {
throw new VoucherCodeRequired();
}
}
}

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 license code to the given phone number via SMS message.
* @param {string} phoneNumber
* @param {string} licenseCode
*/
public async sendLicenseSMSMessage(phoneNumber: string, licenseCode: string) {
const message: string = `Your license card number: ${licenseCode}. If you need any help please contact us. Bigcapital.`;
return this.smsClient.sendMessage(phoneNumber, message);
}
}

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,22 @@
import { IPaymentMethod, IPaymentContext } from "interfaces";
import { Plan } from '@/system/models';
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, plan: Plan) {
return this.paymentMethod.payment(paymentModel, plan);
}
}

View File

@@ -0,0 +1,30 @@
import { Service } from "typedi";
@Service()
export default class SubscriptionMailMessages {
/**
*
* @param phoneNumber
* @param remainingDays
*/
public async sendRemainingSubscriptionPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining subscription is ${remainingDays} days,
please renew your subscription before expire.
`;
this.smsClient.sendMessage(phoneNumber, message);
}
/**
*
* @param phoneNumber
* @param remainingDays
*/
public async sendRemainingTrialPeriod(phoneNumber: string, remainingDays: number) {
const message: string = `
Your remaining free trial is ${remainingDays} days,
please subscription before ends, if you have any quation to contact us.`;
this.smsClient.sendMessage(phoneNumber, message);
}
}

View File

@@ -0,0 +1,40 @@
import { Service, Inject } from 'typedi';
import SMSClient from '@/services/SMSClient';
@Service()
export default class SubscriptionSMSMessages {
@Inject('SMSClient')
smsClient: SMSClient;
/**
* Send remaining subscription period SMS message.
* @param {string} phoneNumber -
* @param {number} remainingDays -
*/
public async sendRemainingSubscriptionPeriod(
phoneNumber: string,
remainingDays: number
): Promise<void> {
const message: string = `
Your remaining subscription is ${remainingDays} days,
please renew your subscription before expire.
`;
this.smsClient.sendMessage(phoneNumber, message);
}
/**
* Send remaining trial period SMS message.
* @param {string} phoneNumber -
* @param {number} remainingDays -
*/
public async sendRemainingTrialPeriod(
phoneNumber: string,
remainingDays: number
): Promise<void> {
const message: string = `
Your remaining free trial is ${remainingDays} days,
please subscription before ends, if you have any quation to contact us.`;
this.smsClient.sendMessage(phoneNumber, message);
}
}

View File

@@ -0,0 +1,80 @@
import { Inject } from 'typedi';
import { Tenant, Plan } from '@/system/models';
import { IPaymentContext } from '@/interfaces';
import { NotAllowedChangeSubscriptionPlan } from '@/exceptions';
export default class Subscription<PaymentModel> {
paymentContext: IPaymentContext | null;
@Inject('logger')
logger: any;
/**
* Constructor method.
* @param {IPaymentContext}
*/
constructor(payment?: IPaymentContext) {
this.paymentContext = payment;
}
/**
* Give the tenant a new subscription.
* @param {Tenant} tenant
* @param {Plan} plan
* @param {string} invoiceInterval
* @param {number} invoicePeriod
* @param {string} subscriptionSlug
*/
protected async newSubscribtion(
tenant,
plan,
invoiceInterval: string,
invoicePeriod: number,
subscriptionSlug: string = 'main'
) {
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(invoiceInterval, invoicePeriod);
// No stored past tenant subscriptions create new one.
} else {
await tenant.newSubscription(
plan.id,
invoiceInterval,
invoicePeriod,
subscriptionSlug
);
}
}
/**
* Subscripe to the given plan.
* @param {Plan} plan
* @throws {NotAllowedChangeSubscriptionPlan}
*/
public async subscribe(
tenant: Tenant,
plan: Plan,
paymentModel?: PaymentModel,
subscriptionSlug: string = 'main'
) {
await this.paymentContext.makePayment(paymentModel, plan);
return this.newSubscribtion(
tenant,
plan,
plan.invoiceInterval,
plan.invoicePeriod,
subscriptionSlug
);
}
}

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,69 @@
import { Service, Inject } from 'typedi';
import { Plan, PlanSubscription, Tenant } from '@/system/models';
import Subscription from '@/services/Subscription/Subscription';
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';
import SubscriptionViaLicense from './SubscriptionViaLicense';
@Service()
export default class SubscriptionService {
@Inject()
smsMessages: SubscriptionSMSMessages;
@Inject()
mailMessages: SubscriptionMailMessages;
@Inject('logger')
logger: any;
@Inject('repositories')
sysRepositories: 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}
*/
public async subscriptionViaLicense(
tenantId: number,
planSlug: string,
paymentModel: ILicensePaymentModel,
subscriptionSlug: string = 'main'
) {
// Retrieve plan details.
const plan = await Plan.query().findOne('slug', planSlug);
// Retrieve tenant details.
const tenant = await Tenant.query().findById(tenantId);
// License payment method.
const paymentViaLicense = new LicensePaymentMethod();
// Payment context.
const paymentContext = new PaymentContext(paymentViaLicense);
// Subscription.
const subscription = new SubscriptionViaLicense(paymentContext);
// Subscribe.
await subscription.subscribe(tenant, plan, paymentModel, subscriptionSlug);
}
/**
* Retrieve all subscription of the given tenant.
* @param {number} tenantId
*/
public async getSubscriptions(tenantId: number) {
const subscriptions = await PlanSubscription.query().where(
'tenant_id',
tenantId
);
return subscriptions;
}
}

View File

@@ -0,0 +1,54 @@
import { License, Tenant, Plan } from '@/system/models';
import Subscription from './Subscription';
import { PaymentModel } from '@/interfaces';
export default class SubscriptionViaLicense extends Subscription<PaymentModel> {
/**
* Subscripe to the given plan.
* @param {Plan} plan
* @throws {NotAllowedChangeSubscriptionPlan}
*/
public async subscribe(
tenant: Tenant,
plan: Plan,
paymentModel?: PaymentModel,
subscriptionSlug: string = 'main'
): Promise<void> {
await this.paymentContext.makePayment(paymentModel, plan);
return this.newSubscriptionFromLicense(
tenant,
plan,
paymentModel.licenseCode,
subscriptionSlug
);
}
/**
* New subscription from the given license.
* @param {Tanant} tenant
* @param {Plab} plan
* @param {string} licenseCode
* @param {string} subscriptionSlug
* @returns {Promise<void>}
*/
private async newSubscriptionFromLicense(
tenant,
plan,
licenseCode: string,
subscriptionSlug: string = 'main'
): Promise<void> {
// License information.
const licenseInfo = await License.query().findOne(
'licenseCode',
licenseCode
);
return this.newSubscribtion(
tenant,
plan,
licenseInfo.periodInterval,
licenseInfo.licensePeriod,
subscriptionSlug
);
}
}

View File

@@ -0,0 +1,22 @@
exports.up = function(knex) {
return knex.schema.createTable('subscriptions_plans', table => {
table.increments();
table.string('name');
table.string('description');
table.decimal('price');
table.string('currency', 3);
table.integer('trial_period');
table.string('trial_interval');
table.integer('invoice_period');
table.string('invoice_interval');
table.timestamps();
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('subscriptions_plans')
};

View File

@@ -0,0 +1,30 @@
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,15 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_plan_features', table => {
table.increments();
table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans');
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,22 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_plan_subscriptions', table => {
table.increments('id');
table.string('slug');
table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans');
table.bigInteger('tenant_id').unsigned().index().references('id').inTable('tenants');
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,22 @@
exports.up = function(knex) {
return knex.schema.createTable('subscription_licenses', (table) => {
table.increments();
table.string('license_code').unique().index();
table.integer('plan_id').unsigned().index().references('id').inTable('subscription_plans');
table.integer('license_period').unsigned();
table.string('period_interval');
table.dateTime('sent_at').index();
table.dateTime('disabled_at').index();
table.dateTime('used_at').index();
table.timestamps();
})
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('subscription_licenses');
};

View File

@@ -0,0 +1,129 @@
import { Model, mixin } from 'objection';
import moment from 'moment';
import SystemModel from '@/system/models/SystemModel';
export default class License extends SystemModel {
/**
* Table name.
*/
static get tableName() {
return 'subscription_licenses';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
// Filters active licenses.
filterActiveLicense(query) {
query.where('disabled_at', null);
query.where('used_at', null);
},
// Find license by its code or id.
findByCodeOrId(query, id, code) {
if (id) {
query.where('id', id);
}
if (code) {
query.where('license_code', code);
}
},
// Filters licenses list.
filter(builder, licensesFilter) {
if (licensesFilter.active) {
builder.modify('filterActiveLicense');
}
if (licensesFilter.disabled) {
builder.whereNot('disabled_at', null);
}
if (licensesFilter.used) {
builder.whereNot('used_at', null);
}
if (licensesFilter.sent) {
builder.whereNot('sent_at', null);
}
},
};
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Plan = require('system/models/Subscriptions/Plan');
return {
plan: {
relation: Model.BelongsToOneRelation,
modelClass: Plan.default,
join: {
from: 'subscription_licenses.planId',
to: 'subscriptions_plans.id',
},
},
};
}
/**
* Deletes the given license code from the storage.
* @param {string} licenseCode
* @return {Promise}
*/
static deleteLicense(licenseCode, viaAttribute = 'license_code') {
return this.query().where(viaAttribute, licenseCode).delete();
}
/**
* Marks the given license code as disabled on the storage.
* @param {string} licenseCode
* @return {Promise}
*/
static markLicenseAsDisabled(licenseCode, viaAttribute = 'license_code') {
return this.query().where(viaAttribute, licenseCode).patch({
disabled_at: moment().toMySqlDateTime(),
});
}
/**
* Marks the given license code as sent on the storage.
* @param {string} licenseCode
*/
static markLicenseAsSent(licenseCode, viaAttribute = 'license_code') {
return this.query().where(viaAttribute, licenseCode).patch({
sent_at: moment().toMySqlDateTime(),
});
}
/**
* Marks the given license code as used on the storage.
* @param {string} licenseCode
* @return {Promise}
*/
static markLicenseAsUsed(licenseCode, viaAttribute = 'license_code') {
return this.query().where(viaAttribute, licenseCode).patch({
used_at: moment().toMySqlDateTime(),
});
}
/**
*
* @param {IIPlan} plan
* @return {boolean}
*/
isEqualPlanPeriod(plan) {
return (
this.invoicePeriod === plan.invoiceInterval &&
license.licensePeriod === license.periodInterval
);
}
}

View File

@@ -0,0 +1,82 @@
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.
*/
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 PlanSubscription = require('system/models/Subscriptions/PlanSubscription');
return {
/**
* The plan may have many subscriptions.
*/
subscriptions: {
relation: Model.HasManyRelation,
modelClass: PlanSubscription.default,
join: {
from: 'subscription_plans.id',
to: 'subscription_plan_subscriptions.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,164 @@
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.
*/
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: Tenant.default,
join: {
from: 'subscription_plan_subscriptions.tenantId',
to: 'tenants.id',
},
},
/**
* Plan description belongs to plan.
*/
plan: {
relation: Model.BelongsToOneRelation,
modelClass: 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}
*/
static setNewPeriod(invoiceInterval, invoicePeriod, start) {
const period = new SubscriptionPeriod(
invoiceInterval,
invoicePeriod,
start,
);
const startsAt = period.getStartDate();
const endsAt = period.getEndDate();
return { startsAt, endsAt };
}
/**
* Renews subscription period.
* @Promise
*/
renew(invoiceInterval, invoicePeriod) {
const { startsAt, endsAt } = PlanSubscription.setNewPeriod(
invoiceInterval,
invoicePeriod,
);
return this.$query().update({ startsAt, endsAt });
}
}

View File

@@ -1,8 +1,10 @@
import moment from 'moment'; import moment from 'moment';
import { Model } from 'objection'; import { Model } from 'objection';
import uniqid from 'uniqid'; import uniqid from 'uniqid';
import SubscriptionPeriod from '@/services/Subscription/SubscriptionPeriod';
import BaseModel from 'models/Model'; import BaseModel from 'models/Model';
import TenantMetadata from './TenantMetadata'; import TenantMetadata from './TenantMetadata';
import PlanSubscription from './Subscriptions/PlanSubscription';
export default class Tenant extends BaseModel { export default class Tenant extends BaseModel {
/** /**
@@ -47,13 +49,33 @@ export default class Tenant extends BaseModel {
return !!this.upgradeJobId; return !!this.upgradeJobId;
} }
/**
* Query modifiers.
*/
static modifiers() {
return {
subscriptions(builder) {
builder.withGraphFetched('subscriptions');
},
};
}
/** /**
* Relations mappings. * Relations mappings.
*/ */
static get relationMappings() { static get relationMappings() {
const PlanSubscription = require('./Subscriptions/PlanSubscription');
const TenantMetadata = require('./TenantMetadata'); const TenantMetadata = require('./TenantMetadata');
return { return {
subscriptions: {
relation: Model.HasManyRelation,
modelClass: PlanSubscription.default,
join: {
from: 'tenants.id',
to: 'subscription_plan_subscriptions.tenantId',
},
},
metadata: { metadata: {
relation: Model.HasOneRelation, relation: Model.HasOneRelation,
modelClass: TenantMetadata.default, modelClass: TenantMetadata.default,
@@ -64,6 +86,55 @@ export default class Tenant extends BaseModel {
}, },
}; };
} }
/**
* Retrieve the subscribed plans ids.
* @return {number[]}
*/
async subscribedPlansIds() {
const { subscriptions } = this;
return chain(subscriptions).map('planId').unq();
}
/**
*
* @param {*} planId
* @param {*} invoiceInterval
* @param {*} invoicePeriod
* @param {*} subscriptionSlug
* @returns
*/
newSubscription(planId, invoiceInterval, invoicePeriod, subscriptionSlug) {
return Tenant.newSubscription(
this.id,
planId,
invoiceInterval,
invoicePeriod,
subscriptionSlug,
);
}
/**
* Records a new subscription for the associated tenant.
*/
static newSubscription(
tenantId,
planId,
invoiceInterval,
invoicePeriod,
subscriptionSlug
) {
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
return PlanSubscription.query().insert({
tenantId,
slug: subscriptionSlug,
planId,
startsAt: period.getStartDate(),
endsAt: period.getEndDate(),
});
}
/** /**
* Creates a new tenant with random organization id. * Creates a new tenant with random organization id.
*/ */
@@ -114,9 +185,9 @@ export default class Tenant extends BaseModel {
/** /**
* Marks the given tenant as upgrading. * Marks the given tenant as upgrading.
* @param {number} tenantId * @param {number} tenantId
* @param {string} upgradeJobId * @param {string} upgradeJobId
* @returns * @returns
*/ */
static markAsUpgrading(tenantId, upgradeJobId) { static markAsUpgrading(tenantId, upgradeJobId) {
return this.query().update({ upgradeJobId }).where({ id: tenantId }); return this.query().update({ upgradeJobId }).where({ id: tenantId });
@@ -124,8 +195,8 @@ export default class Tenant extends BaseModel {
/** /**
* Markes the given tenant as upgraded. * Markes the given tenant as upgraded.
* @param {number} tenantId * @param {number} tenantId
* @returns * @returns
*/ */
static markAsUpgraded(tenantId) { static markAsUpgraded(tenantId) {
return this.query().update({ upgradeJobId: null }).where({ id: tenantId }); return this.query().update({ upgradeJobId: null }).where({ id: tenantId });

View File

@@ -1,7 +1,22 @@
import Plan from './Subscriptions/Plan';
import PlanFeature from './Subscriptions/PlanFeature';
import PlanSubscription from './Subscriptions/PlanSubscription';
import License from './Subscriptions/License';
import Tenant from './Tenant'; import Tenant from './Tenant';
import TenantMetadata from './TenantMetadata'; import TenantMetadata from './TenantMetadata';
import SystemUser from './SystemUser'; import SystemUser from './SystemUser';
import PasswordReset from './PasswordReset'; import PasswordReset from './PasswordReset';
import Invite from './Invite'; import Invite from './Invite';
export { Tenant, TenantMetadata, SystemUser, PasswordReset, Invite }; export {
Plan,
PlanFeature,
PlanSubscription,
License,
Tenant,
TenantMetadata,
SystemUser,
PasswordReset,
Invite,
}

View File

@@ -0,0 +1,26 @@
import SystemRepository from '@/system/repositories/SystemRepository';
import { PlanSubscription } from '@/system/models';
export default class SubscriptionRepository extends SystemRepository {
/**
* Gets the repository's model.
*/
get model() {
return PlanSubscription.bindKnex(this.knex);
}
/**
* Retrieve subscription from a given slug in specific tenant.
* @param {string} slug
* @param {number} tenantId
*/
getBySlugInTenant(slug: string, tenantId: number) {
const cacheKey = this.getCacheKey('getBySlugInTenant', slug, tenantId);
return this.cache.get(cacheKey, () => {
return PlanSubscription.query()
.findOne('slug', slug)
.where('tenant_id', tenantId);
});
}
}

View File

@@ -1,4 +1,9 @@
import SystemUserRepository from '@/system/repositories/SystemUserRepository'; import SystemUserRepository from '@/system/repositories/SystemUserRepository';
import SubscriptionRepository from '@/system/repositories/SubscriptionRepository';
import TenantRepository from '@/system/repositories/TenantRepository'; import TenantRepository from '@/system/repositories/TenantRepository';
export { SystemUserRepository, TenantRepository }; export {
SystemUserRepository,
SubscriptionRepository,
TenantRepository,
};

View File

@@ -0,0 +1,66 @@
exports.seed = (knex) => {
// Deletes ALL existing entries
return knex('subscription_plans').del()
.then(() => {
// Inserts seed entries
return knex('subscription_plans').insert([
{
name: 'Essentials',
slug: 'essentials-monthly',
price: 100,
active: true,
currency: 'LYD',
trial_period: 7,
trial_interval: 'days',
},
{
name: 'Essentials',
slug: 'essentials-yearly',
price: 1200,
active: true,
currency: 'LYD',
trial_period: 12,
trial_interval: 'months',
},
{
name: 'Pro',
slug: 'pro-monthly',
price: 200,
active: true,
currency: 'LYD',
trial_period: 1,
trial_interval: 'months',
},
{
name: 'Pro',
slug: 'pro-yearly',
price: 500,
active: true,
currency: 'LYD',
invoice_period: 12,
invoice_interval: 'month',
index: 2,
},
{
name: 'Plus',
slug: 'plus-monthly',
price: 200,
active: true,
currency: 'LYD',
trial_period: 1,
trial_interval: 'months',
},
{
name: 'Plus',
slug: 'plus-yearly',
price: 500,
active: true,
currency: 'LYD',
invoice_period: 12,
invoice_interval: 'month',
index: 2,
},
]);
});
};

View File

@@ -99,7 +99,7 @@
"yup": "^0.28.1" "yup": "^0.28.1"
}, },
"scripts": { "scripts": {
"dev": "PORT=4000 craco start", "dev": "craco start",
"build": "craco build", "build": "craco build",
"test": "node scripts/test.js", "test": "node scripts/test.js",
"storybook": "start-storybook -p 6006" "storybook": "start-storybook -p 6006"

View File

@@ -14,16 +14,19 @@ import GlobalHotkeys from './GlobalHotkeys';
import DashboardProvider from './DashboardProvider'; import DashboardProvider from './DashboardProvider';
import DrawersContainer from '@/components/DrawersContainer'; import DrawersContainer from '@/components/DrawersContainer';
import AlertsContainer from '@/containers/AlertsContainer'; import AlertsContainer from '@/containers/AlertsContainer';
import EnsureSubscriptionIsActive from '../Guards/EnsureSubscriptionIsActive';
/** /**
* Dashboard preferences. * Dashboard preferences.
*/ */
function DashboardPreferences() { function DashboardPreferences() {
return ( return (
<DashboardSplitPane> <EnsureSubscriptionIsActive>
<Sidebar /> <DashboardSplitPane>
<PreferencesPage /> <Sidebar />
</DashboardSplitPane> <PreferencesPage />
</DashboardSplitPane>
</EnsureSubscriptionIsActive>
); );
} }

View File

@@ -7,20 +7,26 @@ import {
} from '@/hooks/query'; } from '@/hooks/query';
import { useSplashLoading } from '@/hooks/state'; import { useSplashLoading } from '@/hooks/state';
import { useWatch, useWatchImmediate, useWhen } from '@/hooks'; import { useWatch, useWatchImmediate, useWhen } from '@/hooks';
import { useSubscription } from '@/hooks/state';
import { setCookie, getCookie } from '@/utils'; import { setCookie, getCookie } from '@/utils';
/** /**
* Dashboard meta async booting. * Dashboard meta async booting.
* - Fetches the dashboard meta in booting state. * - Fetches the dashboard meta only if the organization subscribe is active.
* - Once the dashboard meta query started loading display dashboard splash screen. * - Once the dashboard meta query is loading display dashboard splash screen.
*/ */
export function useDashboardMetaBoot() { export function useDashboardMetaBoot() {
const { isSubscriptionActive } = useSubscription();
const { const {
data: dashboardMeta, data: dashboardMeta,
isLoading: isDashboardMetaLoading, isLoading: isDashboardMetaLoading,
isSuccess: isDashboardMetaSuccess, isSuccess: isDashboardMetaSuccess,
} = useDashboardMeta({ } = useDashboardMeta({
keepPreviousData: true, keepPreviousData: true,
// Avoid run the query if the organization subscription is not active.
enabled: isSubscriptionActive,
}); });
const [startLoading, stopLoading] = useSplashLoading(); const [startLoading, stopLoading] = useSplashLoading();

View File

@@ -1,6 +1,5 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { useHistory } from 'react-router-dom';
import { import {
CollapsibleList, CollapsibleList,
MenuItem, MenuItem,
@@ -8,29 +7,29 @@ import {
Boundary, Boundary,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import withBreadcrumbs from 'react-router-breadcrumbs-hoc'; import withBreadcrumbs from 'react-router-breadcrumbs-hoc';
import { getDashboardRoutes } from '@/routes/dashboard';
import { useHistory } from 'react-router-dom';
function DashboardBreadcrumbs({ breadcrumbs }) { function DashboardBreadcrumbs({ breadcrumbs }){
const history = useHistory(); const history = useHistory();
return ( return(
<CollapsibleList <CollapsibleList
className={Classes.BREADCRUMBS} className={Classes.BREADCRUMBS}
dropdownTarget={<span className={Classes.BREADCRUMBS_COLLAPSED} />} dropdownTarget={<span className={Classes.BREADCRUMBS_COLLAPSED} />}
collapseFrom={Boundary.START} collapseFrom={Boundary.START}
visibleItemCount={0} visibleItemCount={0}>
> {
{breadcrumbs.map(({ breadcrumb, match }) => { breadcrumbs.map(({ breadcrumb,match })=>{
return ( return (<MenuItem
<MenuItem key={match.url}
key={match.url} icon={'folder-close'}
icon={'folder-close'} text={breadcrumb}
text={breadcrumb} onClick={() => history.push(match.url) } />)
onClick={() => history.push(match.url)} })
/> }
);
})}
</CollapsibleList> </CollapsibleList>
); )
} }
export default withBreadcrumbs([])(DashboardBreadcrumbs); export default withBreadcrumbs([])(DashboardBreadcrumbs)

View File

@@ -3,13 +3,15 @@ import React from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { getDashboardRoutes } from '@/routes/dashboard'; import { getDashboardRoutes } from '@/routes/dashboard';
import EnsureSubscriptionsIsActive from '../Guards/EnsureSubscriptionsIsActive';
import EnsureSubscriptionsIsInactive from '../Guards/EnsureSubscriptionsIsInactive';
import DashboardPage from './DashboardPage'; import DashboardPage from './DashboardPage';
/** /**
* Dashboard inner route content. * Dashboard inner route content.
*/ */
function DashboardContentRouteContent({ route }) { function DashboardContentRouteContent({ route }) {
return ( const content = (
<DashboardPage <DashboardPage
name={route.name} name={route.name}
Component={route.component} Component={route.component}
@@ -21,6 +23,21 @@ function DashboardContentRouteContent({ route }) {
defaultSearchResource={route.defaultSearchResource} defaultSearchResource={route.defaultSearchResource}
/> />
); );
return route.subscriptionActive ? (
<EnsureSubscriptionsIsInactive
subscriptionTypes={route.subscriptionActive}
children={content}
redirectTo={'/billing'}
/>
) : route.subscriptionInactive ? (
<EnsureSubscriptionsIsActive
subscriptionTypes={route.subscriptionInactive}
children={content}
redirectTo={'/'}
/>
) : (
content
);
} }
/** /**

View File

@@ -10,19 +10,57 @@ import {
Tooltip, Tooltip,
Position, Position,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { FormattedMessage as T, Icon, Hint, If } from '@/components'; import { FormattedMessage as T } from '@/components';
import DashboardTopbarUser from '@/components/Dashboard/TopbarUser'; import DashboardTopbarUser from '@/components/Dashboard/TopbarUser';
import DashboardBreadcrumbs from '@/components/Dashboard/DashboardBreadcrumbs'; import DashboardBreadcrumbs from '@/components/Dashboard/DashboardBreadcrumbs';
import DashboardBackLink from '@/components/Dashboard/DashboardBackLink'; import DashboardBackLink from '@/components/Dashboard/DashboardBackLink';
import { Icon, Hint, If } from '@/components';
import withUniversalSearchActions from '@/containers/UniversalSearch/withUniversalSearchActions'; import withUniversalSearchActions from '@/containers/UniversalSearch/withUniversalSearchActions';
import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions';
import withDashboard from '@/containers/Dashboard/withDashboard'; import withDashboard from '@/containers/Dashboard/withDashboard';
import QuickNewDropdown from '@/containers/QuickNewDropdown/QuickNewDropdown'; import QuickNewDropdown from '@/containers/QuickNewDropdown/QuickNewDropdown';
import { DashboardHamburgerButton, DashboardQuickSearchButton } from './_components';
import { compose } from '@/utils'; import { compose } from '@/utils';
import withSubscriptions from '@/containers/Subscriptions/withSubscriptions';
import { useGetUniversalSearchTypeOptions } from '@/containers/UniversalSearch/utils';
function DashboardTopbarSubscriptionMessage() {
return (
<div class="dashboard__topbar-subscription-msg">
<span>
<T id={'dashboard.subscription_msg.period_over'} />
</span>
</div>
);
}
function DashboardHamburgerButton({ ...props }) {
return (
<Button minimal={true} {...props}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
role="img"
focusable="false"
>
<title>
<T id={'menu'} />
</title>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-miterlimit="5"
stroke-width="2"
d="M4 7h15M4 12h15M4 17h15"
></path>
</svg>
</Button>
);
}
/** /**
* Dashboard topbar. * Dashboard topbar.
@@ -41,6 +79,10 @@ function DashboardTopbar({
// #withGlobalSearch // #withGlobalSearch
openGlobalSearch, openGlobalSearch,
// #withSubscriptions
isSubscriptionActive,
isSubscriptionInactive,
}) { }) {
const history = useHistory(); const history = useHistory();
@@ -95,22 +137,28 @@ function DashboardTopbar({
</div> </div>
<div class="dashboard__topbar-right"> <div class="dashboard__topbar-right">
<If condition={isSubscriptionInactive}>
<DashboardTopbarSubscriptionMessage />
</If>
<Navbar class="dashboard__topbar-navbar"> <Navbar class="dashboard__topbar-navbar">
<NavbarGroup> <NavbarGroup>
<DashboardQuickSearchButton <If condition={isSubscriptionActive}>
onClick={() => openGlobalSearch(true)} <DashboardQuickSearchButton
/> onClick={() => openGlobalSearch(true)}
<QuickNewDropdown />
<Tooltip
content={<T id={'notifications'} />}
position={Position.BOTTOM}
>
<Button
className={Classes.MINIMAL}
icon={<Icon icon={'notification-24'} iconSize={20} />}
/> />
</Tooltip> <QuickNewDropdown />
<Tooltip
content={<T id={'notifications'} />}
position={Position.BOTTOM}
>
<Button
className={Classes.MINIMAL}
icon={<Icon icon={'notification-24'} iconSize={20} />}
/>
</Tooltip>
</If>
<Button <Button
className={Classes.MINIMAL} className={Classes.MINIMAL}
@@ -138,4 +186,31 @@ export default compose(
pageHint, pageHint,
})), })),
withDashboardActions, withDashboardActions,
withSubscriptions(
({ isSubscriptionActive, isSubscriptionInactive }) => ({
isSubscriptionActive,
isSubscriptionInactive,
}),
'main',
),
)(DashboardTopbar); )(DashboardTopbar);
/**
* Dashboard quick search button.
*/
function DashboardQuickSearchButton({ ...rest }) {
const searchTypeOptions = useGetUniversalSearchTypeOptions();
// Can't continue if there is no any search type option.
if (searchTypeOptions.length <= 0) {
return null;
}
return (
<Button
className={Classes.MINIMAL}
icon={<Icon icon={'search-24'} iconSize={20} />}
text={<T id={'quick_find'} />}
{...rest}
/>
);
}

View File

@@ -1,61 +0,0 @@
// @ts-nocheck
import React from 'react';
import { Button, Classes } from '@blueprintjs/core';
import { useGetUniversalSearchTypeOptions } from '@/containers/UniversalSearch/utils';
import { Icon, FormattedMessage as T } from '@/components';
export function DashboardTopbarSubscriptionMessage() {
return (
<div class="dashboard__topbar-subscription-msg">
<span>
<T id={'dashboard.subscription_msg.period_over'} />
</span>
</div>
);
}
export function DashboardHamburgerButton({ ...props }) {
return (
<Button minimal={true} {...props}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
role="img"
focusable="false"
>
<title>
<T id={'menu'} />
</title>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-miterlimit="5"
stroke-width="2"
d="M4 7h15M4 12h15M4 17h15"
></path>
</svg>
</Button>
);
}
/**
* Dashboard quick search button.
*/
export function DashboardQuickSearchButton({ ...rest }) {
const searchTypeOptions = useGetUniversalSearchTypeOptions();
// Can't continue if there is no any search type option.
if (searchTypeOptions.length <= 0) {
return null;
}
return (
<Button
className={Classes.MINIMAL}
icon={<Icon icon={'search-24'} iconSize={20} />}
text={<T id={'quick_find'} />}
{...rest}
/>
);
}

View File

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

View File

@@ -9,21 +9,25 @@ import {
Popover, Popover,
Position, Position,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { FormattedMessage as T } from '@/components'; import { If, FormattedMessage as T } from '@/components';
import { firstLettersArgs } from '@/utils';
import { useAuthActions } from '@/hooks/state'; import { useAuthActions } from '@/hooks/state';
import withDialogActions from '@/containers/Dialog/withDialogActions'; import withDialogActions from '@/containers/Dialog/withDialogActions';
import withSubscriptions from '@/containers/Subscriptions/withSubscriptions';
import { useAuthenticatedAccount } from '@/hooks/query'; import { useAuthenticatedAccount } from '@/hooks/query';
import { firstLettersArgs, compose } from '@/utils'; import { compose } from '@/utils';
/** /**
* Dashboard topbar user. * Dashboard topbar user.
*/ */
function DashboardTopbarUser({ function DashboardTopbarUser({
// #withDialogActions
openDialog, openDialog,
// #withSubscriptions
isSubscriptionActive,
}) { }) {
const history = useHistory(); const history = useHistory();
const { setLogout } = useAuthActions(); const { setLogout } = useAuthActions();
@@ -58,14 +62,16 @@ function DashboardTopbarUser({
} }
/> />
<MenuDivider /> <MenuDivider />
<MenuItem <If condition={isSubscriptionActive}>
text={<T id={'keyboard_shortcuts'} />} <MenuItem
onClick={onKeyboardShortcut} text={<T id={'keyboard_shortcuts'} />}
/> onClick={onKeyboardShortcut}
<MenuItem />
text={<T id={'preferences'} />} <MenuItem
onClick={() => history.push('/preferences')} text={<T id={'preferences'} />}
/> onClick={() => history.push('/preferences')}
/>
</If>
<MenuItem text={<T id={'logout'} />} onClick={onClickLogout} /> <MenuItem text={<T id={'logout'} />} onClick={onClickLogout} />
</Menu> </Menu>
} }
@@ -81,4 +87,8 @@ function DashboardTopbarUser({
} }
export default compose( export default compose(
withDialogActions, withDialogActions,
withSubscriptions(
({ isSubscriptionActive }) => ({ isSubscriptionActive }),
'main',
),
)(DashboardTopbarUser); )(DashboardTopbarUser);

View File

@@ -72,7 +72,6 @@ export default function TableCell({ cell, row, index }) {
[`td-${cell.column.id}`]: cell.column.id, [`td-${cell.column.id}`]: cell.column.id,
[`td-${cellType}-type`]: !!cellType, [`td-${cellType}-type`]: !!cellType,
}), }),
tabindex: 0,
onClick: handleCellClick, onClick: handleCellClick,
})} })}
> >

View File

@@ -2,6 +2,9 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
export const getSetupWizardSteps = () => [ export const getSetupWizardSteps = () => [
{
label: intl.get('setup.plan.plans'),
},
{ {
label: intl.get('setup.plan.getting_started'), label: intl.get('setup.plan.getting_started'),
}, },

View File

@@ -5,6 +5,7 @@ import { Features } from '@/constants/features';
import { import {
ISidebarMenuItemType, ISidebarMenuItemType,
ISidebarMenuOverlayIds, ISidebarMenuOverlayIds,
ISidebarSubscriptionAbility,
} from '@/containers/Dashboard/Sidebar/interfaces'; } from '@/containers/Dashboard/Sidebar/interfaces';
import { import {
ReportsAction, ReportsAction,
@@ -23,7 +24,9 @@ import {
ManualJournalAction, ManualJournalAction,
ExpenseAction, ExpenseAction,
CashflowAction, CashflowAction,
ProjectAction,
PreferencesAbility, PreferencesAbility,
SubscriptionBillingAbility,
} from '@/constants/abilityOption'; } from '@/constants/abilityOption';
export const SidebarMenu = [ export const SidebarMenu = [
@@ -778,6 +781,19 @@ export const SidebarMenu = [
ability: PreferencesAbility.Mutate, ability: PreferencesAbility.Mutate,
}, },
}, },
{
text: <T id={'billing'} />,
href: '/billing',
type: ISidebarMenuItemType.Link,
subscription: [
ISidebarSubscriptionAbility.Expired,
ISidebarSubscriptionAbility.Active,
],
permission: {
subject: AbilitySubject.SubscriptionBilling,
ability: SubscriptionBillingAbility.View,
},
},
], ],
}, },
]; ];

View File

@@ -4,7 +4,6 @@ import intl from 'react-intl-universal';
import classNames from 'classnames'; import classNames from 'classnames';
import { Formik, Form } from 'formik'; import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import styled from 'styled-components';
import { CLASSES } from '@/constants/classes'; import { CLASSES } from '@/constants/classes';
import { CreateCustomerForm, EditCustomerForm } from './CustomerForm.schema'; import { CreateCustomerForm, EditCustomerForm } from './CustomerForm.schema';
@@ -105,9 +104,9 @@ function CustomerFormFormik({
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
> >
<Form> <Form>
<CustomerFormHeaderPrimary> <div className={classNames(CLASSES.PAGE_FORM_HEADER_PRIMARY)}>
<CustomerFormPrimarySection /> <CustomerFormPrimarySection />
</CustomerFormHeaderPrimary> </div>
<div className={'page-form__after-priamry-section'}> <div className={'page-form__after-priamry-section'}>
<CustomerFormAfterPrimarySection /> <CustomerFormAfterPrimarySection />
@@ -124,12 +123,4 @@ function CustomerFormFormik({
); );
} }
export const CustomerFormHeaderPrimary = styled.div`
padding: 10px 0 0;
margin: 0 0 20px;
overflow: hidden;
border-bottom: 1px solid #e4e4e4;
max-width: 1000px;
`;
export default compose(withCurrentOrganization())(CustomerFormFormik); export default compose(withCurrentOrganization())(CustomerFormFormik);

View File

@@ -4,6 +4,7 @@ import { Scrollbar } from 'react-scrollbars-custom';
import classNames from 'classnames'; import classNames from 'classnames';
import withDashboard from '@/containers/Dashboard/withDashboard'; import withDashboard from '@/containers/Dashboard/withDashboard';
import withSubscriptions from '@/containers/Subscriptions/withSubscriptions';
import { useObserveSidebarExpendedBodyclass } from './hooks'; import { useObserveSidebarExpendedBodyclass } from './hooks';
import { compose } from '@/utils'; import { compose } from '@/utils';
@@ -18,6 +19,9 @@ function SidebarContainerJSX({
// #withDashboard // #withDashboard
sidebarExpended, sidebarExpended,
// #withSubscription
isSubscriptionActive,
}) { }) {
const sidebarScrollerRef = React.useRef(); const sidebarScrollerRef = React.useRef();
@@ -47,6 +51,7 @@ function SidebarContainerJSX({
<div <div
className={classNames('sidebar', { className={classNames('sidebar', {
'sidebar--mini-sidebar': !sidebarExpended, 'sidebar--mini-sidebar': !sidebarExpended,
'is-subscription-inactive': !isSubscriptionActive,
})} })}
id="sidebar" id="sidebar"
onMouseLeave={handleSidebarMouseLeave} onMouseLeave={handleSidebarMouseLeave}
@@ -67,4 +72,8 @@ export const SidebarContainer = compose(
withDashboard(({ sidebarExpended }) => ({ withDashboard(({ sidebarExpended }) => ({
sidebarExpended, sidebarExpended,
})), })),
withSubscriptions(
({ isSubscriptionActive }) => ({ isSubscriptionActive }),
'main',
),
)(SidebarContainerJSX); )(SidebarContainerJSX);

View File

@@ -1,11 +1,14 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { Menu } from '@blueprintjs/core'; import { Menu } from '@blueprintjs/core';
import * as R from 'ramda';
import { MenuItem, MenuItemLabel } from '@/components'; import { MenuItem, MenuItemLabel } from '@/components';
import { ISidebarMenuItemType } from '@/containers/Dashboard/Sidebar/interfaces'; import { ISidebarMenuItemType } from '@/containers/Dashboard/Sidebar/interfaces';
import { useIsSidebarMenuItemActive } from './hooks'; import { useIsSidebarMenuItemActive } from './hooks';
import withSubscriptions from '@/containers/Subscriptions/withSubscriptions';
/** /**
* Sidebar menu item. * Sidebar menu item.
* @returns {JSX.Element} * @returns {JSX.Element}
@@ -52,7 +55,7 @@ function SidebarMenuItemComposer({ item, index }) {
* Sidebar menu. * Sidebar menu.
* @returns {JSX.Element} * @returns {JSX.Element}
*/ */
export function SidebarMenu({ menu }) { function SidebarMenuJSX({ menu }) {
return ( return (
<div> <div>
<Menu className="sidebar-menu"> <Menu className="sidebar-menu">
@@ -63,3 +66,10 @@ export function SidebarMenu({ menu }) {
</div> </div>
); );
} }
export const SidebarMenu = R.compose(
withSubscriptions(
({ isSubscriptionActive }) => ({ isSubscriptionActive }),
'main',
),
)(SidebarMenuJSX);

View File

@@ -19,6 +19,7 @@ import {
} from './interfaces'; } from './interfaces';
import { filterValuesDeep, deepdash } from '@/utils'; import { filterValuesDeep, deepdash } from '@/utils';
const deepDashConfig = { const deepDashConfig = {
childrenPath: 'children', childrenPath: 'children',
pathFormat: 'array', pathFormat: 'array',
@@ -135,7 +136,9 @@ function useFilterSidebarMenuAbility(menu) {
return deepdash.filterDeep( return deepdash.filterDeep(
menu, menu,
(item) => predFeature(item) && predAbility(item), (item) => {
return predFeature(item) && predAbility(item) && predSubscription(item);
},
deepDashConfig, deepDashConfig,
); );
} }

View File

@@ -3,6 +3,7 @@ import React, { useCallback } from 'react';
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import { Formik } from 'formik'; import { Formik } from 'formik';
import { omit } from 'lodash';
import { AppToaster } from '@/components'; import { AppToaster } from '@/components';
import AccountDialogFormContent from './AccountDialogFormContent'; import AccountDialogFormContent from './AccountDialogFormContent';
@@ -13,11 +14,7 @@ import {
CreateAccountFormSchema, CreateAccountFormSchema,
} from './AccountForm.schema'; } from './AccountForm.schema';
import { compose, transformToForm } from '@/utils'; import { compose, transformToForm } from '@/utils';
import { import { transformApiErrors, transformAccountToForm } from './utils';
transformApiErrors,
transformAccountToForm,
transformFormToReq,
} from './utils';
import '@/style/pages/Accounts/AccountFormDialog.scss'; import '@/style/pages/Accounts/AccountFormDialog.scss';
import { useAccountDialogContext } from './AccountDialogProvider'; import { useAccountDialogContext } from './AccountDialogProvider';
@@ -29,7 +26,7 @@ const defaultInitialValues = {
name: '', name: '',
code: '', code: '',
description: '', description: '',
currency_code: '', currency_code:'',
subaccount: false, subaccount: false,
}; };
@@ -46,6 +43,7 @@ function AccountFormDialogContent({
createAccountMutate, createAccountMutate,
account, account,
accountId,
payload, payload,
isNewMode, isNewMode,
dialogName, dialogName,
@@ -58,7 +56,7 @@ function AccountFormDialogContent({
// Callbacks handles form submit. // Callbacks handles form submit.
const handleFormSubmit = (values, { setSubmitting, setErrors }) => { const handleFormSubmit = (values, { setSubmitting, setErrors }) => {
const form = transformFormToReq(values); const form = omit(values, ['subaccount']);
const toastAccountName = values.code const toastAccountName = values.code
? `${values.code} - ${values.name}` ? `${values.code} - ${values.name}`
: values.name; : values.name;
@@ -92,8 +90,8 @@ function AccountFormDialogContent({
setErrors({ ...errorsTransformed }); setErrors({ ...errorsTransformed });
setSubmitting(false); setSubmitting(false);
}; };
if (payload.accountId) { if (accountId) {
editAccountMutate([payload.accountId, form]) editAccountMutate([accountId, form])
.then(handleSuccess) .then(handleSuccess)
.catch(handleError); .catch(handleError);
} else { } else {
@@ -115,6 +113,7 @@ function AccountFormDialogContent({
defaultInitialValues, defaultInitialValues,
), ),
}; };
// Handles dialog close. // Handles dialog close.
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
closeDialog(dialogName); closeDialog(dialogName);

View File

@@ -26,7 +26,6 @@ import { inputIntent, compose } from '@/utils';
import { useAutofocus } from '@/hooks'; import { useAutofocus } from '@/hooks';
import { FOREIGN_CURRENCY_ACCOUNTS } from '@/constants/accountTypes'; import { FOREIGN_CURRENCY_ACCOUNTS } from '@/constants/accountTypes';
import { useAccountDialogContext } from './AccountDialogProvider'; import { useAccountDialogContext } from './AccountDialogProvider';
import { parentAccountShouldUpdate } from './utils';
/** /**
* Account form dialogs fields. * Account form dialogs fields.
@@ -116,7 +115,12 @@ function AccountFormDialogFields({
> >
<Checkbox <Checkbox
inline={true} inline={true}
label={<T id={'sub_account'} />} label={
<>
<T id={'sub_account'} />
<Hint />
</>
}
name={'subaccount'} name={'subaccount'}
{...field} {...field}
/> />
@@ -124,36 +128,37 @@ function AccountFormDialogFields({
)} )}
</Field> </Field>
<FastField <If condition={values.subaccount}>
name={'parent_account_id'} <FastField name={'parent_account_id'}>
shouldUpdate={parentAccountShouldUpdate} {({
> form: { values, setFieldValue },
{({ field: { value },
form: { values, setFieldValue }, meta: { error, touched },
field: { value }, }) => (
meta: { error, touched }, <FormGroup
}) => ( label={<T id={'parent_account'} />}
<FormGroup className={classNames(
label={<T id={'parent_account'} />} 'form-group--parent-account',
className={classNames('form-group--parent-account', Classes.FILL)} Classes.FILL,
inline={true} )}
intent={inputIntent({ error, touched })} inline={true}
helperText={<ErrorMessage name="parent_account_id" />} intent={inputIntent({ error, touched })}
> helperText={<ErrorMessage name="parent_account_id" />}
<AccountsSelectList >
accounts={accounts} <AccountsSelectList
onAccountSelected={(account) => { accounts={accounts}
setFieldValue('parent_account_id', account.id); onAccountSelected={(account) => {
}} setFieldValue('parent_account_id', account.id);
defaultSelectText={<T id={'select_parent_account'} />} }}
selectedAccountId={value} defaultSelectText={<T id={'select_parent_account'} />}
popoverFill={true} selectedAccountId={value}
filterByTypes={values.account_type} popoverFill={true}
disabled={!values.subaccount} filterByTypes={values.account_type}
/> />
</FormGroup> </FormGroup>
)} )}
</FastField> </FastField>
</If>
<If condition={FOREIGN_CURRENCY_ACCOUNTS.includes(values.account_type)}> <If condition={FOREIGN_CURRENCY_ACCOUNTS.includes(values.account_type)}>
{/*------------ Currency -----------*/} {/*------------ Currency -----------*/}

View File

@@ -2,7 +2,6 @@
import intl from 'react-intl-universal'; import intl from 'react-intl-universal';
import * as R from 'ramda'; import * as R from 'ramda';
import { isUndefined } from 'lodash'; import { isUndefined } from 'lodash';
import { defaultFastFieldShouldUpdate } from '@/utils';
export const AccountDialogAction = { export const AccountDialogAction = {
Edit: 'edit', Edit: 'edit',
@@ -34,7 +33,7 @@ export const transformApiErrors = (errors) => {
/** /**
* Payload transformer in account edit mode. * Payload transformer in account edit mode.
*/ */
function tranformNewChildAccountPayload(account, payload) { function tranformNewChildAccountPayload(payload) {
return { return {
parent_account_id: payload.parentAccountId || '', parent_account_id: payload.parentAccountId || '',
account_type: payload.accountType || '', account_type: payload.accountType || '',
@@ -45,7 +44,7 @@ function tranformNewChildAccountPayload(account, payload) {
/** /**
* Payload transformer in new account with defined type. * Payload transformer in new account with defined type.
*/ */
function transformNewDefinedTypePayload(account, payload) { function transformNewDefinedTypePayload(payload) {
return { return {
account_type: payload.accountType || '', account_type: payload.accountType || '',
}; };
@@ -64,9 +63,7 @@ const mergeWithAccount = R.curry((transformed, account) => {
/** /**
* Default account payload transformer. * Default account payload transformer.
*/ */
const defaultPayloadTransform = (account, payload) => ({ const defaultPayloadTransform = () => ({});
subaccount: !!account.parent_account_id,
});
/** /**
* Defined payload transformers. * Defined payload transformers.
@@ -92,7 +89,7 @@ export const transformAccountToForm = (account, payload) => {
return [ return [
condition[0] === payload.action ? R.T : R.F, condition[0] === payload.action ? R.T : R.F,
mergeWithAccount(transformer(account, payload)), mergeWithAccount(transformer(payload)),
]; ];
}); });
return R.cond(results)(account); return R.cond(results)(account);
@@ -109,29 +106,3 @@ export const getDisabledFormFields = (account, payload) => {
payload.action === AccountDialogAction.NewDefinedType, payload.action === AccountDialogAction.NewDefinedType,
}; };
}; };
/**
* Detarmines whether should update the parent account field.
* @param newProps
* @param oldProps
* @returns {boolean}
*/
export const parentAccountShouldUpdate = (newProps, oldProps) => {
return (
newProps.formik.values.subaccount !== oldProps.formik.values.subaccount ||
defaultFastFieldShouldUpdate(newProps, oldProps)
);
};
/**
* Transformes the form values to the request.
*/
export const transformFormToReq = (form) => {
return R.compose(
R.omit(['subaccount']),
R.when(
R.propSatisfies(R.equals(R.__, false), 'subaccount'),
R.assoc(['parent_account_id'], ''),
),
)(form);
};

View File

@@ -4,13 +4,12 @@ import * as R from 'ramda';
import styled from 'styled-components'; import styled from 'styled-components';
import { Card, DrawerLoading } from '@/components'; import { Card, DrawerLoading } from '@/components';
import { import {
CustomerFormProvider, CustomerFormProvider,
useCustomerFormContext, useCustomerFormContext,
} from '@/containers/Customers/CustomerForm/CustomerFormProvider'; } from '@/containers/Customers/CustomerForm/CustomerFormProvider';
import CustomerFormFormik, { import CustomerFormFormik from '@/containers/Customers/CustomerForm/CustomerFormFormik';
CustomerFormHeaderPrimary,
} from '@/containers/Customers/CustomerForm/CustomerFormFormik';
import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import withDrawerActions from '@/containers/Drawer/withDrawerActions';
@@ -56,18 +55,12 @@ export default R.compose(withDrawerActions)(QuickCustomerFormDrawer);
const CustomerFormCard = styled(Card)` const CustomerFormCard = styled(Card)`
margin: 15px; margin: 15px;
padding: 25px;
margin-bottom: calc(15px + 65px); margin-bottom: calc(15px + 65px);
${CustomerFormHeaderPrimary} {
padding-top: 0;
}
.page-form { .page-form {
padding: 0;
&__floating-actions { &__floating-actions {
margin-left: -41px; margin-left: -36px;
margin-right: -41px; margin-right: -36px;
} }
} }
`; `;

View File

@@ -4,13 +4,12 @@ import * as R from 'ramda';
import styled from 'styled-components'; import styled from 'styled-components';
import { Card, DrawerLoading } from '@/components'; import { Card, DrawerLoading } from '@/components';
import { import {
VendorFormProvider, VendorFormProvider,
useVendorFormContext, useVendorFormContext,
} from '@/containers/Vendors/VendorForm/VendorFormProvider'; } from '@/containers/Vendors/VendorForm/VendorFormProvider';
import VendorFormFormik, { import VendorFormFormik from '@/containers/Vendors/VendorForm/VendorFormFormik';
VendorFormHeaderPrimary,
} from '@/containers/Vendors/VendorForm/VendorFormFormik';
import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; import withDashboardActions from '@/containers/Dashboard/withDashboardActions';
@@ -76,18 +75,12 @@ export default R.compose(
const VendorFormCard = styled(Card)` const VendorFormCard = styled(Card)`
margin: 15px; margin: 15px;
padding: 25px;
margin-bottom: calc(15px + 65px); margin-bottom: calc(15px + 65px);
${VendorFormHeaderPrimary} {
padding-top: 0;
}
.page-form { .page-form {
padding: 0;
&__floating-actions { &__floating-actions {
margin-left: -41px; margin-left: -36px;
margin-right: -41px; margin-right: -36px;
} }
} }
`; `;

View File

@@ -105,7 +105,7 @@ export function ItemsActionMenuList({
</Can> </Can>
<Can I={ItemAction.Create} a={AbilitySubject.Item}> <Can I={ItemAction.Create} a={AbilitySubject.Item}>
<MenuItem <MenuItem
icon={<Icon icon="content-copy" iconSize={16} />} icon={<Icon icon="duplicate-16" />}
text={intl.get('duplicate')} text={intl.get('duplicate')}
onClick={safeCallback(onDuplicate, original)} onClick={safeCallback(onDuplicate, original)}
/> />

View File

@@ -27,6 +27,13 @@ function SetupLeftSectionFooter() {
return ( return (
<div className={'content__footer'}> <div className={'content__footer'}>
<div className={'content__contact-info'}>
<p>
<T id={'setup.left_side.footer_help'} />{' '}
<span>{'+21892-738-1987'}</span>
</p>
</div>
<div className={'content__links'}> <div className={'content__links'}>
<For render={FooterLinkItem} of={footerLinks} /> <For render={FooterLinkItem} of={footerLinks} />
</div> </div>

View File

@@ -54,6 +54,15 @@ function SetupOrganizationPage({ wizard }) {
return ( return (
<div className={'setup-organization'}> <div className={'setup-organization'}>
<div className={'setup-organization__title-wrap'}>
<h1>
<T id={'setup.organization.title'} />
</h1>
<p class="paragraph">
<T id={'setup.organization.description'} />
</p>
</div>
<Formik <Formik
validationSchema={validationSchema} validationSchema={validationSchema}
initialValues={initialValues} initialValues={initialValues}

View File

@@ -4,6 +4,7 @@ import React from 'react';
import SetupDialogs from './SetupDialogs'; import SetupDialogs from './SetupDialogs';
import SetupWizardContent from './SetupWizardContent'; import SetupWizardContent from './SetupWizardContent';
import withSubscriptions from '@/containers/Subscriptions/withSubscriptions';
import withOrganization from '@/containers/Organization/withOrganization'; import withOrganization from '@/containers/Organization/withOrganization';
import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization';
import withSetupWizard from '@/store/organizations/withSetupWizard'; import withSetupWizard from '@/store/organizations/withSetupWizard';
@@ -22,6 +23,9 @@ function SetupRightSection({
// #withSetupWizard // #withSetupWizard
setupStepId, setupStepId,
setupStepIndex, setupStepIndex,
// #withSubscriptions
isSubscriptionActive,
}) { }) {
return ( return (
<section className={'setup-page__right-section'}> <section className={'setup-page__right-section'}>
@@ -53,6 +57,12 @@ export default compose(
isOrganizationBuildRunning, isOrganizationBuildRunning,
}), }),
), ),
withSubscriptions(
({ isSubscriptionActive }) => ({
isSubscriptionActive,
}),
'main',
),
withSetupWizard(({ setupStepId, setupStepIndex }) => ({ withSetupWizard(({ setupStepId, setupStepIndex }) => ({
setupStepId, setupStepId,
setupStepIndex, setupStepIndex,

View File

@@ -4,6 +4,7 @@ import React from 'react';
import SetupSteps from './SetupSteps'; import SetupSteps from './SetupSteps';
import WizardSetupSteps from './WizardSetupSteps'; import WizardSetupSteps from './WizardSetupSteps';
import SetupSubscription from './SetupSubscription';
import SetupOrganizationPage from './SetupOrganizationPage'; import SetupOrganizationPage from './SetupOrganizationPage';
import SetupInitializingForm from './SetupInitializingForm'; import SetupInitializingForm from './SetupInitializingForm';
import SetupCongratsPage from './SetupCongratsPage'; import SetupCongratsPage from './SetupCongratsPage';
@@ -18,6 +19,7 @@ export default function SetupWizardContent({ setupStepIndex, setupStepId }) {
<div class="setup-page-form"> <div class="setup-page-form">
<SetupSteps step={{ id: setupStepId }}> <SetupSteps step={{ id: setupStepId }}>
<SetupSubscription id="subscription" />
<SetupOrganizationPage id="organization" /> <SetupOrganizationPage id="organization" />
<SetupInitializingForm id={'initializing'} /> <SetupInitializingForm id={'initializing'} />
<SetupCongratsPage id="congrats" /> <SetupCongratsPage id="congrats" />

View File

@@ -25,7 +25,6 @@ export default function WizardSetupSteps({ currentStep = 1 }) {
<WizardSetupStep <WizardSetupStep
label={step.label} label={step.label}
isActive={index + 1 === currentStep} isActive={index + 1 === currentStep}
key={index}
/> />
))} ))}
</ul> </ul>

View File

@@ -4,7 +4,6 @@ import intl from 'react-intl-universal';
import { Formik, Form } from 'formik'; import { Formik, Form } from 'formik';
import { Intent } from '@blueprintjs/core'; import { Intent } from '@blueprintjs/core';
import classNames from 'classnames'; import classNames from 'classnames';
import styled from 'styled-components';
import { CLASSES } from '@/constants/classes'; import { CLASSES } from '@/constants/classes';
import { AppToaster } from '@/components'; import { AppToaster } from '@/components';
@@ -116,9 +115,9 @@ function VendorFormFormik({
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
> >
<Form> <Form>
<VendorFormHeaderPrimary> <div className={classNames(CLASSES.PAGE_FORM_HEADER_PRIMARY)}>
<VendorFormPrimarySection /> <VendorFormPrimarySection />
</VendorFormHeaderPrimary> </div>
<div className={'page-form__after-priamry-section'}> <div className={'page-form__after-priamry-section'}>
<VendorFormAfterPrimarySection /> <VendorFormAfterPrimarySection />
@@ -135,12 +134,4 @@ function VendorFormFormik({
); );
} }
export const VendorFormHeaderPrimary = styled.div`
padding: 10px 0 0;
margin: 0 0 20px;
overflow: hidden;
border-bottom: 1px solid #e4e4e4;
max-width: 1000px;
`;
export default compose(withCurrentOrganization())(VendorFormFormik); export default compose(withCurrentOrganization())(VendorFormFormik);

View File

@@ -1,11 +1,11 @@
// @ts-nocheck // @ts-nocheck
import { omit } from 'lodash';
import { useMutation, useQueryClient } from 'react-query'; import { useMutation, useQueryClient } from 'react-query';
import { batch } from 'react-redux'; import { batch } from 'react-redux';
import t from './types'; import t from './types';
import useApiRequest from '../useRequest'; import useApiRequest from '../useRequest';
import { useRequestQuery } from '../useQueryRequest'; import { useRequestQuery } from '../useQueryRequest';
import { useSetOrganizations } from '../state'; import { useSetOrganizations, useSetSubscriptions } from '../state';
import { omit } from 'lodash';
/** /**
* Retrieve organizations of the authenticated user. * Retrieve organizations of the authenticated user.
@@ -32,6 +32,7 @@ export function useOrganizations(props) {
*/ */
export function useCurrentOrganization(props) { export function useCurrentOrganization(props) {
const setOrganizations = useSetOrganizations(); const setOrganizations = useSetOrganizations();
const setSubscriptions = useSetSubscriptions();
return useRequestQuery( return useRequestQuery(
[t.ORGANIZATION_CURRENT], [t.ORGANIZATION_CURRENT],
@@ -43,6 +44,9 @@ export function useCurrentOrganization(props) {
const organization = omit(data, ['subscriptions']); const organization = omit(data, ['subscriptions']);
batch(() => { batch(() => {
// Sets subscriptions.
setSubscriptions(data.subscriptions);
// Sets organizations. // Sets organizations.
setOrganizations([organization]); setOrganizations([organization]);
}); });

View File

@@ -1237,7 +1237,7 @@
"expense.details.total": "Total", "expense.details.total": "Total",
"manual_journal.details.subtotal": "Subtotal", "manual_journal.details.subtotal": "Subtotal",
"manual_journal.details.total": "Total", "manual_journal.details.total": "Total",
"setup.left_side.title": "Register a New Organization now!", "setup.left_side.title": "Register a New Organization now!.",
"setup.left_side.description": "You have a Bigcapital account", "setup.left_side.description": "You have a Bigcapital account",
"setup.left_side.footer_help": "Were Here to Help!", "setup.left_side.footer_help": "Were Here to Help!",
"setup.plan.plans": "Plans & Payment", "setup.plan.plans": "Plans & Payment",

View File

@@ -0,0 +1,23 @@
// @ts-nocheck
import LazyLoader from '@/components/LazyLoader';
export default [
{
path: '/register/subscription',
component: LazyLoader({
loader: () => import('@/containers/Authentication/Register/RegisterSubscriptionForm'),
}),
},
{
path: '/register/organization',
component: LazyLoader({
loader: () => import('@/containers/Authentication/Register/RegisterOrganizationForm'),
}),
},
{
path: `/`,
component: LazyLoader({
loader: () => import('@/containers/Authentication/Register/RegisterUserForm'),
}),
},
];

View File

@@ -559,10 +559,4 @@ export default {
], ],
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
}, },
'content-copy': {
path: [
'M15 0H5c-.55 0-1 .45-1 1v2h2V2h8v7h-1v2h2c.55 0 1-.45 1-1V1c0-.55-.45-1-1-1zm-4 4H1c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h10c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1zm-1 10H2V6h8v8z'
],
viewBox: '0 0 16 16'
}
}; };

View File

@@ -6,15 +6,18 @@ export default (mapState) => {
const { const {
isOrganizationSetupCompleted, isOrganizationSetupCompleted,
isOrganizationReady, isOrganizationReady,
isSubscriptionActive,
isOrganizationBuildRunning isOrganizationBuildRunning
} = props; } = props;
const condits = { const condits = {
isCongratsStep: isOrganizationSetupCompleted, isCongratsStep: isOrganizationSetupCompleted,
isSubscriptionStep: !isSubscriptionActive,
isInitializingStep: isOrganizationBuildRunning, isInitializingStep: isOrganizationBuildRunning,
isOrganizationStep: !isOrganizationReady && !isOrganizationBuildRunning, isOrganizationStep: !isOrganizationReady && !isOrganizationBuildRunning,
}; };
const scenarios = [ const scenarios = [
{ condition: condits.isSubscriptionStep, step: 'subscription' },
{ condition: condits.isOrganizationStep, step: 'organization' }, { condition: condits.isOrganizationStep, step: 'organization' },
{ condition: condits.isInitializingStep, step: 'initializing' }, { condition: condits.isInitializingStep, step: 'initializing' },
{ condition: condits.isCongratsStep, step: 'congrats' }, { condition: condits.isCongratsStep, step: 'congrats' },

View File

@@ -10,7 +10,7 @@
display: block; display: block;
.thead .thead-inner, .thead .thead-inner,
.tbody .tbody-inner { .tbody .tbody-inner{
min-width: fit-content; min-width: fit-content;
} }
@@ -25,7 +25,7 @@
font-weight: 400; font-weight: 400;
border-bottom: 1px solid #d2dde2; border-bottom: 1px solid #d2dde2;
> div { >div {
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -208,10 +208,6 @@
width: 100%; width: 100%;
} }
} }
&:focus {
outline: 1px solid rgba(0, 82, 204, 0.7);
outline-offset: -1px;
}
} }
.tr:hover .td { .tr:hover .td {
@@ -361,9 +357,13 @@
position: sticky; position: sticky;
} }
[data-sticky-last-left-td] {} [data-sticky-last-left-td] {
[data-sticky-first-right-td] {} }
[data-sticky-first-right-td] {
}
} }
&.has-virtualized-rows { &.has-virtualized-rows {

View File

@@ -1,17 +1,30 @@
.setup-organization { .setup-organization {
max-width: 600px; width: 580px;
width: 100%;
margin: 0 auto; margin: 0 auto;
padding: 45px 25px 20px; padding: 45px 0 20px;
&__title-wrap {
margin-bottom: 20px;
h1 {
margin-top: 0;
margin-bottom: 10px;
color: #565e6c;
}
.paragraph {
opacity: 0.8;
}
}
form { form {
h3 { h3 {
color: #868f9f; color: #6b7382;
margin-bottom: 2rem; margin-bottom: 1.6rem;
font-weight: 600; font-weight: 500;
} }
} }
.bp3-form-group { .bp3-form-group {
margin-bottom: 24px; margin-bottom: 24px;
@@ -51,10 +64,10 @@
.register-org-button { .register-org-button {
.bp3-button { .bp3-button {
background-color: #1c2448; background-color: #0052cc;
min-width: 175px;
height: 40px; height: 40px;
font-size: 15px; font-size: 15px;
width: 100%;
} }
} }
} }

View File

@@ -13,17 +13,12 @@
display: block; display: block;
width: 100%; width: 100%;
height: 1px; height: 1px;
max-width: 600px; max-width: 350px;
min-width: 600px; min-width: 350px;
@media only screen and (max-width: 1200px) { @media only screen and (max-width: 1200px) {
min-width: 500px; min-width: 300px;
max-width: 500px; max-width: 350px;
}
@media only screen and (max-width: 1024px) {
min-width: 400px;
max-width: 400px;
} }
} }
@@ -45,20 +40,17 @@
&__left-section { &__left-section {
position: fixed; position: fixed;
background-color: #2f3d6f; background-color: #01115e;
overflow: auto; overflow: auto;
z-index: 1; z-index: 1;
height: 100%; height: 100%;
width: 100%; width: 100%;
left: 0; left: 0;
top: 0; top: 0;
width: 600px; width: 350px;
@media only screen and (max-width: 1200px) { @media only screen and (max-width: 1200px) {
width: 500px; width: 300px;
}
@media only screen and (max-width: 1024px) {
width: 400px;
} }
.content { .content {
@@ -71,29 +63,24 @@
height: 100%; height: 100%;
&__logo { &__logo {
opacity: 0.75; opacity: 0.65;
margin-bottom: 60px; margin-bottom: 60px;
padding-left: 10px; padding-left: 10px;
} }
&__title { &__title {
font-size: 50px; font-size: 26px;
font-weight: 100; font-weight: 600;
line-height: normal; line-height: normal;
margin-bottom: 14px; margin-bottom: 20px;
margin-top: 14px; margin-top: 14px;
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.75);
@media only screen and (max-width: 1024px) {
font-size: 45px;
}
} }
&__text { &__text {
font-size: 18px; font-size: 16px;
opacity: 0.75; opacity: 0.75;
margin-bottom: 10px; margin-bottom: 10px;
font-weight: 200;
} }
&__organization { &__organization {
@@ -109,21 +96,31 @@
} }
&__divider { &__divider {
height: 1px; height: 3px;
width: 60%; width: 100px;
background: rgba(255, 255, 255, 0.25); background: rgba(255, 255, 255, 0.15);
margin: 18px 0; margin: 10px 0;
} }
&__footer { &__footer {
margin-top: auto; margin-top: auto;
border-top: 1px solid rgba(255, 255, 255, 0.25); }
padding-top: 20px;
&__contact-info {
font-size: 16px;
margin-bottom: 20px;
opacity: 0.75;
padding-bottom: 5px;
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
p>span {
unicode-bidi: plaintext;
}
} }
&__links { &__links {
text-align: left; text-align: left;
opacity: 0.65; opacity: 0.55;
>div { >div {
font-size: 13px; font-size: 13px;
@@ -151,14 +148,12 @@
ul { ul {
display: flex; display: flex;
padding: 0 20px;
justify-content: center;
} }
li { li {
position: relative; position: relative;
list-style-type: none; list-style-type: none;
width: 33%; width: 25%;
text-align: center; text-align: center;
color: #333; color: #333;
font-size: 16px; font-size: 16px;