Compare commits

..

17 Commits

Author SHA1 Message Date
Ahmed Bouhuolia
249eadaeaa Merge pull request #525 from bigcapitalhq/fix-plaid-transactions-syncing
fix: Plaid transactions syncing
2024-07-12 23:44:27 +02:00
Ahmed Bouhuolia
59168bc691 fix: Plaid transactions syncing 2024-07-12 23:43:20 +02:00
Ahmed Bouhuolia
81b26c6f13 fix(hotfix): uniqid import 2024-07-12 20:15:28 +02:00
Ahmed Bouhuolia
da435d85d9 Merge pull request #524 from bigcapitalhq/fix-cashflow-transactions-type
fix: Cashflow transactions types
2024-07-09 14:57:43 +02:00
Ahmed Bouhuolia
533006b90e fix: Cashflow transactions types 2024-07-09 14:47:30 +02:00
Ahmed Bouhuolia
d096e49d45 Merge pull request #523 from bigcapitalhq/matching-transactions-fixes
fix: Matching transactions bugs
2024-07-08 22:18:12 +02:00
Ahmed Bouhuolia
73acdb6240 fix: add bank rule categories 2024-07-08 21:48:16 +02:00
Ahmed Bouhuolia
38d4122d11 fix: matching transactions bugs 2024-07-08 19:37:11 +02:00
Ahmed Bouhuolia
24a77c81b3 fix: unexpected char in cashflow transactions report 2024-07-08 15:25:28 +02:00
Ahmed Bouhuolia
7f41b4280e fix: the database migration schema 2024-07-08 15:18:58 +02:00
Ahmed Bouhuolia
aa89653967 Merge pull request #522 from bigcapitalhq/reconcile-match-transactionss
Reconcile match transactionss
2024-07-07 23:52:30 +02:00
Ahmed Bouhuolia
b80bc95fa5 fix: increment/decrement uncategorized transactions on excluding 2024-07-07 23:35:26 +02:00
Ahmed Bouhuolia
9a5befbee7 fix: bank transactions report 2024-07-07 22:11:57 +02:00
Ahmed Bouhuolia
b7487f19d3 fix: improvements to bank matching transactions 2024-07-06 19:10:07 +02:00
Ahmed Bouhuolia
cd9039fe16 fix(server): match transactions query 2024-07-06 16:10:34 +02:00
Ahmed Bouhuolia
87f60f7461 feat: cashflow transaction matching 2024-07-04 22:44:20 +02:00
Ahmed Bouhuolia
202179ec0b feat: reconcile matching transactions 2024-07-04 19:21:05 +02:00
96 changed files with 2097 additions and 698 deletions

View File

