mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-11 02:10:30 +00:00
Compare commits
35 Commits
change-ban
...
date-forma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e6f1efe30 | ||
|
|
c43123db76 | ||
|
|
ebbcab3926 | ||
|
|
a235f573c0 | ||
|
|
84a0b8f495 | ||
|
|
b87321c897 | ||
|
|
fc6ebfea5c | ||
|
|
161d60393a | ||
|
|
79413fa85e | ||
|
|
58552c6c94 | ||
|
|
2072e35cfa | ||
|
|
1eaac9d691 | ||
|
|
a916e8a0cb | ||
|
|
a56f560036 | ||
|
|
0fb886936c | ||
|
|
670136916f | ||
|
|
768297f137 | ||
|
|
ef505a0a62 | ||
|
|
42d40620ec | ||
|
|
959ef7a691 | ||
|
|
60f03f534b | ||
|
|
e44ebb700a | ||
|
|
8e94c7a755 | ||
|
|
3a2ca36c07 | ||
|
|
88ece74c8a | ||
|
|
67a8610328 | ||
|
|
278d61ce61 | ||
|
|
e72d6ad6b8 | ||
|
|
bf66b31679 | ||
|
|
b4d426d2e8 | ||
|
|
7f7dd270e7 | ||
|
|
f6bad8fe30 | ||
|
|
1062b65b5b | ||
|
|
bf3a70cabd | ||
|
|
df8b68fda6 |
@@ -37,6 +37,7 @@
|
||||
"agendash": "^3.1.0",
|
||||
"app-root-path": "^3.0.0",
|
||||
"async": "^3.2.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"axios": "^1.6.0",
|
||||
"babel-loader": "^9.1.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -46,6 +46,10 @@ export class ExcludeBankTransactionsController extends BaseController {
|
||||
query('account_id').optional().isNumeric().toInt(),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('min_date').optional({ nullable: true }).isISO8601().toDate(),
|
||||
query('max_date').optional({ nullable: true }).isISO8601().toDate(),
|
||||
query('min_amount').optional({ nullable: true }).isFloat().toFloat(),
|
||||
query('max_amount').optional({ nullable: true }).isFloat().toFloat(),
|
||||
],
|
||||
this.validationResult,
|
||||
this.getExcludedBankTransactions.bind(this)
|
||||
|
||||
@@ -21,6 +21,10 @@ export class RecognizedTransactionsController extends BaseController {
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('account_id').optional().isNumeric().toInt(),
|
||||
query('min_date').optional({ nullable: true }).isISO8601().toDate(),
|
||||
query('max_date').optional({ nullable: true }).isISO8601().toDate(),
|
||||
query('min_amount').optional({ nullable: true }).isFloat().toFloat(),
|
||||
query('max_amount').optional({ nullable: true }).isFloat().isFloat(),
|
||||
],
|
||||
this.validationResult,
|
||||
this.getRecognizedTransactions.bind(this)
|
||||
|
||||
@@ -84,6 +84,10 @@ export default class NewCashflowTransactionController extends BaseController {
|
||||
param('id').exists().isNumeric().toInt(),
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('min_date').optional({ nullable: true }).isISO8601().toDate(),
|
||||
query('max_date').optional({ nullable: true }).isISO8601().toDate(),
|
||||
query('min_amount').optional({ nullable: true }).isFloat().toFloat(),
|
||||
query('max_amount').optional({ nullable: true }).isFloat().toFloat(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export class ImportController extends BaseController {
|
||||
body('mapping.*.group').optional(),
|
||||
body('mapping.*.from').exists(),
|
||||
body('mapping.*.to').exists(),
|
||||
body('mapping.*.dateFormat').optional({ nullable: true }),
|
||||
],
|
||||
this.validationResult,
|
||||
this.asyncMiddleware(this.mapping.bind(this)),
|
||||
|
||||
@@ -113,7 +113,7 @@ export class SubscriptionController extends BaseController {
|
||||
const { tenantId } = req;
|
||||
|
||||
try {
|
||||
await this.subscriptionApp.cancelSubscription(tenantId, '455610');
|
||||
await this.subscriptionApp.cancelSubscription(tenantId);
|
||||
|
||||
return res.status(200).send({
|
||||
status: 200,
|
||||
|
||||
@@ -35,7 +35,7 @@ export class Webhooks extends BaseController {
|
||||
*/
|
||||
public async lemonWebhooks(req: Request, res: Response, next: NextFunction) {
|
||||
const data = req.body;
|
||||
const signature = req.headers['x-signature'] ?? '';
|
||||
const signature = req.headers['x-signature'] as string ?? '';
|
||||
const rawBody = req.rawBody;
|
||||
|
||||
try {
|
||||
|
||||
@@ -246,8 +246,11 @@ module.exports = {
|
||||
apiKey: process.env.LOOPS_API_KEY,
|
||||
},
|
||||
|
||||
oneClickDemoAccounts: parseBoolean(
|
||||
process.env.ONE_CLICK_DEMO_ACCOUNTS,
|
||||
false
|
||||
),
|
||||
/**
|
||||
* One-click demo accounts.
|
||||
*/
|
||||
oneClickDemoAccounts: {
|
||||
enable: parseBoolean(process.env.ONE_CLICK_DEMO_ACCOUNTS, false),
|
||||
demoUrl: process.env.ONE_CLICK_DEMO_ACCOUNTS_URL || '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export default class SeedSettings extends TenantSeeder {
|
||||
|
||||
// Manual journals settings.
|
||||
{ group: 'manual_journals', key: 'next_number', value: '00001' },
|
||||
{ group: 'manual_journals', key: 'number_prefix', value: 'J-' },
|
||||
{ group: 'manual_journals', key: 'auto_increment', value: true },
|
||||
|
||||
// Sale invoices settings.
|
||||
|
||||
@@ -76,6 +76,10 @@ export interface IAuthSendedResetPassword {
|
||||
|
||||
export interface IAuthGetMetaPOJO {
|
||||
signupDisabled: boolean;
|
||||
oneClickDemo: {
|
||||
enable: boolean;
|
||||
demoUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IAuthSignUpVerifingEventPayload {
|
||||
|
||||
@@ -167,11 +167,18 @@ export interface CategorizeTransactionAsExpenseDTO {
|
||||
export interface IGetUncategorizedTransactionsQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
}
|
||||
|
||||
|
||||
export interface IGetRecognizedTransactionsQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
accountId?: number;
|
||||
}
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@ import { ImportFilePreviewPOJO } from "@/services/Import/interfaces";
|
||||
|
||||
export interface IImportFileCommitedEventPayload {
|
||||
tenantId: number;
|
||||
importId: number;
|
||||
importId: string;
|
||||
meta: ImportFilePreviewPOJO;
|
||||
}
|
||||
8
packages/server/src/interfaces/Subscription.ts
Normal file
8
packages/server/src/interfaces/Subscription.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SubscriptionPayload {
|
||||
lemonSqueezyId?: string;
|
||||
}
|
||||
|
||||
export enum SubscriptionPaymentStatus {
|
||||
Succeed = 'succeed',
|
||||
Failed = 'failed',
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export * from './Times';
|
||||
export * from './ProjectProfitabilitySummary';
|
||||
export * from './TaxRate';
|
||||
export * from './Plaid';
|
||||
export * from './Subscription';
|
||||
|
||||
export interface I18nService {
|
||||
__: (input: string) => string;
|
||||
|
||||
@@ -117,6 +117,7 @@ import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAcco
|
||||
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
||||
import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting';
|
||||
import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData';
|
||||
import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange';
|
||||
|
||||
export default () => {
|
||||
return new EventPublisher();
|
||||
@@ -247,8 +248,10 @@ export const susbcribers = () => {
|
||||
DeleteCashflowTransactionOnUncategorize,
|
||||
PreventDeleteTransactionOnDelete,
|
||||
|
||||
// Subscription
|
||||
SubscribeFreeOnSignupCommunity,
|
||||
SendVerfiyMailOnSignUp,
|
||||
TriggerInvalidateCacheOnSubscriptionChange,
|
||||
|
||||
// Attachments
|
||||
AttachmentsOnSaleInvoiceCreated,
|
||||
@@ -282,9 +285,9 @@ export const susbcribers = () => {
|
||||
DeleteUncategorizedTransactionsOnAccountDeleting,
|
||||
|
||||
// Loops
|
||||
LoopsEventsSubscriber
|
||||
LoopsEventsSubscriber,
|
||||
|
||||
// Demo Account
|
||||
SeedInitialDemoAccountDataOnOrgBuild
|
||||
SeedInitialDemoAccountDataOnOrgBuild,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* eslint-disable global-require */
|
||||
import moment from 'moment';
|
||||
import { Model, mixin } from 'objection';
|
||||
import TenantModel from 'models/TenantModel';
|
||||
import ModelSettings from './ModelSetting';
|
||||
import Account from './Account';
|
||||
import UncategorizedCashflowTransactionMeta from './UncategorizedCashflowTransaction.meta';
|
||||
|
||||
export default class UncategorizedCashflowTransaction extends mixin(
|
||||
@@ -166,6 +166,28 @@ export default class UncategorizedCashflowTransaction extends mixin(
|
||||
pending(query) {
|
||||
query.where('pending', true);
|
||||
},
|
||||
|
||||
minAmount(query, minAmount) {
|
||||
query.where('amount', '>=', minAmount);
|
||||
},
|
||||
|
||||
maxAmount(query, maxAmount) {
|
||||
query.where('amount', '<=', maxAmount);
|
||||
},
|
||||
|
||||
toDate(query, toDate) {
|
||||
const dateFormat = 'YYYY-MM-DD';
|
||||
const _toDate = moment(toDate).endOf('day').format(dateFormat);
|
||||
|
||||
query.where('date', '<=', _toDate);
|
||||
},
|
||||
|
||||
fromDate(query, fromDate) {
|
||||
const dateFormat = 'YYYY-MM-DD';
|
||||
const _fromDate = moment(fromDate).startOf('day').format(dateFormat);
|
||||
|
||||
query.where('date', '>=', _fromDate);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { AccountsApplication } from './AccountsApplication';
|
||||
import { Exportable } from '../Export/Exportable';
|
||||
import { IAccountsFilter, IAccountsStructureType } from '@/interfaces';
|
||||
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
|
||||
|
||||
@Service()
|
||||
export class AccountsExportable extends Exportable {
|
||||
@@ -20,7 +21,7 @@ export class AccountsExportable extends Exportable {
|
||||
inactiveMode: false,
|
||||
...query,
|
||||
structure: IAccountsStructureType.Flat,
|
||||
pageSize: 12000,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
page: 1,
|
||||
} as IAccountsFilter;
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ export class GetAuthMeta {
|
||||
public async getAuthMeta(): Promise<IAuthGetMetaPOJO> {
|
||||
return {
|
||||
signupDisabled: config.signupRestrictions.disabled,
|
||||
oneClickDemo: {
|
||||
enable: config.oneClickDemoAccounts.enable,
|
||||
demoUrl: config.oneClickDemoAccounts.demoUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import moment from 'moment';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { ExcludedBankTransactionsQuery } from './_types';
|
||||
import { UncategorizedTransactionTransformer } from '@/services/Cashflow/UncategorizedTransactionTransformer';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
@@ -39,6 +40,18 @@ export class GetExcludedBankTransactionsService {
|
||||
if (_query.accountId) {
|
||||
q.where('account_id', _query.accountId);
|
||||
}
|
||||
if (_query.minDate) {
|
||||
q.modify('fromDate', _query.minDate);
|
||||
}
|
||||
if (_query.maxDate) {
|
||||
q.modify('toDate', _query.maxDate);
|
||||
}
|
||||
if (_query.minAmount) {
|
||||
q.modify('minAmount', _query.minAmount);
|
||||
}
|
||||
if (_query.maxAmount) {
|
||||
q.modify('maxAmount', _query.maxAmount);
|
||||
}
|
||||
})
|
||||
.pagination(_query.page - 1, _query.pageSize);
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface ExcludedBankTransactionsQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
accountId?: number;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
}
|
||||
|
||||
export interface IBankTransactionUnexcludingEventPayload {
|
||||
|
||||
@@ -100,7 +100,6 @@ export class TriggerRecognizedTransactions {
|
||||
private async triggerRecognizeTransactionsOnImportCommitted({
|
||||
tenantId,
|
||||
importId,
|
||||
meta,
|
||||
}: IImportFileCommitedEventPayload) {
|
||||
const importFile = await Import.query().findOne({ importId });
|
||||
const batch = importFile.paramsParsed.batch;
|
||||
|
||||
@@ -23,7 +23,7 @@ export class GetRecognizedTransactionsService {
|
||||
) {
|
||||
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
|
||||
|
||||
const _filter = {
|
||||
const _query = {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
...filter,
|
||||
@@ -41,11 +41,26 @@ export class GetRecognizedTransactionsService {
|
||||
// Exclude the pending transactions.
|
||||
q.modify('notPending');
|
||||
|
||||
if (_filter.accountId) {
|
||||
q.where('accountId', _filter.accountId);
|
||||
if (_query.accountId) {
|
||||
q.where('accountId', _query.accountId);
|
||||
}
|
||||
if (_query.minDate) {
|
||||
q.modify('fromDate', _query.minDate);
|
||||
}
|
||||
if (_query.maxDate) {
|
||||
q.modify('toDate', _query.maxDate);
|
||||
}
|
||||
if (_query.minAmount) {
|
||||
q.modify('minAmount', _query.minAmount);
|
||||
}
|
||||
if (_query.maxAmount) {
|
||||
q.modify('maxAmount', _query.maxAmount);
|
||||
}
|
||||
if (_query.accountId) {
|
||||
q.where('accountId', _query.accountId);
|
||||
}
|
||||
})
|
||||
.pagination(_filter.page - 1, _filter.pageSize);
|
||||
.pagination(_query.page - 1, _query.pageSize);
|
||||
|
||||
const data = await this.transformer.transform(
|
||||
tenantId,
|
||||
|
||||
@@ -62,6 +62,19 @@ export class GetUncategorizedTransactions {
|
||||
|
||||
q.whereNull('matchedBankTransactions.id');
|
||||
q.orderBy('date', 'DESC');
|
||||
|
||||
if (_query.minDate) {
|
||||
q.modify('fromDate', _query.minDate);
|
||||
}
|
||||
if (_query.maxDate) {
|
||||
q.modify('toDate', _query.maxDate);
|
||||
}
|
||||
if (_query.minAmount) {
|
||||
q.modify('minAmount', _query.minAmount);
|
||||
}
|
||||
if (_query.maxAmount) {
|
||||
q.modify('maxAmount', _query.maxAmount);
|
||||
}
|
||||
})
|
||||
.pagination(_query.page - 1, _query.pageSize);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { IItemsFilter } from '@/interfaces';
|
||||
import { CustomersApplication } from './CustomersApplication';
|
||||
import { Exportable } from '@/services/Export/Exportable';
|
||||
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
|
||||
|
||||
@Service()
|
||||
export class CustomersExportable extends Exportable {
|
||||
@@ -17,9 +18,9 @@ export class CustomersExportable extends Exportable {
|
||||
const parsedQuery = {
|
||||
sortOrder: 'DESC',
|
||||
columnSortBy: 'created_at',
|
||||
page: 1,
|
||||
...query,
|
||||
pageSize: 12,
|
||||
page: 1,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as IItemsFilter;
|
||||
|
||||
return this.customersApplication
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { IItemsFilter } from '@/interfaces';
|
||||
import { Exportable } from '@/services/Export/Exportable';
|
||||
import { VendorsApplication } from './VendorsApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
|
||||
|
||||
@Service()
|
||||
export class VendorsExportable extends Exportable {
|
||||
@@ -17,9 +18,9 @@ export class VendorsExportable extends Exportable {
|
||||
const parsedQuery = {
|
||||
sortOrder: 'DESC',
|
||||
columnSortBy: 'created_at',
|
||||
page: 1,
|
||||
...query,
|
||||
pageSize: 12,
|
||||
page: 1,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as IItemsFilter;
|
||||
|
||||
return this.vendorsApplication
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { Exportable } from '../Export/Exportable';
|
||||
import { IExpensesFilter } from '@/interfaces';
|
||||
import { ExpensesApplication } from './ExpensesApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
|
||||
|
||||
@Service()
|
||||
export class ExpensesExportable extends Exportable {
|
||||
@@ -19,7 +20,7 @@ export class ExpensesExportable extends Exportable {
|
||||
columnSortBy: 'created_at',
|
||||
...query,
|
||||
page: 1,
|
||||
pageSize: 12000,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as IExpensesFilter;
|
||||
|
||||
return this.expensesApplication
|
||||
|
||||
1
packages/server/src/services/Export/constants.ts
Normal file
1
packages/server/src/services/Export/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const EXPORT_SIZE_LIMIT = 9999999;
|
||||
104
packages/server/src/services/Import/ImportALS.ts
Normal file
104
packages/server/src/services/Import/ImportALS.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Service } from 'typedi';
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
@Service()
|
||||
export class ImportAls {
|
||||
private als: AsyncLocalStorage<Map<string, any>>;
|
||||
|
||||
constructor() {
|
||||
this.als = new AsyncLocalStorage();
|
||||
}
|
||||
/**
|
||||
* Runs a callback function within the context of a new AsyncLocalStorage store.
|
||||
* @param callback The function to be executed within the AsyncLocalStorage context.
|
||||
* @returns The result of the callback function.
|
||||
*/
|
||||
public run<T>(callback: () => T): T {
|
||||
return this.als.run<T>(new Map(), callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a callback function in preview mode within the AsyncLocalStorage context.
|
||||
* @param callback The function to be executed in preview mode.
|
||||
* @returns The result of the callback function.
|
||||
*/
|
||||
public runPreview<T>(callback: () => T): T {
|
||||
return this.run(() => {
|
||||
this.markAsImport();
|
||||
this.markAsImportPreview();
|
||||
return callback();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a callback function in commit mode within the AsyncLocalStorage context.
|
||||
* @param {() => T} callback - The function to be executed in commit mode.
|
||||
* @returns {T} The result of the callback function.
|
||||
*/
|
||||
public runCommit<T>(callback: () => T): T {
|
||||
return this.run(() => {
|
||||
this.markAsImport();
|
||||
this.markAsImportCommit();
|
||||
return callback();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current AsyncLocalStorage store.
|
||||
* @returns The current store or undefined if not in a valid context.
|
||||
*/
|
||||
public getStore(): Map<string, any> | undefined {
|
||||
return this.als.getStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the current context as an import operation.
|
||||
* @param flag Boolean flag to set or unset the import status. Defaults to true.
|
||||
*/
|
||||
public markAsImport(flag: boolean = true): void {
|
||||
const store = this.getStore();
|
||||
store?.set('isImport', flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the current context as an import commit operation.
|
||||
* @param flag Boolean flag to set or unset the import commit status. Defaults to true.
|
||||
*/
|
||||
public markAsImportCommit(flag: boolean = true): void {
|
||||
const store = this.getStore();
|
||||
store?.set('isImportCommit', flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the current context as an import preview operation.
|
||||
* @param {boolean} flag - Boolean flag to set or unset the import preview status. Defaults to true.
|
||||
*/
|
||||
public markAsImportPreview(flag: boolean = true): void {
|
||||
const store = this.getStore();
|
||||
store?.set('isImportPreview', flag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current context is an import operation.
|
||||
* @returns {boolean} True if the context is an import operation, false otherwise.
|
||||
*/
|
||||
public isImport(): boolean {
|
||||
return !!this.getStore()?.get('isImport');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current context is an import commit operation.
|
||||
* @returns {boolean} True if the context is an import commit operation, false otherwise.
|
||||
*/
|
||||
public isImportCommit(): boolean {
|
||||
return !!this.getStore()?.get('isImportCommit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current context is an import preview operation.
|
||||
* @returns {boolean} True if the context is an import preview operation, false otherwise.
|
||||
*/
|
||||
public isImportPreview(): boolean {
|
||||
return !!this.getStore()?.get('isImportPreview');
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
ImportableContext,
|
||||
} from './interfaces';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { getUniqueImportableValue, trimObject } from './_utils';
|
||||
import {
|
||||
convertMappingsToObject,
|
||||
getUniqueImportableValue,
|
||||
trimObject,
|
||||
} from './_utils';
|
||||
import { ImportableResources } from './ImportableResources';
|
||||
import ResourceService from '../Resource/ResourceService';
|
||||
import { Import } from '@/system/models';
|
||||
@@ -43,7 +47,6 @@ export class ImportFileCommon {
|
||||
return XLSX.utils.sheet_to_json(worksheet, {});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Imports the given parsed data to the resource storage through registered importable service.
|
||||
* @param {number} tenantId -
|
||||
@@ -82,11 +85,14 @@ export class ImportFileCommon {
|
||||
rowNumber,
|
||||
uniqueValue,
|
||||
};
|
||||
const mappingSettings = convertMappingsToObject(importFile.columnsParsed);
|
||||
|
||||
try {
|
||||
// Validate the DTO object before passing it to the service layer.
|
||||
await this.importFileValidator.validateData(
|
||||
resourceFields,
|
||||
transformedDTO
|
||||
transformedDTO,
|
||||
mappingSettings
|
||||
);
|
||||
try {
|
||||
// Run the importable function and listen to the errors.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Service } from 'typedi';
|
||||
import { ImportInsertError, ResourceMetaFieldsMap } from './interfaces';
|
||||
import {
|
||||
ImportInsertError,
|
||||
ImportMappingAttr,
|
||||
ResourceMetaFieldsMap,
|
||||
} from './interfaces';
|
||||
import { ERRORS, convertFieldsToYupValidation } from './_utils';
|
||||
import { IModelMeta } from '@/interfaces';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
@@ -24,9 +28,13 @@ export class ImportFileDataValidator {
|
||||
*/
|
||||
public async validateData(
|
||||
importableFields: ResourceMetaFieldsMap,
|
||||
data: Record<string, any>
|
||||
data: Record<string, any>,
|
||||
mappingSettings: Record<string, ImportMappingAttr> = {}
|
||||
): Promise<void | ImportInsertError[]> {
|
||||
const YupSchema = convertFieldsToYupValidation(importableFields);
|
||||
const YupSchema = convertFieldsToYupValidation(
|
||||
importableFields,
|
||||
mappingSettings
|
||||
);
|
||||
const _data = { ...data };
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import HasTenancyService from '../Tenancy/TenancyService';
|
||||
import { ImportFilePreviewPOJO } from './interfaces';
|
||||
import { ImportFileProcess } from './ImportFileProcess';
|
||||
import { ImportAls } from './ImportALS';
|
||||
|
||||
@Service()
|
||||
export class ImportFilePreview {
|
||||
@@ -11,13 +12,31 @@ export class ImportFilePreview {
|
||||
@Inject()
|
||||
private importFile: ImportFileProcess;
|
||||
|
||||
@Inject()
|
||||
private importAls: ImportAls;
|
||||
|
||||
/**
|
||||
* Preview the imported file results before commiting the transactions.
|
||||
* @param {number} tenantId -
|
||||
* @param {string} importId -
|
||||
* @returns {Promise<ImportFilePreviewPOJO>}
|
||||
*/
|
||||
public async preview(
|
||||
tenantId: number,
|
||||
importId: string
|
||||
): Promise<ImportFilePreviewPOJO> {
|
||||
return this.importAls.runPreview<Promise<ImportFilePreviewPOJO>>(() =>
|
||||
this.previewAlsRun(tenantId, importId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the imported file results before commiting the transactions.
|
||||
* @param {number} tenantId
|
||||
* @param {number} importId
|
||||
* @returns {Promise<ImportFilePreviewPOJO>}
|
||||
*/
|
||||
public async preview(
|
||||
public async previewAlsRun(
|
||||
tenantId: number,
|
||||
importId: string
|
||||
): Promise<ImportFilePreviewPOJO> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ImportFileProcess } from './ImportFileProcess';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
import { IImportFileCommitedEventPayload } from '@/interfaces/Import';
|
||||
import { ImportAls } from './ImportALS';
|
||||
|
||||
@Service()
|
||||
export class ImportFileProcessCommit {
|
||||
@@ -14,16 +15,34 @@ export class ImportFileProcessCommit {
|
||||
@Inject()
|
||||
private importFile: ImportFileProcess;
|
||||
|
||||
@Inject()
|
||||
private importAls: ImportAls;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Commits the imported file under ALS.
|
||||
* @param {number} tenantId
|
||||
* @param {string} importId
|
||||
* @returns {Promise<ImportFilePreviewPOJO>}
|
||||
*/
|
||||
public commit(
|
||||
tenantId: number,
|
||||
importId: string
|
||||
): Promise<ImportFilePreviewPOJO> {
|
||||
return this.importAls.runCommit<Promise<ImportFilePreviewPOJO>>(() =>
|
||||
this.commitAlsRun(tenantId, importId)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits the imported file.
|
||||
* @param {number} tenantId
|
||||
* @param {number} importId
|
||||
* @returns {Promise<ImportFilePreviewPOJO>}
|
||||
*/
|
||||
public async commit(
|
||||
public async commitAlsRun(
|
||||
tenantId: number,
|
||||
importId: string
|
||||
): Promise<ImportFilePreviewPOJO> {
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
head,
|
||||
split,
|
||||
last,
|
||||
set,
|
||||
} from 'lodash';
|
||||
import pluralize from 'pluralize';
|
||||
import { ResourceMetaFieldsMap } from './interfaces';
|
||||
import { ImportMappingAttr, ResourceMetaFieldsMap } from './interfaces';
|
||||
import { IModelMetaField, IModelMetaField2 } from '@/interfaces';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { multiNumberParse } from '@/utils/multi-number-parse';
|
||||
@@ -58,11 +59,19 @@ export function trimObject(obj: Record<string, string | number>) {
|
||||
* @param {ResourceMetaFieldsMap} fields
|
||||
* @returns {Yup}
|
||||
*/
|
||||
export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
|
||||
export const convertFieldsToYupValidation = (
|
||||
fields: ResourceMetaFieldsMap,
|
||||
parentFieldName: string = '',
|
||||
mappingSettings: Record<string, ImportMappingAttr> = {}
|
||||
) => {
|
||||
const yupSchema = {};
|
||||
|
||||
Object.keys(fields).forEach((fieldName: string) => {
|
||||
const field = fields[fieldName] as IModelMetaField;
|
||||
const fieldPath = parentFieldName
|
||||
? `${parentFieldName}.${fieldName}`
|
||||
: fieldName;
|
||||
|
||||
let fieldSchema;
|
||||
fieldSchema = Yup.string().label(field.name);
|
||||
|
||||
@@ -105,13 +114,23 @@ export const convertFieldsToYupValidation = (fields: ResourceMetaFieldsMap) => {
|
||||
if (!val) {
|
||||
return true;
|
||||
}
|
||||
return moment(val, 'YYYY-MM-DD', true).isValid();
|
||||
const fieldDateFormat =
|
||||
(get(
|
||||
mappingSettings,
|
||||
`${fieldPath}.dateFormat`
|
||||
) as unknown as string) || 'YYYY-MM-DD';
|
||||
|
||||
return moment(val, fieldDateFormat, true).isValid();
|
||||
}
|
||||
);
|
||||
} else if (field.fieldType === 'url') {
|
||||
fieldSchema = fieldSchema.url();
|
||||
} else if (field.fieldType === 'collection') {
|
||||
const nestedFieldShema = convertFieldsToYupValidation(field.fields);
|
||||
const nestedFieldShema = convertFieldsToYupValidation(
|
||||
field.fields,
|
||||
field.name,
|
||||
mappingSettings
|
||||
);
|
||||
fieldSchema = Yup.array().label(field.name);
|
||||
|
||||
if (!isUndefined(field.collectionMaxLength)) {
|
||||
@@ -258,6 +277,7 @@ export const getResourceColumns = (resourceColumns: {
|
||||
]) => {
|
||||
const extra: Record<string, any> = {};
|
||||
const key = fieldKey;
|
||||
const type = field.fieldType;
|
||||
|
||||
if (group) {
|
||||
extra.group = group;
|
||||
@@ -270,6 +290,7 @@ export const getResourceColumns = (resourceColumns: {
|
||||
name,
|
||||
required,
|
||||
hint: importHint,
|
||||
type,
|
||||
order,
|
||||
...extra,
|
||||
};
|
||||
@@ -322,6 +343,8 @@ export const valueParser =
|
||||
});
|
||||
const result = await relationQuery.first();
|
||||
_value = get(result, 'id');
|
||||
} else if (field.fieldType === 'date') {
|
||||
|
||||
} else if (field.fieldType === 'collection') {
|
||||
const ObjectFieldKey = key.includes('.') ? key.split('.')[1] : key;
|
||||
const _valueParser = valueParser(fields, tenantModels);
|
||||
@@ -433,8 +456,8 @@ export const getMapToPath = (to: string, group = '') =>
|
||||
group ? `${group}.${to}` : to;
|
||||
|
||||
export const getImportsStoragePath = () => {
|
||||
return path.join(global.__storage_dir, `/imports`);
|
||||
}
|
||||
return path.join(global.__storage_dir, `/imports`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes the imported file from the storage and database.
|
||||
@@ -457,3 +480,19 @@ export const readImportFile = (filename: string) => {
|
||||
|
||||
return fs.readFile(`${filePath}/${filename}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an array of mapping objects to a structured object.
|
||||
* @param {Array<Object>} mappings - Array of mapping objects.
|
||||
* @returns {Object} - Structured object based on the mappings.
|
||||
*/
|
||||
export const convertMappingsToObject = (mappings) => {
|
||||
return mappings.reduce((acc, mapping) => {
|
||||
const { to, group } = mapping;
|
||||
const key = group ? `['${group}.${to}']` : to;
|
||||
|
||||
set(acc, key, mapping);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
@@ -139,24 +139,25 @@ export default class InventoryService {
|
||||
) {
|
||||
const agenda = Container.get('agenda');
|
||||
|
||||
const commonJobsQuery = {
|
||||
name: 'compute-item-cost',
|
||||
lastRunAt: { $exists: false },
|
||||
'data.tenantId': tenantId,
|
||||
'data.itemId': itemId,
|
||||
};
|
||||
// Cancel any `compute-item-cost` in the queue has upper starting date
|
||||
// with the same given item.
|
||||
await agenda.cancel({
|
||||
name: 'compute-item-cost',
|
||||
nextRunAt: { $ne: null },
|
||||
'data.tenantId': tenantId,
|
||||
'data.itemId': itemId,
|
||||
'data.startingDate': { $gt: startingDate },
|
||||
...commonJobsQuery,
|
||||
'data.startingDate': { $lte: startingDate },
|
||||
});
|
||||
// Retrieve any `compute-item-cost` in the queue has lower starting date
|
||||
// with the same given item.
|
||||
const dependsJobs = await agenda.jobs({
|
||||
name: 'compute-item-cost',
|
||||
nextRunAt: { $ne: null },
|
||||
'data.tenantId': tenantId,
|
||||
'data.itemId': itemId,
|
||||
'data.startingDate': { $lte: startingDate },
|
||||
...commonJobsQuery,
|
||||
'data.startingDate': { $gte: startingDate },
|
||||
});
|
||||
// If the depends jobs cleared.
|
||||
if (dependsJobs.length === 0) {
|
||||
await agenda.schedule(
|
||||
config.scheduleComputeItemCost,
|
||||
@@ -172,6 +173,13 @@ export default class InventoryService {
|
||||
events.inventory.onComputeItemCostJobScheduled,
|
||||
{ startingDate, itemId, tenantId } as IInventoryItemCostScheduledPayload
|
||||
);
|
||||
} else {
|
||||
// Re-schedule the jobs that have higher date from current moment.
|
||||
await Promise.all(
|
||||
dependsJobs.map((job) =>
|
||||
job.schedule(config.scheduleComputeItemCost).save()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { Exportable } from '../Export/Exportable';
|
||||
import { IItemsFilter } from '@/interfaces';
|
||||
import { ItemsApplication } from './ItemsApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
|
||||
|
||||
@Service()
|
||||
export class ItemsExportable extends Exportable {
|
||||
@@ -19,7 +20,7 @@ export class ItemsExportable extends Exportable {
|
||||
columnSortBy: 'created_at',
|
||||
page: 1,
|
||||
...query,
|
||||
pageSize: 12,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as IItemsFilter;
|
||||
|
||||
return this.itemsApplication
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { IManualJournalsFilter } from '@/interfaces';
|
||||
import { Exportable } from '../Export/Exportable';
|
||||
import { ManualJournalsApplication } from './ManualJournalsApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
|
||||
|
||||
@Service()
|
||||
export class ManualJournalsExportable extends Exportable {
|
||||
@@ -19,7 +20,7 @@ export class ManualJournalsExportable extends Exportable {
|
||||
columnSortBy: 'created_at',
|
||||
...query,
|
||||
page: 1,
|
||||
pageSize: 12000,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as IManualJournalsFilter;
|
||||
|
||||
return this.manualJournalsApplication
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { Exportable } from '@/services/Export/Exportable';
|
||||
import { BillPaymentsApplication } from './BillPaymentsApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
|
||||
|
||||
@Service()
|
||||
export class BillPaymentExportable extends Exportable {
|
||||
@@ -14,11 +15,11 @@ export class BillPaymentExportable extends Exportable {
|
||||
*/
|
||||
public exportable(tenantId: number, query: any) {
|
||||
const parsedQuery = {
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
...query,
|
||||
sortOrder: 'desc',
|
||||
columnSortBy: 'created_at',
|
||||
...query,
|
||||
page: 1,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as any;
|
||||
|
||||
return this.billPaymentsApplication
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { IBillsFilter } from '@/interfaces';
|
||||
import { Exportable } from '@/services/Export/Exportable';
|
||||
import { BillsApplication } from './BillsApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
|
||||
|
||||
@Service()
|
||||
export class BillsExportable extends Exportable {
|
||||
@@ -19,7 +20,7 @@ export class BillsExportable extends Exportable {
|
||||
columnSortBy: 'created_at',
|
||||
...query,
|
||||
page: 1,
|
||||
pageSize: 12000,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as IBillsFilter;
|
||||
|
||||
return this.billsApplication
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { ISalesInvoicesFilter } from '@/interfaces';
|
||||
import { Exportable } from '@/services/Export/Exportable';
|
||||
import { SaleEstimatesApplication } from './SaleEstimatesApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
|
||||
|
||||
@Service()
|
||||
export class SaleEstimatesExportable extends Exportable {
|
||||
@@ -19,7 +20,7 @@ export class SaleEstimatesExportable extends Exportable {
|
||||
columnSortBy: 'created_at',
|
||||
...query,
|
||||
page: 1,
|
||||
pageSize: 12000,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as ISalesInvoicesFilter;
|
||||
|
||||
return this.saleEstimatesApplication
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { ISalesInvoicesFilter } from '@/interfaces';
|
||||
import { SaleInvoiceApplication } from './SaleInvoicesApplication';
|
||||
import { Exportable } from '@/services/Export/Exportable';
|
||||
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
|
||||
|
||||
@Service()
|
||||
export class SaleInvoicesExportable extends Exportable {
|
||||
@@ -19,7 +20,7 @@ export class SaleInvoicesExportable extends Exportable {
|
||||
columnSortBy: 'created_at',
|
||||
...query,
|
||||
page: 1,
|
||||
pageSize: 120000,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as ISalesInvoicesFilter;
|
||||
|
||||
return this.saleInvoicesApplication
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { Container, Service, Inject } from 'typedi';
|
||||
import { chain } from 'lodash';
|
||||
import moment from 'moment';
|
||||
@@ -34,17 +35,26 @@ export class SaleInvoicesCost {
|
||||
inventoryItemsIds: number[],
|
||||
startingDate: Date
|
||||
): Promise<void> {
|
||||
const asyncOpers: Promise<[]>[] = [];
|
||||
const mutex = new Mutex();
|
||||
|
||||
inventoryItemsIds.forEach((inventoryItemId: number) => {
|
||||
const oper: Promise<[]> = this.inventoryService.scheduleComputeItemCost(
|
||||
tenantId,
|
||||
inventoryItemId,
|
||||
startingDate
|
||||
);
|
||||
asyncOpers.push(oper);
|
||||
});
|
||||
await Promise.all([...asyncOpers]);
|
||||
const asyncOpers = inventoryItemsIds.map(
|
||||
async (inventoryItemId: number) => {
|
||||
// @todo refactor the lock acquire to be distrbuted using Redis
|
||||
// and run the cost schedule job after running invoice transaction.
|
||||
const release = await mutex.acquire();
|
||||
|
||||
try {
|
||||
await this.inventoryService.scheduleComputeItemCost(
|
||||
tenantId,
|
||||
inventoryItemId,
|
||||
startingDate
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
);
|
||||
await Promise.all(asyncOpers);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,17 +96,22 @@ export class SaleInvoicesCost {
|
||||
tenantId: number,
|
||||
inventoryTransactions: IInventoryTransaction[]
|
||||
) {
|
||||
const asyncOpers: Promise<[]>[] = [];
|
||||
const mutex = new Mutex();
|
||||
const reducedTransactions = this.getMaxDateInventoryTransactions(
|
||||
inventoryTransactions
|
||||
);
|
||||
reducedTransactions.forEach((transaction) => {
|
||||
const oper: Promise<[]> = this.inventoryService.scheduleComputeItemCost(
|
||||
tenantId,
|
||||
transaction.itemId,
|
||||
transaction.date
|
||||
);
|
||||
asyncOpers.push(oper);
|
||||
const asyncOpers = reducedTransactions.map(async (transaction) => {
|
||||
const release = await mutex.acquire();
|
||||
|
||||
try {
|
||||
await this.inventoryService.scheduleComputeItemCost(
|
||||
tenantId,
|
||||
transaction.itemId,
|
||||
transaction.date
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
await Promise.all([...asyncOpers]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { IAccountsStructureType, IPaymentsReceivedFilter } from '@/interfaces';
|
||||
import { Exportable } from '@/services/Export/Exportable';
|
||||
import { PaymentReceivesApplication } from './PaymentReceivedApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
|
||||
|
||||
@Service()
|
||||
export class PaymentsReceivedExportable extends Exportable {
|
||||
@@ -21,6 +22,8 @@ export class PaymentsReceivedExportable extends Exportable {
|
||||
inactiveMode: false,
|
||||
...query,
|
||||
structure: IAccountsStructureType.Flat,
|
||||
page: 1,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as IPaymentsReceivedFilter;
|
||||
|
||||
return this.paymentReceivedApp
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
|
||||
import { ISalesReceiptsFilter } from '@/interfaces';
|
||||
import { Exportable } from '@/services/Export/Exportable';
|
||||
import { SaleReceiptApplication } from './SaleReceiptApplication';
|
||||
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
|
||||
|
||||
@Service()
|
||||
export class SaleReceiptsExportable extends Exportable {
|
||||
@@ -19,7 +20,7 @@ export class SaleReceiptsExportable extends Exportable {
|
||||
columnSortBy: 'created_at',
|
||||
...query,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
pageSize: EXPORT_SIZE_LIMIT,
|
||||
} as ISalesReceiptsFilter;
|
||||
|
||||
return this.saleReceiptsApp
|
||||
|
||||
@@ -4,8 +4,8 @@ import { configureLemonSqueezy } from './utils';
|
||||
import { PlanSubscription } from '@/system/models';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import { ERRORS, IOrganizationSubscriptionCancel } from './types';
|
||||
import events from '@/subscribers/events';
|
||||
import { ERRORS, IOrganizationSubscriptionCanceled } from './types';
|
||||
|
||||
@Service()
|
||||
export class LemonCancelSubscription {
|
||||
@@ -18,12 +18,15 @@ export class LemonCancelSubscription {
|
||||
* @param {number} subscriptionId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async cancelSubscription(tenantId: number) {
|
||||
public async cancelSubscription(
|
||||
tenantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
) {
|
||||
configureLemonSqueezy();
|
||||
|
||||
const subscription = await PlanSubscription.query().findOne({
|
||||
tenantId,
|
||||
slug: 'main',
|
||||
slug: subscriptionSlug,
|
||||
});
|
||||
if (!subscription) {
|
||||
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
|
||||
@@ -35,13 +38,10 @@ export class LemonCancelSubscription {
|
||||
if (cancelledSub.error) {
|
||||
throw new Error(cancelledSub.error.message);
|
||||
}
|
||||
await PlanSubscription.query().findById(subscriptionId).patch({
|
||||
canceledAt: new Date(),
|
||||
});
|
||||
// Triggers `onSubscriptionCanceled` event.
|
||||
// Triggers `onSubscriptionCancelled` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.subscription.onSubscriptionCanceled,
|
||||
{ tenantId, subscriptionId } as IOrganizationSubscriptionCanceled
|
||||
events.subscription.onSubscriptionCancel,
|
||||
{ tenantId, subscriptionId } as IOrganizationSubscriptionCancel
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,25 +18,30 @@ export class LemonChangeSubscriptionPlan {
|
||||
* @param {number} newVariantId - New variant id.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async changeSubscriptionPlan(tenantId: number, newVariantId: number) {
|
||||
public async changeSubscriptionPlan(
|
||||
tenantId: number,
|
||||
newVariantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
) {
|
||||
configureLemonSqueezy();
|
||||
|
||||
const subscription = await PlanSubscription.query().findOne({
|
||||
tenantId,
|
||||
slug: 'main',
|
||||
slug: subscriptionSlug,
|
||||
});
|
||||
const lemonSubscriptionId = subscription.lemonSubscriptionId;
|
||||
|
||||
// Send request to Lemon Squeezy to change the subscription.
|
||||
const updatedSub = await updateSubscription(lemonSubscriptionId, {
|
||||
variantId: newVariantId,
|
||||
invoiceImmediately: true,
|
||||
});
|
||||
if (updatedSub.error) {
|
||||
throw new ServiceError('SOMETHING_WENT_WRONG');
|
||||
}
|
||||
// Triggers `onSubscriptionPlanChanged` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.subscription.onSubscriptionPlanChanged,
|
||||
events.subscription.onSubscriptionPlanChange,
|
||||
{
|
||||
tenantId,
|
||||
lemonSubscriptionId,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
import { configureLemonSqueezy } from './utils';
|
||||
import { PlanSubscription } from '@/system/models';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { ERRORS, IOrganizationSubscriptionResumed } from './types';
|
||||
import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { ERRORS, IOrganizationSubscriptionResume } from './types';
|
||||
|
||||
@Service()
|
||||
export class LemonResumeSubscription {
|
||||
@@ -14,15 +14,19 @@ export class LemonResumeSubscription {
|
||||
|
||||
/**
|
||||
* Resumes the main subscription of the given tenant.
|
||||
* @param {number} tenantId -
|
||||
* @param {number} tenantId - Tenant id.
|
||||
* @param {string} subscriptionSlug - Subscription slug by default main subscription.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public async resumeSubscription(tenantId: number) {
|
||||
public async resumeSubscription(
|
||||
tenantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
) {
|
||||
configureLemonSqueezy();
|
||||
|
||||
const subscription = await PlanSubscription.query().findOne({
|
||||
tenantId,
|
||||
slug: 'main',
|
||||
slug: subscriptionSlug,
|
||||
});
|
||||
if (!subscription) {
|
||||
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
|
||||
@@ -33,16 +37,12 @@ export class LemonResumeSubscription {
|
||||
cancelled: false,
|
||||
});
|
||||
if (returnedSub.error) {
|
||||
throw new ServiceError('');
|
||||
throw new ServiceError(ERRORS.SOMETHING_WENT_WRONG_WITH_LS);
|
||||
}
|
||||
// Update the subscription of the organization.
|
||||
await PlanSubscription.query().findById(subscriptionId).patch({
|
||||
canceledAt: null,
|
||||
});
|
||||
// Triggers `onSubscriptionCanceled` event.
|
||||
// Triggers `onSubscriptionResume` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.subscription.onSubscriptionResumed,
|
||||
{ tenantId, subscriptionId } as IOrganizationSubscriptionResumed
|
||||
events.subscription.onSubscriptionResume,
|
||||
{ tenantId, subscriptionId } as IOrganizationSubscriptionResume
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,11 +59,25 @@ export class LemonSqueezyWebhooks {
|
||||
|
||||
const userId = eventBody.meta.custom_data?.user_id;
|
||||
const tenantId = eventBody.meta.custom_data?.tenant_id;
|
||||
const subscriptionSlug = 'main';
|
||||
|
||||
if (!webhookHasMeta(eventBody)) {
|
||||
throw new Error("Event body is missing the 'meta' property.");
|
||||
} else if (webhookHasData(eventBody)) {
|
||||
if (webhookEvent.startsWith('subscription_payment_')) {
|
||||
// Marks the main subscription payment as succeed.
|
||||
if (webhookEvent === 'subscription_payment_success') {
|
||||
await this.subscriptionService.markSubscriptionPaymentSucceed(
|
||||
tenantId,
|
||||
subscriptionSlug
|
||||
);
|
||||
// Marks the main subscription payment as failed.
|
||||
} else if (webhookEvent === 'subscription_payment_failed') {
|
||||
await this.subscriptionService.markSubscriptionPaymentFailed(
|
||||
tenantId,
|
||||
subscriptionSlug
|
||||
);
|
||||
}
|
||||
// Save subscription invoices; eventBody is a SubscriptionInvoice
|
||||
// Not implemented.
|
||||
} else if (webhookEvent.startsWith('subscription_')) {
|
||||
@@ -74,16 +88,39 @@ export class LemonSqueezyWebhooks {
|
||||
// We assume that the Plan table is up to date.
|
||||
const plan = await Plan.query().findOne('lemonVariantId', variantId);
|
||||
|
||||
// Update the subscription in the database.
|
||||
const priceId = attributes.first_subscription_item.price_id;
|
||||
const subscriptionId = eventBody.data.id;
|
||||
|
||||
// Throw error early if the given lemon variant id is not associated to any plan.
|
||||
if (!plan) {
|
||||
throw new Error(`Plan with variantId ${variantId} not found.`);
|
||||
} else {
|
||||
// Update the subscription in the database.
|
||||
const priceId = attributes.first_subscription_item.price_id;
|
||||
|
||||
// Create a new subscription of the tenant.
|
||||
if (webhookEvent === 'subscription_created') {
|
||||
await this.subscriptionService.newSubscribtion(tenantId, plan.slug);
|
||||
}
|
||||
}
|
||||
// Create a new subscription of the tenant.
|
||||
if (webhookEvent === 'subscription_created') {
|
||||
await this.subscriptionService.newSubscribtion(
|
||||
tenantId,
|
||||
plan.slug,
|
||||
subscriptionSlug,
|
||||
{ lemonSqueezyId: subscriptionId }
|
||||
);
|
||||
// Cancel the given subscription of the organization.
|
||||
} else if (webhookEvent === 'subscription_cancelled') {
|
||||
await this.subscriptionService.cancelSubscription(
|
||||
tenantId,
|
||||
subscriptionSlug
|
||||
);
|
||||
} else if (webhookEvent === 'subscription_plan_changed') {
|
||||
await this.subscriptionService.subscriptionPlanChanged(
|
||||
tenantId,
|
||||
plan.slug,
|
||||
subscriptionSlug
|
||||
);
|
||||
} else if (webhookEvent === 'subscription_resumed') {
|
||||
await this.subscriptionService.resumeSubscription(
|
||||
tenantId,
|
||||
subscriptionSlug
|
||||
);
|
||||
}
|
||||
} else if (webhookEvent.startsWith('order_')) {
|
||||
// Save orders; eventBody is a "Order"
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { Service } from 'typedi';
|
||||
import { NotAllowedChangeSubscriptionPlan } from '@/exceptions';
|
||||
import { Plan, Tenant } from '@/system/models';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { NotAllowedChangeSubscriptionPlan, ServiceError } from '@/exceptions';
|
||||
import { Plan, PlanSubscription, Tenant } from '@/system/models';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
import { SubscriptionPayload, SubscriptionPaymentStatus } from '@/interfaces';
|
||||
import { ERRORS } from './types';
|
||||
|
||||
@Service()
|
||||
export class Subscription {
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Give the tenant a new subscription.
|
||||
* @param {number} tenantId - Tenant id.
|
||||
* @param {string} planSlug - Plan slug.
|
||||
* @param {string} invoiceInterval
|
||||
* @param {number} invoicePeriod
|
||||
* @param {string} subscriptionSlug
|
||||
* @param {string} planSlug - Plan slug of the new subscription.
|
||||
* @param {string} subscriptionSlug - Subscription slug by default takes main subscription
|
||||
* @param {SubscriptionPayload} payload - Subscription payload.
|
||||
*/
|
||||
public async newSubscribtion(
|
||||
tenantId: number,
|
||||
planSlug: string,
|
||||
subscriptionSlug: string = 'main'
|
||||
) {
|
||||
subscriptionSlug: string = 'main',
|
||||
payload?: SubscriptionPayload
|
||||
): Promise<void> {
|
||||
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||
const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound();
|
||||
|
||||
@@ -45,8 +52,169 @@ export class Subscription {
|
||||
plan.id,
|
||||
invoiceInterval,
|
||||
invoicePeriod,
|
||||
subscriptionSlug
|
||||
subscriptionSlug,
|
||||
payload
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the given tenant subscription.
|
||||
* @param {number} tenantId - Tenant id.
|
||||
* @param {string} subscriptionSlug - Subscription slug.
|
||||
*/
|
||||
async cancelSubscription(
|
||||
tenantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
): Promise<void> {
|
||||
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||
|
||||
const subscription = await PlanSubscription.query().findOne({
|
||||
tenantId,
|
||||
slug: subscriptionSlug,
|
||||
});
|
||||
// Throw error early if the subscription is not exist.
|
||||
if (!subscription) {
|
||||
throw new ServiceError(ERRORS.SUBSCRIPTION_NOT_EXIST);
|
||||
}
|
||||
// Throw error early if the subscription is already canceled.
|
||||
if (subscription.canceled()) {
|
||||
throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_CANCELED);
|
||||
}
|
||||
await subscription.$query().patch({ canceledAt: new Date() });
|
||||
|
||||
// Triggers `onSubscriptionCancelled` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.subscription.onSubscriptionCancelled,
|
||||
{
|
||||
tenantId,
|
||||
subscriptionSlug,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the given tenant subscription.
|
||||
* @param {number} tenantId
|
||||
* @param {string} subscriptionSlug - Subscription slug by deafult main subscription.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async resumeSubscription(
|
||||
tenantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
) {
|
||||
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||
|
||||
const subscription = await PlanSubscription.query().findOne({
|
||||
tenantId,
|
||||
slug: subscriptionSlug,
|
||||
});
|
||||
// Throw error early if the subscription is not exist.
|
||||
if (!subscription) {
|
||||
throw new ServiceError(ERRORS.SUBSCRIPTION_NOT_EXIST);
|
||||
}
|
||||
// Throw error early if the subscription is not cancelled.
|
||||
if (!subscription.canceled()) {
|
||||
throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_ACTIVE);
|
||||
}
|
||||
await subscription.$query().patch({ canceledAt: null });
|
||||
|
||||
// Triggers `onSubscriptionResumed` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.subscription.onSubscriptionResumed,
|
||||
{ tenantId, subscriptionSlug }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given subscription payment of the tenant as succeed.
|
||||
* @param {number} tenantId
|
||||
* @param {string} newPlanSlug
|
||||
* @param {string} subscriptionSlug
|
||||
*/
|
||||
async subscriptionPlanChanged(
|
||||
tenantId: number,
|
||||
newPlanSlug: string,
|
||||
subscriptionSlug: string = 'main'
|
||||
): Promise<void> {
|
||||
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
|
||||
const newPlan = await Plan.query()
|
||||
.findOne('slug', newPlanSlug)
|
||||
.throwIfNotFound();
|
||||
|
||||
const subscription = await PlanSubscription.query().findOne({
|
||||
tenantId,
|
||||
slug: subscriptionSlug,
|
||||
});
|
||||
if (subscription.planId === newPlan.id) {
|
||||
throw new ServiceError('');
|
||||
}
|
||||
await subscription.$query().patch({ planId: newPlan.id });
|
||||
|
||||
// Triggers `onSubscriptionPlanChanged` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.subscription.onSubscriptionPlanChanged,
|
||||
{
|
||||
tenantId,
|
||||
newPlanSlug,
|
||||
subscriptionSlug,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the subscription payment as succeed.
|
||||
* @param {number} tenantId - Tenant id.
|
||||
* @param {string} subscriptionSlug - Given subscription slug by default main subscription.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async markSubscriptionPaymentSucceed(
|
||||
tenantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
): Promise<void> {
|
||||
const subscription = await PlanSubscription.query()
|
||||
.findOne({ tenantId, slug: subscriptionSlug })
|
||||
.throwIfNotFound();
|
||||
|
||||
await subscription
|
||||
.$query()
|
||||
.patch({ paymentStatus: SubscriptionPaymentStatus.Succeed });
|
||||
|
||||
// Triggers `onSubscriptionSucceed` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.subscription.onSubscriptionPaymentSucceed,
|
||||
{
|
||||
tenantId,
|
||||
subscriptionSlug,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the given subscription payment of the tenant as failed.
|
||||
* @param {number} tenantId - Tenant id.
|
||||
* @param {string} subscriptionSlug - Given subscription slug.
|
||||
* @returns {Prmise<void>}
|
||||
*/
|
||||
async markSubscriptionPaymentFailed(
|
||||
tenantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
): Promise<void> {
|
||||
const subscription = await PlanSubscription.query()
|
||||
.findOne({ tenantId, slug: subscriptionSlug })
|
||||
.throwIfNotFound();
|
||||
|
||||
await subscription
|
||||
.$query()
|
||||
.patch({ paymentStatus: SubscriptionPaymentStatus.Failed });
|
||||
|
||||
// Triggers `onSubscriptionPaymentFailed` event.
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.subscription.onSubscriptionPaymentFailed,
|
||||
{
|
||||
tenantId,
|
||||
subscriptionSlug,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,14 @@ export class SubscriptionApplication {
|
||||
* @param {string} id
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public cancelSubscription(tenantId: number, id: string) {
|
||||
return this.cancelSubscriptionService.cancelSubscription(tenantId, id);
|
||||
public cancelSubscription(
|
||||
tenantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
) {
|
||||
return this.cancelSubscriptionService.cancelSubscription(
|
||||
tenantId,
|
||||
subscriptionSlug
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,8 +35,14 @@ export class SubscriptionApplication {
|
||||
* @param {number} tenantId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public resumeSubscription(tenantId: number) {
|
||||
return this.resumeSubscriptionService.resumeSubscription(tenantId);
|
||||
public resumeSubscription(
|
||||
tenantId: number,
|
||||
subscriptionSlug: string = 'main'
|
||||
) {
|
||||
return this.resumeSubscriptionService.resumeSubscription(
|
||||
tenantId,
|
||||
subscriptionSlug
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { IAuthSignedUpEventPayload } from '@/interfaces';
|
||||
import events from '@/subscribers/events';
|
||||
import config from '@/config';
|
||||
import { Subscription } from '../Subscription';
|
||||
import { Inject, Service } from 'typedi';
|
||||
|
||||
@Service()
|
||||
export class SubscribeFreeOnSignupCommunity {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import events from '@/subscribers/events';
|
||||
import Container from 'typedi';
|
||||
|
||||
export class TriggerInvalidateCacheOnSubscriptionChange {
|
||||
/**
|
||||
* Attaches events with handlers.
|
||||
*/
|
||||
public attach = (bus) => {
|
||||
bus.subscribe(
|
||||
events.subscription.onSubscriptionCancelled,
|
||||
this.triggerInvalidateCache.bind(this)
|
||||
);
|
||||
bus.subscribe(
|
||||
events.subscription.onSubscriptionResumed,
|
||||
this.triggerInvalidateCache.bind(this)
|
||||
);
|
||||
bus.subscribe(
|
||||
events.subscription.onSubscriptionPlanChanged,
|
||||
this.triggerInvalidateCache.bind(this)
|
||||
);
|
||||
};
|
||||
|
||||
private triggerInvalidateCache() {
|
||||
const io = Container.get('socket');
|
||||
|
||||
// Notify the frontend to reflect the new transactions changes.
|
||||
io.emit('SUBSCRIPTION_CHANGED', { subscriptionSlug: 'main' });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
export const ERRORS = {
|
||||
SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT:
|
||||
'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT',
|
||||
SUBSCRIPTION_NOT_EXIST: 'SUBSCRIPTION_NOT_EXIST',
|
||||
SUBSCRIPTION_ALREADY_CANCELED: 'SUBSCRIPTION_ALREADY_CANCELED',
|
||||
SUBSCRIPTION_ALREADY_ACTIVE: 'SUBSCRIPTION_ALREADY_ACTIVE',
|
||||
SOMETHING_WENT_WRONG_WITH_LS: 'SOMETHING_WENT_WRONG_WITH_LS',
|
||||
};
|
||||
|
||||
export interface IOrganizationSubscriptionChanged {
|
||||
@@ -9,11 +13,20 @@ export interface IOrganizationSubscriptionChanged {
|
||||
newVariantId: number;
|
||||
}
|
||||
|
||||
export interface IOrganizationSubscriptionCanceled {
|
||||
export interface IOrganizationSubscriptionCancel {
|
||||
tenantId: number;
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
export interface IOrganizationSubscriptionCancelled {
|
||||
tenantId: number;
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
export interface IOrganizationSubscriptionResume {
|
||||
tenantId: number;
|
||||
subscriptionId: number;
|
||||
}
|
||||
export interface IOrganizationSubscriptionResumed {
|
||||
tenantId: number;
|
||||
subscriptionId: number;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/interfaces';
|
||||
import { runAfterTransaction } from '@/services/UnitOfWork/TransactionsHooks';
|
||||
import { SaleInvoicesCost } from '@/services/Sales/Invoices/SalesInvoicesCost';
|
||||
import { ImportAls } from '@/services/Import/ImportALS';
|
||||
|
||||
@Service()
|
||||
export default class InventorySubscriber {
|
||||
@@ -25,6 +26,9 @@ export default class InventorySubscriber {
|
||||
@Inject('agenda')
|
||||
private agenda: any;
|
||||
|
||||
@Inject()
|
||||
private importAls: ImportAls;
|
||||
|
||||
/**
|
||||
* Attaches events with handlers.
|
||||
*/
|
||||
@@ -86,20 +90,17 @@ export default class InventorySubscriber {
|
||||
private handleScheduleItemsCostOnInventoryTransactionsCreated = async ({
|
||||
tenantId,
|
||||
inventoryTransactions,
|
||||
trx
|
||||
trx,
|
||||
}: IInventoryTransactionsCreatedPayload) => {
|
||||
const inventoryItemsIds = map(inventoryTransactions, 'itemId');
|
||||
const inImportPreviewScope = this.importAls.isImportPreview();
|
||||
|
||||
runAfterTransaction(trx, async () => {
|
||||
try {
|
||||
await this.saleInvoicesCost.computeItemsCostByInventoryTransactions(
|
||||
tenantId,
|
||||
inventoryTransactions
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
// Avoid running the cost items job if the async process is in import preview.
|
||||
if (inImportPreviewScope) return;
|
||||
|
||||
await this.saleInvoicesCost.computeItemsCostByInventoryTransactions(
|
||||
tenantId,
|
||||
inventoryTransactions
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,10 +46,19 @@ export default {
|
||||
* Organization subscription.
|
||||
*/
|
||||
subscription: {
|
||||
onSubscriptionCanceled: 'onSubscriptionCanceled',
|
||||
onSubscriptionCancel: 'onSubscriptionCancel',
|
||||
onSubscriptionCancelled: 'onSubscriptionCancelled',
|
||||
|
||||
onSubscriptionResume: 'onSubscriptionResume',
|
||||
onSubscriptionResumed: 'onSubscriptionResumed',
|
||||
|
||||
onSubscriptionPlanChange: 'onSubscriptionPlanChange',
|
||||
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
|
||||
onSubscribed: 'onOrganizationSubscribed',
|
||||
|
||||
onSubscriptionSubscribed: 'onSubscriptionSubscribed',
|
||||
|
||||
onSubscriptionPaymentSucceed: 'onSubscriptionPaymentSucceed',
|
||||
onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed'
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.up = function (knex) {
|
||||
return knex.schema.table('subscription_plan_subscriptions', (table) => {
|
||||
table.string('payment_status');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
exports.down = function (knex) {
|
||||
return knex.schema.table('subscription_plan_subscriptions', (table) => {
|
||||
table.dropColumn('payment_status');
|
||||
});
|
||||
};
|
||||
@@ -3,6 +3,10 @@ import SystemModel from '@/system/models/SystemModel';
|
||||
import { PlanSubscription } from '..';
|
||||
|
||||
export default class Plan extends mixin(SystemModel) {
|
||||
price: number;
|
||||
invoiceInternal: number;
|
||||
invoicePeriod: string;
|
||||
|
||||
/**
|
||||
* Table name.
|
||||
*/
|
||||
|
||||
@@ -198,14 +198,16 @@ export default class Tenant extends BaseModel {
|
||||
planId,
|
||||
invoiceInterval,
|
||||
invoicePeriod,
|
||||
subscriptionSlug
|
||||
subscriptionSlug,
|
||||
payload?,
|
||||
) {
|
||||
return Tenant.newSubscription(
|
||||
this.id,
|
||||
planId,
|
||||
invoiceInterval,
|
||||
invoicePeriod,
|
||||
subscriptionSlug
|
||||
subscriptionSlug,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,7 +219,8 @@ export default class Tenant extends BaseModel {
|
||||
planId: number,
|
||||
invoiceInterval: 'month' | 'year',
|
||||
invoicePeriod: number,
|
||||
subscriptionSlug: string
|
||||
subscriptionSlug: string,
|
||||
payload?: { lemonSqueezyId: string }
|
||||
) {
|
||||
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
|
||||
|
||||
@@ -227,6 +230,7 @@ export default class Tenant extends BaseModel {
|
||||
planId,
|
||||
startsAt: period.getStartDate(),
|
||||
endsAt: period.getEndDate(),
|
||||
lemonSubscriptionId: payload?.lemonSqueezyId || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { queryConfig } from '../hooks/query/base';
|
||||
import { EnsureUserEmailVerified } from './Guards/EnsureUserEmailVerified';
|
||||
import { EnsureAuthNotAuthenticated } from './Guards/EnsureAuthNotAuthenticated';
|
||||
import { EnsureUserEmailNotVerified } from './Guards/EnsureUserEmailNotVerified';
|
||||
import { EnsureOneClickDemoAccountEnabled } from '@/containers/OneClickDemo/EnsureOneClickDemoAccountEnabled';
|
||||
|
||||
const EmailConfirmation = LazyLoader({
|
||||
loader: () => import('@/containers/Authentication/EmailConfirmation'),
|
||||
@@ -31,6 +30,7 @@ const RegisterVerify = LazyLoader({
|
||||
const OneClickDemoPage = LazyLoader({
|
||||
loader: () => import('@/containers/OneClickDemo/OneClickDemoPage'),
|
||||
});
|
||||
|
||||
/**
|
||||
* App inner.
|
||||
*/
|
||||
@@ -40,13 +40,7 @@ function AppInsider({ history }) {
|
||||
<DashboardThemeProvider>
|
||||
<Router history={history}>
|
||||
<Switch>
|
||||
<Route path={'/one_click_demo'}>
|
||||
<EnsureOneClickDemoAccountEnabled>
|
||||
<EnsureAuthNotAuthenticated>
|
||||
<OneClickDemoPage />
|
||||
</EnsureAuthNotAuthenticated>
|
||||
</EnsureOneClickDemoAccountEnabled>
|
||||
</Route>
|
||||
<Route path={'/one_click_demo'} children={<OneClickDemoPage />} />
|
||||
<Route path={'/auth/register/verify'}>
|
||||
<EnsureAuthenticated>
|
||||
<EnsureUserEmailNotVerified>
|
||||
|
||||
@@ -23,6 +23,9 @@ export function DashboardSockets() {
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
});
|
||||
socket.current.on('SUBSCRIPTION_CHANGED', () => {
|
||||
client.invalidateQueries('GetSubscriptions');
|
||||
});
|
||||
return () => {
|
||||
socket.current.removeAllListeners();
|
||||
socket.current.close();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { useApplicationBoot } from '@/components';
|
||||
import { useAuthMetadata } from '@/hooks/query/authentication';
|
||||
|
||||
/**
|
||||
* Private pages provider.
|
||||
@@ -9,7 +10,10 @@ export function PrivatePagesProvider({
|
||||
// #ownProps
|
||||
children,
|
||||
}) {
|
||||
const { isLoading } = useApplicationBoot();
|
||||
const { isLoading: isAppBootLoading } = useApplicationBoot();
|
||||
const { isLoading: isAuthMetaLoading } = useAuthMetadata();
|
||||
|
||||
const isLoading = isAppBootLoading || isAuthMetaLoading;
|
||||
|
||||
return <React.Fragment>{!isLoading ? children : null}</React.Fragment>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.tag{
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const Config = {
|
||||
oneClickDemo: {
|
||||
enable: process.env.REACT_APP_ONE_CLICK_DEMO_ENABLE === 'true',
|
||||
demoUrl: process.env.REACT_APP_DEMO_ACCOUNT_URL || '',
|
||||
},
|
||||
};
|
||||
@@ -54,9 +54,9 @@ export default [
|
||||
disabled: false,
|
||||
href: '/preferences/items',
|
||||
},
|
||||
{
|
||||
text: <T id={'sms_integration.label'} />,
|
||||
disabled: false,
|
||||
href: '/preferences/sms-message',
|
||||
},
|
||||
// {
|
||||
// text: <T id={'sms_integration.label'} />,
|
||||
// disabled: false,
|
||||
// href: '/preferences/sms-message',
|
||||
// },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
|
||||
.dateFieldGroup{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// @ts-nocheck
|
||||
import { Button, FormGroup, Intent, Position } from '@blueprintjs/core';
|
||||
import * as Yup from 'yup';
|
||||
import moment from 'moment';
|
||||
import { Form, Formik, FormikConfig, useFormikContext } from 'formik';
|
||||
import {
|
||||
FDateInput,
|
||||
FFormGroup,
|
||||
FSelect,
|
||||
Group,
|
||||
Icon,
|
||||
Stack,
|
||||
} from '@/components';
|
||||
|
||||
const defaultValues = {
|
||||
period: 'all_dates',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
fromDate: Yup.date()
|
||||
.nullable()
|
||||
.required('From Date is required')
|
||||
.max(Yup.ref('toDate'), 'From Date cannot be after To Date'),
|
||||
toDate: Yup.date()
|
||||
.nullable()
|
||||
.required('To Date is required')
|
||||
.min(Yup.ref('fromDate'), 'To Date cannot be before From Date'),
|
||||
});
|
||||
|
||||
interface AccountTransactionsDateFilterFormValues {
|
||||
period: string;
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
}
|
||||
|
||||
interface UncategorizedTransactionsDateFilterProps {
|
||||
initialValues?: AccountTransactionsDateFilterFormValues;
|
||||
onSubmit?: FormikConfig<AccountTransactionsDateFilterFormValues>['onSubmit'];
|
||||
}
|
||||
|
||||
export function AccountTransactionsDateFilterForm({
|
||||
initialValues = {},
|
||||
onSubmit,
|
||||
}: UncategorizedTransactionsDateFilterProps) {
|
||||
const handleSubmit = (values, bag) => {
|
||||
return onSubmit && onSubmit(values, bag);
|
||||
};
|
||||
|
||||
const formInitialValues = {
|
||||
...defaultValues,
|
||||
...initialValues,
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={formInitialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validationSchema}
|
||||
>
|
||||
<Form>
|
||||
<Stack spacing={15}>
|
||||
<Group spacing={10}>
|
||||
<AccountTransactionDatePeriodField />
|
||||
|
||||
<FFormGroup
|
||||
name={'fromDate'}
|
||||
label={'From Date'}
|
||||
style={{ marginBottom: 0, flex: '1' }}
|
||||
>
|
||||
<FDateInput
|
||||
name={'fromDate'}
|
||||
popoverProps={{ position: Position.BOTTOM, minimal: true }}
|
||||
formatDate={(date) => date.toLocaleDateString()}
|
||||
parseDate={(str) => new Date(str)}
|
||||
inputProps={{
|
||||
fill: true,
|
||||
placeholder: 'MM/DD/YYY',
|
||||
leftElement: <Icon icon={'date-range'} />,
|
||||
}}
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<FormGroup
|
||||
label={'To Date'}
|
||||
name={'toDate'}
|
||||
style={{ marginBottom: 0, flex: '1' }}
|
||||
>
|
||||
<FDateInput
|
||||
name={'toDate'}
|
||||
popoverProps={{ position: Position.BOTTOM, minimal: true }}
|
||||
formatDate={(date) => date.toLocaleDateString()}
|
||||
parseDate={(str) => new Date(str)}
|
||||
inputProps={{
|
||||
fill: true,
|
||||
placeholder: 'MM/DD/YYY',
|
||||
leftElement: <Icon icon={'date-range'} />,
|
||||
}}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Group>
|
||||
|
||||
<AccountTransactionsDateFilterFooter />
|
||||
</Stack>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountTransactionsDateFilterFooter() {
|
||||
const { submitForm, setValues } = useFormikContext();
|
||||
|
||||
const handleFilterBtnClick = () => {
|
||||
submitForm();
|
||||
};
|
||||
const handleClearBtnClick = () => {
|
||||
setValues({
|
||||
...defaultValues,
|
||||
});
|
||||
submitForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Group spacing={10}>
|
||||
<Button
|
||||
small
|
||||
intent={Intent.PRIMARY}
|
||||
onClick={handleFilterBtnClick}
|
||||
style={{ minWidth: 75 }}
|
||||
>
|
||||
Filter
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
intent={Intent.DANGER}
|
||||
small
|
||||
onClick={handleClearBtnClick}
|
||||
minimal
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountTransactionDatePeriodField() {
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
const handleItemChange = (item) => {
|
||||
const { fromDate, toDate } = getDateRangePeriod(item.value);
|
||||
|
||||
setFieldValue('fromDate', fromDate);
|
||||
setFieldValue('toDate', toDate);
|
||||
setFieldValue('period', item.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<FFormGroup
|
||||
name={'period'}
|
||||
label={'Date'}
|
||||
style={{ marginBottom: 0, flex: '0 28%' }}
|
||||
>
|
||||
<FSelect
|
||||
name={'period'}
|
||||
items={periodOptions}
|
||||
onItemSelect={handleItemChange}
|
||||
popoverProps={{ captureDismiss: true }}
|
||||
/>
|
||||
</FFormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const periodOptions = [
|
||||
{ text: 'All Dates', value: 'all_dates' },
|
||||
{ text: 'Custom', value: 'custom' },
|
||||
{ text: 'Today', value: 'today' },
|
||||
{ text: 'Yesterday', value: 'yesterday' },
|
||||
{ text: 'This week', value: 'this_week' },
|
||||
{ text: 'This year', value: 'this_year' },
|
||||
{ text: 'This month', value: 'this_month' },
|
||||
{ text: 'last week', value: 'last_week' },
|
||||
{ text: 'Last year', value: 'last_year' },
|
||||
{ text: 'Last month', value: 'last_month' },
|
||||
{ text: 'Last month', value: 'last_month' },
|
||||
];
|
||||
|
||||
const getDateRangePeriod = (period: string) => {
|
||||
switch (period) {
|
||||
case 'today':
|
||||
return {
|
||||
fromDate: moment().startOf('day').toDate(),
|
||||
toDate: moment().endOf('day').toDate(),
|
||||
};
|
||||
case 'yesterday':
|
||||
return {
|
||||
fromDate: moment().subtract(1, 'days').startOf('day').toDate(),
|
||||
toDate: moment().subtract(1, 'days').endOf('day').toDate(),
|
||||
};
|
||||
case 'this_week':
|
||||
return {
|
||||
fromDate: moment().startOf('week').toDate(),
|
||||
toDate: moment().endOf('week').toDate(),
|
||||
};
|
||||
case 'this_month':
|
||||
return {
|
||||
fromDate: moment().startOf('month').toDate(),
|
||||
toDate: moment().endOf('month').toDate(),
|
||||
};
|
||||
case 'this_year':
|
||||
return {
|
||||
fromDate: moment().startOf('year').toDate(),
|
||||
toDate: moment().endOf('year').toDate(),
|
||||
};
|
||||
case 'last_week':
|
||||
return {
|
||||
fromDate: moment().subtract(1, 'weeks').startOf('week').toDate(),
|
||||
toDate: moment().subtract(1, 'weeks').endOf('week').toDate(),
|
||||
};
|
||||
case 'last_month':
|
||||
return {
|
||||
fromDate: moment().subtract(1, 'months').startOf('month').toDate(),
|
||||
toDate: moment().subtract(1, 'months').endOf('month').toDate(),
|
||||
};
|
||||
case 'last_year':
|
||||
return {
|
||||
fromDate: moment().subtract(1, 'years').startOf('year').toDate(),
|
||||
toDate: moment().subtract(1, 'years').endOf('year').toDate(),
|
||||
};
|
||||
case 'all_dates':
|
||||
case 'custom':
|
||||
default:
|
||||
return { fromDate: null, toDate: null };
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
// @ts-nocheck
|
||||
import * as R from 'ramda';
|
||||
import { useMemo } from 'react';
|
||||
import * as R from 'ramda';
|
||||
import { useAppQueryString } from '@/hooks';
|
||||
import { Group } from '@/components';
|
||||
import { Group, Stack, } from '@/components';
|
||||
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
|
||||
import { TagsControl } from '@/components/TagsControl';
|
||||
import { AccountUncategorizedDateFilter } from './UncategorizedTransactions/AccountUncategorizedDateFilter';
|
||||
import { Divider } from '@blueprintjs/core';
|
||||
|
||||
export function AccountTransactionsUncategorizeFilter() {
|
||||
const { bankAccountMetaSummary } = useAccountTransactionsContext();
|
||||
@@ -54,12 +56,17 @@ export function AccountTransactionsUncategorizeFilter() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Group position={'apart'}>
|
||||
<TagsControl
|
||||
options={options}
|
||||
value={locationQuery?.uncategorizedFilter || 'all'}
|
||||
onValueChange={handleTabsChange}
|
||||
/>
|
||||
<Group position={'apart'} style={{ marginBottom: 14 }}>
|
||||
<Group align={'stretch'} spacing={10}>
|
||||
<TagsControl
|
||||
options={options}
|
||||
value={locationQuery?.uncategorizedFilter || 'all'}
|
||||
onValueChange={handleTabsChange}
|
||||
/>
|
||||
<Divider />
|
||||
<AccountUncategorizedDateFilter />
|
||||
</Group>
|
||||
|
||||
<TagsControl
|
||||
options={[{ value: 'excluded', label: 'Excluded' }]}
|
||||
value={locationQuery?.uncategorizedFilter || 'all'}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import React from 'react';
|
||||
import { flatten, map } from 'lodash';
|
||||
import * as R from 'ramda';
|
||||
import { IntersectionObserver } from '@/components';
|
||||
import { useAccountUncategorizedTransactionsInfinity } from '@/hooks/query';
|
||||
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
|
||||
import { withBanking } from '../withBanking';
|
||||
|
||||
const AccountUncategorizedTransactionsContext = React.createContext();
|
||||
|
||||
@@ -13,9 +15,15 @@ function flattenInfinityPagesData(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Account uncategorized transctions provider.
|
||||
* Account un-categorized transactions provider.
|
||||
*/
|
||||
function AccountUncategorizedTransactionsBoot({ children }) {
|
||||
function AccountUncategorizedTransactionsBootRoot({
|
||||
// #withBanking
|
||||
uncategorizedTransactionsFilter,
|
||||
|
||||
// #ownProps
|
||||
children,
|
||||
}) {
|
||||
const { accountId } = useAccountTransactionsContext();
|
||||
|
||||
// Fetches the uncategorized transactions.
|
||||
@@ -29,6 +37,8 @@ function AccountUncategorizedTransactionsBoot({ children }) {
|
||||
hasNextPage: hasUncategorizedTransactionsNextPage,
|
||||
} = useAccountUncategorizedTransactionsInfinity(accountId, {
|
||||
page_size: 50,
|
||||
min_date: uncategorizedTransactionsFilter?.fromDate || null,
|
||||
max_date: uncategorizedTransactionsFilter?.toDate || null,
|
||||
});
|
||||
// Memorized the cashflow account transactions.
|
||||
const uncategorizedTransactions = React.useMemo(
|
||||
@@ -69,6 +79,12 @@ function AccountUncategorizedTransactionsBoot({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
const AccountUncategorizedTransactionsBoot = R.compose(
|
||||
withBanking(({ uncategorizedTransactionsFilter }) => ({
|
||||
uncategorizedTransactionsFilter,
|
||||
})),
|
||||
)(AccountUncategorizedTransactionsBootRoot);
|
||||
|
||||
const useAccountUncategorizedTransactionsContext = () =>
|
||||
React.useContext(AccountUncategorizedTransactionsContext);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { flatten, map } from 'lodash';
|
||||
import * as R from 'ramda';
|
||||
import { IntersectionObserver } from '@/components';
|
||||
import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
|
||||
import { useExcludedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
|
||||
import { withBanking } from '../../withBanking';
|
||||
|
||||
interface ExcludedBankTransactionsContextValue {
|
||||
isExcludedTransactionsLoading: boolean;
|
||||
@@ -27,7 +29,11 @@ interface ExcludedBankTransactionsTableBootProps {
|
||||
/**
|
||||
* Account uncategorized transctions provider.
|
||||
*/
|
||||
function ExcludedBankTransactionsTableBoot({
|
||||
function ExcludedBankTransactionsTableBootRoot({
|
||||
// #withBanking
|
||||
uncategorizedTransactionsFilter,
|
||||
|
||||
// #ownProps
|
||||
children,
|
||||
}: ExcludedBankTransactionsTableBootProps) {
|
||||
const { accountId } = useAccountTransactionsContext();
|
||||
@@ -44,6 +50,8 @@ function ExcludedBankTransactionsTableBoot({
|
||||
} = useExcludedBankTransactionsInfinity({
|
||||
page_size: 50,
|
||||
account_id: accountId,
|
||||
min_date: uncategorizedTransactionsFilter?.fromDate || null,
|
||||
max_date: uncategorizedTransactionsFilter.toDate || null,
|
||||
});
|
||||
// Memorized the cashflow account transactions.
|
||||
const excludedBankTransactions = React.useMemo(
|
||||
@@ -84,6 +92,12 @@ function ExcludedBankTransactionsTableBoot({
|
||||
);
|
||||
}
|
||||
|
||||
const ExcludedBankTransactionsTableBoot = R.compose(
|
||||
withBanking(({ uncategorizedTransactionsFilter }) => ({
|
||||
uncategorizedTransactionsFilter,
|
||||
})),
|
||||
)(ExcludedBankTransactionsTableBootRoot);
|
||||
|
||||
const useExcludedTransactionsBoot = () =>
|
||||
React.useContext(ExcludedTransactionsContext);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import { flatten, map } from 'lodash';
|
||||
import { IntersectionObserver } from '@/components';
|
||||
import * as R from 'ramda';
|
||||
import { IntersectionObserver, NumericInputCell } from '@/components';
|
||||
import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
|
||||
import { useRecognizedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
|
||||
import { withBanking } from '../../withBanking';
|
||||
|
||||
interface RecognizedTransactionsContextValue {
|
||||
isRecongizedTransactionsLoading: boolean;
|
||||
@@ -27,7 +29,10 @@ interface RecognizedTransactionsTableBootProps {
|
||||
/**
|
||||
* Account uncategorized transctions provider.
|
||||
*/
|
||||
function RecognizedTransactionsTableBoot({
|
||||
function RecognizedTransactionsTableBootRoot({
|
||||
// #withBanking
|
||||
uncategorizedTransactionsFilter,
|
||||
|
||||
children,
|
||||
}: RecognizedTransactionsTableBootProps) {
|
||||
const { accountId } = useAccountTransactionsContext();
|
||||
@@ -44,6 +49,8 @@ function RecognizedTransactionsTableBoot({
|
||||
} = useRecognizedBankTransactionsInfinity({
|
||||
page_size: 50,
|
||||
account_id: accountId,
|
||||
min_date: uncategorizedTransactionsFilter.fromDate || null,
|
||||
max_date: uncategorizedTransactionsFilter?.toDate || null,
|
||||
});
|
||||
// Memorized the cashflow account transactions.
|
||||
const recognizedTransactions = React.useMemo(
|
||||
@@ -84,6 +91,12 @@ function RecognizedTransactionsTableBoot({
|
||||
);
|
||||
}
|
||||
|
||||
const RecognizedTransactionsTableBoot = R.compose(
|
||||
withBanking(({ uncategorizedTransactionsFilter }) => ({
|
||||
uncategorizedTransactionsFilter,
|
||||
})),
|
||||
)(RecognizedTransactionsTableBootRoot);
|
||||
|
||||
const useRecognizedTransactionsBoot = () =>
|
||||
React.useContext(RecognizedTransactionsContext);
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @ts-nocheck
|
||||
import { useState } from 'react';
|
||||
import * as R from 'ramda';
|
||||
import moment from 'moment';
|
||||
import { Box, Icon } from '@/components';
|
||||
import { Classes, Popover, Position } from '@blueprintjs/core';
|
||||
import { withBankingActions } from '../../withBankingActions';
|
||||
import { withBanking } from '../../withBanking';
|
||||
import { AccountTransactionsDateFilterForm } from '../AccountTransactionsDateFilter';
|
||||
import { TagButton } from './TagButton';
|
||||
|
||||
function AccountUncategorizedDateFilterRoot({
|
||||
uncategorizedTransactionsFilter,
|
||||
}) {
|
||||
const fromDate = uncategorizedTransactionsFilter?.fromDate;
|
||||
const toDate = uncategorizedTransactionsFilter?.toDate;
|
||||
|
||||
const fromDateFormatted = moment(fromDate).isSame(
|
||||
moment().format('YYYY'),
|
||||
'year',
|
||||
)
|
||||
? moment(fromDate).format('MMM, DD')
|
||||
: moment(fromDate).format('MMM, DD, YYYY');
|
||||
const toDateFormatted = moment(toDate).isSame(moment().format('YYYY'), 'year')
|
||||
? moment(toDate).format('MMM, DD')
|
||||
: moment(toDate).format('MMM, DD, YYYY');
|
||||
|
||||
const buttonText =
|
||||
fromDate && toDate
|
||||
? `Date: ${fromDateFormatted} → ${toDateFormatted}`
|
||||
: 'Date Filter';
|
||||
|
||||
// Popover open state.
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
// Handle the filter form submitting.
|
||||
const handleSubmit = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={
|
||||
<Box style={{ padding: 18 }}>
|
||||
<UncategorizedTransactionsDateFilter onSubmit={handleSubmit} />
|
||||
</Box>
|
||||
}
|
||||
position={Position.RIGHT}
|
||||
popoverClassName={Classes.POPOVER_CONTENT}
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<TagButton
|
||||
outline
|
||||
icon={<Icon icon={'date-range'} />}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{buttonText}
|
||||
</TagButton>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export const AccountUncategorizedDateFilter = R.compose(
|
||||
withBanking(({ uncategorizedTransactionsFilter }) => ({
|
||||
uncategorizedTransactionsFilter,
|
||||
})),
|
||||
)(AccountUncategorizedDateFilterRoot);
|
||||
|
||||
export const UncategorizedTransactionsDateFilter = R.compose(
|
||||
withBankingActions,
|
||||
withBanking(({ uncategorizedTransactionsFilter }) => ({
|
||||
uncategorizedTransactionsFilter,
|
||||
})),
|
||||
)(
|
||||
({
|
||||
// #withBankingActions
|
||||
setUncategorizedTransactionsFilter,
|
||||
|
||||
// #withBanking
|
||||
uncategorizedTransactionsFilter,
|
||||
|
||||
// #ownProps
|
||||
onSubmit,
|
||||
}) => {
|
||||
const initialValues = {
|
||||
...uncategorizedTransactionsFilter,
|
||||
};
|
||||
|
||||
const handleSubmit = (values) => {
|
||||
setUncategorizedTransactionsFilter({
|
||||
fromDate: values.fromDate,
|
||||
toDate: values.toDate,
|
||||
});
|
||||
onSubmit && onSubmit(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<AccountTransactionsDateFilterForm
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
.root{
|
||||
min-height: 26px;
|
||||
border-radius: 15px;
|
||||
font-size: 13px;
|
||||
padding: 0 10px;
|
||||
|
||||
&:global(.bp4-button:not([class*=bp4-intent-]):not(.bp4-minimal)) {
|
||||
background: #fff;
|
||||
border: 1px solid #e1e2e8;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// @ts-nocheck
|
||||
import { Button } from "@blueprintjs/core"
|
||||
import styles from './TagButton.module.scss';
|
||||
|
||||
|
||||
|
||||
export const TagButton = (props) => {
|
||||
return <Button {...props} className={styles.root} />
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export const withBanking = (mapState) => {
|
||||
|
||||
categorizedTransactionsSelected:
|
||||
state.plaid.categorizedTransactionsSelected,
|
||||
|
||||
uncategorizedTransactionsFilter: state.plaid.uncategorizedFilter
|
||||
};
|
||||
return mapState ? mapState(mapped, state, props) : mapped;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
removeTransactionsToCategorizeSelected,
|
||||
setCategorizedTransactionsSelected,
|
||||
resetCategorizedTransactionsSelected,
|
||||
setUncategorizedTransactionsFilter,
|
||||
resetUncategorizedTranasctionsFilter,
|
||||
} from '@/store/banking/banking.reducer';
|
||||
|
||||
export interface WithBankingActionsProps {
|
||||
@@ -40,6 +42,9 @@ export interface WithBankingActionsProps {
|
||||
|
||||
setCategorizedTransactionsSelected: (ids: Array<string | number>) => void;
|
||||
resetCategorizedTransactionsSelected: () => void;
|
||||
|
||||
setUncategorizedTransactionsFilter: (filter: any) => void;
|
||||
resetUncategorizedTranasctionsFilter: () => void;
|
||||
}
|
||||
|
||||
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
|
||||
@@ -138,6 +143,19 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
|
||||
*/
|
||||
resetCategorizedTransactionsSelected: () =>
|
||||
dispatch(resetCategorizedTransactionsSelected()),
|
||||
|
||||
/**
|
||||
* Sets the uncategorized transactions filter.
|
||||
* @param {any} filter -
|
||||
*/
|
||||
setUncategorizedTransactionsFilter: (filter: any) =>
|
||||
dispatch(setUncategorizedTransactionsFilter({ filter })),
|
||||
|
||||
/**
|
||||
* Resets the uncategorized transactions filter.
|
||||
*/
|
||||
resetUncategorizedTranasctionsFilter: () =>
|
||||
dispatch(resetUncategorizedTranasctionsFilter()),
|
||||
});
|
||||
|
||||
export const withBankingActions = connect<
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
th.label,
|
||||
td.label{
|
||||
width: 32% !important;
|
||||
width: 30% !important;
|
||||
}
|
||||
|
||||
thead{
|
||||
@@ -31,16 +31,14 @@
|
||||
tr td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tr td{
|
||||
:global(.bp4-popover-target .bp4-button),
|
||||
:global(.bp4-popover-wrapper){
|
||||
max-width: 250px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.requiredSign{
|
||||
color: rgb(250, 82, 82);
|
||||
}
|
||||
|
||||
.columnSelectButton{
|
||||
max-width: 250px;
|
||||
min-width: 250px;
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import { EntityColumnField, useImportFileContext } from './ImportFileProvider';
|
||||
import { CLASSES } from '@/constants';
|
||||
import { ImportFileContainer } from './ImportFileContainer';
|
||||
import { ImportStepperStep } from './_types';
|
||||
import { ImportFileMapBootProvider } from './ImportFileMappingBoot';
|
||||
import {
|
||||
ImportFileMapBootProvider,
|
||||
useImportFileMapBootContext,
|
||||
} from './ImportFileMappingBoot';
|
||||
import styles from './ImportFileMapping.module.scss';
|
||||
import { getFieldKey } from './_utils';
|
||||
import { getDateFieldKey, getFieldKey } from './_utils';
|
||||
|
||||
export function ImportFileMapping() {
|
||||
const { importId, entityColumns } = useImportFileContext();
|
||||
@@ -82,6 +85,7 @@ interface ImportFileMappingFieldsProps {
|
||||
*/
|
||||
function ImportFileMappingFields({ fields }: ImportFileMappingFieldsProps) {
|
||||
const { sheetColumns } = useImportFileContext();
|
||||
const { dateFormats } = useImportFileMapBootContext();
|
||||
|
||||
const items = useMemo(
|
||||
() => sheetColumns.map((column) => ({ value: column, text: column })),
|
||||
@@ -95,22 +99,35 @@ function ImportFileMappingFields({ fields }: ImportFileMappingFieldsProps) {
|
||||
{column.required && <span className={styles.requiredSign}>*</span>}
|
||||
</td>
|
||||
<td className={styles.field}>
|
||||
<Group spacing={4}>
|
||||
<Group spacing={12} noWrap>
|
||||
<FSelect
|
||||
name={getFieldKey(column.key, column.group)}
|
||||
name={`['${getFieldKey(column.key, column.group)}'].from`}
|
||||
items={items}
|
||||
popoverProps={{ minimal: true }}
|
||||
minimal={true}
|
||||
fill={true}
|
||||
className={styles.columnSelectButton}
|
||||
/>
|
||||
{column.hint && (
|
||||
<Hint content={column.hint} position={Position.BOTTOM} />
|
||||
)}
|
||||
{column.type === 'date' && (
|
||||
<FSelect
|
||||
name={getDateFieldKey(column.key, column.group)}
|
||||
items={dateFormats}
|
||||
placeholder={'Select date format'}
|
||||
minimal={true}
|
||||
fill={true}
|
||||
valueAccessor={'key'}
|
||||
textAccessor={'label'}
|
||||
labelAccessor={''}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
),
|
||||
[items],
|
||||
[items, dateFormats],
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => fields.map(columnMapper),
|
||||
|
||||
@@ -2,8 +2,11 @@ import { Spinner } from '@blueprintjs/core';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { Box } from '@/components';
|
||||
import { useImportFileMeta } from '@/hooks/query/import';
|
||||
import { useDateFormats } from '@/hooks/query';
|
||||
|
||||
interface ImportFileMapBootContextValue {}
|
||||
interface ImportFileMapBootContextValue {
|
||||
dateFormats: Array<any>;
|
||||
}
|
||||
|
||||
const ImportFileMapBootContext = createContext<ImportFileMapBootContextValue>(
|
||||
{} as ImportFileMapBootContextValue,
|
||||
@@ -39,14 +42,22 @@ export const ImportFileMapBootProvider = ({
|
||||
enabled: Boolean(importId),
|
||||
});
|
||||
|
||||
// Fetch date format options.
|
||||
const { data: dateFormats, isLoading: isDateFormatsLoading } =
|
||||
useDateFormats();
|
||||
|
||||
const value = {
|
||||
importFile,
|
||||
isImportFileLoading,
|
||||
isImportFileFetching,
|
||||
dateFormats,
|
||||
isDateFormatsLoading,
|
||||
};
|
||||
const isLoading = isDateFormatsLoading || isImportFileLoading;
|
||||
|
||||
return (
|
||||
<ImportFileMapBootContext.Provider value={value}>
|
||||
{isImportFileLoading ? (
|
||||
{isLoading ? (
|
||||
<Box style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<Spinner size={26} />
|
||||
</Box>
|
||||
|
||||
@@ -13,6 +13,7 @@ export type EntityColumnField = {
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
group?: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export interface EntityColumn {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
flex: 1;
|
||||
padding: 32px 20px;
|
||||
padding-bottom: 80px;
|
||||
min-width: 660px;
|
||||
max-width: 760px;
|
||||
min-width: 800px;
|
||||
max-width: 800px;
|
||||
width: 75%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@@ -12,4 +12,13 @@ export interface ImportFileMappingFormProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export type ImportFileMappingFormValues = Record<string, string | null>;
|
||||
export type ImportFileMappingFormValues = Record<
|
||||
string,
|
||||
{ from: string | null; dateFormat?: string }
|
||||
>;
|
||||
|
||||
export type ImportFileMappingRes = {
|
||||
from: string;
|
||||
to: string;
|
||||
group: string;
|
||||
}[];
|
||||
|
||||
@@ -17,13 +17,15 @@ import {
|
||||
} from './ImportFileProvider';
|
||||
import { useImportFileMapBootContext } from './ImportFileMappingBoot';
|
||||
import { deepdash, transformToForm } from '@/utils';
|
||||
import { ImportFileMappingFormValues } from './_types';
|
||||
import { ImportFileMappingFormValues, ImportFileMappingRes } from './_types';
|
||||
|
||||
export const getFieldKey = (key: string, group = '') => {
|
||||
return group ? `${group}.${key}` : key;
|
||||
};
|
||||
|
||||
type ImportFileMappingRes = { from: string; to: string; group: string }[];
|
||||
export const getDateFieldKey = (key: string, group: string = '') => {
|
||||
return `${getFieldKey(key, group)}.dateFormat`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transformes the mapping form values to request.
|
||||
@@ -34,10 +36,10 @@ export const transformValueToReq = (
|
||||
value: ImportFileMappingFormValues,
|
||||
): { mapping: ImportFileMappingRes[] } => {
|
||||
const mapping = chain(value)
|
||||
.thru(deepdash.index)
|
||||
.pickBy((_value, key) => !isEmpty(get(value, key)))
|
||||
.map((from, key) => ({
|
||||
from,
|
||||
.pickBy((_value, key) => !isEmpty(_value) && _value?.from)
|
||||
.map((_value, key) => ({
|
||||
from: _value.from,
|
||||
dateFormat: _value.dateFormat,
|
||||
to: key.includes('.') ? last(key.split('.')) : key,
|
||||
group: key.includes('.') ? head(key.split('.')) : '',
|
||||
}))
|
||||
@@ -52,19 +54,23 @@ export const transformValueToReq = (
|
||||
* @returns {Record<string, object | string>}
|
||||
*/
|
||||
export const transformResToFormValues = (
|
||||
value: { from: string; to: string , group: string }[],
|
||||
value: { from: string; to: string; group: string }[],
|
||||
): Record<string, object | string> => {
|
||||
return value?.reduce((acc, map) => {
|
||||
const path = map?.group ? `${map.group}.${map.to}` : map.to;
|
||||
set(acc, path, map.from);
|
||||
const path = map?.group ? `['${map.group}.${map.to}']` : map.to;
|
||||
const dateFormatObj = map?.dateFormat
|
||||
? { dateFormat: map?.dateFormat }
|
||||
: {};
|
||||
|
||||
set(acc, path, { from: map?.from, ...dateFormatObj });
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the initial values of mapping form.
|
||||
* @param {EntityColumn[]} entityColumns
|
||||
* @param {SheetColumn[]} sheetColumns
|
||||
* Retrieves the initial values of mapping form.
|
||||
* @param {EntityColumn[]} entityColumns
|
||||
* @param {SheetColumn[]} sheetColumns
|
||||
*/
|
||||
const getInitialDefaultValues = (
|
||||
entityColumns: EntityColumn[],
|
||||
@@ -76,10 +82,10 @@ const getInitialDefaultValues = (
|
||||
const _matched = sheetColumns.find(
|
||||
(column) => lowerCase(column) === _name,
|
||||
);
|
||||
const _key = groupKey ? `${groupKey}.${key}` : key;
|
||||
const _value = _matched ? _matched : '';
|
||||
const path = groupKey ? `['${groupKey}.${key}']` : key;
|
||||
const from = _matched ? _matched : '';
|
||||
|
||||
set(acc, _key, _value);
|
||||
set(acc, path, { from });
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -102,7 +108,6 @@ export const useImportFileMappingInitialValues = () => {
|
||||
() => getInitialDefaultValues(entityColumns, sheetColumns),
|
||||
[entityColumns, sheetColumns],
|
||||
);
|
||||
|
||||
return useMemo<Record<string, any>>(
|
||||
() =>
|
||||
assign(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { Config } from '@/config';
|
||||
import { useOneClickDemoBoot } from './OneClickDemoBoot';
|
||||
|
||||
interface EnsureOneClickDemoAccountEnabledProps {
|
||||
children: React.ReactNode;
|
||||
@@ -11,9 +11,10 @@ export const EnsureOneClickDemoAccountEnabled = ({
|
||||
children,
|
||||
redirectTo = '/',
|
||||
}: EnsureOneClickDemoAccountEnabledProps) => {
|
||||
const enabeld = Config.oneClickDemo.enable || false;
|
||||
const { authMeta } = useOneClickDemoBoot();
|
||||
const enabled = authMeta?.meta?.one_click_demo?.enable || false;
|
||||
|
||||
if (!enabeld) {
|
||||
if (!enabled) {
|
||||
return <Redirect to={{ pathname: redirectTo }} />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { useAuthMetadata } from '@/hooks/query/authentication';
|
||||
|
||||
interface OneClickDemoContextType {
|
||||
authMeta: any;
|
||||
}
|
||||
|
||||
const OneClickDemoContext = createContext<OneClickDemoContextType>(
|
||||
{} as OneClickDemoContextType,
|
||||
);
|
||||
|
||||
export const useOneClickDemoBoot = () => {
|
||||
const context = useContext(OneClickDemoContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useOneClickDemo must be used within a OneClickDemoProvider',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface OneClickDemoBootProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const OneClickDemoBoot: React.FC<OneClickDemoBootProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const { isLoading: isAuthMetaLoading, data: authMeta } = useAuthMetadata();
|
||||
|
||||
const value = {
|
||||
isAuthMetaLoading,
|
||||
authMeta,
|
||||
};
|
||||
|
||||
if (isAuthMetaLoading) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<OneClickDemoContext.Provider value={value}>
|
||||
{children}
|
||||
</OneClickDemoContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +1,16 @@
|
||||
// @ts-nocheck
|
||||
import { Button, Intent, ProgressBar, Text } from '@blueprintjs/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
useCreateOneClickDemo,
|
||||
useOneClickDemoSignin,
|
||||
} from '@/hooks/query/oneclick-demo';
|
||||
import { Box, Icon, Stack } from '@/components';
|
||||
import { useJob } from '@/hooks/query';
|
||||
import style from './OneClickDemoPage.module.scss';
|
||||
import { EnsureAuthNotAuthenticated } from '@/components/Guards/EnsureAuthNotAuthenticated';
|
||||
import { EnsureOneClickDemoAccountEnabled } from './EnsureOneClickDemoAccountEnabled';
|
||||
import { OneClickDemoBoot } from './OneClickDemoBoot';
|
||||
import { OneClickDemoPageContent } from './OneClickDemoPageContent';
|
||||
|
||||
export default function OneClickDemoPage() {
|
||||
const {
|
||||
mutateAsync: createOneClickDemo,
|
||||
isLoading: isCreateOneClickLoading,
|
||||
} = useCreateOneClickDemo();
|
||||
const {
|
||||
mutateAsync: oneClickDemoSignIn,
|
||||
isLoading: isOneclickDemoSigningIn,
|
||||
} = useOneClickDemoSignin();
|
||||
|
||||
// Job states.
|
||||
const [demoId, setDemoId] = useState<string>('');
|
||||
const [buildJobId, setBuildJobId] = useState<string>('');
|
||||
const [isJobDone, setIsJobDone] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
data: { running, completed },
|
||||
} = useJob(buildJobId, {
|
||||
refetchInterval: 2000,
|
||||
enabled: !isJobDone && !!buildJobId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (completed) {
|
||||
setIsJobDone(true);
|
||||
}
|
||||
}, [completed, setIsJobDone]);
|
||||
|
||||
// One the job done request sign-in using the demo id.
|
||||
useEffect(() => {
|
||||
if (isJobDone) {
|
||||
oneClickDemoSignIn({ demoId }).then((res) => {
|
||||
debugger;
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isJobDone]);
|
||||
|
||||
const handleCreateAccountBtnClick = () => {
|
||||
createOneClickDemo({})
|
||||
.then(({ data: { data } }) => {
|
||||
setBuildJobId(data?.build_job?.job_id);
|
||||
setDemoId(data?.demo_id);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
const isLoading = running || isOneclickDemoSigningIn;
|
||||
|
||||
return (
|
||||
<Box className={style.root}>
|
||||
<Box className={style.inner}>
|
||||
<Stack align={'center'} spacing={40}>
|
||||
<Icon icon="bigcapital" height={37} width={228} />
|
||||
|
||||
{isLoading && (
|
||||
<Stack align={'center'} spacing={15}>
|
||||
<ProgressBar stripes value={null} className={style.progressBar} />
|
||||
{isOneclickDemoSigningIn && (
|
||||
<Text className={style.waitingText}>
|
||||
It's signin-in to your demo account, Just a second!
|
||||
</Text>
|
||||
)}
|
||||
{running && (
|
||||
<Text className={style.waitingText}>
|
||||
We're preparing temporary environment for trial, It typically
|
||||
take few seconds. Do not close or refresh the page.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<Button
|
||||
className={style.oneClickBtn}
|
||||
intent={Intent.NONE}
|
||||
onClick={handleCreateAccountBtnClick}
|
||||
loading={isCreateOneClickLoading}
|
||||
>
|
||||
Create Demo Account
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<EnsureAuthNotAuthenticated>
|
||||
<OneClickDemoBoot>
|
||||
<EnsureOneClickDemoAccountEnabled>
|
||||
<OneClickDemoPageContent />
|
||||
</EnsureOneClickDemoAccountEnabled>
|
||||
</OneClickDemoBoot>
|
||||
</EnsureAuthNotAuthenticated>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @ts-nocheck
|
||||
import { Button, Intent, ProgressBar, Text } from '@blueprintjs/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
useCreateOneClickDemo,
|
||||
useOneClickDemoSignin,
|
||||
} from '@/hooks/query/oneclick-demo';
|
||||
import { Box, Icon, Stack } from '@/components';
|
||||
import { useJob } from '@/hooks/query';
|
||||
import style from './OneClickDemoPage.module.scss';
|
||||
|
||||
export function OneClickDemoPageContent() {
|
||||
const {
|
||||
mutateAsync: createOneClickDemo,
|
||||
isLoading: isCreateOneClickLoading,
|
||||
} = useCreateOneClickDemo();
|
||||
const {
|
||||
mutateAsync: oneClickDemoSignIn,
|
||||
isLoading: isOneclickDemoSigningIn,
|
||||
} = useOneClickDemoSignin();
|
||||
|
||||
// Job states.
|
||||
const [demoId, setDemoId] = useState<string>('');
|
||||
const [buildJobId, setBuildJobId] = useState<string>('');
|
||||
const [isJobDone, setIsJobDone] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
data: { running, completed },
|
||||
} = useJob(buildJobId, {
|
||||
refetchInterval: 2000,
|
||||
enabled: !isJobDone && !!buildJobId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (completed) {
|
||||
setIsJobDone(true);
|
||||
}
|
||||
}, [completed, setIsJobDone]);
|
||||
|
||||
// One the job done request sign-in using the demo id.
|
||||
useEffect(() => {
|
||||
if (isJobDone) {
|
||||
oneClickDemoSignIn({ demoId }).then((res) => {
|
||||
debugger;
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isJobDone]);
|
||||
|
||||
const handleCreateAccountBtnClick = () => {
|
||||
createOneClickDemo({})
|
||||
.then(({ data: { data } }) => {
|
||||
setBuildJobId(data?.build_job?.job_id);
|
||||
setDemoId(data?.demo_id);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
const isLoading = running || isOneclickDemoSigningIn;
|
||||
|
||||
return (
|
||||
<Box className={style.root}>
|
||||
<Box className={style.inner}>
|
||||
<Stack align={'center'} spacing={40}>
|
||||
<Icon icon="bigcapital" height={37} width={228} />
|
||||
|
||||
{isLoading && (
|
||||
<Stack align={'center'} spacing={15}>
|
||||
<ProgressBar stripes value={null} className={style.progressBar} />
|
||||
{isOneclickDemoSigningIn && (
|
||||
<Text className={style.waitingText}>
|
||||
It's signin-in to your demo account, Just a second!
|
||||
</Text>
|
||||
)}
|
||||
{running && (
|
||||
<Text className={style.waitingText}>
|
||||
We're preparing temporary environment for trial, It typically
|
||||
take few seconds. Do not close or refresh the page.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{!isLoading && (
|
||||
<Button
|
||||
className={style.oneClickBtn}
|
||||
intent={Intent.NONE}
|
||||
onClick={handleCreateAccountBtnClick}
|
||||
loading={isCreateOneClickLoading}
|
||||
>
|
||||
Create Demo Account
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,9 @@
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgb(255, 255, 255, 0.3);
|
||||
padding: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgb(255, 255, 255, 0.2);
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
@@ -12,11 +12,11 @@
|
||||
transition: border 0.15s ease-in-out, color 0.15s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid rgb(255, 255, 255, 0.6);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgb(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
.demoButtonLabel{
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Icon, For, FormattedMessage as T, Stack } from '@/components';
|
||||
import { getFooterLinks } from '@/constants/footerLinks';
|
||||
import { useAuthActions } from '@/hooks/state';
|
||||
import style from './SetupLeftSection.module.scss';
|
||||
import { Config } from '@/config';
|
||||
import { useAuthMetadata } from '@/hooks/query';
|
||||
|
||||
/**
|
||||
* Footer item link.
|
||||
@@ -28,13 +28,16 @@ function SetupLeftSectionFooter() {
|
||||
// Retrieve the footer links.
|
||||
const footerLinks = getFooterLinks();
|
||||
|
||||
const { data: authMeta } = useAuthMetadata();
|
||||
const demoUrl = authMeta?.meta?.one_click_demo?.demo_url;
|
||||
|
||||
const handleDemoBtnClick = () => {
|
||||
window.open(Config.oneClickDemo.demoUrl);
|
||||
window.open(demoUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'content__footer'}>
|
||||
{Config.oneClickDemo.demoUrl && (
|
||||
{demoUrl && (
|
||||
<Stack spacing={16}>
|
||||
<Text className={style.demoButtonLabel}>Not Now?</Text>
|
||||
<button className={style.demoButton} onClick={handleDemoBtnClick}>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.container {
|
||||
text-align: center;
|
||||
margin-bottom: 1.15rem;
|
||||
}
|
||||
|
||||
.iconText {
|
||||
display: inline-flex;
|
||||
font-size: 14px;
|
||||
margin-right: 16px;
|
||||
color: #00824d;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0; /* Remove the margin on the last item */
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 2px;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import styles from './SubscriptionPlansOfferChecks.module.scss';
|
||||
|
||||
export function SubscriptionPlansOfferChecks() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.iconText}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="18px"
|
||||
width="18px"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="rgb(0, 130, 77)"
|
||||
className={styles.icon}
|
||||
>
|
||||
<path d="M378-225 133-470l66-66 179 180 382-382 66 65-448 448Z"></path>
|
||||
</svg>
|
||||
14-day free trial
|
||||
</span>
|
||||
|
||||
<span className={styles.iconText}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="18px"
|
||||
width="18px"
|
||||
viewBox="0 -960 960 960"
|
||||
fill="rgb(0, 130, 77)"
|
||||
className={styles.icon}
|
||||
>
|
||||
<path d="M378-225 133-470l66-66 179 180 382-382 66 65-448 448Z"></path>
|
||||
</svg>
|
||||
24/7 online support
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function SubscriptionPlansPeriodSwitcherRoot({
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Group position={'center'} spacing={10} style={{ marginBottom: '1.2rem' }}>
|
||||
<Group position={'center'} spacing={10} style={{ marginBottom: '1.6rem' }}>
|
||||
<Text>Pay Monthly</Text>
|
||||
<Switch
|
||||
large
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Callout } from '@blueprintjs/core';
|
||||
import { SubscriptionPlans } from './SubscriptionPlans';
|
||||
import { SubscriptionPlansPeriodSwitcher } from './SubscriptionPlansPeriodSwitcher';
|
||||
import { SubscriptionPlansOfferChecks } from './SubscriptionPlansOfferChecks';
|
||||
|
||||
/**
|
||||
* Billing plans.
|
||||
@@ -14,6 +15,7 @@ export function SubscriptionPlansSection() {
|
||||
include applicable taxes.
|
||||
</Callout>
|
||||
|
||||
<SubscriptionPlansOfferChecks />
|
||||
<SubscriptionPlansPeriodSwitcher />
|
||||
<SubscriptionPlans />
|
||||
</section>
|
||||
|
||||
@@ -100,7 +100,7 @@ export const useAuthResetPassword = (props) => {
|
||||
/**
|
||||
* Fetches the authentication page metadata.
|
||||
*/
|
||||
export const useAuthMetadata = (props) => {
|
||||
export const useAuthMetadata = (props = {}) => {
|
||||
return useRequestQuery(
|
||||
[t.AUTH_METADATA_PAGE],
|
||||
{
|
||||
|
||||
@@ -152,7 +152,7 @@ export function useAccountUncategorizedTransactionsInfinity(
|
||||
const apiRequest = useApiRequest();
|
||||
|
||||
return useInfiniteQuery(
|
||||
[t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, accountId],
|
||||
[t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, accountId, query],
|
||||
async ({ pageParam = 1 }) => {
|
||||
const response = await apiRequest.http({
|
||||
...axios,
|
||||
|
||||
@@ -12,7 +12,7 @@ import useApiRequest from '../useRequest';
|
||||
import { transformToCamelCase } from '@/utils';
|
||||
|
||||
const QueryKeys = {
|
||||
Subscriptions: 'Subscriptions',
|
||||
Subscriptions: 'GetSubscriptions',
|
||||
};
|
||||
|
||||
interface CancelMainSubscriptionValues {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Roles from '../containers/Preferences/Users/Roles/RolesForm/RolesFormPage
|
||||
import Accountant from '@/containers/Preferences/Accountant/Accountant';
|
||||
import Currencies from '@/containers/Preferences/Currencies/Currencies';
|
||||
import Item from '@/containers/Preferences/Item';
|
||||
import SMSIntegration from '../containers/Preferences/SMSIntegration';
|
||||
// import SMSIntegration from '../containers/Preferences/SMSIntegration';
|
||||
import DefaultRoute from '../containers/Preferences/DefaultRoute';
|
||||
import Warehouses from '../containers/Preferences/Warehouses';
|
||||
import Branches from '../containers/Preferences/Branches';
|
||||
@@ -85,11 +85,11 @@ export default [
|
||||
component: Item,
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: `${BASE_URL}/sms-message`,
|
||||
component: SMSIntegration,
|
||||
exact: true,
|
||||
},
|
||||
// {
|
||||
// path: `${BASE_URL}/sms-message`,
|
||||
// component: SMSIntegration,
|
||||
// exact: true,
|
||||
// },
|
||||
{
|
||||
path: `${BASE_URL}/billing`,
|
||||
component: BillingPage,
|
||||
|
||||
@@ -14,6 +14,8 @@ interface StorePlaidState {
|
||||
enableMultipleCategorization: boolean;
|
||||
|
||||
categorizedTransactionsSelected: Array<number | string>;
|
||||
|
||||
uncategorizedFilter: { fromDate?: string; toDate?: string };
|
||||
}
|
||||
|
||||
export const PlaidSlice = createSlice({
|
||||
@@ -31,6 +33,9 @@ export const PlaidSlice = createSlice({
|
||||
transactionsToCategorizeSelected: [],
|
||||
enableMultipleCategorization: false,
|
||||
categorizedTransactionsSelected: [],
|
||||
|
||||
// Filter
|
||||
uncategorizedFilter: {},
|
||||
} as StorePlaidState,
|
||||
reducers: {
|
||||
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
|
||||
@@ -199,6 +204,26 @@ export const PlaidSlice = createSlice({
|
||||
resetCategorizedTransactionsSelected: (state: StorePlaidState) => {
|
||||
state.categorizedTransactionsSelected = [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the uncategorized transactions filter.
|
||||
* @param {StorePlaidState} state
|
||||
* @param {PayloadAction<{ filter: any }>} action
|
||||
*/
|
||||
setUncategorizedTransactionsFilter: (
|
||||
state: StorePlaidState,
|
||||
action: PayloadAction<{ filter: any }>,
|
||||
) => {
|
||||
state.uncategorizedFilter = action.payload.filter;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets the uncategorized transactions filter.
|
||||
* @param {StorePlaidState} state
|
||||
*/
|
||||
resetUncategorizedTranasctionsFilter: (state: StorePlaidState) => {
|
||||
state.uncategorizedFilter = {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -220,6 +245,10 @@ export const {
|
||||
enableMultipleCategorization,
|
||||
setCategorizedTransactionsSelected,
|
||||
resetCategorizedTransactionsSelected,
|
||||
|
||||
// Uncategorized transactions filter.
|
||||
setUncategorizedTransactionsFilter,
|
||||
resetUncategorizedTranasctionsFilter,
|
||||
} = PlaidSlice.actions;
|
||||
|
||||
export const getPlaidToken = (state: any) => state.plaid.plaidToken;
|
||||
@@ -234,3 +263,6 @@ export const isMultipleCategorization = (state: any) =>
|
||||
|
||||
export const getTransactionsToCategorizeIdsSelected = (state: any) =>
|
||||
state.plaid.transactionsToCategorizeSelected;
|
||||
|
||||
export const getUncategorizedTransactionsFilter = (state: any) =>
|
||||
state.uncategorizedFilter;
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
&.bp4-small {
|
||||
font-size: 13px;
|
||||
min-height: 29px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
&__content {
|
||||
width: 100%;
|
||||
padding-bottom: 40px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
&__left-section {
|
||||
@@ -72,10 +72,10 @@
|
||||
|
||||
&__text {
|
||||
font-size: 18px;
|
||||
opacity: 0.75;
|
||||
opacity: 0.55;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 200;
|
||||
}
|
||||
}
|
||||
|
||||
&__organization {
|
||||
font-size: 16px;
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -86,6 +86,9 @@ importers:
|
||||
async:
|
||||
specifier: ^3.2.0
|
||||
version: 3.2.5
|
||||
async-mutex:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
axios:
|
||||
specifier: ^1.6.0
|
||||
version: 1.7.2
|
||||
@@ -8009,6 +8012,12 @@ packages:
|
||||
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
|
||||
dev: false
|
||||
|
||||
/async-mutex@0.5.0:
|
||||
resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
|
||||
dependencies:
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/async-settle@1.0.0:
|
||||
resolution: {integrity: sha512-VPXfB4Vk49z1LHHodrEQ6Xf7W4gg1w0dAPROHngx7qgDjqmIQ+fXmwgGXTW/ITLai0YLSvWepJOP9EVpMnEAcw==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
Reference in New Issue
Block a user