Compare commits

..

16 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
107a6f793b Merge pull request #526 from bigcapitalhq/monthly-plans
feat: upgrade the subscription plans
2024-07-14 14:21:57 +02:00
Ahmed Bouhuolia
67d155759e feat: backend the new monthly susbcription plans 2024-07-14 14:19:04 +02:00
Ahmed Bouhuolia
7e2e87256f Merge pull request #527 from bigcapitalhq/fix-sync-removed-transactions
fix: sync the removed bank transactions from the source
2024-07-13 21:56:13 +02:00
Ahmed Bouhuolia
df7790d7c1 fix: sync the removed bank transactions from the source 2024-07-13 21:54:44 +02:00
Ahmed Bouhuolia
72128a72c4 feat: add variant ids to new subscription plans 2024-07-13 19:53:52 +02:00
Ahmed Bouhuolia
eb3f23554f feat: upgrade the subscription plans 2024-07-13 18:19:18 +02:00
Ahmed Bouhuolia
69ddf43b3e fix: duplicated event emitter 2024-07-13 03:23:25 +02:00
Ahmed Bouhuolia
249eadaeaa Merge pull request #525 from bigcapitalhq/fix-plaid-transactions-syncing
fix: Plaid transactions syncing
2024-07-12 23:44:27 +02:00
Ahmed Bouhuolia
59168bc691 fix: Plaid transactions syncing 2024-07-12 23:43:20 +02:00
Ahmed Bouhuolia
81b26c6f13 fix(hotfix): uniqid import 2024-07-12 20:15:28 +02:00
Ahmed Bouhuolia
da435d85d9 Merge pull request #524 from bigcapitalhq/fix-cashflow-transactions-type
fix: Cashflow transactions types
2024-07-09 14:57:43 +02:00
Ahmed Bouhuolia
d096e49d45 Merge pull request #523 from bigcapitalhq/matching-transactions-fixes
fix: Matching transactions bugs
2024-07-08 22:18:12 +02:00
Ahmed Bouhuolia
73acdb6240 fix: add bank rule categories 2024-07-08 21:48:16 +02:00
Ahmed Bouhuolia
38d4122d11 fix: matching transactions bugs 2024-07-08 19:37:11 +02:00
Ahmed Bouhuolia
24a77c81b3 fix: unexpected char in cashflow transactions report 2024-07-08 15:25:28 +02:00
Ahmed Bouhuolia
7f41b4280e fix: the database migration schema 2024-07-08 15:18:58 +02:00
54 changed files with 1031 additions and 327 deletions

View File

@@ -5,7 +5,8 @@ exports.up = function (knex) {
.integer('uncategorized_transaction_id')
.unsigned()
.references('id')
.inTable('uncategorized_cashflow_transactions');
.inTable('uncategorized_cashflow_transactions')
.withKeyName('recognizedBankTransactionsUncategorizedTransIdForeign');
table
.integer('bank_rule_id')
.unsigned()

View File

@@ -1,6 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('uncategorized_cashflow_transactions', (table) => {
table.integer('recognized_transaction_id').unsigned();
table
.integer('recognized_transaction_id')
.unsigned()
.references('id')
.inTable('recognized_bank_transactions')
.withKeyName('uncategorizedCashflowTransRecognizedTranIdForeign');
});
};

View File

