import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" import { bankRecRecordPaymentModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose, DialogTrigger } 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 { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company" import { FrappeConfig, FrappeContext, useFrappeGetCall, 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 { ChangeEvent, useCallback, useContext, useEffect, useMemo, useState } from "react" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Checkbox } from "@/components/ui/checkbox" import { AlertCircleIcon, 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 { PaymentEntry } from "@/types/Accounts/PaymentEntry" import { H4 } from "@/components/ui/typography" import { usePaymentEntryCalculations } from "@/hooks/usePaymentEntryCalculations" import { MissingFiltersBanner } from "./MissingFiltersBanner" import { formatDate, today } from "@/lib/date" import { slug } from "@/lib/frappe" import MarkdownRenderer from "@/components/ui/markdown" import { Separator } from "@/components/ui/separator" import { PaymentEntryDeduction } from "@/types/Accounts/PaymentEntryDeduction" import { TableLoader } from "@/components/ui/loaders" import SelectedTransactionsTable from "./SelectedTransactionsTable" import { useCurrentCompany } from "@/hooks/useCurrentCompany" import { Label } from "@/components/ui/label" import { FileDropzone } from "@/components/ui/file-dropzone" import { BankTransaction } from "@/types/Accounts/BankTransaction" import FileUploadBanner from "@/components/common/FileUploadBanner" import { useHotkeys } from "react-hotkeys-hook" const RecordPaymentModal = () => { const [isOpen, setIsOpen] = useAtom(bankRecRecordPaymentModalAtom) return ( {_("Record Payment")} {_("Record a payment entry against a customer or supplier")} ) } const RecordPaymentModalContent = () => { 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 BulkPaymentEntryForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) const form = useForm<{ party_type: PaymentEntry['party_type'], party: PaymentEntry['party'], party_name: PaymentEntry['party_name'], /** GL account that's paid from or paid to */ account: string mode_of_payment: PaymentEntry['mode_of_payment'] }>() const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_payment_entry_and_reconcile') const onReconcile = useRefreshUnreconciledTransactions() const addToActionLog = useUpdateActionLog() const onSubmit = (data: { party_type: PaymentEntry['party_type'], party: PaymentEntry['party'], account: string, mode_of_payment: PaymentEntry['mode_of_payment'] }) => { createPaymentEntry({ bank_transaction_names: transactions.map((transaction) => transaction.name), party_type: data.party_type, party: data.party, account: data.account }).then(({ message }) => { addToActionLog({ type: 'payment', timestamp: (new Date()).getTime(), isBulk: true, items: message.map((item) => ({ bankTransaction: item.transaction, voucher: { reference_doctype: "Payment Entry", reference_name: item.payment_entry.name, reference_no: item.payment_entry.reference_no, reference_date: item.payment_entry.reference_date, posting_date: item.payment_entry.posting_date, party_type: item.payment_entry.party_type, party: item.payment_entry.party, doc: item.payment_entry, } })), bulkCommonData: { party_type: data.party_type, party: data.party, account: data.account, } }) toast.success(_("Payment Recorded"), { duration: 4000, closeButton: true, }) onReconcile(transactions[transactions.length - 1]) setIsOpen(false) }) } const party_type = useWatch({ control: form.control, name: 'party_type' }) const party_name = useWatch({ control: form.control, name: 'party_name' }) const party = useWatch({ control: form.control, name: 'party' }) const { call } = useContext(FrappeContext) as FrappeConfig const currentCompany = useCurrentCompany() const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') const onPartyChange = (event: ChangeEvent) => { // Fetch the party and account if (event.target.value) { call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { company: company, party_type: party_type, party: event.target.value, date: today() }).then((res) => { form.setValue('party_name', res.message.party_name) form.setValue('account', res.message.party_account) }) } else { // Clear the party and account form.setValue('party_name', '') form.setValue('account', '') } } return
{error && }
{party_type ? : }
{ if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { return acc.account_type === 'Payable' } else if (party_type === 'Customer') { return acc.account_type === 'Receivable' } return true }} />
} const PaymentEntryForm = ({ selectedTransaction, selectedBankAccount }: { selectedTransaction: UnreconciledTransaction, selectedBankAccount: SelectedBank }) => { const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) const onClose = () => { setIsOpen(false) } const { data: rule } = useGetRuleForTransaction(selectedTransaction) const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false const form = useForm({ defaultValues: { payment_type: isWithdrawal ? 'Pay' : 'Receive', bank_account: selectedTransaction.bank_account, company: selectedTransaction?.company, // If the money is paid, it's usually to a supplier. If it's received, it's usually from a customer party_type: rule?.party_type ?? (isWithdrawal ? 'Supplier' : 'Customer'), party: rule?.party ?? '', // If the transaction is a withdrawal, set the paid from to the selected bank account paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), // If the transaction is a deposit, set the paid to to the selected bank account paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), // Set the amount to the amount of the selected transaction paid_amount: selectedTransaction.unallocated_amount, base_paid_amount: selectedTransaction.unallocated_amount, received_amount: selectedTransaction.unallocated_amount, base_received_amount: selectedTransaction.unallocated_amount, reference_date: selectedTransaction.date, posting_date: selectedTransaction.date, reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), target_exchange_rate: 1, source_exchange_rate: 1, } }) const onReconcile = useRefreshUnreconciledTransactions() const setUnpaidInvoiceOpen = useSetAtom(isUnpaidInvoicesButtonOpen) useEffect(() => { if (rule && rule.party && rule.party_type && rule.account) { setUnpaidInvoiceOpen(true) } }, [rule, setUnpaidInvoiceOpen]) const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_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: PaymentEntry) => { createPaymentEntry({ bank_transaction_name: selectedTransaction.name, payment_entry_doc: { ...data, custom_remarks: data.remarks ? true : false } }).then(async ({ message }) => { addToActionLog({ type: 'payment', timestamp: (new Date()).getTime(), isBulk: false, items: [ { bankTransaction: message.transaction, voucher: { reference_doctype: "Payment Entry", reference_name: message.payment_entry.name, reference_no: message.payment_entry.reference_no, reference_date: message.payment_entry.reference_date, posting_date: message.payment_entry.posting_date, doc: message.payment_entry, } } ] }) toast.success(_("Payment 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: "Payment Entry", docname: message.payment_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) }) } else { return Promise.resolve() } }).then(() => { setUploadProgress(0) setIsUploading(false) onReconcile(selectedTransaction) onClose() }) } useHotkeys('meta+s', () => { form.handleSubmit(onSubmit)() }, { enabled: true, preventDefault: true, enableOnFormTags: true }) if (isUploading && isCompleted) { return } return
{error && }

{isWithdrawal ? _("Paid to") : _("Received from")}

} const isUnpaidInvoicesButtonOpen = atom(false) const PartyField = () => { const { control, setValue } = useFormContext() const party_type = useWatch({ control, name: `party_type` }) const { call } = useContext(FrappeContext) as FrappeConfig const company = useWatch({ control, name: 'company' }) const party_name = useWatch({ control, name: 'party_name' }) const type = useWatch({ control, name: 'payment_type' }) const party = useWatch({ control, name: 'party' }) const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen) const onChange = (event: ChangeEvent) => { // Fetch the party and account if (event.target.value) { call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { company: company, party_type: party_type, party: event.target.value, date: today() }).then((res) => { setValue('party_name', res.message.party_name) if (type === 'Pay') { setValue('paid_to', res.message.party_account) } else { setValue('paid_from', res.message.party_account) } setIsOpen(true) }) } else { // Clear the party and account setValue('party_name', '') if (type === 'Pay') { setValue('paid_to', '') } else { setValue('paid_from', '') } } } if (!party_type) { return } return } const AccountDropdown = ({ isWithdrawal }: { isWithdrawal: boolean }) => { // If it's a withdrawal, then we need to show the "Paid to" account // If it's a deposit, then we need to show the "Paid from" account const { control, setValue } = useFormContext() const party_type = useWatch({ control, name: 'party_type' }) const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen) const accountTypes: string[] | undefined = useMemo(() => { if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { return ['Payable'] } else if (party_type === 'Customer') { return ['Receivable'] } return undefined }, [party_type]) const onAccountChange = (event: ChangeEvent) => { if (event.target.value) { setValue('unallocated_amount', 0) setValue('total_allocated_amount', 0) setValue('difference_amount', 0) setValue('references', []) setIsOpen(true) } } if (isWithdrawal) { return } else { return } } const InvoicesSection = ({ currency }: { currency: string }) => { const { setTotalAllocatedAmount } = usePaymentEntryCalculations() const { control } = useFormContext() const { fields, remove } = useFieldArray({ control, name: 'references' }) 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]) return

{_("Invoices")}

0 && selectedRows.length === fields.length} onCheckedChange={onSelectAll} /> {_("Reference Document")} {_("Invoice No")} {_("Due Date")} {_("Grand Total")} {_("Outstanding")} {_("Allocated")} {fields.map((field, index) => ( onSelectRow(index)} // Make this accessible to screen readers aria-label={_("Select row {0}", [String(index + 1)])} /> {field.reference_doctype}: {field.reference_name} {field.bill_no ?? "-"} {formatDate(field.due_date)} {formatCurrency(field.total_amount, currency)} {formatCurrency(field.outstanding_amount, currency)} setTotalAllocatedAmount() }} hideLabel currency={currency} /> ))}
{selectedRows.length > 0 &&
}
} const DifferenceButton = ({ index, currency }: { index: number, currency: string }) => { const { setTotalAllocatedAmount } = usePaymentEntryCalculations() const { control, setValue } = useFormContext() const outstandingAmount = useWatch({ control, name: `references.${index}.outstanding_amount` }) ?? 0 const allocatedAmount = useWatch({ control, name: `references.${index}.allocated_amount` }) ?? 0 const difference = flt(outstandingAmount - allocatedAmount, 2) const onPayInFull = useCallback(() => { setValue(`references.${index}.allocated_amount`, outstandingAmount, { shouldDirty: true }) setTotalAllocatedAmount() }, [outstandingAmount, index, setValue, setTotalAllocatedAmount]) if (difference !== 0) { return {_("The invoice is not fully allocated as there is a difference of {0}.", [formatCurrency(difference, currency) ?? ''])}
{_("Click to pay in full.")}
} return null } const Summary = ({ currency }: { currency: string }) => { const { control, setValue, getValues } = useFormContext() const { setUnallocatedAmount } = usePaymentEntryCalculations() const amount = useWatch({ control, name: 'paid_amount' }) const unallocatedAmount = useWatch({ control, name: 'unallocated_amount' }) const allocatedAmount = useWatch({ control, name: 'total_allocated_amount' }) const differenceAmount = useWatch({ control, name: 'difference_amount' }) const onAddRow = useCallback((amount?: number) => { if (amount) { const deductions = getValues('deductions') ?? [] setValue('deductions', [...deductions, { amount: amount, account: '', cost_center: getCompanyCostCenter(getValues('company')), description: '' } as PaymentEntryDeduction]) setUnallocatedAmount() } }, [setUnallocatedAmount, getValues, setValue]) const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { return {children} } return
{_("Total Amount")} {formatCurrency(amount, currency)}
{_("Allocated")} {formatCurrency(allocatedAmount, currency)}
{(unallocatedAmount && unallocatedAmount !== 0) ?
{_("Unallocated")} {_("Add a charge to the payment entry with the unallocated amount")}
: null} {(differenceAmount && differenceAmount !== 0) ?
{_("Difference")} {_("Add a charge to the payment entry with the difference amount")}
: null}
} const GetUnpaidInvoicesButton = () => { const [isOpen, setIsOpen] = useAtom(isUnpaidInvoicesButtonOpen) const { control } = useFormContext() const partyType = useWatch({ control, name: 'party_type' }) const party = useWatch({ control, name: 'party' }) const partyName = useWatch({ control, name: 'party_name' }) const amount = useWatch({ control, name: 'paid_amount' }) return <> {partyType && party && } Select Invoices Unpaid invoices from {partyName} for {formatCurrency(amount)}. setIsOpen(false)} /> } interface OutstandingInvoice { voucher_type: string voucher_no: string bill_no?: string due_date: string invoice_amount: number outstanding_amount: number, payment_term?: string, payment_term_outstanding?: string, account?: string, allocated_amount?: number, } const FetchInvoicesModal = ({ onClose }: { onClose: () => void }) => { const { getValues, setValue } = useFormContext() const { allocatePartyAmount } = usePaymentEntryCalculations() const { data, isLoading, error } = useFrappeGetCall<{ message: OutstandingInvoice[], _server_messages?: string }>('erpnext.accounts.doctype.payment_entry.payment_entry.get_outstanding_reference_documents', { args: { company: getValues('company'), posting_date: getValues('posting_date'), party_type: getValues('party_type'), party: getValues('party'), party_account: getValues('payment_type') === 'Pay' ? getValues('paid_to') : getValues('paid_from'), get_outstanding_invoices: true, allocate_payment_amount: 1 } }) const message = useMemo(() => { if (data && data._server_messages) { const message = JSON.parse(JSON.parse(data._server_messages)[0]) return message.message } return '' }, [data]) const [selectedInvoices, setSelectedInvoices] = useState([]) const onSelectRow = (row: OutstandingInvoice) => { if (selectedInvoices.includes(row)) { setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== row)) } else { setSelectedInvoices([...selectedInvoices, row]) } } const { call: allocateAmountToReferences, loading: allocateAmountToReferencesLoading, error: allocateAmountToReferencesError } = useFrappePostCall('run_doc_method') const onSelect = () => { allocateAmountToReferences({ args: { paid_amount: getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount"), allocate_payment_amount: 1, paid_amount_change: false }, method: 'allocate_amount_to_references', docs: { doctype: 'Payment Entry', ...getValues(), name: "new-payment-entry-1", __unsaved: 1, __islocal: 1, references: selectedInvoices.map((ref: OutstandingInvoice) => ({ reference_doctype: ref.voucher_type, reference_name: ref.voucher_no, due_date: ref.due_date, total_amount: ref.invoice_amount, outstanding_amount: ref.outstanding_amount, bill_no: ref.bill_no, payment_term: ref.payment_term, payment_term_outstanding: ref.payment_term_outstanding, allocated_amount: ref.allocated_amount, account: ref.account, exchange_rate: 1, })) } }).then((res) => { const doc = res.docs[0] setValue('references', doc.references) setValue('unallocated_amount', doc.unallocated_amount) setValue('total_allocated_amount', doc.total_allocated_amount) setValue('difference_amount', doc.difference_amount) allocatePartyAmount(getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount")) onClose() }) } return
{isLoading ? : null} {error && } {error && } {message ? } /> : null} {data?.message && data?.message?.length > 0 ? { if (checked) { setSelectedInvoices(data?.message) } else { setSelectedInvoices([]) } }} /> Type Name Invoice No Due Date Grand Total Outstanding {data.message.map((ref) => ( { const target = e.target as HTMLElement // Do not select the checkbox if the user clicks on the checkbox or the link if (target.tagName !== 'INPUT' && !target.className.includes('chakra-checkbox') && !target.className.includes('chakra-link')) { onSelectRow(ref) } }} className="cursor-pointer"> { if (checked) { setSelectedInvoices([...selectedInvoices, ref]) } else { setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== ref)) } }} /> {ref.voucher_type} {ref.voucher_no} {ref.bill_no ?? "-"} {formatDate(ref.due_date)} {formatCurrency(ref.invoice_amount)} {formatCurrency(ref.outstanding_amount)} ))}
: null}
Invoices: {selectedInvoices.length} / Total: {formatCurrency(selectedInvoices.reduce((acc, invoice) => acc + invoice.outstanding_amount, 0))}
} const OtherChargesSection = ({ currency }: { currency: string }) => { const { setTotalAllocatedAmount } = usePaymentEntryCalculations() const { getValues, control } = useFormContext() const { fields, append, remove } = useFieldArray({ control: control, name: 'deductions' }) 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([]) setTotalAllocatedAmount() }, [remove, selectedRows, setTotalAllocatedAmount]) const onAdd = () => { append({ account: '', cost_center: getCompanyCostCenter(getValues('company')), description: '', amount: 0 } as PaymentEntryDeduction) } return

Other Charges / Deductions

0 && selectedRows.length === fields.length} onCheckedChange={onSelectAll} /> {_("Account")} * {_("Cost Center")} * {_("Description")} {_("Amount")} * {fields.map((field, index) => ( onSelectRow(index)} // Make this accessible to screen readers aria-label={_("Select row {0}", [String(index + 1)])} /> { setTotalAllocatedAmount() } }} /> ))}
{selectedRows.length > 0 &&
}
} const TotalDeductions = ({ currency }: { currency: string }) => { const { control } = useFormContext() const total_deductions = useWatch({ control, name: 'deductions' })?.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0) ?? 0 return ({formatCurrency(total_deductions, currency)}) } export default RecordPaymentModal