From b7487f19d35feefa5cfc41b82047d71fed8a7e3b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 6 Jul 2024 19:10:07 +0200 Subject: [PATCH] fix: improvements to bank matching transactions --- packages/server/src/loaders/eventEmitter.ts | 4 +- .../UncategorizedCashflowTransaction.ts | 30 ++- .../BankAccounts/GetBankAccountSummary.ts | 35 +++- .../Matching/UnmatchMatchedTransaction.ts | 2 + .../Matching/ValidateTransactionsMatched.ts | 8 +- ...crementUncategorizedTransactionsOnMatch.ts | 69 ++++++ .../ValidateMatchingOnCashflowDelete.ts | 11 +- .../events/ValidateMatchingOnExpenseDelete.ts | 3 +- .../ValidateMatchingOnManualJournalDelete.ts | 3 +- .../ValidateMatchingOnPaymentMadeDelete.ts | 3 +- ...ValidateMatchingOnPaymentReceivedDelete.ts | 3 +- .../src/services/Banking/Matching/types.ts | 4 + .../AccountDeleteTransactionAlert.tsx | 8 + .../MatchTransactionCheckbox.module.scss | 15 +- .../MatchTransactionCheckbox.tsx | 4 +- .../MatchingTransaction.tsx | 16 +- packages/webapp/src/style/_variables.scss | 6 + packages/webapp/src/style/objects/form.scss | 197 ------------------ 18 files changed, 188 insertions(+), 233 deletions(-) create mode 100644 packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 6a282a8f7..01c43e6c8 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -110,6 +110,7 @@ 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'; export default () => { return new EventPublisher(); @@ -258,6 +259,7 @@ export const susbcribers = () => { // Bank Rules TriggerRecognizedTransactions, UnlinkBankRuleOnDeleteBankRule, + DecrementUncategorizedTransactionOnMatching, // Validate matching ValidateMatchingOnCashflowDelete, @@ -266,7 +268,7 @@ export const susbcribers = () => { ValidateMatchingOnPaymentReceivedDelete, ValidateMatchingOnPaymentMadeDelete, - // Plaid + // Plaid RecognizeSyncedBankTranasctions, ]; }; diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 9318ee978..2418b1711 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/Banking/BankAccounts/GetBankAccountSummary.ts b/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts index 6a07e614a..569222576 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,41 @@ export class GetBankAccountSummary { * @returns */ public async getBankAccountSummary(tenantId: number, bankAccountId: number) { + const knex = this.tenancy.knex(tenantId); const { Account, UncategorizedCashflowTransaction, RecognizedBankTransaction, } = this.tenancy.models(tenantId); + await initialize(knex, [ + UncategorizedCashflowTransaction, + RecognizedBankTransaction, + ]); 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 +62,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/Matching/UnmatchMatchedTransaction.ts b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts index 0f4a1def7..e51fc7cbd 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 e6e5a2cd7..5938c9820 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/events/DecrementUncategorizedTransactionsOnMatch.ts b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts new file mode 100644 index 000000000..d4409962c --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts @@ -0,0 +1,69 @@ +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, + matchTransactionsDTO, + 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) + .decrement('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 c6087b9e8..3e205c4ba 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 38c2dcba8..eb5fda3b4 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 90078bfdc..61132db86 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 0ce97cdb9..c57c28729 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 20c3018ac..446f3d5f1 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 5e60f88f9..d415b6478 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/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx b/packages/webapp/src/containers/Alerts/CashFlow/AccountDeleteTransactionAlert.tsx index e50038a66..5b8350780 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/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss index 94b4613f5..bdf743164 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 29fcd294c..62d3b344d 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/MatchingTransaction.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx index 8a3c37329..308fe36b7 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx @@ -237,9 +237,6 @@ function PossibleMatchingTransactions() {

Possible Matches

- - Transactions up to 20 Aug 2019 -
@@ -247,7 +244,12 @@ function PossibleMatchingTransactions() { {possibleMatches.map((match, index) => ( + {`${match.transsactionTypeFormatted} for `} + {match.amountFormatted} + + } date={match.dateFormatted} transactionId={match.referenceId} transactionType={match.referenceType} @@ -329,7 +331,7 @@ const MatchTransactionFooter = R.compose(withBankingActions)( )} Pending @@ -343,8 +345,8 @@ const MatchTransactionFooter = R.compose(withBankingActions)( intent={Intent.PRIMARY} style={{ minWidth: 85 }} onClick={handleSubmitBtnClick} - // loading={isSubmitting} - // disabled={submitDisabled} + loading={isSubmitting} + disabled={submitDisabled} > Match diff --git a/packages/webapp/src/style/_variables.scss b/packages/webapp/src/style/_variables.scss index 777633b51..55e071a6f 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;