fix: improvements to bank matching transactions

This commit is contained in:
Ahmed Bouhuolia
2024-07-06 19:10:07 +02:00
parent cd9039fe16
commit b7487f19d3
18 changed files with 188 additions and 233 deletions

View File

@@ -110,6 +110,7 @@ import { ValidateMatchingOnPaymentMadeDelete } from '@/services/Banking/Matching
import { ValidateMatchingOnCashflowDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete'; import { ValidateMatchingOnCashflowDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete';
import { RecognizeSyncedBankTranasctions } from '@/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions'; import { RecognizeSyncedBankTranasctions } from '@/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions';
import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule'; import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule';
import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch';
export default () => { export default () => {
return new EventPublisher(); return new EventPublisher();
@@ -258,6 +259,7 @@ export const susbcribers = () => {
// Bank Rules // Bank Rules
TriggerRecognizedTransactions, TriggerRecognizedTransactions,
UnlinkBankRuleOnDeleteBankRule, UnlinkBankRuleOnDeleteBankRule,
DecrementUncategorizedTransactionOnMatching,
// Validate matching // Validate matching
ValidateMatchingOnCashflowDelete, ValidateMatchingOnCashflowDelete,

View File

@@ -105,8 +105,34 @@ export default class UncategorizedCashflowTransaction extends mixin(
* Filters the excluded transactions. * Filters the excluded transactions.
*/ */
excluded(query) { 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');
},
}; };
} }

View File

@@ -1,6 +1,6 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Server } from 'socket.io';
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service() @Service()
export class GetBankAccountSummary { export class GetBankAccountSummary {
@@ -14,22 +14,41 @@ export class GetBankAccountSummary {
* @returns * @returns
*/ */
public async getBankAccountSummary(tenantId: number, bankAccountId: number) { public async getBankAccountSummary(tenantId: number, bankAccountId: number) {
const knex = this.tenancy.knex(tenantId);
const { const {
Account, Account,
UncategorizedCashflowTransaction, UncategorizedCashflowTransaction,
RecognizedBankTransaction, RecognizedBankTransaction,
} = this.tenancy.models(tenantId); } = this.tenancy.models(tenantId);
await initialize(knex, [
UncategorizedCashflowTransaction,
RecognizedBankTransaction,
]);
const bankAccount = await Account.query() const bankAccount = await Account.query()
.findById(bankAccountId) .findById(bankAccountId)
.throwIfNotFound(); .throwIfNotFound();
// Retrieves the uncategorized transactions count of the given bank account. // Retrieves the uncategorized transactions count of the given bank account.
const uncategorizedTranasctionsCount = const uncategorizedTranasctionsCount =
await UncategorizedCashflowTransaction.query() await UncategorizedCashflowTransaction.query().onBuild((q) => {
.where('accountId', bankAccountId) // Include just the given account.
.count('id as total') q.where('accountId', bankAccountId);
.first();
// 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. // Retrieves the recognized transactions count of the given bank account.
const recognizedTransactionsCount = await RecognizedBankTransaction.query() const recognizedTransactionsCount = await RecognizedBankTransaction.query()
@@ -43,8 +62,8 @@ export class GetBankAccountSummary {
.first(); .first();
const totalUncategorizedTransactions = const totalUncategorizedTransactions =
uncategorizedTranasctionsCount?.total; uncategorizedTranasctionsCount?.total || 0;
const totalRecognizedTransactions = recognizedTransactionsCount?.total; const totalRecognizedTransactions = recognizedTransactionsCount?.total || 0;
return { return {
name: bankAccount.name, name: bankAccount.name,

View File

@@ -31,6 +31,7 @@ export class UnmatchMatchedBankTransaction {
return this.uow.withTransaction(tenantId, async (trx) => { return this.uow.withTransaction(tenantId, async (trx) => {
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, { await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, {
tenantId, tenantId,
uncategorizedTransactionId,
trx, trx,
} as IBankTransactionUnmatchingEventPayload); } as IBankTransactionUnmatchingEventPayload);
@@ -40,6 +41,7 @@ export class UnmatchMatchedBankTransaction {
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, { await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, {
tenantId, tenantId,
uncategorizedTransactionId,
trx, trx,
} as IBankTransactionUnmatchingEventPayload); } as IBankTransactionUnmatchingEventPayload);
}); });

View File

@@ -1,6 +1,7 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { ServiceError } from '@/exceptions'; import { ServiceError } from '@/exceptions';
import HasTenancyService from '@/services/Tenancy/TenancyService'; import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { ERRORS } from './types'; import { ERRORS } from './types';
@Service() @Service()
@@ -18,12 +19,13 @@ export class ValidateTransactionMatched {
public async validateTransactionNoMatchLinking( public async validateTransactionNoMatchLinking(
tenantId: number, tenantId: number,
referenceType: string, referenceType: string,
referenceId: number referenceId: number,
trx?: Knex.Transaction
) { ) {
const { MatchedBankTransaction } = this.tenancy.models(tenantId); const { MatchedBankTransaction } = this.tenancy.models(tenantId);
const foundMatchedTransaction = const foundMatchedTransaction =
await MatchedBankTransaction.query().findOne({ await MatchedBankTransaction.query(trx).findOne({
referenceType, referenceType,
referenceId, referenceId,
}); });

View File

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

View File

@@ -1,5 +1,5 @@
import { Inject, Service } from 'typedi'; import { Inject, Service } from 'typedi';
import { IManualJournalDeletingPayload } from '@/interfaces'; import { ICommandCashflowDeletingPayload, IManualJournalDeletingPayload } from '@/interfaces';
import events from '@/subscribers/events'; import events from '@/subscribers/events';
import { ValidateTransactionMatched } from '../ValidateTransactionsMatched'; import { ValidateTransactionMatched } from '../ValidateTransactionsMatched';
@@ -24,13 +24,14 @@ export class ValidateMatchingOnCashflowDelete {
*/ */
public async validateMatchingOnCashflowDeleting({ public async validateMatchingOnCashflowDeleting({
tenantId, tenantId,
oldManualJournal, oldCashflowTransaction,
trx, trx,
}: IManualJournalDeletingPayload) { }: ICommandCashflowDeletingPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId, tenantId,
'ManualJournal', 'CashflowTransaction',
oldManualJournal.id oldCashflowTransaction.id,
trx
); );
} }
} }

View File

@@ -30,7 +30,8 @@ export class ValidateMatchingOnExpenseDelete {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId, tenantId,
'Expense', 'Expense',
oldExpense.id oldExpense.id,
trx
); );
} }
} }

View File

@@ -30,7 +30,8 @@ export class ValidateMatchingOnManualJournalDelete {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId, tenantId,
'ManualJournal', 'ManualJournal',
oldManualJournal.id oldManualJournal.id,
trx
); );
} }
} }

