Merge upstream/develop into erpnext-refactoring

Resolve 4 conflicts from Phase 7 service/mapper extraction vs upstream:
- asset.py: take extraction; repoint dangling make_asset_movement JS to mapper
- job_card: port upstream field_no_map(naming_series) into mapper.make_subcontracting_po
- sales_order: port upstream rows-index fix into mapper.make_delivery_note
- sales_invoice (Phase 7): take service delegations; port upstream SQL->QB/ORM
  changes for get_warehouse, get_all_mode_of_payments, get_discounting_status,
  clear_unallocated_mode_of_payments, and set_pos_fields(POS DN skip) into services
This commit is contained in:
Nabin Hait
2026-06-03 11:05:01 +05:30
247 changed files with 113261 additions and 46190 deletions

52
.github/helper/merge_po_files.py vendored Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Overlay develop's .po translations onto hotfix's .po files.
Called by sync_hotfix_translations.sh before `bench update-po-files`.
Merge rules:
a. msgid absent from develop → keep hotfix's existing msgstr
b. language not yet in hotfix → copy file as-is (bench will filter to main.pot)
c. msgid present in both → use develop's msgstr
"""
from datetime import datetime, timezone
from pathlib import Path
from babel.messages.pofile import read_po, write_po
DEVELOP = Path("/tmp/develop-po/erpnext/locale/")
LOCALE = Path("./apps/erpnext/erpnext/locale/")
added = updated = 0
for src in sorted(DEVELOP.glob("*.po")):
dst = LOCALE / src.name
with src.open("rb") as f:
dev = read_po(f)
if not dst.exists():
dev.revision_date = datetime.now(timezone.utc)
with dst.open("wb") as f:
write_po(f, dev)
added += 1
print(f" [new] {src.name}")
continue
with dst.open("rb") as f:
hf = read_po(f)
changes = 0
for msg in hf:
if msg.id and msg.id in dev and dev[msg.id].string and dev[msg.id].string != msg.string:
msg.string = dev[msg.id].string
changes += 1
if changes:
hf.revision_date = datetime.now(timezone.utc)
with dst.open("wb") as f:
write_po(f, hf)
updated += 1
print(f" [updated] {src.name} ({changes} msgstr(s) from develop)")
else:
print(f" [no-op] {src.name}")
print(f"\n{added} new language(s), {updated} updated.")

View File

@@ -0,0 +1,121 @@
#!/bin/bash
# Syncs Crowdin translations from develop to a hotfix branch.
# Merge logic: see merge_po_files.py.
# Env: GH_TOKEN, PR_REVIEWER, GITHUB_WORKSPACE, APP_NAME, GITHUB_REPOSITORY
# (all set by Actions).
set -e
HOTFIX_BRANCH="${HOTFIX_BRANCH:?HOTFIX_BRANCH env var is required}"
APP_NAME="${APP_NAME:?APP_NAME env var is required}"
cd ~ || exit
echo "=== Setting up bench ==="
pip install frappe-bench
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
cd ./frappe-bench || exit
bench get-app --skip-assets "${APP_NAME}" "${GITHUB_WORKSPACE}"
echo "=== Setting up sync_translations_${HOTFIX_BRANCH} branch ==="
cd "./apps/${APP_NAME}" || exit
git config user.email "developers@erpnext.com"
git config user.name "frappe-pr-bot"
git remote set-url upstream "https://github.com/${GITHUB_REPOSITORY}.git"
git config remote.upstream.fetch "+refs/heads/*:refs/remotes/upstream/*"
gh auth setup-git
git fetch upstream "${HOTFIX_BRANCH}"
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/sync_translations_${HOTFIX_BRANCH}"
git merge -X theirs "upstream/${HOTFIX_BRANCH}" --no-edit
else
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/${HOTFIX_BRANCH}"
fi
cd ../.. || exit
echo "=== Fetching develop's .po files ==="
mkdir -p /tmp/develop-po
git -C "${GITHUB_WORKSPACE}" fetch origin develop
git -C "${GITHUB_WORKSPACE}" archive origin/develop "${APP_NAME}/locale/" \
| tar -xf - -C /tmp/develop-po/
po_count=$(find "/tmp/develop-po/${APP_NAME}/locale" -name "*.po" | wc -l)
if [ "${po_count}" -eq 0 ]; then
echo "ERROR: No .po files found in develop's archive. Aborting." >&2
exit 1
fi
echo "Extracted ${po_count} .po file(s) from develop."
echo "=== Merging and reconciling ==="
env/bin/python "${GITHUB_WORKSPACE}/.github/helper/merge_po_files.py"
bench update-po-files --app "${APP_NAME}"
cd "./apps/${APP_NAME}" || exit
if git diff --quiet "${APP_NAME}/locale/" && [ -z "$(git ls-files --others --exclude-standard "${APP_NAME}/locale/")" ]; then
echo "Translations are already up to date. No PR needed."
exit 0
fi
echo "Changed files:"
git diff --name-only "${APP_NAME}/locale/"
git ls-files --others --exclude-standard "${APP_NAME}/locale/"
echo "=== Committing ==="
while IFS= read -r file; do
git add "${file}"
lang=$(basename "${file}" .po)
git commit -m "chore: add ${lang} translation to ${HOTFIX_BRANCH}"
done < <(git ls-files --others --exclude-standard "${APP_NAME}/locale/" | grep '\.po$' | sort)
while IFS= read -r file; do
git add "${file}"
if ! git diff --staged --quiet -- "${file}"; then
lang=$(basename "${file}" .po)
git commit -m "chore: sync ${lang} translation to ${HOTFIX_BRANCH}"
else
git restore --staged -- "${file}"
fi
done < <(git diff --name-only "${APP_NAME}/locale/" | grep '\.po$' | sort)
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
git merge -X ours "upstream/sync_translations_${HOTFIX_BRANCH}" --no-edit
fi
git push -u upstream sync_translations_${HOTFIX_BRANCH}
echo "=== Opening PR (if not already open) ==="
existing_pr=$(gh pr list \
--base "${HOTFIX_BRANCH}" \
--head "sync_translations_${HOTFIX_BRANCH}" \
--state open \
--json number \
--jq 'length' \
-R "${GITHUB_REPOSITORY}")
if [ "${existing_pr}" -gt 0 ]; then
echo "PR already open — branch updated in place. No new PR needed."
exit 0
fi
gh pr create \
--base "${HOTFIX_BRANCH}" \
--head "sync_translations_${HOTFIX_BRANCH}" \
--title "chore: sync translations to ${HOTFIX_BRANCH}" \
--body "Automated sync of Crowdin translations from \`develop\` to \`${HOTFIX_BRANCH}\`.
A 3-way merge is performed per language, then \`bench update-po-files\` reconciles each \`.po\` against hotfix's \`main.pot\`:
| Case | Condition | Result |
|------|-----------|--------|
| **a** | \`msgid\` in hotfix's \`main.pot\`, **not** in develop's \`.po\` | Hotfix's existing \`msgstr\` is **preserved** (string removed from develop but still needed in hotfix) |
| **b** | \`msgid\` **not** in hotfix's \`main.pot\` | **Dropped** from hotfix's \`.po\` |
| **c** | \`msgid\` in both hotfix's \`main.pot\` and develop's \`.po\` | Develop's \`msgstr\` is used (Crowdin translation wins) |
Generated by the \`sync-hotfix-translations\` workflow." \
--label "translation" \
--label "skip-release-notes" \
--reviewer "${PR_REVIEWER}" \
-R "${GITHUB_REPOSITORY}"

View File

@@ -0,0 +1,70 @@
name: Build and Upload Assets
on:
push:
branches:
- develop
- 'version-*'
concurrency:
group: build-assets-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
build-assets:
name: Build JS/CSS and upload to release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: frappe/frappe
path: apps/frappe
ref: ${{ github.ref_name }}
- uses: actions/checkout@v4
with:
path: apps/erpnext
- name: Create bench structure
run: |
mkdir -p sites
printf "frappe\nerpnext\n" > sites/apps.txt
- uses: actions/setup-node@v4
with:
node-version: 24
cache: yarn
cache-dependency-path: apps/frappe/yarn.lock
- name: Install frappe JS dependencies
working-directory: apps/frappe
run: yarn install --frozen-lockfile
- name: Install erpnext JS dependencies
working-directory: apps/erpnext
run: yarn install --frozen-lockfile --ignore-scripts
- name: Link node_modules into public/
working-directory: apps/frappe
run: ln -s "$PWD/node_modules" frappe/public/node_modules
- name: Build assets (production)
working-directory: apps/frappe
run: yarn run production
- name: Package assets
working-directory: apps/erpnext
run: tar czf erpnext-assets.tar.gz -C ../../sites/assets/erpnext dist
- name: Upload to rolling release
working-directory: apps/erpnext
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="assets-${GITHUB_REF_NAME//\//-}"
gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true
gh release upload "$TAG" erpnext-assets.tar.gz --clobber

View File

@@ -0,0 +1,52 @@
# Runner — maintain this file on each hotfix branch, not on develop.
#
# Fires when main.pot changes on this branch (i.e. after a POT update PR
# merges), or when dispatched by the orchestrator on develop (weekly schedule).
#
# Uses github.ref_name so the file is identical across all hotfix branches
# with no branch-specific edits required.
name: Run hotfix translation sync
on:
workflow_dispatch:
# One run at a time per branch. cancel-in-progress: false to avoid leaving
# an orphaned remote branch from a mid-flight git push + gh pr create.
concurrency:
group: sync-hotfix-translations-${{ github.ref_name }}
cancel-in-progress: false
jobs:
sync-translations:
name: Sync translations from develop into ${{ github.ref_name }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
HOTFIX_BRANCH: ${{ github.ref_name }}
APP_NAME: ${{ github.event.repository.name }}
steps:
- name: Checkout ${{ env.HOTFIX_BRANCH }}
uses: actions/checkout@v6
with:
ref: ${{ env.HOTFIX_BRANCH }}
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Run sync script
run: |
bash "${GITHUB_WORKSPACE}/.github/helper/sync_hotfix_translations.sh"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
PR_REVIEWER: diptanilsaha

View File

@@ -0,0 +1,40 @@
# Orchestrator — lives on develop only.
#
# Triggers on the weekly schedule and dispatches the runner workflow on each
# hotfix branch listed in the matrix. To add or remove a branch, edit the
# matrix below.
#
# POT-change triggers are handled by the runner on each hotfix branch
# (run-hotfix-translation-sync.yml), since GitHub only evaluates a workflow
# from the branch that receives the push.
name: Sync translations to hotfix branches
on:
schedule:
# 10:00 UTC Monday
- cron: "0 10 * * 1"
workflow_dispatch:
# The runner dispatch uses RELEASE_TOKEN (a PAT), not the default GITHUB_TOKEN,
# so no GITHUB_TOKEN permissions are required.
permissions: {}
jobs:
trigger-runners:
name: Trigger sync → ${{ matrix.hotfix_branch }}
runs-on: ubuntu-latest
strategy:
matrix:
hotfix_branch:
- version-16-hotfix
fail-fast: false
steps:
- name: Dispatch runner on ${{ matrix.hotfix_branch }}
run: |
gh workflow run run-hotfix-translation-sync.yml \
--repo "${{ github.repository }}" \
--ref "${{ matrix.hotfix_branch }}"
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -9,16 +9,18 @@ export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite],
plugins: {
"react-hooks": reactHooks,
},
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
onlyExportComponents: false,
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react-refresh/only-export-components": "off",
},
},
]);

View File

@@ -41,7 +41,6 @@
"react-markdown": "^10.1.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"react-virtuoso": "^4.18.6",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",

View File

@@ -1,13 +1,17 @@
const common_site_config = require('../../../sites/common_site_config.json');
import { readFileSync } from 'node:fs';
const common_site_config = JSON.parse(
readFileSync(new URL('../../../sites/common_site_config.json', import.meta.url), 'utf8')
) as { webserver_port: string | number };
const { webserver_port } = common_site_config;
export default {
'^/(app|api|assets|files|private)': {
target: `http://127.0.0.1:${webserver_port}`,
ws: true,
router: function(req) {
const site_name = req.headers.host.split(':')[0];
return `http://${site_name}:${webserver_port}`;
router: function (req) {
const site_name = req.headers?.host?.split(':')[0];
return `http://${site_name ?? 'localhost'}:${webserver_port}`;
}
}
};

View File

@@ -1,14 +1,15 @@
import { useEffect } from 'react'
import { lazy, useEffect } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { FrappeProvider } from 'frappe-react-sdk'
import { Toaster } from '@/components/ui/sonner'
import BankReconciliation from '@/pages/BankReconciliation'
import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer'
import { TooltipProvider } from './components/ui/tooltip'
import BankStatementImporter from '@/pages/BankStatementImporter'
import { LucideProvider } from 'lucide-react'
import { ThemeProvider } from './components/ui/theme-provider'
import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog'
import BankStatementImporterContainer from './pages/BankStatementImporterContainer'
const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter'))
const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog'))
function App() {
useEffect(() => {
@@ -43,7 +44,6 @@ function App() {
>
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
<Routes>
<Route index element={<BankReconciliation />} />
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>

View File

@@ -1,475 +1,42 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { useAtomValue, useSetAtom } from 'jotai'
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
import { HistoryIcon } from 'lucide-react'
import { useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useGetBankAccounts } from '../BankReconciliation/utils'
import { getCompanyCurrency } from '@/lib/company'
import { formatCurrency } from '@/lib/numbers'
import dayjs from 'dayjs'
import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/date'
import { Separator } from '@/components/ui/separator'
import { slug } from '@/lib/frappe'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { JournalEntry } from '@/types/Accounts/JournalEntry'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
import { toast } from 'sonner'
import { getErrorMessage } from '@/lib/frappe'
import ErrorBanner from '@/components/ui/error-banner'
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import BankLogo from '@/components/common/BankLogo'
import ActionLogDialog from './ActionLogDialog'
const ActionLog = () => {
const [isOpen, setIsOpen] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useHotkeys('meta+z', () => {
setIsOpen(true)
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
useHotkeys('meta+z', () => {
setIsOpen(true)
}, {
enabled: true,
enableOnFormTags: false,
preventDefault: true
})
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md'>
<HistoryIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Reconciliation History")}
</TooltipContent>
</Tooltip>
<DialogContent className='min-w-[90vw]'>
<DialogHeader>
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
</DialogHeader>
<ActionLogDialogContent />
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} size='md' onClick={() => setIsOpen(false)}>{_("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md'>
<HistoryIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Reconciliation History")}
</TooltipContent>
</Tooltip>
{isOpen && (
<ActionLogDialog onClose={() => setIsOpen(false)} />
)}
</Dialog>
)
}
const ActionLogDialogContent = () => {
const actionLog = useAtomValue(bankRecActionLog)
return <div className='flex flex-col gap-2'>
{actionLog.map((action) => (
<div key={action.timestamp} className='flex flex-col gap-1'>
<ActionGroupHeader action={action} />
<div>
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
<div className='ms-5'>
{action.items.map((item, index) => (
<Row
item={item}
key={item.bankTransaction.name}
index={index}
action={action}
isLast={index === action.items.length - 1} />
))}
</div>
</div>
</div>
</div>
))}
{actionLog.length === 0 && <Empty>
<EmptyMedia>
<HistoryIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
</EmptyHeader>
</Empty>}
</div>
}
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
const label = useMemo(() => {
switch (action.type) {
case 'match':
return _("Matched")
case 'payment':
if (action.isBulk) {
return _("Bulk Payment")
}
return _("Payment")
case 'transfer':
if (action.isBulk) {
return _("Bulk Transfer")
}
return _("Transfer")
case 'bank_entry':
if (action.isBulk) {
return _("Bulk Bank Entry")
}
return _("Bank Entry")
default:
return _("Action")
}
}, [action])
return <div className='flex items-center gap-2 text-ink-gray-5'>
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
<span className='flex items-center gap-2 text-sm'>
{label} - {dayjs(action.timestamp).fromNow()}
</span>
</div>
}
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (item.bankTransaction.bank_account) {
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
}
return null
}, [item.bankTransaction.bank_account, banks])
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
return <div className='flex items-center gap-2 group'>
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-2'>
<p className='text-p-base'>{item.bankTransaction.description}</p>
<div className='flex items-center gap-3'>
<div className='flex gap-2 items-center'>
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
</div>
<Separator orientation='vertical' />
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
<CalendarIcon className='w-4 h-4' />
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
</div>
<Separator orientation='vertical' />
<div>
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
</div>
</div>
</div>
</div>
<div className='flex justify-end items-center gap-2'>
<div className='text-end flex flex-col gap-2'>
<a
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
target='_blank'
className='underline underline-offset-4 text-base'>
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
</a>
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
</div>
</div>
</div>
</div>
<div className='w-10 h-10 flex items-center justify-center'>
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
</div>
</div>
}
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
<WalletIcon className='w-4 h-4' />
<JournalEntryAccountsTable item={item} bank={bank} />
</div>
}
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
const accounts = useMemo(() => {
const allAccounts = (item.voucher.doc as JournalEntry).accounts
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
}, [item, bank])
return <>
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
<HoverCard>
<HoverCardTrigger>
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Account")}</TableHead>
<TableHead className='text-end'>{_("Debit")}</TableHead>
<TableHead className='text-end'>{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.account}>
<TableCell>{account.account}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</HoverCardContent>
</HoverCard>
}</>
}
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
return <TransferDetails item={item} className={className} />
}
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
return <div className='flex items-center gap-3'>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<UserIcon className='w-4 h-4' />
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
</div>
<Separator orientation='vertical' />
<HoverCard>
<HoverCardTrigger>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<ReceiptTextIcon className='w-4 h-4' />
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<div className='flex flex-col gap-2'>
{invoices.map((invoice) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Invoice No")}</TableHead>
<TableHead>{_("Due Date")}</TableHead>
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
<TableHead className='text-end'>{_("Allocated")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
<TableCell>{formatDate(invoice.due_date)}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
</TableRow>
</TableBody>
</Table>
))}
</div>
</HoverCardContent>
</HoverCard>
</div>
}
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
let transferAccount = ""
if (isWithdrawal) {
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
} else {
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
}
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
return transferBankAccount
}, [banks, item])
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
<span className='text-sm'>{bank?.account}</span>
</div>
}
const ACTION_TYPE_MAP = {
'bank_entry': _("Bank Entry"),
'payment': _("Payment"),
'transfer': _("Transfer"),
'match': _("Match"),
}
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
const [isOpen, setIsOpen] = useState(false)
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
const { mutate } = useSWRConfig()
const actionLog = useSetAtom(bankRecActionLog)
const dates = useAtomValue(bankRecDateAtom)
const matchFilters = useAtomValue(bankRecMatchFilters)
const selectedBank = useAtomValue(selectedBankAccountAtom)
const onUndo = () => {
call({
bank_transaction_id: item.bankTransaction.name,
voucher_type: item.voucher.reference_doctype,
voucher_id: item.voucher.reference_name,
}).then(() => {
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
if (selectedBank?.name === item.bankTransaction.bank_account) {
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-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
}
setTimeout(() => {
actionLog((prev) => {
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
const action = prev.find((action) => action.timestamp === timestamp)
if (action) {
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
}
// If the action is empty, remove the action from the array
if (action && action.items.length === 0) {
return prev.filter((a) => a.timestamp !== timestamp)
} else {
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
}
})
}, 100)
setIsOpen(false)
}).catch((error) => {
toast.error(_("There was an error while performing the action."), {
duration: 5000,
description: getErrorMessage(error),
})
})
}
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
variant={'ghost'}
isIconButton
theme='red'
title={_("Cancel")}
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
<CircleXIcon className='w-8 h-8' />
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Cancel")}
</TooltipContent>
</Tooltip>
<AlertDialogContent className='min-w-3xl'>
<AlertDialogHeader>
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
</AlertDialogHeader>
{error && <ErrorBanner error={error} />}
<div className='flex flex-col gap-2'>
<SelectedTransactionDetails transaction={item.bankTransaction} />
<Table>
<TableRow>
<TableHead>{_("Action Type")}</TableHead>
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Type")}</TableHead>
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Name")}</TableHead>
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Posting Date")}</TableHead>
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
</TableRow>
{type === 'transfer' && item.voucher.doc && <TableRow>
<TableHead>{_("Transfer Account")}</TableHead>
<TableCell>
<TransferDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'payment' && item.voucher.doc && <TableRow>
<TableHead>{_("Payment Details")}</TableHead>
<TableCell>
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'bank_entry' && item.voucher.doc && <TableRow>
<TableHead>{_("Account")}</TableHead>
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
</TableRow>}
</Table>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
{_("Close")}
</AlertDialogCancel>
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
export default ActionLog
export default ActionLog

View File

@@ -0,0 +1,34 @@
import { Button } from '@/components/ui/button'
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import _ from '@/lib/translate'
import { Loader2Icon } from 'lucide-react'
import { lazy, Suspense } from 'react'
const ActionLogDialogBody = lazy(() => import('./ActionLogDialogBody'))
const ActionLogDialogFallback = () => (
<div className="flex flex-1 items-center justify-center min-h-[40vh]">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
)
const ActionLogDialog = ({ onClose }: { onClose: () => void }) => {
return (
<DialogContent className='min-w-[90vw]'>
<DialogHeader>
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
</DialogHeader>
<Suspense fallback={<ActionLogDialogFallback />}>
<ActionLogDialogBody />
</Suspense>
<DialogFooter>
<DialogClose asChild>
<Button variant={'outline'} size='md' onClick={onClose}>{_("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
)
}
export default ActionLogDialog

View File

@@ -0,0 +1,431 @@
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { useAtomValue, useSetAtom } from 'jotai'
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
import { useGetBankAccounts } from '../BankReconciliation/utils'
import { getCompanyCurrency } from '@/lib/company'
import { formatCurrency } from '@/lib/numbers'
import dayjs from 'dayjs'
import { cn } from '@/lib/utils'
import { formatDate } from '@/lib/date'
import { Separator } from '@/components/ui/separator'
import { slug } from '@/lib/frappe'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { JournalEntry } from '@/types/Accounts/JournalEntry'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
import { toast } from 'sonner'
import { getErrorMessage } from '@/lib/frappe'
import ErrorBanner from '@/components/ui/error-banner'
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import BankLogo from '@/components/common/BankLogo'
const ActionLogDialogBody = () => {
const actionLog = useAtomValue(bankRecActionLog)
return <div className='flex flex-col gap-2'>
{actionLog.map((action) => (
<div key={action.timestamp} className='flex flex-col gap-1'>
<ActionGroupHeader action={action} />
<div>
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
<div className='ms-5'>
{action.items.map((item, index) => (
<Row
item={item}
key={item.bankTransaction.name}
index={index}
action={action}
isLast={index === action.items.length - 1} />
))}
</div>
</div>
</div>
</div>
))}
{actionLog.length === 0 && <Empty>
<EmptyMedia>
<HistoryIcon />
</EmptyMedia>
<EmptyHeader>
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
</EmptyHeader>
</Empty>}
</div>
}
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
const label = useMemo(() => {
switch (action.type) {
case 'match':
return _("Matched")
case 'payment':
if (action.isBulk) {
return _("Bulk Payment")
}
return _("Payment")
case 'transfer':
if (action.isBulk) {
return _("Bulk Transfer")
}
return _("Transfer")
case 'bank_entry':
if (action.isBulk) {
return _("Bulk Bank Entry")
}
return _("Bank Entry")
default:
return _("Action")
}
}, [action])
return <div className='flex items-center gap-2 text-ink-gray-5'>
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
<span className='flex items-center gap-2 text-sm'>
{label} - {dayjs(action.timestamp).fromNow()}
</span>
</div>
}
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (item.bankTransaction.bank_account) {
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
}
return null
}, [item.bankTransaction.bank_account, banks])
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
return <div className='flex items-center gap-2 group'>
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-2'>
<p className='text-p-base'>{item.bankTransaction.description}</p>
<div className='flex items-center gap-3'>
<div className='flex gap-2 items-center'>
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
</div>
<Separator orientation='vertical' />
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
<CalendarIcon className='w-4 h-4' />
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
</div>
<Separator orientation='vertical' />
<div>
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
</div>
</div>
</div>
</div>
<div className='flex justify-end items-center gap-2'>
<div className='text-end flex flex-col gap-2'>
<a
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
target='_blank'
className='underline underline-offset-4 text-base'>
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
</a>
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
</div>
</div>
</div>
</div>
<div className='w-10 h-10 flex items-center justify-center'>
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
</div>
</div>
}
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
<WalletIcon className='w-4 h-4' />
<JournalEntryAccountsTable item={item} bank={bank} />
</div>
}
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
const accounts = useMemo(() => {
const allAccounts = (item.voucher.doc as JournalEntry).accounts
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
}, [item, bank])
return <>
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
<HoverCard>
<HoverCardTrigger>
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Account")}</TableHead>
<TableHead className='text-end'>{_("Debit")}</TableHead>
<TableHead className='text-end'>{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.account}>
<TableCell>{account.account}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</HoverCardContent>
</HoverCard>
}</>
}
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
return <TransferDetails item={item} className={className} />
}
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
return <div className='flex items-center gap-3'>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<UserIcon className='w-4 h-4' />
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
</div>
<Separator orientation='vertical' />
<HoverCard>
<HoverCardTrigger>
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<ReceiptTextIcon className='w-4 h-4' />
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
</div>
</HoverCardTrigger>
<HoverCardContent className='w-full p-2' align='end'>
<div className='flex flex-col gap-2'>
{invoices.map((invoice) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Invoice No")}</TableHead>
<TableHead>{_("Due Date")}</TableHead>
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
<TableHead className='text-end'>{_("Allocated")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
<TableCell>{formatDate(invoice.due_date)}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
</TableRow>
</TableBody>
</Table>
))}
</div>
</HoverCardContent>
</HoverCard>
</div>
}
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
let transferAccount = ""
if (isWithdrawal) {
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
} else {
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
}
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
return transferBankAccount
}, [banks, item])
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
<span className='text-sm'>{bank?.account}</span>
</div>
}
const ACTION_TYPE_MAP = {
'bank_entry': _("Bank Entry"),
'payment': _("Payment"),
'transfer': _("Transfer"),
'match': _("Match"),
}
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
const [isOpen, setIsOpen] = useState(false)
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
const { mutate } = useSWRConfig()
const actionLog = useSetAtom(bankRecActionLog)
const dates = useAtomValue(bankRecDateAtom)
const matchFilters = useAtomValue(bankRecMatchFilters)
const selectedBank = useAtomValue(selectedBankAccountAtom)
const onUndo = () => {
call({
bank_transaction_id: item.bankTransaction.name,
voucher_type: item.voucher.reference_doctype,
voucher_id: item.voucher.reference_name,
}).then(() => {
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
if (selectedBank?.name === item.bankTransaction.bank_account) {
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-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
}
setTimeout(() => {
actionLog((prev) => {
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
const action = prev.find((action) => action.timestamp === timestamp)
if (action) {
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
}
// If the action is empty, remove the action from the array
if (action && action.items.length === 0) {
return prev.filter((a) => a.timestamp !== timestamp)
} else {
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
}
})
}, 100)
setIsOpen(false)
}).catch((error) => {
toast.error(_("There was an error while performing the action."), {
duration: 5000,
description: getErrorMessage(error),
})
})
}
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
variant={'ghost'}
isIconButton
theme='red'
title={_("Cancel")}
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
<CircleXIcon className='w-8 h-8' />
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Cancel")}
</TooltipContent>
</Tooltip>
<AlertDialogContent className='min-w-3xl'>
<AlertDialogHeader>
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
</AlertDialogHeader>
{error && <ErrorBanner error={error} />}
<div className='flex flex-col gap-2'>
<SelectedTransactionDetails transaction={item.bankTransaction} />
<Table>
<TableRow>
<TableHead>{_("Action Type")}</TableHead>
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Type")}</TableHead>
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Voucher Name")}</TableHead>
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Posting Date")}</TableHead>
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
</TableRow>
{type === 'transfer' && item.voucher.doc && <TableRow>
<TableHead>{_("Transfer Account")}</TableHead>
<TableCell>
<TransferDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'payment' && item.voucher.doc && <TableRow>
<TableHead>{_("Payment Details")}</TableHead>
<TableCell>
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
</TableCell>
</TableRow>}
{type === 'bank_entry' && item.voucher.doc && <TableRow>
<TableHead>{_("Account")}</TableHead>
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
</TableRow>}
</Table>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>
{_("Close")}
</AlertDialogCancel>
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
export default ActionLogDialogBody

View File

@@ -83,7 +83,7 @@ const BankClearanceSummaryView = () => {
toast.success(_("Copied to clipboard"))
})
},
[copyToClipboard, _],
[copyToClipboard],
)
const accountCurrency = useMemo(
@@ -200,7 +200,7 @@ const BankClearanceSummaryView = () => {
},
},
],
[_, accountCurrency, bankAccount, companyID, mutate, onCopy],
[accountCurrency, bankAccount, companyID, mutate, onCopy],
)
return <div className="space-y-4 py-2">

View File

@@ -1,831 +1,32 @@
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 { useAtom } from "jotai"
import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms"
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
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"
import { lazy, Suspense } from "react"
const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent'))
const BankEntryModal = () => {
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-[95vw]'>
<DialogHeader>
<DialogTitle>{_("Bank Entry")}</DialogTitle>
<DialogDescription>
{_("Record a journal entry for expenses, income or split transactions.")}
</DialogDescription>
</DialogHeader>
<RecordBankEntryModalContent />
</DialogContent>
</Dialog>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-[95vw]'>
<DialogHeader>
<DialogTitle>{_("Bank Entry")}</DialogTitle>
<DialogDescription>
{_("Record a journal entry for expenses, income or split transactions.")}
</DialogDescription>
</DialogHeader>
{isOpen && (
<Suspense fallback={<ModalContentFallback />}>
<RecordBankEntryModalContent />
</Suspense>
)}
</DialogContent>
</Dialog>
)
}
const RecordBankEntryModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <BankEntryForm
selectedTransaction={selectedTransaction[0]} />
}
return <BulkBankEntryForm
selectedTransactions={selectedTransaction}
/>
}
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 <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<div className="grid grid-cols-3 gap-4">
<AccountFormField
name='account'
filterFunction={(acc) => {
// Do not allow payable and receivable accounts
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
}}
label={_('Account')}
isRequired
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
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<JournalEntryAccount>[] = [
{
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<BankEntryFormData>({
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<File[]>([])
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 <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='cheque_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
rules={{
required: _("Reference Date is required"),
}}
/>
</div>
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
rules={{
required: _("Reference is required"),
}} />
</div>
</div>
<div>
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
</div>
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='user_remark'
label={_("Remarks")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
const { call } = useContext(FrappeContext) as FrappeConfig
const partyMapRef = useRef<Record<string, string>>({})
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<number[]>([])
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 <div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableHead><Checkbox
disabled={fields.length === 0}
// Make this accessible to screen readers
aria-label={_("Select all")}
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
onCheckedChange={onSelectAll} /></TableHead>
<TableHead>{_("Party")}</TableHead>
<TableHead>{_("Account")}</TableHead>
<TableHead>{_("Cost Center")}</TableHead>
<TableHead>{_("Remarks")}</TableHead>
<TableHead className="text-end">{_("Debit")}</TableHead>
<TableHead className="text-end">{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
<TableCell>
<Checkbox
checked={selectedRows.includes(index)}
onCheckedChange={() => onSelectRow(index)}
// Make this accessible to screen readers
aria-label={_("Select row {0}", [String(index + 1)])}
disabled={index === 0}
/>
</TableCell>
<TableCell className="align-top">
<div className="flex">
<PartyTypeFormField
name={`entries.${index}.party_type`}
label={_("Party Type")}
isRequired
readOnly={index === 0}
hideLabel
inputProps={{
type: isWithdrawal ? 'Payable' : 'Receivable',
triggerProps: {
className: 'rounded-e-none',
tabIndex: -1
},
readOnly: index === 0,
}} />
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
</div>
</TableCell>
<TableCell className="align-top">
<AccountFormField
name={`entries.${index}.account`}
label={_("Account")}
rules={{
required: _("Account is required"),
onChange: (event) => {
onAccountChange(event.target.value, index)
}
}}
buttonClassName="min-w-64"
readOnly={index === 0}
isRequired
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<LinkFormField
doctype="Cost Center"
name={`entries.${index}.cost_center`}
label={_("Cost Center")}
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
buttonClassName="min-w-48"
readOnly={index === 0}
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<DataField
name={`entries.${index}.user_remark`}
label={_("Remarks")}
readOnly={index === 0}
inputProps={{
placeholder: _("e.g. Bank Charges"),
className: 'min-w-64',
readOnly: index === 0
}}
hideLabel
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.debit`}
label={_("Debit")}
isRequired
hideLabel
readOnly={index === 0}
style={index === 0 ? !isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {} : {}}
currency={currency}
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.credit`}
style={index === 0 && isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {}}
label={_("Credit")}
isRequired
hideLabel
readOnly={index === 0}
currency={currency}
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between gap-2">
<div className="flex gap-2 justify-end">
<div>
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
</div>
{selectedRows.length > 0 && <div>
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
</div>}
</div>
<Summary currency={currency} addRow={onAddDifferenceClicked} />
</div>
</div>
}
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
const { control } = useFormContext<BankEntryFormData>()
const party_type = useWatch({
control,
name: `entries.${index}.party_type`
})
if (!party_type) {
return <DataField
name={`entries.${index}.party`}
label={_("Party")}
isRequired
inputProps={{
disabled: true,
className: 'rounded-s-none border-s-0 min-w-64'
}}
hideLabel
/>
}
return <LinkFormField
name={`entries.${index}.party`}
label={_("Party")}
rules={{
onChange: (event) => {
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<BankEntryFormData>()
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 <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
}
return <div className="flex flex-col gap-2 items-end">
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Debit")}</TextComponent>
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
</div>
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Credit")}</TextComponent>
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
</div>
{total !== 0 && <div className="flex gap-2 justify-between">
<TextComponent>{_("Difference")}</TextComponent>
<Tooltip>
<TooltipTrigger asChild>
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Add a row with the difference amount")}
</TooltipContent>
</Tooltip>
</div>}
</div>
}
export default BankEntryModal

View File

@@ -0,0 +1,811 @@
import { useAtomValue, useSetAtom } from "jotai"
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { 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 { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
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 RecordBankEntryModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <BankEntryForm
selectedTransaction={selectedTransaction[0]} />
}
return <BulkBankEntryForm
selectedTransactions={selectedTransaction}
/>
}
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 <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-4">
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<div className="grid grid-cols-3 gap-4">
<AccountFormField
name='account'
filterFunction={(acc) => {
// Do not allow payable and receivable accounts
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
}}
label={_('Account')}
isRequired
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
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<JournalEntryAccount>[] = [
{
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<BankEntryFormData>({
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, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
const [files, setFiles] = useState<File[]>([])
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)
startTracking(files.length)
const uploadPromises = files.map((f, fileIndex) => {
return frappeFile.uploadFile(f, {
isPrivate: true,
doctype: "Journal Entry",
docname: message.journal_entry.name,
}, (_bytesUploaded, _totalBytes, progress) => {
updateFileProgress(fileIndex, progress?.progress ?? 0)
})
})
return Promise.all(uploadPromises).then(() => {
resetProgress()
setIsUploading(false)
}).catch((error) => {
console.error(error)
toast.error(_("Error uploading attachments"), {
duration: 4000,
})
resetProgress()
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 <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='cheque_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
rules={{
required: _("Reference Date is required"),
}}
/>
</div>
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
rules={{
required: _("Reference is required"),
}} />
</div>
</div>
<div>
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
</div>
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='user_remark'
label={_("Remarks")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
const { call } = useContext(FrappeContext) as FrappeConfig
const partyMapRef = useRef<Record<string, string>>({})
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<number[]>([])
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(() => {
// Do not remove the first row
remove(selectedRows.filter(index => index !== 0))
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 <div className="flex flex-col gap-2">
<Table>
<TableHeader>
<TableRow>
<TableHead><Checkbox
disabled={fields.length === 0}
// Make this accessible to screen readers
aria-label={_("Select all")}
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
onCheckedChange={onSelectAll} /></TableHead>
<TableHead>{_("Party")}</TableHead>
<TableHead>{_("Account")}</TableHead>
<TableHead>{_("Cost Center")}</TableHead>
<TableHead>{_("Remarks")}</TableHead>
<TableHead className="text-end">{_("Debit")}</TableHead>
<TableHead className="text-end">{_("Credit")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fields.map((field, index) => (
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
<TableCell>
<Checkbox
checked={selectedRows.includes(index)}
onCheckedChange={() => onSelectRow(index)}
// Make this accessible to screen readers
aria-label={_("Select row {0}", [String(index + 1)])}
disabled={index === 0}
/>
</TableCell>
<TableCell className="align-top">
<div className="flex">
<PartyTypeFormField
name={`entries.${index}.party_type`}
label={_("Party Type")}
isRequired
readOnly={index === 0}
hideLabel
inputProps={{
type: isWithdrawal ? 'Payable' : 'Receivable',
triggerProps: {
className: 'rounded-e-none',
tabIndex: -1
},
readOnly: index === 0,
}} />
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
</div>
</TableCell>
<TableCell className="align-top">
<AccountFormField
name={`entries.${index}.account`}
label={_("Account")}
rules={{
required: _("Account is required"),
onChange: (event) => {
onAccountChange(event.target.value, index)
}
}}
buttonClassName="min-w-64"
readOnly={index === 0}
isRequired
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<LinkFormField
doctype="Cost Center"
name={`entries.${index}.cost_center`}
label={_("Cost Center")}
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
buttonClassName="min-w-48"
readOnly={index === 0}
hideLabel
/>
</TableCell>
<TableCell className="align-top">
<DataField
name={`entries.${index}.user_remark`}
label={_("Remarks")}
readOnly={index === 0}
inputProps={{
placeholder: _("e.g. Bank Charges"),
className: 'min-w-64',
readOnly: index === 0
}}
hideLabel
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.debit`}
label={_("Debit")}
isRequired
hideLabel
readOnly={index === 0}
style={index === 0 ? !isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {} : {}}
currency={currency}
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
<TableCell className={cn("text-end align-top")}>
<CurrencyFormField
name={`entries.${index}.credit`}
style={index === 0 && isWithdrawal ? {
color: "var(--color-ink-gray-8)",
} : {}}
label={_("Credit")}
isRequired
hideLabel
readOnly={index === 0}
currency={currency}
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
</Tooltip> : undefined}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between gap-2">
<div className="flex gap-2 justify-end">
<div>
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
</div>
{selectedRows.length > 0 && <div>
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
</div>}
</div>
<Summary currency={currency} addRow={onAddDifferenceClicked} />
</div>
</div>
}
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
const { control } = useFormContext<BankEntryFormData>()
const party_type = useWatch({
control,
name: `entries.${index}.party_type`
})
if (!party_type) {
return <DataField
name={`entries.${index}.party`}
label={_("Party")}
isRequired
inputProps={{
disabled: true,
className: 'rounded-s-none border-s-0 min-w-64'
}}
hideLabel
/>
}
return <LinkFormField
name={`entries.${index}.party`}
label={_("Party")}
rules={{
onChange: (event) => {
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<BankEntryFormData>()
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 <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
}
return <div className="flex flex-col gap-2 items-end">
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Debit")}</TextComponent>
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
</div>
<div className="flex gap-2 justify-between">
<TextComponent>{_("Total Credit")}</TextComponent>
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
</div>
{total !== 0 && <div className="flex gap-2 justify-between">
<TextComponent>{_("Difference")}</TextComponent>
<Tooltip>
<TooltipTrigger asChild>
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
</Button>
</TooltipTrigger>
<TooltipContent>
{_("Add a row with the difference amount")}
</TooltipContent>
</Tooltip>
</div>}
</div>
}
export default RecordBankEntryModalContent

View File

@@ -76,7 +76,7 @@ const BankReconciliationStatementView = () => {
toast.success(_("Copied to clipboard"))
})
},
[copyToClipboard, _],
[copyToClipboard],
)
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
@@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => {
cell: ({ row }) => formatDate(row.original.clearance_date),
},
],
[_, onCopy],
[onCopy],
)
const statementRows = useMemo(() => {

View File

@@ -176,7 +176,7 @@ const BankTransactionListView = () => {
),
},
],
[_, accountCurrency, onUndo],
[accountCurrency, onUndo],
)
const [search, setSearch] = useDebounceValue('', 250)

View File

@@ -1,125 +1,52 @@
import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
import { useAtom, useAtomValue } from "jotai"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useMemo } from "react"
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatCurrency } from "@/lib/numbers"
import { Badge } from "@/components/ui/badge"
import { slug } from "@/lib/frappe"
import SelectedTransactionDetails from "./SelectedTransactionDetails"
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { useAtom } from "jotai"
import { Loader2Icon } from "lucide-react"
import { lazy, Suspense } from "react"
import { bankRecUnreconcileModalAtom } from "./bankRecAtoms"
import _ from "@/lib/translate"
const BankTransactionUnreconcileModalBody = lazy(() => import('./BankTransactionUnreconcileModalBody'))
const BankTransactionUnreconcileModalFallback = () => (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
)
const BankTransactionUnreconcileModal = () => {
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const onOpenChange = (v: boolean) => {
if (!v) {
setBankRecUnreconcileModal('')
}
}
return <AlertDialog open={!!unreconcileModal} onOpenChange={onOpenChange}>
<AlertDialogOverlay />
<AlertDialogContent className="min-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
<AlertDialogDescription>
{_("Are you sure you want to unreconcile this transaction?")}
</AlertDialogDescription>
</AlertDialogHeader>
<BankTransactionUnreconcileModalContent />
</AlertDialogContent>
</AlertDialog>
const onOpenChange = (v: boolean) => {
if (!v) {
setBankRecUnreconcileModal('')
}
}
if (!unreconcileModal) {
return null
}
return (
<AlertDialog open onOpenChange={onOpenChange}>
<AlertDialogContent className="min-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
<AlertDialogDescription>
{_("Are you sure you want to unreconcile this transaction?")}
</AlertDialogDescription>
</AlertDialogHeader>
<Suspense fallback={<BankTransactionUnreconcileModalFallback />}>
<BankTransactionUnreconcileModalBody />
</Suspense>
</AlertDialogContent>
</AlertDialog>
)
}
const BankTransactionUnreconcileModalContent = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const { mutate } = useSWRConfig()
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const { data: transaction, error } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
call({
transaction_name: unreconcileModal
}).then(() => {
// Mutate the transactions list, unreconciled transactions list and account closing balance
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
toast.success(_("Transaction Unreconciled"))
setBankRecUnreconcileModal('')
})
event.preventDefault()
}
const vouchersWhichWillBeCancelled = useMemo(() => {
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
}, [transaction])
return <div>
<div className="flex flex-col gap-3">
{error && <ErrorBanner error={error} />}
{unreconcileError && <ErrorBanner error={unreconcileError} />}
{transaction && <SelectedTransactionDetails transaction={transaction} />}
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Amount")}</TableHead>
<TableHead>{_("Reconciliation Type")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transaction?.payment_entries?.map((voucher) => {
return <TableRow key={voucher.name}>
<TableCell>
<a className="underline underline-offset-4"
target="_blank"
rel="noopener noreferrer"
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
>
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
</a>
</TableCell>
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
<TableCell>{voucher.reconciliation_type === 'Voucher Created' ?
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}</TableCell>
</TableRow>
})}
</TableBody>
</Table>
<div className="py-4">
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <span>The following documents will be <strong>cancelled</strong>:</span>}
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && <ol className="ms-6 list-disc [&>li]:mt-2">
{vouchersWhichWillBeCancelled?.map((voucher) => {
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
})}
</ol>}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading}>
{_("Unreconcile")}
</AlertDialogAction>
</AlertDialogFooter>
</div>
}
export default BankTransactionUnreconcileModal
export default BankTransactionUnreconcileModal

View File

@@ -0,0 +1,109 @@
import { AlertDialogAction, AlertDialogCancel, AlertDialogFooter } from "@/components/ui/alert-dialog"
import { useAtom, useAtomValue } from "jotai"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useMemo } from "react"
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
import { BankTransaction } from "@/types/Accounts/BankTransaction"
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { formatCurrency } from "@/lib/numbers"
import { Badge } from "@/components/ui/badge"
import { slug } from "@/lib/frappe"
import SelectedTransactionDetails from "./SelectedTransactionDetails"
import _ from "@/lib/translate"
const BankTransactionUnreconcileModalBody = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const { mutate } = useSWRConfig()
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
const { data: transaction, error, isLoading } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
call({
transaction_name: unreconcileModal
}).then(() => {
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
toast.success(_("Transaction Unreconciled"))
setBankRecUnreconcileModal('')
})
event.preventDefault()
}
const vouchersWhichWillBeCancelled = useMemo(() => {
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
}, [transaction])
return (
<>
<div className="flex flex-col gap-3">
{error && <ErrorBanner error={error} />}
{unreconcileError && <ErrorBanner error={unreconcileError} />}
{transaction && <SelectedTransactionDetails transaction={transaction} />}
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
<Table>
<TableHeader>
<TableRow>
<TableHead>{_("Document")}</TableHead>
<TableHead>{_("Amount")}</TableHead>
<TableHead>{_("Reconciliation Type")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transaction?.payment_entries?.map((voucher) => {
return (
<TableRow key={voucher.name}>
<TableCell>
<a
className="underline underline-offset-4"
target="_blank"
rel="noopener noreferrer"
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
>
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
</a>
</TableCell>
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
<TableCell>
{voucher.reconciliation_type === 'Voucher Created' ?
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<div className="py-4">
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
<span>The following documents will be <strong>cancelled</strong>:</span>
)}
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
<ol className="ms-6 list-disc [&>li]:mt-2">
{vouchersWhichWillBeCancelled?.map((voucher) => {
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
})}
</ol>
)}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading || isLoading}>
{_("Unreconcile")}
</AlertDialogAction>
</AlertDialogFooter>
</>
)
}
export default BankTransactionUnreconcileModalBody

View File

@@ -91,7 +91,7 @@ const IncorrectlyClearedEntriesView = () => {
})
})
},
[clearClearingDate, mutate, _],
[clearClearingDate, mutate],
)
const accountCurrency = useMemo(
@@ -174,7 +174,7 @@ const IncorrectlyClearedEntriesView = () => {
),
},
],
[_, accountCurrency, onClearClick],
[accountCurrency, onClearClick],
)
return <div className="space-y-4 py-2">

View File

@@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
import { Button } from "@/components/ui/button"
import CurrencyInput from 'react-currency-input-field'
import { getCurrencySymbol } from "@/lib/currency"
import { Virtuoso } from 'react-virtuoso'
import { useVirtualizer } from '@tanstack/react-virtual'
import { formatDate } from "@/lib/date"
import { Badge } from "@/components/ui/badge"
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
@@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp
import { Skeleton } from "@/components/ui/skeleton"
import { slug } from "@/lib/frappe"
import _ from "@/lib/translate"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import TransferModal from "./TransferModal"
import BankEntryModal from "./BankEntryModal"
import RecordPaymentModal from "./RecordPaymentModal"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import SelectedTransactionsTable from "./SelectedTransactionsTable"
import MatchFilters from "./MatchFilters"
import { useHotkeys } from "react-hotkeys-hook"
@@ -69,6 +69,59 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
</>
}
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
function VirtualizedListBody<T>({
items,
height,
getItemKey,
children,
estimateSize = 74,
}: {
items: T[]
height: number
getItemKey: (item: T, index: number) => string | number
children: (item: T, index: number) => React.ReactNode
estimateSize?: number
}) {
const scrollRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => estimateSize,
overscan: 8,
getItemKey: (index) => String(getItemKey(items[index], index)),
})
if (items.length === 0) {
return null
}
return (
<div
ref={scrollRef}
className="overflow-auto contain-strict"
style={{ height }}
>
<div
className="relative w-full"
style={{ height: rowVirtualizer.getTotalSize() }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{children(items[virtualRow.index], virtualRow.index)}
</div>
))}
</div>
</div>
)
}
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -134,6 +187,7 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
}
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
const listHeight = contentHeight - 72
if (isLoading) {
return <UnreconciledTransactionsLoadingState />
@@ -222,14 +276,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")}
description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />}
<Virtuoso
data={results}
itemContent={(_index, transaction) => (
<UnreconciledTransactionItem transaction={transaction} />
)}
style={{ minHeight: Math.max(contentHeight - 80, 400) }}
totalCount={results?.length}
/>
<VirtualizedListBody
items={results}
height={listHeight}
estimateSize={74}
getItemKey={(transaction) => transaction.name}
>
{(transaction) => <UnreconciledTransactionItem transaction={transaction} />}
</VirtualizedListBody>
</div>
}
@@ -559,11 +613,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
if (!rule) {
return null
}
const getActionIcon = () => {
if (!rule) return null
switch (rule.classify_as) {
case "Bank Entry":
return <Landmark />
@@ -577,6 +628,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
}
const getActionStyles = () => {
if (!rule) return {}
switch (rule.classify_as) {
case "Bank Entry":
return {
@@ -610,6 +662,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
}
const handleActionClick = () => {
if (!rule) return
switch (rule.classify_as) {
case "Bank Entry":
setRecordJournalEntryModalOpen(true)
@@ -624,6 +677,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
}
const getActionDescription = () => {
if (!rule) return ""
switch (rule.classify_as) {
case "Bank Entry":
return _("Create a journal entry for expenses, income or split transactions")
@@ -636,8 +690,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
}
}
useHotkeys('meta+r', () => {
//
useHotkeys('alt+r', () => {
handleActionClick()
}, {
enabled: true,
@@ -647,6 +700,10 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
const styles = getActionStyles()
if (!rule) {
return null
}
return (
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
<CardHeader className="pb-0">
@@ -721,6 +778,9 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
const voucherList = vouchers?.message ?? []
const listHeight = contentHeight - 120
if (error) {
return <ErrorBanner error={error} />
}
@@ -747,7 +807,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
<span>or</span>
<Separator className="flex-1" />
</div>
{vouchers?.message.length === 0 && <Empty className="my-4">
{voucherList.length === 0 && <Empty className="my-4">
<EmptyMedia>
<ReceiptIcon />
</EmptyMedia>
@@ -756,14 +816,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
</EmptyHeader>
</Empty>}
<Virtuoso
data={vouchers?.message}
itemContent={(index, voucher) => (
<VoucherItem voucher={voucher} index={index} />
)}
style={{ height: contentHeight }}
totalCount={vouchers?.message.length}
/>
<VirtualizedListBody
items={voucherList}
height={listHeight}
estimateSize={121}
getItemKey={(voucher) => voucher.name}
>
{(voucher, index) => <VoucherItem voucher={voucher} index={index} />}
</VirtualizedListBody>
</div >
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,555 +1,32 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { useAtom } from 'jotai'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { ModalContentFallback } from '@/components/ui/modal-content-fallback'
import _ from '@/lib/translate'
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
import { Button } from '@/components/ui/button'
import SelectedTransactionDetails from './SelectedTransactionDetails'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { useForm, useFormContext, useWatch } from 'react-hook-form'
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { H4 } from '@/components/ui/typography'
import { cn } from '@/lib/utils'
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { Form } from '@/components/ui/form'
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
import SelectedTransactionsTable from './SelectedTransactionsTable'
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
import { formatDate } from '@/lib/date'
import { useContext, useMemo, useState } from 'react'
import { formatCurrency } from '@/lib/numbers'
import { Label } from '@/components/ui/label'
import { FileDropzone } from '@/components/ui/file-dropzone'
import FileUploadBanner from '@/components/common/FileUploadBanner'
import { BankTransaction } from '@/types/Accounts/BankTransaction'
import { useHotkeys } from 'react-hotkeys-hook'
import { useDirection } from '@/components/ui/direction'
import BankLogo from '@/components/common/BankLogo'
import { lazy, Suspense } from 'react'
import { bankRecTransferModalAtom } from './bankRecAtoms'
const TransferModalContent = lazy(() => import('./TransferModalContent'))
const TransferModal = () => {
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-7xl'>
<DialogHeader>
<DialogTitle>{_("Transfer")}</DialogTitle>
<DialogDescription>
{_("Record an internal transfer to another bank/credit card/cash account.")}
</DialogDescription>
</DialogHeader>
<TransferModalContent />
</DialogContent>
</Dialog>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className='min-w-7xl'>
<DialogHeader>
<DialogTitle>{_("Transfer")}</DialogTitle>
<DialogDescription>
{_("Record an internal transfer to another bank/credit card/cash account.")}
</DialogDescription>
</DialogHeader>
{isOpen && (
<Suspense fallback={<ModalContentFallback />}>
<TransferModalContent />
</Suspense>
)}
</DialogContent>
</Dialog>
)
}
const TransferModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <InternalTransferForm
selectedBankAccount={selectedBankAccount}
selectedTransaction={selectedTransaction[0]} />
}
return <BulkInternalTransferForm transactions={selectedTransaction} />
}
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
const form = useForm<{
bank_account: string
}>()
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const onSubmit = (data: { bank_account: string }) => {
createPaymentEntry({
bank_transaction_names: transactions.map((transaction) => transaction.name),
bank_account: data.bank_account
}).then(({ message }) => {
addToActionLog({
type: 'transfer',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: item.payment_entry.name,
posting_date: item.payment_entry.posting_date,
doc: item.payment_entry,
}
})),
bulkCommonData: {
bank_account: data.bank_account,
}
})
toast.success(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
})
onReconcile(transactions[transactions.length - 1])
setIsOpen(false)
})
}
const onAccountChange = (account: string) => {
form.setValue('bank_account', account)
}
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
const currentCompany = useCurrentCompany()
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
console.log("This is here", transactions)
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface InternalTransferFormFields extends PaymentEntry {
mirror_transaction_name?: string
}
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const onClose = () => {
setIsOpen(false)
}
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const form = useForm<InternalTransferFormFields>({
defaultValues: {
payment_type: 'Internal Transfer',
company: selectedTransaction?.company,
// 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,
received_amount: selectedTransaction.unallocated_amount,
reference_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
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<File[]>([])
const onSubmit = (data: InternalTransferFormFields) => {
createPaymentEntry({
bank_transaction_name: selectedTransaction.name,
...data,
custom_remarks: data.remarks ? true : false,
// Pass this to reconcile both at the same time
mirror_transaction_name: data.mirror_transaction_name
}).then(async ({ message }) => {
addToActionLog({
type: 'transfer',
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(_("Transfer Recorded"), {
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
})
const onAccountChange = (account: string, is_mirror: boolean = false) => {
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
form.setValue('paid_to', account)
} else {
form.setValue('paid_from', account)
}
if (!is_mirror) {
// Reset the mirror transaction name
form.setValue('mirror_transaction_name', '')
}
}
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
const direction = useDirection()
if (isUploading && isCompleted) {
return <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='reference_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
</div>
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
</div>
</div>
<div className='flex flex-col gap-2'>
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
</div>
<div className='flex flex-col gap-2 py-2'>
<div className='flex items-end justify-between gap-4'>
<div className='flex-1'>
<AccountFormField
name="paid_from"
label={_("Paid From")}
account_type={['Bank', 'Cash']}
readOnly={isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
isRequired
/>
</div>
<div className='pb-2'>
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
</div>
<div className='flex-1'>
<AccountFormField
name="paid_to"
label={_("Paid To")}
account_type={['Bank', 'Cash']}
isRequired
readOnly={!isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
/>
</div>
</div>
</div>
<Separator />
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='remarks'
label={_("Custom Remarks")}
formDescription={_("This will be auto-populated if not set.")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company: string }) => {
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
return <div className='grid grid-cols-4 gap-4'>
{banks.map((bank) => (
<div
className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
role='button'
key={bank.account}
onClick={() => onAccountChange(bank.account ?? '')}
>
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
</div>
</div>
))}
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
</div>
}
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
const { data } = useFrappeGetCall('frappe.client.get_value', {
doctype: 'Company',
filters: company,
fieldname: 'default_cash_account'
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
const account = data?.message?.default_cash_account
if (account) {
return <div className={cn('border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
role='button'
onClick={() => setSelectedAccount(account ?? '')}
>
<div className='flex items-center justify-center h-10 w-10'>
<Banknote size='24px' />
</div>
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>Cash</span>
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
</div>
</div>
}
return null
}
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
const mirrorTransactionName = watch('mirror_transaction_name')
const paid_from = watch('paid_from')
const paid_to = watch('paid_to')
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
transaction_id: transaction.name
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
// Get bank accounts to find the logo
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (data?.message?.bank_account && banks) {
return banks.find(bank => bank.name === data.message.bank_account)
}
return null
}, [data?.message?.bank_account, banks])
const selectTransaction = () => {
if (data?.message) {
setValue('mirror_transaction_name', data.message.name)
onAccountChange(data.message.account, true)
}
}
if (data?.message) {
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
const currency = data.message.currency
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
return (<div className='pb-2'>
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
<div>
<div className='flex flex-col gap-3'>
<div className={cn("flex items-center gap-2 shrink-0",
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
)}>
<BadgeCheck className="w-4 h-4" />
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
</div>
<div className='flex flex-col gap-1'>
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
</div>
<div className='flex flex-col gap-1.5'>
<div className='flex items-center gap-1'>
<Calendar size='16px' />
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
</div>
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
</div>
</div>
</div>
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
<div className="flex items-center gap-2">
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
</div>
<div className='flex gap-1'>
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
)}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
</div>
</div>
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
<div className='pt-1'>
<Button
onClick={selectTransaction}
theme={isSuggested ? "green" : "violet"}
size="md"
type='button'
>
{isSuggested ? <CheckCircle /> : <CheckIcon />}
{isSuggested ? _("Accepted") : _("Use Suggestion")}
</Button>
</div>
</div>
</div>
</div>
)
}
return null
}
export default TransferModal
export default TransferModal

View File

@@ -0,0 +1,530 @@
import { useAtomValue, useSetAtom } from 'jotai'
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
import { DialogFooter, DialogClose } from '@/components/ui/dialog'
import _ from '@/lib/translate'
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
import { Button } from '@/components/ui/button'
import SelectedTransactionDetails from './SelectedTransactionDetails'
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
import { useForm, useFormContext, useWatch } from 'react-hook-form'
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { H4 } from '@/components/ui/typography'
import { cn } from '@/lib/utils'
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { Form } from '@/components/ui/form'
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
import SelectedTransactionsTable from './SelectedTransactionsTable'
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
import { useMultiFileUploadProgress } from '@/hooks/useMultiFileUploadProgress'
import { formatDate } from '@/lib/date'
import { useContext, useMemo, useState } from 'react'
import { formatCurrency } from '@/lib/numbers'
import { Label } from '@/components/ui/label'
import { FileDropzone } from '@/components/ui/file-dropzone'
import FileUploadBanner from '@/components/common/FileUploadBanner'
import { BankTransaction } from '@/types/Accounts/BankTransaction'
import { useHotkeys } from 'react-hotkeys-hook'
import { useDirection } from '@/components/ui/direction'
import BankLogo from '@/components/common/BankLogo'
const TransferModalContent = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
return <div className='p-4'>
<span className='text-center'>{_("No transaction selected")}</span>
</div>
}
if (selectedTransaction.length === 1) {
return <InternalTransferForm
selectedBankAccount={selectedBankAccount}
selectedTransaction={selectedTransaction[0]} />
}
return <BulkInternalTransferForm transactions={selectedTransaction} />
}
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
const form = useForm<{
bank_account: string
}>()
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
const onReconcile = useRefreshUnreconciledTransactions()
const addToActionLog = useUpdateActionLog()
const onSubmit = (data: { bank_account: string }) => {
createPaymentEntry({
bank_transaction_names: transactions.map((transaction) => transaction.name),
bank_account: data.bank_account
}).then(({ message }) => {
addToActionLog({
type: 'transfer',
timestamp: (new Date()).getTime(),
isBulk: true,
items: message.map((item) => ({
bankTransaction: item.transaction,
voucher: {
reference_doctype: "Payment Entry",
reference_name: item.payment_entry.name,
posting_date: item.payment_entry.posting_date,
doc: item.payment_entry,
}
})),
bulkCommonData: {
bank_account: data.bank_account,
}
})
toast.success(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
})
onReconcile(transactions[transactions.length - 1])
setIsOpen(false)
})
}
const onAccountChange = (account: string) => {
form.setValue('bank_account', account)
}
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
const currentCompany = useCurrentCompany()
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<SelectedTransactionsTable />
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
interface InternalTransferFormFields extends PaymentEntry {
mirror_transaction_name?: string
}
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
const onClose = () => {
setIsOpen(false)
}
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
const form = useForm<InternalTransferFormFields>({
defaultValues: {
payment_type: 'Internal Transfer',
company: selectedTransaction?.company,
// 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,
received_amount: selectedTransaction.unallocated_amount,
reference_date: selectedTransaction.date,
posting_date: selectedTransaction.date,
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
}
})
const onReconcile = useRefreshUnreconciledTransactions()
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
const addToActionLog = useUpdateActionLog()
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
const [isUploading, setIsUploading] = useState(false)
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
const [files, setFiles] = useState<File[]>([])
const onSubmit = (data: InternalTransferFormFields) => {
createPaymentEntry({
bank_transaction_name: selectedTransaction.name,
...data,
custom_remarks: data.remarks ? true : false,
// Pass this to reconcile both at the same time
mirror_transaction_name: data.mirror_transaction_name
}).then(async ({ message }) => {
addToActionLog({
type: 'transfer',
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(_("Transfer Recorded"), {
duration: 4000,
closeButton: true,
action: {
label: _("Undo"),
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
},
actionButtonStyle: {
backgroundColor: "rgb(0, 138, 46)"
}
})
if (files.length > 0) {
setIsUploading(true)
startTracking(files.length)
const uploadPromises = files.map((f, fileIndex) => {
return frappeFile.uploadFile(f, {
isPrivate: true,
doctype: "Payment Entry",
docname: message.payment_entry.name,
}, (_bytesUploaded, _totalBytes, progress) => {
updateFileProgress(fileIndex, progress?.progress ?? 0)
})
})
return Promise.all(uploadPromises).then(() => {
resetProgress()
setIsUploading(false)
})
} else {
return Promise.resolve()
}
}).then(() => {
resetProgress()
setIsUploading(false)
onReconcile(selectedTransaction)
onClose()
})
}
useHotkeys('meta+s', () => {
form.handleSubmit(onSubmit)()
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: true
})
const onAccountChange = (account: string, is_mirror: boolean = false) => {
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
form.setValue('paid_to', account)
} else {
form.setValue('paid_from', account)
}
if (!is_mirror) {
// Reset the mirror transaction name
form.setValue('mirror_transaction_name', '')
}
}
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
const direction = useDirection()
if (isUploading && isCompleted) {
return <FileUploadBanner uploadProgress={uploadProgress} />
}
return <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='flex flex-col gap-4'>
{error && <ErrorBanner error={error} />}
<div className='grid grid-cols-2 gap-4'>
<SelectedTransactionDetails transaction={selectedTransaction} />
<div className='flex flex-col gap-4'>
<div className='grid grid-cols-2 gap-4'>
<DateField
name='posting_date'
label={_("Posting Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
<DateField
name='reference_date'
label={_("Reference Date")}
isRequired
inputProps={{ autoFocus: false }}
/>
</div>
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
</div>
</div>
<div className='flex flex-col gap-2'>
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
</div>
<div className='flex flex-col gap-2 py-2'>
<div className='flex items-end justify-between gap-4'>
<div className='flex-1'>
<AccountFormField
name="paid_from"
label={_("Paid From")}
account_type={['Bank', 'Cash']}
readOnly={isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
isRequired
/>
</div>
<div className='pb-2'>
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
</div>
<div className='flex-1'>
<AccountFormField
name="paid_to"
label={_("Paid To")}
account_type={['Bank', 'Cash']}
isRequired
readOnly={!isWithdrawal}
filterFunction={(account) => account.name !== selectedBankAccount.account}
/>
</div>
</div>
</div>
<Separator />
<div className='flex flex-col gap-2'>
<div className='grid grid-cols-2 gap-4'>
<SmallTextField
name='remarks'
label={_("Custom Remarks")}
formDescription={_("This will be auto-populated if not set.")}
/>
<div
data-slot="form-item"
className="flex flex-col gap-2"
>
<Label>{_("Attachments")}</Label>
<FileDropzone files={files} setFiles={setFiles} />
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
</DialogClose>
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
</DialogFooter>
</div>
</form>
</Form>
}
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company?: string }) => {
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
return <div className='grid grid-cols-4 gap-4'>
{banks.map((bank) => (
<button
className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
type='button'
key={bank.account}
onClick={() => onAccountChange(bank.account ?? '')}
>
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
</div>
</button>
))}
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
</div>
}
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
const { data } = useFrappeGetCall('frappe.client.get_value', {
doctype: 'Company',
filters: company,
fieldname: 'default_cash_account'
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
const account = data?.message?.default_cash_account
if (account) {
return <button className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
)}
type='button'
onClick={() => setSelectedAccount(account ?? '')}
>
<div className='flex items-center justify-center h-10 w-10'>
<Banknote size='24px' />
</div>
<div className='flex flex-col gap-1'>
<span className='font-semibold text-sm'>Cash</span>
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
</div>
</button>
}
return null
}
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
const mirrorTransactionName = watch('mirror_transaction_name')
const paid_from = watch('paid_from')
const paid_to = watch('paid_to')
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
transaction_id: transaction.name
}, undefined, {
revalidateOnFocus: false,
revalidateIfStale: false,
})
// Get bank accounts to find the logo
const { banks } = useGetBankAccounts()
const bank = useMemo(() => {
if (data?.message?.bank_account && banks) {
return banks.find(bank => bank.name === data.message.bank_account)
}
return null
}, [data?.message?.bank_account, banks])
const selectTransaction = () => {
if (data?.message) {
setValue('mirror_transaction_name', data.message.name)
onAccountChange(data.message.account, true)
}
}
if (data?.message) {
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
const currency = data.message.currency
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
return (<div className='pb-2'>
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
<div>
<div className='flex flex-col gap-3'>
<div className={cn("flex items-center gap-2 shrink-0",
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
)}>
<BadgeCheck className="w-4 h-4" />
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
</div>
<div className='flex flex-col gap-1'>
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
</div>
<div className='flex flex-col gap-1.5'>
<div className='flex items-center gap-1'>
<Calendar size='16px' />
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
</div>
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
</div>
</div>
</div>
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
<div className="flex items-center gap-2">
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
</div>
<div className='flex gap-1'>
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
)}>
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
</div>
</div>
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
<div className='pt-1'>
<Button
onClick={selectTransaction}
theme={isSuggested ? "green" : "violet"}
size="md"
type='button'
>
{isSuggested ? <CheckCircle /> : <CheckIcon />}
{isSuggested ? _("Accepted") : _("Use Suggestion")}
</Button>
</div>
</div>
</div>
</div>
)
}
return null
}
export default TransferModalContent

View File

@@ -1,6 +1,5 @@
import CSVRawDataPreview from './CSVRawDataPreview'
import StatementDetails from './StatementDetails'
import _ from '@/lib/translate'
import { GetStatementDetailsResponse } from '../import_utils'
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {

View File

@@ -4,7 +4,7 @@ import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys'
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import _ from '@/lib/translate'
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, OptionIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
const Shortcuts = [
{
@@ -32,7 +32,7 @@ const Shortcuts = [
}
},
{
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
shortcut: <KbdGroup><Kbd><OptionIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
action: {
icon: <ZapIcon />,
label: _("Accept Matching Rule"),

View File

@@ -20,7 +20,7 @@ export const Preferences = () => {
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
const onUpdate = (field: keyof AccountsSettings, value: any) => {
const onUpdate = <K extends keyof AccountsSettings>(field: K, value: AccountsSettings[K]) => {
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
[field]: value
}), {

View File

@@ -1,95 +1,42 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
import {
SettingsDialog,
SettingsPanel,
SettingsPanels,
SettingsTabGroup,
SettingsTabItem,
SettingsTabs,
} from '@/components/ui/settings-dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import _ from '@/lib/translate'
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
import { SettingsIcon } from 'lucide-react'
import { useState } from 'react'
import { Preferences } from './Preferences'
import MatchingRules from './MatchingRules'
import KeyboardShortcuts from './KeyboardShortcuts'
import { useHotkeys } from 'react-hotkeys-hook'
import SettingsDialogContent from './SettingsDialogContent'
const Settings = () => {
const [isOpen, setIsOpen] = useState(false)
const [isOpen, setIsOpen] = useState(false)
useHotkeys('shift+meta+g', () => {
setIsOpen(x => !x)
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: false
})
useHotkeys('shift+meta+g', () => {
setIsOpen(x => !x)
}, {
enabled: true,
preventDefault: true,
enableOnFormTags: false
})
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md'>
<SettingsIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Settings")}
</TooltipContent>
</Tooltip>
<SettingsDialog defaultValue="preferences" onClose={() => setIsOpen(false)}>
<SettingsTabs>
<SettingsTabGroup header={_("Settings")}>
<SettingsTabItem
icon={<SlidersVerticalIcon />}
label={_("Preferences")}
value="preferences"
/>
<SettingsTabItem
icon={<ZapIcon />}
label={_("Matching Rules")}
value="rules"
/>
{/* <SettingsTabItem
icon={<LandmarkIcon />}
label={_("Bank Accounts")}
value="bank-accounts"
/>
<SettingsTabItem
icon={<ListIcon />}
label={_("Masters")}
value="masters"
/> */}
<SettingsTabItem
icon={<KeyboardIcon />}
label={_("Keyboard Shortcuts")}
value="keyboard-shortcuts"
/>
</SettingsTabGroup>
</SettingsTabs>
<SettingsPanels>
<SettingsPanel value="preferences">
<Preferences />
</SettingsPanel>
<SettingsPanel value="rules">
<MatchingRules />
</SettingsPanel>
<SettingsPanel value="bank-accounts" />
<SettingsPanel value="masters" />
<SettingsPanel value="keyboard-shortcuts">
<KeyboardShortcuts />
</SettingsPanel>
</SettingsPanels>
</SettingsDialog>
</Dialog >
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant={'outline'} isIconButton size='md' aria-label={_("Settings")}>
<SettingsIcon />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
{_("Settings")}
</TooltipContent>
</Tooltip>
{isOpen && (
<SettingsDialogContent onClose={() => setIsOpen(false)} />
)}
</Dialog>
)
}
export default Settings

View File

@@ -0,0 +1,52 @@
import {
SettingsDialog,
SettingsPanels,
SettingsTabGroup,
SettingsTabItem,
SettingsTabs,
} from '@/components/ui/settings-dialog'
import _ from '@/lib/translate'
import { KeyboardIcon, Loader2Icon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
import { lazy, Suspense } from 'react'
const SettingsPanelsContent = lazy(() => import('./SettingsPanelsContent'))
const SettingsPanelsFallback = () => (
<div className="flex flex-1 items-center justify-center min-h-full">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
)
const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
return (
<SettingsDialog defaultValue="preferences" onClose={onClose}>
<SettingsTabs>
<SettingsTabGroup header={_("Settings")}>
<SettingsTabItem
icon={<SlidersVerticalIcon />}
label={_("Preferences")}
value="preferences"
/>
<SettingsTabItem
icon={<ZapIcon />}
label={_("Matching Rules")}
value="rules"
/>
<SettingsTabItem
icon={<KeyboardIcon />}
label={_("Keyboard Shortcuts")}
value="keyboard-shortcuts"
/>
</SettingsTabGroup>
</SettingsTabs>
<SettingsPanels>
<Suspense fallback={<SettingsPanelsFallback />}>
<SettingsPanelsContent />
</Suspense>
</SettingsPanels>
</SettingsDialog>
)
}
export default SettingsDialogContent

View File

@@ -0,0 +1,24 @@
import { SettingsPanel } from '@/components/ui/settings-dialog'
import { Preferences } from './Preferences'
import MatchingRules from './MatchingRules'
import KeyboardShortcuts from './KeyboardShortcuts'
const SettingsPanelsContent = () => {
return (
<>
<SettingsPanel value="preferences">
<Preferences />
</SettingsPanel>
<SettingsPanel value="rules">
<MatchingRules />
</SettingsPanel>
<SettingsPanel value="bank-accounts" />
<SettingsPanel value="masters" />
<SettingsPanel value="keyboard-shortcuts">
<KeyboardShortcuts />
</SettingsPanel>
</>
)
}
export default SettingsPanelsContent

View File

@@ -170,7 +170,7 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
return (
<Button variant={variant} size={size} asChild>
<Button variant={variant} size={size} theme={theme} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}

View File

@@ -18,7 +18,7 @@ interface ParsedErrorMessage {
}
const parseHeading = (message?: ParsedErrorMessage) => {
if (message?.title === 'Message' || message?.title === 'Error') return "There was an error."
if (message?.title === 'Message' || message?.title === 'Error') return _("There was an error.")
return message?.title
}

View File

@@ -0,0 +1,7 @@
import { Loader2Icon } from 'lucide-react'
export const ModalContentFallback = () => (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
)

View File

@@ -151,7 +151,7 @@ function SettingsTabItem({
)}
<span
className={cn(
"flex-1 shrink-0 truncate text-sm duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
"flex-1 shrink-0 truncate text-sm leading-4 duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
icon && "ms-2"
)}
>

View File

@@ -0,0 +1,37 @@
import { useCallback, useRef, useState } from "react"
/** Tracks per-file upload progress (01) and exposes their average. */
export function useMultiFileUploadProgress() {
const [uploadProgress, setUploadProgress] = useState(0)
const fileProgressesRef = useRef<number[]>([])
const startTracking = useCallback((fileCount: number) => {
if (fileCount <= 0) {
return
}
fileProgressesRef.current = new Array(fileCount).fill(0)
setUploadProgress(0)
}, [])
const updateFileProgress = useCallback((fileIndex: number, progress: number) => {
if (fileIndex < 0 || fileIndex >= fileProgressesRef.current.length) {
return
}
if (fileProgressesRef.current.length === 0) {
return
}
fileProgressesRef.current[fileIndex] = progress
const total =
fileProgressesRef.current.reduce((sum, p) => sum + p, 0) /
fileProgressesRef.current.length
setUploadProgress(total)
}, [])
const resetProgress = useCallback(() => {
fileProgressesRef.current = []
setUploadProgress(0)
}, [])
return { uploadProgress, startTracking, updateFileProgress, resetProgress }
}

View File

@@ -1,4 +1,3 @@
import { in_list } from "./checks";
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
import { getSystemDefault } from "./frappe";
import _ from "@/lib/translate";

View File

@@ -1,20 +1,16 @@
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
import BankClearanceSummary from "@/components/features/BankReconciliation/BankClearanceSummary"
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
import BankReconciliationStatement from "@/components/features/BankReconciliation/BankReconciliationStatement"
import BankTransactions from "@/components/features/BankReconciliation/BankTransactionList"
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
import IncorrectlyClearedEntries from "@/components/features/BankReconciliation/IncorrectlyClearedEntries"
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
import Settings from "@/components/features/Settings/Settings"
import ActionLog from "@/components/features/ActionLog/ActionLog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TooltipProvider } from "@/components/ui/tooltip"
import _ from "@/lib/translate"
import { useLayoutEffect, useRef, useState } from "react"
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Badge } from "@/components/ui/badge"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
@@ -22,6 +18,10 @@ import { Button } from "@/components/ui/button"
import { useAtomValue } from "jotai"
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
const BankReconciliationStatement = lazy(() => import('@/components/features/BankReconciliation/BankReconciliationStatement'))
const BankTransactions = lazy(() => import('@/components/features/BankReconciliation/BankTransactionList'))
const BankClearanceSummary = lazy(() => import('@/components/features/BankReconciliation/BankClearanceSummary'))
const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankReconciliation/IncorrectlyClearedEntries'))
const BankReconciliation = () => {
@@ -35,7 +35,7 @@ const BankReconciliation = () => {
}
}, [])
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 270
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
return (
<div>
@@ -122,18 +122,24 @@ const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: n
<TabsContent value="Match and Reconcile">
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
</TabsContent>
<TabsContent value="Bank Reconciliation Statement">
<BankReconciliationStatement />
</TabsContent>
<TabsContent value="Bank Transactions">
<BankTransactions />
</TabsContent>
<TabsContent value="Bank Clearance Summary">
<BankClearanceSummary />
</TabsContent>
<TabsContent value="Incorrectly Cleared Entries">
<IncorrectlyClearedEntries />
</TabsContent>
<Suspense fallback={
<div className="flex items-center justify-center p-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
}>
<TabsContent value="Bank Reconciliation Statement">
<BankReconciliationStatement />
</TabsContent>
<TabsContent value="Bank Transactions">
<BankTransactions />
</TabsContent>
<TabsContent value="Bank Clearance Summary">
<BankClearanceSummary />
</TabsContent>
<TabsContent value="Incorrectly Cleared Entries">
<IncorrectlyClearedEntries />
</TabsContent>
</Suspense>
</Tabs>
}

View File

@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
import _ from '@/lib/translate'
import { HomeIcon } from 'lucide-react'
import { HomeIcon, Loader2Icon } from 'lucide-react'
import { Link, Outlet } from 'react-router'
const BankStatementImporterContainer = () => {
@@ -29,7 +30,13 @@ const BankStatementImporterContainer = () => {
</BreadcrumbList>
</Breadcrumb>
</div>
<Outlet />
<Suspense fallback={
<div className="flex flex-1 items-center justify-center p-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
}>
<Outlet />
</Suspense>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import CSVImport from '@/components/features/BankStatementImporter/CSV/CSVImport'
import { lazy } from 'react'
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
import { Button } from '@/components/ui/button'
import { useDirection } from '@/components/ui/direction'
@@ -8,6 +8,8 @@ import { useFrappeDocumentEventListener } from 'frappe-react-sdk'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { Link, useParams } from 'react-router'
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
const ViewBankStatementImportLog = () => {
const { id } = useParams<{ id: string }>()

View File

@@ -1,6 +1,6 @@
import { BankStatementImportLogColumnMap } from './BankStatementImportLogColumnMap'
export interface BankStatementImportLog{
export interface BankStatementImportLog {
name: string
creation: string
modified: string
@@ -38,7 +38,7 @@ export interface BankStatementImportLog{
/** Detected Date Format : Data */
detected_date_format?: string
/** Detected Amount Format : Select */
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has "CR"/"DR" values" | "Amount column has positive/negative values" | "Transaction type column has "CR"/"DR" values" | "Transaction type column has "Deposit"/"Withdrawal" values" | "Transaction type column has "C"/"D" values"
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has \"CR\"/\"DR\" values" | "Amount column has positive/negative values" | "Transaction type column has \"CR\"/\"DR\" values" | "Transaction type column has \"Deposit\"/\"Withdrawal\" values" | "Transaction type column has \"C\"/\"D\" values"
/** Detected Header Index : Int */
detected_header_index?: number
/** Detected Transaction Starting Index : Int */

View File

@@ -21,5 +21,35 @@ export default defineConfig({
outDir: '../erpnext/public/banking',
emptyOutDir: true,
target: 'es2015',
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) {
return
}
if (id.includes('react-dom') || id.includes('/react/')) {
return 'vendor-react'
}
if (id.includes('frappe-react-sdk')) {
return 'vendor-frappe'
}
if (id.includes('@tanstack')) {
return 'vendor-tanstack'
}
if (id.includes('fuse.js')) {
return 'vendor-fuse'
}
if (id.includes('radix-ui') || id.includes('@radix-ui')) {
return 'vendor-radix'
}
if (id.includes('jotai')) {
return 'vendor-jotai'
}
if (id.includes('lucide-react')) {
return 'vendor-lucide'
}
},
},
},
},
});

View File

@@ -3333,11 +3333,6 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0"
tslib "^2.0.0"
react-virtuoso@^4.18.6:
version "4.18.6"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.18.6.tgz#953637adf805d562892270aafdeeedb0bda1881b"
integrity sha512-CrT3P6HyjJMHZVWSste2bG2q5aWGlHfW2QuySZjiFwB2Qok/xsvgy+k8Z2jeDP8PP5KsBip7zNrl/F0QoxeyKw==
react@^19.2.6:
version "19.2.6"
resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d"

View File

@@ -175,16 +175,19 @@ class Account(NestedSet):
if cint(self.is_group):
db_value = self.get_doc_before_save()
if db_value:
Account = frappe.qb.DocType("Account")
query = frappe.qb.update(Account).where((Account.lft > self.lft) & (Account.rgt < self.rgt))
updated = False
if self.report_type != db_value.report_type:
frappe.db.sql(
"update `tabAccount` set report_type=%s where lft > %s and rgt < %s",
(self.report_type, self.lft, self.rgt),
)
query = query.set(Account.report_type, self.report_type)
updated = True
if self.root_type != db_value.root_type:
frappe.db.sql(
"update `tabAccount` set root_type=%s where lft > %s and rgt < %s",
(self.root_type, self.lft, self.rgt),
)
query = query.set(Account.root_type, self.root_type)
updated = True
if updated:
query.run()
if self.root_type and not self.report_type:
self.report_type = (
@@ -449,11 +452,7 @@ class Account(NestedSet):
return frappe.db.get_value("GL Entry", {"account": self.name})
def check_if_child_exists(self):
return frappe.db.sql(
"""select name from `tabAccount` where parent_account = %s
and docstatus != 2""",
self.name,
)
return frappe.db.exists("Account", {"parent_account": self.name, "docstatus": ["!=", 2]})
def validate_mandatory(self):
if not self.root_type:
@@ -473,14 +472,24 @@ class Account(NestedSet):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
return frappe.db.sql(
"""select name from tabAccount
where is_group = 1 and docstatus != 2 and company = {}
and {} like {} order by name limit {} offset {}""".format("%s", searchfield, "%s", "%s", "%s"),
(filters["company"], "%%%s%%" % txt, page_len, start),
as_list=1,
Account = frappe.qb.DocType("Account")
search_field_obj = getattr(Account, searchfield)
query = (
frappe.qb.from_(Account)
.select(Account.name)
.where(Account.is_group == 1)
.where(Account.docstatus != 2)
.where(Account.company == filters["company"])
.where(search_field_obj.like(f"%{txt}%"))
.order_by(Account.name)
.limit(page_len)
.offset(start)
)
return query.run(as_list=1)
def get_account_currency(account):
"""Helper function to get account currency"""
@@ -521,6 +530,7 @@ def update_account_number(
):
_ensure_idle_system()
account = frappe.get_cached_doc("Account", name)
account.check_permission("write")
if not account:
return

View File

@@ -570,6 +570,17 @@
"account_number": "5000",
"is_group": 1,
"root_type": "Expense",
"Cost of Goods Sold": {
"account_number": "5001",
"is_group": 1,
"root_type": "Expense",
"Cost of Goods Sold": {
"account_number": "5010",
"is_group": 0,
"root_type": "Expense",
"account_type": "Cost of Goods Sold"
}
},
"Operating Expenses": {
"account_number": "5100",
"is_group": 1,

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "format:Bank Statement Import on {creation}",
"beta": 1,
"creation": "2019-08-04 14:16:08.318714",
"doctype": "DocType",
"editable_grid": 1,
@@ -226,11 +226,11 @@
],
"hide_toolbar": 1,
"links": [],
"modified": "2025-06-11 02:23:22.159961",
"modified": "2026-05-31 00:41:11.251215",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Statement Import",
"naming_rule": "Expression",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

View File

@@ -47,7 +47,7 @@ class TestBankTransaction(ERPNextTestSuite):
from_date=bank_transaction.date,
to_date=utils.today(),
)
self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
self.assertEqual(linked_payments[0]["party"], "Conrad Electronic")
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
def test_reconcile(self):
@@ -70,10 +70,10 @@ class TestBankTransaction(ERPNextTestSuite):
unallocated_amount = frappe.db.get_value(
"Bank Transaction", bank_transaction.name, "unallocated_amount"
)
self.assertTrue(unallocated_amount == 0)
self.assertEqual(unallocated_amount, 0)
clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date")
self.assertTrue(clearance_date is not None)
self.assertIsNot(clearance_date, None)
bank_transaction.reload()
bank_transaction.cancel()
@@ -178,9 +178,8 @@ class TestBankTransaction(ERPNextTestSuite):
self.assertEqual(
frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount"), 0
)
self.assertTrue(
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date")
is not None
self.assertIsNot(
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date"), None
)
@if_lending_app_installed

View File

@@ -182,7 +182,7 @@ class TestCostCenterAllocation(ERPNextTestSuite):
self.assertTrue(gl_entries)
for gle in gl_entries:
self.assertTrue(gle.cost_center in expected_values)
self.assertIn(gle.cost_center, expected_values)
self.assertEqual(gle.debit, 0)
self.assertEqual(gle.credit, expected_values[gle.cost_center])

View File

@@ -1,8 +1,8 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_events_in_timeline": 1,
"autoname": "naming_series:",
"beta": 1,
"creation": "2019-07-05 16:34:31.013238",
"doctype": "DocType",
"engine": "InnoDB",
@@ -400,7 +400,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2024-11-26 13:46:07.760867",
"modified": "2026-05-30 23:18:04.712528",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning",
@@ -449,9 +449,10 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"title_field": "customer_name",
"track_changes": 1
}
}

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"beta": 1,
"creation": "2019-12-04 04:59:08.003664",
"doctype": "DocType",
"editable_grid": 1,
@@ -107,7 +107,7 @@
"link_fieldname": "dunning_type"
}
],
"modified": "2024-03-27 13:08:19.584112",
"modified": "2026-05-30 23:18:20.740726",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Dunning Type",
@@ -151,8 +151,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -565,18 +565,19 @@ class FinancialQueryBuilder:
frappe.qb.from_(acb_table)
.select(
acb_table.account,
(acb_table.debit - acb_table.credit).as_("balance"),
Sum(acb_table.debit - acb_table.credit).as_("balance"),
)
.where(acb_table.company == self.company)
.where(acb_table.account.isin(account_names))
.where(acb_table.period_closing_voucher == closing_voucher)
.groupby(acb_table.account)
)
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
results = self._execute_with_permissions(query, "Account Closing Balance")
for row in results:
closing_balances[row["account"]] = row["balance"]
closing_balances[row["account"]] = row["balance"] or 0.0
return closing_balances

View File

@@ -361,7 +361,7 @@ class CalculationFormulaValidator(Validator):
"sqrt": lambda x: x**0.5,
"pow": pow,
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
"floor": lambda x: int(x),
"floor": int,
}
)

View File

@@ -16,6 +16,7 @@ from erpnext.accounts.doctype.financial_report_template.test_financial_report_te
)
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
from erpnext.tests.utils import change_settings
class TestDependencyResolver(FinancialReportTemplateTestCase):
@@ -1950,6 +1951,104 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
jv_2023.cancel()
@change_settings("Accounts Settings", {"use_legacy_controller_for_pcv": 1})
def test_opening_balance_sums_acb_rows_across_dimensions(self):
"""
Account Closing Balance stores one row per (account, cost_center,
project, finance_book). The closing-balance fetch must sum all rows.
"""
company = "_Test Company"
cash_account = "_Test Cash - _TC"
sales_account = "Sales - _TC"
cc_1 = "_Test Cost Center - _TC"
cc_2 = "_Test Cost Center 2 - _TC"
docs = []
try:
jv_2023_cc1 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=3000,
posting_date="2023-06-15",
cost_center=cc_1,
company=company,
submit=True,
)
docs.append(jv_2023_cc1)
jv_2023_cc2 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=2000,
posting_date="2023-06-15",
cost_center=cc_2,
company=company,
submit=True,
)
docs.append(jv_2023_cc2)
fy_2023 = get_fiscal_year("2023-06-15", company=company)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": "2023-12-31",
"period_start_date": fy_2023[1],
"period_end_date": fy_2023[2],
"company": company,
"fiscal_year": fy_2023[0],
"cost_center": cc_1,
"closing_account_head": "Deferred Revenue - _TC",
"remarks": "Test multi-dim PCV",
}
)
pcv.insert()
pcv.submit()
docs.append(pcv)
jv_2024 = make_journal_entry(
account1=cash_account,
account2=sales_account,
amount=100,
posting_date="2024-01-15",
cost_center=cc_1,
company=company,
submit=True,
)
docs.append(jv_2024)
filters = {
"company": company,
"from_fiscal_year": "2024",
"to_fiscal_year": "2024",
"period_start_date": "2024-01-01",
"period_end_date": "2024-03-31",
"filter_based_on": "Date Range",
"periodicity": "Monthly",
"ignore_closing_entries": True,
}
periods = [
{"key": "2024_jan", "from_date": "2024-01-01", "to_date": "2024-01-31"},
{"key": "2024_feb", "from_date": "2024-02-01", "to_date": "2024-02-29"},
{"key": "2024_mar", "from_date": "2024-03-01", "to_date": "2024-03-31"},
]
query_builder = FinancialQueryBuilder(filters, periods)
accounts = [
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
]
balances_data = query_builder.fetch_account_balances(accounts)
cash_data = balances_data.get(cash_account)
self.assertIsNotNone(cash_data, "Cash account must appear in results")
jan_cash = cash_data.get_period("2024_jan")
self.assertEqual(jan_cash.opening, 5000.0)
self.assertEqual(jan_cash.movement, 100.0)
self.assertEqual(jan_cash.closing, 5100.0)
finally:
self.cancel_docs(docs)
def test_opening_entries_roll_into_opening_after_period_closing(self):
"""
Sequence:

