Compare commits

...

47 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
f92acbcbe0 fix: Getting the sheet columns in import sheet 2024-08-30 17:03:16 +02:00
Ahmed Bouhuolia
c986585cd9 Merge pull request #640 from bigcapitalhq/fix-typo-one-click-demo
fix: Typo one-click demo page
2024-08-30 00:15:29 +02:00
Ahmed Bouhuolia
250f0a30ef fix: Typo one-click demo page 2024-08-30 00:14:59 +02:00
Ahmed Bouhuolia
84b5e1adc1 Merge pull request #639 from bigcapitalhq/add-customer-type-to-customers
fix: Add customer type to customers resource
2024-08-29 22:50:20 +02:00
Ahmed Bouhuolia
2ab28370db fix: Add customer type to customers resource 2024-08-29 22:49:49 +02:00
Ahmed Bouhuolia
a88a525326 Merge pull request #638 from bigcapitalhq/use-standard-date-format-export
fix: use standard ISO 8601 format for exported data
2024-08-29 22:40:59 +02:00
Ahmed Bouhuolia
fce8e2c5a4 fix: use standard ISO 8601 format for exported data 2024-08-29 22:39:51 +02:00
Ahmed Bouhuolia
095608266c Merge pull request #632 from bigcapitalhq/split-lazy-loading
feat: Optimize loading perf. by splitting big chunks and lazy loading them
2024-08-29 21:27:40 +02:00
Ahmed Bouhuolia
0ec8aaa330 fix: delete unwanted files 2024-08-29 21:27:20 +02:00
Ahmed Bouhuolia
9fcb3ef77d feat: Add fallback spinner to authentication lazy-loaded pages 2024-08-29 21:19:36 +02:00
Ahmed Bouhuolia
dc61c57daf fix: Add spinner to preferences lazy loaded pages 2024-08-29 21:03:27 +02:00
Ahmed Bouhuolia
af284f3f6d feat: split the preferences pages 2024-08-29 20:49:08 +02:00
Ahmed Bouhuolia
c43123db76 Merge pull request #636 from bigcapitalhq/expand-export-page-size
fix: Expand the resources export page size limitation
2024-08-29 14:23:48 +02:00
Ahmed Bouhuolia
ebbcab3926 fix: Expand the resources export page size limitation 2024-08-29 14:22:45 +02:00
Ahmed Bouhuolia
a235f573c0 Merge pull request #635 from bigcapitalhq/fix-avoid-cost-job-import-preview
fix: Avoid running the cost job in import preview
2024-08-29 10:09:13 +02:00
Ahmed Bouhuolia
84a0b8f495 fix: re-schedule the jobs have date from the current moment 2024-08-29 10:05:38 +02:00
Ahmed Bouhuolia
b87321c897 fix: Avoid running the cost job in import preview 2024-08-28 22:15:15 +02:00
Ahmed Bouhuolia
fc6ebfea5c Debounce scheduling calculating items cost 2024-08-28 21:25:47 +02:00
Ahmed Bouhuolia
c9fe6d9b37 feat: Optimize loading perf. by spliting big chunks and lazy loading them 2024-08-26 22:51:40 +02:00
Ahmed Bouhuolia
161d60393a Merge pull request #629 from bigcapitalhq/details-subscription
fix: Add subscription plans offer text
2024-08-25 19:44:34 +02:00
Ahmed Bouhuolia
79413fa85e fix: Add subscription plans offer text 2024-08-25 19:43:54 +02:00
Ahmed Bouhuolia
58552c6c94 Merge pull request #628 from bigcapitalhq/fix-webapp-env-variables
fix: Make webapp package env variables dynamic
2024-08-25 18:21:55 +02:00
Ahmed Bouhuolia
2072e35cfa fix: Make webapp package env variables dynamic 2024-08-25 18:21:08 +02:00
Ahmed Bouhuolia
1eaac9d691 Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2024-08-25 14:18:04 +02:00
Ahmed Bouhuolia
a916e8a0cb fix: syntax error 2024-08-25 14:17:23 +02:00
Ahmed Bouhuolia
a56f560036 Merge pull request #627 from bigcapitalhq/fix-style-tweeks-in-onboarding-page
fix: Style tweaks in onboarding page
2024-08-25 13:20:39 +02:00
Ahmed Bouhuolia
0fb886936c Merge pull request #626 from bigcapitalhq/disable-sms-service
fix: Disable sms service until Twilo integration
2024-08-25 13:20:14 +02:00
Ahmed Bouhuolia
670136916f fix: Style tweaks in onboarding page 2024-08-25 13:19:16 +02:00
Ahmed Bouhuolia
768297f137 fix: Disable sms service until Twilo integration 2024-08-25 13:07:07 +02:00
Ahmed Bouhuolia
ef505a0a62 Merge pull request #625 from bigcapitalhq/manual-journal-number-prefix
fix: Add prefix J-00001 to manual journals increments
2024-08-25 13:05:52 +02:00
Ahmed Bouhuolia
42d40620ec fix: add prefix J-00001 to manual journals increments 2024-08-25 13:05:28 +02:00
Ahmed Bouhuolia
959ef7a691 Merge branch 'listen-payment-webhooks' into develop 2024-08-24 21:52:04 +02:00
Ahmed Bouhuolia
60f03f534b fix(subscription): event TS types 2024-08-24 21:51:15 +02:00
Ahmed Bouhuolia
e44ebb700a Merge pull request #623 from bigcapitalhq/listen-payment-webhooks
fix: Listen to payment webhooks
2024-08-24 21:49:36 +02:00
Ahmed Bouhuolia
8e94c7a755 feat(subscription): invalidate subscription cache 2024-08-24 21:40:28 +02:00
Ahmed Bouhuolia
3a2ca36c07 Merge branch 'develop' into listen-payment-webhooks 2024-08-24 21:08:47 +02:00
Ahmed Bouhuolia
88ece74c8a Merge pull request #622 from wolone/develop
Fix: Syntax error caused error
2024-08-24 20:48:14 +02:00
Ahmed Bouhuolia
67a8610328 feat: cancel/resume LS subscriptions 2024-08-24 20:46:30 +02:00
Ahmed Bouhuolia
278d61ce61 fix: Listen to payment webhooks 2024-08-24 18:50:12 +02:00
wolone
e72d6ad6b8 Fix: Syntax error caused error 2024-08-24 23:33:53 +08:00
Ahmed Bouhuolia
bf66b31679 Merge pull request #590 from bigcapitalhq/filter-uncategorized-bank-transactions
feat(banking): Filter uncategorized bank transactions by date
2024-08-23 17:42:59 +02:00
Ahmed Bouhuolia
b4d426d2e8 feat: Filter uncategorized transactions by date 2024-08-23 17:40:05 +02:00
Ahmed Bouhuolia
7f7dd270e7 Merge pull request #619 from bigcapitalhq/change-banking-service-language
feat: change banking service language
2024-08-23 02:00:47 +02:00
Ahmed Bouhuolia
f6bad8fe30 fix: wip filter uncategorized transactions by date 2024-08-23 01:57:52 +02:00
Ahmed Bouhuolia
1062b65b5b feat: wip bank account transactions date filter 2024-08-22 01:03:03 +02:00
Ahmed Bouhuolia
bf3a70cabd Merge branch 'develop' into filter-uncategorized-bank-transactions 2024-08-22 00:12:37 +02:00
Ahmed Bouhuolia
df8b68fda6 feat(banking): Filter uncategorized bank transactions 2024-08-11 18:34:45 +02:00
104 changed files with 1816 additions and 494 deletions

View File

@@ -37,6 +37,7 @@
"agendash": "^3.1.0", "agendash": "^3.1.0",
"app-root-path": "^3.0.0", "app-root-path": "^3.0.0",
"async": "^3.2.0", "async": "^3.2.0",
"async-mutex": "^0.5.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"babel-loader": "^9.1.2", "babel-loader": "^9.1.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",

View File

@@ -46,6 +46,10 @@ export class ExcludeBankTransactionsController extends BaseController {
query('account_id').optional().isNumeric().toInt(), query('account_id').optional().isNumeric().toInt(),
query('page').optional().isNumeric().toInt(), query('page').optional().isNumeric().toInt(),
query('page_size').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.validationResult,
this.getExcludedBankTransactions.bind(this) this.getExcludedBankTransactions.bind(this)

View File

@@ -21,6 +21,10 @@ export class RecognizedTransactionsController extends BaseController {
query('page').optional().isNumeric().toInt(), query('page').optional().isNumeric().toInt(),
query('page_size').optional().isNumeric().toInt(), query('page_size').optional().isNumeric().toInt(),
query('account_id').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.validationResult,
this.getRecognizedTransactions.bind(this) this.getRecognizedTransactions.bind(this)

View File

@@ -84,6 +84,10 @@ export default class NewCashflowTransactionController extends BaseController {
param('id').exists().isNumeric().toInt(), param('id').exists().isNumeric().toInt(),
query('page').optional().isNumeric().toInt(), query('page').optional().isNumeric().toInt(),
query('page_size').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(),
]; ];
} }

View File

