mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 22:00:31 +00:00
Compare commits
25 Commits
reconcile-
...
v0.18.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b6b73b77c | ||
|
|
107a6f793b | ||
|
|
67d155759e | ||
|
|
7e2e87256f | ||
|
|
df7790d7c1 | ||
|
|
72128a72c4 | ||
|
|
eb3f23554f | ||
|
|
69ddf43b3e | ||
|
|
249eadaeaa | ||
|
|
59168bc691 | ||
|
|
81b26c6f13 | ||
|
|
da435d85d9 | ||
|
|
533006b90e | ||
|
|
d096e49d45 | ||
|
|
73acdb6240 | ||
|
|
38d4122d11 | ||
|
|
24a77c81b3 | ||
|
|
7f41b4280e | ||
|
|
aa89653967 | ||
|
|
b80bc95fa5 | ||
|
|
9a5befbee7 | ||
|
|
b7487f19d3 | ||
|
|
cd9039fe16 | ||
|
|
87f60f7461 | ||
|
|
202179ec0b |
@@ -38,15 +38,7 @@ export class BankingRulesController extends BaseController {
|
||||
body('conditions.*.value').exists(),
|
||||
|
||||
// Assign
|
||||
body('assign_category')
|
||||
.isString()
|
||||
.isIn([
|
||||
'interest_income',
|
||||
'other_income',
|
||||
'deposit',
|
||||
'expense',
|
||||
'owner_drawings',
|
||||
]),
|
||||
body('assign_category').isString(),
|
||||
body('assign_account_id').isInt({ min: 0 }),
|
||||
body('assign_payee').isString().optional({ nullable: true }),
|
||||
body('assign_memo').isString().optional({ nullable: true }),
|
||||
|
||||
@@ -237,4 +237,8 @@ module.exports = {
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
bucket: process.env.S3_BUCKET || 'bigcapital-documents',
|
||||
},
|
||||
|
||||
loops: {
|
||||
apiKey: process.env.LOOPS_API_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
export const CashflowTransactionTypes = {
|
||||
OtherIncome: 'Other income',
|
||||
OtherExpense: 'Other expense',
|
||||
OwnerDrawing: 'Owner drawing',
|
||||
OwnerContribution: 'Owner contribution',
|
||||
TransferToAccount: 'Transfer to account',
|
||||
TransferFromAccount: 'Transfer from account',
|
||||
};
|
||||
|
||||
export const TransactionTypes = {
|
||||
SaleInvoice: 'Sale invoice',
|
||||
SaleReceipt: 'Sale receipt',
|
||||
PaymentReceive: 'Payment receive',
|
||||
PaymentReceive: 'Payment received',
|
||||
Bill: 'Bill',
|
||||
BillPayment: 'Payment made',
|
||||
VendorOpeningBalance: 'Vendor opening balance',
|
||||
@@ -17,12 +26,10 @@ export const TransactionTypes = {
|
||||
OtherExpense: 'Other expense',
|
||||
OwnerDrawing: 'Owner drawing',
|
||||
InvoiceWriteOff: 'Invoice write-off',
|
||||
|
||||
CreditNote: 'transaction_type.credit_note',
|
||||
VendorCredit: 'transaction_type.vendor_credit',
|
||||
|
||||
RefundCreditNote: 'transaction_type.refund_credit_note',
|
||||
RefundVendorCredit: 'transaction_type.refund_vendor_credit',
|
||||
|
||||
LandedCost: 'transaction_type.landed_cost',
|
||||
CashflowTransaction: CashflowTransactionTypes,
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
exports.up = function (knex) {
|
||||
return knex('accounts_transactions')
|
||||
.whereIn('referenceType', [
|
||||
'OtherIncome',
|
||||
'OtherExpense',
|
||||
'OwnerDrawing',
|
||||
'OwnerContribution',
|
||||
'TransferToAccount',
|
||||
'TransferFromAccount',
|
||||
])
|
||||
.update({
|
||||
transactionType: knex.raw(`
|
||||
CASE
|
||||
WHEN REFERENCE_TYPE = 'OtherIncome' THEN 'OtherIncome'
|
||||
WHEN REFERENCE_TYPE = 'OtherExpense' THEN 'OtherExpense'
|
||||
WHEN REFERENCE_TYPE = 'OwnerDrawing' THEN 'OwnerDrawing'
|
||||
WHEN REFERENCE_TYPE = 'OwnerContribution' THEN 'OwnerContribution'
|
||||
WHEN REFERENCE_TYPE = 'TransferToAccount' THEN 'TransferToAccount'
|
||||
WHEN REFERENCE_TYPE = 'TransferFromAccount' THEN 'TransferFromAccount'
|
||||
END
|
||||
`),
|
||||
referenceType: knex.raw(`
|
||||
CASE
|
||||
WHEN REFERENCE_TYPE IN ('OtherIncome', 'OtherExpense', 'OwnerDrawing', 'OwnerContribution', 'TransferToAccount', 'TransferFromAccount') THEN 'CashflowTransaction'
|
||||
ELSE REFERENCE_TYPE
|
||||
END
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex('accounts_transactions')
|
||||
.whereIn('transactionType', [
|
||||
'OtherIncome',
|
||||
'OtherExpense',
|
||||
'OwnerDrawing',
|
||||
'OwnerContribution',
|
||||
'TransferToAccount',
|
||||
'TransferFromAccount',
|
||||
])
|
||||
.update({
|
||||
referenceType: knex.raw(`
|
||||
CASE
|
||||
WHEN TRANSACTION_TYPE = 'OtherIncome' THEN 'OtherIncome'
|
||||
WHEN TRANSACTION_TYPE = 'OtherExpense' THEN 'OtherExpense'
|
||||
WHEN TRANSACTION_TYPE = 'OwnerDrawing' THEN 'OwnerDrawing'
|
||||
WHEN TRANSACTION_TYPE = 'OwnerContribution' THEN 'OwnerContribution'
|
||||
WHEN TRANSACTION_TYPE = 'TransferToAccount' THEN 'TransferToAccount'
|
||||
WHEN TRANSACTION_TYPE = 'TransferFromAccount' THEN 'TransferFromAccount'
|
||||
ELSE REFERENCE_TYPE
|
||||
END
|
||||
`),
|
||||
transactionType: knex.raw(`
|
||||
CASE
|
||||
WHEN TRANSACTION_TYPE IN ('OtherIncome', 'OtherExpense', 'OwnerDrawing', 'OwnerContribution', 'TransferToAccount', 'TransferFromAccount') THEN NULL
|
||||
ELSE TRANSACTION_TYPE
|
||||
END
|
||||
`),
|
||||
});
|
||||
};
|
||||
@@ -66,7 +66,9 @@ export interface IAccountTransaction {
|
||||
referenceId: number;
|
||||
|
||||
referenceNumber?: string;
|
||||
|
||||
transactionNumber?: string;
|
||||
transactionType?: string;
|
||||
|
||||
note?: string;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,4 +29,9 @@ export interface ICashflowAccountTransaction {
|
||||
|
||||
date: Date;
|
||||
formattedDate: string;
|
||||
|
||||
status: string;
|
||||
formattedStatus: string;
|
||||
|
||||
uncategorizedTransactionId: number;
|
||||
}
|
||||
|
||||
8
packages/server/src/interfaces/Import.ts
Normal file
8
packages/server/src/interfaces/Import.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ImportFilePreviewPOJO } from "@/services/Import/interfaces";
|
||||
|
||||
|
||||
export interface IImportFileCommitedEventPayload {
|
||||
tenantId: number;
|
||||
importId: number;
|
||||
meta: ImportFilePreviewPOJO;
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export interface ILedgerEntry {
|
||||
date: Date | string;
|
||||
|
||||
transactionType: string;
|
||||
transactionSubType: string;
|
||||
|
||||
transactionId: number;
|
||||
|
||||
transactionNumber?: string;
|
||||
|
||||
@@ -110,6 +110,10 @@ import { ValidateMatchingOnPaymentMadeDelete } from '@/services/Banking/Matching
|
||||
import { ValidateMatchingOnCashflowDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete';
|
||||
import { RecognizeSyncedBankTranasctions } from '@/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions';
|
||||
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';
|
||||
import { LoopsEventsSubscriber } from '@/services/Loops/LoopsEventsSubscriber';
|
||||
|
||||
export default () => {
|
||||
return new EventPublisher();
|
||||
@@ -258,6 +262,9 @@ export const susbcribers = () => {
|
||||
// Bank Rules
|
||||
TriggerRecognizedTransactions,
|
||||
UnlinkBankRuleOnDeleteBankRule,
|
||||
DecrementUncategorizedTransactionOnMatching,
|
||||
DecrementUncategorizedTransactionOnExclude,
|
||||
DecrementUncategorizedTransactionOnCategorize,
|
||||
|
||||
// Validate matching
|
||||
ValidateMatchingOnCashflowDelete,
|
||||
@@ -266,7 +273,10 @@ export const susbcribers = () => {
|
||||
ValidateMatchingOnPaymentReceivedDelete,
|
||||
ValidateMatchingOnPaymentMadeDelete,
|
||||
|
||||
// Plaid
|
||||
// Plaid
|
||||
RecognizeSyncedBankTranasctions,
|
||||
|
||||
// Loops
|
||||
LoopsEventsSubscriber
|
||||
];
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export default class AccountTransaction extends TenantModel {
|
||||
debit: number;
|
||||
exchangeRate: number;
|
||||
taxRate: number;
|
||||
transactionType: string;
|
||||
|
||||
/**
|
||||
* Table name
|
||||
@@ -53,7 +54,7 @@ export default class AccountTransaction extends TenantModel {
|
||||
* @return {string}
|
||||
*/
|
||||
get referenceTypeFormatted() {
|
||||
return getTransactionTypeLabel(this.referenceType);
|
||||
return getTransactionTypeLabel(this.referenceType, this.transactionType);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
getCashflowAccountTransactionsTypes,
|
||||
getCashflowTransactionType,
|
||||
} from '@/services/Cashflow/utils';
|
||||
import AccountTransaction from './AccountTransaction';
|
||||
import { CASHFLOW_DIRECTION } from '@/services/Cashflow/constants';
|
||||
import { getTransactionTypeLabel } from '@/utils/transactions-types';
|
||||
import { getCashflowTransactionFormattedType } from '@/utils/transactions-types';
|
||||
|
||||
export default class CashflowTransaction extends TenantModel {
|
||||
transactionType: string;
|
||||
amount: number;
|
||||
@@ -64,7 +64,7 @@ export default class CashflowTransaction extends TenantModel {
|
||||
* @returns {string}
|
||||
*/
|
||||
get transactionTypeFormatted() {
|
||||
return getTransactionTypeLabel(this.transactionType);
|
||||
return getCashflowTransactionFormattedType(this.transactionType);
|
||||
}
|
||||
|
||||
get typeMeta() {
|
||||
@@ -95,6 +95,34 @@ export default class CashflowTransaction extends TenantModel {
|
||||
return !!this.uncategorizedTransaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model modifiers.
|
||||
*/
|
||||
static get modifiers() {
|
||||
return {
|
||||
/**
|
||||
* Filter the published transactions.
|
||||
*/
|
||||
published(query) {
|
||||
query.whereNot('published_at', null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter the not categorized transactions.
|
||||
*/
|
||||
notCategorized(query) {
|
||||
query.whereNull('cashflowTransactions.uncategorizedTransactionId');
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter the categorized transactions.
|
||||
*/
|
||||
categorized(query) {
|
||||
query.whereNotNull('cashflowTransactions.uncategorizedTransactionId');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Relationship mapping.
|
||||
*/
|
||||
@@ -131,8 +159,7 @@ export default class CashflowTransaction extends TenantModel {
|
||||
to: 'accounts_transactions.referenceId',
|
||||
},
|
||||
filter(builder) {
|
||||
const referenceTypes = getCashflowAccountTransactionsTypes();
|
||||
builder.whereIn('reference_type', referenceTypes);
|
||||
builder.where('reference_type', 'CashflowTransaction');
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -105,8 +105,34 @@ export default class UncategorizedCashflowTransaction extends mixin(
|
||||
* Filters the excluded transactions.
|
||||
*/
|
||||
excluded(query) {
|
||||
query.whereNotNull('excluded_at')
|
||||
}
|
||||
query.whereNotNull('excluded_at');
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter out the recognized transactions.
|
||||
* @param query
|
||||
*/
|
||||
recognized(query) {
|
||||
query.whereNotNull('recognizedTransactionId');
|
||||
},
|
||||
|
||||
/**
|
||||
* Filter out the not recognized transactions.
|
||||
* @param query
|
||||
*/
|
||||
notRecognized(query) {
|
||||
query.whereNull('recognizedTransactionId');
|
||||
},
|
||||
|
||||
categorized(query) {
|
||||
query.whereNotNull('categorizeRefType');
|
||||
query.whereNotNull('categorizeRefId');
|
||||
},
|
||||
|
||||
notCategorized(query) {
|
||||
query.whereNull('categorizeRefType');
|
||||
query.whereNull('categorizeRefId');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export const transformLedgerEntryToTransaction = (
|
||||
referenceId: entry.transactionId,
|
||||
|
||||
transactionNumber: entry.transactionNumber,
|
||||
transactionType: entry.transactionSubType,
|
||||
|
||||
referenceNumber: entry.referenceNumber,
|
||||
|
||||
note: entry.note,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { Server } from 'socket.io';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { initialize } from 'objection';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
|
||||
@Service()
|
||||
export class GetBankAccountSummary {
|
||||
@@ -14,22 +14,43 @@ export class GetBankAccountSummary {
|
||||
* @returns
|
||||
*/
|
||||
public async getBankAccountSummary(tenantId: number, bankAccountId: number) {
|
||||
const knex = this.tenancy.knex(tenantId);
|
||||
const {
|
||||
Account,
|
||||
UncategorizedCashflowTransaction,
|
||||
RecognizedBankTransaction,
|
||||
MatchedBankTransaction,
|
||||
} = this.tenancy.models(tenantId);
|
||||
|
||||
await initialize(knex, [
|
||||
UncategorizedCashflowTransaction,
|
||||
RecognizedBankTransaction,
|
||||
MatchedBankTransaction,
|
||||
]);
|
||||
const bankAccount = await Account.query()
|
||||
.findById(bankAccountId)
|
||||
.throwIfNotFound();
|
||||
|
||||
// Retrieves the uncategorized transactions count of the given bank account.
|
||||
const uncategorizedTranasctionsCount =
|
||||
await UncategorizedCashflowTransaction.query()
|
||||
.where('accountId', bankAccountId)
|
||||
.count('id as total')
|
||||
.first();
|
||||
await UncategorizedCashflowTransaction.query().onBuild((q) => {
|
||||
// Include just the given account.
|
||||
q.where('accountId', bankAccountId);
|
||||
|
||||
// Only the not excluded.
|
||||
q.modify('notExcluded');
|
||||
|
||||
// Only the not categorized.
|
||||
q.modify('notCategorized');
|
||||
|
||||
// Only the not matched bank transactions.
|
||||
q.withGraphJoined('matchedBankTransactions');
|
||||
q.whereNull('matchedBankTransactions.id');
|
||||
|
||||
// Count the results.
|
||||
q.count('uncategorized_cashflow_transactions.id as total');
|
||||
q.first();
|
||||
});
|
||||
|
||||
// Retrieves the recognized transactions count of the given bank account.
|
||||
const recognizedTransactionsCount = await RecognizedBankTransaction.query()
|
||||
@@ -43,8 +64,8 @@ export class GetBankAccountSummary {
|
||||
.first();
|
||||
|
||||
const totalUncategorizedTransactions =
|
||||
uncategorizedTranasctionsCount?.total;
|
||||
const totalRecognizedTransactions = recognizedTransactionsCount?.total;
|
||||
uncategorizedTranasctionsCount?.total || 0;
|
||||
const totalRecognizedTransactions = recognizedTransactionsCount?.total || 0;
|
||||
|
||||
return {
|
||||
name: bankAccount.name,
|
||||
|
||||
@@ -2,6 +2,12 @@ import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { validateTransactionNotCategorized } from './utils';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
import {
|
||||
IBankTransactionUnexcludedEventPayload,
|
||||
IBankTransactionUnexcludingEventPayload,
|
||||
} from './_types';
|
||||
|
||||
@Service()
|
||||
export class ExcludeBankTransaction {
|
||||
@@ -11,6 +17,9 @@ export class ExcludeBankTransaction {
|
||||
@Inject()
|
||||
private uow: UnitOfWork;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Marks the given bank transaction as excluded.
|
||||
* @param {number} tenantId
|
||||
@@ -31,11 +40,23 @@ export class ExcludeBankTransaction {
|
||||
validateTransactionNotCategorized(oldUncategorizedTransaction);
|
||||
|
||||
return this.uow.withTransaction(tenantId, async (trx) => {
|
||||
await this.eventPublisher.emitAsync(events.bankTransactions.onExcluding, {
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
trx,
|
||||
} as IBankTransactionUnexcludingEventPayload);
|
||||
|
||||
await UncategorizedCashflowTransaction.query(trx)
|
||||
.findById(uncategorizedTransactionId)
|
||||
.patch({
|
||||
excludedAt: new Date(),
|
||||
});
|
||||
|
||||
await this.eventPublisher.emitAsync(events.bankTransactions.onExcluded, {
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
trx,
|
||||
} as IBankTransactionUnexcludedEventPayload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import UnitOfWork from '@/services/UnitOfWork';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { validateTransactionNotCategorized } from './utils';
|
||||
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
|
||||
import events from '@/subscribers/events';
|
||||
import {
|
||||
IBankTransactionExcludedEventPayload,
|
||||
IBankTransactionExcludingEventPayload,
|
||||
} from './_types';
|
||||
|
||||
@Service()
|
||||
export class UnexcludeBankTransaction {
|
||||
@@ -11,6 +17,9 @@ export class UnexcludeBankTransaction {
|
||||
@Inject()
|
||||
private uow: UnitOfWork;
|
||||
|
||||
@Inject()
|
||||
private eventPublisher: EventPublisher;
|
||||
|
||||
/**
|
||||
* Marks the given bank transaction as excluded.
|
||||
* @param {number} tenantId
|
||||
@@ -20,7 +29,7 @@ export class UnexcludeBankTransaction {
|
||||
public async unexcludeBankTransaction(
|
||||
tenantId: number,
|
||||
uncategorizedTransactionId: number
|
||||
) {
|
||||
): Promise<void> {
|
||||
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
|
||||
|
||||
const oldUncategorizedTransaction =
|
||||
@@ -31,11 +40,27 @@ export class UnexcludeBankTransaction {
|
||||
validateTransactionNotCategorized(oldUncategorizedTransaction);
|
||||
|
||||
return this.uow.withTransaction(tenantId, async (trx) => {
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.bankTransactions.onUnexcluding,
|
||||
{
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
} as IBankTransactionExcludingEventPayload
|
||||
);
|
||||
|
||||
await UncategorizedCashflowTransaction.query(trx)
|
||||
.findById(uncategorizedTransactionId)
|
||||
.patch({
|
||||
excludedAt: null,
|
||||
});
|
||||
|
||||
await this.eventPublisher.emitAsync(
|
||||
events.bankTransactions.onUnexcluded,
|
||||
{
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
} as IBankTransactionExcludedEventPayload
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
export interface ExcludedBankTransactionsQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
accountId?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IBankTransactionUnexcludingEventPayload {
|
||||
tenantId: number;
|
||||
uncategorizedTransactionId: number;
|
||||
trx?: Knex.Transaction
|
||||
}
|
||||
|
||||
export interface IBankTransactionUnexcludedEventPayload {
|
||||
tenantId: number;
|
||||
uncategorizedTransactionId: number;
|
||||
trx?: Knex.Transaction
|
||||
}
|
||||
|
||||
export interface IBankTransactionExcludingEventPayload {
|
||||
tenantId: number;
|
||||
uncategorizedTransactionId: number;
|
||||
trx?: Knex.Transaction
|
||||
}
|
||||
export interface IBankTransactionExcludedEventPayload {
|
||||
tenantId: number;
|
||||
uncategorizedTransactionId: number;
|
||||
trx?: Knex.Transaction
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import events from '@/subscribers/events';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import {
|
||||
IBankTransactionExcludedEventPayload,
|
||||
IBankTransactionUnexcludedEventPayload,
|
||||
} from '../_types';
|
||||
|
||||
@Service()
|
||||
export class DecrementUncategorizedTransactionOnExclude {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
/**
|
||||
* Constructor method.
|
||||
*/
|
||||
public attach(bus) {
|
||||
bus.subscribe(
|
||||
events.bankTransactions.onExcluded,
|
||||
this.decrementUnCategorizedTransactionsOnExclude.bind(this)
|
||||
);
|
||||
bus.subscribe(
|
||||
events.bankTransactions.onUnexcluded,
|
||||
this.incrementUnCategorizedTransactionsOnUnexclude.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the cashflow transaction whether matched with bank transaction on deleting.
|
||||
* @param {IManualJournalDeletingPayload}
|
||||
*/
|
||||
public async decrementUnCategorizedTransactionsOnExclude({
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
trx,
|
||||
}: IBankTransactionExcludedEventPayload) {
|
||||
const { UncategorizedCashflowTransaction, Account } =
|
||||
this.tenancy.models(tenantId);
|
||||
|
||||
const transaction = await UncategorizedCashflowTransaction.query(
|
||||
trx
|
||||
).findById(uncategorizedTransactionId);
|
||||
|
||||
await Account.query(trx)
|
||||
.findById(transaction.accountId)
|
||||
.decrement('uncategorizedTransactions', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the cashflow transaction whether matched with bank transaction on deleting.
|
||||
* @param {IManualJournalDeletingPayload}
|
||||
*/
|
||||
public async incrementUnCategorizedTransactionsOnUnexclude({
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
trx,
|
||||
}: IBankTransactionUnexcludedEventPayload) {
|
||||
const { UncategorizedCashflowTransaction, Account } =
|
||||
this.tenancy.models(tenantId);
|
||||
|
||||
const transaction = await UncategorizedCashflowTransaction.query().findById(
|
||||
uncategorizedTransactionId
|
||||
);
|
||||
//
|
||||
await Account.query(trx)
|
||||
.findById(transaction.accountId)
|
||||
.increment('uncategorizedTransactions', 1);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ export class GetMatchedTransactionBillsTransformer extends Transformer {
|
||||
'transactionNo',
|
||||
'transactionType',
|
||||
'transsactionTypeFormatted',
|
||||
'transactionNormal',
|
||||
'referenceId',
|
||||
'referenceType',
|
||||
];
|
||||
};
|
||||
|
||||
@@ -100,4 +103,29 @@ export class GetMatchedTransactionBillsTransformer extends Transformer {
|
||||
protected transsactionTypeFormatted() {
|
||||
return 'Bill';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the bill transaction normal (debit or credit).
|
||||
* @returns {string}
|
||||
*/
|
||||
protected transactionNormal() {
|
||||
return 'credit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the match transaction reference id.
|
||||
* @param bill
|
||||
* @returns {number}
|
||||
*/
|
||||
protected referenceId(bill) {
|
||||
return bill.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the match transaction referenece type.
|
||||
* @returns {string}
|
||||
*/
|
||||
protected referenceType() {
|
||||
return 'Bill';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Transformer } from '@/lib/Transformer/Transformer';
|
||||
|
||||
export class GetMatchedTransactionCashflowTransformer extends Transformer {
|
||||
/**
|
||||
* Include these attributes to sale credit note object.
|
||||
* @returns {Array}
|
||||
*/
|
||||
public includeAttributes = (): string[] => {
|
||||
return [
|
||||
'referenceNo',
|
||||
'amount',
|
||||
'amountFormatted',
|
||||
'transactionNo',
|
||||
'date',
|
||||
'dateFormatted',
|
||||
'transactionId',
|
||||
'transactionNo',
|
||||
'transactionType',
|
||||
'transsactionTypeFormatted',
|
||||
'transactionNormal',
|
||||
'referenceId',
|
||||
'referenceType',
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Exclude all attributes.
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
public excludeAttributes = (): string[] => {
|
||||
return ['*'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve the invoice reference number.
|
||||
* @returns {string}
|
||||
*/
|
||||
protected referenceNo(invoice) {
|
||||
return invoice.referenceNo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the transaction amount.
|
||||
* @param transaction
|
||||
* @returns {number}
|
||||
*/
|
||||
protected amount(transaction) {
|
||||
return transaction.amount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the transaction formatted amount.
|
||||
* @param transaction
|
||||
* @returns {string}
|
||||
*/
|
||||
protected amountFormatted(transaction) {
|
||||
return this.formatNumber(transaction.amount, {
|
||||
currencyCode: transaction.currencyCode,
|
||||
money: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the date of the invoice.
|
||||
* @param invoice
|
||||
* @returns {Date}
|
||||
*/
|
||||
protected date(transaction) {
|
||||
return transaction.date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the date of the invoice.
|
||||
* @param invoice
|
||||
* @returns {string}
|
||||
*/
|
||||
protected dateFormatted(transaction) {
|
||||
return this.formatDate(transaction.date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the transaction ID of the invoice.
|
||||
* @param invoice
|
||||
* @returns {number}
|
||||
*/
|
||||
protected transactionId(transaction) {
|
||||
return transaction.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the invoice transaction number.
|
||||
* @param invoice
|
||||
* @returns {string}
|
||||
*/
|
||||
protected transactionNo(transaction) {
|
||||
return transaction.transactionNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the invoice transaction type.
|
||||
* @param invoice
|
||||
* @returns {String}
|
||||
*/
|
||||
protected transactionType(transaction) {
|
||||
return transaction.transactionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the invoice formatted transaction type.
|
||||
* @param invoice
|
||||
* @returns {string}
|
||||
*/
|
||||
protected transsactionTypeFormatted(transaction) {
|
||||
return transaction.transactionTypeFormatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the cashflow transaction normal (credit or debit).
|
||||
* @param transaction
|
||||
* @returns {string}
|
||||
*/
|
||||
protected transactionNormal(transaction) {
|
||||
return transaction.isCashCredit ? 'credit' : 'debit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the cashflow transaction reference id.
|
||||
* @param transaction
|
||||
* @returns {number}
|
||||
*/
|
||||
protected referenceId(transaction) {
|
||||
return transaction.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the cashflow transaction reference type.
|
||||
* @returns {string}
|
||||
*/
|
||||
protected referenceType() {
|
||||
return 'CashflowTransaction';
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ export class GetMatchedTransactionExpensesTransformer extends Transformer {
|
||||
'transactionNo',
|
||||
'transactionType',
|
||||
'transsactionTypeFormatted',
|
||||
'transactionNormal',
|
||||
'referenceType',
|
||||
'referenceId',
|
||||
];
|
||||
};
|
||||
|
||||
@@ -111,4 +114,29 @@ export class GetMatchedTransactionExpensesTransformer extends Transformer {
|
||||
protected transsactionTypeFormatted() {
|
||||
return 'Expense';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the expense transaction normal (credit or debit).
|
||||
* @returns {string}
|
||||
*/
|
||||
protected transactionNormal() {
|
||||
return 'credit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the transaction reference type.
|
||||
* @returns {string}
|
||||
*/
|
||||
protected referenceType() {
|
||||
return 'Expense';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the transaction reference id.
|
||||
* @param transaction
|
||||
* @returns {number}
|
||||
*/
|
||||
protected referenceId(transaction) {
|
||||
return transaction.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer {
|
||||
'transactionNo',
|
||||
'transactionType',
|
||||
'transsactionTypeFormatted',
|
||||
'transactionNormal',
|
||||
'referenceType',
|
||||
'referenceId'
|
||||
];
|
||||
};
|
||||
|
||||
@@ -49,7 +52,7 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer {
|
||||
* @param invoice
|
||||
* @returns {string}
|
||||
*/
|
||||
protected formatAmount(invoice) {
|
||||
protected amountFormatted(invoice) {
|
||||
return this.formatNumber(invoice.dueAmount, {
|
||||
currencyCode: invoice.currencyCode,
|
||||
money: true,
|
||||
@@ -79,7 +82,7 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer {
|
||||
* @param invoice
|
||||
* @returns {number}
|
||||
*/
|
||||
protected getTransactionId(invoice) {
|
||||
protected transactionId(invoice) {
|
||||
return invoice.id;
|
||||
}
|
||||
/**
|
||||
@@ -108,4 +111,28 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer {
|
||||
protected transsactionTypeFormatted(invoice) {
|
||||
return 'Sale invoice';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the transaction normal of invoice (credit or debit).
|
||||
* @returns {string}
|
||||
*/
|
||||
protected transactionNormal() {
|
||||
return 'debit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the transaction reference type.
|
||||
* @returns {string}
|
||||
*/ protected referenceType() {
|
||||
return 'SaleInvoice';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the transaction reference id.
|
||||
* @param transaction
|
||||
* @returns {number}
|
||||
*/
|
||||
protected referenceId(transaction) {
|
||||
return transaction.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { sumBy } from 'lodash';
|
||||
import { Transformer } from '@/lib/Transformer/Transformer';
|
||||
import { AccountNormal } from '@/interfaces';
|
||||
|
||||
export class GetMatchedTransactionManualJournalsTransformer extends Transformer {
|
||||
/**
|
||||
@@ -17,6 +19,9 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer
|
||||
'transactionNo',
|
||||
'transactionType',
|
||||
'transsactionTypeFormatted',
|
||||
'transactionNormal',
|
||||
'referenceType',
|
||||
'referenceId',
|
||||
];
|
||||
};
|
||||
|
||||
@@ -37,13 +42,20 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer
|
||||
return manualJournal.referenceNo;
|
||||
}
|
||||
|
||||
protected total(manualJournal) {
|
||||
const credit = sumBy(manualJournal?.entries, 'credit');
|
||||
const debit = sumBy(manualJournal?.entries, 'debit');
|
||||
|
||||
return debit - credit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the manual journal amount.
|
||||
* @param manualJournal
|
||||
* @returns {number}
|
||||
*/
|
||||
protected amount(manualJournal) {
|
||||
return manualJournal.amount;
|
||||
return Math.abs(this.total(manualJournal));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,5 +119,31 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer
|
||||
protected transsactionTypeFormatted() {
|
||||
return 'Manual Journal';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the manual journal transaction normal (credit or debit).
|
||||
* @returns {string}
|
||||
*/
|
||||
protected transactionNormal(transaction) {
|
||||
const amount = this.total(transaction);
|
||||
|
||||
return amount >= 0 ? AccountNormal.DEBIT : AccountNormal.CREDIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the manual journal reference type.
|
||||
* @returns {string}
|
||||
*/
|
||||
protected referenceType() {
|
||||
return 'ManualJournal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the manual journal reference id.
|
||||
* @param transaction
|
||||
* @returns {number}
|
||||
*/
|
||||
protected referenceId(transaction) {
|
||||
return transaction.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills';
|
||||
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { sortClosestMatchTransactions } from './_utils';
|
||||
import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow';
|
||||
import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices';
|
||||
|
||||
@Service()
|
||||
export class GetMatchedTransactions {
|
||||
@@ -15,7 +17,7 @@ export class GetMatchedTransactions {
|
||||
private tenancy: HasTenancyService;
|
||||
|
||||
@Inject()
|
||||
private getMatchedInvoicesService: GetMatchedTransactionsByExpenses;
|
||||
private getMatchedInvoicesService: GetMatchedTransactionsByInvoices;
|
||||
|
||||
@Inject()
|
||||
private getMatchedBillsService: GetMatchedTransactionsByBills;
|
||||
@@ -26,6 +28,9 @@ export class GetMatchedTransactions {
|
||||
@Inject()
|
||||
private getMatchedExpensesService: GetMatchedTransactionsByExpenses;
|
||||
|
||||
@Inject()
|
||||
private getMatchedCashflowService: GetMatchedTransactionsByCashflow;
|
||||
|
||||
/**
|
||||
* Registered matched transactions types.
|
||||
*/
|
||||
@@ -35,6 +40,7 @@ export class GetMatchedTransactions {
|
||||
{ type: 'Bill', service: this.getMatchedBillsService },
|
||||
{ type: 'Expense', service: this.getMatchedExpensesService },
|
||||
{ type: 'ManualJournal', service: this.getMatchedManualJournalService },
|
||||
{ type: 'Cashflow', service: this.getMatchedCashflowService },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { initialize } from 'objection';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer';
|
||||
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types';
|
||||
@@ -22,10 +23,25 @@ export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType
|
||||
tenantId: number,
|
||||
filter: GetMatchedTransactionsFilter
|
||||
) {
|
||||
const { Bill } = this.tenancy.models(tenantId);
|
||||
const { Bill, MatchedBankTransaction } = this.tenancy.models(tenantId);
|
||||
const knex = this.tenancy.knex(tenantId);
|
||||
|
||||
// Initialize the models metadata.
|
||||
await initialize(knex, [Bill, MatchedBankTransaction]);
|
||||
|
||||
// Retrieves the bill matches.
|
||||
const bills = await Bill.query().onBuild((q) => {
|
||||
q.whereNotExists(Bill.relatedQuery('matchedBankTransaction'));
|
||||
q.withGraphJoined('matchedBankTransaction');
|
||||
q.whereNull('matchedBankTransaction.id');
|
||||
q.modify('published');
|
||||
|
||||
if (filter.fromDate) {
|
||||
q.where('billDate', '>=', filter.fromDate);
|
||||
}
|
||||
if (filter.toDate) {
|
||||
q.where('billDate', '<=', filter.toDate);
|
||||
}
|
||||
q.orderBy('billDate', 'DESC');
|
||||
});
|
||||
|
||||
return this.transformer.transform(
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { initialize } from 'objection';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
|
||||
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
|
||||
import { GetMatchedTransactionsFilter } from './types';
|
||||
|
||||
@Service()
|
||||
export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByType {
|
||||
@Inject()
|
||||
private transformer: TransformerInjectable;
|
||||
|
||||
/**
|
||||
* Retrieve the matched transactions of cash flow.
|
||||
* @param {number} tenantId
|
||||
* @param {GetMatchedTransactionsFilter} filter
|
||||
* @returns
|
||||
*/
|
||||
async getMatchedTransactions(
|
||||
tenantId: number,
|
||||
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>
|
||||
) {
|
||||
const { CashflowTransaction, MatchedBankTransaction } =
|
||||
this.tenancy.models(tenantId);
|
||||
const knex = this.tenancy.knex(tenantId);
|
||||
|
||||
// Initialize the ORM models metadata.
|
||||
await initialize(knex, [CashflowTransaction, MatchedBankTransaction]);
|
||||
|
||||
const transactions = await CashflowTransaction.query().onBuild((q) => {
|
||||
// Not matched to bank transaction.
|
||||
q.withGraphJoined('matchedBankTransaction');
|
||||
q.whereNull('matchedBankTransaction.id');
|
||||
|
||||
// Not categorized.
|
||||
q.modify('notCategorized');
|
||||
|
||||
// Published.
|
||||
q.modify('published');
|
||||
|
||||
if (filter.fromDate) {
|
||||
q.where('date', '>=', filter.fromDate);
|
||||
}
|
||||
if (filter.toDate) {
|
||||
q.where('date', '<=', filter.toDate);
|
||||
}
|
||||
q.orderBy('date', 'DESC');
|
||||
});
|
||||
|
||||
return this.transformer.transform(
|
||||
tenantId,
|
||||
transactions,
|
||||
new GetMatchedTransactionCashflowTransformer()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the matched transaction of cash flow.
|
||||
* @param {number} tenantId
|
||||
* @param {number} transactionId
|
||||
* @returns
|
||||
*/
|
||||
async getMatchedTransaction(tenantId: number, transactionId: number) {
|
||||
const { CashflowTransaction, MatchedBankTransaction } =
|
||||
this.tenancy.models(tenantId);
|
||||
const knex = this.tenancy.knex(tenantId);
|
||||
|
||||
// Initialize the ORM models metadata.
|
||||
await initialize(knex, [CashflowTransaction, MatchedBankTransaction]);
|
||||
|
||||
const transactions = await CashflowTransaction.query()
|
||||
.findById(transactionId)
|
||||
.withGraphJoined('matchedBankTransaction')
|
||||
.whereNull('matchedBankTransaction.id')
|
||||
.modify('notCategorized')
|
||||
.modify('published')
|
||||
.throwIfNotFound();
|
||||
|
||||
return this.transformer.transform(
|
||||
tenantId,
|
||||
transactions,
|
||||
new GetMatchedTransactionCashflowTransformer()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { initialize } from 'objection';
|
||||
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
@@ -23,22 +24,34 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy
|
||||
tenantId: number,
|
||||
filter: GetMatchedTransactionsFilter
|
||||
) {
|
||||
const { Expense } = this.tenancy.models(tenantId);
|
||||
const { Expense, MatchedBankTransaction } = this.tenancy.models(tenantId);
|
||||
const knex = this.tenancy.knex(tenantId);
|
||||
|
||||
// Initialize the models metadata.
|
||||
await initialize(knex, [Expense, MatchedBankTransaction]);
|
||||
|
||||
// Retrieve the expense matches.
|
||||
const expenses = await Expense.query().onBuild((query) => {
|
||||
query.whereNotExists(Expense.relatedQuery('matchedBankTransaction'));
|
||||
// Filter out the not matched to bank transactions.
|
||||
query.withGraphJoined('matchedBankTransaction');
|
||||
query.whereNull('matchedBankTransaction.id');
|
||||
|
||||
// Filter the published onyl
|
||||
query.modify('filterByPublished');
|
||||
|
||||
if (filter.fromDate) {
|
||||
query.where('payment_date', '>=', filter.fromDate);
|
||||
query.where('paymentDate', '>=', filter.fromDate);
|
||||
}
|
||||
if (filter.toDate) {
|
||||
query.where('payment_date', '<=', filter.toDate);
|
||||
query.where('paymentDate', '<=', filter.toDate);
|
||||
}
|
||||
if (filter.minAmount) {
|
||||
query.where('total_amount', '>=', filter.minAmount);
|
||||
query.where('totalAmount', '>=', filter.minAmount);
|
||||
}
|
||||
if (filter.maxAmount) {
|
||||
query.where('total_amount', '<=', filter.maxAmount);
|
||||
query.where('totalAmount', '<=', filter.maxAmount);
|
||||
}
|
||||
query.orderBy('paymentDate', 'DESC');
|
||||
});
|
||||
return this.transformer.transform(
|
||||
tenantId,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { initialize } from 'objection';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer';
|
||||
import {
|
||||
@@ -6,7 +8,6 @@ import {
|
||||
MatchedTransactionsPOJO,
|
||||
} from './types';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
|
||||
|
||||
@Service()
|
||||
@@ -27,10 +28,27 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy
|
||||
tenantId: number,
|
||||
filter: GetMatchedTransactionsFilter
|
||||
): Promise<MatchedTransactionsPOJO> {
|
||||
const { SaleInvoice } = this.tenancy.models(tenantId);
|
||||
const { SaleInvoice, MatchedBankTransaction } =
|
||||
this.tenancy.models(tenantId);
|
||||
const knex = this.tenancy.knex(tenantId);
|
||||
|
||||
// Initialize the models metadata.
|
||||
await initialize(knex, [SaleInvoice, MatchedBankTransaction]);
|
||||
|
||||
// Retrieve the invoices that not matched, unpaid.
|
||||
const invoices = await SaleInvoice.query().onBuild((q) => {
|
||||
q.whereNotExists(SaleInvoice.relatedQuery('matchedBankTransaction'));
|
||||
q.withGraphJoined('matchedBankTransaction');
|
||||
q.whereNull('matchedBankTransaction.id');
|
||||
q.modify('unpaid');
|
||||
q.modify('published');
|
||||
|
||||
if (filter.fromDate) {
|
||||
q.where('invoiceDate', '>=', filter.fromDate);
|
||||
}
|
||||
if (filter.toDate) {
|
||||
q.where('invoiceDate', '<=', filter.toDate);
|
||||
}
|
||||
q.orderBy('invoiceDate', 'DESC');
|
||||
});
|
||||
|
||||
return this.transformer.transform(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { initialize } from 'objection';
|
||||
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||
import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer';
|
||||
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
|
||||
@@ -19,12 +20,26 @@ export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactio
|
||||
tenantId: number,
|
||||
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>
|
||||
) {
|
||||
const { ManualJournal } = this.tenancy.models(tenantId);
|
||||
const { ManualJournal, ManualJournalEntry, MatchedBankTransaction } =
|
||||
this.tenancy.models(tenantId);
|
||||
const knex = this.tenancy.knex(tenantId);
|
||||
|
||||
await initialize(knex, [
|
||||
ManualJournal,
|
||||
ManualJournalEntry,
|
||||
MatchedBankTransaction,
|
||||
]);
|
||||
const accountId = 1000;
|
||||
|
||||
const manualJournals = await ManualJournal.query().onBuild((query) => {
|
||||
query.whereNotExists(
|
||||
ManualJournal.relatedQuery('matchedBankTransaction')
|
||||
);
|
||||
query.withGraphJoined('matchedBankTransaction');
|
||||
query.whereNull('matchedBankTransaction.id');
|
||||
|
||||
query.withGraphJoined('entries');
|
||||
query.where('entries.accountId', accountId);
|
||||
|
||||
query.modify('filterByPublished');
|
||||
|
||||
if (filter.fromDate) {
|
||||
query.where('date', '>=', filter.fromDate);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isEmpty, sumBy } from 'lodash';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { PromisePool } from '@supercharge/promise-pool';
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './types';
|
||||
import { MatchTransactionsTypes } from './MatchTransactionsTypes';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { sumMatchTranasctions } from './_utils';
|
||||
|
||||
@Service()
|
||||
export class MatchBankTransactions {
|
||||
@@ -90,9 +91,8 @@ export class MatchBankTransactions {
|
||||
throw new ServiceError(error);
|
||||
}
|
||||
// Calculate the total given matching transactions.
|
||||
const totalMatchedTranasctions = sumBy(
|
||||
validatationResult.results,
|
||||
'amount'
|
||||
const totalMatchedTranasctions = sumMatchTranasctions(
|
||||
validatationResult.results
|
||||
);
|
||||
// Validates the total given matching transcations whether is not equal
|
||||
// uncategorized transaction amount.
|
||||
|
||||
@@ -4,6 +4,8 @@ import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills';
|
||||
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals';
|
||||
import { MatchTransactionsTypesRegistry } from './MatchTransactionsTypesRegistry';
|
||||
import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices';
|
||||
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
|
||||
import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow';
|
||||
|
||||
@Service()
|
||||
export class MatchTransactionsTypes {
|
||||
@@ -25,6 +27,10 @@ export class MatchTransactionsTypes {
|
||||
type: 'ManualJournal',
|
||||
service: GetMatchedTransactionsByManualJournals,
|
||||
},
|
||||
{
|
||||
type: 'CashflowTransaction',
|
||||
service: GetMatchedTransactionsByCashflow,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export class UnmatchMatchedBankTransaction {
|
||||
return this.uow.withTransaction(tenantId, async (trx) => {
|
||||
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, {
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
trx,
|
||||
} as IBankTransactionUnmatchingEventPayload);
|
||||
|
||||
@@ -40,6 +41,7 @@ export class UnmatchMatchedBankTransaction {
|
||||
|
||||
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, {
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
trx,
|
||||
} as IBankTransactionUnmatchingEventPayload);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Knex } from 'knex';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { ERRORS } from './types';
|
||||
|
||||
@Service()
|
||||
@@ -18,12 +19,13 @@ export class ValidateTransactionMatched {
|
||||
public async validateTransactionNoMatchLinking(
|
||||
tenantId: number,
|
||||
referenceType: string,
|
||||
referenceId: number
|
||||
referenceId: number,
|
||||
trx?: Knex.Transaction
|
||||
) {
|
||||
const { MatchedBankTransaction } = this.tenancy.models(tenantId);
|
||||
|
||||
const foundMatchedTransaction =
|
||||
await MatchedBankTransaction.query().findOne({
|
||||
await MatchedBankTransaction.query(trx).findOne({
|
||||
referenceType,
|
||||
referenceId,
|
||||
});
|
||||
|
||||
@@ -20,3 +20,12 @@ export const sortClosestMatchTransactions = (
|
||||
),
|
||||
])(matches);
|
||||
};
|
||||
|
||||
export const sumMatchTranasctions = (transactions: Array<any>) => {
|
||||
return transactions.reduce(
|
||||
(total, item) =>
|
||||
total +
|
||||
(item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount),
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import events from '@/subscribers/events';
|
||||
import {
|
||||
IBankTransactionMatchedEventPayload,
|
||||
IBankTransactionUnmatchedEventPayload,
|
||||
} from '../types';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
|
||||
@Service()
|
||||
export class DecrementUncategorizedTransactionOnMatching {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
/**
|
||||
* Constructor method.
|
||||
*/
|
||||
public attach(bus) {
|
||||
bus.subscribe(
|
||||
events.bankMatch.onMatched,
|
||||
this.decrementUnCategorizedTransactionsOnMatching.bind(this)
|
||||
);
|
||||
bus.subscribe(
|
||||
events.bankMatch.onUnmatched,
|
||||
this.incrementUnCategorizedTransactionsOnUnmatching.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the cashflow transaction whether matched with bank transaction on deleting.
|
||||
* @param {IManualJournalDeletingPayload}
|
||||
*/
|
||||
public async decrementUnCategorizedTransactionsOnMatching({
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
trx,
|
||||
}: IBankTransactionMatchedEventPayload) {
|
||||
const { UncategorizedCashflowTransaction, Account } =
|
||||
this.tenancy.models(tenantId);
|
||||
|
||||
const transaction = await UncategorizedCashflowTransaction.query().findById(
|
||||
uncategorizedTransactionId
|
||||
);
|
||||
await Account.query(trx)
|
||||
.findById(transaction.accountId)
|
||||
.decrement('uncategorizedTransactions', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the cashflow transaction whether matched with bank transaction on deleting.
|
||||
* @param {IManualJournalDeletingPayload}
|
||||
*/
|
||||
public async incrementUnCategorizedTransactionsOnUnmatching({
|
||||
tenantId,
|
||||
uncategorizedTransactionId,
|
||||
trx,
|
||||
}: IBankTransactionUnmatchedEventPayload) {
|
||||
const { UncategorizedCashflowTransaction, Account } =
|
||||
this.tenancy.models(tenantId);
|
||||
|
||||
const transaction = await UncategorizedCashflowTransaction.query().findById(
|
||||
uncategorizedTransactionId
|
||||
);
|
||||
await Account.query(trx)
|
||||
.findById(transaction.accountId)
|
||||
.increment('uncategorizedTransactions', 1);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Service } from 'typedi';
|
||||
import { IManualJournalDeletingPayload } from '@/interfaces';
|
||||
import { ICommandCashflowDeletingPayload, IManualJournalDeletingPayload } from '@/interfaces';
|
||||
import events from '@/subscribers/events';
|
||||
import { ValidateTransactionMatched } from '../ValidateTransactionsMatched';
|
||||
|
||||
@@ -24,13 +24,14 @@ export class ValidateMatchingOnCashflowDelete {
|
||||
*/
|
||||
public async validateMatchingOnCashflowDeleting({
|
||||
tenantId,
|
||||
oldManualJournal,
|
||||
oldCashflowTransaction,
|
||||
trx,
|
||||
}: IManualJournalDeletingPayload) {
|
||||
}: ICommandCashflowDeletingPayload) {
|
||||
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||
tenantId,
|
||||
'ManualJournal',
|
||||
oldManualJournal.id
|
||||
'CashflowTransaction',
|
||||
oldCashflowTransaction.id,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ export class ValidateMatchingOnExpenseDelete {
|
||||
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||
tenantId,
|
||||
'Expense',
|
||||
oldExpense.id
|
||||
oldExpense.id,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ export class ValidateMatchingOnManualJournalDelete {
|
||||
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||
tenantId,
|
||||
'ManualJournal',
|
||||
oldManualJournal.id
|
||||
oldManualJournal.id,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ export class ValidateMatchingOnPaymentMadeDelete {
|
||||
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||
tenantId,
|
||||
'PaymentMade',
|
||||
oldBillPayment.id
|
||||
oldBillPayment.id,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ export class ValidateMatchingOnPaymentReceivedDelete {
|
||||
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
|
||||
tenantId,
|
||||
'PaymentReceive',
|
||||
oldPaymentReceive.id
|
||||
oldPaymentReceive.id,
|
||||
trx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,14 @@ export interface IBankTransactionMatchedEventPayload {
|
||||
|
||||
export interface IBankTransactionUnmatchingEventPayload {
|
||||
tenantId: number;
|
||||
uncategorizedTransactionId: number;
|
||||
trx?: Knex.Transaction;
|
||||
}
|
||||
|
||||
export interface IBankTransactionUnmatchedEventPayload {
|
||||
tenantId: number;
|
||||
uncategorizedTransactionId: number;
|
||||
trx?: Knex.Transaction;
|
||||
}
|
||||
|
||||
export interface IMatchTransactionDTO {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,7 +37,6 @@ export const transformPlaidAccountToCreateAccount = R.curry(
|
||||
export const transformPlaidTrxsToCashflowCreate = R.curry(
|
||||
(
|
||||
cashflowAccountId: number,
|
||||
creditAccountId: number,
|
||||
plaidTranasction: PlaidTransaction
|
||||
): CreateUncategorizedTransactionDTO => {
|
||||
return {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { upperFirst, camelCase } from 'lodash';
|
||||
import { Transformer } from '@/lib/Transformer/Transformer';
|
||||
import { getTransactionTypeLabel } from '@/utils/transactions-types';
|
||||
import { getCashflowTransactionFormattedType } from '@/utils/transactions-types';
|
||||
|
||||
export class GetBankRulesTransformer extends Transformer {
|
||||
/**
|
||||
@@ -29,8 +28,7 @@ export class GetBankRulesTransformer extends Transformer {
|
||||
* @returns {string}
|
||||
*/
|
||||
protected assignCategoryFormatted(bankRule: any) {
|
||||
const assignCategory = upperFirst(camelCase(bankRule.assignCategory));
|
||||
return getTransactionTypeLabel(assignCategory);
|
||||
return getCashflowTransactionFormattedType(bankRule.assignCategory);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,11 +34,12 @@ export default class CashflowTransactionJournalEntries {
|
||||
currencyCode: transaction.currencyCode,
|
||||
exchangeRate: transaction.exchangeRate,
|
||||
|
||||
transactionType: transformCashflowTransactionType(
|
||||
transaction.transactionType
|
||||
),
|
||||
transactionType: 'CashflowTransaction',
|
||||
transactionId: transaction.id,
|
||||
transactionNumber: transaction.transactionNumber,
|
||||
transactionSubType: transformCashflowTransactionType(
|
||||
transaction.transactionType
|
||||
),
|
||||
referenceNumber: transaction.referenceNo,
|
||||
|
||||
note: transaction.description,
|
||||
@@ -161,12 +162,10 @@ export default class CashflowTransactionJournalEntries {
|
||||
cashflowTransactionId: number,
|
||||
trx?: Knex.Transaction
|
||||
): Promise<void> => {
|
||||
const transactionTypes = getCashflowAccountTransactionsTypes();
|
||||
|
||||
await this.ledgerStorage.deleteByReference(
|
||||
tenantId,
|
||||
cashflowTransactionId,
|
||||
transactionTypes,
|
||||
'CashflowTransaction',
|
||||
trx
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -101,6 +101,7 @@ export default class NewCashflowTransactionService {
|
||||
...fromDTO,
|
||||
transactionNumber,
|
||||
currencyCode: cashflowAccount.currencyCode,
|
||||
exchangeRate: fromDTO?.exchangeRate || 1,
|
||||
transactionType: transformCashflowTransactionType(
|
||||
fromDTO.transactionType
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import R from 'ramda';
|
||||
import moment from 'moment';
|
||||
import { first, isEmpty } from 'lodash';
|
||||
import {
|
||||
ICashflowAccountTransaction,
|
||||
ICashflowAccountTransactionsQuery,
|
||||
INumberFormatQuery,
|
||||
} from '@/interfaces';
|
||||
import FinancialSheet from '../FinancialSheet';
|
||||
import { runningAmount } from 'utils';
|
||||
import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo';
|
||||
import { BankTransactionStatus } from './constants';
|
||||
import { formatBankTransactionsStatus } from './utils';
|
||||
|
||||
export default class CashflowAccountTransactionReport extends FinancialSheet {
|
||||
private transactions: any;
|
||||
private openingBalance: number;
|
||||
export class CashflowAccountTransactionReport extends FinancialSheet {
|
||||
private runningBalance: any;
|
||||
private numberFormat: INumberFormatQuery;
|
||||
private baseCurrency: string;
|
||||
private query: ICashflowAccountTransactionsQuery;
|
||||
private repo: CashflowAccountTransactionsRepo;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
@@ -23,19 +23,61 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
|
||||
* @param {ICashflowAccountTransactionsQuery} query -
|
||||
*/
|
||||
constructor(
|
||||
transactions,
|
||||
openingBalance: number,
|
||||
repo: CashflowAccountTransactionsRepo,
|
||||
query: ICashflowAccountTransactionsQuery
|
||||
) {
|
||||
super();
|
||||
|
||||
this.transactions = transactions;
|
||||
this.openingBalance = openingBalance;
|
||||
|
||||
this.runningBalance = runningAmount(this.openingBalance);
|
||||
this.repo = repo;
|
||||
this.query = query;
|
||||
this.numberFormat = query.numberFormat;
|
||||
this.baseCurrency = 'USD';
|
||||
this.runningBalance = runningAmount(this.repo.openingBalance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the transaction status.
|
||||
* @param {} transaction
|
||||
* @returns {BankTransactionStatus}
|
||||
*/
|
||||
private getTransactionStatus(transaction: any): BankTransactionStatus {
|
||||
const categorizedTrans = this.repo.uncategorizedTransactionsMapByRef.get(
|
||||
`${transaction.referenceType}-${transaction.referenceId}`
|
||||
);
|
||||
const matchedTrans = this.repo.matchedBankTransactionsMapByRef.get(
|
||||
`${transaction.referenceType}-${transaction.referenceId}`
|
||||
);
|
||||
if (!isEmpty(categorizedTrans)) {
|
||||
return BankTransactionStatus.Categorized;
|
||||
} else if (!isEmpty(matchedTrans)) {
|
||||
return BankTransactionStatus.Matched;
|
||||
} else {
|
||||
return BankTransactionStatus.Manual;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the uncategoized transaction id from the given transaction.
|
||||
* @param transaction
|
||||
* @returns {number|null}
|
||||
*/
|
||||
private getUncategorizedTransId(transaction: any): number {
|
||||
// The given transaction would be categorized, matched or not, so we'd take a look at
|
||||
// the categorized transaction first to get the id if not exist, then should look at the matched
|
||||
// transaction if not exist too, so the given transaction has no uncategorized transaction id.
|
||||
const categorizedTrans = this.repo.uncategorizedTransactionsMapByRef.get(
|
||||
`${transaction.referenceType}-${transaction.referenceId}`
|
||||
);
|
||||
const matchedTrans = this.repo.matchedBankTransactionsMapByRef.get(
|
||||
`${transaction.referenceType}-${transaction.referenceId}`
|
||||
);
|
||||
// Relation between the transaction and matching always been one-to-one.
|
||||
const firstCategorizedTrans = first(categorizedTrans);
|
||||
const firstMatchedTrans = first(matchedTrans);
|
||||
|
||||
return (
|
||||
firstCategorizedTrans?.id ||
|
||||
firstMatchedTrans?.uncategorizedTransactionId ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +86,10 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
|
||||
* @returns {ICashflowAccountTransaction}
|
||||
*/
|
||||
private transactionNode = (transaction: any): ICashflowAccountTransaction => {
|
||||
const status = this.getTransactionStatus(transaction);
|
||||
const uncategorizedTransactionId =
|
||||
this.getUncategorizedTransId(transaction);
|
||||
|
||||
return {
|
||||
date: transaction.date,
|
||||
formattedDate: moment(transaction.date).format('YYYY-MM-DD'),
|
||||
@@ -67,6 +113,9 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
|
||||
|
||||
balance: 0,
|
||||
formattedBalance: '',
|
||||
status,
|
||||
formattedStatus: formatBankTransactionsStatus(status),
|
||||
uncategorizedTransactionId,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -146,6 +195,6 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
|
||||
* @returns {ICashflowAccountTransaction[]}
|
||||
*/
|
||||
public reportData(): ICashflowAccountTransaction[] {
|
||||
return this.transactionsNode(this.transactions);
|
||||
return this.transactionsNode(this.repo.transactions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,59 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import HasTenancyService from '@/services/Tenancy/TenancyService';
|
||||
import { ICashflowAccountTransactionsQuery, IPaginationMeta } from '@/interfaces';
|
||||
import * as R from 'ramda';
|
||||
import { ICashflowAccountTransactionsQuery } from '@/interfaces';
|
||||
import {
|
||||
groupMatchedBankTransactions,
|
||||
groupUncategorizedTransactions,
|
||||
} from './utils';
|
||||
|
||||
@Service()
|
||||
export default class CashflowAccountTransactionsRepo {
|
||||
@Inject()
|
||||
private tenancy: HasTenancyService;
|
||||
export class CashflowAccountTransactionsRepo {
|
||||
private models: any;
|
||||
public query: ICashflowAccountTransactionsQuery;
|
||||
public transactions: any;
|
||||
public uncategorizedTransactions: any;
|
||||
public uncategorizedTransactionsMapByRef: Map<string, any>;
|
||||
public matchedBankTransactions: any;
|
||||
public matchedBankTransactionsMapByRef: Map<string, any>;
|
||||
public pagination: any;
|
||||
public openingBalance: any;
|
||||
|
||||
/**
|
||||
* Constructor method.
|
||||
* @param {any} models
|
||||
* @param {ICashflowAccountTransactionsQuery} query
|
||||
*/
|
||||
constructor(models: any, query: ICashflowAccountTransactionsQuery) {
|
||||
this.models = models;
|
||||
this.query = query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async initalize the resources.
|
||||
*/
|
||||
async asyncInit() {
|
||||
await this.initCashflowAccountTransactions();
|
||||
await this.initCashflowAccountOpeningBalance();
|
||||
await this.initCategorizedTransactions();
|
||||
await this.initMatchedTransactions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the cashflow account transactions.
|
||||
* @param {number} tenantId -
|
||||
* @param {ICashflowAccountTransactionsQuery} query -
|
||||
*/
|
||||
async getCashflowAccountTransactions(
|
||||
tenantId: number,
|
||||
query: ICashflowAccountTransactionsQuery
|
||||
) {
|
||||
const { AccountTransaction } = this.tenancy.models(tenantId);
|
||||
async initCashflowAccountTransactions() {
|
||||
const { AccountTransaction } = this.models;
|
||||
|
||||
return AccountTransaction.query()
|
||||
.where('account_id', query.accountId)
|
||||
const { results, pagination } = await AccountTransaction.query()
|
||||
.where('account_id', this.query.accountId)
|
||||
.orderBy([
|
||||
{ column: 'date', order: 'desc' },
|
||||
{ column: 'created_at', order: 'desc' },
|
||||
])
|
||||
.pagination(query.page - 1, query.pageSize);
|
||||
.pagination(this.query.page - 1, this.query.pageSize);
|
||||
|
||||
this.transactions = results;
|
||||
this.pagination = pagination;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,22 +63,18 @@ export default class CashflowAccountTransactionsRepo {
|
||||
* @param {IPaginationMeta} pagination
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
async getCashflowAccountOpeningBalance(
|
||||
tenantId: number,
|
||||
accountId: number,
|
||||
pagination: IPaginationMeta
|
||||
): Promise<number> {
|
||||
const { AccountTransaction } = this.tenancy.models(tenantId);
|
||||
async initCashflowAccountOpeningBalance(): Promise<void> {
|
||||
const { AccountTransaction } = this.models;
|
||||
|
||||
// Retrieve the opening balance of credit and debit balances.
|
||||
const openingBalancesSubquery = AccountTransaction.query()
|
||||
.where('account_id', accountId)
|
||||
.where('account_id', this.query.accountId)
|
||||
.orderBy([
|
||||
{ column: 'date', order: 'desc' },
|
||||
{ column: 'created_at', order: 'desc' },
|
||||
])
|
||||
.limit(pagination.total)
|
||||
.offset(pagination.pageSize * (pagination.page - 1));
|
||||
.limit(this.pagination.total)
|
||||
.offset(this.pagination.pageSize * (this.pagination.page - 1));
|
||||
|
||||
// Sumation of credit and debit balance.
|
||||
const openingBalances = await AccountTransaction.query()
|
||||
@@ -60,6 +85,43 @@ export default class CashflowAccountTransactionsRepo {
|
||||
|
||||
const openingBalance = openingBalances.debit - openingBalances.credit;
|
||||
|
||||
return openingBalance;
|
||||
this.openingBalance = openingBalance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the uncategorized transactions of the bank account.
|
||||
*/
|
||||
async initCategorizedTransactions() {
|
||||
const { UncategorizedCashflowTransaction } = this.models;
|
||||
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
|
||||
|
||||
const uncategorizedTransactions =
|
||||
await UncategorizedCashflowTransaction.query().whereIn(
|
||||
['categorizeRefType', 'categorizeRefId'],
|
||||
refs
|
||||
);
|
||||
|
||||
this.uncategorizedTransactions = uncategorizedTransactions;
|
||||
this.uncategorizedTransactionsMapByRef = groupUncategorizedTransactions(
|
||||
uncategorizedTransactions
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the matched bank transactions of the bank account.
|
||||
*/
|
||||
async initMatchedTransactions(): Promise<void> {
|
||||
const { MatchedBankTransaction } = this.models;
|
||||
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
|
||||
|
||||
const matchedBankTransactions =
|
||||
await MatchedBankTransaction.query().whereIn(
|
||||
['referenceType', 'referenceId'],
|
||||
refs
|
||||
);
|
||||
this.matchedBankTransactions = matchedBankTransactions;
|
||||
this.matchedBankTransactionsMapByRef = groupMatchedBankTransactions(
|
||||
matchedBankTransactions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,16 @@
|
||||
import { Service, Inject } from 'typedi';
|
||||
import { includes } from 'lodash';
|
||||
import * as qim from 'qim';
|
||||
import { ICashflowAccountTransactionsQuery, IAccount } from '@/interfaces';
|
||||
import TenancyService from '@/services/Tenancy/TenancyService';
|
||||
import FinancialSheet from '../FinancialSheet';
|
||||
import CashflowAccountTransactionsRepo from './CashflowAccountTransactionsRepo';
|
||||
import CashflowAccountTransactionsReport from './CashflowAccountTransactions';
|
||||
import { ACCOUNT_TYPE } from '@/data/AccountTypes';
|
||||
import { ServiceError } from '@/exceptions';
|
||||
import { ERRORS } from './constants';
|
||||
import { CashflowAccountTransactionReport } from './CashflowAccountTransactions';
|
||||
import I18nService from '@/services/I18n/I18nService';
|
||||
import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo';
|
||||
|
||||
@Service()
|
||||
export default class CashflowAccountTransactionsService extends FinancialSheet {
|
||||
@Inject()
|
||||
tenancy: TenancyService;
|
||||
|
||||
@Inject()
|
||||
cashflowTransactionsRepo: CashflowAccountTransactionsRepo;
|
||||
|
||||
@Inject()
|
||||
i18nService: I18nService;
|
||||
private tenancy: TenancyService;
|
||||
|
||||
/**
|
||||
* Defaults balance sheet filter query.
|
||||
@@ -50,59 +40,24 @@ export default class CashflowAccountTransactionsService extends FinancialSheet {
|
||||
tenantId: number,
|
||||
query: ICashflowAccountTransactionsQuery
|
||||
) {
|
||||
const { Account } = this.tenancy.models(tenantId);
|
||||
const models = this.tenancy.models(tenantId);
|
||||
const parsedQuery = { ...this.defaultQuery, ...query };
|
||||
|
||||
// Retrieve the given account or throw not found service error.
|
||||
const account = await Account.query().findById(parsedQuery.accountId);
|
||||
|
||||
// Validates the cashflow account type.
|
||||
this.validateCashflowAccountType(account);
|
||||
|
||||
// Retrieve the cashflow account transactions.
|
||||
const { results: transactions, pagination } =
|
||||
await this.cashflowTransactionsRepo.getCashflowAccountTransactions(
|
||||
tenantId,
|
||||
parsedQuery
|
||||
);
|
||||
// Retrieve the cashflow account opening balance.
|
||||
const openingBalance =
|
||||
await this.cashflowTransactionsRepo.getCashflowAccountOpeningBalance(
|
||||
tenantId,
|
||||
parsedQuery.accountId,
|
||||
pagination
|
||||
);
|
||||
// Retrieve the computed report.
|
||||
const report = new CashflowAccountTransactionsReport(
|
||||
transactions,
|
||||
openingBalance,
|
||||
// Initalize the bank transactions report repository.
|
||||
const cashflowTransactionsRepo = new CashflowAccountTransactionsRepo(
|
||||
models,
|
||||
parsedQuery
|
||||
);
|
||||
const reportTranasctions = report.reportData();
|
||||
await cashflowTransactionsRepo.asyncInit();
|
||||
|
||||
return {
|
||||
transactions: this.i18nService.i18nApply(
|
||||
[[qim.$each, 'formattedTransactionType']],
|
||||
reportTranasctions,
|
||||
tenantId
|
||||
),
|
||||
pagination,
|
||||
};
|
||||
}
|
||||
// Retrieve the computed report.
|
||||
const report = new CashflowAccountTransactionReport(
|
||||
cashflowTransactionsRepo,
|
||||
parsedQuery
|
||||
);
|
||||
const transactions = report.reportData();
|
||||
const pagination = cashflowTransactionsRepo.pagination;
|
||||
|
||||
/**
|
||||
* Validates the cashflow account type.
|
||||
* @param {IAccount} account -
|
||||
*/
|
||||
private validateCashflowAccountType(account: IAccount) {
|
||||
const cashflowTypes = [
|
||||
ACCOUNT_TYPE.CASH,
|
||||
ACCOUNT_TYPE.CREDIT_CARD,
|
||||
ACCOUNT_TYPE.BANK,
|
||||
];
|
||||
|
||||
if (!includes(cashflowTypes, account.accountType)) {
|
||||
throw new ServiceError(ERRORS.ACCOUNT_ID_HAS_INVALID_TYPE);
|
||||
}
|
||||
return { transactions, pagination };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
export const ERRORS = {
|
||||
ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE',
|
||||
};
|
||||
|
||||
export enum BankTransactionStatus {
|
||||
Categorized = 'categorized',
|
||||
Matched = 'matched',
|
||||
Manual = 'manual',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as R from 'ramda';
|
||||
|
||||
export const groupUncategorizedTransactions = (
|
||||
uncategorizedTransactions: any
|
||||
): Map<string, any> => {
|
||||
return new Map(
|
||||
R.toPairs(
|
||||
R.groupBy(
|
||||
(transaction) =>
|
||||
`${transaction.categorizeRefType}-${transaction.categorizeRefId}`,
|
||||
uncategorizedTransactions
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const groupMatchedBankTransactions = (
|
||||
uncategorizedTransactions: any
|
||||
): Map<string, any> => {
|
||||
return new Map(
|
||||
R.toPairs(
|
||||
R.groupBy(
|
||||
(transaction) =>
|
||||
`${transaction.referenceType}-${transaction.referenceId}`,
|
||||
uncategorizedTransactions
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const formatBankTransactionsStatus = (status) => {
|
||||
switch (status) {
|
||||
case 'categorized':
|
||||
return 'Categorized';
|
||||
case 'matched':
|
||||
return 'Matched';
|
||||
case 'manual':
|
||||
return 'Manual';
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
*/
|
||||
|
||||
51
packages/server/src/services/Loops/LoopsEventsSubscriber.ts
Normal file
51
packages/server/src/services/Loops/LoopsEventsSubscriber.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import axios from 'axios';
|
||||
import config from '@/config';
|
||||
import { IAuthSignUpVerifiedEventPayload } from '@/interfaces';
|
||||
import events from '@/subscribers/events';
|
||||
import { SystemUser } from '@/system/models';
|
||||
|
||||
export class LoopsEventsSubscriber {
|
||||
/**
|
||||
* Constructor method.
|
||||
*/
|
||||
public attach(bus) {
|
||||
bus.subscribe(
|
||||
events.auth.signUpConfirmed,
|
||||
this.triggerEventOnSignupVerified.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the user verified sends the event to the Loops.
|
||||
* @param {IAuthSignUpVerifiedEventPayload} param0
|
||||
*/
|
||||
public async triggerEventOnSignupVerified({
|
||||
email,
|
||||
userId,
|
||||
}: IAuthSignUpVerifiedEventPayload) {
|
||||
// Can't continue since the Loops the api key is not configured.
|
||||
if (!config.loops.apiKey) {
|
||||
return;
|
||||
}
|
||||
const user = await SystemUser.query().findById(userId);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
url: 'https://app.loops.so/api/v1/events/send',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.loops.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
email,
|
||||
userId,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
eventName: 'USER_VERIFIED',
|
||||
eventProperties: {},
|
||||
mailingLists: {},
|
||||
},
|
||||
};
|
||||
await axios(options);
|
||||
}
|
||||
}
|
||||
@@ -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_')) {
|
||||
|
||||
@@ -40,6 +40,13 @@ export default {
|
||||
baseCurrencyUpdated: 'onOrganizationBaseCurrencyUpdated',
|
||||
},
|
||||
|
||||
/**
|
||||
* User subscription events.
|
||||
*/
|
||||
subscription: {
|
||||
onSubscribed: 'onOrganizationSubscribed',
|
||||
},
|
||||
|
||||
/**
|
||||
* Tenants managment service.
|
||||
*/
|
||||
@@ -399,6 +406,9 @@ export default {
|
||||
onTransactionCategorizing: 'onTransactionCategorizing',
|
||||
onTransactionCategorized: 'onCashflowTransactionCategorized',
|
||||
|
||||
onTransactionUncategorizedCreating: 'onTransactionUncategorizedCreating',
|
||||
onTransactionUncategorizedCreated: 'onTransactionUncategorizedCreated',
|
||||
|
||||
onTransactionUncategorizing: 'onTransactionUncategorizing',
|
||||
onTransactionUncategorized: 'onTransactionUncategorized',
|
||||
|
||||
@@ -639,4 +649,17 @@ export default {
|
||||
onUnmatching: 'onBankTransactionUnmathcing',
|
||||
onUnmatched: 'onBankTransactionUnmathced',
|
||||
},
|
||||
|
||||
bankTransactions: {
|
||||
onExcluding: 'onBankTransactionExclude',
|
||||
onExcluded: 'onBankTransactionExcluded',
|
||||
|
||||
onUnexcluding: 'onBankTransactionUnexcluding',
|
||||
onUnexcluded: 'onBankTransactionUnexcluded',
|
||||
},
|
||||
|
||||
// Import files.
|
||||
import: {
|
||||
onImportCommitted: 'onImportFileCommitted',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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) {};
|
||||
@@ -1,5 +1,36 @@
|
||||
import { TransactionTypes } from '@/data/TransactionTypes';
|
||||
import { isObject, upperFirst, camelCase } from 'lodash';
|
||||
import {
|
||||
TransactionTypes,
|
||||
CashflowTransactionTypes,
|
||||
} from '@/data/TransactionTypes';
|
||||
|
||||
export const getTransactionTypeLabel = (transactionType: string) => {
|
||||
return TransactionTypes[transactionType];
|
||||
/**
|
||||
* Retrieves the formatted type of account transaction.
|
||||
* @param {string} referenceType
|
||||
* @param {string} transactionType
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getTransactionTypeLabel = (
|
||||
referenceType: string,
|
||||
transactionType?: string
|
||||
) => {
|
||||
const _referenceType = upperFirst(camelCase(referenceType));
|
||||
const _transactionType = upperFirst(camelCase(transactionType));
|
||||
|
||||
return isObject(TransactionTypes[_referenceType])
|
||||
? TransactionTypes[_referenceType][_transactionType]
|
||||
: TransactionTypes[_referenceType] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the formatted type of cashflow transaction.
|
||||
* @param {string} transactionType
|
||||
* @returns {string¿}
|
||||
*/
|
||||
export const getCashflowTransactionFormattedType = (
|
||||
transactionType: string
|
||||
) => {
|
||||
const _transactionType = upperFirst(camelCase(transactionType));
|
||||
|
||||
return CashflowTransactionTypes[_transactionType] || null;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -69,6 +69,14 @@ function AccountDeleteTransactionAlert({
|
||||
'Cannot delete transaction converted from uncategorized transaction but you uncategorize it.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
} else if (
|
||||
errors.find((e) => e.type === 'CANNOT_DELETE_TRANSACTION_MATCHED')
|
||||
) {
|
||||
AppToaster.show({
|
||||
message:
|
||||
'Cannot delete a transaction matched to the bank transaction',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) || [];
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { Intent } from '@blueprintjs/core';
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
TableSkeletonHeader,
|
||||
TableVirtualizedListRows,
|
||||
FormattedMessage as T,
|
||||
AppToaster,
|
||||
} from '@/components';
|
||||
import { TABLES } from '@/constants/tables';
|
||||
|
||||
@@ -19,9 +21,11 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions';
|
||||
import { useMemorizedColumnsWidths } from '@/hooks';
|
||||
import { useAccountTransactionsColumns, ActionsMenu } from './components';
|
||||
import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot';
|
||||
import { useUnmatchMatchedUncategorizedTransaction } from '@/hooks/query/bank-rules';
|
||||
import { handleCashFlowTransactionType } from './utils';
|
||||
|
||||
import { compose } from '@/utils';
|
||||
import { useUncategorizeTransaction } from '@/hooks/query';
|
||||
|
||||
/**
|
||||
* Account transactions data table.
|
||||
@@ -43,14 +47,14 @@ function AccountTransactionsDataTable({
|
||||
const { cashflowTransactions, isCashFlowTransactionsLoading } =
|
||||
useAccountTransactionsAllContext();
|
||||
|
||||
const { mutateAsync: uncategorizeTransaction } = useUncategorizeTransaction();
|
||||
const { mutateAsync: unmatchTransaction } =
|
||||
useUnmatchMatchedUncategorizedTransaction();
|
||||
|
||||
// Local storage memorizing columns widths.
|
||||
const [initialColumnsWidths, , handleColumnResizing] =
|
||||
useMemorizedColumnsWidths(TABLES.CASHFLOW_Transactions);
|
||||
|
||||
// handle delete transaction
|
||||
const handleDeleteTransaction = ({ reference_id }) => {
|
||||
openAlert('account-transaction-delete', { referenceId: reference_id });
|
||||
};
|
||||
// Handle view details action.
|
||||
const handleViewDetailCashflowTransaction = (referenceType) => {
|
||||
handleCashFlowTransactionType(referenceType, openDrawer);
|
||||
@@ -60,6 +64,38 @@ function AccountTransactionsDataTable({
|
||||
const referenceType = cell.row.original;
|
||||
handleCashFlowTransactionType(referenceType, openDrawer);
|
||||
};
|
||||
// Handles the unmatching the matched transaction.
|
||||
const handleUnmatchTransaction = (transaction) => {
|
||||
unmatchTransaction({ id: transaction.uncategorized_transaction_id })
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: 'The bank transaction has been unmatched.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
};
|
||||
// Handle uncategorize transaction.
|
||||
const handleUncategorizeTransaction = (transaction) => {
|
||||
uncategorizeTransaction(transaction.uncategorized_transaction_id)
|
||||
.then(() => {
|
||||
AppToaster.show({
|
||||
message: 'The bank transaction has been uncategorized.',
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<CashflowTransactionsTable
|
||||
@@ -87,7 +123,8 @@ function AccountTransactionsDataTable({
|
||||
className="table-constrant"
|
||||
payload={{
|
||||
onViewDetails: handleViewDetailCashflowTransaction,
|
||||
onDelete: handleDeleteTransaction,
|
||||
onUncategorize: handleUncategorizeTransaction,
|
||||
onUnmatch: handleUnmatchTransaction,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,19 +12,17 @@ import {
|
||||
AppToaster,
|
||||
} from '@/components';
|
||||
import { TABLES } from '@/constants/tables';
|
||||
import { ActionsMenu } from './UncategorizedTransactions/components';
|
||||
|
||||
import withSettings from '@/containers/Settings/withSettings';
|
||||
import { withBankingActions } from '../withBankingActions';
|
||||
|
||||
import { useMemorizedColumnsWidths } from '@/hooks';
|
||||
import {
|
||||
ActionsMenu,
|
||||
useAccountUncategorizedTransactionsColumns,
|
||||
} from './components';
|
||||
import { useAccountUncategorizedTransactionsColumns } from './components';
|
||||
import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot';
|
||||
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
|
||||
|
||||
import { compose } from '@/utils';
|
||||
import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules';
|
||||
|
||||
/**
|
||||
* Account transactions data table.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @ts-nocheck
|
||||
import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
|
||||
import { Icon } from '@/components';
|
||||
import { safeCallback } from '@/utils';
|
||||
|
||||
export function ActionsMenu({
|
||||
payload: { onCategorize, onExclude },
|
||||
row: { original },
|
||||
}) {
|
||||
return (
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon={<Icon icon="reader-18" />}
|
||||
text={'Categorize'}
|
||||
onClick={safeCallback(onCategorize, original)}
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
text={'Exclude'}
|
||||
onClick={safeCallback(onExclude, original)}
|
||||
icon={<Icon icon="disable" iconSize={16} />}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -5,44 +5,56 @@ import {
|
||||
Intent,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuDivider,
|
||||
Tag,
|
||||
Popover,
|
||||
PopoverInteractionKind,
|
||||
Position,
|
||||
Tooltip,
|
||||
} from '@blueprintjs/core';
|
||||
import {
|
||||
Box,
|
||||
Can,
|
||||
FormatDateCell,
|
||||
Icon,
|
||||
MaterialProgressBar,
|
||||
} from '@/components';
|
||||
import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components';
|
||||
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
|
||||
import { safeCallback } from '@/utils';
|
||||
|
||||
export function ActionsMenu({
|
||||
payload: { onCategorize, onExclude },
|
||||
payload: { onUncategorize, onUnmatch },
|
||||
row: { original },
|
||||
}) {
|
||||
return (
|
||||
<Menu>
|
||||
<MenuItem
|
||||
icon={<Icon icon="reader-18" />}
|
||||
text={'Categorize'}
|
||||
onClick={safeCallback(onCategorize, original)}
|
||||
/>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
text={'Exclude'}
|
||||
onClick={safeCallback(onExclude, original)}
|
||||
icon={<Icon icon="disable" iconSize={16} />}
|
||||
/>
|
||||
{original.status === 'categorized' && (
|
||||
<MenuItem
|
||||
icon={<Icon icon="reader-18" />}
|
||||
text={'Uncategorize'}
|
||||
onClick={safeCallback(onUncategorize, original)}
|
||||
/>
|
||||
)}
|
||||
{original.status === 'matched' && (
|
||||
<MenuItem
|
||||
text={'Unmatch'}
|
||||
icon={<Icon icon="unlink" iconSize={16} />}
|
||||
onClick={safeCallback(onUnmatch, original)}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
const allTransactionsStatusAccessor = (transaction) => {
|
||||
return (
|
||||
<Tag
|
||||
intent={
|
||||
transaction.status === 'categorized'
|
||||
? Intent.SUCCESS
|
||||
: transaction.status === 'matched'
|
||||
? Intent.SUCCESS
|
||||
: Intent.NONE
|
||||
}
|
||||
minimal={transaction.status === 'manual'}
|
||||
>
|
||||
{transaction.formatted_status}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve account transctions table columns.
|
||||
*/
|
||||
@@ -70,7 +82,7 @@ export function useAccountTransactionsColumns() {
|
||||
},
|
||||
{
|
||||
id: 'transaction_number',
|
||||
Header: intl.get('transaction_number'),
|
||||
Header: 'Transaction #',
|
||||
accessor: 'transaction_number',
|
||||
width: 160,
|
||||
className: 'transaction_number',
|
||||
@@ -79,13 +91,18 @@ export function useAccountTransactionsColumns() {
|
||||
},
|
||||
{
|
||||
id: 'reference_number',
|
||||
Header: intl.get('reference_no'),
|
||||
Header: 'Ref.#',
|
||||
accessor: 'reference_number',
|
||||
width: 160,
|
||||
className: 'reference_number',
|
||||
clickable: true,
|
||||
textOverview: true,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
Header: 'Status',
|
||||
accessor: allTransactionsStatusAccessor,
|
||||
},
|
||||
{
|
||||
id: 'deposit',
|
||||
Header: intl.get('cash_flow.label.deposit'),
|
||||
@@ -116,16 +133,6 @@ export function useAccountTransactionsColumns() {
|
||||
align: 'right',
|
||||
clickable: true,
|
||||
},
|
||||
{
|
||||
id: 'balance',
|
||||
Header: intl.get('balance'),
|
||||
accessor: 'formatted_balance',
|
||||
className: 'balance',
|
||||
width: 150,
|
||||
textOverview: true,
|
||||
clickable: true,
|
||||
align: 'right',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
@@ -204,10 +211,9 @@ export function useAccountUncategorizedTransactionsColumns() {
|
||||
},
|
||||
{
|
||||
id: 'reference_number',
|
||||
Header: intl.get('reference_no'),
|
||||
accessor: 'reference_number',
|
||||
Header: 'Ref.#',
|
||||
accessor: 'reference_no',
|
||||
width: 50,
|
||||
className: 'reference_number',
|
||||
clickable: true,
|
||||
textOverview: true,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -8,16 +8,26 @@ import {
|
||||
} from '../withBankingActions';
|
||||
import { CategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot';
|
||||
import { withBanking } from '../withBanking';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface CategorizeTransactionAsideProps extends WithBankingActionsProps {}
|
||||
|
||||
function CategorizeTransactionAsideRoot({
|
||||
// #withBankingActions
|
||||
closeMatchingTransactionAside,
|
||||
closeReconcileMatchingTransaction,
|
||||
|
||||
// #withBanking
|
||||
selectedUncategorizedTransactionId,
|
||||
}: CategorizeTransactionAsideProps) {
|
||||
//
|
||||
useEffect(
|
||||
() => () => {
|
||||
closeReconcileMatchingTransaction();
|
||||
},
|
||||
[closeReconcileMatchingTransaction],
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
closeMatchingTransactionAside();
|
||||
};
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
color: rgb(21, 82, 200),
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.active){
|
||||
border-color: #c0c0c0;
|
||||
}
|
||||
@@ -25,7 +24,7 @@
|
||||
margin: 0;
|
||||
}
|
||||
.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{
|
||||
border-color: #CBCBCB;
|
||||
box-shadow: 0 0 0 1px #CBCBCB;
|
||||
}
|
||||
.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{
|
||||
margin-right: 4px;
|
||||
@@ -34,9 +33,17 @@
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.checkbox:global(.bp4-control.bp4-checkbox) :global input:checked ~ .bp4-control-indicator{
|
||||
box-shadow: 0 0 0 1px #0069ff;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #10161A;
|
||||
font-size: 15px;
|
||||
color: #252A33;
|
||||
font-size: 15px;
|
||||
}
|
||||
.label :global strong {
|
||||
font-weight: 500;
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
|
||||
.date {
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface MatchTransactionCheckboxProps {
|
||||
active?: boolean;
|
||||
initialActive?: boolean;
|
||||
onChange?: (state: boolean) => void;
|
||||
label: string;
|
||||
label: string | React.ReactNode;
|
||||
date: string;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function MatchTransactionCheckbox({
|
||||
position="apart"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<Text className={styles.date}>Date: {date}</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// @ts-nocheck
|
||||
import * as R from 'ramda';
|
||||
import { Button, Intent, Position, Tag } from '@blueprintjs/core';
|
||||
import { Form, Formik, FormikValues, useFormikContext } from 'formik';
|
||||
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||
import moment from 'moment';
|
||||
import { round } from 'lodash';
|
||||
import {
|
||||
AccountsSelect,
|
||||
AppToaster,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
FInputGroup,
|
||||
FMoneyInputGroup,
|
||||
Group,
|
||||
Icon,
|
||||
} from '@/components';
|
||||
import { Aside } from '@/components/Aside/Aside';
|
||||
import { momentFormatter } from '@/utils';
|
||||
@@ -25,29 +28,43 @@ import {
|
||||
import { useCreateCashflowTransaction } from '@/hooks/query';
|
||||
import { useAccountTransactionsContext } from '../../AccountTransactions/AccountTransactionsProvider';
|
||||
import { MatchingReconcileFormSchema } from './MatchingReconcileTransactionForm.schema';
|
||||
import { initialValues } from './_utils';
|
||||
import { initialValues, transformToReq } from './_utils';
|
||||
import { withBanking } from '../../withBanking';
|
||||
|
||||
interface MatchingReconcileTransactionFormProps {
|
||||
onSubmitSuccess?: (values: any) => void;
|
||||
}
|
||||
|
||||
function MatchingReconcileTransactionFormRoot({
|
||||
closeReconcileMatchingTransaction,
|
||||
}) {
|
||||
reconcileMatchingTransactionPendingAmount,
|
||||
|
||||
// #props¿
|
||||
onSubmitSuccess,
|
||||
}: MatchingReconcileTransactionFormProps) {
|
||||
// Mutation create cashflow transaction.
|
||||
const { mutateAsync: createCashflowTransactionMutate } =
|
||||
useCreateCashflowTransaction();
|
||||
|
||||
const { accountId } = useAccountTransactionsContext();
|
||||
|
||||
// Handles the aside close.
|
||||
const handleAsideClose = () => {
|
||||
closeReconcileMatchingTransaction();
|
||||
};
|
||||
// Handle the form submitting.
|
||||
const handleSubmit = (
|
||||
values: MatchingReconcileTransactionValues,
|
||||
{ setSubmitting }: FormikValues<MatchingReconcileTransactionValues>,
|
||||
{
|
||||
setSubmitting,
|
||||
setErrors,
|
||||
}: FormikHelpers<MatchingReconcileTransactionValues>,
|
||||
) => {
|
||||
setSubmitting(true);
|
||||
const _values = transformToReq(values, accountId);
|
||||
|
||||
createCashflowTransactionMutate(_values)
|
||||
.then(() => {
|
||||
.then((res) => {
|
||||
setSubmitting(false);
|
||||
|
||||
AppToaster.show({
|
||||
@@ -55,17 +72,36 @@ function MatchingReconcileTransactionFormRoot({
|
||||
intent: Intent.SUCCESS,
|
||||
});
|
||||
closeReconcileMatchingTransaction();
|
||||
onSubmitSuccess &&
|
||||
onSubmitSuccess({ id: res.data.id, type: 'CashflowTransaction' });
|
||||
})
|
||||
.catch((error) => {
|
||||
setSubmitting(false);
|
||||
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
if (
|
||||
error.response.data?.errors?.find(
|
||||
(e) => e.type === 'BRANCH_ID_REQUIRED',
|
||||
)
|
||||
) {
|
||||
setErrors({
|
||||
branchId: 'The branch is required.',
|
||||
});
|
||||
} else {
|
||||
AppToaster.show({
|
||||
message: 'Something went wrong.',
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const _initialValues = {
|
||||
...initialValues,
|
||||
amount: round(Math.abs(reconcileMatchingTransactionPendingAmount), 2) || 0,
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
type:
|
||||
reconcileMatchingTransactionPendingAmount > 0 ? 'deposit' : 'withdrawal',
|
||||
};
|
||||
|
||||
return (
|
||||
<Aside
|
||||
title={'Create Reconcile Transactions'}
|
||||
@@ -75,7 +111,7 @@ function MatchingReconcileTransactionFormRoot({
|
||||
<MatchingReconcileTransactionBoot>
|
||||
<Formik
|
||||
onSubmit={handleSubmit}
|
||||
initialValues={initialValues}
|
||||
initialValues={_initialValues}
|
||||
validationSchema={MatchingReconcileFormSchema}
|
||||
>
|
||||
<Form className={styles.form}>
|
||||
@@ -93,11 +129,136 @@ function MatchingReconcileTransactionFormRoot({
|
||||
);
|
||||
}
|
||||
|
||||
export const MatchingReconcileTransactionForm = R.compose(withBankingActions)(
|
||||
MatchingReconcileTransactionFormRoot,
|
||||
);
|
||||
export const MatchingReconcileTransactionForm = R.compose(
|
||||
withBankingActions,
|
||||
withBanking(({ reconcileMatchingTransactionPendingAmount }) => ({
|
||||
reconcileMatchingTransactionPendingAmount,
|
||||
})),
|
||||
)(MatchingReconcileTransactionFormRoot);
|
||||
|
||||
export function MatchingReconcileTransactionFooter() {
|
||||
function ReconcileMatchingType() {
|
||||
const { setFieldValue, values } =
|
||||
useFormikContext<MatchingReconcileFormValues>();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setFieldValue('type', value);
|
||||
setFieldValue('category');
|
||||
};
|
||||
return (
|
||||
<ContentTabs
|
||||
value={values?.type || 'deposit'}
|
||||
onChange={handleChange}
|
||||
small
|
||||
>
|
||||
<ContentTabs.Tab id={'deposit'} title={'Deposit'} />
|
||||
<ContentTabs.Tab id={'withdrawal'} title={'Withdrawal'} />
|
||||
</ContentTabs>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateReconcileTransactionContent() {
|
||||
const { branches } = useMatchingReconcileTransactionBoot();
|
||||
|
||||
return (
|
||||
<Box className={styles.content}>
|
||||
<ReconcileMatchingType />
|
||||
|
||||
<FFormGroup label={'Date'} name={'date'} fastField>
|
||||
<FDateInput
|
||||
{...momentFormatter('YYYY/MM/DD')}
|
||||
name={'date'}
|
||||
popoverProps={{
|
||||
minimal: false,
|
||||
position: Position.LEFT,
|
||||
modifiers: {
|
||||
preventOverflow: { enabled: true },
|
||||
},
|
||||
boundary: 'viewport',
|
||||
}}
|
||||
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
|
||||
fill
|
||||
fastField
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<FFormGroup
|
||||
label={'Amount'}
|
||||
name={'amount'}
|
||||
labelInfo={<Tag minimal>Required</Tag>}
|
||||
fastField
|
||||
>
|
||||
<FMoneyInputGroup name={'amount'} fastField />
|
||||
</FFormGroup>
|
||||
|
||||
<MatchingReconcileCategoryField />
|
||||
|
||||
<FFormGroup
|
||||
label={'Memo'}
|
||||
name={'memo'}
|
||||
labelInfo={<Tag minimal>Required</Tag>}
|
||||
fastField
|
||||
>
|
||||
<FInputGroup name={'memo'} fastField />
|
||||
</FFormGroup>
|
||||
|
||||
<FFormGroup label={'Reference No.'} name={'reference_no'} fastField>
|
||||
<FInputGroup name={'reference_no'} />
|
||||
</FFormGroup>
|
||||
|
||||
<FFormGroup
|
||||
name={'branchId'}
|
||||
label={'Branch'}
|
||||
labelInfo={<Tag minimal>Required</Tag>}
|
||||
fastField
|
||||
>
|
||||
<BranchSelect
|
||||
name={'branchId'}
|
||||
branches={branches}
|
||||
popoverProps={{
|
||||
minimal: false,
|
||||
position: Position.LEFT,
|
||||
modifiers: {
|
||||
preventOverflow: { enabled: true },
|
||||
},
|
||||
boundary: 'viewport',
|
||||
}}
|
||||
fastField
|
||||
/>
|
||||
</FFormGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchingReconcileCategoryField() {
|
||||
const { accounts } = useMatchingReconcileTransactionBoot();
|
||||
const { values } = useFormikContext();
|
||||
|
||||
return (
|
||||
<FFormGroup
|
||||
label={'Category'}
|
||||
name={'category'}
|
||||
labelInfo={<Tag minimal>Required</Tag>}
|
||||
fastField
|
||||
>
|
||||
<AccountsSelect
|
||||
name={'category'}
|
||||
items={accounts}
|
||||
popoverProps={{
|
||||
minimal: false,
|
||||
position: Position.LEFT,
|
||||
modifiers: {
|
||||
preventOverflow: { enabled: true },
|
||||
},
|
||||
boundary: 'viewport',
|
||||
}}
|
||||
filterByRootTypes={values.type === 'deposit' ? 'income' : 'expense'}
|
||||
fastField
|
||||
/>
|
||||
</FFormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchingReconcileTransactionFooter() {
|
||||
const { isSubmitting } = useFormikContext();
|
||||
|
||||
return (
|
||||
@@ -115,85 +276,3 @@ export function MatchingReconcileTransactionFooter() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ReconcileMatchingType() {
|
||||
const { setFieldValue, values } =
|
||||
useFormikContext<MatchingReconcileFormValues>();
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setFieldValue('type', value);
|
||||
};
|
||||
return (
|
||||
<ContentTabs
|
||||
value={values?.type || 'deposit'}
|
||||
onChange={handleChange}
|
||||
small
|
||||
>
|
||||
<ContentTabs.Tab id={'deposit'} title={'Deposit'} />
|
||||
<ContentTabs.Tab id={'withdrawal'} title={'Withdrawal'} />
|
||||
</ContentTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateReconcileTransactionContent() {
|
||||
const { accounts, branches } = useMatchingReconcileTransactionBoot();
|
||||
|
||||
return (
|
||||
<Box className={styles.content}>
|
||||
<ReconcileMatchingType />
|
||||
|
||||
<FFormGroup label={'Date'} name={'date'}>
|
||||
<FDateInput
|
||||
{...momentFormatter('YYYY/MM/DD')}
|
||||
name={'date'}
|
||||
formatDate={(date) => date.toLocaleString()}
|
||||
popoverProps={{
|
||||
position: Position.LEFT,
|
||||
}}
|
||||
inputProps={{ fill: true }}
|
||||
fill
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<FFormGroup
|
||||
label={'Amount'}
|
||||
name={'amount'}
|
||||
labelInfo={<Tag minimal>Required</Tag>}
|
||||
>
|
||||
<FMoneyInputGroup name={'amount'} />
|
||||
</FFormGroup>
|
||||
|
||||
<FFormGroup
|
||||
label={'Category'}
|
||||
name={'category'}
|
||||
labelInfo={<Tag minimal>Required</Tag>}
|
||||
>
|
||||
<AccountsSelect
|
||||
name={'category'}
|
||||
items={accounts}
|
||||
popoverProps={{ minimal: false, position: Position.LEFT }}
|
||||
/>
|
||||
</FFormGroup>
|
||||
|
||||
<FFormGroup
|
||||
label={'Memo'}
|
||||
name={'memo'}
|
||||
labelInfo={<Tag minimal>Required</Tag>}
|
||||
>
|
||||
<FInputGroup name={'memo'} />
|
||||
</FFormGroup>
|
||||
|
||||
<FFormGroup label={'Reference No.'} name={'reference_no'}>
|
||||
<FInputGroup name={'reference_no'} />
|
||||
</FFormGroup>
|
||||
|
||||
<FFormGroup name={'branchId'} label={'Branch'}>
|
||||
<BranchSelect
|
||||
name={'branchId'}
|
||||
branches={branches}
|
||||
popoverProps={{ minimal: true }}
|
||||
/>
|
||||
</FFormGroup>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-nocheck
|
||||
import { isEmpty } from 'lodash';
|
||||
import * as R from 'ramda';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core';
|
||||
import { FastField, FastFieldProps, Formik, useFormikContext } from 'formik';
|
||||
import { AppToaster, Box, FormatNumber, Group, Stack } from '@/components';
|
||||
@@ -39,9 +40,6 @@ const initialValues = {
|
||||
function MatchingBankTransactionRoot({
|
||||
// #withBankingActions
|
||||
closeMatchingTransactionAside,
|
||||
|
||||
// #withBanking
|
||||
openReconcileMatchingTransaction,
|
||||
}) {
|
||||
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
|
||||
const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction();
|
||||
@@ -71,6 +69,18 @@ function MatchingBankTransactionRoot({
|
||||
closeMatchingTransactionAside();
|
||||
})
|
||||
.catch((err) => {
|
||||
if (
|
||||
err.response?.data.errors.find(
|
||||
(e) => e.type === 'TOTAL_MATCHING_TRANSACTIONS_INVALID',
|
||||
)
|
||||
) {
|
||||
AppToaster.show({
|
||||
message: `The total amount does not equal the uncategorized transaction.`,
|
||||
intent: Intent.DANGER,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
AppToaster.show({
|
||||
intent: Intent.DANGER,
|
||||
message: 'Something went wrong.',
|
||||
@@ -84,25 +94,86 @@ function MatchingBankTransactionRoot({
|
||||
uncategorizedTransactionId={uncategorizedTransactionId}
|
||||
>
|
||||
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||
<>
|
||||
<MatchingBankTransactionContent />
|
||||
|
||||
{openReconcileMatchingTransaction && (
|
||||
<MatchingReconcileTransactionForm />
|
||||
)}
|
||||
{!openReconcileMatchingTransaction && <MatchTransactionFooter />}
|
||||
</>
|
||||
<MatchingBankTransactionFormContent />
|
||||
</Formik>
|
||||
</MatchingTransactionBoot>
|
||||
);
|
||||
}
|
||||
|
||||
export const MatchingBankTransaction = R.compose(
|
||||
export const MatchingBankTransaction = R.compose(withBankingActions)(
|
||||
MatchingBankTransactionRoot,
|
||||
);
|
||||
|
||||
/**
|
||||
* Matching bank transaction form content.
|
||||
* @returns {React.ReactNode}
|
||||
*/
|
||||
const MatchingBankTransactionFormContent = R.compose(
|
||||
withBankingActions,
|
||||
withBanking(({ openReconcileMatchingTransaction }) => ({
|
||||
openReconcileMatchingTransaction,
|
||||
})),
|
||||
)(MatchingBankTransactionRoot);
|
||||
)(
|
||||
({
|
||||
// #withBanking
|
||||
openReconcileMatchingTransaction,
|
||||
}) => {
|
||||
const {
|
||||
isMatchingTransactionsFetching,
|
||||
isMatchingTransactionsSuccess,
|
||||
matches,
|
||||
} = useMatchingTransactionBoot();
|
||||
const [pending, setPending] = useState<null | {
|
||||
refId: number;
|
||||
refType: string;
|
||||
}>(null);
|
||||
|
||||
const { setFieldValue } = useFormikContext();
|
||||
|
||||
// This effect is responsible for automatically marking a transaction as matched
|
||||
// when the matching process is successful and not currently fetching.
|
||||
useEffect(() => {
|
||||
if (
|
||||
pending &&
|
||||
isMatchingTransactionsSuccess &&
|
||||
!isMatchingTransactionsFetching
|
||||
) {
|
||||
const foundMatch = matches?.find(
|
||||
(m) =>
|
||||
m.referenceType === pending?.refType &&
|
||||
m.referenceId === pending?.refId,
|
||||
);
|
||||
if (foundMatch) {
|
||||
setFieldValue(`matched.${pending.refType}-${pending.refId}`, true);
|
||||
}
|
||||
setPending(null);
|
||||
}
|
||||
}, [
|
||||
isMatchingTransactionsFetching,
|
||||
isMatchingTransactionsSuccess,
|
||||
matches,
|
||||
pending,
|
||||
setFieldValue,
|
||||
]);
|
||||
|
||||
const handleReconcileFormSubmitSuccess = (payload) => {
|
||||
setPending({ refId: payload.id, refType: payload.type });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MatchingBankTransactionContent />
|
||||
|
||||
{openReconcileMatchingTransaction && (
|
||||
<MatchingReconcileTransactionForm
|
||||
onSubmitSuccess={handleReconcileFormSubmitSuccess}
|
||||
/>
|
||||
)}
|
||||
{!openReconcileMatchingTransaction && <MatchTransactionFooter />}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function MatchingBankTransactionContent() {
|
||||
return (
|
||||
@@ -141,8 +212,8 @@ function PerfectMatchingTransactions() {
|
||||
key={index}
|
||||
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}
|
||||
date={match.dateFormatted}
|
||||
transactionId={match.transactionId}
|
||||
transactionType={match.transactionType}
|
||||
transactionId={match.referenceId}
|
||||
transactionType={match.referenceType}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -166,9 +237,6 @@ function PossibleMatchingTransactions() {
|
||||
<Box className={styles.matchBar}>
|
||||
<Stack spacing={2}>
|
||||
<h2 className={styles.matchBarTitle}>Possible Matches</h2>
|
||||
<Text style={{ fontSize: 12, color: '#5C7080' }}>
|
||||
Transactions up to 20 Aug 2019
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
@@ -176,10 +244,15 @@ function PossibleMatchingTransactions() {
|
||||
{possibleMatches.map((match, index) => (
|
||||
<MatchTransactionField
|
||||
key={index}
|
||||
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}
|
||||
label={
|
||||
<>
|
||||
{`${match.transsactionTypeFormatted} for `}
|
||||
<strong>{match.amountFormatted}</strong>
|
||||
</>
|
||||
}
|
||||
date={match.dateFormatted}
|
||||
transactionId={match.transactionId}
|
||||
transactionType={match.transactionType}
|
||||
transactionId={match.referenceId}
|
||||
transactionType={match.referenceType}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -240,7 +313,7 @@ const MatchTransactionFooter = R.compose(withBankingActions)(
|
||||
submitForm();
|
||||
};
|
||||
const handleReconcileTransaction = () => {
|
||||
openReconcileMatchingTransaction();
|
||||
openReconcileMatchingTransaction(totalPending);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -258,7 +331,7 @@ const MatchTransactionFooter = R.compose(withBankingActions)(
|
||||
</AnchorButton>
|
||||
)}
|
||||
<Text
|
||||
style={{ fontSize: 14, marginLeft: 'auto', color: '#5F6B7C' }}
|
||||
style={{ fontSize: 14, marginLeft: 'auto', color: '#404854' }}
|
||||
tagName="span"
|
||||
>
|
||||
Pending <FormatNumber value={totalPending} currency={'USD'} />
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { defaultTo } from 'lodash';
|
||||
import React, { createContext } from 'react';
|
||||
import { defaultTo } from 'lodash';
|
||||
import * as R from 'ramda';
|
||||
import { useGetBankTransactionsMatches } from '@/hooks/query/bank-rules';
|
||||
|
||||
interface MatchingTransactionBootValues {
|
||||
isMatchingTransactionsLoading: boolean;
|
||||
isMatchingTransactionsFetching: boolean;
|
||||
isMatchingTransactionsSuccess: boolean;
|
||||
possibleMatches: Array<any>;
|
||||
perfectMatchesCount: number;
|
||||
perfectMatches: Array<any>;
|
||||
@@ -26,13 +29,24 @@ function MatchingTransactionBoot({
|
||||
const {
|
||||
data: matchingTransactions,
|
||||
isLoading: isMatchingTransactionsLoading,
|
||||
isFetching: isMatchingTransactionsFetching,
|
||||
isSuccess: isMatchingTransactionsSuccess,
|
||||
} = useGetBankTransactionsMatches(uncategorizedTransactionId);
|
||||
|
||||
const possibleMatches = defaultTo(matchingTransactions?.possibleMatches, []);
|
||||
const perfectMatchesCount = matchingTransactions?.perfectMatches?.length || 0;
|
||||
const perfectMatches = defaultTo(matchingTransactions?.perfectMatches, []);
|
||||
|
||||
const matches = R.concat(perfectMatches, possibleMatches);
|
||||
|
||||
const provider = {
|
||||
isMatchingTransactionsLoading,
|
||||
possibleMatches: defaultTo(matchingTransactions?.possibleMatches, []),
|
||||
perfectMatchesCount: matchingTransactions?.perfectMatches?.length || 0,
|
||||
perfectMatches: defaultTo(matchingTransactions?.perfectMatches, []),
|
||||
isMatchingTransactionsFetching,
|
||||
isMatchingTransactionsSuccess,
|
||||
possibleMatches,
|
||||
perfectMatchesCount,
|
||||
perfectMatches,
|
||||
matches,
|
||||
} as MatchingTransactionBootValues;
|
||||
|
||||
return <RuleFormBootContext.Provider value={provider} {...props} />;
|
||||
|
||||
@@ -24,12 +24,14 @@ export const useGetPendingAmountMatched = () => {
|
||||
return useMemo(() => {
|
||||
const matchedItems = [...perfectMatches, ...possibleMatches].filter(
|
||||
(match) => {
|
||||
const key = `${match.transactionType}-${match.transactionId}`;
|
||||
const key = `${match.referenceType}-${match.referenceId}`;
|
||||
return values.matched[key];
|
||||
},
|
||||
);
|
||||
const totalMatchedAmount = matchedItems.reduce(
|
||||
(total, item) => total + parseFloat(item.amount),
|
||||
(total, item) =>
|
||||
total +
|
||||
(item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount),
|
||||
0,
|
||||
);
|
||||
const amount = uncategorizedTransaction.amount;
|
||||
|
||||
@@ -9,7 +9,10 @@ export const withBanking = (mapState) => {
|
||||
selectedUncategorizedTransactionId:
|
||||
state.plaid.uncategorizedTransactionIdForMatching,
|
||||
openReconcileMatchingTransaction:
|
||||
state.plaid.openReconcileMatchingTransaction,
|
||||
state.plaid.openReconcileMatchingTransaction.isOpen,
|
||||
|
||||
reconcileMatchingTransactionPendingAmount:
|
||||
state.plaid.openReconcileMatchingTransaction.pending,
|
||||
};
|
||||
return mapState ? mapState(mapped, state, props) : mapped;
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface WithBankingActionsProps {
|
||||
setUncategorizedTransactionIdForMatching: (
|
||||
uncategorizedTransactionId: number,
|
||||
) => void;
|
||||
openReconcileMatchingTransaction: () => void;
|
||||
openReconcileMatchingTransaction: (pendingAmount: number) => void;
|
||||
closeReconcileMatchingTransaction: () => void;
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
|
||||
dispatch(
|
||||
setUncategorizedTransactionIdForMatching(uncategorizedTransactionId),
|
||||
),
|
||||
openReconcileMatchingTransaction: () =>
|
||||
dispatch(openReconcileMatchingTransaction()),
|
||||
openReconcileMatchingTransaction: (pendingAmount: number) =>
|
||||
dispatch(openReconcileMatchingTransaction({ pending: pendingAmount })),
|
||||
closeReconcileMatchingTransaction: () =>
|
||||
dispatch(closeReconcileMatchingTransaction()),
|
||||
});
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.periodSwitch {
|
||||
margin: 0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user