@@ -38,15 +38,7 @@ export class BankingRulesController extends BaseController {
body('conditions.*.value').exists(),
// Assign
body('assign_category')
.isString()
.isIn([
'interest_income',
'other_income',
'deposit',
'expense',
'owner_drawings',
]),
body('assign_category').isString(),
body('assign_account_id').isInt({ min: 0 }),
body('assign_payee').isString().optional({ nullable: true }),
body('assign_memo').isString().optional({ nullable: true }),

View File

@@ -1,7 +1,16 @@
export const CashflowTransactionTypes = {
OtherIncome: 'Other income',
OtherExpense: 'Other expense',
OwnerDrawing: 'Owner drawing',
OwnerContribution: 'Owner contribution',
TransferToAccount: 'Transfer to account',
TransferFromAccount: 'Transfer from account',
};
export const TransactionTypes = {
SaleInvoice: 'Sale invoice',
SaleReceipt: 'Sale receipt',
PaymentReceive: 'Payment receive',
PaymentReceive: 'Payment received',
Bill: 'Bill',
BillPayment: 'Payment made',
VendorOpeningBalance: 'Vendor opening balance',
@@ -17,12 +26,10 @@ export const TransactionTypes = {
OtherExpense: 'Other expense',
OwnerDrawing: 'Owner drawing',
InvoiceWriteOff: 'Invoice write-off',
CreditNote: 'transaction_type.credit_note',
VendorCredit: 'transaction_type.vendor_credit',
RefundCreditNote: 'transaction_type.refund_credit_note',
RefundVendorCredit: 'transaction_type.refund_vendor_credit',
LandedCost: 'transaction_type.landed_cost',
CashflowTransaction: CashflowTransactionTypes,
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
exports.up = function (knex) {
return knex('accounts_transactions')
.whereIn('referenceType', [
'OtherIncome',
'OtherExpense',
'OwnerDrawing',
'OwnerContribution',
'TransferToAccount',
'TransferFromAccount',
])
.update({
transactionType: knex.raw(`
CASE
WHEN REFERENCE_TYPE = 'OtherIncome' THEN 'OtherIncome'
WHEN REFERENCE_TYPE = 'OtherExpense' THEN 'OtherExpense'
WHEN REFERENCE_TYPE = 'OwnerDrawing' THEN 'OwnerDrawing'
WHEN REFERENCE_TYPE = 'OwnerContribution' THEN 'OwnerContribution'
WHEN REFERENCE_TYPE = 'TransferToAccount' THEN 'TransferToAccount'
WHEN REFERENCE_TYPE = 'TransferFromAccount' THEN 'TransferFromAccount'
END
`),
referenceType: knex.raw(`
CASE
WHEN REFERENCE_TYPE IN ('OtherIncome', 'OtherExpense', 'OwnerDrawing', 'OwnerContribution', 'TransferToAccount', 'TransferFromAccount') THEN 'CashflowTransaction'
ELSE REFERENCE_TYPE
END
`),
});
};
exports.down = function (knex) {
return knex('accounts_transactions')
.whereIn('transactionType', [
'OtherIncome',
'OtherExpense',
'OwnerDrawing',
'OwnerContribution',
'TransferToAccount',
'TransferFromAccount',
])
.update({
referenceType: knex.raw(`
CASE
WHEN TRANSACTION_TYPE = 'OtherIncome' THEN 'OtherIncome'
WHEN TRANSACTION_TYPE = 'OtherExpense' THEN 'OtherExpense'
WHEN TRANSACTION_TYPE = 'OwnerDrawing' THEN 'OwnerDrawing'
WHEN TRANSACTION_TYPE = 'OwnerContribution' THEN 'OwnerContribution'
WHEN TRANSACTION_TYPE = 'TransferToAccount' THEN 'TransferToAccount'
WHEN TRANSACTION_TYPE = 'TransferFromAccount' THEN 'TransferFromAccount'
ELSE REFERENCE_TYPE
END
`),
transactionType: knex.raw(`
CASE
WHEN TRANSACTION_TYPE IN ('OtherIncome', 'OtherExpense', 'OwnerDrawing', 'OwnerContribution', 'TransferToAccount', 'TransferFromAccount') THEN NULL
ELSE TRANSACTION_TYPE
END
`),
});
};

View File

@@ -66,7 +66,9 @@ export interface IAccountTransaction {
referenceId: number;
referenceNumber?: string;
transactionNumber?: string;
transactionType?: string;
note?: string;

View File

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

View File

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

View File

@@ -29,4 +29,9 @@ export interface ICashflowAccountTransaction {
date: Date;
formattedDate: string;
status: string;
formattedStatus: string;
uncategorizedTransactionId: number;
}

View File

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

View File

@@ -40,6 +40,8 @@ export interface ILedgerEntry {
date: Date | string;
transactionType: string;
transactionSubType: string;
transactionId: number;
transactionNumber?: string;

View File

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

View File

@@ -10,6 +10,7 @@ export default class AccountTransaction extends TenantModel {
debit: number;
exchangeRate: number;
taxRate: number;
transactionType: string;
/**
* Table name
@@ -53,7 +54,7 @@ export default class AccountTransaction extends TenantModel {
* @return {string}
*/
get referenceTypeFormatted() {
return getTransactionTypeLabel(this.referenceType);
return getTransactionTypeLabel(this.referenceType, this.transactionType);
}
/**

View File

@@ -5,9 +5,9 @@ import {
getCashflowAccountTransactionsTypes,
getCashflowTransactionType,
} from '@/services/Cashflow/utils';
import AccountTransaction from './AccountTransaction';
import { CASHFLOW_DIRECTION } from '@/services/Cashflow/constants';
import { getTransactionTypeLabel } from '@/utils/transactions-types';
import { getCashflowTransactionFormattedType } from '@/utils/transactions-types';
export default class CashflowTransaction extends TenantModel {
transactionType: string;
amount: number;
@@ -64,7 +64,7 @@ export default class CashflowTransaction extends TenantModel {
* @returns {string}
*/
get transactionTypeFormatted() {
return getTransactionTypeLabel(this.transactionType);
return getCashflowTransactionFormattedType(this.transactionType);
}
get typeMeta() {
@@ -95,6 +95,34 @@ export default class CashflowTransaction extends TenantModel {
return !!this.uncategorizedTransaction;
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
/**
* Filter the published transactions.
*/
published(query) {
query.whereNot('published_at', null);
},
/**
* Filter the not categorized transactions.
*/
notCategorized(query) {
query.whereNull('cashflowTransactions.uncategorizedTransactionId');
},
/**
* Filter the categorized transactions.
*/
categorized(query) {
query.whereNotNull('cashflowTransactions.uncategorizedTransactionId');
},
};
}
/**
* Relationship mapping.
*/
@@ -131,8 +159,7 @@ export default class CashflowTransaction extends TenantModel {
to: 'accounts_transactions.referenceId',
},
filter(builder) {
const referenceTypes = getCashflowAccountTransactionsTypes();
builder.whereIn('reference_type', referenceTypes);
builder.where('reference_type', 'CashflowTransaction');
},
},

View File

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

View File

@@ -19,6 +19,8 @@ export const transformLedgerEntryToTransaction = (
referenceId: entry.transactionId,
transactionNumber: entry.transactionNumber,
transactionType: entry.transactionSubType,
referenceNumber: entry.referenceNumber,
note: entry.note,

View File

@@ -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,

View File

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

View File

@@ -2,6 +2,12 @@ import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { Inject, Service } from 'typedi';
import { validateTransactionNotCategorized } from './utils';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
IBankTransactionExcludedEventPayload,
IBankTransactionExcludingEventPayload,
} from './_types';
@Service()
export class UnexcludeBankTransaction {
@@ -11,6 +17,9 @@ export class UnexcludeBankTransaction {
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Marks the given bank transaction as excluded.
* @param {number} tenantId
@@ -20,7 +29,7 @@ export class UnexcludeBankTransaction {
public async unexcludeBankTransaction(
tenantId: number,
uncategorizedTransactionId: number
) {
): Promise<void> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const oldUncategorizedTransaction =
@@ -31,11 +40,27 @@ export class UnexcludeBankTransaction {
validateTransactionNotCategorized(oldUncategorizedTransaction);
return this.uow.withTransaction(tenantId, async (trx) => {
await this.eventPublisher.emitAsync(
events.bankTransactions.onUnexcluding,
{
tenantId,
uncategorizedTransactionId,
} as IBankTransactionExcludingEventPayload
);
await UncategorizedCashflowTransaction.query(trx)
.findById(uncategorizedTransactionId)
.patch({
excludedAt: null,
});
await this.eventPublisher.emitAsync(
events.bankTransactions.onUnexcluded,
{
tenantId,
uncategorizedTransactionId,
} as IBankTransactionExcludedEventPayload
);
});
}
}

View File

@@ -1,6 +1,30 @@
import { Knex } from "knex";
export interface ExcludedBankTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}
}
export interface IBankTransactionUnexcludingEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction
}
export interface IBankTransactionUnexcludedEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction
}
export interface IBankTransactionExcludingEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction
}
export interface IBankTransactionExcludedEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
trx?: Knex.Transaction
}

View File

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

View File

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

View File

@@ -0,0 +1,142 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetMatchedTransactionCashflowTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
'transactionNormal',
'referenceId',
'referenceType',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieve the invoice reference number.
* @returns {string}
*/
protected referenceNo(invoice) {
return invoice.referenceNo;
}
/**
* Retrieve the transaction amount.
* @param transaction
* @returns {number}
*/
protected amount(transaction) {
return transaction.amount;
}
/**
* Retrieve the transaction formatted amount.
* @param transaction
* @returns {string}
*/
protected amountFormatted(transaction) {
return this.formatNumber(transaction.amount, {
currencyCode: transaction.currencyCode,
money: true,
});
}
/**
* Retrieve the date of the invoice.
* @param invoice
* @returns {Date}
*/
protected date(transaction) {
return transaction.date;
}
/**
* Format the date of the invoice.
* @param invoice
* @returns {string}
*/
protected dateFormatted(transaction) {
return this.formatDate(transaction.date);
}
/**
* Retrieve the transaction ID of the invoice.
* @param invoice
* @returns {number}
*/
protected transactionId(transaction) {
return transaction.id;
}
/**
* Retrieve the invoice transaction number.
* @param invoice
* @returns {string}
*/
protected transactionNo(transaction) {
return transaction.transactionNumber;
}
/**
* Retrieve the invoice transaction type.
* @param invoice
* @returns {String}
*/
protected transactionType(transaction) {
return transaction.transactionType;
}
/**
* Retrieve the invoice formatted transaction type.
* @param invoice
* @returns {string}
*/
protected transsactionTypeFormatted(transaction) {
return transaction.transactionTypeFormatted;
}
/**
* Retrieve the cashflow transaction normal (credit or debit).
* @param transaction
* @returns {string}
*/
protected transactionNormal(transaction) {
return transaction.isCashCredit ? 'credit' : 'debit';
}
/**
* Retrieves the cashflow transaction reference id.
* @param transaction
* @returns {number}
*/
protected referenceId(transaction) {
return transaction.id;
}
/**
* Retrieves the cashflow transaction reference type.
* @returns {string}
*/
protected referenceType() {
return 'CashflowTransaction';
}
}

View File

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

View File

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

View File

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

View File

@@ -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 },
];
}

View File

