import { useAtom, useAtomValue, useSetAtom } from "jotai" import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog" import _ from "@/lib/translate" import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils" import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form" import { JournalEntry } from "@/types/Accounts/JournalEntry" import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company" import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk" import { toast } from "sonner" import ErrorBanner from "@/components/ui/error-banner" import { Button } from "@/components/ui/button" import SelectedTransactionDetails from "./SelectedTransactionDetails" import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements" import { Form } from "@/components/ui/form" import { useCallback, useContext, useMemo, useRef, useState } from "react" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Checkbox } from "@/components/ui/checkbox" import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react" import { flt, formatCurrency } from "@/lib/numbers" import { cn } from "@/lib/utils" import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" import SelectedTransactionsTable from "./SelectedTransactionsTable" import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount" import { BankTransaction } from "@/types/Accounts/BankTransaction" import FileUploadBanner from "@/components/common/FileUploadBanner" import { Label } from "@/components/ui/label" import { FileDropzone } from "@/components/ui/file-dropzone" import { useGetAccounts } from "@/components/common/AccountsDropdown" import { useHotkeys } from "react-hotkeys-hook" const BankEntryModal = () => { const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom) return ( {_("Bank Entry")} {_("Record a journal entry for expenses, income or split transactions.")} ) } const RecordBankEntryModalContent = () => { const selectedBankAccount = useAtomValue(selectedBankAccountAtom) const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { return
{_("No transaction selected")}
} if (selectedTransaction.length === 1) { return } return } const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => { const form = useForm<{ account: string }>({ defaultValues: { account: '' } }) const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile') const onReconcile = useRefreshUnreconciledTransactions() const addToActionLog = useUpdateActionLog() const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) const onSubmit = (data: { account: string }) => { call({ bank_transactions: selectedTransactions.map(transaction => transaction.name), account: data.account }).then(({ message }) => { addToActionLog({ type: 'bank_entry', timestamp: (new Date()).getTime(), isBulk: true, items: message.map((item) => ({ bankTransaction: item.transaction, voucher: { reference_doctype: "Journal Entry", reference_name: item.journal_entry.name, doc: item.journal_entry, posting_date: item.journal_entry.posting_date, } })), bulkCommonData: { account: data.account, } }) toast.success(_("Bank Entries Created"), { duration: 4000, }) // Set this to the last selected transaction onReconcile(selectedTransactions[selectedTransactions.length - 1]) setIsOpen(false) }) } return
{error && }
{ // Do not allow payable and receivable accounts return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable' }} label={_('Account')} isRequired />
} interface BankEntryFormData extends Pick { entries: JournalEntry['accounts'] } const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => { const selectedBankAccount = useAtomValue(selectedBankAccountAtom) const { data: rule } = useGetRuleForTransaction(selectedTransaction) const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) const onClose = () => { setIsOpen(false) } const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false const defaultAccounts = useMemo(() => { const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false const accounts: Partial[] = [ { account: selectedBankAccount?.account ?? '', bank_account: selectedTransaction.bank_account, // Bank is debited if it's a deposit debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, party_type: '', party: '', cost_center: '' }] // If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse if (!rule) { accounts.push( { account: '', // Amounts will be the reverse of the bank account transaction debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', } ) } else { // Rule exists, so we need to check the type of rule if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") { // Only a single account needs to be added accounts.push({ account: rule.account ?? '', // Amounts will be the reverse of the bank account transaction debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', }) } else { // For multiple accounts, we need to loop over and add entries for each // The last row will just be the remaining amount let hasTotallyEmptyRowEarlier = false; let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0 let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0 for (let i = 0; i < (rule.accounts?.length ?? 0); i++) { const acc = rule.accounts?.[i] // If it's the last row, add the difference amount if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) { const differenceAmount = flt(totalDebits - totalCredits, 2) accounts.push({ account: acc?.account ?? '', debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount), credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0, cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', user_remark: acc?.user_remark ?? '', }) } else { /** * The debit and credit amounts can also be expressions - like "transaction_amount * 0.5" * So we need to compute the value of the expression * We can use the eval function to do this. But we need to expose certain variables to the expression. * One of them is transaction_amount which is the unallocated amount of the selected transaction * @param expression - The expression to compute * @returns The computed value */ const computeExpression = (expression: string) => { const script = ` const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0} ${expression}; ` let value = 0; try { value = window.eval(script); } catch (error: unknown) { console.error(error); value = 0; } return value; } if (!acc?.debit && !acc?.credit) { hasTotallyEmptyRowEarlier = true; } const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0 const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0 totalDebits = flt(totalDebits + computedDebit, 2) totalCredits = flt(totalCredits + computedCredit, 2) accounts.push({ account: acc?.account ?? '', debit: computedDebit, credit: computedCredit, cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', user_remark: acc?.user_remark ?? '', }) } } } } return accounts }, [rule, selectedTransaction, selectedBankAccount]) const form = useForm({ defaultValues: { voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry', cheque_date: selectedTransaction.date, posting_date: selectedTransaction.date, cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), user_remark: selectedTransaction.description, entries: defaultAccounts, } }) const onReconcile = useRefreshUnreconciledTransactions() const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile') const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) const addToActionLog = useUpdateActionLog() const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig const [isUploading, setIsUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState(0) const [files, setFiles] = useState([]) const onSubmit = (data: BankEntryFormData) => { createBankEntry({ bank_transaction_name: selectedTransaction.name, ...data }).then(async ({ message }) => { addToActionLog({ type: 'bank_entry', isBulk: false, timestamp: (new Date()).getTime(), items: [ { bankTransaction: message.transaction, voucher: { reference_doctype: "Journal Entry", reference_name: message.journal_entry.name, reference_no: message.journal_entry.cheque_no, reference_date: message.journal_entry.cheque_date, posting_date: message.journal_entry.posting_date, doc: message.journal_entry, } } ] }) toast.success(_("Bank Entry Created"), { duration: 4000, closeButton: true, action: { label: _("Undo"), onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) }, actionButtonStyle: { backgroundColor: "rgb(0, 138, 46)" } }) if (files.length > 0) { setIsUploading(true) const uploadPromises = files.map(f => { return frappeFile.uploadFile(f, { isPrivate: true, doctype: "Journal Entry", docname: message.journal_entry.name, }, (_bytesUploaded, _totalBytes, progress) => { setUploadProgress((currentProgress) => { //If there are multiple files, we need to add the progress to the current progress return currentProgress + ((progress?.progress ?? 0) / files.length) }) }) }) return Promise.all(uploadPromises).then(() => { setUploadProgress(0) setIsUploading(false) }).catch((error) => { console.error(error) toast.error(_("Error uploading attachments"), { duration: 4000, }) setIsUploading(false) }) } else { return Promise.resolve() } }).then(() => { onReconcile(selectedTransaction) onClose() }) } useHotkeys('meta+s', () => { form.handleSubmit(onSubmit)() }, { enabled: true, preventDefault: true, enableOnFormTags: true }) if (isUploading && isCompleted) { return } return
{error && }
} const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => { const { getValues, setValue, control } = useFormContext() const { call } = useContext(FrappeContext) as FrappeConfig const partyMapRef = useRef>({}) const onPartyChange = (value: string, index: number) => { // Get the account for the party type if (value) { if (partyMapRef.current[value]) { setValue(`entries.${index}.account`, partyMapRef.current[value]) } else { call.get('erpnext.accounts.party.get_party_account', { party: value, party_type: getValues(`entries.${index}.party_type`), company: company }).then((result: { message: string }) => { setValue(`entries.${index}.account`, result.message) partyMapRef.current[value] = result.message }) } } else { setValue(`entries.${index}.account`, '') } } const { data: accounts } = useGetAccounts() const onAccountChange = (value: string, index: number) => { // If it's an income or expense account, get the default cost center if (value) { const account = accounts?.find((acc) => acc.name === value) if (account && account.report_type === "Profit and Loss") { // Set the default company cost center setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '') return } } setValue(`entries.${index}.cost_center`, '') } const { fields, append, remove } = useFieldArray({ control: control, name: 'entries' }) const onAdd = useCallback(() => { const existingEntries = getValues('entries') const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) const remainingAmount = flt(totalDebits - totalCredits, 2) // Remaining amount is credit if it's positive - since some debit is pending to be cleared. const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 append({ party_type: '', party: '', account: '', debit: debitAmount, credit: creditAmount, cost_center: getCompanyCostCenter(company) ?? '' } as JournalEntryAccount, { focusName: `entries.${existingEntries.length}.account` }) }, [company, append, getValues]) const [selectedRows, setSelectedRows] = useState([]) const onSelectRow = useCallback((index: number) => { setSelectedRows(prev => { if (prev.includes(index)) { return prev.filter(i => i !== index) } return [...prev, index] }) }, []) const onSelectAll = useCallback(() => { setSelectedRows(prev => { if (prev.length === fields.length) { return [] } return [...fields.map((_, index) => index)] }) }, [fields]) const onRemove = useCallback(() => { remove(selectedRows) setSelectedRows([]) }, [remove, selectedRows]) /** * When add difference is clicked, check if the last row has nothing filled in. * If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount. */ const onAddDifferenceClicked = () => { const existingEntries = getValues('entries') const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) const lastIndex = existingEntries.length - 1 const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined) const remainingAmount = flt(totalDebits - totalCredits, 2) // Remaining amount is credit if it's positive - since some debit is pending to be cleared. const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 if (isLastRowEmpty) { setValue(`entries.${lastIndex}.debit`, debitAmount) setValue(`entries.${lastIndex}.credit`, creditAmount) } else { append({ party_type: '', party: '', account: '', debit: debitAmount, credit: creditAmount, cost_center: getCompanyCostCenter(company) ?? '' } as JournalEntryAccount, { focusName: `entries.${existingEntries.length}.account` }) } } return
0 && selectedRows.length === fields.length} onCheckedChange={onSelectAll} /> {_("Party")} {_("Account")} {_("Cost Center")} {_("Remarks")} {_("Debit")} {_("Credit")} {fields.map((field, index) => ( onSelectRow(index)} // Make this accessible to screen readers aria-label={_("Select row {0}", [String(index + 1)])} disabled={index === 0} />
{ onAccountChange(event.target.value, index) } }} buttonClassName="min-w-64" readOnly={index === 0} isRequired hideLabel /> {_("Bank account debit for deposit")} : undefined} /> {_("Bank account credit for withdrawal")} : undefined} />
))}
{selectedRows.length > 0 &&
}
} const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => { const { control } = useFormContext() const party_type = useWatch({ control, name: `entries.${index}.party_type` }) if (!party_type) { return } return { onChange(event.target.value, index) }, }} hideLabel readOnly={readOnly} buttonClassName="rounded-s-none border-s-0 min-w-64" doctype={party_type} /> } const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => { const { control } = useFormContext() const entries = useWatch({ control, name: 'entries' }) const { total, totalCredits, totalDebits } = useMemo(() => { // Do a total debits - total credits const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits } }, [entries]) const onAddRow = useCallback(() => { addRow() }, [addRow]) const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { return {children} } return
{_("Total Debit")} {formatCurrency(totalDebits, currency)}
{_("Total Credit")} {formatCurrency(totalCredits, currency)}
{total !== 0 &&
{_("Difference")} {_("Add a row with the difference amount")}
}
} export default BankEntryModal