@@ -113,7 +113,7 @@ export class SubscriptionController extends BaseController {
const { tenantId } = req; const { tenantId } = req;
try { try {
await this.subscriptionApp.cancelSubscription(tenantId, '455610'); await this.subscriptionApp.cancelSubscription(tenantId);
return res.status(200).send({ return res.status(200).send({
status: 200, status: 200,

View File

@@ -35,7 +35,7 @@ export class Webhooks extends BaseController {
*/ */
public async lemonWebhooks(req: Request, res: Response, next: NextFunction) { public async lemonWebhooks(req: Request, res: Response, next: NextFunction) {
const data = req.body; const data = req.body;
const signature = req.headers['x-signature'] ?? ''; const signature = req.headers['x-signature'] as string ?? '';
const rawBody = req.rawBody; const rawBody = req.rawBody;
try { try {

View File

@@ -246,8 +246,11 @@ module.exports = {
apiKey: process.env.LOOPS_API_KEY, apiKey: process.env.LOOPS_API_KEY,
}, },
oneClickDemoAccounts: parseBoolean( /**
process.env.ONE_CLICK_DEMO_ACCOUNTS, * One-click demo accounts.
false */
), oneClickDemoAccounts: {
enable: parseBoolean(process.env.ONE_CLICK_DEMO_ACCOUNTS, false),
demoUrl: process.env.ONE_CLICK_DEMO_ACCOUNTS_URL || '',
},
}; };

View File

@@ -15,6 +15,7 @@ export default class SeedSettings extends TenantSeeder {
// Manual journals settings. // Manual journals settings.
{ group: 'manual_journals', key: 'next_number', value: '00001' }, { group: 'manual_journals', key: 'next_number', value: '00001' },
{ group: 'manual_journals', key: 'number_prefix', value: 'J-' },
{ group: 'manual_journals', key: 'auto_increment', value: true }, { group: 'manual_journals', key: 'auto_increment', value: true },
// Sale invoices settings. // Sale invoices settings.

View File

@@ -76,6 +76,10 @@ export interface IAuthSendedResetPassword {
export interface IAuthGetMetaPOJO { export interface IAuthGetMetaPOJO {
signupDisabled: boolean; signupDisabled: boolean;
oneClickDemo: {
enable: boolean;
demoUrl: string;
};
} }
export interface IAuthSignUpVerifingEventPayload { export interface IAuthSignUpVerifingEventPayload {

View File

@@ -167,11 +167,18 @@ export interface CategorizeTransactionAsExpenseDTO {
export interface IGetUncategorizedTransactionsQuery { export interface IGetUncategorizedTransactionsQuery {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
minDate?: Date;
maxDate?: Date;
minAmount?: number;
maxAmount?: number;
} }
export interface IGetRecognizedTransactionsQuery { export interface IGetRecognizedTransactionsQuery {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
accountId?: number; accountId?: number;
minDate?: Date;
maxDate?: Date;
minAmount?: number;
maxAmount?: number;
} }

View File

@@ -3,6 +3,6 @@ import { ImportFilePreviewPOJO } from "@/services/Import/interfaces";
export interface IImportFileCommitedEventPayload { export interface IImportFileCommitedEventPayload {
tenantId: number; tenantId: number;
importId: number; importId: string;
meta: ImportFilePreviewPOJO; meta: ImportFilePreviewPOJO;
} }

View File

@@ -0,0 +1,8 @@
export interface SubscriptionPayload {
lemonSqueezyId?: string;
}
export enum SubscriptionPaymentStatus {
Succeed = 'succeed',
Failed = 'failed',
}

View File

@@ -75,6 +75,7 @@ export * from './Times';
export * from './ProjectProfitabilitySummary'; export * from './ProjectProfitabilitySummary';
export * from './TaxRate'; export * from './TaxRate';
export * from './Plaid'; export * from './Plaid';
export * from './Subscription';
export interface I18nService { export interface I18nService {
__: (input: string) => string; __: (input: string) => string;

View File

@@ -2,6 +2,7 @@ import moment from 'moment';
import * as R from 'ramda'; import * as R from 'ramda';
import { includes, isFunction, isObject, isUndefined, omit } from 'lodash'; import { includes, isFunction, isObject, isUndefined, omit } from 'lodash';
import { formatNumber, sortObjectKeysAlphabetically } from 'utils'; import { formatNumber, sortObjectKeysAlphabetically } from 'utils';
import { EXPORT_DTE_FORMAT } from '@/services/Export/constants';
export class Transformer { export class Transformer {
public context: any; public context: any;
@@ -156,22 +157,34 @@ export class Transformer {
} }
/** /**
* * Format date.
* @param date * @param {} date
* @returns * @param {string} format -
* @returns {}
*/ */
protected formatDate(date) { protected formatDate(date, format?: string) {
return date ? moment(date).format(this.dateFormat) : ''; // Use the export date format if the async operation is in exporting,
// otherwise use the given or default format.
const _format = this.context.exportAls.isExport
? EXPORT_DTE_FORMAT
: format || this.dateFormat;
return date ? moment(date).format(_format) : '';
} }
protected formatDateFromNow(date){ /**
*
* @param date
* @returns {}
*/
protected formatDateFromNow(date) {
return date ? moment(date).fromNow(true) : ''; return date ? moment(date).fromNow(true) : '';
} }
/** /**
* *
* @param number * @param number
* @returns * @returns {}
*/ */
protected formatNumber(number, props?) { protected formatNumber(number, props?) {
return formatNumber(number, { money: false, ...props }); return formatNumber(number, { money: false, ...props });
@@ -181,7 +194,7 @@ export class Transformer {
* *
* @param money * @param money
* @param options * @param options
* @returns * @returns {}
*/ */
protected formatMoney(money, options?) { protected formatMoney(money, options?) {
return formatNumber(money, { return formatNumber(money, {

View File

@@ -3,12 +3,17 @@ import { isNull } from 'lodash';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import { TenantMetadata } from '@/system/models'; import { TenantMetadata } from '@/system/models';
import { Transformer } from './Transformer'; import { Transformer } from './Transformer';
import { ImportAls } from '@/services/Import/ImportALS';
import { ExportAls } from '@/services/Export/ExportAls';
@Service() @Service()
export class TransformerInjectable { export class TransformerInjectable {
@Inject() @Inject()
private tenancy: HasTenancyService; private tenancy: HasTenancyService;
@Inject()
private exportAls: ExportAls;
/** /**
* Retrieves the application context of all tenant transformers. * Retrieves the application context of all tenant transformers.
* @param {number} tenantId * @param {number} tenantId
@@ -17,10 +22,12 @@ export class TransformerInjectable {
async getApplicationContext(tenantId: number) { async getApplicationContext(tenantId: number) {
const i18n = this.tenancy.i18n(tenantId); const i18n = this.tenancy.i18n(tenantId);
const organization = await TenantMetadata.query().findOne({ tenantId }); const organization = await TenantMetadata.query().findOne({ tenantId });
const exportAls = this.exportAls;
return { return {
organization, organization,
i18n, i18n,
exportAls,
}; };
} }

View File

@@ -117,6 +117,7 @@ import { DisconnectPlaidItemOnAccountDeleted } from '@/services/Banking/BankAcco
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber'; import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting'; import { DeleteUncategorizedTransactionsOnAccountDeleting } from '@/services/Banking/BankAccounts/events/DeleteUncategorizedTransactionsOnAccountDeleting';
import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData'; import { SeedInitialDemoAccountDataOnOrgBuild } from '@/services/OneClickDemo/events/SeedInitialDemoAccountData';
import { TriggerInvalidateCacheOnSubscriptionChange } from '@/services/Subscription/events/TriggerInvalidateCacheOnSubscriptionChange';
export default () => { export default () => {
return new EventPublisher(); return new EventPublisher();
@@ -247,8 +248,10 @@ export const susbcribers = () => {
DeleteCashflowTransactionOnUncategorize, DeleteCashflowTransactionOnUncategorize,
PreventDeleteTransactionOnDelete, PreventDeleteTransactionOnDelete,
// Subscription
SubscribeFreeOnSignupCommunity, SubscribeFreeOnSignupCommunity,
SendVerfiyMailOnSignUp, SendVerfiyMailOnSignUp,
TriggerInvalidateCacheOnSubscriptionChange,
// Attachments // Attachments
AttachmentsOnSaleInvoiceCreated, AttachmentsOnSaleInvoiceCreated,
@@ -282,9 +285,9 @@ export const susbcribers = () => {
DeleteUncategorizedTransactionsOnAccountDeleting, DeleteUncategorizedTransactionsOnAccountDeleting,
// Loops // Loops
LoopsEventsSubscriber LoopsEventsSubscriber,
// Demo Account // Demo Account
SeedInitialDemoAccountDataOnOrgBuild SeedInitialDemoAccountDataOnOrgBuild,
]; ];
}; };

View File

@@ -95,6 +95,11 @@ export default {
}, },
}, },
columns: { columns: {
customerType: {
name: 'Customer Type',
type: 'text',
accessor: 'formattedCustomerType',
},
firstName: { firstName: {
name: 'vendor.field.first_name', name: 'vendor.field.first_name',
type: 'text', type: 'text',
@@ -135,116 +140,117 @@ export default {
openingBalance: { openingBalance: {
name: 'vendor.field.opening_balance', name: 'vendor.field.opening_balance',
type: 'number', type: 'number',
printable: false printable: false,
}, },
openingBalanceAt: { openingBalanceAt: {
name: 'vendor.field.opening_balance_at', name: 'vendor.field.opening_balance_at',
type: 'date', type: 'date',
printable: false printable: false,
accessor: 'formattedOpeningBalanceAt'
}, },
currencyCode: { currencyCode: {
name: 'vendor.field.currency', name: 'vendor.field.currency',
type: 'text', type: 'text',
printable: false printable: false,
}, },
status: { status: {
name: 'vendor.field.status', name: 'vendor.field.status',
printable: false printable: false,
}, },
note: { note: {
name: 'vendor.field.note', name: 'vendor.field.note',
printable: false printable: false,
}, },
// Billing Address // Billing Address
billingAddress1: { billingAddress1: {
name: 'Billing Address 1', name: 'Billing Address 1',
column: 'billing_address1', column: 'billing_address1',
type: 'text', type: 'text',
printable: false printable: false,
}, },
billingAddress2: { billingAddress2: {
name: 'Billing Address 2', name: 'Billing Address 2',
column: 'billing_address2', column: 'billing_address2',
type: 'text', type: 'text',
printable: false printable: false,
}, },
billingAddressCity: { billingAddressCity: {
name: 'Billing Address City', name: 'Billing Address City',
column: 'billing_address_city', column: 'billing_address_city',
type: 'text', type: 'text',
printable: false printable: false,
}, },
billingAddressCountry: { billingAddressCountry: {
name: 'Billing Address Country', name: 'Billing Address Country',
column: 'billing_address_country', column: 'billing_address_country',
type: 'text', type: 'text',
printable: false printable: false,
}, },
billingAddressPostcode: { billingAddressPostcode: {
name: 'Billing Address Postcode', name: 'Billing Address Postcode',
column: 'billing_address_postcode', column: 'billing_address_postcode',
type: 'text', type: 'text',
printable: false printable: false,
}, },
billingAddressState: { billingAddressState: {
name: 'Billing Address State', name: 'Billing Address State',
column: 'billing_address_state', column: 'billing_address_state',
type: 'text', type: 'text',
printable: false printable: false,
}, },
billingAddressPhone: { billingAddressPhone: {
name: 'Billing Address Phone', name: 'Billing Address Phone',
column: 'billing_address_phone', column: 'billing_address_phone',
type: 'text', type: 'text',
printable: false printable: false,
}, },
// Shipping Address // Shipping Address
shippingAddress1: { shippingAddress1: {
name: 'Shipping Address 1', name: 'Shipping Address 1',
column: 'shipping_address1', column: 'shipping_address1',
type: 'text', type: 'text',
printable: false printable: false,
}, },
shippingAddress2: { shippingAddress2: {
name: 'Shipping Address 2', name: 'Shipping Address 2',
column: 'shipping_address2', column: 'shipping_address2',
type: 'text', type: 'text',
printable: false printable: false,
}, },
shippingAddressCity: { shippingAddressCity: {
name: 'Shipping Address City', name: 'Shipping Address City',
column: 'shipping_address_city', column: 'shipping_address_city',
type: 'text', type: 'text',
printable: false printable: false,
}, },
shippingAddressCountry: { shippingAddressCountry: {
name: 'Shipping Address Country', name: 'Shipping Address Country',
column: 'shipping_address_country', column: 'shipping_address_country',
type: 'text', type: 'text',
printable: false printable: false,
}, },
shippingAddressPostcode: { shippingAddressPostcode: {
name: 'Shipping Address Postcode', name: 'Shipping Address Postcode',
column: 'shipping_address_postcode', column: 'shipping_address_postcode',
type: 'text', type: 'text',
printable: false printable: false,
}, },
shippingAddressPhone: { shippingAddressPhone: {
name: 'Shipping Address Phone', name: 'Shipping Address Phone',
column: 'shipping_address_phone', column: 'shipping_address_phone',
type: 'text', type: 'text',
printable: false printable: false,
}, },
shippingAddressState: { shippingAddressState: {
name: 'Shipping Address State', name: 'Shipping Address State',
column: 'shipping_address_state', column: 'shipping_address_state',
type: 'text', type: 'text',
printable: false printable: false,
}, },
createdAt: { createdAt: {
name: 'vendor.field.created_at', name: 'vendor.field.created_at',
type: 'date', type: 'date',
printable: false printable: false,
}, },
}, },
fields2: { fields2: {

View File

@@ -1,8 +1,8 @@
/* eslint-disable global-require */ /* eslint-disable global-require */
import moment from 'moment';
import { Model, mixin } from 'objection'; import { Model, mixin } from 'objection';
import TenantModel from 'models/TenantModel'; import TenantModel from 'models/TenantModel';
import ModelSettings from './ModelSetting'; import ModelSettings from './ModelSetting';
import Account from './Account';
import UncategorizedCashflowTransactionMeta from './UncategorizedCashflowTransaction.meta'; import UncategorizedCashflowTransactionMeta from './UncategorizedCashflowTransaction.meta';
export default class UncategorizedCashflowTransaction extends mixin( export default class UncategorizedCashflowTransaction extends mixin(
@@ -166,6 +166,28 @@ export default class UncategorizedCashflowTransaction extends mixin(
pending(query) { pending(query) {
query.where('pending', true); 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);
},
}; };
} }

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { AccountsApplication } from './AccountsApplication'; import { AccountsApplication } from './AccountsApplication';
import { Exportable } from '../Export/Exportable'; import { Exportable } from '../Export/Exportable';
import { IAccountsFilter, IAccountsStructureType } from '@/interfaces'; import { IAccountsFilter, IAccountsStructureType } from '@/interfaces';
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
@Service() @Service()
export class AccountsExportable extends Exportable { export class AccountsExportable extends Exportable {
@@ -20,7 +21,7 @@ export class AccountsExportable extends Exportable {
inactiveMode: false, inactiveMode: false,
...query, ...query,
structure: IAccountsStructureType.Flat, structure: IAccountsStructureType.Flat,
pageSize: 12000, pageSize: EXPORT_SIZE_LIMIT,
page: 1, page: 1,
} as IAccountsFilter; } as IAccountsFilter;

View File

@@ -11,6 +11,10 @@ export class GetAuthMeta {
public async getAuthMeta(): Promise<IAuthGetMetaPOJO> { public async getAuthMeta(): Promise<IAuthGetMetaPOJO> {
return { return {
signupDisabled: config.signupRestrictions.disabled, signupDisabled: config.signupRestrictions.disabled,
oneClickDemo: {
enable: config.oneClickDemoAccounts.enable,
demoUrl: config.oneClickDemoAccounts.demoUrl,
},
}; };
} }
} }

View File

@@ -1,5 +1,6 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import moment from 'moment';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ExcludedBankTransactionsQuery } from './_types'; import { ExcludedBankTransactionsQuery } from './_types';
import { UncategorizedTransactionTransformer } from '@/services/Cashflow/UncategorizedTransactionTransformer'; import { UncategorizedTransactionTransformer } from '@/services/Cashflow/UncategorizedTransactionTransformer';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@@ -39,6 +40,18 @@ export class GetExcludedBankTransactionsService {
if (_query.accountId) { if (_query.accountId) {
q.where('account_id', _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); .pagination(_query.page - 1, _query.pageSize);

View File

@@ -4,6 +4,10 @@ export interface ExcludedBankTransactionsQuery {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
accountId?: number; accountId?: number;
minDate?: Date;
maxDate?: Date;
minAmount?: number;
maxAmount?: number;
} }
export interface IBankTransactionUnexcludingEventPayload { export interface IBankTransactionUnexcludingEventPayload {

View File

@@ -100,7 +100,6 @@ export class TriggerRecognizedTransactions {
private async triggerRecognizeTransactionsOnImportCommitted({ private async triggerRecognizeTransactionsOnImportCommitted({
tenantId, tenantId,
importId, importId,
meta,
}: IImportFileCommitedEventPayload) { }: IImportFileCommitedEventPayload) {
const importFile = await Import.query().findOne({ importId }); const importFile = await Import.query().findOne({ importId });
const batch = importFile.paramsParsed.batch; const batch = importFile.paramsParsed.batch;

View File

@@ -23,7 +23,7 @@ export class GetRecognizedTransactionsService {
) { ) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const _filter = { const _query = {
page: 1, page: 1,
pageSize: 20, pageSize: 20,
...filter, ...filter,
@@ -41,11 +41,26 @@ export class GetRecognizedTransactionsService {
// Exclude the pending transactions. // Exclude the pending transactions.
q.modify('notPending'); q.modify('notPending');
if (_filter.accountId) { if (_query.accountId) {
q.where('accountId', _filter.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( const data = await this.transformer.transform(
tenantId, tenantId,

View File

@@ -62,6 +62,19 @@ export class GetUncategorizedTransactions {
q.whereNull('matchedBankTransactions.id'); q.whereNull('matchedBankTransactions.id');
q.orderBy('date', 'DESC'); 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); .pagination(_query.page - 1, _query.pageSize);

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { IItemsFilter } from '@/interfaces'; import { IItemsFilter } from '@/interfaces';
import { CustomersApplication } from './CustomersApplication'; import { CustomersApplication } from './CustomersApplication';
import { Exportable } from '@/services/Export/Exportable'; import { Exportable } from '@/services/Export/Exportable';
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
@Service() @Service()
export class CustomersExportable extends Exportable { export class CustomersExportable extends Exportable {
@@ -17,9 +18,9 @@ export class CustomersExportable extends Exportable {
const parsedQuery = { const parsedQuery = {
sortOrder: 'DESC', sortOrder: 'DESC',
columnSortBy: 'created_at', columnSortBy: 'created_at',
page: 1,
...query, ...query,
pageSize: 12, page: 1,
pageSize: EXPORT_SIZE_LIMIT,
} as IItemsFilter; } as IItemsFilter;
return this.customersApplication return this.customersApplication

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { IItemsFilter } from '@/interfaces'; import { IItemsFilter } from '@/interfaces';
import { Exportable } from '@/services/Export/Exportable'; import { Exportable } from '@/services/Export/Exportable';
import { VendorsApplication } from './VendorsApplication'; import { VendorsApplication } from './VendorsApplication';
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
@Service() @Service()
export class VendorsExportable extends Exportable { export class VendorsExportable extends Exportable {
@@ -17,9 +18,9 @@ export class VendorsExportable extends Exportable {
const parsedQuery = { const parsedQuery = {
sortOrder: 'DESC', sortOrder: 'DESC',
columnSortBy: 'created_at', columnSortBy: 'created_at',
page: 1,
...query, ...query,
pageSize: 12, page: 1,
pageSize: EXPORT_SIZE_LIMIT,
} as IItemsFilter; } as IItemsFilter;
return this.vendorsApplication return this.vendorsApplication

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { Exportable } from '../Export/Exportable'; import { Exportable } from '../Export/Exportable';
import { IExpensesFilter } from '@/interfaces'; import { IExpensesFilter } from '@/interfaces';
import { ExpensesApplication } from './ExpensesApplication'; import { ExpensesApplication } from './ExpensesApplication';
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
@Service() @Service()
export class ExpensesExportable extends Exportable { export class ExpensesExportable extends Exportable {
@@ -19,7 +20,7 @@ export class ExpensesExportable extends Exportable {
columnSortBy: 'created_at', columnSortBy: 'created_at',
...query, ...query,
page: 1, page: 1,
pageSize: 12000, pageSize: EXPORT_SIZE_LIMIT,
} as IExpensesFilter; } as IExpensesFilter;
return this.expensesApplication return this.expensesApplication

View File

@@ -0,0 +1,48 @@
import { Service } from 'typedi';
import { AsyncLocalStorage } from 'async_hooks';
@Service()
export class ExportAls {
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(), () => {
this.markAsExport();
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 export operation.
* @param flag Boolean flag to set or unset the export status. Defaults to true.
*/
public markAsExport(flag: boolean = true): void {
const store = this.getStore();
store?.set('isExport', flag);
}
/**
* Checks if the current context is an export operation.
* @returns {boolean} True if the context is an export operation, false otherwise.
*/
public get isExport(): boolean {
return !!this.getStore()?.get('isExport');
}
}

View File

@@ -10,6 +10,7 @@ import { Errors, ExportFormat } from './common';
import { IModelMeta, IModelMetaColumn } from '@/interfaces'; import { IModelMeta, IModelMetaColumn } from '@/interfaces';
import { flatDataCollections, getDataAccessor } from './utils'; import { flatDataCollections, getDataAccessor } from './utils';
import { ExportPdf } from './ExportPdf'; import { ExportPdf } from './ExportPdf';
import { ExportAls } from './ExportAls';
@Service() @Service()
export class ExportResourceService { export class ExportResourceService {
@@ -22,13 +23,33 @@ export class ExportResourceService {
@Inject() @Inject()
private exportPdf: ExportPdf; private exportPdf: ExportPdf;
@Inject()
private exportAls: ExportAls;
/**
*
* @param {number} tenantId
* @param {string} resourceName
* @param {ExportFormat} format
* @returns
*/
public async export(
tenantId: number,
resourceName: string,
format: ExportFormat = ExportFormat.Csv
) {
return this.exportAls.run(() =>
this.exportAlsRun(tenantId, resourceName, format)
);
}
/** /**
* Exports the given resource data through csv, xlsx or pdf. * Exports the given resource data through csv, xlsx or pdf.
* @param {number} tenantId - Tenant id. * @param {number} tenantId - Tenant id.
* @param {string} resourceName - Resource name. * @param {string} resourceName - Resource name.
* @param {ExportFormat} format - File format. * @param {ExportFormat} format - File format.
*/ */
public async export( public async exportAlsRun(
tenantId: number, tenantId: number,
resourceName: string, resourceName: string,
format: ExportFormat = ExportFormat.Csv format: ExportFormat = ExportFormat.Csv

View File

@@ -0,0 +1,2 @@
export const EXPORT_SIZE_LIMIT = 9999999;
export const EXPORT_DTE_FORMAT = 'YYYY-MM-DD';

View File

@@ -0,0 +1,105 @@
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 get 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 get 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 get isImportPreview(): boolean {
return !!this.getStore()?.get('isImportPreview');
}
}

View File

@@ -1,4 +1,3 @@
import XLSX from 'xlsx';
import bluebird from 'bluebird'; import bluebird from 'bluebird';
import * as R from 'ramda'; import * as R from 'ramda';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
@@ -28,22 +27,6 @@ export class ImportFileCommon {
@Inject() @Inject()
private resource: ResourceService; private resource: ResourceService;
/**
* Maps the columns of the imported data based on the provided mapping attributes.
* @param {Record<string, any>[]} body - The array of data objects to map.
* @param {ImportMappingAttr[]} map - The mapping attributes.
* @returns {Record<string, any>[]} - The mapped data objects.
*/
public parseXlsxSheet(buffer: Buffer): Record<string, unknown>[] {
const workbook = XLSX.read(buffer, { type: 'buffer', raw: true });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
return XLSX.utils.sheet_to_json(worksheet, {});
}
/** /**
* Imports the given parsed data to the resource storage through registered importable service. * Imports the given parsed data to the resource storage through registered importable service.
* @param {number} tenantId - * @param {number} tenantId -

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService'; import HasTenancyService from '../Tenancy/TenancyService';
import { ImportFilePreviewPOJO } from './interfaces'; import { ImportFilePreviewPOJO } from './interfaces';
import { ImportFileProcess } from './ImportFileProcess'; import { ImportFileProcess } from './ImportFileProcess';
import { ImportAls } from './ImportALS';
@Service() @Service()
export class ImportFilePreview { export class ImportFilePreview {
@@ -11,13 +12,31 @@ export class ImportFilePreview {
@Inject() @Inject()
private importFile: ImportFileProcess; 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. * Preview the imported file results before commiting the transactions.
* @param {number} tenantId * @param {number} tenantId
* @param {number} importId * @param {number} importId
* @returns {Promise<ImportFilePreviewPOJO>} * @returns {Promise<ImportFilePreviewPOJO>}
*/ */
public async preview( public async previewAlsRun(
tenantId: number, tenantId: number,
importId: string importId: string
): Promise<ImportFilePreviewPOJO> { ): Promise<ImportFilePreviewPOJO> {

View File

@@ -2,18 +2,14 @@ import { Inject, Service } from 'typedi';
import { chain } from 'lodash'; import { chain } from 'lodash';
import { Knex } from 'knex'; import { Knex } from 'knex';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { import { ERRORS, getUnmappedSheetColumns, readImportFile } from './_utils';
ERRORS,
getSheetColumns,
getUnmappedSheetColumns,
readImportFile,
} from './_utils';
import { ImportFileCommon } from './ImportFileCommon'; import { ImportFileCommon } from './ImportFileCommon';
import { ImportFileDataTransformer } from './ImportFileDataTransformer'; import { ImportFileDataTransformer } from './ImportFileDataTransformer';
import ResourceService from '../Resource/ResourceService'; import ResourceService from '../Resource/ResourceService';
import UnitOfWork from '../UnitOfWork'; import UnitOfWork from '../UnitOfWork';
import { ImportFilePreviewPOJO } from './interfaces'; import { ImportFilePreviewPOJO } from './interfaces';
import { Import } from '@/system/models'; import { Import } from '@/system/models';
import { parseSheetData } from './sheet_utils';
@Service() @Service()
export class ImportFileProcess { export class ImportFileProcess {
@@ -49,10 +45,10 @@ export class ImportFileProcess {
if (!importFile.isMapped) { if (!importFile.isMapped) {
throw new ServiceError(ERRORS.IMPORT_FILE_NOT_MAPPED); throw new ServiceError(ERRORS.IMPORT_FILE_NOT_MAPPED);
} }
// Read the imported file. // Read the imported file and parse the given buffer to get columns
// and sheet data in json format.
const buffer = await readImportFile(importFile.filename); const buffer = await readImportFile(importFile.filename);
const sheetData = this.importCommon.parseXlsxSheet(buffer); const [sheetData, sheetColumns] = parseSheetData(buffer);
const header = getSheetColumns(sheetData);
const resource = importFile.resource; const resource = importFile.resource;
const resourceFields = this.resource.getResourceFields2(tenantId, resource); const resourceFields = this.resource.getResourceFields2(tenantId, resource);
@@ -87,7 +83,7 @@ export class ImportFileProcess {
.flatten() .flatten()
.value(); .value();
const unmappedColumns = getUnmappedSheetColumns(header, mapping); const unmappedColumns = getUnmappedSheetColumns(sheetColumns, mapping);
const totalCount = allData.length; const totalCount = allData.length;
const createdCount = successedImport.length; const createdCount = successedImport.length;

View File

@@ -5,6 +5,7 @@ import { ImportFileProcess } from './ImportFileProcess';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { IImportFileCommitedEventPayload } from '@/interfaces/Import'; import { IImportFileCommitedEventPayload } from '@/interfaces/Import';
import { ImportAls } from './ImportALS';
@Service() @Service()
export class ImportFileProcessCommit { export class ImportFileProcessCommit {
@@ -14,16 +15,34 @@ export class ImportFileProcessCommit {
@Inject() @Inject()
private importFile: ImportFileProcess; private importFile: ImportFileProcess;
@Inject()
private importAls: ImportAls;
@Inject() @Inject()
private eventPublisher: EventPublisher; 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. * Commits the imported file.
* @param {number} tenantId * @param {number} tenantId
* @param {number} importId * @param {number} importId
* @returns {Promise<ImportFilePreviewPOJO>} * @returns {Promise<ImportFilePreviewPOJO>}
*/ */
public async commit( public async commitAlsRun(
tenantId: number, tenantId: number,
importId: string importId: string
): Promise<ImportFilePreviewPOJO> { ): Promise<ImportFilePreviewPOJO> {

View File

@@ -11,6 +11,7 @@ import { ImportFileCommon } from './ImportFileCommon';
import { ImportFileDataValidator } from './ImportFileDataValidator'; import { ImportFileDataValidator } from './ImportFileDataValidator';
import { ImportFileUploadPOJO } from './interfaces'; import { ImportFileUploadPOJO } from './interfaces';
import { Import } from '@/system/models'; import { Import } from '@/system/models';
import { parseSheetData } from './sheet_utils';
@Service() @Service()
export class ImportFileUploadService { export class ImportFileUploadService {
@@ -77,14 +78,12 @@ export class ImportFileUploadService {
const buffer = await readImportFile(filename); const buffer = await readImportFile(filename);
// Parse the buffer file to array data. // Parse the buffer file to array data.
const sheetData = this.importFileCommon.parseXlsxSheet(buffer); const [sheetData, sheetColumns] = parseSheetData(buffer);
const coumnsStringified = JSON.stringify(sheetColumns);
// Throws service error if the sheet data is empty. // Throws service error if the sheet data is empty.
validateSheetEmpty(sheetData); validateSheetEmpty(sheetData);
const sheetColumns = this.importFileCommon.parseSheetColumns(sheetData);
const coumnsStringified = JSON.stringify(sheetColumns);
try { try {
// Validates the params Yup schema. // Validates the params Yup schema.
await this.importFileCommon.validateParamsSchema(resource, params); await this.importFileCommon.validateParamsSchema(resource, params);

View File

@@ -0,0 +1,56 @@
import XLSX from 'xlsx';
import { first } from 'lodash';
/**
* Parses the given sheet buffer to worksheet.
* @param {Buffer} buffer
* @returns {XLSX.WorkSheet}
*/
export function parseFirstSheet(buffer: Buffer): XLSX.WorkSheet {
const workbook = XLSX.read(buffer, { type: 'buffer', raw: true });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
return worksheet;
}
/**
* Extracts the given worksheet to columns.
* @param {XLSX.WorkSheet} worksheet
* @returns {Array<string>}
*/
export function extractSheetColumns(worksheet: XLSX.WorkSheet): Array<string> {
// By default, sheet_to_json scans the first row and uses the values as headers.
// With the header: 1 option, the function exports an array of arrays of values.
const sheetCells = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const sheetCols = first(sheetCells) as Array<string>;
return sheetCols.filter((col) => col);
}
/**
* Parses the given worksheet to json values. the keys are columns labels.
* @param {XLSX.WorkSheet} worksheet
* @returns {Array<Record<string, string>>}
*/
export function parseSheetToJson(
worksheet: XLSX.WorkSheet
): Array<Record<string, string>> {
return XLSX.utils.sheet_to_json(worksheet, {});
}
/**
* Parses the given sheet buffer then retrieves the sheet data and columns.
* @param {Buffer} buffer
*/
export function parseSheetData(
buffer: Buffer
): [Array<Record<string, string>>, string[]] {
const worksheet = parseFirstSheet(buffer);
const columns = extractSheetColumns(worksheet);
const data = parseSheetToJson(worksheet);
return [data, columns];
}

View File

@@ -139,24 +139,25 @@ export default class InventoryService {
) { ) {
const agenda = Container.get('agenda'); 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 // Cancel any `compute-item-cost` in the queue has upper starting date
// with the same given item. // with the same given item.
await agenda.cancel({ await agenda.cancel({
name: 'compute-item-cost', ...commonJobsQuery,
nextRunAt: { $ne: null }, 'data.startingDate': { $lte: startingDate },
'data.tenantId': tenantId,
'data.itemId': itemId,
'data.startingDate': { $gt: startingDate },
}); });
// Retrieve any `compute-item-cost` in the queue has lower starting date // Retrieve any `compute-item-cost` in the queue has lower starting date
// with the same given item. // with the same given item.
const dependsJobs = await agenda.jobs({ const dependsJobs = await agenda.jobs({
name: 'compute-item-cost', ...commonJobsQuery,
nextRunAt: { $ne: null }, 'data.startingDate': { $gte: startingDate },
'data.tenantId': tenantId,
'data.itemId': itemId,
'data.startingDate': { $lte: startingDate },
}); });
// If the depends jobs cleared.
if (dependsJobs.length === 0) { if (dependsJobs.length === 0) {
await agenda.schedule( await agenda.schedule(
config.scheduleComputeItemCost, config.scheduleComputeItemCost,
@@ -172,6 +173,13 @@ export default class InventoryService {
events.inventory.onComputeItemCostJobScheduled, events.inventory.onComputeItemCostJobScheduled,
{ startingDate, itemId, tenantId } as IInventoryItemCostScheduledPayload { 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()
)
);
} }
} }

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { Exportable } from '../Export/Exportable'; import { Exportable } from '../Export/Exportable';
import { IItemsFilter } from '@/interfaces'; import { IItemsFilter } from '@/interfaces';
import { ItemsApplication } from './ItemsApplication'; import { ItemsApplication } from './ItemsApplication';
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
@Service() @Service()
export class ItemsExportable extends Exportable { export class ItemsExportable extends Exportable {
@@ -19,7 +20,7 @@ export class ItemsExportable extends Exportable {
columnSortBy: 'created_at', columnSortBy: 'created_at',
page: 1, page: 1,
...query, ...query,
pageSize: 12, pageSize: EXPORT_SIZE_LIMIT,
} as IItemsFilter; } as IItemsFilter;
return this.itemsApplication return this.itemsApplication

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { IManualJournalsFilter } from '@/interfaces'; import { IManualJournalsFilter } from '@/interfaces';
import { Exportable } from '../Export/Exportable'; import { Exportable } from '../Export/Exportable';
import { ManualJournalsApplication } from './ManualJournalsApplication'; import { ManualJournalsApplication } from './ManualJournalsApplication';
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
@Service() @Service()
export class ManualJournalsExportable extends Exportable { export class ManualJournalsExportable extends Exportable {
@@ -19,7 +20,7 @@ export class ManualJournalsExportable extends Exportable {
columnSortBy: 'created_at', columnSortBy: 'created_at',
...query, ...query,
page: 1, page: 1,
pageSize: 12000, pageSize: EXPORT_SIZE_LIMIT,
} as IManualJournalsFilter; } as IManualJournalsFilter;
return this.manualJournalsApplication return this.manualJournalsApplication

View File

@@ -1,6 +1,7 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { Exportable } from '@/services/Export/Exportable'; import { Exportable } from '@/services/Export/Exportable';
import { BillPaymentsApplication } from './BillPaymentsApplication'; import { BillPaymentsApplication } from './BillPaymentsApplication';
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
@Service() @Service()
export class BillPaymentExportable extends Exportable { export class BillPaymentExportable extends Exportable {
@@ -14,11 +15,11 @@ export class BillPaymentExportable extends Exportable {
*/ */
public exportable(tenantId: number, query: any) { public exportable(tenantId: number, query: any) {
const parsedQuery = { const parsedQuery = {
page: 1,
pageSize: 12,
...query,
sortOrder: 'desc', sortOrder: 'desc',
columnSortBy: 'created_at', columnSortBy: 'created_at',
...query,
page: 1,
pageSize: EXPORT_SIZE_LIMIT,
} as any; } as any;
return this.billPaymentsApplication return this.billPaymentsApplication

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { IBillsFilter } from '@/interfaces'; import { IBillsFilter } from '@/interfaces';
import { Exportable } from '@/services/Export/Exportable'; import { Exportable } from '@/services/Export/Exportable';
import { BillsApplication } from './BillsApplication'; import { BillsApplication } from './BillsApplication';
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
@Service() @Service()
export class BillsExportable extends Exportable { export class BillsExportable extends Exportable {
@@ -19,7 +20,7 @@ export class BillsExportable extends Exportable {
columnSortBy: 'created_at', columnSortBy: 'created_at',
...query, ...query,
page: 1, page: 1,
pageSize: 12000, pageSize: EXPORT_SIZE_LIMIT,
} as IBillsFilter; } as IBillsFilter;
return this.billsApplication return this.billsApplication

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { ISalesInvoicesFilter } from '@/interfaces'; import { ISalesInvoicesFilter } from '@/interfaces';
import { Exportable } from '@/services/Export/Exportable'; import { Exportable } from '@/services/Export/Exportable';
import { SaleEstimatesApplication } from './SaleEstimatesApplication'; import { SaleEstimatesApplication } from './SaleEstimatesApplication';
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
@Service() @Service()
export class SaleEstimatesExportable extends Exportable { export class SaleEstimatesExportable extends Exportable {
@@ -19,7 +20,7 @@ export class SaleEstimatesExportable extends Exportable {
columnSortBy: 'created_at', columnSortBy: 'created_at',
...query, ...query,
page: 1, page: 1,
pageSize: 12000, pageSize: EXPORT_SIZE_LIMIT,
} as ISalesInvoicesFilter; } as ISalesInvoicesFilter;
return this.saleEstimatesApplication return this.saleEstimatesApplication

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { ISalesInvoicesFilter } from '@/interfaces'; import { ISalesInvoicesFilter } from '@/interfaces';
import { SaleInvoiceApplication } from './SaleInvoicesApplication'; import { SaleInvoiceApplication } from './SaleInvoicesApplication';
import { Exportable } from '@/services/Export/Exportable'; import { Exportable } from '@/services/Export/Exportable';
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
@Service() @Service()
export class SaleInvoicesExportable extends Exportable { export class SaleInvoicesExportable extends Exportable {
@@ -19,7 +20,7 @@ export class SaleInvoicesExportable extends Exportable {
columnSortBy: 'created_at', columnSortBy: 'created_at',
...query, ...query,
page: 1, page: 1,
pageSize: 120000, pageSize: EXPORT_SIZE_LIMIT,
} as ISalesInvoicesFilter; } as ISalesInvoicesFilter;
return this.saleInvoicesApplication return this.saleInvoicesApplication

View File

@@ -1,3 +1,4 @@
import { Mutex } from 'async-mutex';
import { Container, Service, Inject } from 'typedi'; import { Container, Service, Inject } from 'typedi';
import { chain } from 'lodash'; import { chain } from 'lodash';
import moment from 'moment'; import moment from 'moment';
@@ -34,17 +35,26 @@ export class SaleInvoicesCost {
inventoryItemsIds: number[], inventoryItemsIds: number[],
startingDate: Date startingDate: Date
): Promise<void> { ): Promise<void> {
const asyncOpers: Promise<[]>[] = []; const mutex = new Mutex();
inventoryItemsIds.forEach((inventoryItemId: number) => { const asyncOpers = inventoryItemsIds.map(
const oper: Promise<[]> = this.inventoryService.scheduleComputeItemCost( async (inventoryItemId: number) => {
tenantId, // @todo refactor the lock acquire to be distrbuted using Redis
inventoryItemId, // and run the cost schedule job after running invoice transaction.
startingDate const release = await mutex.acquire();
);
asyncOpers.push(oper); try {
}); await this.inventoryService.scheduleComputeItemCost(
await Promise.all([...asyncOpers]); tenantId,
inventoryItemId,
startingDate
);
} finally {
release();
}
}
);
await Promise.all(asyncOpers);
} }
/** /**
@@ -86,17 +96,22 @@ export class SaleInvoicesCost {
tenantId: number, tenantId: number,
inventoryTransactions: IInventoryTransaction[] inventoryTransactions: IInventoryTransaction[]
) { ) {
const asyncOpers: Promise<[]>[] = []; const mutex = new Mutex();
const reducedTransactions = this.getMaxDateInventoryTransactions( const reducedTransactions = this.getMaxDateInventoryTransactions(
inventoryTransactions inventoryTransactions
); );
reducedTransactions.forEach((transaction) => { const asyncOpers = reducedTransactions.map(async (transaction) => {
const oper: Promise<[]> = this.inventoryService.scheduleComputeItemCost( const release = await mutex.acquire();
tenantId,
transaction.itemId, try {
transaction.date await this.inventoryService.scheduleComputeItemCost(
); tenantId,
asyncOpers.push(oper); transaction.itemId,
transaction.date
);
} finally {
release();
}
}); });
await Promise.all([...asyncOpers]); await Promise.all([...asyncOpers]);
} }

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { IAccountsStructureType, IPaymentsReceivedFilter } from '@/interfaces'; import { IAccountsStructureType, IPaymentsReceivedFilter } from '@/interfaces';
import { Exportable } from '@/services/Export/Exportable'; import { Exportable } from '@/services/Export/Exportable';
import { PaymentReceivesApplication } from './PaymentReceivedApplication'; import { PaymentReceivesApplication } from './PaymentReceivedApplication';
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
@Service() @Service()
export class PaymentsReceivedExportable extends Exportable { export class PaymentsReceivedExportable extends Exportable {
@@ -21,6 +22,8 @@ export class PaymentsReceivedExportable extends Exportable {
inactiveMode: false, inactiveMode: false,
...query, ...query,
structure: IAccountsStructureType.Flat, structure: IAccountsStructureType.Flat,
page: 1,
pageSize: EXPORT_SIZE_LIMIT,
} as IPaymentsReceivedFilter; } as IPaymentsReceivedFilter;
return this.paymentReceivedApp return this.paymentReceivedApp

View File

@@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi';
import { ISalesReceiptsFilter } from '@/interfaces'; import { ISalesReceiptsFilter } from '@/interfaces';
import { Exportable } from '@/services/Export/Exportable'; import { Exportable } from '@/services/Export/Exportable';
import { SaleReceiptApplication } from './SaleReceiptApplication'; import { SaleReceiptApplication } from './SaleReceiptApplication';
import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants';
@Service() @Service()
export class SaleReceiptsExportable extends Exportable { export class SaleReceiptsExportable extends Exportable {
@@ -19,7 +20,7 @@ export class SaleReceiptsExportable extends Exportable {
columnSortBy: 'created_at', columnSortBy: 'created_at',
...query, ...query,
page: 1, page: 1,
pageSize: 12, pageSize: EXPORT_SIZE_LIMIT,
} as ISalesReceiptsFilter; } as ISalesReceiptsFilter;
return this.saleReceiptsApp return this.saleReceiptsApp

View File

@@ -4,8 +4,8 @@ import { configureLemonSqueezy } from './utils';
import { PlanSubscription } from '@/system/models'; import { PlanSubscription } from '@/system/models';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import { ERRORS, IOrganizationSubscriptionCancel } from './types';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { ERRORS, IOrganizationSubscriptionCanceled } from './types';
@Service() @Service()
export class LemonCancelSubscription { export class LemonCancelSubscription {
@@ -18,12 +18,15 @@ export class LemonCancelSubscription {
* @param {number} subscriptionId * @param {number} subscriptionId
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public async cancelSubscription(tenantId: number) { public async cancelSubscription(
tenantId: number,
subscriptionSlug: string = 'main'
) {
configureLemonSqueezy(); configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({ const subscription = await PlanSubscription.query().findOne({
tenantId, tenantId,
slug: 'main', slug: subscriptionSlug,
}); });
if (!subscription) { if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT); throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
@@ -35,13 +38,10 @@ export class LemonCancelSubscription {
if (cancelledSub.error) { if (cancelledSub.error) {
throw new Error(cancelledSub.error.message); throw new Error(cancelledSub.error.message);
} }
await PlanSubscription.query().findById(subscriptionId).patch({ // Triggers `onSubscriptionCancelled` event.
canceledAt: new Date(),
});
// Triggers `onSubscriptionCanceled` event.
await this.eventPublisher.emitAsync( await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionCanceled, events.subscription.onSubscriptionCancel,
{ tenantId, subscriptionId } as IOrganizationSubscriptionCanceled { tenantId, subscriptionId } as IOrganizationSubscriptionCancel
); );
} }
} }

View File

@@ -18,25 +18,30 @@ export class LemonChangeSubscriptionPlan {
* @param {number} newVariantId - New variant id. * @param {number} newVariantId - New variant id.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public async changeSubscriptionPlan(tenantId: number, newVariantId: number) { public async changeSubscriptionPlan(
tenantId: number,
newVariantId: number,
subscriptionSlug: string = 'main'
) {
configureLemonSqueezy(); configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({ const subscription = await PlanSubscription.query().findOne({
tenantId, tenantId,
slug: 'main', slug: subscriptionSlug,
}); });
const lemonSubscriptionId = subscription.lemonSubscriptionId; const lemonSubscriptionId = subscription.lemonSubscriptionId;
// Send request to Lemon Squeezy to change the subscription. // Send request to Lemon Squeezy to change the subscription.
const updatedSub = await updateSubscription(lemonSubscriptionId, { const updatedSub = await updateSubscription(lemonSubscriptionId, {
variantId: newVariantId, variantId: newVariantId,
invoiceImmediately: true,
}); });
if (updatedSub.error) { if (updatedSub.error) {
throw new ServiceError('SOMETHING_WENT_WRONG'); throw new ServiceError('SOMETHING_WENT_WRONG');
} }
// Triggers `onSubscriptionPlanChanged` event. // Triggers `onSubscriptionPlanChanged` event.
await this.eventPublisher.emitAsync( await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionPlanChanged, events.subscription.onSubscriptionPlanChange,
{ {
tenantId, tenantId,
lemonSubscriptionId, lemonSubscriptionId,

View File

@@ -1,11 +1,11 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { configureLemonSqueezy } from './utils'; import { configureLemonSqueezy } from './utils';
import { PlanSubscription } from '@/system/models'; import { PlanSubscription } from '@/system/models';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import { ERRORS, IOrganizationSubscriptionResumed } from './types'; import { ERRORS, IOrganizationSubscriptionResume } from './types';
import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js';
@Service() @Service()
export class LemonResumeSubscription { export class LemonResumeSubscription {
@@ -14,15 +14,19 @@ export class LemonResumeSubscription {
/** /**
* Resumes the main subscription of the given tenant. * 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>} * @returns {Promise<void>}
*/ */
public async resumeSubscription(tenantId: number) { public async resumeSubscription(
tenantId: number,
subscriptionSlug: string = 'main'
) {
configureLemonSqueezy(); configureLemonSqueezy();
const subscription = await PlanSubscription.query().findOne({ const subscription = await PlanSubscription.query().findOne({
tenantId, tenantId,
slug: 'main', slug: subscriptionSlug,
}); });
if (!subscription) { if (!subscription) {
throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT); throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT);
@@ -33,16 +37,12 @@ export class LemonResumeSubscription {
cancelled: false, cancelled: false,
}); });
if (returnedSub.error) { if (returnedSub.error) {
throw new ServiceError(''); throw new ServiceError(ERRORS.SOMETHING_WENT_WRONG_WITH_LS);
} }
// Update the subscription of the organization. // Triggers `onSubscriptionResume` event.
await PlanSubscription.query().findById(subscriptionId).patch({
canceledAt: null,
});
// Triggers `onSubscriptionCanceled` event.
await this.eventPublisher.emitAsync( await this.eventPublisher.emitAsync(
events.subscription.onSubscriptionResumed, events.subscription.onSubscriptionResume,
{ tenantId, subscriptionId } as IOrganizationSubscriptionResumed { tenantId, subscriptionId } as IOrganizationSubscriptionResume
); );
} }
} }

View File

@@ -59,11 +59,25 @@ export class LemonSqueezyWebhooks {
const userId = eventBody.meta.custom_data?.user_id; const userId = eventBody.meta.custom_data?.user_id;
const tenantId = eventBody.meta.custom_data?.tenant_id; const tenantId = eventBody.meta.custom_data?.tenant_id;
const subscriptionSlug = 'main';
if (!webhookHasMeta(eventBody)) { if (!webhookHasMeta(eventBody)) {
throw new Error("Event body is missing the 'meta' property."); throw new Error("Event body is missing the 'meta' property.");
} else if (webhookHasData(eventBody)) { } else if (webhookHasData(eventBody)) {
if (webhookEvent.startsWith('subscription_payment_')) { 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 // Save subscription invoices; eventBody is a SubscriptionInvoice
// Not implemented. // Not implemented.
} else if (webhookEvent.startsWith('subscription_')) { } else if (webhookEvent.startsWith('subscription_')) {
@@ -74,16 +88,39 @@ export class LemonSqueezyWebhooks {
// We assume that the Plan table is up to date. // We assume that the Plan table is up to date.
const plan = await Plan.query().findOne('lemonVariantId', variantId); 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) { if (!plan) {
throw new Error(`Plan with variantId ${variantId} not found.`); throw new Error(`Plan with variantId ${variantId} not found.`);
} else { }
// Update the subscription in the database. // Create a new subscription of the tenant.
const priceId = attributes.first_subscription_item.price_id; if (webhookEvent === 'subscription_created') {
await this.subscriptionService.newSubscribtion(
// Create a new subscription of the tenant. tenantId,
if (webhookEvent === 'subscription_created') { plan.slug,
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_')) { } else if (webhookEvent.startsWith('order_')) {
// Save orders; eventBody is a "Order" // Save orders; eventBody is a "Order"

View File

@@ -1,22 +1,29 @@
import { Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { NotAllowedChangeSubscriptionPlan } from '@/exceptions'; import { NotAllowedChangeSubscriptionPlan, ServiceError } from '@/exceptions';
import { Plan, Tenant } from '@/system/models'; 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() @Service()
export class Subscription { export class Subscription {
@Inject()
private eventPublisher: EventPublisher;
/** /**
* Give the tenant a new subscription. * Give the tenant a new subscription.
* @param {number} tenantId - Tenant id. * @param {number} tenantId - Tenant id.
* @param {string} planSlug - Plan slug. * @param {string} planSlug - Plan slug of the new subscription.
* @param {string} invoiceInterval * @param {string} subscriptionSlug - Subscription slug by default takes main subscription
* @param {number} invoicePeriod * @param {SubscriptionPayload} payload - Subscription payload.
* @param {string} subscriptionSlug
*/ */
public async newSubscribtion( public async newSubscribtion(
tenantId: number, tenantId: number,
planSlug: string, planSlug: string,
subscriptionSlug: string = 'main' subscriptionSlug: string = 'main',
) { payload?: SubscriptionPayload
): Promise<void> {
const tenant = await Tenant.query().findById(tenantId).throwIfNotFound(); const tenant = await Tenant.query().findById(tenantId).throwIfNotFound();
const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound(); const plan = await Plan.query().findOne('slug', planSlug).throwIfNotFound();
@@ -45,8 +52,169 @@ export class Subscription {
plan.id, plan.id,
invoiceInterval, invoiceInterval,
invoicePeriod, 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,
}
);
}
} }

View File

@@ -20,8 +20,14 @@ export class SubscriptionApplication {
* @param {string} id * @param {string} id
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public cancelSubscription(tenantId: number, id: string) { public cancelSubscription(
return this.cancelSubscriptionService.cancelSubscription(tenantId, id); tenantId: number,
subscriptionSlug: string = 'main'
) {
return this.cancelSubscriptionService.cancelSubscription(
tenantId,
subscriptionSlug
);
} }
/** /**
@@ -29,8 +35,14 @@ export class SubscriptionApplication {
* @param {number} tenantId * @param {number} tenantId
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
public resumeSubscription(tenantId: number) { public resumeSubscription(
return this.resumeSubscriptionService.resumeSubscription(tenantId); tenantId: number,
subscriptionSlug: string = 'main'
) {
return this.resumeSubscriptionService.resumeSubscription(
tenantId,
subscriptionSlug
);
} }
/** /**

View File

@@ -1,8 +1,8 @@
import { Inject, Service } from 'typedi';
import { IAuthSignedUpEventPayload } from '@/interfaces'; import { IAuthSignedUpEventPayload } from '@/interfaces';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import config from '@/config'; import config from '@/config';
import { Subscription } from '../Subscription'; import { Subscription } from '../Subscription';
import { Inject, Service } from 'typedi';
@Service() @Service()
export class SubscribeFreeOnSignupCommunity { export class SubscribeFreeOnSignupCommunity {

View File

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

View File

@@ -1,6 +1,10 @@
export const ERRORS = { export const ERRORS = {
SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT: SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT:
'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 { export interface IOrganizationSubscriptionChanged {
@@ -9,11 +13,20 @@ export interface IOrganizationSubscriptionChanged {
newVariantId: number; newVariantId: number;
} }
export interface IOrganizationSubscriptionCanceled { export interface IOrganizationSubscriptionCancel {
tenantId: number; tenantId: number;
subscriptionId: string; subscriptionId: string;
} }
export interface IOrganizationSubscriptionCancelled {
tenantId: number;
subscriptionId: string;
}
export interface IOrganizationSubscriptionResume {
tenantId: number;
subscriptionId: number;
}
export interface IOrganizationSubscriptionResumed { export interface IOrganizationSubscriptionResumed {
tenantId: number; tenantId: number;
subscriptionId: number; subscriptionId: number;

View File

@@ -10,6 +10,7 @@ import {
} from '@/interfaces'; } from '@/interfaces';
import { runAfterTransaction } from '@/services/UnitOfWork/TransactionsHooks'; import { runAfterTransaction } from '@/services/UnitOfWork/TransactionsHooks';
import { SaleInvoicesCost } from '@/services/Sales/Invoices/SalesInvoicesCost'; import { SaleInvoicesCost } from '@/services/Sales/Invoices/SalesInvoicesCost';
import { ImportAls } from '@/services/Import/ImportALS';
@Service() @Service()
export default class InventorySubscriber { export default class InventorySubscriber {
@@ -25,6 +26,9 @@ export default class InventorySubscriber {
@Inject('agenda') @Inject('agenda')
private agenda: any; private agenda: any;
@Inject()
private importAls: ImportAls;
/** /**
* Attaches events with handlers. * Attaches events with handlers.
*/ */
@@ -86,20 +90,17 @@ export default class InventorySubscriber {
private handleScheduleItemsCostOnInventoryTransactionsCreated = async ({ private handleScheduleItemsCostOnInventoryTransactionsCreated = async ({
tenantId, tenantId,
inventoryTransactions, inventoryTransactions,
trx trx,
}: IInventoryTransactionsCreatedPayload) => { }: IInventoryTransactionsCreatedPayload) => {
const inventoryItemsIds = map(inventoryTransactions, 'itemId'); const inImportPreviewScope = this.importAls.isImportPreview;
runAfterTransaction(trx, async () => { // Avoid running the cost items job if the async process is in import preview.
try { if (inImportPreviewScope) return;
await this.saleInvoicesCost.computeItemsCostByInventoryTransactions(
tenantId, await this.saleInvoicesCost.computeItemsCostByInventoryTransactions(
inventoryTransactions tenantId,
); inventoryTransactions
} catch (error) { );
console.error(error);
}
});
}; };
/** /**

View File

@@ -46,10 +46,19 @@ export default {
* Organization subscription. * Organization subscription.
*/ */
subscription: { subscription: {
onSubscriptionCanceled: 'onSubscriptionCanceled', onSubscriptionCancel: 'onSubscriptionCancel',
onSubscriptionCancelled: 'onSubscriptionCancelled',
onSubscriptionResume: 'onSubscriptionResume',
onSubscriptionResumed: 'onSubscriptionResumed', onSubscriptionResumed: 'onSubscriptionResumed',
onSubscriptionPlanChange: 'onSubscriptionPlanChange',
onSubscriptionPlanChanged: 'onSubscriptionPlanChanged', onSubscriptionPlanChanged: 'onSubscriptionPlanChanged',
onSubscribed: 'onOrganizationSubscribed',
onSubscriptionSubscribed: 'onSubscriptionSubscribed',
onSubscriptionPaymentSucceed: 'onSubscriptionPaymentSucceed',
onSubscriptionPaymentFailed: 'onSubscriptionPaymentFailed'
}, },
/** /**

View File

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

View File

@@ -3,6 +3,10 @@ import SystemModel from '@/system/models/SystemModel';
import { PlanSubscription } from '..'; import { PlanSubscription } from '..';
export default class Plan extends mixin(SystemModel) { export default class Plan extends mixin(SystemModel) {
price: number;
invoiceInternal: number;
invoicePeriod: string;
/** /**
* Table name. * Table name.
*/ */

View File

@@ -198,14 +198,16 @@ export default class Tenant extends BaseModel {
planId, planId,
invoiceInterval, invoiceInterval,
invoicePeriod, invoicePeriod,
subscriptionSlug subscriptionSlug,
payload?,
) { ) {
return Tenant.newSubscription( return Tenant.newSubscription(
this.id, this.id,
planId, planId,
invoiceInterval, invoiceInterval,
invoicePeriod, invoicePeriod,
subscriptionSlug subscriptionSlug,
payload
); );
} }
@@ -217,7 +219,8 @@ export default class Tenant extends BaseModel {
planId: number, planId: number,
invoiceInterval: 'month' | 'year', invoiceInterval: 'month' | 'year',
invoicePeriod: number, invoicePeriod: number,
subscriptionSlug: string subscriptionSlug: string,
payload?: { lemonSqueezyId: string }
) { ) {
const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod); const period = new SubscriptionPeriod(invoiceInterval, invoicePeriod);
@@ -227,6 +230,7 @@ export default class Tenant extends BaseModel {
planId, planId,
startsAt: period.getStartDate(), startsAt: period.getStartDate(),
endsAt: period.getEndDate(), endsAt: period.getEndDate(),
lemonSubscriptionId: payload?.lemonSqueezyId || null,
}); });
} }
} }

View File

@@ -1,4 +1,5 @@
// @ts-nocheck // @ts-nocheck
import { lazy, Suspense } from 'react';
import { Router, Switch, Route } from 'react-router'; import { Router, Switch, Route } from 'react-router';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { QueryClientProvider, QueryClient } from 'react-query'; import { QueryClientProvider, QueryClient } from 'react-query';
@@ -11,26 +12,27 @@ import 'moment/locale/es-us';
import AppIntlLoader from './AppIntlLoader'; import AppIntlLoader from './AppIntlLoader';
import { EnsureAuthenticated } from '@/components/Guards/EnsureAuthenticated'; import { EnsureAuthenticated } from '@/components/Guards/EnsureAuthenticated';
import GlobalErrors from '@/containers/GlobalErrors/GlobalErrors'; import GlobalErrors from '@/containers/GlobalErrors/GlobalErrors';
import DashboardPrivatePages from '@/components/Dashboard/PrivatePages';
import { Authentication } from '@/containers/Authentication/Authentication';
import LazyLoader from '@/components/LazyLoader';
import { SplashScreen, DashboardThemeProvider } from '../components'; import { SplashScreen, DashboardThemeProvider } from '../components';
import { queryConfig } from '../hooks/query/base'; import { queryConfig } from '../hooks/query/base';
import { EnsureUserEmailVerified } from './Guards/EnsureUserEmailVerified';
import { EnsureAuthNotAuthenticated } from './Guards/EnsureAuthNotAuthenticated';
import { EnsureUserEmailNotVerified } from './Guards/EnsureUserEmailNotVerified'; import { EnsureUserEmailNotVerified } from './Guards/EnsureUserEmailNotVerified';
import { EnsureOneClickDemoAccountEnabled } from '@/containers/OneClickDemo/EnsureOneClickDemoAccountEnabled';
const EmailConfirmation = LazyLoader({ const DashboardPrivatePages = lazy(
loader: () => import('@/containers/Authentication/EmailConfirmation'), () => import('@/components/Dashboard/PrivatePages'),
}); );
const RegisterVerify = LazyLoader({ const AuthenticationPage = lazy(
loader: () => import('@/containers/Authentication/RegisterVerify'), () => import('@/containers/Authentication/AuthenticationPage'),
}); );
const OneClickDemoPage = LazyLoader({ const EmailConfirmation = lazy(
loader: () => import('@/containers/OneClickDemo/OneClickDemoPage'), () => import('@/containers/Authentication/EmailConfirmation'),
}); );
const RegisterVerify = lazy(
() => import('@/containers/Authentication/RegisterVerify'),
);
const OneClickDemoPage = lazy(
() => import('@/containers/OneClickDemo/OneClickDemoPage'),
);
/** /**
* App inner. * App inner.
*/ */
@@ -38,42 +40,27 @@ function AppInsider({ history }) {
return ( return (
<div className="App"> <div className="App">
<DashboardThemeProvider> <DashboardThemeProvider>
<Router history={history}> <Suspense fallback={'Loading...'}>
<Switch> <Router history={history}>
<Route path={'/one_click_demo'}> <Switch>
<EnsureOneClickDemoAccountEnabled> <Route path={'/one_click_demo'} children={<OneClickDemoPage />} />
<EnsureAuthNotAuthenticated> <Route path={'/auth/register/verify'}>
<OneClickDemoPage /> <EnsureAuthenticated>
</EnsureAuthNotAuthenticated> <EnsureUserEmailNotVerified>
</EnsureOneClickDemoAccountEnabled> <RegisterVerify />
</Route> </EnsureUserEmailNotVerified>
<Route path={'/auth/register/verify'}> </EnsureAuthenticated>
<EnsureAuthenticated> </Route>
<EnsureUserEmailNotVerified>
<RegisterVerify />
</EnsureUserEmailNotVerified>
</EnsureAuthenticated>
</Route>
<Route path={'/auth/email_confirmation'}> <Route
<EmailConfirmation /> path={'/auth/email_confirmation'}
</Route> children={<EmailConfirmation />}
/>
<Route path={'/auth'}> <Route path={'/auth'} children={<AuthenticationPage />} />
<EnsureAuthNotAuthenticated> <Route path={'/'} children={<DashboardPrivatePages />} />
<Authentication /> </Switch>
</EnsureAuthNotAuthenticated> </Router>
</Route> </Suspense>
<Route path={'/'}>
<EnsureAuthenticated>
<EnsureUserEmailVerified>
<DashboardPrivatePages />
</EnsureUserEmailVerified>
</EnsureAuthenticated>
</Route>
</Switch>
</Router>
<GlobalErrors /> <GlobalErrors />
</DashboardThemeProvider> </DashboardThemeProvider>

View File

@@ -23,6 +23,9 @@ export function DashboardSockets() {
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
}); });
}); });
socket.current.on('SUBSCRIPTION_CHANGED', () => {
client.invalidateQueries('GetSubscriptions');
});
return () => { return () => {
socket.current.removeAllListeners(); socket.current.removeAllListeners();
socket.current.close(); socket.current.close();

View File

@@ -1,35 +1,37 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React, { lazy } from 'react';
import { Switch, Route } from 'react-router'; import { Switch, Route } from 'react-router';
import Dashboard from '@/components/Dashboard/Dashboard'; import Dashboard from '@/components/Dashboard/Dashboard';
import SetupWizardPage from '@/containers/Setup/WizardSetupPage';
import EnsureOrganizationIsReady from '../Guards/EnsureOrganizationIsReady';
import EnsureOrganizationIsNotReady from '../Guards/EnsureOrganizationIsNotReady';
import { PrivatePagesProvider } from './PrivatePagesProvider'; import { PrivatePagesProvider } from './PrivatePagesProvider';
import EnsureOrganizationIsReady from '../Guards/EnsureOrganizationIsReady';
import { EnsureAuthenticated } from '../Guards/EnsureAuthenticated';
import { EnsureUserEmailVerified } from '../Guards/EnsureUserEmailVerified';
import '@/style/pages/Dashboard/Dashboard.scss'; import '@/style/pages/Dashboard/Dashboard.scss';
const SetupWizardPage = lazy(
() => import('@/containers/Setup/WizardSetupPage'),
);
/** /**
* Dashboard inner private pages. * Dashboard inner private pages.
*/ */
export default function DashboardPrivatePages() { export default function DashboardPrivatePages() {
return ( return (
<PrivatePagesProvider> <EnsureAuthenticated>
<Switch> <EnsureUserEmailVerified>
<Route path={'/setup'}> <PrivatePagesProvider>
<EnsureOrganizationIsNotReady> <Switch>
<SetupWizardPage /> <Route path={'/setup'} children={<SetupWizardPage />} />
</EnsureOrganizationIsNotReady> <Route path="/">
</Route> <EnsureOrganizationIsReady>
<Dashboard />
<Route path="/"> </EnsureOrganizationIsReady>
<EnsureOrganizationIsReady> </Route>
<Dashboard /> </Switch>
</EnsureOrganizationIsReady> </PrivatePagesProvider>
</Route> </EnsureUserEmailVerified>
</Switch> </EnsureAuthenticated>
</PrivatePagesProvider>
); );
} }

View File

@@ -1,6 +1,7 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { useApplicationBoot } from '@/components'; import { useApplicationBoot } from '@/components';
import { useAuthMetadata } from '@/hooks/query/authentication';
/** /**
* Private pages provider. * Private pages provider.
@@ -9,7 +10,10 @@ export function PrivatePagesProvider({
// #ownProps // #ownProps
children, children,
}) { }) {
const { isLoading } = useApplicationBoot(); const { isLoading: isAppBootLoading } = useApplicationBoot();
const { isLoading: isAuthMetaLoading } = useAuthMetadata();
const isLoading = isAppBootLoading || isAuthMetaLoading;
return <React.Fragment>{!isLoading ? children : null}</React.Fragment>; return <React.Fragment>{!isLoading ? children : null}</React.Fragment>;
} }

View File

@@ -1,22 +0,0 @@
// @ts-nocheck
import * as React from 'react';
import * as Loadable from 'react-loadable';
const Loader = (config) =>
Loadable({
loading: (props) => {
if (props.error) {
/* tslint:disable */
console.error(`======= DefaultLoader Error =======`);
console.error(props.error);
console.error(`======= DefaultLoader Error =======`);
/* tslint:enable */
return null;
}
return null;
},
delay: 250,
...config
});
export default Loader;

View File

@@ -1,21 +1,33 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React, { Suspense } from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import preferencesRoutes from '@/routes/preferences'; import { getPreferenceRoutes } from '@/routes/preferences';
import { Spinner } from '@blueprintjs/core';
import { Box } from '../Layout';
export default function DashboardContentRoute() { export default function DashboardContentRoute() {
const preferencesRoutes = getPreferenceRoutes();
return ( return (
<Route pathname="/preferences"> <Route pathname="/preferences">
<Switch> <Suspense
{preferencesRoutes.map((route, index) => ( fallback={
<Route <Box style={{ padding: 20 }}>
key={index} <Spinner size={20} />
path={`${route.path}`} </Box>
exact={route.exact} }
component={route.component} >
/> <Switch>
))} {preferencesRoutes.map((route, index) => (
</Switch> <Route
key={index}
path={`${route.path}`}
exact={route.exact}
component={route.component}
/>
))}
</Switch>
</Suspense>
</Route> </Route>
); );
} }

View File

@@ -2,7 +2,6 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
margin-bottom: 14px;
} }
.tag{ .tag{

View File

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

View File

@@ -54,9 +54,9 @@ export default [
disabled: false, disabled: false,
href: '/preferences/items', href: '/preferences/items',
}, },
{ // {
text: <T id={'sms_integration.label'} />, // text: <T id={'sms_integration.label'} />,
disabled: false, // disabled: false,
href: '/preferences/sms-message', // href: '/preferences/sms-message',
}, // },
]; ];

View File

@@ -2,10 +2,12 @@
import { Route, Switch, useLocation } from 'react-router-dom'; import { Route, Switch, useLocation } from 'react-router-dom';
import BodyClassName from 'react-body-classname'; import BodyClassName from 'react-body-classname';
import styled from 'styled-components'; import styled from 'styled-components';
import { Suspense } from 'react';
import { TransitionGroup, CSSTransition } from 'react-transition-group'; import { TransitionGroup, CSSTransition } from 'react-transition-group';
import { Spinner } from '@blueprintjs/core';
import authenticationRoutes from '@/routes/authentication'; import authenticationRoutes from '@/routes/authentication';
import { Icon, FormattedMessage as T } from '@/components'; import { Box, Icon, FormattedMessage as T } from '@/components';
import { AuthMetaBootProvider } from './AuthMetaBoot'; import { AuthMetaBootProvider } from './AuthMetaBoot';
import '@/style/pages/Authentication/Auth.scss'; import '@/style/pages/Authentication/Auth.scss';
@@ -20,7 +22,15 @@ export function Authentication() {
</AuthLogo> </AuthLogo>
<AuthMetaBootProvider> <AuthMetaBootProvider>
<AuthenticationRoutes /> <Suspense
fallback={
<Box style={{ marginTop: '5rem' }}>
<Spinner size={30} />
</Box>
}
>
<AuthenticationRoutes />
</Suspense>
</AuthMetaBootProvider> </AuthMetaBootProvider>
</AuthInsider> </AuthInsider>
</AuthPage> </AuthPage>

View File

@@ -0,0 +1,10 @@
import { EnsureAuthNotAuthenticated } from "@/components/Guards/EnsureAuthNotAuthenticated";
import { Authentication } from "./Authentication";
export default function AuthenticationPage() {
return (
<EnsureAuthNotAuthenticated>
<Authentication />
</EnsureAuthNotAuthenticated>
);
}

View File

@@ -0,0 +1,5 @@
.dateFieldGroup{
margin-bottom: 0;
}

View File

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

View File

@@ -1,10 +1,12 @@
// @ts-nocheck // @ts-nocheck
import * as R from 'ramda';
import { useMemo } from 'react'; import { useMemo } from 'react';
import * as R from 'ramda';
import { useAppQueryString } from '@/hooks'; import { useAppQueryString } from '@/hooks';
import { Group } from '@/components'; import { Group, Stack, } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { TagsControl } from '@/components/TagsControl'; import { TagsControl } from '@/components/TagsControl';
import { AccountUncategorizedDateFilter } from './UncategorizedTransactions/AccountUncategorizedDateFilter';
import { Divider } from '@blueprintjs/core';
export function AccountTransactionsUncategorizeFilter() { export function AccountTransactionsUncategorizeFilter() {
const { bankAccountMetaSummary } = useAccountTransactionsContext(); const { bankAccountMetaSummary } = useAccountTransactionsContext();
@@ -54,12 +56,17 @@ export function AccountTransactionsUncategorizeFilter() {
); );
return ( return (
<Group position={'apart'}> <Group position={'apart'} style={{ marginBottom: 14 }}>
<TagsControl <Group align={'stretch'} spacing={10}>
options={options} <TagsControl
value={locationQuery?.uncategorizedFilter || 'all'} options={options}
onValueChange={handleTabsChange} value={locationQuery?.uncategorizedFilter || 'all'}
/> onValueChange={handleTabsChange}
/>
<Divider />
<AccountUncategorizedDateFilter />
</Group>
<TagsControl <TagsControl
options={[{ value: 'excluded', label: 'Excluded' }]} options={[{ value: 'excluded', label: 'Excluded' }]}
value={locationQuery?.uncategorizedFilter || 'all'} value={locationQuery?.uncategorizedFilter || 'all'}

View File

@@ -2,9 +2,11 @@
import React from 'react'; import React from 'react';
import { flatten, map } from 'lodash'; import { flatten, map } from 'lodash';
import * as R from 'ramda';
import { IntersectionObserver } from '@/components'; import { IntersectionObserver } from '@/components';
import { useAccountUncategorizedTransactionsInfinity } from '@/hooks/query'; import { useAccountUncategorizedTransactionsInfinity } from '@/hooks/query';
import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { withBanking } from '../withBanking';
const AccountUncategorizedTransactionsContext = React.createContext(); 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(); const { accountId } = useAccountTransactionsContext();
// Fetches the uncategorized transactions. // Fetches the uncategorized transactions.
@@ -29,6 +37,8 @@ function AccountUncategorizedTransactionsBoot({ children }) {
hasNextPage: hasUncategorizedTransactionsNextPage, hasNextPage: hasUncategorizedTransactionsNextPage,
} = useAccountUncategorizedTransactionsInfinity(accountId, { } = useAccountUncategorizedTransactionsInfinity(accountId, {
page_size: 50, page_size: 50,
min_date: uncategorizedTransactionsFilter?.fromDate || null,
max_date: uncategorizedTransactionsFilter?.toDate || null,
}); });
// Memorized the cashflow account transactions. // Memorized the cashflow account transactions.
const uncategorizedTransactions = React.useMemo( const uncategorizedTransactions = React.useMemo(
@@ -69,6 +79,12 @@ function AccountUncategorizedTransactionsBoot({ children }) {
); );
} }
const AccountUncategorizedTransactionsBoot = R.compose(
withBanking(({ uncategorizedTransactionsFilter }) => ({
uncategorizedTransactionsFilter,
})),
)(AccountUncategorizedTransactionsBootRoot);
const useAccountUncategorizedTransactionsContext = () => const useAccountUncategorizedTransactionsContext = () =>
React.useContext(AccountUncategorizedTransactionsContext); React.useContext(AccountUncategorizedTransactionsContext);

View File

@@ -1,9 +1,11 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { flatten, map } from 'lodash'; import { flatten, map } from 'lodash';
import * as R from 'ramda';
import { IntersectionObserver } from '@/components'; import { IntersectionObserver } from '@/components';
import { useAccountTransactionsContext } from '../AccountTransactionsProvider'; import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
import { useExcludedBankTransactionsInfinity } from '@/hooks/query/bank-rules'; import { useExcludedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
import { withBanking } from '../../withBanking';
interface ExcludedBankTransactionsContextValue { interface ExcludedBankTransactionsContextValue {
isExcludedTransactionsLoading: boolean; isExcludedTransactionsLoading: boolean;
@@ -27,7 +29,11 @@ interface ExcludedBankTransactionsTableBootProps {
/** /**
* Account uncategorized transctions provider. * Account uncategorized transctions provider.
*/ */
function ExcludedBankTransactionsTableBoot({ function ExcludedBankTransactionsTableBootRoot({
// #withBanking
uncategorizedTransactionsFilter,
// #ownProps
children, children,
}: ExcludedBankTransactionsTableBootProps) { }: ExcludedBankTransactionsTableBootProps) {
const { accountId } = useAccountTransactionsContext(); const { accountId } = useAccountTransactionsContext();
@@ -44,6 +50,8 @@ function ExcludedBankTransactionsTableBoot({
} = useExcludedBankTransactionsInfinity({ } = useExcludedBankTransactionsInfinity({
page_size: 50, page_size: 50,
account_id: accountId, account_id: accountId,
min_date: uncategorizedTransactionsFilter?.fromDate || null,
max_date: uncategorizedTransactionsFilter.toDate || null,
}); });
// Memorized the cashflow account transactions. // Memorized the cashflow account transactions.
const excludedBankTransactions = React.useMemo( const excludedBankTransactions = React.useMemo(
@@ -84,6 +92,12 @@ function ExcludedBankTransactionsTableBoot({
); );
} }
const ExcludedBankTransactionsTableBoot = R.compose(
withBanking(({ uncategorizedTransactionsFilter }) => ({
uncategorizedTransactionsFilter,
})),
)(ExcludedBankTransactionsTableBootRoot);
const useExcludedTransactionsBoot = () => const useExcludedTransactionsBoot = () =>
React.useContext(ExcludedTransactionsContext); React.useContext(ExcludedTransactionsContext);

View File

@@ -1,9 +1,11 @@
// @ts-nocheck // @ts-nocheck
import React from 'react'; import React from 'react';
import { flatten, map } from 'lodash'; import { flatten, map } from 'lodash';
import { IntersectionObserver } from '@/components'; import * as R from 'ramda';
import { IntersectionObserver, NumericInputCell } from '@/components';
import { useAccountTransactionsContext } from '../AccountTransactionsProvider'; import { useAccountTransactionsContext } from '../AccountTransactionsProvider';
import { useRecognizedBankTransactionsInfinity } from '@/hooks/query/bank-rules'; import { useRecognizedBankTransactionsInfinity } from '@/hooks/query/bank-rules';
import { withBanking } from '../../withBanking';
interface RecognizedTransactionsContextValue { interface RecognizedTransactionsContextValue {
isRecongizedTransactionsLoading: boolean; isRecongizedTransactionsLoading: boolean;
@@ -27,7 +29,10 @@ interface RecognizedTransactionsTableBootProps {
/** /**
* Account uncategorized transctions provider. * Account uncategorized transctions provider.
*/ */
function RecognizedTransactionsTableBoot({ function RecognizedTransactionsTableBootRoot({
// #withBanking
uncategorizedTransactionsFilter,
children, children,
}: RecognizedTransactionsTableBootProps) { }: RecognizedTransactionsTableBootProps) {
const { accountId } = useAccountTransactionsContext(); const { accountId } = useAccountTransactionsContext();
@@ -44,6 +49,8 @@ function RecognizedTransactionsTableBoot({
} = useRecognizedBankTransactionsInfinity({ } = useRecognizedBankTransactionsInfinity({
page_size: 50, page_size: 50,
account_id: accountId, account_id: accountId,
min_date: uncategorizedTransactionsFilter.fromDate || null,
max_date: uncategorizedTransactionsFilter?.toDate || null,
}); });
// Memorized the cashflow account transactions. // Memorized the cashflow account transactions.
const recognizedTransactions = React.useMemo( const recognizedTransactions = React.useMemo(
@@ -84,6 +91,12 @@ function RecognizedTransactionsTableBoot({
); );
} }
const RecognizedTransactionsTableBoot = R.compose(
withBanking(({ uncategorizedTransactionsFilter }) => ({
uncategorizedTransactionsFilter,
})),
)(RecognizedTransactionsTableBootRoot);
const useRecognizedTransactionsBoot = () => const useRecognizedTransactionsBoot = () =>
React.useContext(RecognizedTransactionsContext); React.useContext(RecognizedTransactionsContext);

View File

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

View File

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

View File

@@ -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} />
}

View File

@@ -25,6 +25,8 @@ export const withBanking = (mapState) => {
categorizedTransactionsSelected: categorizedTransactionsSelected:
state.plaid.categorizedTransactionsSelected, state.plaid.categorizedTransactionsSelected,
uncategorizedTransactionsFilter: state.plaid.uncategorizedFilter
}; };
return mapState ? mapState(mapped, state, props) : mapped; return mapState ? mapState(mapped, state, props) : mapped;
}; };

View File

@@ -15,6 +15,8 @@ import {
removeTransactionsToCategorizeSelected, removeTransactionsToCategorizeSelected,
setCategorizedTransactionsSelected, setCategorizedTransactionsSelected,
resetCategorizedTransactionsSelected, resetCategorizedTransactionsSelected,
setUncategorizedTransactionsFilter,
resetUncategorizedTranasctionsFilter,
} from '@/store/banking/banking.reducer'; } from '@/store/banking/banking.reducer';
export interface WithBankingActionsProps { export interface WithBankingActionsProps {
@@ -40,6 +42,9 @@ export interface WithBankingActionsProps {
setCategorizedTransactionsSelected: (ids: Array<string | number>) => void; setCategorizedTransactionsSelected: (ids: Array<string | number>) => void;
resetCategorizedTransactionsSelected: () => void; resetCategorizedTransactionsSelected: () => void;
setUncategorizedTransactionsFilter: (filter: any) => void;
resetUncategorizedTranasctionsFilter: () => void;
} }
const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
@@ -138,6 +143,19 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
*/ */
resetCategorizedTransactionsSelected: () => resetCategorizedTransactionsSelected: () =>
dispatch(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< export const withBankingActions = connect<

View File

@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { Config } from '@/config'; import { useOneClickDemoBoot } from './OneClickDemoBoot';
interface EnsureOneClickDemoAccountEnabledProps { interface EnsureOneClickDemoAccountEnabledProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,9 +11,10 @@ export const EnsureOneClickDemoAccountEnabled = ({
children, children,
redirectTo = '/', redirectTo = '/',
}: EnsureOneClickDemoAccountEnabledProps) => { }: 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 <Redirect to={{ pathname: redirectTo }} />;
} }
return <>{children}</>; return <>{children}</>;

View File

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

View File

@@ -1,97 +1,16 @@
// @ts-nocheck import { EnsureAuthNotAuthenticated } from '@/components/Guards/EnsureAuthNotAuthenticated';
import { Button, Intent, ProgressBar, Text } from '@blueprintjs/core'; import { EnsureOneClickDemoAccountEnabled } from './EnsureOneClickDemoAccountEnabled';
import { useEffect, useState } from 'react'; import { OneClickDemoBoot } from './OneClickDemoBoot';
import { import { OneClickDemoPageContent } from './OneClickDemoPageContent';
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 default function OneClickDemoPage() { 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 ( return (
<Box className={style.root}> <EnsureAuthNotAuthenticated>
<Box className={style.inner}> <OneClickDemoBoot>
<Stack align={'center'} spacing={40}> <EnsureOneClickDemoAccountEnabled>
<Icon icon="bigcapital" height={37} width={228} /> <OneClickDemoPageContent />
</EnsureOneClickDemoAccountEnabled>
{isLoading && ( </OneClickDemoBoot>
<Stack align={'center'} spacing={15}> </EnsureAuthNotAuthenticated>
<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>
); );
} }

View File

@@ -0,0 +1,98 @@
// @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 the temporary environment for trial. It
typically takes a 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>
);
}

View File

@@ -2,9 +2,9 @@
width: 100%; width: 100%;
margin-bottom: 2rem; margin-bottom: 2rem;
background: transparent; background: transparent;
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.6);
border: 1px solid rgb(255, 255, 255, 0.3); border: 1px solid rgb(255, 255, 255, 0.2);
padding: 10px; padding: 8px;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
border-radius: 5px; border-radius: 5px;
@@ -12,11 +12,11 @@
transition: border 0.15s ease-in-out, color 0.15s ease-in-out; transition: border 0.15s ease-in-out, color 0.15s ease-in-out;
&:hover { &:hover {
color: rgba(255, 255, 255, 0.95); color: rgba(255, 255, 255, 0.7);
border: 1px solid rgb(255, 255, 255, 0.6); border: 1px solid rgb(255, 255, 255, 0.3);
} }
} }
.demoButtonLabel{ .demoButtonLabel{
color: rgba(255, 255, 255, 0.75); color: rgba(255, 255, 255, 0.5);
font-size: 13px; font-size: 13px;
} }

View File

@@ -6,7 +6,7 @@ import { Icon, For, FormattedMessage as T, Stack } from '@/components';
import { getFooterLinks } from '@/constants/footerLinks'; import { getFooterLinks } from '@/constants/footerLinks';
import { useAuthActions } from '@/hooks/state'; import { useAuthActions } from '@/hooks/state';
import style from './SetupLeftSection.module.scss'; import style from './SetupLeftSection.module.scss';
import { Config } from '@/config'; import { useAuthMetadata } from '@/hooks/query';
/** /**
* Footer item link. * Footer item link.
@@ -28,13 +28,16 @@ function SetupLeftSectionFooter() {
// Retrieve the footer links. // Retrieve the footer links.
const footerLinks = getFooterLinks(); const footerLinks = getFooterLinks();
const { data: authMeta } = useAuthMetadata();
const demoUrl = authMeta?.meta?.one_click_demo?.demo_url;
const handleDemoBtnClick = () => { const handleDemoBtnClick = () => {
window.open(Config.oneClickDemo.demoUrl); window.open(demoUrl);
}; };
return ( return (
<div className={'content__footer'}> <div className={'content__footer'}>
{Config.oneClickDemo.demoUrl && ( {demoUrl && (
<Stack spacing={16}> <Stack spacing={16}>
<Text className={style.demoButtonLabel}>Not Now?</Text> <Text className={style.demoButtonLabel}>Not Now?</Text>
<button className={style.demoButton} onClick={handleDemoBtnClick}> <button className={style.demoButton} onClick={handleDemoBtnClick}>

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ function SubscriptionPlansPeriodSwitcherRoot({
); );
}; };
return ( return (
<Group position={'center'} spacing={10} style={{ marginBottom: '1.2rem' }}> <Group position={'center'} spacing={10} style={{ marginBottom: '1.6rem' }}>
<Text>Pay Monthly</Text> <Text>Pay Monthly</Text>
<Switch <Switch
large large

View File

@@ -1,6 +1,7 @@
import { Callout } from '@blueprintjs/core'; import { Callout } from '@blueprintjs/core';
import { SubscriptionPlans } from './SubscriptionPlans'; import { SubscriptionPlans } from './SubscriptionPlans';
import { SubscriptionPlansPeriodSwitcher } from './SubscriptionPlansPeriodSwitcher'; import { SubscriptionPlansPeriodSwitcher } from './SubscriptionPlansPeriodSwitcher';
import { SubscriptionPlansOfferChecks } from './SubscriptionPlansOfferChecks';
/** /**
* Billing plans. * Billing plans.
@@ -14,6 +15,7 @@ export function SubscriptionPlansSection() {
include applicable taxes. include applicable taxes.
</Callout> </Callout>
<SubscriptionPlansOfferChecks />
<SubscriptionPlansPeriodSwitcher /> <SubscriptionPlansPeriodSwitcher />
<SubscriptionPlans /> <SubscriptionPlans />
</section> </section>

View File

@@ -2,14 +2,17 @@
import React from 'react'; import React from 'react';
import SetupRightSection from './SetupRightSection'; import SetupRightSection from './SetupRightSection';
import SetupLeftSection from './SetupLeftSection'; import SetupLeftSection from './SetupLeftSection';
import EnsureOrganizationIsNotReady from '@/components/Guards/EnsureOrganizationIsNotReady';
import '@/style/pages/Setup/SetupPage.scss'; import '@/style/pages/Setup/SetupPage.scss';
export default function WizardSetupPage() { export default function WizardSetupPage() {
return ( return (
<div class="setup-page"> <EnsureOrganizationIsNotReady>
<SetupLeftSection /> <div class="setup-page">
<SetupRightSection /> <SetupLeftSection />
</div> <SetupRightSection />
</div>
</EnsureOrganizationIsNotReady>
); );
}; }

View File

@@ -100,7 +100,7 @@ export const useAuthResetPassword = (props) => {
/** /**
* Fetches the authentication page metadata. * Fetches the authentication page metadata.
*/ */
export const useAuthMetadata = (props) => { export const useAuthMetadata = (props = {}) => {
return useRequestQuery( return useRequestQuery(
[t.AUTH_METADATA_PAGE], [t.AUTH_METADATA_PAGE],
{ {

View File

@@ -152,7 +152,7 @@ export function useAccountUncategorizedTransactionsInfinity(
const apiRequest = useApiRequest(); const apiRequest = useApiRequest();
return useInfiniteQuery( return useInfiniteQuery(
[t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, accountId], [t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, accountId, query],
async ({ pageParam = 1 }) => { async ({ pageParam = 1 }) => {
const response = await apiRequest.http({ const response = await apiRequest.http({
...axios, ...axios,

View File

@@ -12,7 +12,7 @@ import useApiRequest from '../useRequest';
import { transformToCamelCase } from '@/utils'; import { transformToCamelCase } from '@/utils';
const QueryKeys = { const QueryKeys = {
Subscriptions: 'Subscriptions', Subscriptions: 'GetSubscriptions',
}; };
interface CancelMainSubscriptionValues {} interface CancelMainSubscriptionValues {}

View File

@@ -1,43 +1,35 @@
// @ts-nocheck // @ts-nocheck
import LazyLoader from '@/components/LazyLoader'; import { lazy } from 'react';
const BASE_URL = '/auth'; const BASE_URL = '/auth';
export default [ export default [
{ {
path: `${BASE_URL}/login`, path: `${BASE_URL}/login`,
component: LazyLoader({ component: lazy(() => import('@/containers/Authentication/Login')),
loader: () => import('@/containers/Authentication/Login'),
}),
}, },
{ {
path: `${BASE_URL}/send_reset_password`, path: `${BASE_URL}/send_reset_password`,
component: LazyLoader({ component: lazy(
loader: () => import('@/containers/Authentication/SendResetPassword'), () => import('@/containers/Authentication/SendResetPassword'),
}), ),
}, },
{ {
path: `${BASE_URL}/reset_password/:token`, path: `${BASE_URL}/reset_password/:token`,
component: LazyLoader({ component: lazy(() => import('@/containers/Authentication/ResetPassword')),
loader: () => import('@/containers/Authentication/ResetPassword'),
}),
}, },
{ {
path: `${BASE_URL}/invite/:token/accept`, path: `${BASE_URL}/invite/:token/accept`,
component: LazyLoader({ component: lazy(() => import('@/containers/Authentication/InviteAccept')),
loader: () => import('@/containers/Authentication/InviteAccept'),
}),
}, },
{ {
path: `${BASE_URL}/register/email_confirmation`, path: `${BASE_URL}/register/email_confirmation`,
component: LazyLoader({ component: lazy(
loader: () => import('@/containers/Authentication/EmailConfirmation'), () => import('@/containers/Authentication/EmailConfirmation'),
}), ),
}, },
{ {
path: `${BASE_URL}/register`, path: `${BASE_URL}/register`,
component: LazyLoader({ component: lazy(() => import('@/containers/Authentication/Register')),
loader: () => import('@/containers/Authentication/Register'),
}),
}, },
]; ];

View File

@@ -1,103 +1,111 @@
// @ts-nocheck // @ts-nocheck
import { lazy } from 'react'; import { lazy } from 'react';
import General from '@/containers/Preferences/General/General';
import Users from '../containers/Preferences/Users/Users';
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 DefaultRoute from '../containers/Preferences/DefaultRoute';
import Warehouses from '../containers/Preferences/Warehouses';
import Branches from '../containers/Preferences/Branches';
import Invoices from '../containers/Preferences/Invoices/PreferencesInvoices';
import { PreferencesCreditNotes } from '../containers/Preferences/CreditNotes/PreferencesCreditNotes';
import { PreferencesEstimates } from '@/containers/Preferences/Estimates/PreferencesEstimates';
import { PreferencesReceipts } from '@/containers/Preferences/Receipts/PreferencesReceipts';
import BillingPage from '@/containers/Subscriptions/BillingPage';
const BASE_URL = '/preferences'; const BASE_URL = '/preferences';
export default [ export const getPreferenceRoutes = () => [
{ {
path: `${BASE_URL}/general`, path: `${BASE_URL}/general`,
component: General, component: lazy(() => import('@/containers/Preferences/General/General')),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/users`, path: `${BASE_URL}/users`,
component: Users, component: lazy(() => import('../containers/Preferences/Users/Users')),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/invoices`, path: `${BASE_URL}/invoices`,
component: Invoices, component: lazy(
() => import('../containers/Preferences/Invoices/PreferencesInvoices'),
),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/credit-notes`, path: `${BASE_URL}/credit-notes`,
component: PreferencesCreditNotes, component: lazy(() =>
import(
'../containers/Preferences/CreditNotes/PreferencesCreditNotes'
).then((module) => ({ default: module.PreferencesCreditNotes })),
),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/estimates`, path: `${BASE_URL}/estimates`,
component: PreferencesEstimates, component: lazy(() =>
import('@/containers/Preferences/Estimates/PreferencesEstimates').then(
(module) => ({ default: module.PreferencesEstimates }),
),
),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/receipts`, path: `${BASE_URL}/receipts`,
component: PreferencesReceipts, component: lazy(() =>
import('@/containers/Preferences/Receipts/PreferencesReceipts').then(
(module) => ({ default: module.PreferencesReceipts }),
),
),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/roles`, path: `${BASE_URL}/roles`,
component: Roles, component: lazy(
() =>
import('../containers/Preferences/Users/Roles/RolesForm/RolesFormPage'),
),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/roles/:id`, path: `${BASE_URL}/roles/:id`,
component: Roles, component: lazy(
() =>
import('../containers/Preferences/Users/Roles/RolesForm/RolesFormPage'),
),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/currencies`, path: `${BASE_URL}/currencies`,
component: Currencies, component: lazy(
() => import('@/containers/Preferences/Currencies/Currencies'),
),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/warehouses`, path: `${BASE_URL}/warehouses`,
component: Warehouses, component: lazy(() => import('../containers/Preferences/Warehouses')),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/branches`, path: `${BASE_URL}/branches`,
component: Branches, component: lazy(() => import('../containers/Preferences/Branches')),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/accountant`, path: `${BASE_URL}/accountant`,
component: Accountant, component: lazy(
() => import('@/containers/Preferences/Accountant/Accountant'),
),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/items`, path: `${BASE_URL}/items`,
component: Item, component: lazy(() => import('@/containers/Preferences/Item')),
exact: true,
},
{
path: `${BASE_URL}/sms-message`,
component: SMSIntegration,
exact: true, exact: true,
}, },
// {
// path: `${BASE_URL}/sms-message`,
// component: SMSIntegration,
// exact: true,
// },
{ {
path: `${BASE_URL}/billing`, path: `${BASE_URL}/billing`,
component: BillingPage, component: lazy(() => import('@/containers/Subscriptions/BillingPage')),
exact: true, exact: true,
}, },
{ {
path: `${BASE_URL}/`, path: `${BASE_URL}/`,
component: DefaultRoute, component: lazy(() => import('../containers/Preferences/DefaultRoute')),
exact: true, exact: true,
}, },
]; ];

Some files were not shown because too many files have changed in this diff Show More