@@ -1,7 +1,11 @@
exports.up = function (knex) {
return knex.schema.createTable('matched_bank_transactions', (table) => {
table.increments('id');
table.integer('uncategorized_transaction_id').unsigned();
table
.integer('uncategorized_transaction_id')
.unsigned()
.references('id')
.inTable('uncategorized_cashflow_transactions');
table.string('reference_type');
table.integer('reference_id').unsigned();
table.decimal('amount');

View File

@@ -1,3 +1,4 @@
import { Knex } from 'knex';
import {
IFinancialSheetCommonMeta,
INumberFormatQuery,
@@ -257,7 +258,6 @@ export interface IUncategorizedCashflowTransaction {
categorized: boolean;
}
export interface CreateUncategorizedTransactionDTO {
date: Date | string;
accountId: number;
@@ -269,3 +269,16 @@ export interface CreateUncategorizedTransactionDTO {
plaidTransactionId?: string | null;
batch?: string;
}
export interface IUncategorizedTransactionCreatingEventPayload {
tenantId: number;
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO;
trx: Knex.Transaction;
}
export interface IUncategorizedTransactionCreatedEventPayload {
tenantId: number;
uncategorizedTransaction: any;
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO;
trx: Knex.Transaction;
}

View File

@@ -130,8 +130,9 @@ export interface ICommandCashflowDeletedPayload {
export interface ICashflowTransactionCategorizedPayload {
tenantId: number;
cashflowTransactionId: number;
uncategorizedTransaction: any;
cashflowTransaction: ICashflowTransaction;
categorizeDTO: any;
trx: Knex.Transaction;
}
export interface ICashflowTransactionUncategorizingPayload {

View File

@@ -0,0 +1,8 @@
import { ImportFilePreviewPOJO } from "@/services/Import/interfaces";
export interface IImportFileCommitedEventPayload {
tenantId: number;
importId: number;
meta: ImportFilePreviewPOJO;
}

View File

@@ -112,6 +112,7 @@ import { RecognizeSyncedBankTranasctions } from '@/services/Banking/Plaid/subscr
import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule';
import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch';
import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude';
import { DecrementUncategorizedTransactionOnCategorize } from '@/services/Cashflow/subscribers/DecrementUncategorizedTransactionOnCategorize';
export default () => {
return new EventPublisher();
@@ -262,6 +263,7 @@ export const susbcribers = () => {
UnlinkBankRuleOnDeleteBankRule,
DecrementUncategorizedTransactionOnMatching,
DecrementUncategorizedTransactionOnExclude,
DecrementUncategorizedTransactionOnCategorize,
// Validate matching
ValidateMatchingOnCashflowDelete,

View File

@@ -184,56 +184,4 @@ export default class UncategorizedCashflowTransaction extends mixin(
},
};
}
/**
* Updates the count of uncategorized transactions for the associated account
* based on the specified operation.
* @param {QueryContext} queryContext - The query context for the transaction.
* @param {boolean} increment - Indicates whether to increment or decrement the count.
*/
private async updateUncategorizedTransactionCount(
queryContext: QueryContext,
increment: boolean,
amount: number = 1
) {
const operation = increment ? 'increment' : 'decrement';
await Account.query(queryContext.transaction)
.findById(this.accountId)
[operation]('uncategorized_transactions', amount);
}
/**
* Runs after insert.
* @param {QueryContext} queryContext
*/
public async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
await this.updateUncategorizedTransactionCount(queryContext, true);
}
/**
* Runs after update.
* @param {ModelOptions} opt
* @param {QueryContext} queryContext
*/
public async $afterUpdate(
opt: ModelOptions,
queryContext: QueryContext
): Promise<any> {
await super.$afterUpdate(opt, queryContext);
if (this.id && this.categorized) {
await this.updateUncategorizedTransactionCount(queryContext, false);
}
}
/**
* Runs after delete.
* @param {QueryContext} queryContext
*/
public async $afterDelete(queryContext: QueryContext) {
await super.$afterDelete(queryContext);
await this.updateUncategorizedTransactionCount(queryContext, false);
}
}

View File

@@ -39,7 +39,6 @@ export class DecrementUncategorizedTransactionOnMatching {
const transaction = await UncategorizedCashflowTransaction.query().findById(
uncategorizedTransactionId
);
//
await Account.query(trx)
.findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1);
@@ -60,7 +59,6 @@ export class DecrementUncategorizedTransactionOnMatching {
const transaction = await UncategorizedCashflowTransaction.query().findById(
uncategorizedTransactionId
);
//
await Account.query(trx)
.findById(transaction.accountId)
.increment('uncategorizedTransactions', 1);

View File

@@ -17,7 +17,7 @@ import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTra
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { Knex } from 'knex';
import { uniqid } from 'uniqid';
import uniqid from 'uniqid';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
@@ -148,7 +148,6 @@ export class PlaidSyncDb {
*/
public async syncAccountsTransactions(
tenantId: number,
batchNo: string,
plaidAccountsTransactions: PlaidTransaction[],
trx?: Knex.Transaction
): Promise<void> {
@@ -161,7 +160,6 @@ export class PlaidSyncDb {
return this.syncAccountTranactions(
tenantId,
plaidAccountId,
batchNo,
plaidTransactions,
trx
);

View File

@@ -73,8 +73,6 @@ export class PlaidUpdateTransactions {
added.concat(modified),
trx
);
// Sync removed transactions.
await this.plaidSync.syncRemoveTransactions(tenantId, removed, trx);
// Sync transactions cursor.
await this.plaidSync.syncTransactionsCursor(
tenantId,

View File

@@ -37,7 +37,6 @@ export const transformPlaidAccountToCreateAccount = R.curry(
export const transformPlaidTrxsToCashflowCreate = R.curry(
(
cashflowAccountId: number,
creditAccountId: number,
plaidTranasction: PlaidTransaction
): CreateUncategorizedTransactionDTO => {
return {

View File

@@ -18,11 +18,11 @@ export class RegonizeTransactionsJob {
* Triggers sending invoice mail.
*/
private handler = async (job, done: Function) => {
const { tenantId } = job.attrs.data;
const { tenantId, batch } = job.attrs.data;
const regonizeTransactions = Container.get(RecognizeTranasctionsService);
try {
await regonizeTransactions.recognizeTransactions(tenantId);
await regonizeTransactions.recognizeTransactions(tenantId, batch);
done();
} catch (error) {
console.log(error);

View File

@@ -5,6 +5,8 @@ import {
IBankRuleEventDeletedPayload,
IBankRuleEventEditedPayload,
} from '../../Rules/types';
import { IImportFileCommitedEventPayload } from '@/interfaces/Import';
import { Import } from '@/system/models';
@Service()
export class TriggerRecognizedTransactions {
@@ -27,6 +29,10 @@ export class TriggerRecognizedTransactions {
events.bankRules.onDeleted,
this.recognizedTransactionsOnRuleDeleted.bind(this)
);
bus.subscribe(
events.import.onImportCommitted,
this.triggerRecognizeTransactionsOnImportCommitted.bind(this)
);
}
/**
@@ -73,4 +79,20 @@ export class TriggerRecognizedTransactions {
const payload = { tenantId };
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
/**
* Triggers the recognize bank transactions once the imported file commit.
* @param {IImportFileCommitedEventPayload} payload -
*/
private async triggerRecognizeTransactionsOnImportCommitted({
tenantId,
importId,
meta,
}: IImportFileCommitedEventPayload) {
const importFile = await Import.query().findOne({ importId });
const batch = importFile.paramsParsed.batch;
const payload = { tenantId, batch };
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
}

View File

@@ -84,20 +84,23 @@ export class CategorizeCashflowTransaction {
cashflowTransactionDTO
);
// Updates the uncategorized transaction as categorized.
await UncategorizedCashflowTransaction.query(trx).patchAndFetchById(
uncategorizedTransactionId,
{
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
}
);
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query(trx).patchAndFetchById(
uncategorizedTransactionId,
{
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
}
);
// Triggers `onCashflowTransactionCategorized` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorized,
{
tenantId,
// cashflowTransaction,
cashflowTransaction,
uncategorizedTransaction,
categorizeDTO,
trx,
} as ICashflowTransactionCategorizedPayload
);

View File

@@ -2,7 +2,13 @@ import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import UnitOfWork from '../UnitOfWork';
import { CreateUncategorizedTransactionDTO } from '@/interfaces';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
CreateUncategorizedTransactionDTO,
IUncategorizedTransactionCreatedEventPayload,
IUncategorizedTransactionCreatingEventPayload,
} from '@/interfaces';
@Service()
export class CreateUncategorizedTransaction {
@@ -12,6 +18,9 @@ export class CreateUncategorizedTransaction {
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Creates an uncategorized cashflow transaction.
* @param {number} tenantId
@@ -19,7 +28,7 @@ export class CreateUncategorizedTransaction {
*/
public create(
tenantId: number,
createDTO: CreateUncategorizedTransactionDTO,
createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO,
trx?: Knex.Transaction
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
@@ -27,12 +36,30 @@ export class CreateUncategorizedTransaction {
return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
const transaction = await UncategorizedCashflowTransaction.query(
trx
).insertAndFetch({
...createDTO,
});
return transaction;
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorizedCreating,
{
tenantId,
createUncategorizedTransactionDTO,
trx,
} as IUncategorizedTransactionCreatingEventPayload
);
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query(trx).insertAndFetch({
...createUncategorizedTransactionDTO,
});
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorizedCreated,
{
tenantId,
uncategorizedTransaction,
createUncategorizedTransactionDTO,
trx,
} as IUncategorizedTransactionCreatedEventPayload
);
return uncategorizedTransaction;
},
trx
);

View File

@@ -1,6 +1,7 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import * as yup from 'yup';
import uniqid from 'uniqid';
import { Importable } from '../Import/Importable';
import { CreateUncategorizedTransaction } from './CreateUncategorizedTransaction';
import { CreateUncategorizedTransactionDTO } from '@/interfaces';
@@ -15,6 +16,7 @@ export class UncategorizedTransactionsImportable extends Importable {
@Inject()
private tenancy: HasTenancyService;
/**
* Passing the sheet DTO to create uncategorized transaction.
* @param {number} tenantId
@@ -43,6 +45,7 @@ export class UncategorizedTransactionsImportable extends Importable {
return {
...createDTO,
accountId: context.import.paramsParsed.accountId,
batch: context.import.paramsParsed.batch,
};
}
@@ -54,6 +57,9 @@ export class UncategorizedTransactionsImportable extends Importable {
return BankTransactionsSampleData;
}
// ------------------
// # Params
// ------------------
/**
* Params validation schema.
* @returns {ValidationSchema[]}
@@ -79,4 +85,17 @@ export class UncategorizedTransactionsImportable extends Importable {
await Account.query().findById(params.accountId).throwIfNotFound({});
}
}
/**
* Transformes the import params before storing them.
* @param {Record<string, any>} parmas
*/
public transformParams(parmas: Record<string, any>) {
const batch = uniqid();
return {
...parmas,
batch,
};
}
}

View File

@@ -0,0 +1,78 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import {
ICashflowTransactionCategorizedPayload,
ICashflowTransactionUncategorizedPayload,
} from '@/interfaces';
@Service()
export class DecrementUncategorizedTransactionOnCategorize {
@Inject()
private tenancy: HasTenancyService;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.cashflow.onTransactionCategorized,
this.decrementUnCategorizedTransactionsOnCategorized.bind(this)
);
bus.subscribe(
events.cashflow.onTransactionUncategorized,
this.incrementUnCategorizedTransactionsOnUncategorized.bind(this)
);
bus.subscribe(
events.cashflow.onTransactionUncategorizedCreated,
this.incrementUncategoirzedTransactionsOnCreated.bind(this)
);
}
/**
* Decrement the uncategoirzed transactions on the account once categorizing.
* @param {ICashflowTransactionCategorizedPayload}
*/
public async decrementUnCategorizedTransactionsOnCategorized({
tenantId,
uncategorizedTransaction,
}: ICashflowTransactionCategorizedPayload) {
const { Account } = this.tenancy.models(tenantId);
await Account.query()
.findById(uncategorizedTransaction.accountId)
.decrement('uncategorizedTransactions', 1);
}
/**
* Increment the uncategorized transaction on the given account on uncategorizing.
* @param {IManualJournalDeletingPayload}
*/
public async incrementUnCategorizedTransactionsOnUncategorized({
tenantId,
uncategorizedTransaction,
}: ICashflowTransactionUncategorizedPayload) {
const { Account } = this.tenancy.models(tenantId);
await Account.query()
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);
}
/**
* Increments uncategorized transactions count once creating a new transaction.
* @param {ICommandCashflowCreatedPayload} payload -
*/
public async incrementUncategoirzedTransactionsOnCreated({
tenantId,
uncategorizedTransaction,
trx,
}: any) {
const { Account } = this.tenancy.models(tenantId);
if (!uncategorizedTransaction.accountId) return;
await Account.query(trx)
.findById(uncategorizedTransaction.accountId)
.increment('uncategorizedTransactions', 1);
}
}

View File

@@ -74,7 +74,7 @@ export class CashflowAccountTransactionReport extends FinancialSheet {
const firstMatchedTrans = first(matchedTrans);
return (
(firstCategorizedTrans?.id ||
firstCategorizedTrans?.id ||
firstMatchedTrans?.uncategorizedTransactionId ||
null
);

View File

@@ -15,14 +15,10 @@ import { ServiceError } from '@/exceptions';
import { getUniqueImportableValue, trimObject } from './_utils';
import { ImportableResources } from './ImportableResources';
import ResourceService from '../Resource/ResourceService';
import HasTenancyService from '../Tenancy/TenancyService';
import { Import } from '@/system/models';
@Service()
export class ImportFileCommon {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private importFileValidator: ImportFileDataValidator;

View File

@@ -0,0 +1,47 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { ImportFilePreviewPOJO } from './interfaces';
import { ImportFileProcess } from './ImportFileProcess';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import { IImportFileCommitedEventPayload } from '@/interfaces/Import';
@Service()
export class ImportFileProcessCommit {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private importFile: ImportFileProcess;
@Inject()
private eventPublisher: EventPublisher;
/**
* Commits the imported file.
* @param {number} tenantId
* @param {number} importId
* @returns {Promise<ImportFilePreviewPOJO>}
*/
public async commit(
tenantId: number,
importId: number
): Promise<ImportFilePreviewPOJO> {
const knex = this.tenancy.knex(tenantId);
const trx = await knex.transaction({ isolationLevel: 'read uncommitted' });
const meta = await this.importFile.import(tenantId, importId, trx);
// Commit the successed transaction.
await trx.commit();
// Triggers `onImportFileCommitted` event.
await this.eventPublisher.emitAsync(events.import.onImportCommitted, {
meta,
importId,
tenantId,
} as IImportFileCommitedEventPayload);
return meta;
}
}

View File

@@ -6,6 +6,7 @@ import { ImportFileProcess } from './ImportFileProcess';
import { ImportFilePreview } from './ImportFilePreview';
import { ImportSampleService } from './ImportSample';
import { ImportFileMeta } from './ImportFileMeta';
import { ImportFileProcessCommit } from './ImportFileProcessCommit';
@Inject()
export class ImportResourceApplication {
@@ -27,6 +28,9 @@ export class ImportResourceApplication {
@Inject()
private importMetaService: ImportFileMeta;
@Inject()
private importProcessCommit: ImportFileProcessCommit;
/**
* Reads the imported file and stores the import file meta under unqiue id.
* @param {number} tenantId -
@@ -74,12 +78,12 @@ export class ImportResourceApplication {
* @returns {Promise<ImportFilePreviewPOJO>}
*/
public async process(tenantId: number, importId: number) {
return this.importProcessService.import(tenantId, importId);
return this.importProcessCommit.commit(tenantId, importId);
}
/**
* Retrieves the import meta of the given import id.
* @param {number} tenantId -
* @param {number} tenantId -
* @param {string} importId - Import id.
* @returns {}
*/

View File

@@ -1,4 +1,3 @@
import { getPrice } from '@lemonsqueezy/lemonsqueezy.js';
import config from '@/config';
import { Inject, Service } from 'typedi';
import {
@@ -10,7 +9,6 @@ import {
} from './utils';
import { Plan } from '@/system/models';
import { Subscription } from './Subscription';
import { isEmpty } from 'lodash';
@Service()
export class LemonSqueezyWebhooks {
@@ -18,7 +16,7 @@ export class LemonSqueezyWebhooks {
private subscriptionService: Subscription;
/**
* handle the LemonSqueezy webhooks.
* Handles the Lemon Squeezy webhooks.
* @param {string} rawBody
* @param {string} signature
* @returns {Promise<void>}
@@ -74,7 +72,7 @@ export class LemonSqueezyWebhooks {
const variantId = attributes.variant_id as string;
// We assume that the Plan table is up to date.
const plan = await Plan.query().findOne('slug', 'early-adaptor');
const plan = await Plan.query().findOne('lemonVariantId', variantId);
if (!plan) {
throw new Error(`Plan with variantId ${variantId} not found.`);
@@ -82,26 +80,9 @@ export class LemonSqueezyWebhooks {
// Update the subscription in the database.
const priceId = attributes.first_subscription_item.price_id;
// Get the price data from Lemon Squeezy.
const priceData = await getPrice(priceId);
if (priceData.error) {
throw new Error(
`Failed to get the price data for the subscription ${eventBody.data.id}.`
);
}
const isUsageBased =
attributes.first_subscription_item.is_usage_based;
const price = isUsageBased
? priceData.data?.data.attributes.unit_price_decimal
: priceData.data?.data.attributes.unit_price;
// Create a new subscription of the tenant.
if (webhookEvent === 'subscription_created') {
await this.subscriptionService.newSubscribtion(
tenantId,
'early-adaptor'
);
await this.subscriptionService.newSubscribtion(tenantId, plan.slug);
}
}
} else if (webhookEvent.startsWith('order_')) {

View File

@@ -399,6 +399,9 @@ export default {
onTransactionCategorizing: 'onTransactionCategorizing',
onTransactionCategorized: 'onCashflowTransactionCategorized',
onTransactionUncategorizedCreating: 'onTransactionUncategorizedCreating',
onTransactionUncategorizedCreated: 'onTransactionUncategorizedCreated',
onTransactionUncategorizing: 'onTransactionUncategorizing',
onTransactionUncategorized: 'onTransactionUncategorized',
@@ -647,4 +650,9 @@ export default {
onUnexcluding: 'onBankTransactionUnexcluding',
onUnexcluded: 'onBankTransactionUnexcluded',
},
// Import files.
import: {
onImportCommitted: 'onImportFileCommitted',
},
};

View File

@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('subscription_plans', (table) => {
table.string('lemon_variant_id').nullable().index();
});
};
exports.down = (knex) => {
return knex.schema.table('subscription_plans', (table) => {
table.dropColumn('lemon_variant_id');
});
};

View File

@@ -0,0 +1,96 @@
exports.up = function (knex) {
return knex('subscription_plans').insert([
// Capital Basic
{
name: 'Capital Basic (Monthly)',
slug: 'capital-basic-monthly',
price: 10,
active: true,
currency: 'USD',
invoice_period: 1,
invoice_interval: 'month',
lemon_variant_id: '446152',
// lemon_variant_id: '450016',
},
{
name: 'Capital Basic (Annually)',
slug: 'capital-basic-annually',
price: 90,
active: true,
currency: 'USD',
invoice_period: 1,
invoice_interval: 'year',
lemon_variant_id: '446153',
// lemon_variant_id: '450018',
},
// # Capital Essential
{
name: 'Capital Essential (Monthly)',
slug: 'capital-essential-monthly',
price: 20,
active: true,
currency: 'USD',
invoice_period: 1,
invoice_interval: 'month',
lemon_variant_id: '446155',
// lemon_variant_id: '450028',
},
{
name: 'Capital Essential (Annually)',
slug: 'capital-essential-annually',
price: 180,
active: true,
invoice_period: 1,
invoice_interval: 'year',
lemon_variant_id: '446156',
// lemon_variant_id: '450029',
},
// # Capital Plus
{
name: 'Capital Plus (Monthly)',
slug: 'capital-plus-monthly',
price: 25,
active: true,
invoice_period: 1,
invoice_interval: 'month',
lemon_variant_id: '446165',
// lemon_variant_id: '450031',
},
{
name: 'Capital Plus (Annually)',
slug: 'capital-plus-annually',
price: 228,
active: true,
invoice_period: 1,
invoice_interval: 'year',
lemon_variant_id: '446164',
// lemon_variant_id: '450032',
},
// # Capital Big
{
name: 'Capital Big (Monthly)',
slug: 'capital-big-monthly',
price: 40,
active: true,
invoice_period: 1,
invoice_interval: 'month',
lemon_variant_id: '446167',
// lemon_variant_id: '450024',
},
{
name: 'Capital Big (Annually)',
slug: 'capital-big-annually',
price: 360,
active: true,
invoice_period: 1,
invoice_interval: 'year',
lemon_variant_id: '446168',
// lemon_variant_id: '450025',
},
]);
};
exports.down = function (knex) {};

View File

@@ -23,9 +23,10 @@
color: #fff;
text-align: center;
font-size: 12px;
text-transform: uppercase;
}
.label {
font-size: 14px;
font-size: 16px;
font-weight: 600;
color: #2F343C;
@@ -47,13 +48,31 @@
}
.price {
font-size: 18px;
line-height: 1;
font-weight: 500;
color: #404854;
line-height: 1;
font-weight: 500;
color: #252A31;
}
.pricePer{
color: #738091;
font-size: 12px;
line-height: 1;
}
.featureItem{
flex: 1;
color: #1C2127;
}
.featurePopover :global .bp4-popover-content{
border-radius: 0;
}
.featurePopoverContent{
font-size: 12px
}
.featurePopoverLabel {
text-transform: uppercase;
letter-spacing: 0.4px;
font-size: 12px;
font-weight: 500;
}

View File

@@ -1,4 +1,11 @@
import { Button, ButtonProps, Intent } from '@blueprintjs/core';
import {
Button,
ButtonProps,
Intent,
Position,
Text,
Tooltip,
} from '@blueprintjs/core';
import clsx from 'classnames';
import { Box, Group, Stack } from '../Layout';
import styles from './PricingPlan.module.scss';
@@ -64,7 +71,7 @@ export interface PricingPriceProps {
*/
PricingPlan.Price = ({ price, subPrice }: PricingPriceProps) => {
return (
<Stack spacing={6} className={styles.priceRoot}>
<Stack spacing={4} className={styles.priceRoot}>
<h4 className={styles.price}>{price}</h4>
<span className={styles.pricePer}>{subPrice}</span>
</Stack>
@@ -101,7 +108,7 @@ export interface PricingFeaturesProps {
*/
PricingPlan.Features = ({ children }: PricingFeaturesProps) => {
return (
<Stack spacing={10} className={styles.features}>
<Stack spacing={14} className={styles.features}>
{children}
</Stack>
);
@@ -109,15 +116,41 @@ PricingPlan.Features = ({ children }: PricingFeaturesProps) => {
export interface PricingFeatureLineProps {
children: React.ReactNode;
hintContent?: string;
hintLabel?: string;
}
/**
* Displays a single feature line within a list of features.
* @param children - The content of the feature line.
*/
PricingPlan.FeatureLine = ({ children }: PricingFeatureLineProps) => {
return (
<Group noWrap spacing={12}>
PricingPlan.FeatureLine = ({
children,
hintContent,
hintLabel,
}: PricingFeatureLineProps) => {
return hintContent ? (
<Tooltip
content={
<Stack spacing={5}>
{hintLabel && (
<Text className={styles.featurePopoverLabel}>{hintLabel}</Text>
)}
<Text className={styles.featurePopoverContent}>{hintContent}</Text>
</Stack>
}
position={Position.TOP_LEFT}
popoverClassName={styles.featurePopover}
modifiers={{ offset: { enabled: true, offset: '0,10' } }}
minimal
>
<Group noWrap spacing={8} style={{ cursor: 'help' }}>
<CheckCircled height={12} width={12} />
<Box className={styles.featureItem}>{children}</Box>
</Group>
</Tooltip>
) : (
<Group noWrap spacing={8}>
<CheckCircled height={12} width={12} />
<Box className={styles.featureItem}>{children}</Box>
</Group>

View File

@@ -39,3 +39,12 @@ export const TRANSACRIONS_TYPE = [
'OtherExpense',
'TransferToAccount',
];
export const MoneyCategoryPerCreditAccountRootType = {
OwnerContribution: ['equity'],
OtherIncome: ['income'],
OwnerDrawing: ['equity'],
OtherExpense: ['expense'],
TransferToAccount: ['asset'],
TransferFromAccount: ['asset'],
};

View File

@@ -1,10 +1,140 @@
// @ts-nocheck
// Subscription plans.
export const plans = [
];
interface SubscriptionPlanFeature {
text: string;
hint?: string;
label?: string;
style?: Record<string, string>;
}
interface SubscriptionPlan {
name: string;
slug: string;
description: string;
features: SubscriptionPlanFeature[];
featured?: boolean;
monthlyPrice: string;
monthlyPriceLabel: string;
annuallyPrice: string;
annuallyPriceLabel: string;
monthlyVariantId: string;
annuallyVariantId: string;
}
// Payment methods.
export const paymentMethods = [
];
export const SubscriptionPlans = [
{
name: 'Capital Basic',
slug: 'capital_basic',
description: 'Good for service businesses that just started.',
features: [
{
text: 'Unlimited Sale Invoices',
hintLabel: 'Unlimited Sale Invoices',
hint: 'Good for service businesses that just started for service businesses that just started',
},
{ text: 'Unlimated Sale Estimates' },
{ text: 'Track GST and VAT' },
{ text: 'Connect Banks for Automatic Importing' },
{ text: 'Chart of Accounts' },
{
text: 'Manual Journals',
hintLabel: 'Manual Journals',
hint: 'Write manual journals entries for financial transactions not automatically captured by the system to adjust financial statements.',
},
{
text: 'Basic Financial Reports & Insights',
hint: 'Balance sheet, profit & loss statement, cashflow statement, general ledger, journal sheet, A/P aging summary, A/R aging summary',
},
{ text: 'Unlimited User Seats' },
],
monthlyPrice: '$10',
monthlyPriceLabel: 'Per month',
annuallyPrice: '$7.5',
annuallyPriceLabel: 'Per month',
monthlyVariantId: '446152',
// monthlyVariantId: '450016',
annuallyVariantId: '446153',
// annuallyVariantId: '450018',
},
{
name: 'Capital Essential',
slug: 'capital_plus',
description: 'Good for have inventory and want more financial reports.',
features: [
{ text: 'All Capital Basic features' },
{ text: 'Purchase Invoices' },
{
text: 'Multi Currency Transactions',
hintLabel: 'Multi Currency',
hint: 'Pay and get paid and do manual journals in any currency with real time exchange rates conversions.',
},
{
text: 'Transactions Locking',
hintLabel: 'Transactions Locking',
hint: 'Transaction Locking freezes transactions to prevent any additions, modifications, or deletions of transactions recorded during the specified date.',
},
{
text: 'Inventory Tracking',
hintLabel: 'Inventory Tracking',
hint: 'Track goods in the stock, cost of goods, and get notifications when quantity is low.',
},
{ text: 'Smart Financial Reports' },
{ text: 'Advanced Inventory Reports' },
],
monthlyPrice: '$20',
monthlyPriceLabel: 'Per month',
annuallyPrice: '$15',
annuallyPriceLabel: 'Per month',
// monthlyVariantId: '450028',
monthlyVariantId: '446155',
// annuallyVariantId: '450029',
annuallyVariantId: '446156',
},
{
name: 'Capital Plus',
slug: 'essentials',
description: 'Good for business want financial and access control.',
features: [
{ text: 'All Capital Essential features' },
{ text: 'Custom User Roles Access' },
{ text: 'Vendor Credits' },
{
text: 'Budgeting',
hint: 'Create multiple budgets and compare targets with actuals to understand how your business is performing.',
},
{ text: 'Analysis Cost Center' },
],
monthlyPrice: '$25',
monthlyPriceLabel: 'Per month',
annuallyPrice: '$19',
annuallyPriceLabel: 'Per month',
featured: true,
// monthlyVariantId: '450031',
monthlyVariantId: '446165',
// annuallyVariantId: '450032',
annuallyVariantId: '446164',
},
{
name: 'Capital Big',
slug: 'essentials',
description: 'Good for businesses have multiple branches.',
features: [
{ text: 'All Capital Plus features' },
{
text: 'Multiple Branches',
hintLabel: '',
hint: 'Track the organization transactions and accounts in multiple branches.',
},
{
text: 'Multiple Warehouses',
hintLabel: 'Multiple Warehouses',
hint: 'Track the organization inventory in multiple warehouses and transfer goods between them.',
},
],
monthlyPrice: '$40',
monthlyPriceLabel: 'Per month',
annuallyPrice: '$30',
annuallyPriceLabel: 'Per month',
// monthlyVariantId: '450024',
monthlyVariantId: '446167',
// annuallyVariantId: '450025',
annuallyVariantId: '446168',
},
] as SubscriptionPlan[];

View File

@@ -1,4 +1,5 @@
// @ts-nocheck
import { useCallback, useMemo } from 'react';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Button, Classes, Intent, Radio, Tag } from '@blueprintjs/core';
import * as R from 'ramda';
@@ -16,11 +17,11 @@ import {
} from '@/components';
import { useCreateBankRule, useEditBankRule } from '@/hooks/query/bank-rules';
import {
AssignTransactionTypeOptions,
FieldCondition,
Fields,
RuleFormValues,
TransactionTypeOptions,
getAccountRootFromMoneyCategory,
initialValues,
} from './_utils';
import { useRuleFormDialogBoot } from './RuleFormBoot';
@@ -31,6 +32,11 @@ import {
} from '@/utils';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { DialogsName } from '@/constants/dialogs';
import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants';
// Retrieves the add money in button options.
const MoneyInOptions = getAddMoneyInOptions();
const MoneyOutOptions = getAddMoneyOutOptions();
function RuleFormContentFormRoot({
// #withDialogActions
@@ -47,7 +53,6 @@ function RuleFormContentFormRoot({
...initialValues,
...transformToForm(transformToCamelCase(bankRule), initialValues),
};
// Handles the form submitting.
const handleSubmit = (
values: RuleFormValues,
@@ -92,8 +97,9 @@ function RuleFormContentFormRoot({
label={'Rule Name'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 300 }}
fastField
>
<FInputGroup name={'name'} />
<FInputGroup name={'name'} fastField />
</FFormGroup>
<FFormGroup
@@ -101,29 +107,22 @@ function RuleFormContentFormRoot({
label={'Apply the rule to account'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 350 }}
fastField
>
<AccountsSelect
name={'applyIfAccountId'}
items={accounts}
filterByTypes={['cash', 'bank']}
fastField
/>
</FFormGroup>
<FFormGroup
name={'applyIfTransactionType'}
label={'Apply to transactions are'}
style={{ maxWidth: 350 }}
>
<FSelect
name={'applyIfTransactionType'}
items={TransactionTypeOptions}
popoverProps={{ minimal: true, inline: false }}
/>
</FFormGroup>
<RuleApplyIfTransactionTypeField />
<FFormGroup
name={'conditionsType'}
label={'Categorize the transactions when'}
fastField
>
<FRadioGroup name={'conditionsType'}>
<Radio value={'and'} label={'All the following criteria matches'} />
@@ -139,34 +138,16 @@ function RuleFormContentFormRoot({
Then Assign
</h3>
<FFormGroup
name={'assignCategory'}
label={'Transaction type'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 300 }}
>
<FSelect
name={'assignCategory'}
items={AssignTransactionTypeOptions}
popoverProps={{ minimal: true, inline: false }}
/>
</FFormGroup>
<FFormGroup
name={'assignAccountId'}
label={'Account category'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 300 }}
>
<AccountsSelect name={'assignAccountId'} items={accounts} />
</FFormGroup>
<RuleAssignCategoryField />
<RuleAssignCategoryAccountField />
<FFormGroup
name={'assignRef'}
label={'Reference'}
style={{ maxWidth: 300 }}
fastField
>
<FInputGroup name={'assignRef'} />
<FInputGroup name={'assignRef'} fastField />
</FFormGroup>
<RuleFormActions />
@@ -203,11 +184,13 @@ function RuleFormConditions() {
name={`conditions[${index}].field`}
label={'Field'}
style={{ marginBottom: 0, flex: '1 0' }}
fastField
>
<FSelect
name={`conditions[${index}].field`}
items={Fields}
popoverProps={{ minimal: true, inline: false }}
fastField
/>
</FFormGroup>
@@ -215,11 +198,13 @@ function RuleFormConditions() {
name={`conditions[${index}].comparator`}
label={'Condition'}
style={{ marginBottom: 0, flex: '1 0' }}
fastField
>
<FSelect
name={`conditions[${index}].comparator`}
items={FieldCondition}
popoverProps={{ minimal: true, inline: false }}
fastField
/>
</FFormGroup>
@@ -227,8 +212,9 @@ function RuleFormConditions() {
name={`conditions[${index}].value`}
label={'Value'}
style={{ marginBottom: 0, flex: '1 0 ', width: '40%' }}
fastField
>
<FInputGroup name={`conditions[${index}].value`} />
<FInputGroup name={`conditions[${index}].value`} fastField />
</FFormGroup>
</Group>
))}
@@ -284,3 +270,104 @@ function RuleFormActionsRoot({
}
const RuleFormActions = R.compose(withDialogActions)(RuleFormActionsRoot);
function RuleApplyIfTransactionTypeField() {
const { setFieldValue } = useFormikContext<RuleFormValues>();
const handleItemChange = useCallback(
(item: any) => {
setFieldValue('applyIfTransactionType', item.value);
setFieldValue('assignCategory', '');
setFieldValue('assignAccountId', '');
},
[setFieldValue],
);
return (
<FFormGroup
name={'applyIfTransactionType'}
label={'Apply to transactions are'}
style={{ maxWidth: 350 }}
fastField
>
<FSelect
name={'applyIfTransactionType'}
items={TransactionTypeOptions}
popoverProps={{ minimal: true, inline: false }}
onItemChange={handleItemChange}
fastField
/>
</FFormGroup>
);
}
function RuleAssignCategoryField() {
const { values, setFieldValue } = useFormikContext<RuleFormValues>();
// Retrieves the transaction types if it is deposit or withdrawal.
const transactionTypes = useMemo(
() =>
values?.applyIfTransactionType === 'deposit'
? MoneyInOptions
: MoneyOutOptions,
[values?.applyIfTransactionType],
);
// Handles the select item change.
const handleItemChange = useCallback(
(item: any) => {
setFieldValue('assignCategory', item.value);
setFieldValue('assignAccountId', '');
},
[setFieldValue],
);
return (
<FFormGroup
name={'assignCategory'}
label={'Transaction type'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 300 }}
fastField
>
<FSelect
name={'assignCategory'}
items={transactionTypes}
popoverProps={{ minimal: true, inline: false }}
valueAccessor={'value'}
textAccessor={'name'}
onItemChange={handleItemChange}
fastField
/>
</FFormGroup>
);
}
function RuleAssignCategoryAccountField() {
const { values } = useFormikContext<RuleFormValues>();
const { accounts } = useRuleFormDialogBoot();
const accountRoot = useMemo(
() => getAccountRootFromMoneyCategory(values.assignCategory),
[values.assignCategory],
);
return (
<FFormGroup
name={'assignAccountId'}
label={'Account category'}
labelInfo={<Tag minimal>Required</Tag>}
style={{ maxWidth: 300 }}
fastField
shouldUpdateDeps={{ accountRoot }}
>
<AccountsSelect
name={'assignAccountId'}
items={accounts}
filterByRootTypes={accountRoot}
shouldUpdateDeps={{ accountRoot }}
fastField
/>
</FFormGroup>
);
}

View File

@@ -1,8 +1,11 @@
import { camelCase, get, upperFirst } from 'lodash';
import { MoneyCategoryPerCreditAccountRootType } from '@/constants/cashflowOptions';
export const initialValues = {
name: '',
order: 0,
applyIfAccountId: '',
applyIfTransactionType: '',
applyIfTransactionType: 'deposit',
conditionsType: 'and',
conditions: [
{
@@ -47,3 +50,9 @@ export const FieldCondition = [
export const AssignTransactionTypeOptions = [
{ value: 'expense', text: 'Expense' },
];
export const getAccountRootFromMoneyCategory = (category: string): string[] => {
const _category = upperFirst(camelCase(category));
return get(MoneyCategoryPerCreditAccountRootType, _category) || [];
};

View File

@@ -9,7 +9,6 @@ import {
PopoverInteractionKind,
Position,
Tooltip,
MenuDivider,
} from '@blueprintjs/core';
import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
@@ -213,9 +212,8 @@ export function useAccountUncategorizedTransactionsColumns() {
{
id: 'reference_number',
Header: 'Ref.#',
accessor: 'reference_number',
accessor: 'reference_no',
width: 50,
className: 'reference_number',
clickable: true,
textOverview: true,
},

View File

@@ -6,6 +6,7 @@ import {
FFormGroup,
FInputGroup,
FTextArea,
Icon,
} from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
@@ -21,7 +22,7 @@ export default function CategorizeTransactionOtherIncome() {
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{ fill: true }}
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
/>
</FFormGroup>

View File

@@ -6,6 +6,7 @@ import {
FFormGroup,
FInputGroup,
FTextArea,
Icon,
} from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
@@ -21,7 +22,7 @@ export default function CategorizeTransactionOwnerContribution() {
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{ fill: true }}
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
/>
</FFormGroup>

View File

@@ -6,6 +6,7 @@ import {
FFormGroup,
FInputGroup,
FTextArea,
Icon,
} from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
@@ -21,7 +22,7 @@ export default function CategorizeTransactionTransferFrom() {
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{ fill: true }}
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
/>
</FFormGroup>

View File

@@ -6,6 +6,7 @@ import {
FFormGroup,
FInputGroup,
FTextArea,
Icon,
} from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
@@ -21,7 +22,7 @@ export default function CategorizeTransactionOtherExpense() {
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{ fill: true }}
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
/>
</FFormGroup>

View File

@@ -6,6 +6,7 @@ import {
FFormGroup,
FInputGroup,
FTextArea,
Icon,
} from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
@@ -21,7 +22,7 @@ export default function CategorizeTransactionOwnerDrawings() {
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{ fill: true }}
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
/>
</FFormGroup>

View File

@@ -6,6 +6,7 @@ import {
FFormGroup,
FInputGroup,
FTextArea,
Icon,
} from '@/components';
import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot';
import { CategorizeTransactionBranchField } from '../CategorizeTransactionBranchField';
@@ -21,7 +22,7 @@ export default function CategorizeTransactionToAccount() {
popoverProps={{ position: Position.BOTTOM, minimal: true }}
formatDate={(date) => date.toLocaleDateString()}
parseDate={(str) => new Date(str)}
inputProps={{ fill: true }}
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
/>
</FFormGroup>

View File

@@ -4,7 +4,7 @@ export const MatchingReconcileFormSchema = Yup.object().shape({
type: Yup.string().required().label('Type'),
date: Yup.string().required().label('Date'),
amount: Yup.string().required().label('Amount'),
memo: Yup.string().required().label('Memo'),
memo: Yup.string().required().min(3).label('Memo'),
referenceNo: Yup.string().label('Refernece #'),
category: Yup.string().required().label('Categogry'),
});

View File

@@ -1,14 +1,9 @@
// @ts-nocheck
import * as R from 'ramda';
import { Button, Intent, Position, Tag } from '@blueprintjs/core';
import {
Form,
Formik,
FormikHelpers,
FormikValues,
useFormikContext,
} from 'formik';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import moment from 'moment';
import { round } from 'lodash';
import {
AccountsSelect,
AppToaster,
@@ -19,6 +14,7 @@ import {
FInputGroup,
FMoneyInputGroup,
Group,
Icon,
} from '@/components';
import { Aside } from '@/components/Aside/Aside';
import { momentFormatter } from '@/utils';
@@ -100,7 +96,7 @@ function MatchingReconcileTransactionFormRoot({
const _initialValues = {
...initialValues,
amount: Math.abs(reconcileMatchingTransactionPendingAmount) || 0,
amount: round(Math.abs(reconcileMatchingTransactionPendingAmount), 2) || 0,
date: moment().format('YYYY-MM-DD'),
type:
reconcileMatchingTransactionPendingAmount > 0 ? 'deposit' : 'withdrawal',
@@ -179,7 +175,7 @@ function CreateReconcileTransactionContent() {
},
boundary: 'viewport',
}}
inputProps={{ fill: true }}
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
fill
fastField
/>

View File

@@ -3,3 +3,7 @@
margin: 0 auto;
padding: 0 40px;
}
.periodSwitch {
margin: 0;
}

View File

@@ -1,32 +1,65 @@
// @ts-nocheck
import { AppToaster, Group, T } from '@/components';
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
import { Intent } from '@blueprintjs/core';
import * as R from 'ramda';
import { AppToaster } from '@/components';
import { useGetLemonSqueezyCheckout } from '@/hooks/query';
import { PricingPlan } from '@/components/PricingPlan/PricingPlan';
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
import {
WithPlansProps,
withPlans,
} from '@/containers/Subscriptions/withPlans';
interface SubscriptionPricingFeature {
text: string;
hint?: string;
hintLabel?: string;
style?: Record<string, string>;
}
interface SubscriptionPricingProps {
slug: string;
label: string;
description: string;
features?: Array<String>;
features?: Array<SubscriptionPricingFeature>;
featured?: boolean;
price: string;
pricePeriod: string;
monthlyPrice: string;
monthlyPriceLabel: string;
annuallyPrice: string;
annuallyPriceLabel: string;
monthlyVariantId?: string;
annuallyVariantId?: string;
}
function SubscriptionPricing({
featured,
interface SubscriptionPricingCombinedProps
extends SubscriptionPricingProps,
WithPlansProps {}
function SubscriptionPlanRoot({
label,
description,
featured,
features,
price,
pricePeriod,
}: SubscriptionPricingProps) {
monthlyPrice,
monthlyPriceLabel,
annuallyPrice,
annuallyPriceLabel,
monthlyVariantId,
annuallyVariantId,
// #withPlans
plansPeriod,
}: SubscriptionPricingCombinedProps) {
const { mutateAsync: getLemonCheckout, isLoading } =
useGetLemonSqueezyCheckout();
const handleClick = () => {
getLemonCheckout({ variantId: '338516' })
const variantId =
SubscriptionPlansPeriod.Monthly === plansPeriod
? monthlyVariantId
: annuallyVariantId;
getLemonCheckout({ variantId })
.then((res) => {
const checkoutUrl = res.data.data.attributes.url;
window.LemonSqueezy.Url.Open(checkoutUrl);
@@ -42,37 +75,34 @@ function SubscriptionPricing({
return (
<PricingPlan featured={featured}>
{featured && <PricingPlan.Featured>Most Popular</PricingPlan.Featured>}
<PricingPlan.Header label={label} description={description} />
<PricingPlan.Price price={price} subPrice={pricePeriod} />
{plansPeriod === SubscriptionPlansPeriod.Monthly ? (
<PricingPlan.Price price={monthlyPrice} subPrice={monthlyPriceLabel} />
) : (
<PricingPlan.Price
price={annuallyPrice}
subPrice={annuallyPriceLabel}
/>
)}
<PricingPlan.BuyButton loading={isLoading} onClick={handleClick}>
Subscribe
</PricingPlan.BuyButton>
<PricingPlan.Features>
{features?.map((feature) => (
<PricingPlan.FeatureLine>{feature}</PricingPlan.FeatureLine>
<PricingPlan.FeatureLine
hintLabel={feature.hintLabel}
hintContent={feature.hint}
>
{feature.text}
</PricingPlan.FeatureLine>
))}
</PricingPlan.Features>
</PricingPlan>
);
}
export function SubscriptionPlans({ plans }) {
return (
<Group spacing={18} noWrap align='stretch'>
{plans.map((plan, index) => (
<SubscriptionPricing
key={index}
slug={plan.slug}
label={plan.name}
description={plan.description}
features={plan.features}
featured={plan.featured}
price={plan.price}
pricePeriod={plan.pricePeriod}
/>
))}
</Group>
);
}
export const SubscriptionPlan = R.compose(
withPlans(({ plansPeriod }) => ({ plansPeriod })),
)(SubscriptionPlanRoot);

View File

@@ -0,0 +1,28 @@
import { Group } from '@/components';
import { SubscriptionPlan } from './SubscriptionPlan';
import { useSubscriptionPlans } from './hooks';
export function SubscriptionPlans() {
const subscriptionPlans = useSubscriptionPlans();
return (
<Group spacing={14} noWrap align="stretch">
{subscriptionPlans.map((plan, index) => (
<SubscriptionPlan
key={index}
slug={plan.slug}
label={plan.name}
description={plan.description}
features={plan.features}
featured={plan.featured}
monthlyPrice={plan.monthlyPrice}
monthlyPriceLabel={plan.monthlyPriceLabel}
annuallyPrice={plan.annuallyPrice}
annuallyPriceLabel={plan.annuallyPriceLabel}
monthlyVariantId={plan.monthlyVariantId}
annuallyVariantId={plan.annuallyVariantId}
/>
))}
</Group>
);
}

View File

@@ -0,0 +1,46 @@
import { ChangeEvent } from 'react';
import * as R from 'ramda';
import { Intent, Switch, Tag, Text } from '@blueprintjs/core';
import { Group } from '@/components';
import withSubscriptionPlansActions, {
WithSubscriptionPlansActionsProps,
} from '@/containers/Subscriptions/withSubscriptionPlansActions';
import { SubscriptionPlansPeriod } from '@/store/plans/plans.reducer';
import styles from './SetupSubscription.module.scss';
interface SubscriptionPlansPeriodsSwitchCombinedProps
extends WithSubscriptionPlansActionsProps {}
function SubscriptionPlansPeriodSwitcherRoot({
// #withSubscriptionPlansActions
changeSubscriptionPlansPeriod,
}: SubscriptionPlansPeriodsSwitchCombinedProps) {
// Handles the period switch change.
const handleSwitchChange = (event: ChangeEvent<HTMLInputElement>) => {
changeSubscriptionPlansPeriod(
event.currentTarget.checked
? SubscriptionPlansPeriod.Annually
: SubscriptionPlansPeriod.Monthly,
);
};
return (
<Group position={'center'} spacing={10} style={{ marginBottom: '1.2rem' }}>
<Text>Pay Monthly</Text>
<Switch
large
onChange={handleSwitchChange}
className={styles.periodSwitch}
/>
<Text>
Pay Yearly{' '}
<Tag minimal intent={Intent.NONE}>
25% Off All Year
</Tag>
</Text>
</Group>
);
}
export const SubscriptionPlansPeriodSwitcher = R.compose(
withSubscriptionPlansActions,
)(SubscriptionPlansPeriodSwitcherRoot);

View File

@@ -1,29 +1,21 @@
// @ts-nocheck
import { Callout } from '@blueprintjs/core';
import { SubscriptionPlans } from './SubscriptionPlan';
import withPlans from '../../Subscriptions/withPlans';
import { compose } from '@/utils';
import { SubscriptionPlans } from './SubscriptionPlans';
import { SubscriptionPlansPeriodSwitcher } from './SubscriptionPlansPeriodSwitcher';
/**
* Billing plans.
*/
function SubscriptionPlansSectionRoot({ plans }) {
export function SubscriptionPlansSection() {
return (
<section>
<Callout
style={{ marginBottom: '1.5rem' }}
icon={null}
title={'Early Adopter Plan'}
>
We're looking for 200 early adopters, when you subscribe you'll get the
full features and unlimited users for a year regardless of the
subscribed plan.
<Callout style={{ marginBottom: '2rem' }} icon={null}>
Simple plans. Simple prices. Only pay for what you really need. All
plans come with award-winning 24/7 customer support. Prices do not
include applicable taxes.
</Callout>
<SubscriptionPlans plans={plans} />
<SubscriptionPlansPeriodSwitcher />
<SubscriptionPlans />
</section>
);
}
export const SubscriptionPlansSection = compose(
withPlans(({ plans }) => ({ plans })),
)(SubscriptionPlansSectionRoot);

View File

@@ -0,0 +1,5 @@
import { SubscriptionPlans } from '@/constants/subscriptionModels';
export const useSubscriptionPlans = () => {
return SubscriptionPlans;
};

View File

@@ -1,17 +1,35 @@
// @ts-nocheck
import { connect } from 'react-redux';
import { MapStateToProps, connect } from 'react-redux';
import {
getPlansPeriodSelector,
getPlansSelector,
} from '@/store/plans/plans.selectors';
import { ApplicationState } from '@/store/reducers';
export default (mapState) => {
const mapStateToProps = (state, props) => {
export interface WithPlansProps {
plans: ReturnType<ReturnType<typeof getPlansSelector>>;
plansPeriod: ReturnType<ReturnType<typeof getPlansPeriodSelector>>;
}
type MapState<Props> = (
mapped: WithPlansProps,
state: ApplicationState,
props: Props,
) => any;
export function withPlans<Props>(mapState?: MapState<Props>) {
const mapStateToProps: MapStateToProps<
WithPlansProps,
Props,
ApplicationState
> = (state, props) => {
const getPlans = getPlansSelector();
const getPlansPeriod = getPlansPeriodSelector();
const mapped = {
plans: getPlans(state, props),
plans: getPlans(state),
plansPeriod: getPlansPeriod(state),
};
return mapState ? mapState(mapped, state, props) : mapped;
};
return connect(mapStateToProps);
};
}

View File

@@ -1,9 +1,22 @@
// @ts-nocheck
import { connect } from 'react-redux';
import { initSubscriptionPlans } from '@/store/plans/plans.actions';
import { MapDispatchToProps, connect } from 'react-redux';
import {
SubscriptionPlansPeriod,
changePlansPeriod,
initSubscriptionPlans,
} from '@/store/plans/plans.reducer';
export const mapDispatchToProps = (dispatch) => ({
export interface WithSubscriptionPlansActionsProps {
initSubscriptionPlans: () => void;
changeSubscriptionPlansPeriod: (period: SubscriptionPlansPeriod) => void;
}
export const mapDispatchToProps: MapDispatchToProps<
WithSubscriptionPlansActionsProps,
{}
> = (dispatch: any) => ({
initSubscriptionPlans: () => dispatch(initSubscriptionPlans()),
changeSubscriptionPlansPeriod: (period: SubscriptionPlansPeriod) =>
dispatch(changePlansPeriod({ period })),
});
export default connect(null, mapDispatchToProps);
export default connect(null, mapDispatchToProps);

View File

@@ -235,6 +235,12 @@ export function useExcludeUncategorizedTransaction(
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...options,
},
@@ -282,6 +288,12 @@ export function useUnexcludeUncategorizedTransaction(
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// Invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...options,
},
@@ -323,6 +335,13 @@ export function useMatchUncategorizedTransaction(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// Invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...props,
});
@@ -362,6 +381,13 @@ export function useUnmatchMatchedUncategorizedTransaction(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// Invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...props,
});

View File

@@ -253,6 +253,9 @@ export function useCategorizeTransaction(props) {
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate bank account summary.
queryClient.invalidateQueries('BANK_ACCOUNT_SUMMARY_META');
},
...props,
},
@@ -276,6 +279,9 @@ export function useUncategorizeTransaction(props) {
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate bank account summary.
queryClient.invalidateQueries('BANK_ACCOUNT_SUMMARY_META');
},
...props,
},

View File

@@ -1,70 +1,46 @@
// @ts-nocheck
import { createReducer } from '@reduxjs/toolkit';
import t from '@/store/types';
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { SubscriptionPlans } from '@/constants/subscriptionModels';
const getSubscriptionPlans = () => [
{
name: 'Capital Basic',
slug: 'capital_basic',
description: 'Good for service businesses that just started.',
features: [
'Sale Invoices and Estimates',
'Tracking Expenses',
'Customize Invoice',
'Manual Journals',
'Bank Reconciliation',
'Chart of Accounts',
'Taxes',
'Basic Financial Reports & Insights',
],
price: '$29',
pricePeriod: 'Per Year',
},
{
name: 'Capital Plus',
slug: 'capital_plus',
description:
'Good for businesses have inventory and want more financial reports.',
features: [
'All Capital Basic features',
'Manage Bills',
'Inventory Tracking',
'Multi Currencies',
'Predefined user roles.',
'Transactions locking.',
'Smart Financial Reports.',
],
price: '$29',
pricePeriod: 'Per Year',
featured: true,
},
{
name: 'Capital Big',
slug: 'essentials',
description: 'Good for businesses have multiple inventory or branches.',
features: [
'All Capital Plus features',
'Multiple Warehouses',
'Multiple Branches',
'Invite >= 15 Users',
],
price: '$29',
pricePeriod: 'Per Year',
},
];
export enum SubscriptionPlansPeriod {
Monthly = 'monthly',
Annually = 'Annually',
}
const initialState = {
plans: [],
periods: [],
};
interface StorePlansState {
plans: any;
plansPeriod: SubscriptionPlansPeriod;
}
export default createReducer(initialState, {
/**
* Initialize the subscription plans.
*/
[t.INIT_SUBSCRIPTION_PLANS]: (state) => {
const plans = getSubscriptionPlans();
export const SubscriptionPlansSlice = createSlice({
name: 'plans',
initialState: {
plans: [],
periods: [],
plansPeriod: 'monthly',
} as StorePlansState,
reducers: {
/**
* Initialize the subscription plans.
* @param {StorePlansState} state
*/
initSubscriptionPlans: (state: StorePlansState) => {
const plans = SubscriptionPlans;
state.plans = plans;
},
state.plans = plans;
/**
* Changes the plans period (monthly or annually).
* @param {StorePlansState} state
* @param {PayloadAction<{ period: SubscriptionPlansPeriod }>} action
*/
changePlansPeriod: (
state: StorePlansState,
action: PayloadAction<{ period: SubscriptionPlansPeriod }>,
) => {
state.plansPeriod = action.payload.period;
},
},
});
export const { initSubscriptionPlans, changePlansPeriod } =
SubscriptionPlansSlice.actions;

View File

@@ -2,19 +2,21 @@
import { createSelector } from 'reselect';
const plansSelector = (state) => state.plans.plans;
const planSelector = (state, props) => state.plans.plans
.find((plan) => plan.slug === props.planSlug);
const planSelector = (state, props) =>
state.plans.plans.find((plan) => plan.slug === props.planSlug);
const plansPeriodSelector = (state) => state.plans.plansPeriod;
// Retrieve manual jounral current page results.
export const getPlansSelector = () => createSelector(
plansSelector,
(plans) => {
export const getPlansSelector = () =>
createSelector(plansSelector, (plans) => {
return plans;
},
);
});
// Retrieve plan details.
export const getPlanSelector = () => createSelector(
planSelector,
(plan) => plan,
)
export const getPlanSelector = () =>
createSelector(planSelector, (plan) => plan);
// Retrieves the plans period (monthly or annually).
export const getPlansPeriodSelector = () =>
createSelector(plansPeriodSelector, (periods) => periods);

View File

@@ -32,13 +32,17 @@ import paymentMades from './PaymentMades/paymentMades.reducer';
import organizations from './organizations/organizations.reducers';
import subscriptions from './subscription/subscription.reducer';
import inventoryAdjustments from './inventoryAdjustments/inventoryAdjustment.reducer';
import plans from './plans/plans.reducer';
import { SubscriptionPlansSlice } from './plans/plans.reducer';
import creditNotes from './CreditNote/creditNote.reducer';
import vendorCredit from './VendorCredit/VendorCredit.reducer';
import warehouseTransfers from './WarehouseTransfer/warehouseTransfer.reducer';
import projects from './Project/projects.reducer';
import { PlaidSlice } from './banking/banking.reducer';
export interface ApplicationState {
}
const appReducer = combineReducers({
authentication,
organizations,
@@ -69,7 +73,7 @@ const appReducer = combineReducers({
paymentReceives,
paymentMades,
inventoryAdjustments,
plans,
plans: SubscriptionPlansSlice.reducer,
creditNotes,
vendorCredit,
warehouseTransfers,