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
}
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
}
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 &&
{_("Remove")}
}
}
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")}
onAddRow(unallocatedAmount ?? 0)}>
{formatCurrency(unallocatedAmount, currency)}
{_("Add a charge to the payment entry with the unallocated amount")}
: null}
{(differenceAmount && differenceAmount !== 0) ?
{_("Difference")}
onAddRow(differenceAmount ?? 0)}>
{formatCurrency(differenceAmount, currency)}
{_("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 &&
Get Unpaid Invoices
}
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))}
Cancel
Select
}
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 &&
{_("Remove")}
}
}
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