diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index 43ba31444d..b1a880b80c 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -66,7 +66,9 @@ export interface IAccountTransaction { referenceId: number; referenceNumber?: string; + transactionNumber?: string; + transactionType?: string; note?: string; diff --git a/packages/server/src/interfaces/FinancialReports/CashflowAccountTransactions/index.ts b/packages/server/src/interfaces/FinancialReports/CashflowAccountTransactions/index.ts index afc98f7dcd..3372d73fd6 100644 --- a/packages/server/src/interfaces/FinancialReports/CashflowAccountTransactions/index.ts +++ b/packages/server/src/interfaces/FinancialReports/CashflowAccountTransactions/index.ts @@ -29,4 +29,9 @@ export interface ICashflowAccountTransaction { date: Date; formattedDate: string; + + status: string; + formattedStatus: string; + + uncategorizedTransactionId: number; } diff --git a/packages/server/src/interfaces/Ledger.ts b/packages/server/src/interfaces/Ledger.ts index 2ab52a6318..d7045eb41c 100644 --- a/packages/server/src/interfaces/Ledger.ts +++ b/packages/server/src/interfaces/Ledger.ts @@ -40,6 +40,8 @@ export interface ILedgerEntry { date: Date | string; transactionType: string; + transactionSubType: string; + transactionId: number; transactionNumber?: string; diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 6a282a8f7b..21d20c3876 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -110,6 +110,8 @@ 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'; export default () => { return new EventPublisher(); @@ -258,6 +260,8 @@ export const susbcribers = () => { // Bank Rules TriggerRecognizedTransactions, UnlinkBankRuleOnDeleteBankRule, + DecrementUncategorizedTransactionOnMatching, + DecrementUncategorizedTransactionOnExclude, // Validate matching ValidateMatchingOnCashflowDelete, @@ -266,7 +270,7 @@ export const susbcribers = () => { ValidateMatchingOnPaymentReceivedDelete, ValidateMatchingOnPaymentMadeDelete, - // Plaid + // Plaid RecognizeSyncedBankTranasctions, ]; }; diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index c5aadbccb9..f1cb8dd7ac 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -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'; + export default class CashflowTransaction extends TenantModel { transactionType: string; amount: number; @@ -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. */ diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 9318ee9782..2418b17118 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -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'); + }, }; } diff --git a/packages/server/src/services/Accounting/utils.ts b/packages/server/src/services/Accounting/utils.ts index ee675f09ca..2edc25b973 100644 --- a/packages/server/src/services/Accounting/utils.ts +++ b/packages/server/src/services/Accounting/utils.ts @@ -19,6 +19,8 @@ export const transformLedgerEntryToTransaction = ( referenceId: entry.transactionId, transactionNumber: entry.transactionNumber, + transactionType: entry.transactionSubType, + referenceNumber: entry.referenceNumber, note: entry.note, diff --git a/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts b/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts index 6a07e614ac..cfa3c8d4cc 100644 --- a/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts +++ b/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts @@ -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, diff --git a/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts b/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts index 7a19381997..16a25433cc 100644 --- a/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts +++ b/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts @@ -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); }); } } diff --git a/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts b/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts index 46148b81ba..46bd81862a 100644 --- a/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts +++ b/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts @@ -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 { 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 + ); }); } } diff --git a/packages/server/src/services/Banking/Exclude/_types.ts b/packages/server/src/services/Banking/Exclude/_types.ts index d8a5188a75..c7aa571fdf 100644 --- a/packages/server/src/services/Banking/Exclude/_types.ts +++ b/packages/server/src/services/Banking/Exclude/_types.ts @@ -1,6 +1,30 @@ +import { Knex } from "knex"; export interface ExcludedBankTransactionsQuery { page?: number; pageSize?: number; accountId?: number; -} \ No newline at end of file +} + +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 +} diff --git a/packages/server/src/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude.ts b/packages/server/src/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude.ts new file mode 100644 index 0000000000..6f3aeba318 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude.ts @@ -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); + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts index f5dd9eaa01..4f833c599e 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts @@ -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'; + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts new file mode 100644 index 0000000000..cd40951ffd --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts @@ -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} + */ + 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'; + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts index d6f71e7056..a77dcffb0f 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts @@ -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; + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts index ed2dcfaa24..dd5de9bfb8 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts @@ -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; + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts index 9b19b01a02..11ab194a01 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts @@ -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; + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts index 43f5ba532c..f0cfdcaedc 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -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 }, ]; } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts index 4796f75986..394fecba5c 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts @@ -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( diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts new file mode 100644 index 0000000000..ef8887ae7b --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts @@ -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 + ) { + 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() + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts index 8996c4b91c..39db88cf1d 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts @@ -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, diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts index 141d22fe1f..88cc72c1ee 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts @@ -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 { - 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( diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts index 2aa6341aff..42dae0988b 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts @@ -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 ) { - 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); } diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts index c91cb152d9..a85fb6a988 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactions.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -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. diff --git a/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts b/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts index 6c5c938d49..d90db1a364 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts @@ -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, + }, ]; } diff --git a/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts index 0f4a1def70..e51fc7cbd8 100644 --- a/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts +++ b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts @@ -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); }); diff --git a/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts b/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts index e6e5a2cd7e..5938c9820b 100644 --- a/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts +++ b/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts @@ -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, }); diff --git a/packages/server/src/services/Banking/Matching/_utils.ts b/packages/server/src/services/Banking/Matching/_utils.ts index 89a316a4bb..67e7b00421 100644 --- a/packages/server/src/services/Banking/Matching/_utils.ts +++ b/packages/server/src/services/Banking/Matching/_utils.ts @@ -20,3 +20,12 @@ export const sortClosestMatchTransactions = ( ), ])(matches); }; + +export const sumMatchTranasctions = (transactions: Array) => { + return transactions.reduce( + (total, item) => + total + + (item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount), + 0 + ); +}; diff --git a/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts new file mode 100644 index 0000000000..67dda577dd --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts @@ -0,0 +1,68 @@ +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); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts index c6087b9e81..3e205c4ba3 100644 --- a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts @@ -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 ); } } diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts index 38c2dcba80..eb5fda3b4e 100644 --- a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts @@ -30,7 +30,8 @@ export class ValidateMatchingOnExpenseDelete { await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( tenantId, 'Expense', - oldExpense.id + oldExpense.id, + trx ); } } diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts index 90078bfdc7..61132db869 100644 --- a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts @@ -30,7 +30,8 @@ export class ValidateMatchingOnManualJournalDelete { await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( tenantId, 'ManualJournal', - oldManualJournal.id + oldManualJournal.id, + trx ); } } diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts index 0ce97cdb93..c57c287296 100644 --- a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts @@ -33,7 +33,8 @@ export class ValidateMatchingOnPaymentMadeDelete { await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( tenantId, 'PaymentMade', - oldBillPayment.id + oldBillPayment.id, + trx ); } } diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts index 20c3018aca..446f3d5f14 100644 --- a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts @@ -30,7 +30,8 @@ export class ValidateMatchingOnPaymentReceivedDelete { await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( tenantId, 'PaymentReceive', - oldPaymentReceive.id + oldPaymentReceive.id, + trx ); } } diff --git a/packages/server/src/services/Banking/Matching/types.ts b/packages/server/src/services/Banking/Matching/types.ts index 5e60f88f91..d415b6478c 100644 --- a/packages/server/src/services/Banking/Matching/types.ts +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -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 { diff --git a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts index c1c590d551..55ba105631 100644 --- a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts +++ b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts @@ -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 => { - const transactionTypes = getCashflowAccountTransactionsTypes(); - await this.ledgerStorage.deleteByReference( tenantId, cashflowTransactionId, - transactionTypes, + 'CashflowTransaction', trx ); }; diff --git a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts index ecc0d32676..8a5b15be04 100644 --- a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts @@ -101,6 +101,7 @@ export default class NewCashflowTransactionService { ...fromDTO, transactionNumber, currencyCode: cashflowAccount.currencyCode, + exchangeRate: fromDTO?.exchangeRate || 1, transactionType: transformCashflowTransactionType( fromDTO.transactionType ), diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactions.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactions.ts index 7fc1d00e29..7ca2694cf5 100644 --- a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactions.ts +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactions.ts @@ -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); } } diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsRepo.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsRepo.ts index 99456fa3ae..7bb50373de 100644 --- a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsRepo.ts +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsRepo.ts @@ -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; + public matchedBankTransactions: any; + public matchedBankTransactionsMapByRef: Map; + 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); - - return AccountTransaction.query() - .where('account_id', query.accountId) + async initCashflowAccountTransactions() { + const { AccountTransaction } = this.models; + + 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} */ - async getCashflowAccountOpeningBalance( - tenantId: number, - accountId: number, - pagination: IPaginationMeta - ): Promise { - const { AccountTransaction } = this.tenancy.models(tenantId); + async initCashflowAccountOpeningBalance(): Promise { + 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 { + 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 + ); } } diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsService.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsService.ts index f3035a1485..adf4a519ee 100644 --- a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsService.ts +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsService.ts @@ -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); + // Initalize the bank transactions report repository. + const cashflowTransactionsRepo = new CashflowAccountTransactionsRepo( + models, + parsedQuery + ); + await cashflowTransactionsRepo.asyncInit(); - // 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, + const report = new CashflowAccountTransactionReport( + cashflowTransactionsRepo, parsedQuery ); - const reportTranasctions = report.reportData(); - - return { - transactions: this.i18nService.i18nApply( - [[qim.$each, 'formattedTransactionType']], - reportTranasctions, - tenantId - ), - 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, - ]; + const transactions = report.reportData(); + const pagination = cashflowTransactionsRepo.pagination; - if (!includes(cashflowTypes, account.accountType)) { - throw new ServiceError(ERRORS.ACCOUNT_ID_HAS_INVALID_TYPE); - } + return { transactions, pagination }; } } diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/constants.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/constants.ts index bf0a0eeada..298cbe801e 100644 --- a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/constants.ts +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/constants.ts @@ -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', +} diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/utils.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/utils.ts new file mode 100644 index 0000000000..a1e47d4f45 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/utils.ts @@ -0,0 +1,40 @@ +import * as R from 'ramda'; + +export const groupUncategorizedTransactions = ( + uncategorizedTransactions: any +): Map => { + return new Map( + R.toPairs( + R.groupBy( + (transaction) => + `${transaction.categorizeRefType}-${transaction.categorizeRefId}`, + uncategorizedTransactions + ) + ) + ); +}; + +export const groupMatchedBankTransactions = ( + uncategorizedTransactions: any +): Map => { + 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'; + } +}; diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 507fe9e526..b254c71133 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -639,4 +639,12 @@ export default { onUnmatching: 'onBankTransactionUnmathcing', onUnmatched: 'onBankTransactionUnmathced', }, + + bankTransactions: { + onExcluding: 'onBankTransactionExclude', + onExcluded: 'onBankTransactionExcluded', + + onUnexcluding: 'onBankTransactionUnexcluding', + onUnexcluded: 'onBankTransactionUnexcluded', + }, }; diff --git a/packages/webapp/src/components/Aside/Aside.module.scss b/packages/webapp/src/components/Aside/Aside.module.scss index 742d0c4c58..73ee0e2991 100644 --- a/packages/webapp/src/components/Aside/Aside.module.scss +++ b/packages/webapp/src/components/Aside/Aside.module.scss @@ -21,4 +21,5 @@ flex-direction: column; flex: 1 1 auto; background-color: #fff; + overflow-y: auto; } \ No newline at end of file diff --git a/packages/webapp/src/components/Aside/Aside.tsx b/packages/webapp/src/components/Aside/Aside.tsx index 7967cd2b33..a9f8e25e97 100644 --- a/packages/webapp/src/components/Aside/Aside.tsx +++ b/packages/webapp/src/components/Aside/Aside.tsx @@ -1,13 +1,16 @@ import { Button, Classes } from '@blueprintjs/core'; -import { Box, Group } from '../Layout'; +import clsx from 'classnames'; +import { Box, BoxProps, Group } from '../Layout'; import { Icon } from '../Icon'; import styles from './Aside.module.scss'; -interface AsideProps { +interface AsideProps extends BoxProps { title?: string; onClose?: () => void; children?: React.ReactNode; hideCloseButton?: boolean; + classNames?: Record; + className?: string; } export function Aside({ @@ -15,13 +18,15 @@ export function Aside({ onClose, children, hideCloseButton, + classNames, + className }: AsideProps) { const handleClose = () => { onClose && onClose(); }; return ( - - + + {title} {hideCloseButton !== true && ( @@ -34,7 +39,23 @@ export function Aside({ /> )} - {children} + + {children} ); -} \ No newline at end of file +} + +interface AsideContentProps extends BoxProps {} + +function AsideContent({ ...props }: AsideContentProps) { + return ; +} + +interface AsideFooterProps extends BoxProps {} + +function AsideFooter({ ...props }: AsideFooterProps) { + return ; +} + +Aside.Body = AsideContent; +Aside.Footer = AsideFooter; diff --git a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx index 58f8447828..f752918f24 100644 --- a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx +++ b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx @@ -19,6 +19,12 @@ const ContentTabItemRoot = styled.button` text-align: left; cursor: pointer; + ${(props) => + props.small && + ` + padding: 8px 10px; + `} + ${(props) => props.active && ` @@ -55,6 +61,8 @@ interface ContentTabsItemProps { title?: React.ReactNode; description?: React.ReactNode; active?: boolean; + className?: string; + small?: booean; } const ContentTabsItem = ({ @@ -62,11 +70,18 @@ const ContentTabsItem = ({ description, active, onClick, + small, + className, }: ContentTabsItemProps) => { return ( - + {title} - {description} + {description && {description}} ); }; @@ -77,6 +92,7 @@ interface ContentTabsProps { onChange?: (value: string) => void; children?: React.ReactNode; className?: string; + small?: boolean; } export function ContentTabs({ @@ -85,6 +101,7 @@ export function ContentTabs({ onChange, children, className, + small, }: ContentTabsProps) { const [localValue, handleItemChange] = useUncontrolled({ initialValue, @@ -102,6 +119,7 @@ export function ContentTabs({ {...tab.props} active={localValue === tab.props.id} onClick={() => handleItemChange(tab.props?.id)} + small={small} /> ))} diff --git a/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx b/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx index e50038a668..5b83507804 100644 --- a/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx +++ b/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx @@ -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, + }); } }, ) diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx index 3db6e8c176..1c37a28f10 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx @@ -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 ( ); diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx index 5fbe791184..e54c11f9dc 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -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. diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/components.tsx new file mode 100644 index 0000000000..58735ec118 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/components.tsx @@ -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 ( + + } + text={'Categorize'} + onClick={safeCallback(onCategorize, original)} + /> + + } + /> + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 2829e25ddc..d691961633 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -5,44 +5,57 @@ import { Intent, Menu, MenuItem, - MenuDivider, Tag, - Popover, PopoverInteractionKind, Position, Tooltip, + MenuDivider, } 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 ( - } - text={'Categorize'} - onClick={safeCallback(onCategorize, original)} - /> - - } - /> + {original.status === 'categorized' && ( + } + text={'Uncategorize'} + onClick={safeCallback(onUncategorize, original)} + /> + )} + {original.status === 'matched' && ( + } + onClick={safeCallback(onUnmatch, original)} + /> + )} ); } +const allTransactionsStatusAccessor = (transaction) => { + return ( + + {transaction.formatted_status} + + ); +}; + /** * Retrieve account transctions table columns. */ @@ -70,7 +83,7 @@ export function useAccountTransactionsColumns() { }, { id: 'transaction_number', - Header: intl.get('transaction_number'), + Header: 'Transaction #', accessor: 'transaction_number', width: 160, className: 'transaction_number', @@ -79,13 +92,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 +134,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,7 +212,7 @@ export function useAccountUncategorizedTransactionsColumns() { }, { id: 'reference_number', - Header: intl.get('reference_no'), + Header: 'Ref.#', accessor: 'reference_number', width: 50, className: 'reference_number', diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx index 463cfe461f..bc228471dd 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx @@ -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(); }; @@ -28,11 +38,13 @@ function CategorizeTransactionAsideRoot({ } return ( ); } diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss index 94b4613f5b..bdf7431646 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss @@ -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 { diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.tsx index 29fcd294cd..62d3b344dc 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.tsx @@ -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} > - + {label} Date: {date} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionBoot.tsx new file mode 100644 index 0000000000..e2f74a3d62 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionBoot.tsx @@ -0,0 +1,43 @@ +import { useAccounts, useBranches } from '@/hooks/query'; +import { Spinner } from '@blueprintjs/core'; +import React from 'react'; + +interface MatchingReconcileTransactionBootProps { + children: React.ReactNode; +} +interface MatchingReconcileTransactionBootValue {} + +const MatchingReconcileTransactionBootContext = + React.createContext( + {} as MatchingReconcileTransactionBootValue, + ); + +export function MatchingReconcileTransactionBoot({ + children, +}: MatchingReconcileTransactionBootProps) { + const { data: accounts, isLoading: isAccountsLoading } = useAccounts({}, {}); + const { data: branches, isLoading: isBranchesLoading } = useBranches({}, {}); + + const provider = { + accounts, + branches, + isAccountsLoading, + isBranchesLoading, + }; + const isLoading = isAccountsLoading || isBranchesLoading; + + if (isLoading) { + return ; + } + + return ( + + {children} + + ); +} + +export const useMatchingReconcileTransactionBoot = () => + React.useContext( + MatchingReconcileTransactionBootContext, + ); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.module.scss b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.module.scss new file mode 100644 index 0000000000..65dd9e6840 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.module.scss @@ -0,0 +1,43 @@ + + + +.content{ + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; +} + +.footer { + padding: 11px 20px; + border-top: 1px solid #ced4db; +} + +.form{ + display: flex; + flex-direction: column; + flex: 1 1 0; + + :global .bp4-form-group{ + margin-bottom: 0; + } + :global .bp4-input { + line-height: 30px; + height: 30px; + } +} + +.asideContent{ + background: #F6F7F9; + height: 335px; +} + +.asideRoot { + flex: 1 1 0; + box-shadow: 0 0 0 1px rgba(17,20,24,.1),0 1px 1px rgba(17,20,24,.2),0 2px 6px rgba(17,20,24,.2); +} + +.asideFooter { + background: #F6F7F9; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.schema.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.schema.ts new file mode 100644 index 0000000000..107444d445 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.schema.ts @@ -0,0 +1,10 @@ +import * as Yup from 'yup'; + +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'), + referenceNo: Yup.string().label('Refernece #'), + category: Yup.string().required().label('Categogry'), +}); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx new file mode 100644 index 0000000000..3ae33fd684 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx @@ -0,0 +1,282 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Button, Intent, Position, Tag } from '@blueprintjs/core'; +import { + Form, + Formik, + FormikHelpers, + FormikValues, + useFormikContext, +} from 'formik'; +import moment from 'moment'; +import { + AccountsSelect, + AppToaster, + Box, + BranchSelect, + FDateInput, + FFormGroup, + FInputGroup, + FMoneyInputGroup, + Group, +} from '@/components'; +import { Aside } from '@/components/Aside/Aside'; +import { momentFormatter } from '@/utils'; +import styles from './MatchingReconcileTransactionForm.module.scss'; +import { ContentTabs } from '@/components/ContentTabs'; +import { withBankingActions } from '../../withBankingActions'; +import { + MatchingReconcileTransactionBoot, + useMatchingReconcileTransactionBoot, +} from './MatchingReconcileTransactionBoot'; +import { useCreateCashflowTransaction } from '@/hooks/query'; +import { useAccountTransactionsContext } from '../../AccountTransactions/AccountTransactionsProvider'; +import { MatchingReconcileFormSchema } from './MatchingReconcileTransactionForm.schema'; +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, + setErrors, + }: FormikHelpers, + ) => { + setSubmitting(true); + const _values = transformToReq(values, accountId); + + createCashflowTransactionMutate(_values) + .then((res) => { + setSubmitting(false); + + AppToaster.show({ + message: 'The transaction has been created.', + intent: Intent.SUCCESS, + }); + closeReconcileMatchingTransaction(); + onSubmitSuccess && + onSubmitSuccess({ id: res.data.id, type: 'CashflowTransaction' }); + }) + .catch((error) => { + setSubmitting(false); + 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: Math.abs(reconcileMatchingTransactionPendingAmount) || 0, + date: moment().format('YYYY-MM-DD'), + type: + reconcileMatchingTransactionPendingAmount > 0 ? 'deposit' : 'withdrawal', + }; + + return ( + + ); +} + +export const MatchingReconcileTransactionForm = R.compose( + withBankingActions, + withBanking(({ reconcileMatchingTransactionPendingAmount }) => ({ + reconcileMatchingTransactionPendingAmount, + })), +)(MatchingReconcileTransactionFormRoot); + +function ReconcileMatchingType() { + const { setFieldValue, values } = + useFormikContext(); + + const handleChange = (value: string) => { + setFieldValue('type', value); + setFieldValue('category'); + }; + return ( + + + + + ); +} + +function CreateReconcileTransactionContent() { + const { branches } = useMatchingReconcileTransactionBoot(); + + return ( + + + + + + + + Required} + fastField + > + + + + + + Required} + fastField + > + + + + + + + + Required} + fastField + > + + + + ); +} + +function MatchingReconcileCategoryField() { + const { accounts } = useMatchingReconcileTransactionBoot(); + const { values } = useFormikContext(); + + return ( + Required} + fastField + > + + + ); +} + +function MatchingReconcileTransactionFooter() { + const { isSubmitting } = useFormikContext(); + + return ( + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_types.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_types.ts new file mode 100644 index 0000000000..1817497e1d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_types.ts @@ -0,0 +1,9 @@ +export interface MatchingReconcileTransactionValues { + type: string; + date: string; + amount: string; + memo: string; + referenceNo: string; + category: string; + branchId: string; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_utils.ts new file mode 100644 index 0000000000..2a747b7cf1 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_utils.ts @@ -0,0 +1,30 @@ +import { MatchingReconcileTransactionValues } from './_types'; + +export const transformToReq = ( + values: MatchingReconcileTransactionValues, + bankAccountId: number, +) => { + return { + date: values.date, + reference_no: values.referenceNo, + transaction_type: + values.type === 'deposit' ? 'other_income' : 'other_expense', + description: values.memo, + amount: values.amount, + credit_account_id: values.category, + cashflow_account_id: bankAccountId, + branch_id: values.branchId, + publish: true, + }; +}; + + +export const initialValues = { + type: 'deposit', + date: '', + amount: '', + memo: '', + referenceNo: '', + category: '', + branchId: '', +}; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx index 8aef55e8e9..a96368e70f 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx @@ -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'; @@ -25,6 +26,8 @@ import { withBankingActions, } from '../withBankingActions'; import styles from './CategorizeTransactionAside.module.scss'; +import { MatchingReconcileTransactionForm } from './MatchingReconcileTransactionAside/MatchingReconcileTransactionForm'; +import { withBanking } from '../withBanking'; const initialValues = { matched: {}, @@ -66,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.', @@ -79,10 +94,7 @@ function MatchingBankTransactionRoot({ uncategorizedTransactionId={uncategorizedTransactionId} > - <> - - - + ); @@ -92,6 +104,77 @@ export const MatchingBankTransaction = R.compose(withBankingActions)( MatchingBankTransactionRoot, ); +/** + * Matching bank transaction form content. + * @returns {React.ReactNode} + */ +const MatchingBankTransactionFormContent = R.compose( + withBankingActions, + withBanking(({ openReconcileMatchingTransaction }) => ({ + openReconcileMatchingTransaction, + })), +)( + ({ + // #withBanking + openReconcileMatchingTransaction, + }) => { + const { + isMatchingTransactionsFetching, + isMatchingTransactionsSuccess, + matches, + } = useMatchingTransactionBoot(); + const [pending, setPending] = useState(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 ( + <> + + + {openReconcileMatchingTransaction && ( + + )} + {!openReconcileMatchingTransaction && } + + ); + }, +); + function MatchingBankTransactionContent() { return ( @@ -129,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} /> ))} @@ -154,9 +237,6 @@ function PossibleMatchingTransactions() {

Possible Matches

- - Transactions up to 20 Aug 2019 -
@@ -164,10 +244,15 @@ function PossibleMatchingTransactions() { {possibleMatches.map((match, index) => ( + {`${match.transsactionTypeFormatted} for `} + {match.amountFormatted} + + } date={match.dateFormatted} - transactionId={match.transactionId} - transactionType={match.transactionType} + transactionId={match.referenceId} + transactionType={match.referenceType} /> ))} @@ -212,7 +297,10 @@ interface MatchTransctionFooterProps extends WithBankingActionsProps {} * @returns {React.ReactNode} */ const MatchTransactionFooter = R.compose(withBankingActions)( - ({ closeMatchingTransactionAside }: MatchTransctionFooterProps) => { + ({ + closeMatchingTransactionAside, + openReconcileMatchingTransaction, + }: MatchTransctionFooterProps) => { const { submitForm, isSubmitting } = useFormikContext(); const totalPending = useGetPendingAmountMatched(); const showReconcileLink = useIsShowReconcileTransactionLink(); @@ -224,18 +312,26 @@ const MatchTransactionFooter = R.compose(withBankingActions)( const handleSubmitBtnClick = () => { submitForm(); }; + const handleReconcileTransaction = () => { + openReconcileMatchingTransaction(totalPending); + }; return ( {showReconcileLink && ( - + Add Reconcile Transaction + )} Pending diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx index ff14cafe16..51ad9beb16 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx @@ -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; perfectMatchesCount: number; perfectMatches: Array; @@ -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 ; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts index 6cb13f0b05..7a0bd535ee 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts @@ -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; diff --git a/packages/webapp/src/containers/CashFlow/withBanking.ts b/packages/webapp/src/containers/CashFlow/withBanking.ts index 93e056a29b..af51c8f591 100644 --- a/packages/webapp/src/containers/CashFlow/withBanking.ts +++ b/packages/webapp/src/containers/CashFlow/withBanking.ts @@ -8,6 +8,11 @@ export const withBanking = (mapState) => { openMatchingTransactionAside: state.plaid.openMatchingTransactionAside, selectedUncategorizedTransactionId: state.plaid.uncategorizedTransactionIdForMatching, + openReconcileMatchingTransaction: + state.plaid.openReconcileMatchingTransaction.isOpen, + + reconcileMatchingTransactionPendingAmount: + state.plaid.openReconcileMatchingTransaction.pending, }; return mapState ? mapState(mapped, state, props) : mapped; }; diff --git a/packages/webapp/src/containers/CashFlow/withBankingActions.ts b/packages/webapp/src/containers/CashFlow/withBankingActions.ts index 5a7f86ea52..d49241989d 100644 --- a/packages/webapp/src/containers/CashFlow/withBankingActions.ts +++ b/packages/webapp/src/containers/CashFlow/withBankingActions.ts @@ -2,6 +2,8 @@ import { connect } from 'react-redux'; import { closeMatchingTransactionAside, setUncategorizedTransactionIdForMatching, + openReconcileMatchingTransaction, + closeReconcileMatchingTransaction, } from '@/store/banking/banking.reducer'; export interface WithBankingActionsProps { @@ -9,6 +11,8 @@ export interface WithBankingActionsProps { setUncategorizedTransactionIdForMatching: ( uncategorizedTransactionId: number, ) => void; + openReconcileMatchingTransaction: (pendingAmount: number) => void; + closeReconcileMatchingTransaction: () => void; } const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ @@ -20,6 +24,10 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ dispatch( setUncategorizedTransactionIdForMatching(uncategorizedTransactionId), ), + openReconcileMatchingTransaction: (pendingAmount: number) => + dispatch(openReconcileMatchingTransaction({ pending: pendingAmount })), + closeReconcileMatchingTransaction: () => + dispatch(closeReconcileMatchingTransaction()), }); export const withBankingActions = connect< diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index 9608f7496c..7f489f92a7 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -37,7 +37,7 @@ interface CreateBankRuleResponse {} /** * Creates a new bank rule. * @param {UseMutationOptions} options - - * @returns {UseMutationResult} + * @returns {UseMutationResult}TCHES */ export function useCreateBankRule( options?: UseMutationOptions< @@ -322,6 +322,46 @@ export function useMatchUncategorizedTransaction( queryClient.invalidateQueries( t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, ); + queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY); + }, + ...props, + }); +} + +interface UnmatchUncategorizedTransactionValues { + id: number; +} +interface UnmatchUncategorizedTransactionRes {} + +/** + * Unmatch the given matched uncategorized transaction. + * @param {UseMutationOptions} props + * @returns {UseMutationResult} + */ +export function useUnmatchMatchedUncategorizedTransaction( + props?: UseMutationOptions< + UnmatchUncategorizedTransactionRes, + Error, + UnmatchUncategorizedTransactionValues + >, +): UseMutationResult< + UnmatchUncategorizedTransactionRes, + Error, + UnmatchUncategorizedTransactionValues +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + UnmatchUncategorizedTransactionRes, + Error, + UnmatchUncategorizedTransactionValues + >(({ id }) => apiRequest.post(`/banking/matches/unmatch/${id}`), { + onSuccess: (res, id) => { + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY); }, ...props, }); diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index a442009614..656d7cccf9 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -58,6 +58,8 @@ export function useCreateCashflowTransaction(props) { onSuccess: () => { // Invalidate queries. commonInvalidateQueries(queryClient); + + queryClient.invalidateQueries('BANK_TRANSACTION_MATCHES'); }, ...props, }, diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index c39e72f30c..2e3d19ed7b 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -629,4 +629,10 @@ export default { ], viewBox: '0 0 16 16', }, + unlink: { + path: [ + 'M11.9975 0.00500107C14.2061 0.00500107 15.995 1.79388 15.995 4.0025C15.995 5.11181 15.5353 6.0912 14.8058 6.81075L14.8257 6.83074L13.8264 7.83011L13.8064 7.81012C13.2562 8.36798 12.5482 8.76807 11.7539 8.92548L10.8249 7.99643L12.4073 6.401L13.4066 5.40163L13.3966 5.39163C13.7564 5.03186 13.9963 4.54217 13.9963 3.99251C13.9963 2.8932 13.0968 1.99376 11.9975 1.99376C11.4479 1.99376 10.9582 2.23361 10.5984 2.59338L10.5884 2.58339L8.0001 5.17168L7.07559 4.24717C7.23518 3.45247 7.63943 2.74409 8.18989 2.19363L8.1699 2.17365L9.16928 1.17427L9.18926 1.19426C9.90882 0.464714 10.8982 0.00500107 11.9975 0.00500107ZM2.29289 2.29289C2.68341 1.90237 3.31657 1.90237 3.7071 2.29289L13.7071 12.2929C14.0976 12.6834 14.0976 13.3166 13.7071 13.7071C13.3166 14.0976 12.6834 14.0976 12.2929 13.7071L8.93565 10.3499C8.97565 10.562 8.99938 10.7781 8.99938 10.9981C8.99938 12.0974 8.53966 13.0868 7.81012 13.8064L7.83011 13.8263L6.83073 14.8257L6.81074 14.8057C6.09119 15.5353 5.10181 15.995 4.0025 15.995C1.79388 15.995 0.00499688 14.2061 0.00499688 11.9975C0.00499688 10.8982 0.464709 9.90879 1.19425 9.18924L1.17427 9.16925L2.17364 8.16988L2.19363 8.18986C2.91318 7.46032 3.90256 7.00061 5.00187 7.00061C5.2251 7.00061 5.44064 7.02369 5.65087 7.06509L2.29289 3.7071C1.90236 3.31658 1.90236 2.68341 2.29289 2.29289ZM8.00244 9.41666L8.707 10.1212L5.41162 13.4166L5.40162 13.4066C5.04185 13.7664 4.55216 14.0062 4.0025 14.0062C2.90319 14.0062 2.00375 13.1068 2.00375 12.0075C2.00375 11.4578 2.2436 10.9681 2.60337 10.6084L2.59338 10.5984L5.88876 7.30298L6.58333 7.99755L4.29231 10.2886C4.11243 10.4685 4.00249 10.7183 4.00249 10.9981C4.00249 11.5478 4.45221 11.9975 5.00187 11.9975C5.28169 11.9975 5.53154 11.8876 5.71143 11.7077L8.00244 9.41666ZM8.70466 5.87623L10.1238 7.29534L11.7077 5.71143C11.8876 5.53154 11.9975 5.2817 11.9975 5.00187C11.9975 4.45222 11.5478 4.0025 10.9981 4.0025C10.7183 4.0025 10.4685 4.11243 10.2886 4.29232L8.70466 5.87623Z', + ], + viewBox: '0 0 16 16', + }, }; diff --git a/packages/webapp/src/store/banking/banking.reducer.ts b/packages/webapp/src/store/banking/banking.reducer.ts index d77a146aa2..53be7ae235 100644 --- a/packages/webapp/src/store/banking/banking.reducer.ts +++ b/packages/webapp/src/store/banking/banking.reducer.ts @@ -4,6 +4,7 @@ interface StorePlaidState { plaidToken: string; openMatchingTransactionAside: boolean; uncategorizedTransactionIdForMatching: number | null; + openReconcileMatchingTransaction: { isOpen: boolean; pending: number }; } export const PlaidSlice = createSlice({ @@ -12,6 +13,10 @@ export const PlaidSlice = createSlice({ plaidToken: '', openMatchingTransactionAside: false, uncategorizedTransactionIdForMatching: null, + openReconcileMatchingTransaction: { + isOpen: false, + pending: 0, + }, } as StorePlaidState, reducers: { setPlaidId: (state: StorePlaidState, action: PayloadAction) => { @@ -34,6 +39,19 @@ export const PlaidSlice = createSlice({ state.openMatchingTransactionAside = false; state.uncategorizedTransactionIdForMatching = null; }, + + openReconcileMatchingTransaction: ( + state: StorePlaidState, + action: PayloadAction<{ pending: number }>, + ) => { + state.openReconcileMatchingTransaction.isOpen = true; + state.openReconcileMatchingTransaction.pending = action.payload.pending; + }, + + closeReconcileMatchingTransaction: (state: StorePlaidState) => { + state.openReconcileMatchingTransaction.isOpen = false; + state.openReconcileMatchingTransaction.pending = 0; + }, }, }); @@ -42,6 +60,8 @@ export const { resetPlaidId, setUncategorizedTransactionIdForMatching, closeMatchingTransactionAside, + openReconcileMatchingTransaction, + closeReconcileMatchingTransaction, } = PlaidSlice.actions; export const getPlaidToken = (state: any) => state.plaid.plaidToken; diff --git a/packages/webapp/src/style/_variables.scss b/packages/webapp/src/style/_variables.scss index 777633b51c..55e071a6f3 100644 --- a/packages/webapp/src/style/_variables.scss +++ b/packages/webapp/src/style/_variables.scss @@ -50,3 +50,9 @@ $form-check-input-indeterminate-bg-image: url("data:image/svg+xml, - - - Checkbox - - - :checked - Checked - :disabled - Disabled. Also add .#{$ns}-disabled to .#{$ns}-control to change text color (not shown below). - :indeterminate - Indeterminate. Note that this style can only be achieved via JavaScript - input.indeterminate = true. - .#{$ns}-align-right - Right-aligned indicator - .#{$ns}-large - Large - - Styleguide checkbox - */ - &.#{$ns}-checkbox { - &:hover input:indeterminate~.#{$ns}-control-indicator { - // box-shadow: 0 0 0 transparent; - } - - @mixin indicator-inline-icon($icon) { - &::before { - // embed SVG icon image as backgroud-image above gradient. - // the SVG image content is inlined into the CSS, so use this sparingly. - height: 100%; - width: 100%; - } - } - - @include control-checked-colors(':checked'); - - // make :indeterminate look like :checked _for Checkbox only_ - @include control-checked-colors(':indeterminate'); - - .#{$ns}-control-indicator { - border: 1px solid #c6c6c6; - border-radius: $pt-border-radius; - background-color: #fff; - } - - input:checked~.#{$ns}-control-indicator { - background-image: escape-svg($form-check-input-checked-bg-image); - border-color: $form-check-input-checked-bg-color; - background-color: $form-check-input-checked-bg-color; - } - - input:indeterminate~.#{$ns}-control-indicator { - // background-image: escape-svg($form-check-input-indeterminate-bg-image); - border-color: $form-check-input-checked-bg-color; - background-color: $form-check-input-checked-bg-color; - box-shadow: 0 0 0 0 transparent; - } - } - - /* - Radio - - Markup: - - - :checked - Selected - :disabled - Disabled. Also add .#{$ns}-disabled to .#{$ns}-control to change text color (not shown below). - .#{$ns}-align-right - Right-aligned indicator - .#{$ns}-large - Large - - Styleguide radio - */ - &.#{$ns}-radio { - .#{$ns}-control-indicator { - border: 2px solid #cecece; - background-color: #fff; - - &::before { - height: 14px; - width: 14px; - } - } - - input:checked~.#{$ns}-control-indicator { - border-color: $form-check-input-checked-bg-color; - - &::before { - background-image: radial-gradient($form-check-input-checked-bg-color 40%, - transparent 40%); - } - } - - input:checked:disabled~.#{$ns}-control-indicator::before { - opacity: 0.5; - } - - input:focus~.#{$ns}-control-indicator { - -moz-outline-radius: $control-indicator-size; - } - } -} - .bp4-menu-item::before, .bp4-menu-item>.bp4-icon { color: #4b5d6b;