@@ -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(

View File

@@ -0,0 +1,85 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer';
import { GetMatchedTransactionsFilter } from './types';
@Service()
export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByType {
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieve the matched transactions of cash flow.
* @param {number} tenantId
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(
tenantId: number,
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>
) {
const { CashflowTransaction, MatchedBankTransaction } =
this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Initialize the ORM models metadata.
await initialize(knex, [CashflowTransaction, MatchedBankTransaction]);
const transactions = await CashflowTransaction.query().onBuild((q) => {
// Not matched to bank transaction.
q.withGraphJoined('matchedBankTransaction');
q.whereNull('matchedBankTransaction.id');
// Not categorized.
q.modify('notCategorized');
// Published.
q.modify('published');
if (filter.fromDate) {
q.where('date', '>=', filter.fromDate);
}
if (filter.toDate) {
q.where('date', '<=', filter.toDate);
}
q.orderBy('date', 'DESC');
});
return this.transformer.transform(
tenantId,
transactions,
new GetMatchedTransactionCashflowTransformer()
);
}
/**
* Retrieves the matched transaction of cash flow.
* @param {number} tenantId
* @param {number} transactionId
* @returns
*/
async getMatchedTransaction(tenantId: number, transactionId: number) {
const { CashflowTransaction, MatchedBankTransaction } =
this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Initialize the ORM models metadata.
await initialize(knex, [CashflowTransaction, MatchedBankTransaction]);
const transactions = await CashflowTransaction.query()
.findById(transactionId)
.withGraphJoined('matchedBankTransaction')
.whereNull('matchedBankTransaction.id')
.modify('notCategorized')
.modify('published')
.throwIfNotFound();
return this.transformer.transform(
tenantId,
transactions,
new GetMatchedTransactionCashflowTransformer()
);
}
}

View File

@@ -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,

View File

@@ -1,3 +1,5 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer';
import {
@@ -6,7 +8,6 @@ import {
MatchedTransactionsPOJO,
} from './types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
@Service()
@@ -27,10 +28,27 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy
tenantId: number,
filter: GetMatchedTransactionsFilter
): Promise<MatchedTransactionsPOJO> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const { SaleInvoice, MatchedBankTransaction } =
this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Initialize the models metadata.
await initialize(knex, [SaleInvoice, MatchedBankTransaction]);
// Retrieve the invoices that not matched, unpaid.
const invoices = await SaleInvoice.query().onBuild((q) => {
q.whereNotExists(SaleInvoice.relatedQuery('matchedBankTransaction'));
q.withGraphJoined('matchedBankTransaction');
q.whereNull('matchedBankTransaction.id');
q.modify('unpaid');
q.modify('published');
if (filter.fromDate) {
q.where('invoiceDate', '>=', filter.fromDate);
}
if (filter.toDate) {
q.where('invoiceDate', '<=', filter.toDate);
}
q.orderBy('invoiceDate', 'DESC');
});
return this.transformer.transform(

View File

@@ -1,4 +1,5 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
@@ -19,12 +20,26 @@ export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactio
tenantId: number,
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>
) {
const { ManualJournal } = this.tenancy.models(tenantId);
const { ManualJournal, ManualJournalEntry, MatchedBankTransaction } =
this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
await initialize(knex, [
ManualJournal,
ManualJournalEntry,
MatchedBankTransaction,
]);
const accountId = 1000;
const manualJournals = await ManualJournal.query().onBuild((query) => {
query.whereNotExists(
ManualJournal.relatedQuery('matchedBankTransaction')
);
query.withGraphJoined('matchedBankTransaction');
query.whereNull('matchedBankTransaction.id');
query.withGraphJoined('entries');
query.where('entries.accountId', accountId);
query.modify('filterByPublished');
if (filter.fromDate) {
query.where('date', '>=', filter.fromDate);
}

View File

@@ -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.

View File

@@ -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,
},
];
}

View File

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

View File

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

View File

@@ -20,3 +20,12 @@ export const sortClosestMatchTransactions = (
),
])(matches);
};
export const sumMatchTranasctions = (transactions: Array<any>) => {
return transactions.reduce(
(total, item) =>
total +
(item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount),
0
);
};

View File

