mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-18 12:39:18 +00:00
* feat: initial SPA setup for banking * wip: bring over new banking module * feat: added Espresso design tokens * feat: button styles * fix: add all ink colors * wip: espresso design system changes * feat: button and badge espresso components * fix: button styling for reconcile * feat: Espresso progress bar * feat: Espresso toggle switch * feat: Espresso tabs design * fix: vertical tab support * fix: button sizing across modals * feat: Espresso style table layout * feat: Espresso tooltip * feat: Espresso elevations and checkbox * feat: Dialog with Espresso styles * feat: Espresso textarea * fix: input styles * fix: colors on bank picker * fix: breadcrumb styling * fix: bank picker styling * feat: create doctypes and fields for bank reconciliation * feat: APIs for banking * fix: use date format parser * fix: font styling to match Espresso * wip: settings modal * feat: settings dialog component * fix: icons and invalid requests * feat: preferences tab * fix: adjust icon stroke width to 1.5 * feat: rule configuration in settings * fix: remove sheet component * feat: alert and error banner component * feat: dropdown in Espresso * feat: popover and select in Espresso * fix: cleanup more styles * fix: match size of link fields * feat: command styling * fix: remove unused style tokens * fix: styles for global date picker dropdown * fix: styles for match and reconcile * feat: table Espresso component * feat: remove all other design tokens * fix: remove unused tokens * fix: form elements * fix: remove unused styles and fix filters in bank transaction list * feat: fetch bank rec doctypes for filtering * fix: record payment modal * feat: support for dark mode switching * fix: move bank logos to public folder * feat: add support for RTL * feat: support for RTL * chore: send layout direction in dev boot * fix: make checkbox work in RTL * feat: dark mode support * fix: dark mode style * feat: bank logos in dark mode * feat: dark mode bank logos * chore: use dark mode bank logos everywhere * chore: move rule evaluation to controller * chore: add tests for bank transaction rules * fix: move deps to fix actions errors * fix: move tw-animate-css to deps * fix: remove shadcn * fix: do not open modal if no transactions selected * fix: add translation strings * feat: add banner on existing bank reconciliation tool * feat: bank statement import * fix: translations and layout directions * fix: validation for transaction matching rule * fix: styles * fix: show conflicting transactions in alert * fix: show help text for new banking module forms * feat: show total debits and credits * fix: dark mode colors in automatic config * feat: add keyboard shortcuts help * feat: added keyboard shortcut for settings * fix: decrease size of progress bar * chore: bump packages * feat: add tests for statement import * fix: settings dialog * fix: show banner on small screens * fix: show banner when no bank account set
457 lines
17 KiB
TypeScript
457 lines
17 KiB
TypeScript
import { ActionLog, bankRecActionLog, bankRecAmountFilter, bankRecDateAtom, bankRecMatchFilters, bankRecSearchText, bankRecSelectedTransactionAtom, bankRecTransactionTypeFilter, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
|
import { useMemo } from 'react'
|
|
import { SWRConfiguration, useFrappeGetCall, useFrappeGetDoc, useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
|
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
|
import { BankAccount } from '@/types/Accounts/BankAccount'
|
|
import dayjs from 'dayjs'
|
|
import { toast } from 'sonner'
|
|
import { BANK_LOGOS } from './logos'
|
|
import { getErrorMessage } from '@/lib/frappe'
|
|
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
|
import _ from '@/lib/translate'
|
|
import { BankTransactionRule } from '@/types/Accounts/BankTransactionRule'
|
|
import { useRef } from 'react'
|
|
import type { DebouncedState } from 'usehooks-ts'
|
|
import { useDebounceCallback } from 'usehooks-ts'
|
|
import Fuse from 'fuse.js'
|
|
|
|
export const useGetAccountOpeningBalance = () => {
|
|
|
|
const companyID = useCurrentCompany()
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
|
|
const args = useMemo(() => {
|
|
|
|
return {
|
|
bank_account: bankAccount?.name,
|
|
company: companyID,
|
|
till_date: dayjs(dates.fromDate).subtract(1, 'days').format('YYYY-MM-DD'),
|
|
}
|
|
|
|
}, [companyID, bankAccount?.name, dates.fromDate])
|
|
|
|
return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args, undefined, {
|
|
revalidateOnFocus: false
|
|
})
|
|
}
|
|
|
|
export const useGetAccountClosingBalance = () => {
|
|
|
|
const companyID = useCurrentCompany()
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
|
|
const args = useMemo(() => {
|
|
|
|
return {
|
|
bank_account: bankAccount?.name,
|
|
company: companyID,
|
|
till_date: dates.toDate,
|
|
}
|
|
|
|
}, [companyID, bankAccount?.name, dates.toDate])
|
|
|
|
return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args,
|
|
`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`,
|
|
{
|
|
revalidateOnFocus: false
|
|
}
|
|
)
|
|
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch the closing balance set in the database for the given bank and date
|
|
*/
|
|
export const useGetAccountClosingBalanceAsPerStatement = (swrConfig: SWRConfiguration = {}) => {
|
|
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
|
|
return useFrappeGetCall<{ message: { balance: number, date?: string } }>("erpnext.accounts.doctype.bank_account.bank_account.get_closing_balance_as_per_statement", {
|
|
bank_account: bankAccount?.name,
|
|
date: dates.toDate
|
|
}, `bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${dates.toDate}`, {
|
|
revalidateOnFocus: false,
|
|
...swrConfig
|
|
})
|
|
}
|
|
|
|
export type UnreconciledTransaction = Pick<BankTransaction, 'name' | 'matched_transaction_rule' | 'date' | 'withdrawal' | 'deposit' | 'currency' | 'description' | 'status' | 'transaction_type' | 'reference_number' | 'party_type' | 'party' | 'bank_account' | 'company' | 'unallocated_amount'>
|
|
|
|
|
|
export const useGetUnreconciledTransactions = () => {
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
return useFrappeGetCall<{ message: UnreconciledTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', {
|
|
bank_account: bankAccount?.name,
|
|
from_date: dates.fromDate,
|
|
to_date: dates.toDate
|
|
}, bankAccount ? `bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null, {
|
|
revalidateOnFocus: false,
|
|
revalidateIfStale: false
|
|
})
|
|
}
|
|
|
|
export interface LinkedPayment {
|
|
rank: number,
|
|
doctype: string,
|
|
name: string,
|
|
paid_amount: number,
|
|
reference_no: string,
|
|
reference_date: string,
|
|
posting_date: string,
|
|
party_type?: string,
|
|
party?: string,
|
|
currency: string
|
|
}
|
|
|
|
export const useGetBankTransactions = () => {
|
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
return useFrappeGetCall<{ message: BankTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', {
|
|
bank_account: bankAccount?.name,
|
|
from_date: dates.fromDate,
|
|
to_date: dates.toDate,
|
|
all_transactions: true
|
|
}, bankAccount ? `bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null)
|
|
}
|
|
|
|
|
|
export const useGetVouchersForTransaction = (transaction: UnreconciledTransaction) => {
|
|
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
|
|
const matchFilters = useAtomValue(bankRecMatchFilters)
|
|
|
|
return useFrappeGetCall<{ message: LinkedPayment[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_linked_payments', {
|
|
bank_transaction_name: transaction.name,
|
|
document_types: matchFilters ?? ['payment_entry', 'journal_entry'],
|
|
from_date: dates.fromDate,
|
|
to_date: dates.toDate,
|
|
filter_by_reference_date: 0
|
|
}, `bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`, {
|
|
revalidateOnFocus: false
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Common hook to refresh the unreconciled transactions list after a transaction is reconciled
|
|
* @returns function to call to refresh the unreconciled transactions list AFTER the operation is done
|
|
*/
|
|
export const useRefreshUnreconciledTransactions = () => {
|
|
|
|
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
|
const dates = useAtomValue(bankRecDateAtom)
|
|
const matchFilters = useAtomValue(bankRecMatchFilters)
|
|
const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
|
|
|
const { mutate } = useSWRConfig()
|
|
|
|
const searchString = useAtomValue(bankRecSearchText)
|
|
const typeFilter = useAtomValue(bankRecTransactionTypeFilter)
|
|
const amountFilter = useAtomValue(bankRecAmountFilter)
|
|
|
|
const { data: unreconciledTransactions } = useGetUnreconciledTransactions()
|
|
|
|
/**
|
|
* This function should be called after a transaction is reconciled
|
|
* It will get the next unreconciled transaction and select it
|
|
* And then refresh the balance + unreconciled transactions list
|
|
*/
|
|
const onReconcileTransaction = (transaction: UnreconciledTransaction, updatedTransaction?: BankTransaction) => {
|
|
|
|
// If the updated transaction has an unallocated amount of 0, then we need to select the next unreconciled transaction
|
|
if (updatedTransaction && updatedTransaction?.unallocated_amount !== 0) {
|
|
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
|
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
|
// Update the matching vouchers for the selected transaction
|
|
mutate(`bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
|
|
return
|
|
}
|
|
|
|
// From unreconciled transactions list, first apply the filters based on the search criteria and other filters
|
|
|
|
const searchIndex = unreconciledTransactions ? new Fuse(unreconciledTransactions.message, {
|
|
keys: ['description', 'reference_number'],
|
|
threshold: 0.5,
|
|
includeScore: true
|
|
}) : null
|
|
|
|
const results = getSearchResults(searchIndex, searchString, typeFilter, amountFilter.value, unreconciledTransactions?.message)
|
|
|
|
const currentIndex = results.findIndex(t => t.name === transaction.name)
|
|
let nextTransaction = null
|
|
|
|
if (currentIndex !== -1) {
|
|
// Check if there is a next transaction
|
|
if (currentIndex < (results.length || 0) - 1) {
|
|
nextTransaction = results[currentIndex + 1]
|
|
}
|
|
}
|
|
|
|
// We need to select the next unreconciled transaction for a better UX
|
|
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
|
.then(res => {
|
|
if (nextTransaction) {
|
|
// Check if next transaction is there in the response
|
|
const nextTransactionObj = res?.message.find((t: UnreconciledTransaction) => t.name === nextTransaction.name)
|
|
if (nextTransactionObj) {
|
|
setSelectedTransaction([nextTransactionObj])
|
|
} else {
|
|
// If the next transaction is not there in the response, we need to clear the selection
|
|
setSelectedTransaction([])
|
|
}
|
|
} else {
|
|
// If there is no next transaction, we need to clear the selection
|
|
setSelectedTransaction([])
|
|
}
|
|
})
|
|
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
|
}
|
|
|
|
return onReconcileTransaction
|
|
|
|
}
|
|
|
|
export const useReconcileTransaction = () => {
|
|
|
|
const { call, loading } = useFrappePostCall<{ message: BankTransaction }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers')
|
|
|
|
const onReconcileTransaction = useRefreshUnreconciledTransactions()
|
|
|
|
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
|
|
|
const addToActionLog = useUpdateActionLog()
|
|
|
|
const reconcileTransaction = (transaction: UnreconciledTransaction, voucher: LinkedPayment) => {
|
|
|
|
call({
|
|
bank_transaction_name: transaction.name,
|
|
vouchers: JSON.stringify([{
|
|
"payment_doctype": voucher.doctype,
|
|
"payment_name": voucher.name,
|
|
"amount": voucher.paid_amount
|
|
}])
|
|
}).then((res) => {
|
|
addToActionLog({
|
|
type: 'match',
|
|
timestamp: (new Date()).getTime(),
|
|
isBulk: false,
|
|
items: [
|
|
{
|
|
bankTransaction: res.message,
|
|
voucher: {
|
|
reference_doctype: voucher.doctype,
|
|
reference_name: voucher.name,
|
|
reference_no: voucher.reference_no,
|
|
reference_date: voucher.reference_date,
|
|
posting_date: voucher.posting_date,
|
|
}
|
|
}
|
|
]
|
|
})
|
|
onReconcileTransaction(transaction, res.message)
|
|
toast.success(_("Reconciled"), {
|
|
duration: 4000,
|
|
closeButton: true,
|
|
action: {
|
|
label: _("Undo"),
|
|
onClick: () => setBankRecUnreconcileModalAtom(transaction.name)
|
|
},
|
|
actionButtonStyle: {
|
|
backgroundColor: "rgb(0, 138, 46)"
|
|
}
|
|
})
|
|
}).catch((error) => {
|
|
console.error(error)
|
|
toast.error(_("Error"), {
|
|
duration: 5000,
|
|
description: getErrorMessage(error)
|
|
})
|
|
})
|
|
}
|
|
|
|
return { reconcileTransaction, loading }
|
|
|
|
}
|
|
|
|
interface BankAccountWithCurrency extends Pick<BankAccount, 'name' | 'bank' | 'account_name' | 'is_credit_card' | 'company' | 'account' | 'account_type' | 'account_subtype' | 'bank_account_no' | 'last_integration_date'> {
|
|
account_currency?: string
|
|
}
|
|
|
|
type BankLogoEntry = (typeof BANK_LOGOS)[number]
|
|
|
|
/** Prefer the longest keyword match so short tokens (e.g. "anz" in "finanzas") do not beat full bank names. */
|
|
function findBankLogoForName(bankName: string | undefined | null): BankLogoEntry | undefined {
|
|
if (!bankName) return undefined
|
|
const haystack = bankName.toLowerCase()
|
|
let best: BankLogoEntry | undefined
|
|
let bestKeywordLen = 0
|
|
for (const entry of BANK_LOGOS) {
|
|
for (const keyword of entry.keywords) {
|
|
const needle = keyword.toLowerCase()
|
|
if (needle.length === 0) continue
|
|
if (haystack.includes(needle) && needle.length > bestKeywordLen) {
|
|
bestKeywordLen = needle.length
|
|
best = entry
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
export const useGetBankAccounts = (onSuccess?: (data?: Omit<SelectedBank, 'logo'>[]) => void, filterFn?: (bank: SelectedBank) => boolean) => {
|
|
|
|
const company = useCurrentCompany()
|
|
|
|
const { data, isLoading, error } = useFrappeGetCall<{ message: BankAccountWithCurrency[] }>('erpnext.accounts.doctype.bank_account.bank_account.get_list', {
|
|
company: company
|
|
}, undefined, {
|
|
revalidateOnFocus: false,
|
|
revalidateIfStale: false,
|
|
onSuccess: (data) => {
|
|
onSuccess?.(data?.message)
|
|
}
|
|
})
|
|
|
|
const banks = useMemo(() => {
|
|
// Match the bank account to the logo
|
|
const banksWithLogos = data?.message.map((bank) => {
|
|
const logo = findBankLogoForName(bank.bank)
|
|
return {
|
|
...bank,
|
|
logo: logo?.logo,
|
|
logoDark: logo?.logoDark,
|
|
darkModeInvert: logo?.darkModeInvert,
|
|
logoClassName: logo?.logoClassName
|
|
}
|
|
}) ?? []
|
|
|
|
if (filterFn) {
|
|
return banksWithLogos.filter(filterFn)
|
|
}
|
|
|
|
return banksWithLogos
|
|
}, [data, filterFn])
|
|
|
|
return {
|
|
banks,
|
|
isLoading,
|
|
error
|
|
}
|
|
|
|
}
|
|
|
|
export const useIsTransactionWithdrawal = (transaction: UnreconciledTransaction) => {
|
|
return useMemo(() => {
|
|
const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0
|
|
const isDeposit = transaction.deposit && transaction.deposit > 0
|
|
|
|
return {
|
|
amount: isWithdrawal ? transaction.withdrawal : transaction.deposit,
|
|
isWithdrawal,
|
|
isDeposit
|
|
}
|
|
}, [transaction])
|
|
}
|
|
|
|
export const useGetRuleForTransaction = (transaction: UnreconciledTransaction) => {
|
|
|
|
return useFrappeGetDoc<BankTransactionRule>('Bank Transaction Rule', transaction.matched_transaction_rule,
|
|
transaction.matched_transaction_rule ? undefined : null, {
|
|
revalidateOnFocus: false,
|
|
revalidateIfStale: false
|
|
}
|
|
)
|
|
}
|
|
|
|
/** Hook to handle the search input while maintaining debouncing and global state. */
|
|
export function useTransactionSearch(): [string, DebouncedState<(value: string) => void>] {
|
|
const delay = 500
|
|
const unwrappedInitialValue = ''
|
|
const eq = (left: string, right: string) => left === right
|
|
const [debouncedValue, setDebouncedValue] = useAtom(bankRecSearchText)
|
|
const previousValueRef = useRef<string | undefined>(unwrappedInitialValue)
|
|
|
|
const updateDebouncedValue = useDebounceCallback(
|
|
setDebouncedValue,
|
|
delay,
|
|
)
|
|
|
|
// Update the debounced value if the initial value changes
|
|
if (!eq(previousValueRef.current as string, unwrappedInitialValue)) {
|
|
updateDebouncedValue(unwrappedInitialValue)
|
|
previousValueRef.current = unwrappedInitialValue
|
|
}
|
|
|
|
return [debouncedValue, updateDebouncedValue]
|
|
}
|
|
|
|
/** Utility function to get the search results based on the search index, search string, type filter, amount filter and unreconciled transactions */
|
|
export const getSearchResults = (
|
|
/** Fuse index of the unreconciled transactions */
|
|
searchIndex: Fuse<UnreconciledTransaction> | null,
|
|
/** Search string */
|
|
search: string,
|
|
/** Type filter */
|
|
typeFilter: string,
|
|
/** Amount filter */
|
|
amountFilter: number,
|
|
/** Unreconciled transactions */
|
|
unreconciledTransactions?: UnreconciledTransaction[]) => {
|
|
|
|
let r = []
|
|
if (!searchIndex || !search) {
|
|
r = unreconciledTransactions ?? []
|
|
} else {
|
|
r = searchIndex.search(search).map((result) => result.item)
|
|
}
|
|
|
|
if (typeFilter !== 'All') {
|
|
r = r.filter((transaction) => {
|
|
if (typeFilter === 'Debits') {
|
|
return transaction.withdrawal && transaction.withdrawal > 0
|
|
}
|
|
if (typeFilter === 'Credits') {
|
|
return transaction.deposit && transaction.deposit > 0
|
|
}
|
|
})
|
|
}
|
|
|
|
if (amountFilter > 0) {
|
|
r = r.filter((transaction) => {
|
|
if (transaction.withdrawal && transaction.withdrawal > 0) {
|
|
return transaction.withdrawal === amountFilter
|
|
}
|
|
if (transaction.deposit && transaction.deposit > 0) {
|
|
return transaction.deposit === amountFilter
|
|
}
|
|
return false
|
|
})
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
export const useUpdateActionLog = () => {
|
|
|
|
const setActionLog = useSetAtom(bankRecActionLog)
|
|
|
|
const addToActionLog = (action: ActionLog) => {
|
|
// Store at max 100 actions
|
|
setActionLog((prev) => {
|
|
const newActions = [action, ...prev]
|
|
if (newActions.length > 100) {
|
|
return newActions.slice(0, 100)
|
|
}
|
|
return newActions
|
|
})
|
|
}
|
|
|
|
return addToActionLog
|
|
} |