View File

@@ -33,7 +33,8 @@ export class ValidateMatchingOnPaymentMadeDelete {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId, tenantId,
'PaymentMade', 'PaymentMade',
oldBillPayment.id oldBillPayment.id,
trx
); );
} }
} }

View File

@@ -30,7 +30,8 @@ export class ValidateMatchingOnPaymentReceivedDelete {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId, tenantId,
'PaymentReceive', 'PaymentReceive',
oldPaymentReceive.id oldPaymentReceive.id,
trx
); );
} }
} }

View File

@@ -16,10 +16,14 @@ export interface IBankTransactionMatchedEventPayload {
export interface IBankTransactionUnmatchingEventPayload { export interface IBankTransactionUnmatchingEventPayload {
tenantId: number; tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction;
} }
export interface IBankTransactionUnmatchedEventPayload { export interface IBankTransactionUnmatchedEventPayload {
tenantId: number; tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction;
} }
export interface IMatchTransactionDTO { export interface IMatchTransactionDTO {

View File

@@ -69,6 +69,14 @@ function AccountDeleteTransactionAlert({
'Cannot delete transaction converted from uncategorized transaction but you uncategorize it.', 'Cannot delete transaction converted from uncategorized transaction but you uncategorize it.',
intent: Intent.DANGER, 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,
});
} }
}, },
) )