@@ -0,0 +1,66 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import {
IBankTransactionMatchedEventPayload,
IBankTransactionUnmatchedEventPayload,
} from '../types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class DecrementUncategorizedTransactionOnMatching {
@Inject()
private tenancy: HasTenancyService;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.bankMatch.onMatched,
this.decrementUnCategorizedTransactionsOnMatching.bind(this)
);
bus.subscribe(
events.bankMatch.onUnmatched,
this.incrementUnCategorizedTransactionsOnUnmatching.bind(this)
);
}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
public async decrementUnCategorizedTransactionsOnMatching({
tenantId,
uncategorizedTransactionId,
trx,
}: IBankTransactionMatchedEventPayload) {
const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId);
const transaction = await UncategorizedCashflowTransaction.query().findById(
uncategorizedTransactionId
);
await Account.query(trx)
.findById(transaction.accountId)
.decrement('uncategorizedTransactions', 1);
}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
public async incrementUnCategorizedTransactionsOnUnmatching({
tenantId,
uncategorizedTransactionId,
trx,
}: IBankTransactionUnmatchedEventPayload) {
const { UncategorizedCashflowTransaction, Account } =
this.tenancy.models(tenantId);
const transaction = await UncategorizedCashflowTransaction.query().findById(
uncategorizedTransactionId
);
await Account.query(trx)
.findById(transaction.accountId)
.increment('uncategorizedTransactions', 1);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { upperFirst, camelCase } from 'lodash';
import { Transformer } from '@/lib/Transformer/Transformer';
import { getTransactionTypeLabel } from '@/utils/transactions-types';
import { getCashflowTransactionFormattedType } from '@/utils/transactions-types';
export class GetBankRulesTransformer extends Transformer {
/**
@@ -29,8 +28,7 @@ export class GetBankRulesTransformer extends Transformer {
* @returns {string}
*/
protected assignCategoryFormatted(bankRule: any) {
const assignCategory = upperFirst(camelCase(bankRule.assignCategory));
return getTransactionTypeLabel(assignCategory);
return getCashflowTransactionFormattedType(bankRule.assignCategory);
}
/**

View File

@@ -34,11 +34,12 @@ export default class CashflowTransactionJournalEntries {
currencyCode: transaction.currencyCode,
exchangeRate: transaction.exchangeRate,
transactionType: transformCashflowTransactionType(
transaction.transactionType
),
transactionType: 'CashflowTransaction',
transactionId: transaction.id,
transactionNumber: transaction.transactionNumber,
transactionSubType: transformCashflowTransactionType(
transaction.transactionType
),
referenceNumber: transaction.referenceNo,
note: transaction.description,
@@ -161,12 +162,10 @@ export default class CashflowTransactionJournalEntries {
cashflowTransactionId: number,
trx?: Knex.Transaction
): Promise<void> => {
const transactionTypes = getCashflowAccountTransactionsTypes();
await this.ledgerStorage.deleteByReference(
tenantId,
cashflowTransactionId,
transactionTypes,
'CashflowTransaction',
trx
);
};

View File

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

View File

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

View File

@@ -101,6 +101,7 @@ export default class NewCashflowTransactionService {
...fromDTO,
transactionNumber,
currencyCode: cashflowAccount.currencyCode,
exchangeRate: fromDTO?.exchangeRate || 1,
transactionType: transformCashflowTransactionType(
fromDTO.transactionType
),

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,59 @@
import { Service, Inject } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ICashflowAccountTransactionsQuery, IPaginationMeta } from '@/interfaces';
import * as R from 'ramda';
import { ICashflowAccountTransactionsQuery } from '@/interfaces';
import {
groupMatchedBankTransactions,
groupUncategorizedTransactions,
} from './utils';
@Service()
export default class CashflowAccountTransactionsRepo {
@Inject()
private tenancy: HasTenancyService;
export class CashflowAccountTransactionsRepo {
private models: any;
public query: ICashflowAccountTransactionsQuery;
public transactions: any;
public uncategorizedTransactions: any;
public uncategorizedTransactionsMapByRef: Map<string, any>;
public matchedBankTransactions: any;
public matchedBankTransactionsMapByRef: Map<string, any>;
public pagination: any;
public openingBalance: any;
/**
* Constructor method.
* @param {any} models
* @param {ICashflowAccountTransactionsQuery} query
*/
constructor(models: any, query: ICashflowAccountTransactionsQuery) {
this.models = models;
this.query = query;
}
/**
* Async initalize the resources.
*/
async asyncInit() {
await this.initCashflowAccountTransactions();
await this.initCashflowAccountOpeningBalance();
await this.initCategorizedTransactions();
await this.initMatchedTransactions();
}
/**
* Retrieve the cashflow account transactions.
* @param {number} tenantId -
* @param {ICashflowAccountTransactionsQuery} query -
*/
async getCashflowAccountTransactions(
tenantId: number,
query: ICashflowAccountTransactionsQuery
) {
const { AccountTransaction } = this.tenancy.models(tenantId);
async initCashflowAccountTransactions() {
const { AccountTransaction } = this.models;
return AccountTransaction.query()
.where('account_id', query.accountId)
const { results, pagination } = await AccountTransaction.query()
.where('account_id', this.query.accountId)
.orderBy([
{ column: 'date', order: 'desc' },
{ column: 'created_at', order: 'desc' },
])
.pagination(query.page - 1, query.pageSize);
.pagination(this.query.page - 1, this.query.pageSize);
this.transactions = results;
this.pagination = pagination;
}
/**
@@ -34,22 +63,18 @@ export default class CashflowAccountTransactionsRepo {
* @param {IPaginationMeta} pagination
* @return {Promise<number>}
*/
async getCashflowAccountOpeningBalance(
tenantId: number,
accountId: number,
pagination: IPaginationMeta
): Promise<number> {
const { AccountTransaction } = this.tenancy.models(tenantId);
async initCashflowAccountOpeningBalance(): Promise<void> {
const { AccountTransaction } = this.models;
// Retrieve the opening balance of credit and debit balances.
const openingBalancesSubquery = AccountTransaction.query()
.where('account_id', accountId)
.where('account_id', this.query.accountId)
.orderBy([
{ column: 'date', order: 'desc' },
{ column: 'created_at', order: 'desc' },
])
.limit(pagination.total)
.offset(pagination.pageSize * (pagination.page - 1));
.limit(this.pagination.total)
.offset(this.pagination.pageSize * (this.pagination.page - 1));
// Sumation of credit and debit balance.
const openingBalances = await AccountTransaction.query()
@@ -60,6 +85,43 @@ export default class CashflowAccountTransactionsRepo {
const openingBalance = openingBalances.debit - openingBalances.credit;
return openingBalance;
this.openingBalance = openingBalance;
}
/**
* Initialize the uncategorized transactions of the bank account.
*/
async initCategorizedTransactions() {
const { UncategorizedCashflowTransaction } = this.models;
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query().whereIn(
['categorizeRefType', 'categorizeRefId'],
refs
);
this.uncategorizedTransactions = uncategorizedTransactions;
this.uncategorizedTransactionsMapByRef = groupUncategorizedTransactions(
uncategorizedTransactions
);
}
/**
* Initialize the matched bank transactions of the bank account.
*/
async initMatchedTransactions(): Promise<void> {
const { MatchedBankTransaction } = this.models;
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
const matchedBankTransactions =
await MatchedBankTransaction.query().whereIn(
['referenceType', 'referenceId'],
refs
);
this.matchedBankTransactions = matchedBankTransactions;
this.matchedBankTransactionsMapByRef = groupMatchedBankTransactions(
matchedBankTransactions
);
}
}

View File

@@ -1,26 +1,16 @@
import { Service, Inject } from 'typedi';
import { includes } from 'lodash';
import * as qim from 'qim';
import { ICashflowAccountTransactionsQuery, IAccount } from '@/interfaces';
import TenancyService from '@/services/Tenancy/TenancyService';
import FinancialSheet from '../FinancialSheet';
import CashflowAccountTransactionsRepo from './CashflowAccountTransactionsRepo';
import CashflowAccountTransactionsReport from './CashflowAccountTransactions';
import { ACCOUNT_TYPE } from '@/data/AccountTypes';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
import { CashflowAccountTransactionReport } from './CashflowAccountTransactions';
import I18nService from '@/services/I18n/I18nService';
import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo';
@Service()
export default class CashflowAccountTransactionsService extends FinancialSheet {
@Inject()
tenancy: TenancyService;
@Inject()
cashflowTransactionsRepo: CashflowAccountTransactionsRepo;
@Inject()
i18nService: I18nService;
private tenancy: TenancyService;
/**
* Defaults balance sheet filter query.
@@ -50,59 +40,24 @@ export default class CashflowAccountTransactionsService extends FinancialSheet {
tenantId: number,
query: ICashflowAccountTransactionsQuery
) {
const { Account } = this.tenancy.models(tenantId);
const models = this.tenancy.models(tenantId);
const parsedQuery = { ...this.defaultQuery, ...query };
// Retrieve the given account or throw not found service error.
const account = await Account.query().findById(parsedQuery.accountId);
// Validates the cashflow account type.
this.validateCashflowAccountType(account);
// Retrieve the cashflow account transactions.
const { results: transactions, pagination } =
await this.cashflowTransactionsRepo.getCashflowAccountTransactions(
tenantId,
parsedQuery
);
// Retrieve the cashflow account opening balance.
const openingBalance =
await this.cashflowTransactionsRepo.getCashflowAccountOpeningBalance(
tenantId,
parsedQuery.accountId,
pagination
);
// Retrieve the computed report.
const report = new CashflowAccountTransactionsReport(
transactions,
openingBalance,
// Initalize the bank transactions report repository.
const cashflowTransactionsRepo = new CashflowAccountTransactionsRepo(
models,
parsedQuery
);
const reportTranasctions = report.reportData();
await cashflowTransactionsRepo.asyncInit();
return {
transactions: this.i18nService.i18nApply(
[[qim.$each, 'formattedTransactionType']],
reportTranasctions,
tenantId
),
pagination,
};
}
// Retrieve the computed report.
const report = new CashflowAccountTransactionReport(
cashflowTransactionsRepo,
parsedQuery
);
const transactions = report.reportData();
const pagination = cashflowTransactionsRepo.pagination;
/**
* Validates the cashflow account type.
* @param {IAccount} account -
*/
private validateCashflowAccountType(account: IAccount) {
const cashflowTypes = [
ACCOUNT_TYPE.CASH,
ACCOUNT_TYPE.CREDIT_CARD,
ACCOUNT_TYPE.BANK,
];
if (!includes(cashflowTypes, account.accountType)) {
throw new ServiceError(ERRORS.ACCOUNT_ID_HAS_INVALID_TYPE);
}
return { transactions, pagination };
}
}

View File

@@ -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',
}

View File

@@ -0,0 +1,40 @@
import * as R from 'ramda';
export const groupUncategorizedTransactions = (
uncategorizedTransactions: any
): Map<string, any> => {
return new Map(
R.toPairs(
R.groupBy(
(transaction) =>
`${transaction.categorizeRefType}-${transaction.categorizeRefId}`,
uncategorizedTransactions
)
)
);
};
export const groupMatchedBankTransactions = (
uncategorizedTransactions: any
): Map<string, any> => {
return new Map(
R.toPairs(
R.groupBy(
(transaction) =>
`${transaction.referenceType}-${transaction.referenceId}`,
uncategorizedTransactions
)
)
);
};
export const formatBankTransactionsStatus = (status) => {
switch (status) {
case 'categorized':
return 'Categorized';
case 'matched':
return 'Matched';
case 'manual':
return 'Manual';
}
};

View File

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

View File

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

View File

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

View File

@@ -399,6 +399,9 @@ export default {
onTransactionCategorizing: 'onTransactionCategorizing',
onTransactionCategorized: 'onCashflowTransactionCategorized',
onTransactionUncategorizedCreating: 'onTransactionUncategorizedCreating',
onTransactionUncategorizedCreated: 'onTransactionUncategorizedCreated',
onTransactionUncategorizing: 'onTransactionUncategorizing',
onTransactionUncategorized: 'onTransactionUncategorized',
@@ -639,4 +642,17 @@ export default {
onUnmatching: 'onBankTransactionUnmathcing',
onUnmatched: 'onBankTransactionUnmathced',
},
bankTransactions: {
onExcluding: 'onBankTransactionExclude',
onExcluded: 'onBankTransactionExcluded',
onUnexcluding: 'onBankTransactionUnexcluding',
onUnexcluded: 'onBankTransactionUnexcluded',
},
// Import files.
import: {
onImportCommitted: 'onImportFileCommitted',
},
};

View File

@@ -1,5 +1,36 @@
import { TransactionTypes } from '@/data/TransactionTypes';
import { isObject, upperFirst, camelCase } from 'lodash';
import {
TransactionTypes,
CashflowTransactionTypes,
} from '@/data/TransactionTypes';
export const getTransactionTypeLabel = (transactionType: string) => {
return TransactionTypes[transactionType];
/**
* Retrieves the formatted type of account transaction.
* @param {string} referenceType
* @param {string} transactionType
* @returns {string}
*/
export const getTransactionTypeLabel = (
referenceType: string,
transactionType?: string
) => {
const _referenceType = upperFirst(camelCase(referenceType));
const _transactionType = upperFirst(camelCase(transactionType));
return isObject(TransactionTypes[_referenceType])
? TransactionTypes[_referenceType][_transactionType]
: TransactionTypes[_referenceType] || null;
};
/**
* Retrieves the formatted type of cashflow transaction.
* @param {string} transactionType
* @returns {string¿}
*/
export const getCashflowTransactionFormattedType = (
transactionType: string
) => {
const _transactionType = upperFirst(camelCase(transactionType));
return CashflowTransactionTypes[_transactionType] || null;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
// @ts-nocheck
import React from 'react';
import styled from 'styled-components';
import { Intent } from '@blueprintjs/core';
import {
DataTable,
@@ -9,6 +10,7 @@ import {
TableSkeletonHeader,
TableVirtualizedListRows,
FormattedMessage as T,
AppToaster,
} from '@/components';
import { TABLES } from '@/constants/tables';
@@ -19,9 +21,11 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions';
import { useMemorizedColumnsWidths } from '@/hooks';
import { useAccountTransactionsColumns, ActionsMenu } from './components';
import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot';
import { useUnmatchMatchedUncategorizedTransaction } from '@/hooks/query/bank-rules';
import { handleCashFlowTransactionType } from './utils';
import { compose } from '@/utils';
import { useUncategorizeTransaction } from '@/hooks/query';
/**
* Account transactions data table.
@@ -43,14 +47,14 @@ function AccountTransactionsDataTable({
const { cashflowTransactions, isCashFlowTransactionsLoading } =
useAccountTransactionsAllContext();
const { mutateAsync: uncategorizeTransaction } = useUncategorizeTransaction();
const { mutateAsync: unmatchTransaction } =
useUnmatchMatchedUncategorizedTransaction();
// Local storage memorizing columns widths.
const [initialColumnsWidths, , handleColumnResizing] =
useMemorizedColumnsWidths(TABLES.CASHFLOW_Transactions);
// handle delete transaction
const handleDeleteTransaction = ({ reference_id }) => {
openAlert('account-transaction-delete', { referenceId: reference_id });
};
// Handle view details action.
const handleViewDetailCashflowTransaction = (referenceType) => {
handleCashFlowTransactionType(referenceType, openDrawer);
@@ -60,6 +64,38 @@ function AccountTransactionsDataTable({
const referenceType = cell.row.original;
handleCashFlowTransactionType(referenceType, openDrawer);
};
// Handles the unmatching the matched transaction.
const handleUnmatchTransaction = (transaction) => {
unmatchTransaction({ id: transaction.uncategorized_transaction_id })
.then(() => {
AppToaster.show({
message: 'The bank transaction has been unmatched.',
intent: Intent.SUCCESS,
});
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
// Handle uncategorize transaction.
const handleUncategorizeTransaction = (transaction) => {
uncategorizeTransaction(transaction.uncategorized_transaction_id)
.then(() => {
AppToaster.show({
message: 'The bank transaction has been uncategorized.',
intent: Intent.SUCCESS,
});
})
.catch(() => {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
});
};
return (
<CashflowTransactionsTable
@@ -87,7 +123,8 @@ function AccountTransactionsDataTable({
className="table-constrant"
payload={{
onViewDetails: handleViewDetailCashflowTransaction,
onDelete: handleDeleteTransaction,
onUncategorize: handleUncategorizeTransaction,
onUnmatch: handleUnmatchTransaction,
}}
/>
);

View File

@@ -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.

View File

@@ -0,0 +1,25 @@
// @ts-nocheck
import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core';
import { Icon } from '@/components';
import { safeCallback } from '@/utils';
export function ActionsMenu({
payload: { onCategorize, onExclude },
row: { original },
}) {
return (
<Menu>
<MenuItem
icon={<Icon icon="reader-18" />}
text={'Categorize'}
onClick={safeCallback(onCategorize, original)}
/>
<MenuDivider />
<MenuItem
text={'Exclude'}
onClick={safeCallback(onExclude, original)}
icon={<Icon icon="disable" iconSize={16} />}
/>
</Menu>
);
}

View File

@@ -5,44 +5,56 @@ import {
Intent,
Menu,
MenuItem,
MenuDivider,
Tag,
Popover,
PopoverInteractionKind,
Position,
Tooltip,
} from '@blueprintjs/core';
import {
Box,
Can,
FormatDateCell,
Icon,
MaterialProgressBar,
} from '@/components';
import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components';
import { useAccountTransactionsContext } from './AccountTransactionsProvider';
import { safeCallback } from '@/utils';
export function ActionsMenu({
payload: { onCategorize, onExclude },
payload: { onUncategorize, onUnmatch },
row: { original },
}) {
return (
<Menu>
<MenuItem
icon={<Icon icon="reader-18" />}
text={'Categorize'}
onClick={safeCallback(onCategorize, original)}
/>
<MenuDivider />
<MenuItem
text={'Exclude'}
onClick={safeCallback(onExclude, original)}
icon={<Icon icon="disable" iconSize={16} />}
/>
{original.status === 'categorized' && (
<MenuItem
icon={<Icon icon="reader-18" />}
text={'Uncategorize'}
onClick={safeCallback(onUncategorize, original)}
/>
)}
{original.status === 'matched' && (
<MenuItem
text={'Unmatch'}
icon={<Icon icon="unlink" iconSize={16} />}
onClick={safeCallback(onUnmatch, original)}
/>
)}
</Menu>
);
}
const allTransactionsStatusAccessor = (transaction) => {
return (
<Tag
intent={
transaction.status === 'categorized'
? Intent.SUCCESS
: transaction.status === 'matched'
? Intent.SUCCESS
: Intent.NONE
}
minimal={transaction.status === 'manual'}
>
{transaction.formatted_status}
</Tag>
);
};
/**
* Retrieve account transctions table columns.
*/
@@ -70,7 +82,7 @@ export function useAccountTransactionsColumns() {
},
{
id: 'transaction_number',
Header: intl.get('transaction_number'),
Header: 'Transaction #',
accessor: 'transaction_number',
width: 160,
className: 'transaction_number',
@@ -79,13 +91,18 @@ export function useAccountTransactionsColumns() {
},
{
id: 'reference_number',
Header: intl.get('reference_no'),
Header: 'Ref.#',
accessor: 'reference_number',
width: 160,
className: 'reference_number',
clickable: true,
textOverview: true,
},
{
id: 'status',
Header: 'Status',
accessor: allTransactionsStatusAccessor,
},
{
id: 'deposit',
Header: intl.get('cash_flow.label.deposit'),
@@ -116,16 +133,6 @@ export function useAccountTransactionsColumns() {
align: 'right',
clickable: true,
},
{
id: 'balance',
Header: intl.get('balance'),
accessor: 'formatted_balance',
className: 'balance',
width: 150,
textOverview: true,
clickable: true,
align: 'right',
},
],
[],
);
@@ -204,10 +211,9 @@ export function useAccountUncategorizedTransactionsColumns() {
},
{
id: 'reference_number',
Header: intl.get('reference_no'),
accessor: 'reference_number',
Header: 'Ref.#',
accessor: 'reference_no',
width: 50,
className: 'reference_number',
clickable: true,
textOverview: true,
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
// @ts-nocheck
import * as R from 'ramda';
import { Button, Intent, Position, Tag } from '@blueprintjs/core';
import { Form, Formik, FormikValues, useFormikContext } from 'formik';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import moment from 'moment';
import { round } from 'lodash';
import {
AccountsSelect,
AppToaster,
@@ -12,6 +14,7 @@ import {
FInputGroup,
FMoneyInputGroup,
Group,
Icon,
} from '@/components';
import { Aside } from '@/components/Aside/Aside';
import { momentFormatter } from '@/utils';
@@ -25,29 +28,43 @@ import {
import { useCreateCashflowTransaction } from '@/hooks/query';
import { useAccountTransactionsContext } from '../../AccountTransactions/AccountTransactionsProvider';
import { MatchingReconcileFormSchema } from './MatchingReconcileTransactionForm.schema';
import { initialValues } from './_utils';
import { initialValues, transformToReq } from './_utils';
import { withBanking } from '../../withBanking';
interface MatchingReconcileTransactionFormProps {
onSubmitSuccess?: (values: any) => void;
}
function MatchingReconcileTransactionFormRoot({
closeReconcileMatchingTransaction,
}) {
reconcileMatchingTransactionPendingAmount,
// #props¿
onSubmitSuccess,
}: MatchingReconcileTransactionFormProps) {
// Mutation create cashflow transaction.
const { mutateAsync: createCashflowTransactionMutate } =
useCreateCashflowTransaction();
const { accountId } = useAccountTransactionsContext();
// Handles the aside close.
const handleAsideClose = () => {
closeReconcileMatchingTransaction();
};
// Handle the form submitting.
const handleSubmit = (
values: MatchingReconcileTransactionValues,
{ setSubmitting }: FormikValues<MatchingReconcileTransactionValues>,
{
setSubmitting,
setErrors,
}: FormikHelpers<MatchingReconcileTransactionValues>,
) => {
setSubmitting(true);
const _values = transformToReq(values, accountId);
createCashflowTransactionMutate(_values)
.then(() => {
.then((res) => {
setSubmitting(false);
AppToaster.show({
@@ -55,17 +72,36 @@ function MatchingReconcileTransactionFormRoot({
intent: Intent.SUCCESS,
});
closeReconcileMatchingTransaction();
onSubmitSuccess &&
onSubmitSuccess({ id: res.data.id, type: 'CashflowTransaction' });
})
.catch((error) => {
setSubmitting(false);
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
if (
error.response.data?.errors?.find(
(e) => e.type === 'BRANCH_ID_REQUIRED',
)
) {
setErrors({
branchId: 'The branch is required.',
});
} else {
AppToaster.show({
message: 'Something went wrong.',
intent: Intent.DANGER,
});
}
});
};
const _initialValues = {
...initialValues,
amount: round(Math.abs(reconcileMatchingTransactionPendingAmount), 2) || 0,
date: moment().format('YYYY-MM-DD'),
type:
reconcileMatchingTransactionPendingAmount > 0 ? 'deposit' : 'withdrawal',
};
return (
<Aside
title={'Create Reconcile Transactions'}
@@ -75,7 +111,7 @@ function MatchingReconcileTransactionFormRoot({
<MatchingReconcileTransactionBoot>
<Formik
onSubmit={handleSubmit}
initialValues={initialValues}
initialValues={_initialValues}
validationSchema={MatchingReconcileFormSchema}
>
<Form className={styles.form}>
@@ -93,11 +129,136 @@ function MatchingReconcileTransactionFormRoot({
);
}
export const MatchingReconcileTransactionForm = R.compose(withBankingActions)(
MatchingReconcileTransactionFormRoot,
);
export const MatchingReconcileTransactionForm = R.compose(
withBankingActions,
withBanking(({ reconcileMatchingTransactionPendingAmount }) => ({
reconcileMatchingTransactionPendingAmount,
})),
)(MatchingReconcileTransactionFormRoot);
export function MatchingReconcileTransactionFooter() {
function ReconcileMatchingType() {
const { setFieldValue, values } =
useFormikContext<MatchingReconcileFormValues>();
const handleChange = (value: string) => {
setFieldValue('type', value);
setFieldValue('category');
};
return (
<ContentTabs
value={values?.type || 'deposit'}
onChange={handleChange}
small
>
<ContentTabs.Tab id={'deposit'} title={'Deposit'} />
<ContentTabs.Tab id={'withdrawal'} title={'Withdrawal'} />
</ContentTabs>
);
}
function CreateReconcileTransactionContent() {
const { branches } = useMatchingReconcileTransactionBoot();
return (
<Box className={styles.content}>
<ReconcileMatchingType />
<FFormGroup label={'Date'} name={'date'} fastField>
<FDateInput
{...momentFormatter('YYYY/MM/DD')}
name={'date'}
popoverProps={{
minimal: false,
position: Position.LEFT,
modifiers: {
preventOverflow: { enabled: true },
},
boundary: 'viewport',
}}
inputProps={{ fill: true, leftElement: <Icon icon={'date-range'} /> }}
fill
fastField
/>
</FFormGroup>
<FFormGroup
label={'Amount'}
name={'amount'}
labelInfo={<Tag minimal>Required</Tag>}
fastField
>
<FMoneyInputGroup name={'amount'} fastField />
</FFormGroup>
<MatchingReconcileCategoryField />
<FFormGroup
label={'Memo'}
name={'memo'}
labelInfo={<Tag minimal>Required</Tag>}
fastField
>
<FInputGroup name={'memo'} fastField />
</FFormGroup>
<FFormGroup label={'Reference No.'} name={'reference_no'} fastField>
<FInputGroup name={'reference_no'} />
</FFormGroup>
<FFormGroup
name={'branchId'}
label={'Branch'}
labelInfo={<Tag minimal>Required</Tag>}
fastField
>
<BranchSelect
name={'branchId'}
branches={branches}
popoverProps={{
minimal: false,
position: Position.LEFT,
modifiers: {
preventOverflow: { enabled: true },
},
boundary: 'viewport',
}}
fastField
/>
</FFormGroup>
</Box>
);
}
function MatchingReconcileCategoryField() {
const { accounts } = useMatchingReconcileTransactionBoot();
const { values } = useFormikContext();
return (
<FFormGroup
label={'Category'}
name={'category'}
labelInfo={<Tag minimal>Required</Tag>}
fastField
>
<AccountsSelect
name={'category'}
items={accounts}
popoverProps={{
minimal: false,
position: Position.LEFT,
modifiers: {
preventOverflow: { enabled: true },
},
boundary: 'viewport',
}}
filterByRootTypes={values.type === 'deposit' ? 'income' : 'expense'}
fastField
/>
</FFormGroup>
);
}
function MatchingReconcileTransactionFooter() {
const { isSubmitting } = useFormikContext();
return (
@@ -115,85 +276,3 @@ export function MatchingReconcileTransactionFooter() {
</Box>
);
}
function ReconcileMatchingType() {
const { setFieldValue, values } =
useFormikContext<MatchingReconcileFormValues>();
const handleChange = (value: string) => {
setFieldValue('type', value);
};
return (
<ContentTabs
value={values?.type || 'deposit'}
onChange={handleChange}
small
>
<ContentTabs.Tab id={'deposit'} title={'Deposit'} />
<ContentTabs.Tab id={'withdrawal'} title={'Withdrawal'} />
</ContentTabs>
);
}
export function CreateReconcileTransactionContent() {
const { accounts, branches } = useMatchingReconcileTransactionBoot();
return (
<Box className={styles.content}>
<ReconcileMatchingType />
<FFormGroup label={'Date'} name={'date'}>
<FDateInput
{...momentFormatter('YYYY/MM/DD')}
name={'date'}
formatDate={(date) => date.toLocaleString()}
popoverProps={{
position: Position.LEFT,
}}
inputProps={{ fill: true }}
fill
/>
</FFormGroup>
<FFormGroup
label={'Amount'}
name={'amount'}
labelInfo={<Tag minimal>Required</Tag>}
>
<FMoneyInputGroup name={'amount'} />
</FFormGroup>
<FFormGroup
label={'Category'}
name={'category'}
labelInfo={<Tag minimal>Required</Tag>}
>
<AccountsSelect
name={'category'}
items={accounts}
popoverProps={{ minimal: false, position: Position.LEFT }}
/>
</FFormGroup>
<FFormGroup
label={'Memo'}
name={'memo'}
labelInfo={<Tag minimal>Required</Tag>}
>
<FInputGroup name={'memo'} />
</FFormGroup>
<FFormGroup label={'Reference No.'} name={'reference_no'}>
<FInputGroup name={'reference_no'} />
</FFormGroup>
<FFormGroup name={'branchId'} label={'Branch'}>
<BranchSelect
name={'branchId'}
branches={branches}
popoverProps={{ minimal: true }}
/>
</FFormGroup>
</Box>
);
}

View File

@@ -1,6 +1,7 @@
// @ts-nocheck
import { isEmpty } from 'lodash';
import * as R from 'ramda';
import { useEffect, useState } from 'react';
import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core';
import { FastField, FastFieldProps, Formik, useFormikContext } from 'formik';
import { AppToaster, Box, FormatNumber, Group, Stack } from '@/components';
@@ -39,9 +40,6 @@ const initialValues = {
function MatchingBankTransactionRoot({
// #withBankingActions
closeMatchingTransactionAside,
// #withBanking
openReconcileMatchingTransaction,
}) {
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction();
@@ -71,6 +69,18 @@ function MatchingBankTransactionRoot({
closeMatchingTransactionAside();
})
.catch((err) => {
if (
err.response?.data.errors.find(
(e) => e.type === 'TOTAL_MATCHING_TRANSACTIONS_INVALID',
)
) {
AppToaster.show({
message: `The total amount does not equal the uncategorized transaction.`,
intent: Intent.DANGER,
});
return;
}
AppToaster.show({
intent: Intent.DANGER,
message: 'Something went wrong.',
@@ -84,25 +94,86 @@ function MatchingBankTransactionRoot({
uncategorizedTransactionId={uncategorizedTransactionId}
>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<>
<MatchingBankTransactionContent />
{openReconcileMatchingTransaction && (
<MatchingReconcileTransactionForm />
)}
{!openReconcileMatchingTransaction && <MatchTransactionFooter />}
</>
<MatchingBankTransactionFormContent />
</Formik>
</MatchingTransactionBoot>
);
}
export const MatchingBankTransaction = R.compose(
export const MatchingBankTransaction = R.compose(withBankingActions)(
MatchingBankTransactionRoot,
);
/**
* Matching bank transaction form content.
* @returns {React.ReactNode}
*/
const MatchingBankTransactionFormContent = R.compose(
withBankingActions,
withBanking(({ openReconcileMatchingTransaction }) => ({
openReconcileMatchingTransaction,
})),
)(MatchingBankTransactionRoot);
)(
({
// #withBanking
openReconcileMatchingTransaction,
}) => {
const {
isMatchingTransactionsFetching,
isMatchingTransactionsSuccess,
matches,
} = useMatchingTransactionBoot();
const [pending, setPending] = useState<null | {
refId: number;
refType: string;
}>(null);
const { setFieldValue } = useFormikContext();
// This effect is responsible for automatically marking a transaction as matched
// when the matching process is successful and not currently fetching.
useEffect(() => {
if (
pending &&
isMatchingTransactionsSuccess &&
!isMatchingTransactionsFetching
) {
const foundMatch = matches?.find(
(m) =>
m.referenceType === pending?.refType &&
m.referenceId === pending?.refId,
);
if (foundMatch) {
setFieldValue(`matched.${pending.refType}-${pending.refId}`, true);
}
setPending(null);
}
}, [
isMatchingTransactionsFetching,
isMatchingTransactionsSuccess,
matches,
pending,
setFieldValue,
]);
const handleReconcileFormSubmitSuccess = (payload) => {
setPending({ refId: payload.id, refType: payload.type });
};
return (
<>
<MatchingBankTransactionContent />
{openReconcileMatchingTransaction && (
<MatchingReconcileTransactionForm
onSubmitSuccess={handleReconcileFormSubmitSuccess}
/>
)}
{!openReconcileMatchingTransaction && <MatchTransactionFooter />}
</>
);
},
);
function MatchingBankTransactionContent() {
return (
@@ -141,8 +212,8 @@ function PerfectMatchingTransactions() {
key={index}
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}
date={match.dateFormatted}
transactionId={match.transactionId}
transactionType={match.transactionType}
transactionId={match.referenceId}
transactionType={match.referenceType}
/>
))}
</Stack>
@@ -166,9 +237,6 @@ function PossibleMatchingTransactions() {
<Box className={styles.matchBar}>
<Stack spacing={2}>
<h2 className={styles.matchBarTitle}>Possible Matches</h2>
<Text style={{ fontSize: 12, color: '#5C7080' }}>
Transactions up to 20 Aug 2019
</Text>
</Stack>
</Box>
@@ -176,10 +244,15 @@ function PossibleMatchingTransactions() {
{possibleMatches.map((match, index) => (
<MatchTransactionField
key={index}
label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`}
label={
<>
{`${match.transsactionTypeFormatted} for `}
<strong>{match.amountFormatted}</strong>
</>
}
date={match.dateFormatted}
transactionId={match.transactionId}
transactionType={match.transactionType}
transactionId={match.referenceId}
transactionType={match.referenceType}
/>
))}
</Stack>
@@ -240,7 +313,7 @@ const MatchTransactionFooter = R.compose(withBankingActions)(
submitForm();
};
const handleReconcileTransaction = () => {
openReconcileMatchingTransaction();
openReconcileMatchingTransaction(totalPending);
};
return (
@@ -258,7 +331,7 @@ const MatchTransactionFooter = R.compose(withBankingActions)(
</AnchorButton>
)}
<Text
style={{ fontSize: 14, marginLeft: 'auto', color: '#5F6B7C' }}
style={{ fontSize: 14, marginLeft: 'auto', color: '#404854' }}
tagName="span"
>
Pending <FormatNumber value={totalPending} currency={'USD'} />

View File

@@ -1,9 +1,12 @@
import { defaultTo } from 'lodash';
import React, { createContext } from 'react';
import { defaultTo } from 'lodash';
import * as R from 'ramda';
import { useGetBankTransactionsMatches } from '@/hooks/query/bank-rules';
interface MatchingTransactionBootValues {
isMatchingTransactionsLoading: boolean;
isMatchingTransactionsFetching: boolean;
isMatchingTransactionsSuccess: boolean;
possibleMatches: Array<any>;
perfectMatchesCount: number;
perfectMatches: Array<any>;
@@ -26,13 +29,24 @@ function MatchingTransactionBoot({
const {
data: matchingTransactions,
isLoading: isMatchingTransactionsLoading,
isFetching: isMatchingTransactionsFetching,
isSuccess: isMatchingTransactionsSuccess,
} = useGetBankTransactionsMatches(uncategorizedTransactionId);
const possibleMatches = defaultTo(matchingTransactions?.possibleMatches, []);
const perfectMatchesCount = matchingTransactions?.perfectMatches?.length || 0;
const perfectMatches = defaultTo(matchingTransactions?.perfectMatches, []);
const matches = R.concat(perfectMatches, possibleMatches);
const provider = {
isMatchingTransactionsLoading,
possibleMatches: defaultTo(matchingTransactions?.possibleMatches, []),
perfectMatchesCount: matchingTransactions?.perfectMatches?.length || 0,
perfectMatches: defaultTo(matchingTransactions?.perfectMatches, []),
isMatchingTransactionsFetching,
isMatchingTransactionsSuccess,
possibleMatches,
perfectMatchesCount,
perfectMatches,
matches,
} as MatchingTransactionBootValues;
return <RuleFormBootContext.Provider value={provider} {...props} />;

View File

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

View File

@@ -9,7 +9,10 @@ export const withBanking = (mapState) => {
selectedUncategorizedTransactionId:
state.plaid.uncategorizedTransactionIdForMatching,
openReconcileMatchingTransaction:
state.plaid.openReconcileMatchingTransaction,
state.plaid.openReconcileMatchingTransaction.isOpen,
reconcileMatchingTransactionPendingAmount:
state.plaid.openReconcileMatchingTransaction.pending,
};
return mapState ? mapState(mapped, state, props) : mapped;
};

View File

@@ -11,7 +11,7 @@ export interface WithBankingActionsProps {
setUncategorizedTransactionIdForMatching: (
uncategorizedTransactionId: number,
) => void;
openReconcileMatchingTransaction: () => void;
openReconcileMatchingTransaction: (pendingAmount: number) => void;
closeReconcileMatchingTransaction: () => void;
}
@@ -24,8 +24,8 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({
dispatch(
setUncategorizedTransactionIdForMatching(uncategorizedTransactionId),
),
openReconcileMatchingTransaction: () =>
dispatch(openReconcileMatchingTransaction()),
openReconcileMatchingTransaction: (pendingAmount: number) =>
dispatch(openReconcileMatchingTransaction({ pending: pendingAmount })),
closeReconcileMatchingTransaction: () =>
dispatch(closeReconcileMatchingTransaction()),
});

View File

@@ -37,7 +37,7 @@ interface CreateBankRuleResponse {}
/**
* Creates a new bank rule.
* @param {UseMutationOptions<CreateBankRuleValues, Error, CreateBankRuleValues>} options -
* @returns {UseMutationResult<CreateBankRuleValues, Error, CreateBankRuleValues>}
* @returns {UseMutationResult<CreateBankRuleValues, Error, CreateBankRuleValues>}TCHES
*/
export function useCreateBankRule(
options?: UseMutationOptions<
@@ -235,6 +235,12 @@ export function useExcludeUncategorizedTransaction(
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...options,
},
@@ -282,6 +288,12 @@ export function useUnexcludeUncategorizedTransaction(
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// Invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...options,
},
@@ -322,6 +334,60 @@ export function useMatchUncategorizedTransaction(
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// Invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...props,
});
}
interface UnmatchUncategorizedTransactionValues {
id: number;
}
interface UnmatchUncategorizedTransactionRes {}
/**
* Unmatch the given matched uncategorized transaction.
* @param {UseMutationOptions<UnmatchUncategorizedTransactionRes, Error, UnmatchUncategorizedTransactionValues>} props
* @returns {UseMutationResult<UnmatchUncategorizedTransactionRes, Error, UnmatchUncategorizedTransactionValues>}
*/
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);
// Invalidate accounts.
queryClient.invalidateQueries(t.ACCOUNTS);
queryClient.invalidateQueries(t.ACCOUNT);
// Invalidate bank account summary.
queryClient.invalidateQueries(QUERY_KEY.BANK_ACCOUNT_SUMMARY_META);
},
...props,
});

View File

@@ -58,6 +58,8 @@ export function useCreateCashflowTransaction(props) {
onSuccess: () => {
// Invalidate queries.
commonInvalidateQueries(queryClient);
queryClient.invalidateQueries('BANK_TRANSACTION_MATCHES');
},
...props,
},
@@ -251,6 +253,9 @@ export function useCategorizeTransaction(props) {
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate bank account summary.
queryClient.invalidateQueries('BANK_ACCOUNT_SUMMARY_META');
},
...props,
},
@@ -274,6 +279,9 @@ export function useUncategorizeTransaction(props) {
queryClient.invalidateQueries(
t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY,
);
// Invalidate bank account summary.
queryClient.invalidateQueries('BANK_ACCOUNT_SUMMARY_META');
},
...props,
},

View File

@@ -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',
},
};

View File

@@ -4,7 +4,7 @@ interface StorePlaidState {
plaidToken: string;
openMatchingTransactionAside: boolean;
uncategorizedTransactionIdForMatching: number | null;
openReconcileMatchingTransaction: boolean;
openReconcileMatchingTransaction: { isOpen: boolean; pending: number };
}
export const PlaidSlice = createSlice({
@@ -13,7 +13,10 @@ export const PlaidSlice = createSlice({
plaidToken: '',
openMatchingTransactionAside: false,
uncategorizedTransactionIdForMatching: null,
openReconcileMatchingTransaction: false,
openReconcileMatchingTransaction: {
isOpen: false,
pending: 0,
},
} as StorePlaidState,
reducers: {
setPlaidId: (state: StorePlaidState, action: PayloadAction<string>) => {
@@ -37,12 +40,17 @@ export const PlaidSlice = createSlice({
state.uncategorizedTransactionIdForMatching = null;
},
openReconcileMatchingTransaction: (state: StorePlaidState) => {
state.openReconcileMatchingTransaction = true;
openReconcileMatchingTransaction: (
state: StorePlaidState,
action: PayloadAction<{ pending: number }>,
) => {
state.openReconcileMatchingTransaction.isOpen = true;
state.openReconcileMatchingTransaction.pending = action.payload.pending;
},
closeReconcileMatchingTransaction: (state: StorePlaidState) => {
state.openReconcileMatchingTransaction = false;
state.openReconcileMatchingTransaction.isOpen = false;
state.openReconcileMatchingTransaction.pending = 0;
},
},
});

View File

@@ -50,3 +50,9 @@ $form-check-input-indeterminate-bg-image: url("data:image/svg+xml,<svg xmlns='ht
// z-indexs
$zindex-dashboard-splash-screen: 39;
$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 {
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>.bp4-icon {
color: #4b5d6b;