View File

@@ -9,6 +9,14 @@ from erpnext.tests.utils import ERPNextTestSuite
class FinancialReportTemplateTestCase(ERPNextTestSuite):
"""Utility class with common setup and helper methods for all test classes"""
def cancel_docs(self, docs):
"""Cancel submitted docs in reverse creation order to avoid dependency issues."""
for doc in reversed(docs):
if doc:
doc.reload()
if doc.docstatus == 1:
doc.cancel()
def setUp(self):
"""Set up test data"""
self.create_test_template()

View File

@@ -433,15 +433,17 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
accounts_add(doc, cdt, cdn) {
var row = frappe.get_doc(cdt, cdn);
row.exchange_rate = 1;
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
if (!row.exchange_rate) row.exchange_rate = 1;
if (!row.account) {
$.each(doc.accounts, function (i, d) {
if (d.account && d.party && d.party_type) {
row.account = d.account;
row.party = d.party;
row.party_type = d.party_type;
row.exchange_rate = d.exchange_rate;
}
});
}
// set difference
if (doc.difference) {

View File

@@ -89,7 +89,7 @@ class TestJournalEntry(ERPNextTestSuite):
)
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
self.assertTrue(flt(advance_paid[0][0]) == flt(payment_against_order))
self.assertEqual(flt(advance_paid[0][0]), flt(payment_against_order))
def cancel_against_voucher_testcase(self, test_voucher):
if test_voucher.doctype == "Journal Entry":

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_copy": 1,
"beta": 1,
"creation": "2017-08-29 02:22:54.947711",
"doctype": "DocType",
"editable_grid": 1,
@@ -90,7 +90,7 @@
"hide_toolbar": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-31 01:47:20.360352",
"modified": "2026-05-30 23:18:48.691227",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool",

View File

@@ -29,6 +29,7 @@
{
"fieldname": "advance_account",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Advance Account",
"options": "Account"
}
@@ -36,14 +37,15 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:08.489183",
"modified": "2026-05-27 14:19:00.888437",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Party Account",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -1726,6 +1726,35 @@ frappe.ui.form.on("Payment Entry", {
},
});
},
before_cancel: function (frm) {
return new Promise((resolve, reject) => {
frappe.call({
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
args: { payment_entry: frm.doc.name },
callback: function (r) {
const linked = r.message || [];
if (!linked.length) {
resolve();
return;
}
const bt_links = linked
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
.join(", ");
frappe.confirm(
__(
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
[bt_links]
),
() => resolve(),
() => reject(),
__("Yes"),
__("No")
);
},
});
});
},
});
frappe.ui.form.on("Payment Entry Reference", {

View File

@@ -3314,3 +3314,16 @@ def make_payment_order(source_name: str, target_doc: str | Document | None = Non
@erpnext.allow_regional
def add_regional_gl_entries(gl_entries, doc):
return
@frappe.whitelist()
def get_linked_bank_transactions(payment_entry: str) -> list:
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
return frappe.get_all(
"Bank Transaction Payments",
filters={
"payment_document": "Payment Entry",
"payment_entry": payment_entry,
},
pluck="parent",
)

View File

@@ -1119,7 +1119,7 @@ class TestPaymentEntry(ERPNextTestSuite):
with self.assertRaises(frappe.ValidationError) as err:
pe.save()
self.assertTrue("is on hold" in str(err.exception).lower())
self.assertIn("is on hold", str(err.exception).lower())
def test_payment_entry_for_employee(self):
employee = make_employee("test_payment_entry@salary.com", company="_Test Company")
@@ -2035,8 +2035,8 @@ class TestPaymentEntry(ERPNextTestSuite):
# check cancellation of payment entry and journal entry
pe.cancel()
self.assertTrue(pe.docstatus == 2)
self.assertTrue(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus") == 2)
self.assertEqual(pe.docstatus, 2)
self.assertEqual(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus"), 2)
# check deletion of payment entry and journal entry
pe.delete()

View File

@@ -3,11 +3,9 @@
import frappe
from frappe import qb
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
from frappe.utils.data import getdate as convert_to_date
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
@@ -15,7 +13,6 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import get_fiscal_year
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestSuite

View File

@@ -1293,11 +1293,16 @@ def get_open_payment_requests_query(
)
return [
(
pr.name,
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
)
{
"value": pr.name,
"description": ", ".join(
[
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
]
),
"description_html": True,
}
for pr in open_payment_requests
]

View File

@@ -26,8 +26,6 @@
"due_date",
"amended_from",
"return_against",
"section_break_clmv",
"title",
"accounting_dimensions_section",
"project",
"dimension_col_break",
@@ -172,6 +170,7 @@
"is_discounted",
"col_break23",
"status",
"title",
"more_info",
"debit_to",
"party_account_currency",
@@ -1625,10 +1624,6 @@
"fieldtype": "Section Break",
"label": "Auto Repeat"
},
{
"fieldname": "section_break_clmv",
"fieldtype": "Section Break"
},
{
"allow_on_submit": 1,
"fieldname": "title",
@@ -1641,7 +1636,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-05-01 02:37:30.580568",
"modified": "2026-05-28 12:22:50.253090",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -59,7 +59,7 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
pos_inv3.load_from_db()
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice)
self.assertNotEqual(pos_inv.consolidated_invoice, pos_inv3.consolidated_invoice)
def test_consolidated_credit_note_creation(self):
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
@@ -454,12 +454,12 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
pos_inv2.load_from_db()
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv2.consolidated_invoice))
self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice)
self.assertNotEqual(pos_inv.consolidated_invoice, pos_inv3.consolidated_invoice)
pos_inv3.load_from_db()
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
self.assertTrue(pos_inv2.consolidated_invoice == pos_inv3.consolidated_invoice)
self.assertEqual(pos_inv2.consolidated_invoice, pos_inv3.consolidated_invoice)
def test_company_in_pos_invoice_merge_log(self):
"""

View File

@@ -209,15 +209,14 @@ class POSProfile(Document):
def set_defaults(self, include_current_pos=True):
frappe.defaults.clear_default("is_pos")
if not include_current_pos:
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
else:
condition = " where pfu.default = 1 "
pfu = frappe.qb.DocType("POS Profile User")
pos_view_users = frappe.db.sql_list(
f"""select pfu.user
from `tabPOS Profile User` as pfu {condition}"""
)
query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
if not include_current_pos:
query = query.where(pfu.name != self.name)
pos_view_users = query.run(as_list=1, pluck=True)
for user in pos_view_users:
if user:

View File

@@ -151,13 +151,13 @@
"label": "Default Advance Account",
"mandatory_depends_on": "doc.party_type",
"options": "Account",
"reqd": 1
"reqd": 0
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-01-08 08:22:14.798085",
"modified": "2026-05-16 11:43:12.758685",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Payment Reconciliation",

View File

@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
bank_cash_account: DF.Link | None
company: DF.Link
cost_center: DF.Link | None
default_advance_account: DF.Link
default_advance_account: DF.Link | None
error_log: DF.LongText | None
from_invoice_date: DF.Date | None
from_payment_date: DF.Date | None
@@ -218,10 +218,7 @@ def trigger_reconciliation_for_queued_docs():
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
def get_filters_as_tuple(fields, doc):
filters = ()
for x in fields:
filters += tuple(doc.get(x))
return filters
return tuple(doc.get(x) or "" for x in fields)
for x in all_queued:
doc = frappe.get_doc("Process Payment Reconciliation", x)

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "format:Process-PCV-{###}",
"creation": "2025-09-25 15:44:03.534699",
"doctype": "DocType",
@@ -7,11 +8,13 @@
"field_order": [
"parent_pcv",
"status",
"amended_from",
"section_normal_balances",
"p_l_closing_balance",
"normal_balances",
"bs_closing_balance",
"z_opening_balances",
"amended_from"
"normal_balances",
"section_opening_balances",
"z_opening_balances"
],
"fields": [
{
@@ -64,17 +67,27 @@
"fieldname": "bs_closing_balance",
"fieldtype": "JSON",
"label": "Balance Sheet Closing Balance"
},
{
"fieldname": "section_normal_balances",
"fieldtype": "Tab Break",
"label": "Normal Balances"
},
{
"fieldname": "section_opening_balances",
"fieldtype": "Tab Break",
"label": "Opening Balances"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2025-11-05 11:40:24.996403",
"modified": "2026-06-01 12:16:37.374412",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Period Closing Voucher",
"naming_rule": "Expression",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{

View File

@@ -36,8 +36,8 @@ class ProcessPeriodClosingVoucher(Document):
parent_pcv: DF.Link
status: DF.Literal["Queued", "Running", "Paused", "Completed", "Cancelled"]
z_opening_balances: DF.Table[ProcessPeriodClosingVoucherDetail]
# end: auto-generated types
def on_discard(self):
self.db_set("status", "Cancelled")
@@ -562,6 +562,9 @@ def process_individual_date(docname: str, date, report_type, parentfield):
if parentfield == "z_opening_balances":
query = query.where(gle.is_opening.eq("Yes"))
else:
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
query = query.where(gle.is_opening.eq("No"))
query = query.groupby(gle.account)
for dim in dimensions:

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
// render
frappe.listview_settings["Process Period Closing Voucher"] = {
add_fields: ["status"],
get_indicator: function (doc) {
const status_colors = {
Queued: "blue",
Running: "orange",
Paused: "gray",
Completed: "green",
Cancelled: "red",
};
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
},
};

View File

@@ -1,4 +1,173 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from frappe.utils import today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher import (
process_individual_date,
)
from erpnext.accounts.utils import get_fiscal_year
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
def setUp(self):
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 0)
self.company = "_Test Company"
def make_period_closing_voucher(self, posting_date, submit=True):
fy = get_fiscal_year(posting_date, company="_Test Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": posting_date or today(),
"period_start_date": fy[1],
"period_end_date": fy[2],
"company": self.company,
"fiscal_year": fy[0],
"closing_account_head": "Retained Earnings - _TC",
"remarks": "closing",
}
)
pcv.insert()
if submit:
pcv.submit()
return pcv
def make_process_pcv(self):
self.pcv = self.make_period_closing_voucher(posting_date=today(), submit=False)
ppcv = frappe.get_doc(
{
"doctype": "Process Period Closing Voucher",
"parent_pcv": self.pcv.name,
}
)
ppcv.save()
return ppcv
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
"status",
status,
)
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
return frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
"closing_balance",
)
def test_opening_balance_double_counting(self):
ppcv = self.make_process_pcv()
self.assertEqual(self.pcv.is_first_period_closing_voucher(), True)
opening_jv = make_journal_entry(
posting_date=today(),
amount=10,
account1="Cash - _TC",
account2="Debtors - _TC",
company=self.company,
save=False,
)
opening_jv.accounts[1].party_type = "Customer"
opening_jv.accounts[1].party = "_Test Customer"
opening_jv.is_opening = "Yes"
opening_jv.save()
opening_jv.submit()
jv = make_journal_entry(
posting_date=today(),
amount=120,
account1="Debtors - _TC",
account2="Sales - _TC",
company=self.company,
save=False,
)
jv.accounts[0].party_type = "Customer"
jv.accounts[0].party = "_Test Customer"
jv.save()
jv.submit()
# P&L balance
parentfield = "normal_balances"
rpt_type = "Profit and Loss"
# status has to be set to 'Running' for logic to run
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 1)
expected_pl = {
"account": "Sales - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 0.0,
"credit": 120.0,
"debit_in_account_currency": 0.0,
"credit_in_account_currency": 120.0,
}
for k in expected_pl.keys():
with self.subTest(k):
self.assertEqual(expected_pl[k], bal[0][k])
# Balance sheet balance
rpt_type = "Balance Sheet"
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 1)
expected_bs = {
"account": "Debtors - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 120.0,
"credit": 0.0,
"debit_in_account_currency": 120.0,
"credit_in_account_currency": 0.0,
}
for k in expected_bs.keys():
with self.subTest(k):
self.assertEqual(expected_bs[k], bal[0][k])
# Opening balance
parentfield = "z_opening_balances"
rpt_type = "Balance Sheet"
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 2)
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
expected_opening_cash = {
"account": "Cash - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 10.0,
"credit": 0.0,
"debit_in_account_currency": 10.0,
"credit_in_account_currency": 0.0,
"account_currency": "INR",
}
for k in expected_opening_cash.keys():
with self.subTest(k):
self.assertEqual(expected_opening_cash[k], opening_cash[k])
opening_debtors = next(x for x in bal if x["account"] == "Debtors - _TC")
expected_opening_debtors = {
"account": "Debtors - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit": 0.0,
"credit": 10.0,
"debit_in_account_currency": 0.0,
"credit_in_account_currency": 10.0,
"account_currency": "INR",
}
for k in expected_opening_debtors.keys():
with self.subTest(k):
self.assertEqual(expected_opening_debtors[k], opening_debtors[k])

View File

@@ -28,8 +28,6 @@
"update_billed_amount_in_purchase_receipt",
"apply_tds",
"amended_from",
"section_break_ecfi",
"title",
"supplier_invoice_details",
"bill_no",
"column_break_15",
@@ -202,6 +200,7 @@
"hold_comment",
"additional_info_section",
"is_internal_supplier",
"title",
"represents_company",
"supplier_group",
"sender",
@@ -1684,10 +1683,6 @@
"fieldname": "automation_section",
"fieldtype": "Section Break",
"label": "Automation"
},
{
"fieldname": "section_break_ecfi",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
@@ -1695,7 +1690,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-05-04 10:10:11.717131",
"modified": "2026-05-28 12:36:55.215363",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -2077,7 +2077,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.save().submit()
self.assertTrue(return_pi.docstatus == 1)
self.assertEqual(return_pi.docstatus, 1)
def test_advance_entries_as_asset(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry

View File

@@ -33,8 +33,6 @@
"is_created_using_pos",
"pos_closing_entry",
"has_subcontracted",
"section_break_sgnf",
"title",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -232,6 +230,7 @@
"status",
"remarks",
"customer_group",
"title",
"column_break_imbx",
"is_internal_customer",
"represents_company",
@@ -2333,10 +2332,6 @@
"fieldtype": "Section Break",
"label": "Automation"
},
{
"fieldname": "section_break_sgnf",
"fieldtype": "Section Break"
},
{
"allow_on_submit": 1,
"fieldname": "title",
@@ -2357,7 +2352,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-05-21 17:31:11.190958",
"modified": "2026-05-28 12:15:12.486443",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -692,7 +692,7 @@ class SalesInvoice(SellingController):
POSService(self).clear_unallocated_mode_of_payments()
def get_company_abbr(self):
return frappe.db.sql("select abbr from tabCompany where name=%s", self.company)[0][0]
return frappe.db.get_value("Company", self.company, "abbr")
def validate_debit_to_acc(self):
if not self.debit_to:
@@ -822,12 +822,20 @@ class SalesInvoice(SellingController):
def validate_proj_cust(self):
"""check for does customer belong to same project as entered.."""
if self.project and self.customer:
res = frappe.db.sql(
"""select name from `tabProject`
where name = %s and (customer = %s or customer is null or customer = '')""",
(self.project, self.customer),
Project = frappe.qb.DocType("Project")
query = (
frappe.qb.from_(Project)
.select(Project.name)
.where(Project.name == self.project)
.where(
(Project.customer == self.customer)
| (Project.customer.isnull())
| (Project.customer == "")
)
)
if not res:
if not query.run():
throw(_("Customer {0} does not belong to project {1}").format(self.customer, self.project))
def validate_warehouse(self):
@@ -1013,15 +1021,24 @@ class SalesInvoice(SellingController):
def update_billing_status_in_dn(self, update_modified=True):
if self.is_return and not self.update_billed_amount_in_delivery_note:
return
updated_delivery_notes = []
SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item")
from frappe.query_builder.functions import Coalesce, Sum
for d in self.get("items"):
if d.dn_detail:
billed_amt = frappe.db.sql(
"""select sum(amount) from `tabSales Invoice Item`
where dn_detail=%s and docstatus=1""",
d.dn_detail,
query = (
frappe.qb.from_(SalesInvoiceItem)
.select(Coalesce(Sum(SalesInvoiceItem.amount), 0))
.where(SalesInvoiceItem.dn_detail == d.dn_detail)
.where(SalesInvoiceItem.docstatus == 1)
)
billed_amt = billed_amt and billed_amt[0][0] or 0
res = query.run()
billed_amt = res[0][0] if res else 0
frappe.db.set_value(
"Delivery Note Item",
d.dn_detail,
@@ -1166,27 +1183,28 @@ def check_if_return_invoice_linked_with_payment_entry(self):
else:
invoice = self.name
payment_entries = frappe.db.sql_list(
"""
SELECT
t1.name
FROM
`tabPayment Entry` t1, `tabPayment Entry Reference` t2
WHERE
t1.name = t2.parent
and t1.docstatus = 1
and t2.reference_name = %s
and t2.allocated_amount < 0
""",
invoice,
PaymentEntry = frappe.qb.DocType("Payment Entry")
PaymentEntryReference = frappe.qb.DocType("Payment Entry Reference")
query = (
frappe.qb.from_(PaymentEntry)
.join(PaymentEntryReference)
.on(PaymentEntry.name == PaymentEntryReference.parent)
.select(PaymentEntry.name)
.where(PaymentEntry.docstatus == 1)
.where(PaymentEntryReference.reference_name == invoice)
.where(PaymentEntryReference.allocated_amount < 0)
)
payment_entries = query.run(pluck=True)
links_to_pe = []
if payment_entries:
for payment in payment_entries:
payment_entry = frappe.get_doc("Payment Entry", payment)
if len(payment_entry.references) > 1:
links_to_pe.append(payment_entry.name)
if links_to_pe:
payment_entries_link = [
get_link_to_form("Payment Entry", name, label=name) for name in links_to_pe

View File

@@ -92,7 +92,8 @@ class POSService:
doc.set("selling_price_list", selling_price_list)
if not for_validate:
doc.update_stock = cint(pos.get("update_stock"))
dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
for item in doc.get("items"):
if item.get("item_code"):
@@ -257,10 +258,7 @@ class POSService:
def clear_unallocated_mode_of_payments(self) -> None:
doc = self.doc
doc.set("payments", doc.get("payments", {"amount": ["not in", [0, None, ""]]}))
frappe.db.sql(
"""delete from `tabSales Invoice Payment` where parent = %s and amount = 0""",
doc.name,
)
frappe.db.delete("Sales Invoice Payment", filters={"parent": doc.name, "amount": 0})
def allow_write_off_only_on_pos(self) -> None:
if not self.doc.is_pos and self.doc.write_off_account:
@@ -278,19 +276,29 @@ class POSService:
def get_warehouse(self) -> str | None:
doc = self.doc
user_pos_profile = frappe.db.sql(
"""select name, warehouse from `tabPOS Profile`
where ifnull(user,'') = %s and company = %s""",
(frappe.session["user"], doc.company),
POSProfile = frappe.qb.DocType("POS Profile")
user_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == doc.company)
.where(
(POSProfile.user == frappe.session["user"])
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
)
)
user_pos_profile = user_query.run()
warehouse = user_pos_profile[0][1] if user_pos_profile else None
if not warehouse:
global_pos_profile = frappe.db.sql(
"""select name, warehouse from `tabPOS Profile`
where (user is null or user = '') and company = %s""",
doc.company,
global_query = (
frappe.qb.from_(POSProfile)
.select(POSProfile.name, POSProfile.warehouse)
.where(POSProfile.company == doc.company)
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
)
global_pos_profile = global_query.run()
if global_pos_profile:
warehouse = global_pos_profile[0][1]
elif not user_pos_profile:
@@ -354,15 +362,22 @@ def update_multi_mode_option(doc, pos_profile) -> None:
def get_all_mode_of_payments(doc) -> list:
return frappe.db.sql(
"""
select mpa.default_account, mpa.parent, mp.type as type
from `tabMode of Payment Account` mpa,`tabMode of Payment` mp
where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""",
{"company": doc.company},
as_dict=1,
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPaymentAccount)
.join(ModeOfPayment)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == doc.company)
.where(ModeOfPayment.enabled == 1)
)
return query.run(as_dict=1)
def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
data = frappe.db.sql(

View File

@@ -111,18 +111,22 @@ def is_overdue(doc, total: float) -> bool | None:
def get_discounting_status(sales_invoice: str) -> str | None:
status = None
invoice_discounting_list = frappe.db.sql(
"""
select status
from `tabInvoice Discounting` id, `tabDiscounted Invoice` d
where
id.name = d.parent
and d.sales_invoice=%s
and id.docstatus=1
and status in ('Disbursed', 'Settled')
""",
sales_invoice,
InvoiceDiscounting = frappe.qb.DocType("Invoice Discounting")
DiscountedInvoice = frappe.qb.DocType("Discounted Invoice")
query = (
frappe.qb.from_(InvoiceDiscounting)
.join(DiscountedInvoice)
.on(InvoiceDiscounting.name == DiscountedInvoice.parent)
.select(InvoiceDiscounting.status)
.where(DiscountedInvoice.sales_invoice == sales_invoice)
.where(InvoiceDiscounting.docstatus == 1)
.where(InvoiceDiscounting.status.isin(["Disbursed", "Settled"]))
)
invoice_discounting_list = query.run()
for d in invoice_discounting_list:
status = d[0]
if status == "Disbursed":

View File

@@ -881,7 +881,7 @@ class TestSalesInvoice(ERPNextTestSuite):
link_doctypes = [d.parent for d in link_data]
# test case for dynamic link order
self.assertTrue(link_doctypes.index("GL Entry") > link_doctypes.index("Journal Entry Account"))
self.assertGreater(link_doctypes.index("GL Entry"), link_doctypes.index("Journal Entry Account"))
jv.cancel()
self.assertEqual(frappe.db.get_value("Sales Invoice", w.name, "outstanding_amount"), 562.0)
@@ -3525,7 +3525,7 @@ class TestSalesInvoice(ERPNextTestSuite):
with self.assertRaises(frappe.ValidationError) as err:
si.save()
self.assertTrue("cannot overbill" in str(err.exception).lower())
self.assertIn("cannot overbill", str(err.exception).lower())
dn.cancel()
@ERPNextTestSuite.change_settings(
@@ -3638,9 +3638,7 @@ class TestSalesInvoice(ERPNextTestSuite):
with self.assertRaises(frappe.ValidationError) as err:
si.submit()
self.assertTrue(
"Cannot create accounting entries against disabled accounts" in str(err.exception)
)
self.assertIn("Cannot create accounting entries against disabled accounts", str(err.exception))
finally:
account.disabled = 0
@@ -3735,7 +3733,7 @@ class TestSalesInvoice(ERPNextTestSuite):
return_si = make_return_doc(si.doctype, si.name)
return_si.save().submit()
self.assertTrue(return_si.docstatus == 1)
self.assertEqual(return_si.docstatus, 1)
def test_sales_invoice_with_payable_tax_account(self):
si = create_sales_invoice(do_not_submit=True)

View File

@@ -947,7 +947,8 @@
"fieldtype": "Currency",
"label": "Distributed Discount Amount",
"options": "currency",
"print_hide": 1
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "available_quantity_section",
@@ -1016,7 +1017,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-02-24 14:37:16.853941",
"modified": "2026-05-29 12:23:28.259905",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -387,7 +387,7 @@ class TestTaxRule(ERPNextTestSuite):
self.assertEqual(quotation.taxes_and_charges, "_Test Sales Taxes and Charges Template - _TC")
# Check if accounts heads and rate fetched are also fetched from tax template or not
self.assertTrue(len(quotation.taxes) > 0)
self.assertGreater(len(quotation.taxes), 0)
def make_tax_rule(**args):

View File

@@ -7,7 +7,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.utils import getdate
from frappe.utils import cstr, getdate
from erpnext import allow_regional
from erpnext.controllers.accounts_controller import validate_account_head
@@ -48,7 +48,7 @@ class TaxWithholdingCategory(Document):
for d in self.get("rates"):
if getdate(d.from_date) >= getdate(d.to_date):
frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx))
group_rates[d.tax_withholding_group].append(d)
group_rates[cstr(d.tax_withholding_group)].append(d)
# Validate overlapping dates within each group
for group, rates in group_rates.items():
@@ -92,10 +92,9 @@ class TaxWithholdingCategory(Document):
def get_applicable_tax_row(self, posting_date, tax_withholding_group):
for row in self.rates:
if (
getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date)
and row.tax_withholding_group == tax_withholding_group
):
if getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) and cstr(
row.tax_withholding_group
) == cstr(tax_withholding_group):
return row
frappe.throw(_("No Tax Withholding data found for the current posting date."))
@@ -116,7 +115,7 @@ class TaxWithholdingDetails:
def __init__(
self,
tax_withholding_categories: list[str],
tax_withholding_group: str,
tax_withholding_group: str | None,
posting_date: str,
party_type: str,
party: str,

View File

@@ -476,7 +476,7 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
# Cumulative threshold is 10,000
# Threshold calculation should be only on the third invoice
self.assertTrue(len(pi1.taxes) > 0)
self.assertGreater(len(pi1.taxes), 0)
self.assertEqual(pi1.taxes[0].tax_amount, 1000)
self.cleanup_invoices(invoices)
@@ -999,6 +999,47 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
self.cleanup_invoices(invoices)
def test_null_and_empty_tax_withholding_group_are_equivalent(self):
"""
NULL and empty-string `tax_withholding_group` must be treated as the
same value.
"""
category = frappe.get_doc("Tax Withholding Category", "Cumulative Threshold TDS")
original_row = category.rates[0]
original_row.tax_withholding_group = None
# Part 1: validate_dates must detect overlap between NULL-group and
# empty-string-group rows covering the same date range.
category.append(
"rates",
{
"from_date": original_row.from_date,
"to_date": original_row.to_date,
"tax_withholding_group": "",
"tax_withholding_rate": original_row.tax_withholding_rate,
},
)
with self.assertRaises(frappe.ValidationError):
category.validate_dates()
category.rates.pop()
# Part 2: get_applicable_tax_row must match NULL <-> "" in either direction.
posting_date = original_row.from_date
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="")
self.assertEqual(row.name, original_row.name)
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
self.assertEqual(row.name, original_row.name)
original_row.tax_withholding_group = ""
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
self.assertEqual(row.name, original_row.name)
original_row.tax_withholding_group = None
with self.assertRaises(frappe.ValidationError):
category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="194R")
def test_tds_calculation_on_net_total(self):
self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS")
invoices = []
@@ -3613,7 +3654,7 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=50000, do_not_save=True)
pi.save()
self.assertTrue(len(pi.tax_withholding_entries) > 0)
self.assertGreater(len(pi.tax_withholding_entries), 0)
pi.delete()
def test_tds_rounding_with_decimal_amounts(self):
@@ -3679,7 +3720,7 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
self.setup_party_with_category("Supplier", "Test TDS Supplier", "Cumulative Threshold TDS")
pi = create_purchase_invoice(supplier="Test TDS Supplier", rate=50000)
self.assertTrue(len(pi.tax_withholding_entries) > 0)
self.assertGreater(len(pi.tax_withholding_entries), 0)
pi.override_tax_withholding_entries = 1
entry = pi.tax_withholding_entries[0]

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Purchase Invoice",
"dynamic_filters_json": "[[\"Purchase Invoice\",\"company\",\"=\",\" frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"],[\"Purchase Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]",
"dynamic_filters_json": "[[\"Purchase Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Purchase Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Purchase Invoice\",\"docstatus\",\"=\",\"1\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Bills",
"modified": "2024-12-05 12:00:00.000000",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Receive\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Incoming Payment",
"modified": "2024-12-05 12:00:00.000000",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Incoming Payment",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Sales Invoice",
"dynamic_filters_json": "[[\"Sales Invoice\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"],[\"Sales Invoice\",\"posting_date\",\"Timespan\",\"this year\"]]",
"dynamic_filters_json": "[[\"Sales Invoice\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Sales Invoice\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Sales Invoice\",\"docstatus\",\"=\",\"1\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Bills",
"modified": "2024-12-05 12:00:00.000000",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Bills",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Payment Entry",
"dynamic_filters_json": "[[\"Payment Entry\",\"company\",\"=\",\"frappe.defaults.get_user_default(\\\"Company\\\")\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"posting_date\",\"Timespan\",\"this year\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
"dynamic_filters_json": "[[\"Payment Entry\", \"company\", \"=\", \"frappe.defaults.get_user_default(\\\"Company\\\")\"], [\"Payment Entry\", \"posting_date\", \"Between\", \"(frappe.boot.current_fiscal_year || [null, `${frappe.datetime.get_today().slice(0,4)}-01-01`, `${frappe.datetime.get_today().slice(0,4)}-12-31`]).slice(1)\"]]",
"filters_json": "[[\"Payment Entry\",\"docstatus\",\"=\",\"1\"],[\"Payment Entry\",\"payment_type\",\"=\",\"Pay\"]]",
"function": "Sum",
"idx": 0,
"is_public": 1,
"is_standard": 1,
"label": "Total Outgoing Payment",
"modified": "2024-12-05 12:00:00.000000",
"modified": "2026-06-01 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Total Outgoing Payment",

View File

@@ -6,7 +6,6 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, query_builder, scrub
from frappe.database.schema import get_definition
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate

View File

@@ -194,7 +194,7 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
row = report[1]
self.assertTrue(len(row) == 0)
self.assertEqual(len(row), 0)
@ERPNextTestSuite.change_settings(
"Accounts Settings",
@@ -764,7 +764,7 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)[1]
# Assert that the report contains data for the specified customer groups
self.assertTrue(len(report) > 0)
self.assertGreater(len(report), 0)
for row in report:
# Assert that the customer group of each row is in the list of customer groups

View File

@@ -38,6 +38,14 @@ function get_filters() {
let budget_against_options = get_dimensions();
let filters = [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "from_fiscal_year",
label: __("From Fiscal Year"),
@@ -67,14 +75,6 @@ function get_filters() {
default: "Yearly",
reqd: 1,
},
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "budget_against",
label: __("Budget Against"),
@@ -96,9 +96,12 @@ function get_filters() {
if (!frappe.query_report.filters) return;
let budget_against = frappe.query_report.get_filter_value("budget_against");
let company = frappe.query_report.get_filter_value("company");
if (!budget_against) return;
return frappe.db.get_link_options(budget_against, txt);
const filters = budget_against !== "Branch" && company ? { company: company } : {};
return frappe.db.get_link_options(budget_against, txt, filters);
},
},
{

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder import CustomFunction
from frappe.utils import add_months, flt, formatdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
@@ -19,6 +20,8 @@ def execute(filters=None):
columns = get_columns(filters)
if filters.get("budget_against_filter"):
dimensions = filters.get("budget_against_filter")
if filters.get("budget_against") == "Cost Center":
dimensions = get_cost_center_with_children(dimensions)
else:
dimensions = get_budget_dimensions(filters)
if not dimensions:
@@ -40,39 +43,29 @@ def validate_filters(filters):
def get_budget_records(filters, dimensions):
budget_against_field = frappe.scrub(filters["budget_against"])
budget = frappe.qb.DocType("Budget")
return frappe.db.sql(
f"""
SELECT
b.name,
b.account,
b.{budget_against_field} AS dimension,
b.budget_amount,
b.from_fiscal_year,
b.to_fiscal_year,
b.budget_start_date,
b.budget_end_date
FROM
`tabBudget` b
WHERE
b.company = %s
AND b.docstatus = 1
AND b.budget_against = %s
AND b.{budget_against_field} IN ({", ".join(["%s"] * len(dimensions))})
AND (
b.from_fiscal_year <= %s
AND b.to_fiscal_year >= %s
)
""",
(
filters.company,
filters.budget_against,
*dimensions,
filters.to_fiscal_year,
filters.from_fiscal_year,
),
as_dict=True,
)
return (
frappe.qb.from_(budget)
.select(
budget.name,
budget.account,
budget[budget_against_field].as_("dimension"),
budget.budget_amount,
budget.from_fiscal_year,
budget.to_fiscal_year,
budget.budget_start_date,
budget.budget_end_date,
)
.where(
(budget.company == filters.company)
& (budget.docstatus == 1)
& (budget.budget_against == filters.budget_against)
& (budget[budget_against_field].isin(dimensions))
& (budget.from_fiscal_year <= filters.to_fiscal_year)
& (budget.to_fiscal_year >= filters.from_fiscal_year)
)
).run(as_dict=True)
def build_budget_map(budget_records, filters):
@@ -120,50 +113,41 @@ def build_budget_map(budget_records, filters):
def get_actual_transactions(dimension_name, filters):
budget_against = frappe.scrub(filters.get("budget_against"))
cost_center_filter = ""
monthname = CustomFunction("MONTHNAME", ["date"])
gle = frappe.qb.DocType("GL Entry")
budget = frappe.qb.DocType("Budget")
query = (
frappe.qb.from_(gle)
.from_(budget)
.select(
gle.account,
gle.debit,
gle.credit,
gle.fiscal_year,
monthname(gle.posting_date).as_("month_name"),
budget[budget_against].as_("budget_against"),
)
.where(
(budget.docstatus == 1)
& (budget.account == gle.account)
& (gle.fiscal_year >= filters.from_fiscal_year)
& (gle.fiscal_year <= filters.to_fiscal_year)
& (gle.is_cancelled == 0)
& (budget[budget_against] == dimension_name)
)
.groupby(gle.name)
.orderby(gle.fiscal_year)
)
if filters.get("budget_against") == "Cost Center" and dimension_name:
cc_lft, cc_rgt = frappe.db.get_value("Cost Center", dimension_name, ["lft", "rgt"])
cost_center_filter = f"""
and lft >= "{cc_lft}"
and rgt <= "{cc_rgt}"
"""
cost_centers = get_cost_center_with_children([dimension_name])
query = query.where(gle.cost_center.isin(cost_centers))
else:
query = query.where(budget[budget_against] == gle[budget_against])
actual_transactions = frappe.db.sql(
f"""
select
gl.account,
gl.debit,
gl.credit,
gl.fiscal_year,
MONTHNAME(gl.posting_date) as month_name,
b.{budget_against} as budget_against
from
`tabGL Entry` gl,
`tabBudget` b
where
b.docstatus = 1
and b.account=gl.account
and b.{budget_against} = gl.{budget_against}
and gl.fiscal_year between %s and %s
and gl.is_cancelled = 0
and b.{budget_against} = %s
and exists(
select
name
from
`tab{filters.budget_against}`
where
name = gl.{budget_against}
{cost_center_filter}
)
group by
gl.name
order by gl.fiscal_year
""",
(filters.from_fiscal_year, filters.to_fiscal_year, dimension_name),
as_dict=1,
)
actual_transactions = query.run(as_dict=True)
actual_transactions_map = {}
for transaction in actual_transactions:
@@ -382,33 +366,37 @@ def get_fiscal_years(filters):
return fiscal_year
def get_budget_dimensions(filters):
order_by = ""
if filters.get("budget_against") == "Cost Center":
order_by = "order by lft"
if filters.get("budget_against") in ["Cost Center", "Project"]:
return frappe.db.sql_list(
"""
select
name
from
`tab{tab}`
where
company = %s
{order_by}
""".format(tab=filters.get("budget_against"), order_by=order_by),
filters.get("company"),
def get_cost_center_with_children(cost_centers):
"""Expand each cost center to include itself and all its descendants."""
cc = frappe.qb.DocType("Cost Center")
all_cost_centers = set()
for cost_center in cost_centers:
result = frappe.db.get_value("Cost Center", cost_center, ["lft", "rgt"])
if not result:
continue
lft, rgt = result
children = (
frappe.qb.from_(cc).select(cc.name).where((cc.lft >= lft) & (cc.rgt <= rgt)).run(pluck="name")
)
all_cost_centers.update(children)
return list(all_cost_centers)
def get_budget_dimensions(filters):
budget_against = filters.get("budget_against")
dimension = frappe.qb.DocType(budget_against)
if budget_against in ["Cost Center", "Project"]:
query = (
frappe.qb.from_(dimension)
.select(dimension.name)
.where(dimension.company == filters.get("company"))
)
if budget_against == "Cost Center":
query = query.orderby(dimension.lft)
return query.run(pluck="name")
else:
return frappe.db.sql_list(
"""
select
name
from
`tab{tab}`
""".format(tab=filters.get("budget_against"))
) # nosec
return frappe.qb.from_(dimension).select(dimension.name).run(pluck="name")
def validate_budget_dimensions(filters):

View File

@@ -36,8 +36,8 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
pe.submit()
mop = get_mode_of_payments(filters)
self.assertTrue("Credit Card" in next(iter(mop.values())))
self.assertTrue("Cash" in next(iter(mop.values())))
self.assertIn("Credit Card", next(iter(mop.values())))
self.assertIn("Cash", next(iter(mop.values())))
# Cancel all Cash payment entry and check if this mode of payment is still fetched.
payment_entries = frappe.get_all(
@@ -50,8 +50,8 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
pe.cancel()
mop = get_mode_of_payments(filters)
self.assertTrue("Credit Card" in next(iter(mop.values())))
self.assertTrue("Cash" not in next(iter(mop.values())))
self.assertIn("Credit Card", next(iter(mop.values())))
self.assertNotIn("Cash", next(iter(mop.values())))
def test_get_mode_of_payments_details(self):
filters = get_filters()
@@ -100,7 +100,7 @@ class TestSalesPaymentSummary(ERPNextTestSuite):
if mopd_value[0] == "Credit Card":
cc_final_amount = mopd_value[1]
self.assertTrue(cc_init_amount > cc_final_amount)
self.assertGreater(cc_init_amount, cc_final_amount)
def get_filters():

View File

@@ -37,15 +37,17 @@ class TestUtils(ERPNextTestSuite):
future_vouchers = get_future_stock_vouchers("2021-01-01", "00:00:00", for_items=["_Test Item"])
voucher_type_and_no = ("Purchase Receipt", pr.name)
self.assertTrue(
voucher_type_and_no in future_vouchers,
self.assertIn(
voucher_type_and_no,
future_vouchers,
msg="get_future_stock_vouchers not returning correct value",
)
posting_date = "2021-01-01"
gl_entries = get_voucherwise_gl_entries(future_vouchers, posting_date)
self.assertTrue(
voucher_type_and_no in gl_entries,
self.assertIn(
voucher_type_and_no,
gl_entries,
msg="get_voucherwise_gl_entries not returning expected GLes",
)

View File

@@ -46,7 +46,7 @@ frappe.ui.form.on("Asset", {
frm.make_methods = {
"Asset Movement": () => {
frappe.call({
method: "erpnext.assets.doctype.asset.asset.make_asset_movement",
method: "erpnext.assets.doctype.asset.mapper.make_asset_movement",
freeze: true,
args: {
assets: [{ name: frm.doc.name }],
@@ -967,7 +967,7 @@ erpnext.asset.restore_asset = function (frm) {
erpnext.asset.transfer_asset = function (frm) {
frappe.call({
method: "erpnext.assets.doctype.asset.asset.make_asset_movement",
method: "erpnext.assets.doctype.asset.mapper.make_asset_movement",
freeze: true,
args: {
assets: [{ name: frm.doc.name }],

View File

@@ -32,7 +32,7 @@ frappe.listview_settings["Asset"] = {
me.page.add_action_item(__("Make Asset Movement"), function () {
const assets = me.get_checked_items();
frappe.call({
method: "erpnext.assets.doctype.asset.asset.make_asset_movement",
method: "erpnext.assets.doctype.asset.mapper.make_asset_movement",
freeze: true,
args: {
assets: assets,

View File

@@ -887,9 +887,9 @@ class TestAsset(AssetSetup):
with self.assertRaises(frappe.ValidationError) as err:
asset.save()
self.assertTrue(
"Please set Depreciation related Accounts in Asset Category Computers or Company"
in str(err.exception)
self.assertIn(
"Please set Depreciation related Accounts in Asset Category Computers or Company",
str(err.exception),
)
finally:
frappe.db.set_value("Company", "_Test Company", company_depreciation_accounts)
@@ -1701,8 +1701,8 @@ class TestDepreciationBasics(AssetSetup):
accumulated_depreciation_after_full_schedule
)
self.assertTrue(
asset.finance_books[0].expected_value_after_useful_life >= asset_value_after_full_schedule
self.assertGreaterEqual(
asset.finance_books[0].expected_value_after_useful_life, asset_value_after_full_schedule
)
def test_gle_made_by_depreciation_entries(self):

View File

@@ -72,7 +72,7 @@ class TestAssetCategory(ERPNextTestSuite):
)
with self.assertRaises(frappe.ValidationError) as err:
asset_category.save()
self.assertTrue("Cannot set multiple account rows for the same company" in str(err.exception))
self.assertIn("Cannot set multiple account rows for the same company", str(err.exception))
def test_depreciation_accounts_required_for_existing_depreciable_assets(self):
asset = create_asset(
@@ -110,9 +110,9 @@ class TestAssetCategory(ERPNextTestSuite):
with self.assertRaises(frappe.ValidationError) as err:
asset_category.save()
self.assertTrue(
"Since there are active depreciable assets under this category, the following accounts are required."
in str(err.exception)
self.assertIn(
"Since there are active depreciable assets under this category, the following accounts are required.",
str(err.exception),
)
finally:
frappe.db.set_value("Company", asset.company, company_acccount_depreciation)

View File

@@ -17,6 +17,7 @@
"section_break_vwgg",
"maintain_same_rate",
"column_break_lwxs",
"set_landed_cost_based_on_purchase_invoice_rate",
"maintain_same_rate_action",
"role_to_override_stop_action",
"transaction_settings_section",
@@ -24,7 +25,8 @@
"po_required",
"pr_required",
"project_update_frequency",
"column_break_12",
"over_order_allowance",
"column_break_kdcm",
"allow_multiple_items",
"allow_negative_rates_for_items",
"set_valuation_rate_for_rejected_materials",
@@ -33,7 +35,6 @@
"purchase_invoice_settings_section",
"bill_for_rejected_quantity_in_purchase_invoice",
"use_transaction_date_exchange_rate",
"set_landed_cost_based_on_purchase_invoice_rate",
"zero_quantity_line_items_section",
"allow_zero_qty_in_supplier_quotation",
"allow_zero_qty_in_request_for_quotation",
@@ -156,10 +157,6 @@
"fieldtype": "Tab Break",
"label": "Transaction Settings"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
},
{
"default": "0",
"description": "Prevents the system from automatically using the rate from the last purchase transaction when creating new purchase orders or transactions.",
@@ -335,6 +332,16 @@
"hidden": 1,
"is_virtual": 1,
"label": "Naming Series options"
},
{
"description": "The percentage by which you are allowed to order more on a Purchase Order than the quantity requested on the originating Material Request. For example, if the Material Request has 100 units and the allowance is 10%, you can order up to 110 units",
"fieldname": "over_order_allowance",
"fieldtype": "Float",
"label": "Over Order Allowance (%)"
},
{
"fieldname": "column_break_kdcm",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
@@ -343,7 +350,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-05-05 16:30:37.184607",
"modified": "2026-05-27 23:04:00.842393",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",

View File

@@ -34,6 +34,7 @@ class BuyingSettings(Document):
fixed_email: DF.Link | None
maintain_same_rate: DF.Check
maintain_same_rate_action: DF.Literal["Stop", "Warn"]
over_order_allowance: DF.Float
over_transfer_allowance: DF.Float
po_required: DF.Literal["No", "Yes"]
pr_required: DF.Literal["No", "Yes"]

View File

@@ -24,8 +24,6 @@
"is_subcontracted",
"has_unit_price_items",
"supplier_warehouse",
"section_break_zymg",
"title",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -57,9 +55,7 @@
"net_total",
"section_break_48",
"pricing_rules",
"raw_material_details",
"set_reserve_warehouse",
"supplied_items",
"taxes_section",
"tax_category",
"taxes_and_charges",
@@ -157,6 +153,7 @@
"auto_repeat",
"update_auto_repeat_reference",
"additional_info_section",
"title",
"party_account_currency",
"represents_company",
"ref_sq",
@@ -1294,10 +1291,6 @@
"fieldname": "auto_repeat_section",
"fieldtype": "Section Break",
"label": "Auto Repeat"
},
{
"fieldname": "section_break_zymg",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
@@ -1305,7 +1298,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-05-04 10:10:22.608381",
"modified": "2026-05-28 12:34:19.659621",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

Some files were not shown because too many files have changed in this diff Show More