View File

@@ -15,7 +15,6 @@
color: rgb(21, 82, 200), color: rgb(21, 82, 200),
} }
} }
&:hover:not(.active){ &:hover:not(.active){
border-color: #c0c0c0; border-color: #c0c0c0;
} }
@@ -25,7 +24,7 @@
margin: 0; margin: 0;
} }
.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{ .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{ .checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{
margin-right: 4px; margin-right: 4px;
@@ -34,10 +33,18 @@
width: 16px; width: 16px;
} }
.checkbox:global(.bp4-control.bp4-checkbox) :global input:checked ~ .bp4-control-indicator{
box-shadow: 0 0 0 1px #0069ff;
}
.label { .label {
color: #10161A; color: #252A33;
font-size: 15px; font-size: 15px;
} }
.label :global strong {
font-weight: 500;
font-variant-numeric:tabular-nums;
}
.date { .date {
font-size: 12px; font-size: 12px;

View File

@@ -9,7 +9,7 @@ export interface MatchTransactionCheckboxProps {
active?: boolean; active?: boolean;
initialActive?: boolean; initialActive?: boolean;
onChange?: (state: boolean) => void; onChange?: (state: boolean) => void;
label: string; label: string | React.ReactNode;
date: string; date: string;
} }
@@ -43,7 +43,7 @@ export function MatchTransactionCheckbox({
position="apart" position="apart"
onClick={handleClick} onClick={handleClick}
> >
<Stack spacing={3}> <Stack spacing={2}>
<span className={styles.label}>{label}</span> <span className={styles.label}>{label}</span>
<Text className={styles.date}>Date: {date}</Text> <Text className={styles.date}>Date: {date}</Text>
</Stack> </Stack>

View File

@@ -237,9 +237,6 @@ function PossibleMatchingTransactions() {
<Box className={styles.matchBar}> <Box className={styles.matchBar}>
<Stack spacing={2}> <Stack spacing={2}>
<h2 className={styles.matchBarTitle}>Possible Matches</h2> <h2 className={styles.matchBarTitle}>Possible Matches</h2>
<Text style={{ fontSize: 12, color: '#5C7080' }}>
Transactions up to 20 Aug 2019
</Text>
</Stack> </Stack>
</Box> </Box>
@@ -247,7 +244,12 @@ function PossibleMatchingTransactions() {
{possibleMatches.map((match, index) => ( {possibleMatches.map((match, index) => (
<MatchTransactionField <MatchTransactionField
key={index} key={index}
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`} label={
<>
{`${match.transsactionTypeFormatted} for `}
<strong>{match.amountFormatted}</strong>
</>
}
date={match.dateFormatted} date={match.dateFormatted}
transactionId={match.referenceId} transactionId={match.referenceId}
transactionType={match.referenceType} transactionType={match.referenceType}
@@ -329,7 +331,7 @@ const MatchTransactionFooter = R.compose(withBankingActions)(
</AnchorButton> </AnchorButton>
)} )}
<Text <Text
style={{ fontSize: 14, marginLeft: 'auto', color: '#5F6B7C' }} style={{ fontSize: 14, marginLeft: 'auto', color: '#404854' }}
tagName="span" tagName="span"
> >
Pending <FormatNumber value={totalPending} currency={'USD'} /> Pending <FormatNumber value={totalPending} currency={'USD'} />
@@ -343,8 +345,8 @@ const MatchTransactionFooter = R.compose(withBankingActions)(
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
style={{ minWidth: 85 }} style={{ minWidth: 85 }}
onClick={handleSubmitBtnClick} onClick={handleSubmitBtnClick}
// loading={isSubmitting} loading={isSubmitting}
// disabled={submitDisabled} disabled={submitDisabled}
> >
Match Match
</Button> </Button>

View File

@@ -50,3 +50,9 @@ $form-check-input-indeterminate-bg-image: url("data:image/svg+xml,<svg xmlns='ht
// z-indexs // z-indexs
$zindex-dashboard-splash-screen: 39; $zindex-dashboard-splash-screen: 39;
$zindex-toast: 40; $zindex-toast: 40;
// Controls
$control-checked-background-color: #0069ff !default;
$control-checked-background-color-hover: #0069ff !default;
$control-checked-background-color-active: #0069ff !default;
$control-box-shadow: inset 0 0 0 1px #666 !default;

View File

@@ -271,203 +271,6 @@ label.bp4-label {
.select-list--tooltip-items .bp4-popover-target { .select-list--tooltip-items .bp4-popover-target {
display: block; display: block;
} }
@mixin control-checked-colors($selector: ':checked') {
input#{$selector}~.#{$ns}-control-indicator {
box-shadow: none;
background-color: $control-checked-background-color;
color: $white;
}
&:hover input#{$selector}~.#{$ns}-control-indicator {
box-shadow: none;
background-color: $control-checked-background-color-hover;
border-color: $control-checked-background-color-active;
}
input:not(:disabled):active#{$selector}~.#{$ns}-control-indicator {
box-shadow: none;
background-color: $control-checked-background-color-active;
border-color: $control-checked-background-color-active;
}
input:disabled#{$selector}~.#{$ns}-control-indicator {
box-shadow: none;
background: rgba($control-checked-background-color, 0.5);
}
}
///@extend
.#{$ns}-control {
input:checked~.#{$ns}-control-indicator {
box-shadow: none;
background-color: transparent;
background-image: none;
}
&:hover input:checked~.#{$ns}-control-indicator {
box-shadow: none;
background-color: transparent;
}
input:not(:disabled):active:checked~.#{$ns}-control-indicator {
box-shadow: none;
background: transparent;
}
input:disabled:checked~.#{$ns}-control-indicator {
box-shadow: none;
background: transparent;
}
&.#{$ns}-disabled {
cursor: not-allowed;
color: $pt-text-color-disabled;
}
&.#{$ns}-inline {
display: inline-block;
margin-right: $pt-grid-size * 2;
}
.#{$ns}-control-indicator {
box-shadow: 0 0 0 transparent;
background-clip: padding-box;
background-color: transparent;
background-image: none;
width: 18px;
height: 18px;
&::before {
width: 18px;
height: 18px;
}
}
&.bp4-large .#{$ns}-control-indicator {
width: 20px;
height: 20px;
&::before {
width: 20px;
height: 20px;
}
}
&:hover .#{$ns}-control-indicator {
background-color: transparent;
}
input:not(:disabled):active~.#{$ns}-control-indicator {
box-shadow: 0 0 0 transparent;
background: transparent;
}
/*
Checkbox
Markup:
<label class="#{$ns}-control #{$ns}-checkbox {{.modifier}}">
<input type="checkbox" {{:modifier}} />
<span class="#{$ns}-control-indicator"></span>
Checkbox
</label>
:checked - Checked
:disabled - Disabled. Also add <code>.#{$ns}-disabled</code> to <code>.#{$ns}-control</code> to change text color (not shown below).
:indeterminate - Indeterminate. Note that this style can only be achieved via JavaScript
<code>input.indeterminate = true</code>.
.#{$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:
<label class="#{$ns}-control #{$ns}-radio {{.modifier}}">
<input type="radio" name="docs-radio-regular" {{:modifier}} />
<span class="#{$ns}-control-indicator"></span>
Radio
</label>
:checked - Selected
:disabled - Disabled. Also add <code>.#{$ns}-disabled</code> to <code>.#{$ns}-control</code> 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::before,
.bp4-menu-item>.bp4-icon { .bp4-menu-item>.bp4-icon {
color: #4b5d6b; color: #4b5d6b;