Merge branch 'develop' into inact-cust-report

This commit is contained in:
Shllokkk
2026-06-04 14:57:58 +05:30
committed by GitHub
373 changed files with 35177 additions and 26502 deletions

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

@@ -16,6 +16,10 @@ on:
- 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 }}

View File

@@ -2,9 +2,7 @@ import CSVRawDataPreview from './CSVRawDataPreview'
import StatementDetails from './StatementDetails'
import { GetStatementDetailsResponse } from '../import_utils'
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
const CSVImport = ({ data, mutate }: { data: { message: GetStatementDetailsResponse }, mutate: () => void }) => {
return (
<div className="w-full flex">
@@ -12,7 +10,7 @@ const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } })
<StatementDetails data={data.message} />
</div>
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
<CSVRawDataPreview data={data.message} />
<CSVRawDataPreview data={data.message} mutate={mutate} />
</div>
</div>
)

View File

@@ -1,151 +1,104 @@
import { Table, TableBody, TableCell, TableHead, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { ArrowDownRightIcon, ArrowUpDownIcon, ArrowUpRightIcon, BanknoteIcon, CalendarIcon, DollarSignIcon, FileTextIcon, ListIcon, ReceiptIcon } from "lucide-react"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import _ from "@/lib/translate"
import { GetStatementDetailsResponse } from "../import_utils"
import { useMemo } from "react"
import RawTableGrid from "../RawTableGrid"
import {
applyColumnMappingChange,
ColumnMapsTo,
GetStatementDetailsResponse,
useSetHeaderIndex,
useUpdateColumnMapping,
} from "../import_utils"
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
type Mapping = Pick<BankStatementImportLogColumnMap, "index" | "maps_to" | "header_text" | "variable">
const CSVRawDataPreview = ({ data }: { data: GetStatementDetailsResponse }) => {
const toMapping = (columns?: BankStatementImportLogColumnMap[]): Mapping[] =>
(columns ?? []).map((c) => ({
index: c.index,
maps_to: c.maps_to,
header_text: c.header_text,
variable: c.variable,
}))
const column_mapping: Record<StandardColumnTypes, number> = useMemo(() => {
const headerToState = (index?: number) => (index != null && index >= 0 ? index : null)
const col_map: Record<string, number> = {}
const CSVRawDataPreview = ({
data,
mutate,
}: {
data: GetStatementDetailsResponse
mutate: () => void
}) => {
const isCompleted = data.doc.status === "Completed"
data.doc.column_mapping?.forEach(col => {
if (col.maps_to && col.maps_to !== "Do not import") {
col_map[col.maps_to] = col.index;
}
})
const [mapping, setMapping] = useState<Mapping[]>(() => toMapping(data.doc.column_mapping))
const [headerIndex, setHeaderIndex] = useState<number | null>(() =>
headerToState(data.doc.detected_header_index),
)
return col_map
const { call: updateMapping, loading: savingMapping } = useUpdateColumnMapping()
const { call: setHeader, loading: savingHeader } = useSetHeaderIndex()
}, [data])
const mappingRef = useRef(mapping)
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const validColumns = Object.values(column_mapping)
useEffect(() => () => clearTimeout(saveTimer.current), [])
// Reverse the column mapping to get a map of column index to variable name
const columnIndexMap: Record<number, StandardColumnTypes> = Object.fromEntries(Object.entries(column_mapping).map(([variable, columnIndex]) => [columnIndex, variable as StandardColumnTypes]))
const columnMappingRecord: Record<number, ColumnMapsTo> = {}
mapping.forEach((c) => {
if (c.maps_to) columnMappingRecord[c.index] = c.maps_to as ColumnMapsTo
})
const commitMapping = (next: Mapping[]) => {
mappingRef.current = next
setMapping(next)
}
// Persist mapping edits (debounced) so the transaction preview updates in realtime.
const scheduleSaveMapping = () => {
if (isCompleted) return
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
updateMapping({ statement_import_id: data.doc.name, column_mapping: mappingRef.current })
.then(() => mutate())
.catch(() => toast.error(_("Could not save the column mapping.")))
}, 500)
}
const onChangeMapping = (columnIndex: number, mapsTo: ColumnMapsTo) => {
if (isCompleted) return
commitMapping(applyColumnMappingChange(mappingRef.current, columnIndex, mapsTo))
scheduleSaveMapping()
}
const onSetHeader = (rowIndex: number | null) => {
if (isCompleted) return
setHeaderIndex(rowIndex)
setHeader({ statement_import_id: data.doc.name, header_index: rowIndex ?? -1 })
.then((res) => {
// The backend re-derives the mapping for the new header; sync local state.
const doc = res?.message?.doc
if (doc) {
commitMapping(toMapping(doc.column_mapping))
setHeaderIndex(headerToState(doc.detected_header_index))
}
mutate()
})
.catch(() => toast.error(_("Could not update the header row.")))
}
// Loop over the contents of the CSV file and show a preview - highlight the header row and the transaction rows
return (
<Table containerClassName="rounded-none">
<TableBody>
{data.raw_data.map((row, index) => {
const isHeaderRow = index === data.doc.detected_header_index;
const isTransactionRow = index >= (data.doc.detected_transaction_starting_index ?? 0) && index <= (data.doc.detected_transaction_ending_index ?? 0);
return <TableRow key={index}
title={isHeaderRow ? "Header Row" : ""}
className={cn({
// "bg-yellow-100": isHeaderRow,
// "hover:bg-yellow-100": isHeaderRow,
"bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700": isTransactionRow,
"text-ink-gray-5/70": !isTransactionRow && !isHeaderRow,
})}>
{isHeaderRow ? <TableHead className="bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400 text-center font-semibold text-ink-gray-8">
{index + 1}
</TableHead> :
<TableCell className="text-center px-1 py-0.5">
{index + 1}
</TableCell>
}
{row.map((cell, cellIndex) => {
const isValidColumn = validColumns.includes(cellIndex);
const columnType = columnIndexMap[cellIndex];
const isAmountColumn = ["Amount", "Withdrawal", "Deposit", "Balance"].includes(columnType);
if (isHeaderRow) {
return <TableHead key={cellIndex} className={cn("max-w-[250px] w-fit overflow-hidden text-ellipsis py-0.5",
isValidColumn ? "bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400" : "bg-surface-gray-2",
)}>
<div className={cn("flex items-center text-xs gap-1 px-1 text-ink-gray-8 font-medium", {
"justify-end": isAmountColumn && isValidColumn
})}>
{columnType && <Tooltip>
<TooltipTrigger>
<ColumnHeaderIcon columnType={columnType} />
</TooltipTrigger>
<TooltipContent>
{_(columnType)}
</TooltipContent>
</Tooltip>
}
{cell}
</div>
</TableHead>
} else {
return <TableCell key={cellIndex} className={cn("max-w-[200px] w-fit overflow-hidden text-ellipsis py-0.5",
{
"bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400": isValidColumn && isTransactionRow,
"text-ink-gray-5": !isValidColumn && isTransactionRow,
}
)} >
<div className={cn("min-h-5 flex items-center text-xs px-1", {
"justify-end": isAmountColumn && isValidColumn && isTransactionRow
})} title={cell}>
{cell}
</div>
</TableCell>
}
}
)}
</TableRow>
})}
</TableBody>
</Table >
<RawTableGrid
rows={data.raw_data}
columnMapping={columnMappingRecord}
headerIndex={headerIndex}
editable={!isCompleted}
disabled={isCompleted || savingMapping || savingHeader}
onChangeMapping={onChangeMapping}
onSetHeader={onSetHeader}
/>
)
}
type StandardColumnTypes = BankStatementImportLogColumnMap['maps_to'];
const ColumnHeaderIcon = ({ columnType }: { columnType?: StandardColumnTypes }) => {
if (!columnType) {
return null
}
if (columnType === 'Amount') {
return <DollarSignIcon className="w-4 h-4" />
}
if (columnType === 'Withdrawal') {
return <ArrowUpRightIcon className="w-4 h-4 text-ink-red-3" />
}
if (columnType === 'Deposit') {
return <ArrowDownRightIcon className="w-4 h-4 text-ink-green-3" />
}
if (columnType === 'Balance') {
return <BanknoteIcon className="w-4 h-4" />
}
if (columnType === 'Date') {
return <CalendarIcon className="w-4 h-4" />
}
if (columnType === 'Description') {
return <FileTextIcon className="w-4 h-4" />
}
if (columnType === 'Reference') {
return <ReceiptIcon className="w-4 h-4" />
}
if (columnType === 'Transaction Type') {
return <ListIcon className="w-4 h-4" />
}
if (columnType === 'Debit/Credit') {
return <ArrowUpDownIcon className="w-4 h-4" />
}
return null
}
export default CSVRawDataPreview
export default CSVRawDataPreview

View File

@@ -142,11 +142,16 @@ const StatementDetails = ({ data }: Props) => {
<TableCell>
<div className='flex items-center gap-2'>
<BankLogo bank={bank} />
<span className="tracking-tight text-sm font-medium">{bank?.account_name}</span>
<span title="GL Account" className="text-sm">{bank?.account}</span>
<span className="text-sm">{bank?.account_name}</span>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Account")}</TableHead>
<TableCell>
<span title="GL Account" className="text-sm">{bank?.account}</span>
</TableCell>
</TableRow>
<TableRow>
<TableHead>{_("Statement File")}</TableHead>
<TableCell>
@@ -158,7 +163,11 @@ const StatementDetails = ({ data }: Props) => {
</TableRow>
<TableRow>
<TableHead>{_("Transaction Dates")}</TableHead>
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
{data.doc.start_date && data.doc.end_date ? (
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
) : (
<TableCell>-</TableCell>
)}
</TableRow>
<TableRow>
<TableHead>{_("Number of Transactions")}</TableHead>

View File

@@ -0,0 +1,129 @@
import { RefObject, useEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
type Bbox = [number, number, number, number]
const MIN_SIZE = 8 // PDF points
// Keep the box valid: normalise flipped edges, enforce a min size, clamp to the page.
const clampBbox = (bbox: Bbox, pageWidth: number, pageHeight: number): Bbox => {
let [x0, top, x1, bottom] = bbox
if (x1 < x0) [x0, x1] = [x1, x0]
if (bottom < top) [top, bottom] = [bottom, top]
x0 = Math.max(0, Math.min(x0, pageWidth - MIN_SIZE))
top = Math.max(0, Math.min(top, pageHeight - MIN_SIZE))
x1 = Math.min(pageWidth, Math.max(x1, x0 + MIN_SIZE))
bottom = Math.min(pageHeight, Math.max(bottom, top + MIN_SIZE))
return [x0, top, x1, bottom]
}
const HANDLES = [
{ id: 'nw', className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize' },
{ id: 'ne', className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize' },
{ id: 'sw', className: 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize' },
{ id: 'se', className: 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize' },
]
type Props = {
bbox: Bbox
pageWidth: number
pageHeight: number
color: { border: string; bg: string; swatch: string }
label: string
included: boolean
disabled?: boolean
containerRef: RefObject<HTMLDivElement | null>
onCommit: (bbox: Bbox) => void
}
/** A draggable + corner-resizable rectangle over a rendered PDF page. Coordinates are in PDF
* points (top-left origin); pixel deltas are converted using the container's rendered size. */
const BBoxOverlay = ({ bbox, pageWidth, pageHeight, color, label, included, disabled, containerRef, onCommit }: Props) => {
const [draft, setDraft] = useState<Bbox>(bbox)
const draftRef = useRef<Bbox>(bbox)
const drag = useRef<{ mode: string; startX: number; startY: number; start: Bbox } | null>(null)
// Reset to the authoritative bbox whenever it changes (e.g. after a server re-extract).
useEffect(() => {
setDraft(bbox)
draftRef.current = bbox
}, [bbox])
const apply = (next: Bbox) => {
draftRef.current = next
setDraft(next)
}
const onPointerDown = (e: React.PointerEvent) => {
if (disabled) return
e.preventDefault()
e.stopPropagation()
const mode = (e.target as HTMLElement).dataset.handle ?? 'move'
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
drag.current = { mode, startX: e.clientX, startY: e.clientY, start: draftRef.current }
}
const onPointerMove = (e: React.PointerEvent) => {
if (!drag.current || !containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const dx = ((e.clientX - drag.current.startX) / rect.width) * pageWidth
const dy = ((e.clientY - drag.current.startY) / rect.height) * pageHeight
let [x0, top, x1, bottom] = drag.current.start
const m = drag.current.mode
if (m === 'move') {
x0 += dx
x1 += dx
top += dy
bottom += dy
} else {
if (m.includes('w')) x0 += dx
if (m.includes('e')) x1 += dx
if (m.includes('n')) top += dy
if (m.includes('s')) bottom += dy
}
apply(clampBbox([x0, top, x1, bottom], pageWidth, pageHeight))
}
const onPointerUp = (e: React.PointerEvent) => {
if (!drag.current) return
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
drag.current = null
onCommit(draftRef.current)
}
const [x0, top, x1, bottom] = draft
return (
<div
className={cn(
'absolute touch-none border-2',
color.border,
included ? color.bg : 'opacity-40',
disabled ? 'pointer-events-none' : 'cursor-move',
)}
style={{
left: `${(x0 / pageWidth) * 100}%`,
top: `${(top / pageHeight) * 100}%`,
width: `${((x1 - x0) / pageWidth) * 100}%`,
height: `${((bottom - top) / pageHeight) * 100}%`,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<span className={cn('pointer-events-none absolute -top-5 left-0 rounded px-1 text-[10px] font-medium text-white', color.swatch)}>
{label}
</span>
{!disabled &&
HANDLES.map((handle) => (
<span
key={handle.id}
data-handle={handle.id}
className={cn('absolute size-2.5 rounded-sm border border-white', color.swatch, handle.className)}
/>
))}
</div>
)
}
export default BBoxOverlay

View File

@@ -0,0 +1,23 @@
import StatementDetails from '../CSV/StatementDetails'
import PDFTableEditor from './PDFTableEditor'
import { GetStatementDetailsResponse } from '../import_utils'
type Props = {
data: { message: GetStatementDetailsResponse }
mutate: () => void
}
const PDFImport = ({ data, mutate }: Props) => {
return (
<div className="w-full flex">
<div className="w-[45%] p-4 h-[calc(100vh-72px)] overflow-scroll">
<StatementDetails data={data.message} />
</div>
<div className="w-[55%] border-s pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
<PDFTableEditor data={data.message} mutate={mutate} />
</div>
</div>
)
}
export default PDFImport

View File

@@ -0,0 +1,362 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, FileTextIcon, Loader2Icon, TableIcon } from 'lucide-react'
import _ from '@/lib/translate'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { H3, Paragraph } from '@/components/ui/typography'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import ErrorBanner from '@/components/ui/error-banner'
import RawTableGrid from '../RawTableGrid'
import BBoxOverlay from './BBoxOverlay'
import {
applyColumnMappingChange,
ColumnMapsTo,
GetStatementDetailsResponse,
PDFTable,
useReextractPDFTable,
useSetPDFTableHeader,
useUpdatePDFTables,
} from '../import_utils'
type Props = {
data: GetStatementDetailsResponse
mutate: () => void
}
// Distinct overlay colours per table on a page.
const OVERLAY_COLORS = [
{ border: 'border-blue-500', bg: 'bg-blue-500/10', swatch: 'bg-blue-500' },
{ border: 'border-purple-500', bg: 'bg-purple-500/10', swatch: 'bg-purple-500' },
{ border: 'border-amber-500', bg: 'bg-amber-500/10', swatch: 'bg-amber-500' },
{ border: 'border-teal-500', bg: 'bg-teal-500/10', swatch: 'bg-teal-500' },
]
const columnMappingRecord = (table: PDFTable): Record<number, ColumnMapsTo> => {
const map: Record<number, ColumnMapsTo> = {}
table.column_mapping?.forEach((col) => {
map[col.index] = col.maps_to
})
return map
}
const PDFTableEditor = ({ data, mutate }: Props) => {
const isCompleted = data.doc.status === 'Completed'
const [tables, setTables] = useState<PDFTable[]>(() => data.pdf_tables ?? [])
const [viewMode, setViewMode] = useState<'pdf' | 'table'>('pdf')
const [pageIndex, setPageIndex] = useState(0)
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
const toggleCollapsed = (tableIndex: number) =>
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(tableIndex)) {
next.delete(tableIndex)
} else {
next.add(tableIndex)
}
return next
})
const { call, loading, error } = useUpdatePDFTables()
const { call: reextract, loading: reextracting } = useReextractPDFTable()
const { call: setHeaderCall, loading: settingHeader } = useSetPDFTableHeader()
const busy = loading || reextracting || settingHeader
// Persist edits automatically (debounced) so the transaction preview updates in realtime.
const tablesRef = useRef(tables)
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const reextractTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
const scheduleSave = () => {
if (isCompleted) return
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => {
call({ statement_import_id: data.doc.name, tables: tablesRef.current })
.then(() => mutate())
.catch(() => toast.error(_('Could not save the table settings.')))
}, 500)
}
// After a bbox change, re-extract that table's rows from the new region (debounced).
// The target is read inside the timeout so it always reflects the committed bbox.
const scheduleReextract = (tableIndex: number) => {
if (isCompleted) return
clearTimeout(reextractTimer.current)
reextractTimer.current = setTimeout(() => {
const target = tablesRef.current[tableIndex]
reextract({
statement_import_id: data.doc.name,
page: target.page,
table_index: target.table_index,
bbox: target.bbox,
})
.then((res) => {
commitTables(res?.message?.pdf_tables ?? [])
mutate()
})
.catch(() => toast.error(_('Could not re-extract the table.')))
}, 500)
}
useEffect(() => () => {
clearTimeout(saveTimer.current)
clearTimeout(reextractTimer.current)
}, [])
const pages = useMemo(() => Array.from(new Set(tables.map((t) => t.page))).sort((a, b) => a - b), [tables])
const currentPage = pages[pageIndex]
// Keep the table's position in the flat array so edits target the right one.
const pageTables = useMemo(
() => tables.map((table, index) => ({ table, index })).filter((t) => t.table.page === currentPage),
[tables, currentPage],
)
// Keep tablesRef in sync synchronously so the debounced save/re-extract never read stale state.
const commitTables = (next: PDFTable[]) => {
tablesRef.current = next
setTables(next)
}
const updateTable = (tableIndex: number, updater: (table: PDFTable) => PDFTable) => {
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? updater(t) : t)))
scheduleSave()
}
const onChangeMapping = (tableIndex: number, columnIndex: number, mapsTo: ColumnMapsTo) => {
updateTable(tableIndex, (table) => ({
...table,
column_mapping: applyColumnMappingChange(table.column_mapping, columnIndex, mapsTo),
}))
}
const onToggleIncluded = (tableIndex: number, included: boolean) =>
updateTable(tableIndex, (table) => ({ ...table, included }))
const onBboxCommit = (tableIndex: number, bbox: [number, number, number, number]) => {
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, bbox } : t)))
scheduleReextract(tableIndex)
}
// Set/clear the header row of a table; the backend re-derives the column mapping.
const onSetHeader = (tableIndex: number, headerIndex: number | null) => {
commitTables(tablesRef.current.map((t, i) => (i === tableIndex ? { ...t, header_index: headerIndex } : t)))
const target = tablesRef.current[tableIndex]
setHeaderCall({
statement_import_id: data.doc.name,
page: target.page,
table_index: target.table_index,
header_index: headerIndex ?? -1,
})
.then((res) => {
commitTables(res?.message?.pdf_tables ?? [])
mutate()
})
.catch(() => toast.error(_('Could not update the header row.')))
}
if (tables.length === 0) {
return (
<div className="p-4">
<Paragraph className="text-p-sm text-ink-gray-5">
{_('No tables were extracted from this PDF.')}
</Paragraph>
</div>
)
}
return (
<div className="flex flex-col gap-3 p-4">
<div className="flex flex-col gap-1">
<H3 className="text-base border-0 p-0">{_('Detected Tables')}</H3>
<Paragraph className="text-p-sm">
{_('Review each page. In the Table view, map each column, click a row number to set/clear the header row, and exclude anything that is not transactions (ads, summaries).')}
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}
<div className="flex items-center justify-between gap-2">
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'pdf' | 'table')}>
<TabsList variant="subtle">
<TabsTrigger value="pdf"><FileTextIcon />{_('PDF')}</TabsTrigger>
<TabsTrigger value="table"><TableIcon />{_('Table')}</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-1">
{busy && (
<span className="flex items-center gap-1 pe-1 text-xs text-ink-gray-5">
<Loader2Icon className="size-3 animate-spin" />
{reextracting ? _('Re-extracting') : _('Saving')}
</span>
)}
<Button
variant="ghost"
isIconButton
disabled={pageIndex === 0}
onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="min-w-24 text-center text-sm text-ink-gray-7">
{_('Page {0} of {1}', [currentPage.toString(), pages.length.toString()])}
</span>
<Button
variant="ghost"
isIconButton
disabled={pageIndex >= pages.length - 1}
onClick={() => setPageIndex((i) => Math.min(pages.length - 1, i + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{viewMode === 'pdf' ? (
<PageView
pageTables={pageTables}
disabled={isCompleted}
onToggleIncluded={onToggleIncluded}
onBboxCommit={onBboxCommit}
/>
) : (
<div className="flex flex-col gap-4">
{pageTables.map(({ table, index }, position) => {
const isCollapsed = collapsed.has(index)
return (
<div
key={index}
className={cn('flex flex-col rounded border border-outline-gray-2', !table.included && 'opacity-60')}
>
<div className="flex items-center justify-between p-2">
<span className="ps-1 text-sm font-medium text-ink-gray-8">
{_('Table {0}', [(position + 1).toString()])}
</span>
<div className="flex items-center gap-2">
<IncludeToggle
id={`tbl-${index}`}
checked={table.included}
disabled={isCompleted}
onCheckedChange={(c) => onToggleIncluded(index, c)}
/>
<Button variant="ghost" size="sm" isIconButton onClick={() => toggleCollapsed(index)}>
<ChevronDownIcon className={cn('transition-transform', isCollapsed && '-rotate-90')} />
</Button>
</div>
</div>
{!isCollapsed && (
<div className="overflow-auto border-t border-outline-gray-2">
<RawTableGrid
rows={table.rows}
columnMapping={columnMappingRecord(table)}
headerIndex={table.header_index}
editable
disabled={isCompleted}
onChangeMapping={(columnIndex, mapsTo) => onChangeMapping(index, columnIndex, mapsTo)}
onSetHeader={(rowIndex) => onSetHeader(index, rowIndex)}
/>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
type PageViewProps = {
pageTables: { table: PDFTable; index: number }[]
disabled: boolean
onToggleIncluded: (tableIndex: number, included: boolean) => void
onBboxCommit: (tableIndex: number, bbox: [number, number, number, number]) => void
}
const PageView = ({ pageTables, disabled, onToggleIncluded, onBboxCommit }: PageViewProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const pageImage = pageTables[0]?.table.page_image
const pageWidth = pageTables[0]?.table.page_width ?? 1
const pageHeight = pageTables[0]?.table.page_height ?? 1
if (!pageImage) {
return (
<Paragraph className="text-p-sm text-ink-gray-5">
{_('No page image is available for this page.')}
</Paragraph>
)
}
return (
<div className="flex flex-col gap-3">
{!disabled && (
<Paragraph className="text-xs text-ink-gray-5">
{_('Drag a box to move it, or drag a corner to resize. The table is re-read from the new region automatically.')}
</Paragraph>
)}
<div ref={containerRef} className="relative w-full overflow-auto rounded border border-outline-gray-2 bg-surface-gray-1">
<img src={pageImage} alt={_('Page preview')} className="w-full" />
{pageTables.map(({ table, index }, position) => {
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
return (
<BBoxOverlay
key={index}
bbox={table.bbox}
pageWidth={pageWidth}
pageHeight={pageHeight}
color={color}
label={_('Table {0}', [(position + 1).toString()])}
included={table.included}
disabled={disabled}
containerRef={containerRef}
onCommit={(bbox) => onBboxCommit(index, bbox)}
/>
)
})}
</div>
<div className="flex flex-col gap-1.5">
{pageTables.map(({ table, index }, position) => {
const color = OVERLAY_COLORS[position % OVERLAY_COLORS.length]
return (
<div key={index} className="flex items-center justify-between rounded border border-outline-gray-2 px-2 py-1.5">
<div className="flex items-center gap-2">
<span className={cn('size-3 rounded-sm', color.swatch)} />
<span className="text-xs">{_('Table {0}', [(position + 1).toString()])}</span>
</div>
<IncludeToggle
id={`pdf-tbl-${index}`}
checked={table.included}
disabled={disabled}
onCheckedChange={(c) => onToggleIncluded(index, c)}
/>
</div>
)
})}
</div>
</div>
)
}
const IncludeToggle = ({
id,
checked,
disabled,
onCheckedChange,
}: {
id: string
checked: boolean
disabled: boolean
onCheckedChange: (checked: boolean) => void
}) => (
<div className="flex items-center gap-2">
<Label htmlFor={id} className="text-xs text-ink-gray-6">{_('Include')}</Label>
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
</div>
)
export default PDFTableEditor

View File

@@ -0,0 +1,222 @@
import { useMemo } from 'react'
import {
ArrowDownRightIcon,
ArrowUpDownIcon,
ArrowUpRightIcon,
BanknoteIcon,
CalendarIcon,
DollarSignIcon,
FileTextIcon,
ListIcon,
ReceiptIcon,
} from 'lucide-react'
import _ from '@/lib/translate'
import { cn } from '@/lib/utils'
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { COLUMN_MAPS_TO_OPTIONS, ColumnMapsTo } from './import_utils'
const AMOUNT_COLUMNS: ColumnMapsTo[] = ['Amount', 'Withdrawal', 'Deposit', 'Balance']
const DATE_LIKE = /\d{1,4}[/\-.\s]\d{1,2}[/\-.\s]\d{1,4}|\d{1,2}[\s-][a-z]{3}/i
type Props = {
rows: string[][]
/** Column index -> mapped field */
columnMapping: Record<number, ColumnMapsTo>
headerIndex: number | null
editable?: boolean
disabled?: boolean
onChangeMapping?: (columnIndex: number, mapsTo: ColumnMapsTo) => void
/** Set the header row (or null to mark the table as having no header). */
onSetHeader?: (rowIndex: number | null) => void
}
/**
* A preview of extracted rows with CSV-style colour coding: the header row is highlighted,
* detected transaction rows are green, and mapped columns are emphasised. When `editable`, a
* compact row of column -> field dropdowns sits at the top, and row numbers can be clicked to
* set/clear the header row.
*/
const RawTableGrid = ({ rows, columnMapping, headerIndex, editable, disabled, onChangeMapping, onSetHeader }: Props) => {
// Tabular (XLSX) cells can be numbers/dates, not strings - coerce so .trim()/render are safe.
const stringRows = useMemo(
() => rows.map((row) => row.map((cell) => (cell == null ? '' : String(cell)))),
[rows],
)
const numColumns = useMemo(() => stringRows.reduce((max, row) => Math.max(max, row.length), 0), [stringRows])
const validColumns = useMemo(
() => Object.entries(columnMapping).filter(([, m]) => m && m !== 'Do not import').map(([i]) => Number(i)),
[columnMapping],
)
const dateColumn = useMemo(() => Object.entries(columnMapping).find(([, m]) => m === 'Date')?.[0], [columnMapping])
const amountColumns = useMemo(
() => Object.entries(columnMapping).filter(([, m]) => ['Amount', 'Withdrawal', 'Deposit'].includes(m)).map(([i]) => Number(i)),
[columnMapping],
)
// Approximate the backend's transaction-row detection so the highlighting tracks edits live.
const transactionRows = useMemo(() => {
const set = new Set<number>()
if (dateColumn === undefined) return set
const dateIdx = Number(dateColumn)
stringRows.forEach((row, index) => {
if (index === headerIndex) return
const dateCell = (row[dateIdx] ?? '').trim()
if (!dateCell || !DATE_LIKE.test(dateCell)) return
if (amountColumns.some((c) => (row[c] ?? '').trim() !== '')) set.add(index)
})
return set
}, [stringRows, headerIndex, dateColumn, amountColumns])
return (
<Table containerClassName="rounded-none">
<TableBody>
{editable && (
<TableRow className="border-b border-outline-gray-2 bg-surface-white hover:bg-surface-white">
<TableHead className="w-8 p-1" />
{Array.from({ length: numColumns }).map((_unused, columnIndex) => (
<TableHead key={columnIndex} className="p-1 align-top">
<Select
disabled={disabled}
value={columnMapping[columnIndex] ?? 'Do not import'}
onValueChange={(value) => onChangeMapping?.(columnIndex, value as ColumnMapsTo)}
>
<SelectTrigger variant="outline" inputSize="sm" className="h-7 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLUMN_MAPS_TO_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
<span className="flex items-center gap-1.5">
<ColumnHeaderIcon columnType={option} />
{_(option)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</TableHead>
))}
</TableRow>
)}
{stringRows.map((row, index) => {
const isHeaderRow = index === headerIndex
const isTransactionRow = transactionRows.has(index)
return (
<TableRow
key={index}
className={cn({
'bg-green-50 hover:bg-green-50 dark:bg-green-700 dark:hover:bg-green-700': isTransactionRow,
'bg-yellow-100 hover:bg-yellow-100 dark:bg-yellow-400': isHeaderRow,
'text-ink-gray-5/70': !isTransactionRow && !isHeaderRow,
})}
>
{editable && onSetHeader ? (
<TableCell className="h-px w-8 p-0 text-center">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
disabled={disabled}
onClick={() => onSetHeader(isHeaderRow ? null : index)}
className={cn(
'flex h-full w-full items-center justify-center px-1 text-ink-gray-6 hover:bg-surface-gray-3',
isHeaderRow && 'font-semibold text-ink-gray-8',
)}
>
{index + 1}
</button>
</TooltipTrigger>
<TooltipContent>
{isHeaderRow
? _('This is the header row. Click to mark the table as having no header.')
: _('Click to set this as the header row.')}
</TooltipContent>
</Tooltip>
</TableCell>
) : (
<TableCell className="w-8 px-1 py-0.5 text-center text-ink-gray-6">{index + 1}</TableCell>
)}
{Array.from({ length: numColumns }).map((_unused, cellIndex) => {
const columnType = columnMapping[cellIndex]
const isValidColumn = validColumns.includes(cellIndex)
const isAmountColumn = AMOUNT_COLUMNS.includes(columnType)
const cellText = row[cellIndex] ?? ''
// Read-only header row: icon + label.
if (isHeaderRow) {
return (
<TableCell key={cellIndex} className="max-w-[200px] overflow-hidden text-ellipsis py-1">
<div className="flex items-center gap-1 px-1 text-xs font-medium text-ink-gray-8">
{columnType && (
<Tooltip>
<TooltipTrigger>
<ColumnHeaderIcon columnType={columnType} />
</TooltipTrigger>
<TooltipContent>{_(columnType)}</TooltipContent>
</Tooltip>
)}
{cellText}
</div>
</TableCell>
)
}
return (
<TableCell
key={cellIndex}
className={cn('max-w-[200px] overflow-hidden text-ellipsis py-0.5', {
'bg-green-100 dark:bg-green-400 hover:bg-green-100 dark:hover:bg-green-400': isValidColumn && isTransactionRow,
'text-ink-gray-5': !isValidColumn && isTransactionRow,
})}
>
<div
className={cn('min-h-5 flex items-center px-1 text-xs', {
'justify-end': isAmountColumn && isValidColumn && isTransactionRow,
})}
title={cellText}
>
{cellText}
</div>
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</Table>
)
}
const ColumnHeaderIcon = ({ columnType }: { columnType?: ColumnMapsTo }) => {
switch (columnType) {
case 'Amount':
return <DollarSignIcon className="size-4" />
case 'Withdrawal':
return <ArrowUpRightIcon className="size-4 text-ink-red-3" />
case 'Deposit':
return <ArrowDownRightIcon className="size-4 text-ink-green-3" />
case 'Balance':
return <BanknoteIcon className="size-4" />
case 'Date':
return <CalendarIcon className="size-4" />
case 'Description':
return <FileTextIcon className="size-4" />
case 'Reference':
return <ReceiptIcon className="size-4" />
case 'Transaction Type':
return <ListIcon className="size-4" />
case 'Debit/Credit':
return <ArrowUpDownIcon className="size-4" />
default:
return null
}
}
export default RawTableGrid

View File

@@ -1,6 +1,97 @@
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
import { useFrappeGetCall } from "frappe-react-sdk"
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
export type ColumnMapsTo =
| "Do not import"
| "Date"
| "Withdrawal"
| "Deposit"
| "Amount"
| "Description"
| "Reference"
| "Transaction Type"
| "Debit/Credit"
| "Balance"
| "Included Fee"
| "Excluded Fee"
| "Party Name/Account Holder"
| "Party Account No."
| "Party IBAN"
export type ColumnMappingEntry = {
index: number
maps_to: ColumnMapsTo | string
header_text?: string
variable?: string
}
/** Apply a column mapping change, clearing the same mapping from any other column. */
export function applyColumnMappingChange<T extends ColumnMappingEntry>(
columns: T[],
columnIndex: number,
mapsTo: ColumnMapsTo,
): T[] {
const previous = columns.find((c) => c.index === columnIndex)
const cleared =
mapsTo === "Do not import"
? columns
: columns.map((c) =>
c.index !== columnIndex && c.maps_to === mapsTo
? { ...c, maps_to: "Do not import" as ColumnMapsTo }
: c,
)
return [
...cleared.filter((c) => c.index !== columnIndex),
{
index: columnIndex,
maps_to: mapsTo,
header_text: previous?.header_text ?? "",
variable: previous?.variable ?? `column_${columnIndex}`,
} as T,
].sort((a, b) => a.index - b.index)
}
export const COLUMN_MAPS_TO_OPTIONS: ColumnMapsTo[] = [
"Do not import",
"Date",
"Description",
"Reference",
"Withdrawal",
"Deposit",
"Amount",
"Balance",
"Debit/Credit",
"Transaction Type",
"Included Fee",
"Excluded Fee",
"Party Name/Account Holder",
"Party Account No.",
"Party IBAN",
]
export interface PDFTableColumn {
index: number
header_text: string
variable?: string
maps_to: ColumnMapsTo
}
export interface PDFTable {
page: number
table_index: number
bbox: [number, number, number, number]
page_width: number
page_height: number
page_image: string | null
render_scale: number | null
rows: string[][]
header_index: number | null
column_mapping: PDFTableColumn[]
date_format?: string
amount_format?: string
included: boolean
}
export interface GetStatementDetailsResponse {
doc: BankStatementImportLog,
@@ -30,6 +121,7 @@ export interface GetStatementDetailsResponse {
date_format: string,
raw_data: Array<Array<string>>,
currency: string,
pdf_tables?: PDFTable[],
}
export const useGetStatementDetails = (id: string) => {
@@ -39,4 +131,24 @@ export const useGetStatementDetails = (id: string) => {
revalidateOnFocus: false
})
}
export const useUpdatePDFTables = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_pdf_tables")
}
export const useReextractPDFTable = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.reextract_pdf_table")
}
export const useSetPDFTableHeader = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_pdf_table_header")
}
export const useUpdateColumnMapping = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.update_column_mapping")
}
export const useSetHeaderIndex = () => {
return useFrappePostCall<{ message: GetStatementDetailsResponse }>("erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log.set_header_index")
}

View File

@@ -231,7 +231,7 @@ export const FileTypeIcon = ({
const getTextColor = () => {
switch (fileType.toLowerCase()) {
case 'pdf':
return 'text-red-700'
return 'text-ink-red-3'
case 'doc':
case 'docx':
return 'text-[#1A5CBD]'

View File

@@ -7,6 +7,7 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, Di
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import ErrorBanner from "@/components/ui/error-banner"
import { FileDropzone } from "@/components/ui/file-dropzone"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { H3, Paragraph } from "@/components/ui/typography"
@@ -16,7 +17,7 @@ import { flt, formatCurrency } from "@/lib/numbers"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankStatementImportLog } from "@/types/Accounts/BankStatementImportLog"
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList } from "frappe-react-sdk"
import { useFrappeCreateDoc, useFrappeFileUpload, useFrappeGetDocList, useFrappeUpdateDoc } from "frappe-react-sdk"
import { useAtom, useAtomValue } from "jotai"
import { ListIcon, Loader2Icon } from "lucide-react"
import { useState } from "react"
@@ -30,11 +31,15 @@ const BankStatementImporter = () => {
const [selectedBankAccount] = useAtom(selectedBankAccountAtom)
const [files, setFiles] = useState<File[]>([])
const [password, setPassword] = useState("")
const { upload, error, loading } = useFrappeFileUpload()
const navigate = useNavigate()
const { createDoc, loading: createLoading, error: createError } = useFrappeCreateDoc<BankStatementImportLog>()
const { updateDoc, error: updateError } = useFrappeUpdateDoc()
const isPdf = files[0]?.name?.toLowerCase().endsWith(".pdf") ?? false
const onUpload = () => {
@@ -44,12 +49,18 @@ const BankStatementImporter = () => {
const id = `new-bank-statement-import-log-${Date.now()}`
upload(files[0], {
// For protected PDFs, persist the password on the Bank Account so it is reused for
// every statement of this account (and is available before the import doc is created).
const ensurePassword = isPdf && password
? updateDoc("Bank Account", selectedBankAccount.name, { statement_password: password })
: Promise.resolve()
ensurePassword.then(() => upload(files[0], {
isPrivate: true,
doctype: "Bank Statement Import Log",
docname: id,
fieldname: 'file'
}).then((file) => {
})).then((file) => {
return createDoc("Bank Statement Import Log",
// @ts-expect-error - not filling everything else
{
@@ -67,6 +78,7 @@ const BankStatementImporter = () => {
<div className="w-[52%]">
{error && <ErrorBanner error={error} />}
{createError && <ErrorBanner error={createError} />}
{updateError && <ErrorBanner error={updateError} />}
<div className="py-2 flex flex-col gap-6">
<div className="flex flex-col gap-2">
<Label>{_("Company")}<span className="text-ink-red-3">*</span></Label>
@@ -89,7 +101,7 @@ const BankStatementImporter = () => {
data-slot="form-description"
className={cn("text-ink-gray-5 text-xs")}
>
{_("Upload your bank statement file to start the import process. We support CSV, and XLSX files.")}
{_("Upload your bank statement file to start the import process. We support CSV, XLSX and PDF files.")}
</p>
</div>
<div>
@@ -105,10 +117,27 @@ const BankStatementImporter = () => {
'text/csv': ['.csv'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
'application/vnd.ms-excel': ['.xls'],
'application/pdf': ['.pdf'],
// 'application/xml': ['.xml'],
}}
multiple={false}
/>
{isPdf && <div className="flex flex-col gap-2">
<Label htmlFor="pdf-password">{_("PDF Password")}</Label>
<Input
id="pdf-password"
type="password"
autoComplete="off"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={_("Only if the PDF is password protected")}
className="max-w-sm"
/>
<p data-slot="form-description" className={cn("text-ink-gray-5 text-p-sm")}>
{_("Leave blank to use the password already saved for this bank account (if any). It is stored encrypted and reused for future statements.")}
</p>
</div>}
</div>}
<div className="flex justify-end px-4">
<Button
@@ -137,9 +166,10 @@ const StatementInstructions = () => {
<DialogContent className="min-w-7xl">
<DialogHeader>
<DialogTitle>{_("Statement Import Instructions")}</DialogTitle>
<DialogDescription>{_("We support uploading CSV, XLSX and XLS files. Please make sure the file contains the correct columns.")}</DialogDescription>
<DialogDescription>{_("We support uploading CSV, XLSX, XLS and PDF files. Please make sure the file contains the correct columns.")}</DialogDescription>
</DialogHeader>
<Paragraph className="text-sm">{_("The file should contain the following columns with a distinct header row. You can upload most bank statements as is without changing the columns.")}</Paragraph>
<Paragraph className="text-sm text-ink-gray-6">{_("For PDF statements, we auto-detect the tables on each page. You can then confirm each detected table, map its columns, and exclude anything that is not transactions (e.g. ads or summaries). Password-protected PDFs are supported - the password is saved on the bank account and reused.")}</Paragraph>
<Table>
<TableHeader>
<TableRow>
@@ -231,7 +261,13 @@ const StatementImportLog = () => {
<TableRow key={item.name} onClick={() => onViewDetails(item.name)} className="cursor-pointer hover:bg-surface-gray-2">
<TableCell>{formatDate(item.creation, 'Do MMM YYYY')}</TableCell>
<TableCell><Badge theme={item.status === "Completed" ? "green" : "gray"}>{item.status}</Badge></TableCell>
<TableCell>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</TableCell>
<TableCell>
{item.start_date && item.end_date ? (
<span>{formatDate(item.start_date, 'Do MMM YYYY')} to {formatDate(item.end_date, 'Do MMM YYYY')}</span>
) : (
<span>-</span>
)}
</TableCell>
<TableCell className="text-end">{item.number_of_transactions}</TableCell>
<TableCell className="text-end font-numeric">{formatCurrency(flt(item.closing_balance, 2))}</TableCell>
<TableCell><a

View File

@@ -9,12 +9,13 @@ import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import { Link, useParams } from 'react-router'
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
const PDFImport = lazy(() => import('@/components/features/BankStatementImporter/PDF/PDFImport'))
const ViewBankStatementImportLog = () => {
const { id } = useParams<{ id: string }>()
const { data, isLoading, error } = useGetStatementDetails(id ?? "")
const { data, isLoading, error, mutate } = useGetStatementDetails(id ?? "")
useFrappeDocumentEventListener("Bank Statement Import Log", id ?? "", () => {
})
@@ -42,7 +43,13 @@ const ViewBankStatementImportLog = () => {
<ErrorBanner error={error} />
</div>
}
return <CSVImport data={data} />
const isPdf = data.message.doc.file?.toLowerCase().endsWith('.pdf')
if (isPdf) {
return <PDFImport data={data} mutate={mutate} />
}
return <CSVImport data={data} mutate={mutate} />
}
export default ViewBankStatementImportLog

View File

@@ -38,6 +38,8 @@ export interface BankAccount{
branch_code?: string
/** Bank Account No : Data */
bank_account_no?: string
/** Statement PDF Password : Password - Password used to open password-protected PDF statements for this account. Stored encrypted. */
statement_password?: string
/** Is Credit Card : Check */
is_credit_card?: 0 | 1
/** Integration ID : Data */

View File

@@ -47,4 +47,6 @@ export interface BankStatementImportLog {
detected_transaction_ending_index?: number
/** Column Mapping : Table - Bank Statement Import Log Column Map */
column_mapping?: BankStatementImportLogColumnMap[]
/** PDF Tables : JSON - Per-table extraction data for PDF statements */
pdf_tables?: string
}

View File

@@ -0,0 +1,449 @@
{
"country_code": "nz",
"name": "New Zealand - Chart of Accounts with Account Numbers",
"disabled": "No",
"tree": {
"Application of Funds (Assets)": {
"Current Assets": {
"Bank Accounts": {
"Business Transaction Account": {
"account_number": "11011",
"account_type": "Bank"
},
"Business Savings Account": {
"account_number": "11012",
"account_type": "Bank"
},
"account_number": "11010",
"is_group": 1
},
"Cash on Hand": {
"account_number": "11020",
"account_type": "Cash"
},
"Accounts Receivable": {
"Debtors": {
"account_number": "11210",
"account_type": "Receivable"
},
"Provision for Doubtful Debts": {
"account_number": "11220"
},
"account_number": "11200",
"is_group": 1
},
"Inventory": {
"Stock on Hand": {
"account_number": "11311",
"account_type": "Stock"
},
"Work In Progress": {
"account_number": "11312",
"account_type": "Stock"
},
"account_number": "11310",
"account_type": "Stock",
"is_group": 1
},
"Prepayments": {
"Prepayments": {
"account_number": "11411"
},
"Supplier Advances": {
"account_number": "11412"
},
"Deferred Expense": {
"account_number": "11413"
},
"account_number": "11410",
"is_group": 1
},
"GST Receivable": {
"account_number": "11510",
"account_type": "Tax"
},
"Income Tax Receivable": {
"account_number": "11520",
"account_type": "Tax"
},
"account_number": "11000",
"is_group": 1
},
"Fixed Assets": {
"Plant & Equipment": {
"Plant & Equipment": {
"account_number": "16011",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Plant & Equipment": {
"account_number": "16012",
"account_type": "Accumulated Depreciation"
},
"account_number": "16010",
"is_group": 1
},
"Motor Vehicles": {
"Motor Vehicles": {
"account_number": "16021",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Motor Vehicles": {
"account_number": "16022",
"account_type": "Accumulated Depreciation"
},
"account_number": "16020",
"is_group": 1
},
"Office Equipment": {
"Office Equipment": {
"account_number": "16031",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Office Equipment": {
"account_number": "16032",
"account_type": "Accumulated Depreciation"
},
"account_number": "16030",
"is_group": 1
},
"Buildings": {
"Buildings": {
"account_number": "16041",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Buildings": {
"account_number": "16042",
"account_type": "Accumulated Depreciation"
},
"account_number": "16040",
"is_group": 1
},
"Computer Equipment": {
"Computer Equipment": {
"account_number": "16051",
"account_type": "Fixed Asset"
},
"Accumulated Depreciation - Computer Equipment": {
"account_number": "16052",
"account_type": "Accumulated Depreciation"
},
"account_number": "16050",
"is_group": 1
},
"Capital Work in Progress": {
"account_number": "16090",
"account_type": "Capital Work in Progress"
},
"account_number": "16000",
"is_group": 1
},
"account_number": "10000",
"root_type": "Asset"
},
"Source of Funds (Liabilities)": {
"Current Liabilities": {
"Accounts Payable": {
"Creditors": {
"account_number": "21010",
"account_type": "Payable"
},
"account_number": "21000",
"is_group": 1
},
"Goods Received Not Invoiced": {
"account_number": "21100",
"account_type": "Stock Received But Not Billed"
},
"Asset Received Not Invoiced": {
"account_number": "21110",
"account_type": "Asset Received But Not Billed"
},
"Service Received Not Invoiced": {
"account_number": "21120",
"account_type": "Service Received But Not Billed"
},
"Accrued Expenses": {
"account_number": "21200"
},
"Wages Payable": {
"account_number": "21300"
},
"PAYE Payable": {
"account_number": "22010"
},
"KiwiSaver Payable": {
"account_number": "22020"
},
"ACC Payable": {
"account_number": "22030"
},
"Credit Cards": {
"Business Credit Card": {
"account_number": "22110"
},
"account_number": "22100",
"is_group": 1
},
"Customer Advances": {
"account_number": "22200"
},
"Deferred Revenue": {
"account_number": "22210"
},
"Provisional Account": {
"account_number": "22220"
},
"Tax Liabilities": {
"GST Payable": {
"account_number": "22310",
"account_type": "Tax"
},
"GST Suspense": {
"account_number": "22320",
"account_type": "Tax"
},
"FBT Payable": {
"account_number": "22330",
"account_type": "Tax"
},
"Income Tax Payable": {
"account_number": "22340",
"account_type": "Tax"
},
"account_number": "22300",
"is_group": 1
},
"account_number": "21500",
"is_group": 1
},
"Non-Current Liabilities": {
"Bank Loans": {
"Bank Loan": {
"account_number": "25011"
},
"account_number": "25010",
"is_group": 1
},
"Lease Liabilities": {
"Lease Liability": {
"account_number": "25021"
},
"account_number": "25020",
"is_group": 1
},
"Shareholder Loans": {
"Shareholder Loan": {
"account_number": "25031"
},
"account_number": "25030",
"is_group": 1
},
"account_number": "25000",
"is_group": 1
},
"account_number": "20000",
"root_type": "Liability"
},
"Equity": {
"Share Capital": {
"account_number": "31010",
"account_type": "Equity"
},
"Drawings": {
"account_number": "31020",
"account_type": "Equity"
},
"Current Year Earnings": {
"account_number": "35010",
"account_type": "Equity"
},
"Retained Earnings": {
"account_number": "35020",
"account_type": "Equity"
},
"account_number": "30000",
"root_type": "Equity"
},
"Income": {
"Sales": {
"account_number": "41010",
"account_type": "Income Account"
},
"Other Income": {
"Interest Income": {
"account_number": "47010",
"account_type": "Income Account"
},
"Rounding Gain/Loss": {
"account_number": "47020",
"account_type": "Income Account"
},
"Foreign Exchange Gain": {
"account_number": "47030",
"account_type": "Income Account"
},
"account_number": "47000",
"is_group": 1
},
"account_number": "40000",
"root_type": "Income"
},
"Expenses": {
"Cost of Goods Sold": {
"Purchases": {
"account_number": "51010",
"account_type": "Cost of Goods Sold"
},
"Freight Inwards": {
"account_number": "51020",
"account_type": "Expenses Included In Valuation"
},
"Duty and Landing Costs": {
"account_number": "51030",
"account_type": "Expenses Included In Valuation"
},
"Stock Adjustment": {
"account_number": "51040",
"account_type": "Stock Adjustment"
},
"Stock Write Off": {
"account_number": "51050",
"account_type": "Stock Adjustment"
},
"account_number": "51000",
"account_type": "Cost of Goods Sold",
"is_group": 1
},
"Operating Expenses": {
"Wages & Salaries": {
"account_number": "61010",
"account_type": "Expense Account"
},
"KiwiSaver Employer Contribution": {
"account_number": "61020",
"account_type": "Expense Account"
},
"ACC Levies": {
"account_number": "61030",
"account_type": "Expense Account"
},
"Rent": {
"account_number": "65010",
"account_type": "Expense Account"
},
"Power": {
"account_number": "65020",
"account_type": "Expense Account"
},
"Telephone": {
"account_number": "66010",
"account_type": "Expense Account"
},
"Insurance": {
"account_number": "64010",
"account_type": "Expense Account"
},
"Accounting Fees": {
"account_number": "64020",
"account_type": "Expense Account"
},
"Legal Fees": {
"account_number": "64030",
"account_type": "Expense Account"
},
"Advertising and Marketing": {
"account_number": "65030",
"account_type": "Expense Account"
},
"Repairs and Maintenance": {
"account_number": "65040",
"account_type": "Expense Account"
},
"Freight and Courier": {
"account_number": "65050",
"account_type": "Expense Account"
},
"Operating Costs": {
"account_number": "65060",
"account_type": "Expense Account"
},
"account_number": "60000",
"is_group": 1
},
"Depreciation and Amortisation": {
"Depreciation - Plant & Equipment": {
"account_number": "62010",
"account_type": "Depreciation"
},
"Depreciation - Motor Vehicles": {
"account_number": "62020",
"account_type": "Depreciation"
},
"Depreciation - Office Equipment": {
"account_number": "62030",
"account_type": "Depreciation"
},
"Depreciation - Computer Equipment": {
"account_number": "62040",
"account_type": "Depreciation"
},
"account_number": "62000",
"is_group": 1
},
"Finance Costs": {
"Bank Charges": {
"account_number": "67010",
"account_type": "Expense Account"
},
"Interest Expense": {
"account_number": "67020",
"account_type": "Expense Account"
},
"Rounding Off": {
"account_number": "67030",
"account_type": "Round Off"
},
"Payment Discounts": {
"account_number": "67040",
"account_type": "Expense Account"
},
"account_number": "67000",
"is_group": 1
},
"Income Tax Expense": {
"account_number": "81010",
"account_type": "Expense Account"
},
"Foreign Exchange": {
"Exchange Gain/Loss": {
"account_number": "82010",
"account_type": "Expense Account"
},
"Unrealized Exchange Gain/Loss": {
"account_number": "82020",
"account_type": "Expense Account"
},
"account_number": "82000",
"is_group": 1
},
"Bad Debts": {
"account_number": "83010",
"account_type": "Expense Account"
},
"Write Off": {
"account_number": "83020",
"account_type": "Expense Account"
},
"Gain/Loss on Asset Disposal": {
"account_number": "83030",
"account_type": "Expense Account"
},
"Expenses Included In Asset Valuation": {
"account_number": "84010",
"account_type": "Expenses Included In Asset Valuation"
},
"account_number": "50000",
"root_type": "Expense"
}
}
}

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

@@ -198,21 +198,9 @@ def add_dimension_to_budget_doctype(df, doc):
def delete_accounting_dimension(doc):
doclist = get_doctypes_with_dimensions()
frappe.db.sql(
"""
DELETE FROM `tabCustom Field`
WHERE fieldname = {}
AND dt IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
tuple([doc.fieldname, *doclist]),
)
frappe.db.delete("Custom Field", filters={"fieldname": doc.fieldname, "dt": ["in", doclist]})
frappe.db.sql(
"""
DELETE FROM `tabProperty Setter`
WHERE field_name = {}
AND doc_type IN ({})""".format("%s", ", ".join(["%s"] * len(doclist))), # nosec
tuple([doc.fieldname, *doclist]),
)
frappe.db.delete("Property Setter", filters={"field_name": doc.fieldname, "doc_type": ["in", doclist]})
budget_against_property = frappe.get_doc("Property Setter", "Budget-budget_against-options")
value_list = budget_against_property.value.split("\n")[3:]
@@ -273,13 +261,27 @@ def get_accounting_dimensions(as_list=True):
def get_checks_for_pl_and_bs_accounts():
return frappe.db.sql(
"""SELECT p.label, p.disabled, p.fieldname, c.default_dimension, c.company, c.mandatory_for_pl, c.mandatory_for_bs
FROM `tabAccounting Dimension`p ,`tabAccounting Dimension Detail` c
WHERE p.name = c.parent AND p.disabled = 0""",
as_dict=1,
AccountingDimension = frappe.qb.DocType("Accounting Dimension")
AccountingDimensionDetail = frappe.qb.DocType("Accounting Dimension Detail")
query = (
frappe.qb.from_(AccountingDimension)
.join(AccountingDimensionDetail)
.on(AccountingDimension.name == AccountingDimensionDetail.parent)
.select(
AccountingDimension.label,
AccountingDimension.disabled,
AccountingDimension.fieldname,
AccountingDimensionDetail.default_dimension,
AccountingDimensionDetail.company,
AccountingDimensionDetail.mandatory_for_pl,
AccountingDimensionDetail.mandatory_for_bs,
)
.where(AccountingDimension.disabled == 0)
)
return query.run(as_dict=1)
def get_dimension_with_children(doctype, dimensions):
if isinstance(dimensions, str):

View File

@@ -43,18 +43,19 @@ class AccountingDimensionFilter(Document):
self.validate_applicable_accounts()
def validate_applicable_accounts(self):
accounts = frappe.db.sql(
"""
SELECT a.applicable_on_account as account
FROM `tabApplicable On Account` a, `tabAccounting Dimension Filter` d
WHERE d.name = a.parent
and d.name != %s
and d.accounting_dimension = %s
""",
(self.name, self.accounting_dimension),
as_dict=1,
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
query = (
frappe.qb.from_(ApplicableOnAccount)
.join(AccountingDimensionFilter)
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
.select(ApplicableOnAccount.applicable_on_account.as_("account"))
.where(AccountingDimensionFilter.name != self.name)
.where(AccountingDimensionFilter.accounting_dimension == self.accounting_dimension)
)
accounts = query.run(as_dict=1)
account_list = [d.account for d in accounts]
for account in self.get("accounts"):
@@ -69,22 +70,28 @@ class AccountingDimensionFilter(Document):
def get_dimension_filter_map():
filters = frappe.db.sql(
"""
SELECT
a.applicable_on_account, d.dimension_value, p.accounting_dimension,
p.allow_or_restrict, p.fieldname, a.is_mandatory
FROM
`tabApplicable On Account` a,
`tabAccounting Dimension Filter` p
LEFT JOIN `tabAllowed Dimension` d ON d.parent = p.name
WHERE
p.name = a.parent
AND p.disabled = 0
""",
as_dict=1,
ApplicableOnAccount = frappe.qb.DocType("Applicable On Account")
AccountingDimensionFilter = frappe.qb.DocType("Accounting Dimension Filter")
AllowedDimension = frappe.qb.DocType("Allowed Dimension")
query = (
frappe.qb.from_(AccountingDimensionFilter)
.join(ApplicableOnAccount)
.on(AccountingDimensionFilter.name == ApplicableOnAccount.parent)
.left_join(AllowedDimension)
.on(AllowedDimension.parent == AccountingDimensionFilter.name)
.select(
ApplicableOnAccount.applicable_on_account,
AllowedDimension.dimension_value,
AccountingDimensionFilter.accounting_dimension,
AccountingDimensionFilter.allow_or_restrict,
AccountingDimensionFilter.fieldname,
ApplicableOnAccount.is_mandatory,
)
.where(AccountingDimensionFilter.disabled == 0)
)
filters = query.run(as_dict=1)
dimension_filter_map = {}
for f in filters:

View File

@@ -46,23 +46,19 @@ class AccountingPeriod(Document):
self.name = " - ".join([self.period_name, company_abbr])
def validate_overlap(self):
existing_accounting_period = frappe.db.sql(
"""select name from `tabAccounting Period`
where (
(%(start_date)s between start_date and end_date)
or (%(end_date)s between start_date and end_date)
or (start_date between %(start_date)s and %(end_date)s)
or (end_date between %(start_date)s and %(end_date)s)
) and name!=%(name)s and company=%(company)s""",
{
"start_date": self.start_date,
"end_date": self.end_date,
"name": self.name,
"company": self.company,
},
as_dict=True,
AccountingPeriod = frappe.qb.DocType("Accounting Period")
query = (
frappe.qb.from_(AccountingPeriod)
.select(AccountingPeriod.name)
.where(AccountingPeriod.start_date <= self.end_date)
.where(AccountingPeriod.end_date >= self.start_date)
.where(AccountingPeriod.name != self.name)
.where(AccountingPeriod.company == self.company)
)
existing_accounting_period = query.run(as_dict=True)
if len(existing_accounting_period) > 0:
frappe.throw(
_("Accounting Period overlaps with {0}").format(existing_accounting_period[0].get("name")),

View File

@@ -10,6 +10,9 @@ frappe.ui.form.on("Accounts Settings", {
},
};
});
if (!frm.naming_controller) frm.naming_controller = new erpnext.NamingSeriesController(frm);
frm.naming_controller.render_table("transaction_naming_html", get_transactions(frm));
},
enable_immutable_ledger: function (frm) {
if (!frm.doc.enable_immutable_ledger) {
@@ -49,3 +52,16 @@ function toggle_tax_settings(frm, field_name) {
frm.set_value(other_field, 0);
}
}
function get_transactions(frm) {
const transactions = [
{ label: __("Journal Entry"), doctype: "Journal Entry" },
{ label: __("Payment Entry"), doctype: "Payment Entry" },
{ label: __("Purchase Invoice"), doctype: "Purchase Invoice" },
{ label: __("Purchase Order"), doctype: "Purchase Order" },
{ label: __("Purchase Receipt"), doctype: "Purchase Receipt" },
{ label: __("Sales Invoice"), doctype: "Sales Invoice" },
];
return transactions;
}

View File

@@ -23,9 +23,9 @@
"confirm_before_resetting_posting_date",
"preview_mode",
"analytics_section",
"enable_discounts_and_margin",
"enable_accounting_dimensions",
"column_break_vtnr",
"enable_discounts_and_margin",
"journals_section",
"merge_similar_account_heads",
"deferred_accounting_settings_section",
@@ -44,7 +44,6 @@
"print_settings",
"show_inclusive_tax_in_print",
"show_taxes_as_table_in_print",
"column_break_12",
"show_payment_schedule_in_print",
"item_price_settings_section",
"maintain_same_internal_transaction_rate",
@@ -60,29 +59,30 @@
"payments_tab",
"section_break_jpd0",
"auto_reconcile_payments",
"exchange_gain_loss_posting_date",
"auto_reconciliation_job_trigger",
"reconciliation_queue_size",
"column_break_resa",
"exchange_gain_loss_posting_date",
"repost_section",
"column_break_mfor",
"repost_allowed_types",
"payment_options_section",
"fetch_payment_schedule_in_payment_request",
"enable_loyalty_point_program",
"column_break_ctam",
"fetch_payment_schedule_in_payment_request",
"invoicing_settings_tab",
"accounts_transactions_settings_section",
"over_billing_allowance",
"column_break_11",
"role_allowed_to_over_bill",
"credit_controller",
"make_payment_via_journal_entry",
"over_billing_allowance",
"credit_controller",
"role_allowed_to_over_bill",
"column_break_11",
"assets_tab",
"asset_settings_section",
"calculate_depr_using_total_days",
"column_break_gjcc",
"book_asset_depreciation_entry_automatically",
"calculate_depr_using_total_days",
"role_to_notify_on_depreciation_failure",
"column_break_gjcc",
"closing_settings_tab",
"period_closing_settings_section",
"ignore_account_closing_balance",
@@ -91,8 +91,8 @@
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"column_break_lvjk",
"receivable_payable_remarks_length",
"column_break_lvjk",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"default_ageing_range",
@@ -104,13 +104,15 @@
"show_balance_in_coa",
"banking_section",
"enable_party_matching",
"automatically_run_rules_on_unreconciled_transactions",
"enable_fuzzy_matching",
"transfer_match_days",
"automatically_run_rules_on_unreconciled_transactions",
"payment_request_section",
"create_pr_in_draft_status",
"budget_section",
"use_legacy_budget_controller"
"use_legacy_budget_controller",
"document_naming_tab",
"transaction_naming_html"
],
"fields": [
{
@@ -118,14 +120,14 @@
"description": "Address used to determine Tax Category in transactions",
"fieldname": "determine_address_tax_category_from",
"fieldtype": "Select",
"label": "Determine Address Tax Category From",
"label": "Determine Address Tax Category from",
"options": "Billing Address\nShipping Address"
},
{
"fieldname": "credit_controller",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Role allowed to bypass Credit Limit",
"label": "Role allowed to bypass credit limit",
"options": "Role"
},
{
@@ -133,7 +135,7 @@
"description": "Enabling this ensures each Purchase Invoice has a unique value in Supplier Invoice No. field within a particular fiscal year",
"fieldname": "check_supplier_invoice_uniqueness",
"fieldtype": "Check",
"label": "Check Supplier Invoice Number Uniqueness"
"label": "Check Supplier invoice number uniqueness"
},
{
"default": "0",
@@ -144,27 +146,29 @@
},
{
"default": "1",
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#4-unlink-payment-on-cancellation-of-invoice",
"fieldname": "unlink_payment_on_cancellation_of_invoice",
"fieldtype": "Check",
"label": "Unlink Payment on Cancellation of Invoice"
"label": "Unlink Payment on cancellation of invoice"
},
{
"default": "1",
"documentation_url": "https://docs.frappe.io/erpnext/accounts-settings#8-unlink-advance-payment-on-cancellation-of-order",
"fieldname": "unlink_advance_payment_on_cancelation_of_order",
"fieldtype": "Check",
"label": "Unlink Advance Payment on Cancellation of Order"
"label": "Unlink Advance Payment on cancellation of order"
},
{
"default": "1",
"fieldname": "book_asset_depreciation_entry_automatically",
"fieldtype": "Check",
"label": "Book Asset Depreciation Entry Automatically"
"label": "Book Asset Depreciation entry automatically"
},
{
"default": "1",
"fieldname": "add_taxes_from_item_tax_template",
"fieldtype": "Check",
"label": "Automatically Add Taxes and Charges from Item Tax Template"
"label": "Automatically add Taxes and Charges from Item Tax Template"
},
{
"fieldname": "print_settings",
@@ -175,17 +179,13 @@
"default": "0",
"fieldname": "show_inclusive_tax_in_print",
"fieldtype": "Check",
"label": "Show Inclusive Tax in Print"
},
{
"fieldname": "column_break_12",
"fieldtype": "Column Break"
"label": "Show inclusive tax in print"
},
{
"default": "0",
"fieldname": "show_payment_schedule_in_print",
"fieldtype": "Check",
"label": "Show Payment Schedule in Print"
"label": "Show Payment Schedule in print"
},
{
"fieldname": "currency_exchange_section",
@@ -211,7 +211,7 @@
"description": "Payment Terms from orders will be fetched into the invoices as is",
"fieldname": "automatically_fetch_payment_terms",
"fieldtype": "Check",
"label": "Automatically Fetch Payment Terms from Order/Quotation"
"label": "Automatically fetch Payment Terms from Order/Quotation"
},
{
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
@@ -223,7 +223,7 @@
"default": "1",
"fieldname": "automatically_process_deferred_accounting_entry",
"fieldtype": "Check",
"label": "Automatically Process Deferred Accounting Entry"
"label": "Automatically process deferred Accounting entry"
},
{
"fieldname": "deferred_accounting_settings_section",
@@ -239,7 +239,7 @@
"description": "If this is unchecked, direct GL entries will be created to book deferred revenue or expense",
"fieldname": "book_deferred_entries_via_journal_entry",
"fieldtype": "Check",
"label": "Book Deferred Entries Via Journal Entry"
"label": "Book deferred entries via Journal Entry"
},
{
"default": "0",
@@ -247,38 +247,37 @@
"description": "If this is unchecked Journal Entries will be saved in a Draft state and will have to be submitted manually",
"fieldname": "submit_journal_entries",
"fieldtype": "Check",
"label": "Submit Journal Entries"
"label": "Submit Journal entries"
},
{
"default": "Days",
"description": "If \"Months\" is selected, a fixed amount will be booked as deferred revenue or expense for each month irrespective of the number of days in a month. It will be prorated if deferred revenue or expense is not booked for an entire month",
"fieldname": "book_deferred_entries_based_on",
"fieldtype": "Select",
"label": "Book Deferred Entries Based On",
"label": "Book Deferred entries based on",
"options": "Days\nMonths"
},
{
"default": "0",
"fieldname": "delete_linked_ledger_entries",
"fieldtype": "Check",
"label": "Delete Accounting and Stock Ledger Entries on deletion of Transaction"
"label": "Delete Accounting and Stock Ledger entries on deletion of transaction"
},
{
"depends_on": "eval: doc.over_billing_allowance > 0",
"description": "Users with this role are allowed to over bill above the allowance percentage",
"fieldname": "role_allowed_to_over_bill",
"fieldtype": "Link",
"label": "Role Allowed to Over Bill ",
"label": "Role Allowed to over bill ",
"options": "Role"
},
{
"fieldname": "period_closing_settings_section",
"fieldtype": "Section Break",
"label": "Period Closing Settings"
"fieldtype": "Section Break"
},
{
"fieldname": "accounts_transactions_settings_section",
"fieldtype": "Section Break",
"label": "Credit Limit Settings"
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_11",
@@ -363,14 +362,14 @@
"default": "1",
"fieldname": "show_balance_in_coa",
"fieldtype": "Check",
"label": "Show Balances in Chart Of Accounts"
"label": "Show balances in Chart of Accounts"
},
{
"default": "0",
"description": "Split Early Payment Discount Loss into Income and Tax Loss",
"fieldname": "book_tax_discount_loss",
"fieldtype": "Check",
"label": "Book Tax Loss on Early Payment Discount"
"label": "Book tax loss on early payment discount"
},
{
"fieldname": "journals_section",
@@ -382,7 +381,7 @@
"description": "Rows with Same Account heads will be merged on Ledger",
"fieldname": "merge_similar_account_heads",
"fieldtype": "Check",
"label": "Merge Similar Account Heads"
"label": "Merge similar Account Heads"
},
{
"fieldname": "section_break_jpd0",
@@ -393,13 +392,13 @@
"default": "0",
"fieldname": "auto_reconcile_payments",
"fieldtype": "Check",
"label": "Auto Reconcile Payments"
"label": "Auto reconcile Payments"
},
{
"default": "0",
"fieldname": "show_taxes_as_table_in_print",
"fieldtype": "Check",
"label": "Show Taxes as Table in Print"
"label": "Show taxes as table in print"
},
{
"default": "0",
@@ -421,14 +420,14 @@
"description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ",
"fieldname": "ignore_account_closing_balance",
"fieldtype": "Check",
"label": "Ignore Account Closing Balance"
"label": "Ignore Account closing balance"
},
{
"default": "0",
"description": "Tax Amount will be rounded on a row(items) level",
"fieldname": "round_row_wise_tax",
"fieldtype": "Check",
"label": "Round Tax Amount Row-wise"
"label": "Round tax amount row-wise"
},
{
"fieldname": "reports_tab",
@@ -440,14 +439,14 @@
"description": "Truncates 'Remarks' column to set character length",
"fieldname": "general_ledger_remarks_length",
"fieldtype": "Int",
"label": "General Ledger"
"label": "General Ledger remarks length"
},
{
"default": "0",
"description": "Truncates 'Remarks' column to set character length",
"fieldname": "receivable_payable_remarks_length",
"fieldtype": "Int",
"label": "Accounts Receivable/Payable"
"label": "Accounts Receivable / Payable remarks length"
},
{
"fieldname": "column_break_lvjk",
@@ -481,7 +480,7 @@
"description": "Payment Requests made from Sales / Purchase Invoice will be put in Draft explicitly",
"fieldname": "create_pr_in_draft_status",
"fieldtype": "Check",
"label": "Create in Draft Status"
"label": "Create payment requests in Draft status"
},
{
"fieldname": "column_break_yuug",
@@ -496,14 +495,14 @@
"description": "Interval should be between 1 to 59 MInutes",
"fieldname": "auto_reconciliation_job_trigger",
"fieldtype": "Int",
"label": "Auto Reconciliation Job Trigger"
"label": "Auto Reconciliation job trigger"
},
{
"default": "5",
"description": "Documents Processed on each trigger. Queue Size should be between 5 and 100",
"fieldname": "reconciliation_queue_size",
"fieldtype": "Int",
"label": "Reconciliation Queue Size"
"label": "Reconciliation queue size"
},
{
"default": "0",
@@ -517,14 +516,14 @@
"description": "Only applies for Normal Payments",
"fieldname": "exchange_gain_loss_posting_date",
"fieldtype": "Select",
"label": "Posting Date Inheritance for Exchange Gain / Loss",
"label": "Posting Date inheritance for exchange gain / loss",
"options": "Invoice\nPayment\nReconciliation Date"
},
{
"default": "Buffered Cursor",
"fieldname": "receivable_payable_fetch_method",
"fieldtype": "Select",
"label": "Data Fetch Method",
"label": "Data fetch method",
"options": "Buffered Cursor\nUnBuffered Cursor"
},
{
@@ -541,14 +540,14 @@
"default": "0",
"fieldname": "maintain_same_internal_transaction_rate",
"fieldtype": "Check",
"label": "Maintain Same Rate Throughout Internal Transaction"
"label": "Maintain same rate throughout internal Transaction"
},
{
"default": "Stop",
"depends_on": "maintain_same_internal_transaction_rate",
"fieldname": "maintain_same_rate_action",
"fieldtype": "Select",
"label": "Action if Same Rate is Not Maintained Throughout Internal Transaction",
"label": "Action if same rate is not maintained throughout internal transaction",
"mandatory_depends_on": "maintain_same_internal_transaction_rate",
"options": "Stop\nWarn"
},
@@ -556,7 +555,7 @@
"depends_on": "eval: doc.maintain_same_internal_transaction_rate && doc.maintain_same_rate_action == 'Stop'",
"fieldname": "role_to_override_stop_action",
"fieldtype": "Link",
"label": "Role Allowed to Override Stop Action",
"label": "Role allowed to override stop action",
"options": "Role"
},
{
@@ -588,7 +587,7 @@
"description": "If no taxes are set, and Taxes and Charges Template is selected, the system will automatically apply the taxes from the chosen template.",
"fieldname": "add_taxes_from_taxes_and_charges_template",
"fieldtype": "Check",
"label": "Automatically Add Taxes from Taxes and Charges Template"
"label": "Automatically add taxes from Taxes and Charges Template"
},
{
"fieldname": "column_break_ntmi",
@@ -598,19 +597,20 @@
"default": "0",
"fieldname": "fetch_valuation_rate_for_internal_transaction",
"fieldtype": "Check",
"label": "Fetch Valuation Rate for Internal Transaction"
"label": "Fetch valuation rate for internal Transaction"
},
{
"default": "0",
"description": "Enable this if you are experiencing issues with the new budget controller. Uses the older budget validation logic",
"fieldname": "use_legacy_budget_controller",
"fieldtype": "Check",
"label": "Use Legacy Budget Controller"
"label": "Use legacy Budget Controller"
},
{
"default": "1",
"fieldname": "use_legacy_controller_for_pcv",
"fieldtype": "Check",
"label": "Use Legacy Controller For Period Closing Voucher"
"label": "Use legacy controller for Period Closing Voucher"
},
{
"description": "Users with this role will be notified if the asset depreciation gets failed",
@@ -628,7 +628,7 @@
{
"fieldname": "chart_of_accounts_section",
"fieldtype": "Section Break",
"label": "Chart Of Accounts"
"label": "Chart of Accounts"
},
{
"fieldname": "banking_section",
@@ -673,6 +673,7 @@
},
{
"default": "0",
"documentation_url": "https://docs.frappe.io/erpnext/loyalty-program",
"fieldname": "enable_loyalty_point_program",
"fieldtype": "Check",
"label": "Enable Loyalty Point Program"
@@ -699,7 +700,7 @@
"default": "1",
"fieldname": "fetch_payment_schedule_in_payment_request",
"fieldtype": "Check",
"label": "Fetch Payment Schedule In Payment Request"
"label": "Fetch Payment Schedule in Payment Request"
},
{
"default": "3",
@@ -724,7 +725,7 @@
{
"fieldname": "repost_allowed_types",
"fieldtype": "Table",
"label": "Allowed Doctypes",
"label": "Allowed DocTypes",
"options": "Repost Allowed Types"
},
{
@@ -732,7 +733,21 @@
"description": "Runs a preview check on save before submission without making any actual changes.",
"fieldname": "preview_mode",
"fieldtype": "Check",
"label": "Preview Mode"
"label": "Preview mode"
},
{
"fieldname": "document_naming_tab",
"fieldtype": "Tab Break",
"label": "Document Naming"
},
{
"fieldname": "transaction_naming_html",
"fieldtype": "HTML"
},
{
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
"fieldname": "column_break_mfor",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
@@ -741,7 +756,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-05-18 12:16:33.679345",
"modified": "2026-06-03 13:11:54.721495",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -27,6 +27,7 @@
"column_break_12",
"branch_code",
"bank_account_no",
"statement_password",
"address_and_contact",
"address_html",
"column_break_13",
@@ -149,6 +150,12 @@
"label": "Bank Account No",
"length": 30
},
{
"description": "Password used to open password-protected PDF statements for this account. Stored encrypted.",
"fieldname": "statement_password",
"fieldtype": "Password",
"label": "Statement PDF Password"
},
{
"fieldname": "address_and_contact",
"fieldtype": "Section Break",

View File

@@ -41,6 +41,7 @@ class BankAccount(Document):
mask: DF.Data | None
party: DF.DynamicLink | None
party_type: DF.Link | None
statement_password: DF.Password | None
# end: auto-generated types
def onload(self):

View File

@@ -28,7 +28,8 @@
"detected_transaction_starting_index",
"detected_transaction_ending_index",
"section_break_yulq",
"column_mapping"
"column_mapping",
"pdf_tables"
],
"fields": [
{
@@ -128,6 +129,13 @@
"label": "Column Mapping",
"options": "Bank Statement Import Log Column Map"
},
{
"description": "Per-table extraction data for PDF statements (rows, bbox, page image, column mapping). Edited via the banking app.",
"fieldname": "pdf_tables",
"fieldtype": "JSON",
"label": "PDF Tables",
"read_only": 1
},
{
"default": "Not Started",
"fieldname": "status",

View File

@@ -7,7 +7,18 @@ from frappe.utils import getdate
from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_log import (
BankStatementImportLog,
build_table_transactions,
detect_column_mapping,
detect_header_row,
extract_pdf_tables,
get_float_amount,
get_statement_details,
guess_column_mapping_by_content,
reextract_pdf_table,
set_header_index,
set_pdf_table_header,
update_column_mapping,
update_pdf_tables,
)
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
@@ -113,6 +124,346 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
self.assertIsNone(get_float_amount("ABCD"))
self.assertIsNone(get_float_amount("****"))
# ------------------------------------------------------------------ #
# PDF statement import
# ------------------------------------------------------------------ #
@staticmethod
def _make_pdf(html: str) -> bytes:
import pdfkit
return pdfkit.from_string(html, False)
@staticmethod
def _encrypt(pdf_bytes: bytes, password: str) -> bytes:
import io
from pypdf import PdfReader, PdfWriter
reader = PdfReader(io.BytesIO(pdf_bytes))
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
writer.encrypt(password)
buffer = io.BytesIO()
writer.write(buffer)
return buffer.getvalue()
@staticmethod
def _auto_map(table: dict) -> dict:
"""Mimic prepare_pdf_tables' best-effort mapping for a single extracted table."""
header_index, score = detect_header_row(table["rows"])
if score >= 2:
table["header_index"] = header_index
table["column_mapping"] = detect_column_mapping(table["rows"][header_index])
else:
table["header_index"] = None
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
table["included"] = True
return table
def test_pdf_multi_page_kept_separate_and_unioned(self):
"""Tables on separate pages must NOT be merged; transactions are the union."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td><td></td><td>9500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td></td><td>20000.00</td><td>29500.00</td></tr></table>
<div style="page-break-before: always"></div>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td><td></td><td>27500.00</td></tr></table>
</body></html>
"""
tables = extract_pdf_tables(self._make_pdf(html))
# Two separate tables, one per page
self.assertEqual(len(tables), 2)
self.assertEqual(sorted(t["page"] for t in tables), [1, 2])
for table in tables:
self.assertIn("bbox", table)
self.assertEqual(len(table["bbox"]), 4)
union = []
for table in tables:
final, _df, _af = build_table_transactions(self._auto_map(table))
union.extend(final)
self.assertEqual(len(union), 3)
self.assertEqual(sorted(t["date"] for t in union), ["2024-04-01", "2024-04-03", "2024-04-05"])
def test_pdf_junk_table_excluded(self):
"""A non-transactions table (ad/summary) should yield zero transactions."""
ad_table = self._auto_map({"rows": [["Open a new account!", "Call 1800-XYZ"]]})
final, _df, _af = build_table_transactions(ad_table)
self.assertEqual(final, [])
def test_headerless_content_mapping(self):
"""Without a header row, columns are guessed from their contents."""
rows = [
["01/04/2024", "UPI PAYMENT", "500.00"],
["03/04/2024", "SALARY CREDIT", "20000.00"],
]
mapping = {
c["maps_to"]: c["index"]
for c in guess_column_mapping_by_content(rows)
if c["maps_to"] != "Do not import"
}
self.assertEqual(mapping.get("Date"), 0)
self.assertEqual(mapping.get("Description"), 1)
self.assertEqual(mapping.get("Amount"), 2)
def test_pdf_password_protected(self):
"""Encrypted PDFs error without a password and succeed with the right one."""
html = """
<html><body><table border="1">
<tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr></table></body></html>
"""
encrypted = self._encrypt(self._make_pdf(html), "secret123")
# No / wrong password -> recognizable error
self.assertRaises(frappe.ValidationError, extract_pdf_tables, encrypted)
self.assertRaises(frappe.ValidationError, extract_pdf_tables, encrypted, "wrong")
# Correct password -> extracts
tables = extract_pdf_tables(encrypted, "secret123")
self.assertTrue(tables)
def test_pdf_no_tables_detected(self):
"""A PDF with no detectable tables raises a clear error (e.g. scanned PDFs)."""
html = "<html><body><p>Just some prose with no tabular data at all.</p></body></html>"
self.assertRaises(frappe.ValidationError, extract_pdf_tables, self._make_pdf(html))
def _create_pdf_import_log(self, html: str) -> BankStatementImportLog:
pdf_bytes = self._make_pdf(html)
file_doc = frappe.get_doc(
{
"doctype": "File",
"file_name": f"test-statement-{frappe.generate_hash(length=8)}.pdf",
"is_private": 1,
"content": pdf_bytes,
}
).insert(ignore_permissions=True)
doc = frappe.get_doc(
{
"doctype": "Bank Statement Import Log",
"name": f"test-pdf-{frappe.generate_hash(length=8)}",
"bank_account": self.bank_account,
"file": file_doc.file_url,
}
)
return doc.insert()
def test_pdf_full_lifecycle(self):
"""End-to-end doc lifecycle: insert -> rasterize -> preview -> edit -> import."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td><td></td><td>9500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td></td><td>20000.00</td><td>29500.00</td></tr></table>
<div style="page-break-before: always"></div>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Withdrawal</th><th>Deposit</th><th>Balance</th></tr>
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td><td></td><td>27500.00</td></tr></table>
</body></html>
"""
doc = self._create_pdf_import_log(html)
# before_insert populated the per-table JSON, page images and the union summary
tables = doc.get_pdf_tables()
self.assertEqual(len(tables), 2)
for table in tables:
self.assertTrue(table.get("page_image"))
self.assertIn("bbox", table)
# Page-image File must be attached to the final docname, not the client's temp id
attached_to = frappe.db.get_value("File", {"file_url": table["page_image"]}, "attached_to_name")
self.assertEqual(attached_to, doc.name)
self.assertEqual(doc.number_of_transactions, 3)
self.assertEqual(doc.total_debit_transactions, 2)
self.assertEqual(doc.total_credit_transactions, 1)
# get_statement_details returns the union and the per-table data for the editor
details = get_statement_details(doc.name)
self.assertEqual(len(details["final_transactions"]), 3)
self.assertEqual(details["raw_data"], [])
self.assertEqual(len(details["pdf_tables"]), 2)
# Excluding the second table (page 2) drops its single transaction
tables[1]["included"] = False
update_pdf_tables(doc.name, tables)
doc.reload()
self.assertEqual(doc.number_of_transactions, 2)
# Re-include and import; transactions are created for the union
tables[1]["included"] = True
update_pdf_tables(doc.name, tables)
doc.reload()
doc.insert_transactions()
doc.reload()
self.assertEqual(doc.status, "Completed")
created = frappe.get_all(
"Bank Transaction", filters={"bank_account": self.bank_account, "docstatus": 1}
)
self.assertEqual(len(created), 3)
def test_pdf_reextract_table_from_bbox(self):
"""Re-extracting a table from an adjusted bbox updates its rows and stores the bbox."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr></table>
</body></html>
"""
doc = self._create_pdf_import_log(html)
table = doc.get_pdf_tables()[0]
bbox = table["bbox"]
details = reextract_pdf_table(doc.name, table["page"], table["table_index"], bbox)
updated = details["pdf_tables"][0]
# Same region -> same rows; bbox is persisted
self.assertTrue(updated["rows"])
self.assertEqual(updated["bbox"], [round(float(v), 2) for v in bbox])
self.assertEqual(updated["rows"], table["rows"])
def test_pdf_reextract_changed_bbox_updates_rows_and_transactions(self):
"""Shrinking a table's bbox must drop rows and update the transaction count end-to-end."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr>
<tr><td>05/04/2024</td><td>ATM WDL</td><td>2000.00</td></tr>
<tr><td>07/04/2024</td><td>INTEREST</td><td>12.50</td></tr></table>
</body></html>
"""
doc = self._create_pdf_import_log(html)
original = doc.get_pdf_tables()[0]
original_rows = len(original["rows"])
original_txns = doc.number_of_transactions
# Shrink the box to roughly the top half (simulating a user drag).
x0, top, x1, bottom = original["bbox"]
shrunk = [x0, top, x1, top + (bottom - top) * 0.5]
details = reextract_pdf_table(doc.name, original["page"], original["table_index"], shrunk)
updated = details["pdf_tables"][0]
doc.reload()
self.assertLess(len(updated["rows"]), original_rows)
self.assertLess(doc.number_of_transactions, original_txns)
self.assertEqual(len(details["final_transactions"]), doc.number_of_transactions)
def test_pdf_set_table_header(self):
"""User can clear a table's header (no header row) or set a specific header row."""
html = """
<html><body>
<table border="1"><tr><th>Date</th><th>Narration</th><th>Amount</th></tr>
<tr><td>01/04/2024</td><td>UPI PAYMENT</td><td>500.00</td></tr>
<tr><td>03/04/2024</td><td>SALARY</td><td>20000.00</td></tr></table>
</body></html>
"""
doc = self._create_pdf_import_log(html)
table = doc.get_pdf_tables()[0]
self.assertEqual(table["header_index"], 0)
original = {
c["maps_to"]: c["index"] for c in table["column_mapping"] if c["maps_to"] != "Do not import"
}
# Clear the header (-1): header is removed but the mapping is preserved (not re-guessed).
details = set_pdf_table_header(doc.name, table["page"], table["table_index"], -1)
updated = details["pdf_tables"][0]
self.assertIsNone(updated["header_index"])
preserved = {
c["maps_to"]: c["index"] for c in updated["column_mapping"] if c["maps_to"] != "Do not import"
}
self.assertEqual(preserved, original)
# Set row 0 back as the header: it resolves meaningfully, so mapping is re-derived.
details = set_pdf_table_header(doc.name, table["page"], table["table_index"], 0)
updated = details["pdf_tables"][0]
self.assertEqual(updated["header_index"], 0)
mapped = {
c["maps_to"]: c["index"] for c in updated["column_mapping"] if c["maps_to"] != "Do not import"
}
self.assertEqual(mapped.get("Date"), 0)
self.assertEqual(mapped.get("Description"), 1)
# ------------------------------------------------------------------ #
# CSV/XLSX column mapping + header overrides
# ------------------------------------------------------------------ #
def _create_csv_import_log(self, csv_text: str) -> BankStatementImportLog:
file_doc = frappe.get_doc(
{
"doctype": "File",
"file_name": f"test-statement-{frappe.generate_hash(length=8)}.csv",
"is_private": 1,
"content": csv_text,
}
).insert(ignore_permissions=True)
doc = frappe.get_doc(
{
"doctype": "Bank Statement Import Log",
"bank_account": self.bank_account,
"file": file_doc.file_url,
}
)
return doc.insert()
def test_csv_update_column_mapping(self):
"""Overriding the column mapping recomputes the transaction count."""
csv_text = "Date,Narration,Amount\n01/04/2024,UPI PAYMENT,500.00\n03/04/2024,SALARY,20000.00\n"
doc = self._create_csv_import_log(csv_text)
self.assertEqual(doc.number_of_transactions, 2)
# Drop the amount column -> no amount -> no transactions detected.
mapping = [
{"index": c.index, "maps_to": "Do not import" if c.maps_to == "Amount" else c.maps_to}
for c in doc.column_mapping
]
details = update_column_mapping(doc.name, mapping)
doc.reload()
self.assertEqual(doc.number_of_transactions, 0)
self.assertEqual(len(details["final_transactions"]), 0)
def test_csv_set_header_index_preserves_mapping(self):
"""Clearing the header keeps the user's mapping; it is not re-guessed."""
csv_text = "Date,Narration,Amount\n01/04/2024,UPI PAYMENT,500.00\n03/04/2024,SALARY,20000.00\n"
doc = self._create_csv_import_log(csv_text)
self.assertEqual(doc.detected_header_index, 0)
# Manually map the Narration column (1) as Reference.
mapping = [
{
"index": c.index,
"maps_to": "Reference" if c.index == 1 else c.maps_to,
"header_text": c.header_text,
}
for c in doc.column_mapping
]
update_column_mapping(doc.name, mapping)
doc.reload()
# Clear the header row: the manual mapping must be preserved (column 1 stays Reference,
# not re-guessed to Description). The label row fails date parsing, so 2 transactions remain.
set_header_index(doc.name, -1)
doc.reload()
self.assertEqual(doc.detected_header_index, -1)
self.assertEqual(doc.number_of_transactions, 2)
current = {c.index: c.maps_to for c in doc.column_mapping}
self.assertEqual(current.get(1), "Reference")
# Restore row 0 as the header (resolves meaningfully -> re-derived from labels).
set_header_index(doc.name, 0)
doc.reload()
self.assertEqual(doc.detected_header_index, 0)
restored = {c.maps_to: c.index for c in doc.column_mapping if c.maps_to != "Do not import"}
self.assertEqual(restored.get("Description"), 1)
test_hdfc_sample_statement_data = [
["HDFC BANK Ltd. Page No .: 1 Statement of accounts", "", "", "", "", "", ""],

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

@@ -705,18 +705,20 @@ def get_ordered_amount(params):
def get_other_condition(params, for_doc):
condition = f"expense_account = '{params.expense_account}'"
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
budget_against_field = params.get("budget_against_field")
if budget_against_field and params.get(budget_against_field):
condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'"
condition += (
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
)
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
return condition

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

@@ -60,7 +60,7 @@ frappe.ui.form.on("Dunning", {
if (frm.doc.docstatus === 0) {
frm.add_custom_button(__("Fetch Overdue Payments"), () => {
erpnext.utils.map_current_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning",
source_doctype: "Sales Invoice",
date_field: "due_date",
target: frm,

View File

@@ -8,7 +8,7 @@ from frappe.utils import add_days, nowdate, today
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
from erpnext.accounts.doctype.sales_invoice.mapper import (
create_dunning as create_dunning_from_sales_invoice,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
@@ -73,7 +73,7 @@ class TestDunning(ERPNextTestSuite):
dunning = create_dunning_from_sales_invoice(si1.name)
dunning.overdue_payments = []
method = "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning"
method = "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning"
updated_dunning = mapper.map_docs(method, json.dumps([si1.name, si2.name]), dunning)
self.assertEqual(len(updated_dunning.overdue_payments), 2)

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

@@ -24,7 +24,6 @@ from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
get_advance_payment_doctypes,
get_balance_on,
get_stock_accounts,
get_stock_and_account_balance,
@@ -1120,87 +1119,9 @@ class JournalEntry(AccountsController):
self.total_amount_in_words = money_in_words(amt, currency)
def build_gl_map(self):
gl_map = []
from erpnext.accounts.doctype.journal_entry.services.gl_composer import JournalEntryGLComposer
company_currency = erpnext.get_company_currency(self.company)
self.transaction_currency = company_currency
self.transaction_exchange_rate = 1
if self.multi_currency:
for row in self.get("accounts"):
if row.account_currency != company_currency:
# Journal assumes the first foreign currency as transaction currency
self.transaction_currency = row.account_currency
self.transaction_exchange_rate = row.exchange_rate
break
advance_doctypes = get_advance_payment_doctypes()
for d in self.get("accounts"):
if d.debit or d.credit or (self.voucher_type == "Exchange Gain Or Loss"):
r = [d.user_remark, self.remark]
r = [x for x in r if x]
remarks = "\n".join(r)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": self.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": self.transaction_currency,
"transaction_exchange_rate": self.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if self.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / self.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if self.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / self.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": self.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": self.doctype,
"against_voucher": self.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and self.party_not_required:
frappe.flags.party_not_required = True
gl_map.append(
self.get_gl_dict(
row,
item=d,
)
)
return gl_map
return JournalEntryGLComposer(self).compose()
def make_gl_entries(self, cancel=0, adv_adj=0):
from erpnext.accounts.general_ledger import make_gl_entries
@@ -1292,7 +1213,11 @@ class JournalEntry(AccountsController):
self.validate_total_debit_and_credit()
def get_values(self):
cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else ""
cond = (
f" and outstanding_amount <= {flt(self.write_off_amount)}"
if flt(self.write_off_amount) > 0
else ""
)
if self.write_off_based_on == "Accounts Receivable":
return frappe.db.sql(

View File

@@ -0,0 +1,103 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt
import erpnext
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
from erpnext.accounts.utils import get_advance_payment_doctypes
class JournalEntryGLComposer(BaseGLComposer):
"""Assembles the GL entries for a Journal Entry.
A Journal Entry already carries its ledger rows in the ``accounts`` child
table, so composing is a straight projection of those rows into GL dicts
via ``self.get_gl_dict``. The transaction currency/rate are resolved
from the first foreign-currency row (mirroring the former build_gl_map).
"""
def compose(self):
doc = self.doc
gl_map = []
company_currency = erpnext.get_company_currency(doc.company)
doc.transaction_currency = company_currency
doc.transaction_exchange_rate = 1
if doc.multi_currency:
for row in doc.get("accounts"):
if row.account_currency != company_currency:
# Journal assumes the first foreign currency as transaction currency
doc.transaction_currency = row.account_currency
doc.transaction_exchange_rate = row.exchange_rate
break
advance_doctypes = get_advance_payment_doctypes()
for d in doc.get("accounts"):
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
r = [d.user_remark, doc.remark]
r = [x for x in r if x]
remarks = "\n".join(r)
row = {
"account": d.account,
"party_type": d.party_type,
"due_date": doc.due_date,
"party": d.party,
"against": d.against_account,
"debit": flt(d.debit, d.precision("debit")),
"credit": flt(d.credit, d.precision("credit")),
"account_currency": d.account_currency,
"debit_in_account_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
),
"credit_in_account_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
),
"transaction_currency": doc.transaction_currency,
"transaction_exchange_rate": doc.transaction_exchange_rate,
"debit_in_transaction_currency": flt(
d.debit_in_account_currency, d.precision("debit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
"credit_in_transaction_currency": flt(
d.credit_in_account_currency, d.precision("credit_in_account_currency")
)
if doc.transaction_currency == d.account_currency
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
"against_voucher_type": d.reference_type,
"against_voucher": d.reference_name,
"remarks": remarks,
"voucher_detail_no": d.reference_detail_no,
"cost_center": d.cost_center,
"project": d.project,
"finance_book": doc.finance_book,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
}
if d.reference_type in advance_doctypes:
row.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_type,
"advance_voucher_no": d.reference_name,
}
)
# set flag to skip party validation
account_type = frappe.get_cached_value("Account", d.account, "account_type")
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
frappe.flags.party_not_required = True
gl_map.append(
self.get_gl_dict(
row,
item=d,
)
)
return gl_map

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

@@ -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

@@ -1287,17 +1287,9 @@ class PaymentEntry(AccountsController):
self.transaction_exchange_rate = self.target_exchange_rate
def build_gl_map(self):
if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
self.setup_party_account_field()
self.set_transaction_currency_and_rate()
from erpnext.accounts.doctype.payment_entry.services.gl_composer import PaymentEntryGLComposer
gl_entries = []
self.add_party_gl_entries(gl_entries)
self.add_bank_gl_entries(gl_entries)
self.add_deductions_gl_entries(gl_entries)
self.add_tax_gl_entries(gl_entries)
add_regional_gl_entries(gl_entries, self)
return gl_entries
return PaymentEntryGLComposer(self).compose()
def make_gl_entries(self, cancel=0, adv_adj=0):
gl_entries = self.build_gl_map()
@@ -1313,132 +1305,6 @@ class PaymentEntry(AccountsController):
self.make_advance_gl_entries(cancel=cancel)
def add_party_gl_entries(self, gl_entries):
if not self.party_account:
return
advance_payment_doctypes = get_advance_payment_doctypes()
if self.payment_type == "Receive":
against_account = self.paid_to
else:
against_account = self.paid_from
party_account_type = frappe.db.get_value("Party Type", self.party_type, "account_type")
party_gl_dict = self.get_gl_dict(
{
"account": self.party_account,
"party_type": self.party_type,
"party": self.party,
"against": against_account,
"account_currency": self.party_account_currency,
"cost_center": self.cost_center,
},
item=self,
)
for d in self.get("references"):
# re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
cost_center = self.cost_center
if d.reference_doctype == "Sales Invoice" and not cost_center:
cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
gle = party_gl_dict.copy()
allocated_amount_in_company_currency = self.calculate_base_allocated_amount_for_reference(d)
if (
d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
and d.allocated_amount < 0
and (
(party_account_type == "Receivable" and self.payment_type == "Pay")
or (party_account_type == "Payable" and self.payment_type == "Receive")
)
):
# reversing dr_cr because because it will get reversed in gl processing due to negative amount
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gle.update(
self.get_gl_dict(
{
"account": self.party_account,
"party_type": self.party_type,
"party": self.party,
"against": against_account,
"account_currency": self.party_account_currency,
"cost_center": cost_center,
dr_or_cr + "_in_account_currency": d.allocated_amount,
dr_or_cr: allocated_amount_in_company_currency,
dr_or_cr + "_in_transaction_currency": d.allocated_amount
if self.transaction_currency == self.party_account_currency
else allocated_amount_in_company_currency / self.transaction_exchange_rate,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
"transaction_exchange_rate": self.target_exchange_rate,
},
item=self,
)
)
if d.reference_doctype in advance_payment_doctypes:
# advance reference
gle.update(
{
"against_voucher_type": self.doctype,
"against_voucher": self.name,
"advance_voucher_type": d.reference_doctype,
"advance_voucher_no": d.reference_name,
}
)
elif self.book_advance_payments_in_separate_party_account:
# Do not reference Invoices while Advance is in separate party account
gle.update({"against_voucher_type": self.doctype, "against_voucher": self.name})
else:
gle.update(
{
"against_voucher_type": d.reference_doctype,
"against_voucher": d.reference_name,
}
)
gl_entries.append(gle)
if self.unallocated_amount:
dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
exchange_rate = self.get_exchange_rate()
base_unallocated_amount = self.unallocated_amount * exchange_rate
gle = party_gl_dict.copy()
gle.update(
self.get_gl_dict(
{
"account": self.party_account,
"party_type": self.party_type,
"party": self.party,
"against": against_account,
"account_currency": self.party_account_currency,
"cost_center": self.cost_center,
dr_or_cr + "_in_account_currency": self.unallocated_amount,
dr_or_cr + "_in_transaction_currency": self.unallocated_amount
if self.party_account_currency == self.transaction_currency
else base_unallocated_amount / self.transaction_exchange_rate,
dr_or_cr: base_unallocated_amount,
},
item=self,
)
)
if self.book_advance_payments_in_separate_party_account:
gle.update(
{
"against_voucher_type": "Payment Entry",
"against_voucher": self.name,
}
)
gl_entries.append(gle)
def make_advance_gl_entries(
self, entry: object | dict = None, cancel: bool = 0, update_outstanding: str = "Yes"
):
@@ -1560,132 +1426,6 @@ class PaymentEntry(AccountsController):
)
gl_entries.append(gle)
def add_bank_gl_entries(self, gl_entries):
if self.payment_type in ("Pay", "Internal Transfer"):
gl_entries.append(
self.get_gl_dict(
{
"account": self.paid_from,
"account_currency": self.paid_from_account_currency,
"against": self.party if self.payment_type == "Pay" else self.paid_to,
"credit_in_account_currency": self.paid_amount,
"credit_in_transaction_currency": self.paid_amount
if self.paid_from_account_currency == self.transaction_currency
else self.base_paid_amount / self.transaction_exchange_rate,
"credit": self.base_paid_amount,
"cost_center": self.cost_center,
"post_net_value": True,
},
item=self,
)
)
if self.payment_type in ("Receive", "Internal Transfer"):
gl_entries.append(
self.get_gl_dict(
{
"account": self.paid_to,
"account_currency": self.paid_to_account_currency,
"against": self.party if self.payment_type == "Receive" else self.paid_from,
"debit_in_account_currency": self.received_amount,
"debit_in_transaction_currency": self.received_amount
if self.paid_to_account_currency == self.transaction_currency
else self.base_received_amount / self.transaction_exchange_rate,
"debit": self.base_received_amount,
"cost_center": self.cost_center,
},
item=self,
)
)
def add_tax_gl_entries(self, gl_entries):
for d in self.get("taxes"):
account_currency = get_account_currency(d.account_head)
if account_currency != self.company_currency:
frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, self.company_currency))
if self.payment_type in ("Pay", "Internal Transfer"):
dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
against = self.party or self.paid_from
elif self.payment_type == "Receive":
dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
against = self.party or self.paid_to
payment_account = self.get_party_account_for_taxes()
tax_amount = d.tax_amount
base_tax_amount = d.base_tax_amount
gl_entries.append(
self.get_gl_dict(
{
"account": d.account_head,
"against": against,
dr_or_cr: tax_amount,
dr_or_cr + "_in_account_currency": base_tax_amount
if account_currency == self.company_currency
else d.tax_amount,
dr_or_cr + "_in_transaction_currency": base_tax_amount
/ self.transaction_exchange_rate,
"cost_center": d.cost_center,
"post_net_value": True,
},
account_currency,
item=d,
)
)
if not d.included_in_paid_amount:
if get_account_currency(payment_account) != self.company_currency:
if self.payment_type == "Receive":
exchange_rate = self.target_exchange_rate
elif self.payment_type in ["Pay", "Internal Transfer"]:
exchange_rate = self.source_exchange_rate
base_tax_amount = flt((tax_amount / exchange_rate), self.precision("paid_amount"))
gl_entries.append(
self.get_gl_dict(
{
"account": payment_account,
"against": against,
rev_dr_or_cr: tax_amount,
rev_dr_or_cr + "_in_account_currency": base_tax_amount
if account_currency == self.company_currency
else d.tax_amount,
rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
/ self.transaction_exchange_rate,
"cost_center": self.cost_center,
"post_net_value": True,
},
account_currency,
item=d,
)
)
def add_deductions_gl_entries(self, gl_entries):
for d in self.get("deductions"):
if not d.amount:
continue
account_currency = get_account_currency(d.account)
if account_currency != self.company_currency:
frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency))
gl_entries.append(
self.get_gl_dict(
{
"account": d.account,
"account_currency": account_currency,
"against": self.party or self.paid_from,
"debit_in_account_currency": d.amount,
"debit_in_transaction_currency": d.amount / self.transaction_exchange_rate,
"debit": d.amount,
"cost_center": d.cost_center,
},
item=d,
)
)
def get_party_account_for_taxes(self):
if self.payment_type == "Receive":
return self.paid_to
@@ -3574,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

@@ -0,0 +1,293 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes
class PaymentEntryGLComposer(BaseGLComposer):
"""Assembles the GL entries for a Payment Entry.
The voucher-specific row builders live here and operate on ``self.doc``.
Shared helpers (get_gl_dict, calculate_base_allocated_amount_for_reference,
get_exchange_rate, get_party_account_for_taxes) remain on the document for
now and are invoked via ``self.doc``. The advance-posting builders stay on
the document; they post separately from this compose pass and move with the
advances service in a later phase.
"""
def compose(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import add_regional_gl_entries
doc = self.doc
if doc.payment_type in ("Receive", "Pay") and not doc.get("party_account_field"):
doc.setup_party_account_field()
doc.set_transaction_currency_and_rate()
gl_entries = []
self.add_party_gl_entries(gl_entries)
self.add_bank_gl_entries(gl_entries)
self.add_deductions_gl_entries(gl_entries)
self.add_tax_gl_entries(gl_entries)
add_regional_gl_entries(gl_entries, doc)
return gl_entries
def add_party_gl_entries(self, gl_entries):
doc = self.doc
if not doc.party_account:
return
advance_payment_doctypes = get_advance_payment_doctypes()
if doc.payment_type == "Receive":
against_account = doc.paid_to
else:
against_account = doc.paid_from
party_account_type = frappe.db.get_value("Party Type", doc.party_type, "account_type")
party_gl_dict = self.get_gl_dict(
{
"account": doc.party_account,
"party_type": doc.party_type,
"party": doc.party,
"against": against_account,
"account_currency": doc.party_account_currency,
"cost_center": doc.cost_center,
},
item=doc,
)
for d in doc.get("references"):
# re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit"
cost_center = doc.cost_center
if d.reference_doctype == "Sales Invoice" and not cost_center:
cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
gle = party_gl_dict.copy()
allocated_amount_in_company_currency = doc.calculate_base_allocated_amount_for_reference(d)
if (
d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
and d.allocated_amount < 0
and (
(party_account_type == "Receivable" and doc.payment_type == "Pay")
or (party_account_type == "Payable" and doc.payment_type == "Receive")
)
):
# reversing dr_cr because because it will get reversed in gl processing due to negative amount
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gle.update(
self.get_gl_dict(
{
"account": doc.party_account,
"party_type": doc.party_type,
"party": doc.party,
"against": against_account,
"account_currency": doc.party_account_currency,
"cost_center": cost_center,
dr_or_cr + "_in_account_currency": d.allocated_amount,
dr_or_cr: allocated_amount_in_company_currency,
dr_or_cr + "_in_transaction_currency": d.allocated_amount
if doc.transaction_currency == doc.party_account_currency
else allocated_amount_in_company_currency / doc.transaction_exchange_rate,
"advance_voucher_type": d.advance_voucher_type,
"advance_voucher_no": d.advance_voucher_no,
"transaction_exchange_rate": doc.target_exchange_rate,
},
item=doc,
)
)
if d.reference_doctype in advance_payment_doctypes:
# advance reference
gle.update(
{
"against_voucher_type": doc.doctype,
"against_voucher": doc.name,
"advance_voucher_type": d.reference_doctype,
"advance_voucher_no": d.reference_name,
}
)
elif doc.book_advance_payments_in_separate_party_account:
# Do not reference Invoices while Advance is in separate party account
gle.update({"against_voucher_type": doc.doctype, "against_voucher": doc.name})
else:
gle.update(
{
"against_voucher_type": d.reference_doctype,
"against_voucher": d.reference_name,
}
)
gl_entries.append(gle)
if doc.unallocated_amount:
dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit"
exchange_rate = doc.get_exchange_rate()
base_unallocated_amount = doc.unallocated_amount * exchange_rate
gle = party_gl_dict.copy()
gle.update(
self.get_gl_dict(
{
"account": doc.party_account,
"party_type": doc.party_type,
"party": doc.party,
"against": against_account,
"account_currency": doc.party_account_currency,
"cost_center": doc.cost_center,
dr_or_cr + "_in_account_currency": doc.unallocated_amount,
dr_or_cr + "_in_transaction_currency": doc.unallocated_amount
if doc.party_account_currency == doc.transaction_currency
else base_unallocated_amount / doc.transaction_exchange_rate,
dr_or_cr: base_unallocated_amount,
},
item=doc,
)
)
if doc.book_advance_payments_in_separate_party_account:
gle.update(
{
"against_voucher_type": "Payment Entry",
"against_voucher": doc.name,
}
)
gl_entries.append(gle)
def add_bank_gl_entries(self, gl_entries):
doc = self.doc
if doc.payment_type in ("Pay", "Internal Transfer"):
gl_entries.append(
self.get_gl_dict(
{
"account": doc.paid_from,
"account_currency": doc.paid_from_account_currency,
"against": doc.party if doc.payment_type == "Pay" else doc.paid_to,
"credit_in_account_currency": doc.paid_amount,
"credit_in_transaction_currency": doc.paid_amount
if doc.paid_from_account_currency == doc.transaction_currency
else doc.base_paid_amount / doc.transaction_exchange_rate,
"credit": doc.base_paid_amount,
"cost_center": doc.cost_center,
"post_net_value": True,
},
item=doc,
)
)
if doc.payment_type in ("Receive", "Internal Transfer"):
gl_entries.append(
self.get_gl_dict(
{
"account": doc.paid_to,
"account_currency": doc.paid_to_account_currency,
"against": doc.party if doc.payment_type == "Receive" else doc.paid_from,
"debit_in_account_currency": doc.received_amount,
"debit_in_transaction_currency": doc.received_amount
if doc.paid_to_account_currency == doc.transaction_currency
else doc.base_received_amount / doc.transaction_exchange_rate,
"debit": doc.base_received_amount,
"cost_center": doc.cost_center,
},
item=doc,
)
)
def add_tax_gl_entries(self, gl_entries):
doc = self.doc
for d in doc.get("taxes"):
account_currency = get_account_currency(d.account_head)
if account_currency != doc.company_currency:
frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, doc.company_currency))
if doc.payment_type in ("Pay", "Internal Transfer"):
dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
against = doc.party or doc.paid_from
elif doc.payment_type == "Receive":
dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
against = doc.party or doc.paid_to
payment_account = doc.get_party_account_for_taxes()
tax_amount = d.tax_amount
base_tax_amount = d.base_tax_amount
gl_entries.append(
self.get_gl_dict(
{
"account": d.account_head,
"against": against,
dr_or_cr: tax_amount,
dr_or_cr + "_in_account_currency": base_tax_amount
if account_currency == doc.company_currency
else d.tax_amount,
dr_or_cr + "_in_transaction_currency": base_tax_amount
/ doc.transaction_exchange_rate,
"cost_center": d.cost_center,
"post_net_value": True,
},
account_currency,
item=d,
)
)
if not d.included_in_paid_amount:
if get_account_currency(payment_account) != doc.company_currency:
if doc.payment_type == "Receive":
exchange_rate = doc.target_exchange_rate
elif doc.payment_type in ["Pay", "Internal Transfer"]:
exchange_rate = doc.source_exchange_rate
base_tax_amount = flt((tax_amount / exchange_rate), doc.precision("paid_amount"))
gl_entries.append(
self.get_gl_dict(
{
"account": payment_account,
"against": against,
rev_dr_or_cr: tax_amount,
rev_dr_or_cr + "_in_account_currency": base_tax_amount
if account_currency == doc.company_currency
else d.tax_amount,
rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
/ doc.transaction_exchange_rate,
"cost_center": doc.cost_center,
"post_net_value": True,
},
account_currency,
item=d,
)
)
def add_deductions_gl_entries(self, gl_entries):
doc = self.doc
for d in doc.get("deductions"):
if not d.amount:
continue
account_currency = get_account_currency(d.account)
if account_currency != doc.company_currency:
frappe.throw(_("Currency for {0} must be {1}").format(d.account, doc.company_currency))
gl_entries.append(
self.get_gl_dict(
{
"account": d.account,
"account_currency": account_currency,
"against": doc.party or doc.paid_from,
"debit_in_account_currency": d.amount,
"debit_in_transaction_currency": d.amount / doc.transaction_exchange_rate,
"debit": d.amount,
"cost_center": d.cost_center,
},
item=d,
)
)

View File

@@ -196,7 +196,7 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertEqual(outstanding_amount, 100)
def test_reference_outstanding_amount_on_advance_pull(self):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
so = make_sales_order(qty=1, rate=1000)
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
@@ -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")
@@ -1567,7 +1567,7 @@ class TestPaymentEntry(ERPNextTestSuite):
self.check_pl_entries()
def test_advance_as_liability_against_order(self):
from erpnext.buying.doctype.purchase_order.purchase_order import (
from erpnext.buying.doctype.purchase_order.mapper import (
make_purchase_invoice as _make_purchase_invoice,
)
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
@@ -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

@@ -15,13 +15,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
is_any_doc_running,
)
from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional
from erpnext.accounts.utils import (
QueryPaymentLedger,
create_gain_loss_journal,
get_outstanding_invoices,
reconcile_against_document,
)
from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional
class PaymentReconciliation(Document):

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

@@ -443,7 +443,7 @@ class PaymentRequest(Document):
self.update_reference_advance_payment_status()
def make_invoice(self):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
si = make_sales_invoice(self.reference_name, ignore_permissions=True)
si.allocate_advances_automatically = True

View File

@@ -2,7 +2,9 @@
# See license.txt
import frappe
from frappe.utils import add_days, getdate
from erpnext.controllers.accounts_controller import get_payment_term_details
from erpnext.tests.utils import ERPNextTestSuite
@@ -55,6 +57,52 @@ class TestPaymentTermsTemplate(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, template.insert)
def test_no_discount_date_without_discount(self):
posting_date = "2026-05-29"
term = frappe._dict(
{
"payment_term": "_Test No Discount Term",
"invoice_portion": 100.0,
"due_date_based_on": "Day(s) after invoice date",
"credit_days": 0,
"credit_months": 0,
"discount_type": "Percentage",
"discount": 0,
"discount_validity_based_on": "Day(s) after invoice date",
"discount_validity": 0,
}
)
details = get_payment_term_details(
term, posting_date=posting_date, grand_total=100, base_grand_total=100
)
self.assertEqual(getdate(details.due_date), getdate(posting_date))
self.assertIsNone(details.discount_date)
def test_discount_date_generated_with_discount(self):
posting_date = "2026-05-29"
term = frappe._dict(
{
"payment_term": "_Test Discount Term",
"invoice_portion": 100.0,
"due_date_based_on": "Day(s) after invoice date",
"credit_days": 30,
"credit_months": 0,
"discount_type": "Percentage",
"discount": 5,
"discount_validity_based_on": "Day(s) after invoice date",
"discount_validity": 10,
}
)
details = get_payment_term_details(
term, posting_date=posting_date, grand_total=100, base_grand_total=100
)
self.assertEqual(getdate(details.due_date), getdate(add_days(posting_date, 30)))
self.assertEqual(getdate(details.discount_date), getdate(add_days(posting_date, 10)))
def test_duplicate_terms(self):
template = frappe.get_doc(
{

View File

@@ -330,7 +330,7 @@ class TestPOSClosingEntry(ERPNextTestSuite):
"""
Test Sales Invoice and Return Sales Invoice creation during POS Invoice mode.
"""
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
test_user, pos_profile = init_user_and_profile()

View File

@@ -17,6 +17,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
get_mode_of_payment_info,
update_multi_mode_option,
)
from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyService
from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.controllers.queries import item_query as _item_query
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
@@ -241,13 +242,13 @@ class POSInvoice(SalesInvoice):
def on_submit(self):
# create the loyalty point ledger entry if the customer is enrolled in any loyalty program
if not self.is_return and self.loyalty_program:
self.make_loyalty_point_entry()
LoyaltyService(self).make_loyalty_point_entry()
elif self.is_return and self.return_against and self.loyalty_program:
against_psi_doc = frappe.get_doc("POS Invoice", self.return_against)
against_psi_doc.delete_loyalty_point_entry()
against_psi_doc.make_loyalty_point_entry()
LoyaltyService(against_psi_doc).delete_loyalty_point_entry()
LoyaltyService(against_psi_doc).make_loyalty_point_entry()
if self.redeem_loyalty_points and self.loyalty_points:
self.apply_loyalty_points()
LoyaltyService(self).apply_loyalty_points()
self.check_phone_payments()
self.set_status(update=True)
self.make_bundle_for_sales_purchase_return()
@@ -288,11 +289,11 @@ class POSInvoice(SalesInvoice):
# run on cancel method of selling controller
super(SalesInvoice, self).on_cancel()
if not self.is_return and self.loyalty_program:
self.delete_loyalty_point_entry()
LoyaltyService(self).delete_loyalty_point_entry()
elif self.is_return and self.return_against and self.loyalty_program:
against_psi_doc = frappe.get_doc("POS Invoice", self.return_against)
against_psi_doc.delete_loyalty_point_entry()
against_psi_doc.make_loyalty_point_entry()
LoyaltyService(against_psi_doc).delete_loyalty_point_entry()
LoyaltyService(against_psi_doc).make_loyalty_point_entry()
self.db_set("status", "Cancelled")
@@ -745,7 +746,9 @@ class POSInvoice(SalesInvoice):
# fetch charges
if self.taxes_and_charges and not len(self.get("taxes")):
self.set_taxes()
from erpnext.accounts.services.taxes import TaxService
TaxService(self).set_taxes()
if not self.account_for_change_amount:
self.account_for_change_amount = frappe.get_cached_value(

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

@@ -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

@@ -17,9 +17,13 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestProcessStatementOfAccounts(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
letterhead = frappe.get_doc("Letter Head", "Company Letterhead - Grey")
letterhead.is_default = 0
letterhead.save()
frappe.db.set_value(
"Letter Head",
"Company Letterhead - Grey",
"is_default",
0,
update_modified=False,
)
self.create_company()
self.create_customer()

View File

@@ -0,0 +1,129 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt
from erpnext.controllers.accounts_controller import merge_taxes
@frappe.whitelist()
def make_debit_note(source_name: str, target_doc: str | Document | None = None):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
return make_return_doc("Purchase Invoice", source_name, target_doc)
@frappe.whitelist()
def make_stock_entry(source_name: str, target_doc: str | Document | None = None):
doc = get_mapped_doc(
"Purchase Invoice",
source_name,
{
"Purchase Invoice": {"doctype": "Stock Entry", "validation": {"docstatus": ["=", 1]}},
"Purchase Invoice Item": {
"doctype": "Stock Entry Detail",
"field_map": {"stock_qty": "transfer_qty", "batch_no": "batch_no"},
},
},
target_doc,
)
return doc
@frappe.whitelist()
def make_inter_company_sales_invoice(source_name: str, target_doc: Document | None = None):
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
return make_inter_company_transaction("Purchase Invoice", source_name, target_doc)
@frappe.whitelist()
def make_purchase_receipt(
source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
):
if args is None:
args = {}
if isinstance(args, str):
args = json.loads(args)
def post_parent_process(source_parent, target_parent):
remove_items_with_zero_qty(target_parent)
set_missing_values(source_parent, target_parent)
def remove_items_with_zero_qty(target_parent):
target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0]
def set_missing_values(source_parent, target_parent):
target_parent.run_method("set_missing_values")
if args and args.get("merge_taxes"):
merge_taxes(source_parent, target_parent)
target_parent.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
returned_qty_map = (
get_returned_qty_map_for_row(
source_parent.name, source_parent.supplier, obj.name, "Purchase Invoice"
)
or {}
)
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
obj.conversion_factor
)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
def select_item(d):
filtered_items = args.get("filtered_children", [])
child_filter = d.name in filtered_items if filtered_items else True
return child_filter
doc = get_mapped_doc(
"Purchase Invoice",
source_name,
{
"Purchase Invoice": {
"doctype": "Purchase Receipt",
"validation": {
"docstatus": ["=", 1],
},
},
"Purchase Invoice Item": {
"doctype": "Purchase Receipt Item",
"field_map": {
"name": "purchase_invoice_item",
"parent": "purchase_invoice",
"bom": "bom",
"purchase_order": "purchase_order",
"po_detail": "purchase_order_item",
"material_request": "material_request",
"material_request_item": "material_request_item",
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",
"reset_value": not (args and args.get("merge_taxes")),
"ignore": args.get("merge_taxes") if args else 0,
},
},
target_doc,
post_parent_process,
)
return doc

View File

@@ -156,7 +156,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
__("Purchase Order"),
function () {
erpnext.utils.map_current_doc({
method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_invoice",
source_doctype: "Purchase Order",
target: me.frm,
setters: {
@@ -181,7 +181,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
__("Purchase Receipt"),
function () {
erpnext.utils.map_current_doc({
method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice",
source_doctype: "Purchase Receipt",
target: me.frm,
setters: {
@@ -414,7 +414,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
make_inter_company_invoice(frm) {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_inter_company_sales_invoice",
method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_inter_company_sales_invoice",
frm: frm,
});
}
@@ -474,7 +474,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
make_debit_note() {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_debit_note",
method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_debit_note",
frm: this.frm,
});
}
@@ -591,6 +591,25 @@ frappe.ui.form.on("Purchase Invoice", {
};
});
frm.set_query("write_off_account", function (doc) {
return {
filters: {
report_type: "Profit and Loss",
is_group: 0,
company: doc.company,
},
};
});
frm.set_query("write_off_cost_center", function (doc) {
return {
filters: {
is_group: 0,
company: doc.company,
},
};
});
frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) {
return {
filters: {
@@ -701,7 +720,7 @@ frappe.ui.form.on("Purchase Invoice", {
make_purchase_receipt: function (frm) {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt",
method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_purchase_receipt",
frm: frm,
freeze_message: __("Creating Purchase Receipt ..."),
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,850 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import cint, flt, get_link_to_form
import erpnext
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
from erpnext.accounts.services.taxes import TaxService
from erpnext.accounts.utils import get_account_currency
class PurchaseInvoiceGLComposer(BaseGLComposer):
"""Assembles the GL entries for a Purchase Invoice."""
def compose(self, inventory_account_map=None):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_regional_gl_entries
from erpnext.accounts.general_ledger import merge_similar_entries
doc = self.doc
doc.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company)
if doc.auto_accounting_for_stock:
doc.stock_received_but_not_billed = doc.get_company_default("stock_received_but_not_billed")
else:
doc.stock_received_but_not_billed = None
doc.negative_expense_to_be_booked = 0.0
gl_entries = []
self.make_supplier_gl_entry(gl_entries)
self.make_item_gl_entries(gl_entries)
self.make_precision_loss_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
self.make_gl_entries_for_tax_withholding(gl_entries)
gl_entries = make_regional_gl_entries(gl_entries, doc)
gl_entries = merge_similar_entries(gl_entries)
self.make_payment_gl_entries(gl_entries)
self.make_write_off_gl_entry(gl_entries)
self.make_gle_for_rounding_adjustment(gl_entries)
doc.set_transaction_currency_and_rate_in_gl_map(gl_entries)
doc.set_gl_entry_for_purchase_expense(gl_entries)
return gl_entries
def make_precision_loss_gl_entry(self, gl_entries):
doc = self.doc
(
round_off_account,
round_off_cost_center,
_round_off_for_opening,
) = get_round_off_account_and_cost_center(
doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
)
precision_loss = doc.get("base_net_total") - flt(
doc.get("net_total") * doc.conversion_rate, doc.precision("net_total")
)
if precision_loss:
gl_entries.append(
doc.get_gl_dict(
{
"account": round_off_account,
"against": doc.supplier,
"credit": precision_loss,
"cost_center": round_off_cost_center
if doc.use_company_roundoff_cost_center
else doc.cost_center or round_off_cost_center,
"remarks": _("Net total calculation precision loss"),
}
)
)
def make_supplier_gl_entry(self, gl_entries):
doc = self.doc
grand_total = (
doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total
)
base_grand_total = flt(
doc.base_rounded_total
if (doc.base_rounding_adjustment and doc.base_rounded_total)
else doc.base_grand_total,
doc.precision("base_grand_total"),
)
if grand_total and not doc.is_internal_transfer():
self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total)
def add_supplier_gl_entry(
self,
gl_entries,
base_grand_total,
grand_total,
against_account=None,
remarks=None,
skip_merge=False,
):
doc = self.doc
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
gl = {
"account": doc.credit_to,
"party_type": "Supplier",
"party": doc.supplier,
"due_date": doc.due_date,
"against": against_account or doc.against_expense_account,
"credit": base_grand_total,
"credit_in_account_currency": base_grand_total
if doc.party_account_currency == doc.company_currency
else grand_total,
"credit_in_transaction_currency": grand_total,
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"project": doc.project,
"cost_center": doc.cost_center,
"_skip_merge": skip_merge,
}
if remarks:
gl["remarks"] = remarks
gl_entries.append(self.get_gl_dict(gl, doc.party_account_currency, item=doc))
def make_item_gl_entries(self, gl_entries):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
get_purchase_document_details,
)
doc = self.doc
tax_service = TaxService(doc)
stock_items = doc.get_stock_items()
if doc.update_stock and doc.auto_accounting_for_stock:
inventory_account_map = doc.get_inventory_account_map()
landed_cost_entries = doc.get_item_account_wise_lcv_entries()
voucher_wise_stock_value = {}
if doc.update_stock:
stock_ledger_entries = frappe.get_all(
"Stock Ledger Entry",
fields=["voucher_detail_no", "stock_value_difference", "warehouse"],
filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0},
)
for d in stock_ledger_entries:
voucher_wise_stock_value.setdefault(
(d.voucher_detail_no, d.warehouse), d.stock_value_difference
)
valuation_tax_accounts = [
d.account_head
for d in doc.get("taxes")
if d.category in ("Valuation", "Valuation and Total")
and flt(d.base_tax_amount_after_discount_amount)
]
exchange_rate_map, net_rate_map = get_purchase_document_details(doc)
provisional_accounting_for_non_stock_items = cint(
frappe.get_cached_value(
"Company", doc.company, "enable_provisional_accounting_for_non_stock_items"
)
)
if provisional_accounting_for_non_stock_items:
self.get_provisional_accounts()
adjust_incoming_rate = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
for item in doc.get("items"):
if flt(item.base_net_amount) or (doc.get("update_stock") and item.valuation_rate):
if item.item_code:
frappe.get_cached_value("Item", item.item_code, "asset_category")
if (
doc.update_stock
and doc.auto_accounting_for_stock
and (item.item_code in stock_items or item.is_fixed_asset)
):
account_currency = get_account_currency(item.expense_account)
warehouse_debit_amount = self.make_stock_adjustment_entry(
gl_entries, item, voucher_wise_stock_value, account_currency
)
if item.from_warehouse:
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
_inv_dict_from_warehouse = doc.get_inventory_account_dict(
item, inventory_account_map, "from_warehouse"
)
gl_entries.append(
self.get_gl_dict(
{
"account": _inv_dict["account"],
"against": _inv_dict_from_warehouse["account"],
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"debit": warehouse_debit_amount,
"debit_in_transaction_currency": item.net_amount,
},
_inv_dict["account_currency"],
item=item,
)
)
credit_amount = item.base_net_amount
if doc.is_internal_supplier and item.valuation_rate:
credit_amount = flt(item.valuation_rate * item.stock_qty)
# Intentionally passed negative debit amount to avoid incorrect GL Entry validation
gl_entries.append(
self.get_gl_dict(
{
"account": _inv_dict_from_warehouse["account"],
"against": _inv_dict["account"],
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"debit": -1 * flt(credit_amount, item.precision("base_net_amount")),
"debit_in_transaction_currency": item.net_amount,
},
_inv_dict_from_warehouse["account_currency"],
item=item,
)
)
if not doc.is_internal_transfer():
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": doc.supplier,
"debit": flt(item.base_net_amount, item.precision("base_net_amount")),
"debit_in_transaction_currency": item.net_amount,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
else:
if not doc.is_internal_transfer():
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": doc.supplier,
"debit": warehouse_debit_amount,
"debit_in_transaction_currency": flt(
warehouse_debit_amount / doc.conversion_rate,
item.precision("net_amount"),
),
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
# Amount added through landed-cost-voucher
if landed_cost_entries:
if (item.item_code, item.name) in landed_cost_entries:
for account, base_amount in landed_cost_entries[
(item.item_code, item.name)
].items():
gl_entries.append(
self.get_gl_dict(
{
"account": account,
"against": item.expense_account,
"cost_center": item.cost_center,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(base_amount["base_amount"]),
"credit_in_account_currency": flt(base_amount["amount"]),
"credit_in_transaction_currency": item.net_amount,
"project": item.project or doc.project,
},
item=item,
)
)
# sub-contracting warehouse
if flt(item.rm_supp_cost):
supplier_wh_dict = doc.get_inventory_account_dict(
item, inventory_account_map, "supplier_warehouse"
)
supplier_inventory_account = supplier_wh_dict["account"]
if not supplier_inventory_account:
frappe.throw(
_("Please set account in Warehouse {0}").format(doc.supplier_warehouse)
)
gl_entries.append(
self.get_gl_dict(
{
"account": supplier_inventory_account,
"against": item.expense_account,
"cost_center": item.cost_center,
"project": item.project or doc.project,
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(item.rm_supp_cost),
"credit_in_transaction_currency": item.net_amount,
},
supplier_wh_dict["account_currency"],
item=item,
)
)
else:
expense_account = (
item.expense_account
if (not item.enable_deferred_expense or doc.is_return)
else item.deferred_expense_account
)
account_currency = get_account_currency(expense_account)
amount, base_amount = tax_service.get_amount_and_base_amount(item, None)
if provisional_accounting_for_non_stock_items:
self.make_provisional_gl_entry(gl_entries, item)
if not doc.is_internal_transfer():
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": base_amount,
"debit_in_transaction_currency": amount,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
# check if the exchange rate has changed
if (
not adjust_incoming_rate
and item.get("purchase_receipt")
and doc.auto_accounting_for_stock
):
if (
exchange_rate_map[item.purchase_receipt]
and doc.conversion_rate != exchange_rate_map[item.purchase_receipt]
and item.net_rate == net_rate_map[item.pr_detail]
and item.item_code in stock_items
):
discrepancy_caused_by_exchange_rate_difference = (
item.qty * item.net_rate
) * (exchange_rate_map[item.purchase_receipt] - doc.conversion_rate)
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": discrepancy_caused_by_exchange_rate_difference,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.get_company_default("exchange_gain_loss_account"),
"against": doc.supplier,
"credit": discrepancy_caused_by_exchange_rate_difference,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
if (
doc.auto_accounting_for_stock
and doc.is_opening == "No"
and item.item_code in stock_items
and item.item_tax_amount
):
# Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
if item.purchase_receipt and valuation_tax_accounts:
negative_expense_booked_in_pr = frappe.db.sql(
"""select name from `tabGL Entry`
where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
(item.purchase_receipt, valuation_tax_accounts),
)
(
doc.get_company_default("asset_received_but_not_billed")
if item.is_fixed_asset
else doc.stock_received_but_not_billed
)
if not negative_expense_booked_in_pr:
gl_entries.append(
self.get_gl_dict(
{
"account": doc.stock_received_but_not_billed,
"against": doc.supplier,
"debit": flt(item.item_tax_amount, item.precision("item_tax_amount")),
"debit_in_transaction_currency": flt(
item.item_tax_amount / doc.conversion_rate,
item.precision("item_tax_amount"),
),
"remarks": doc.remarks or _("Accounting Entry for Stock"),
"cost_center": doc.cost_center,
"project": item.project or doc.project,
},
item=item,
)
)
doc.negative_expense_to_be_booked += flt(
item.item_tax_amount, item.precision("item_tax_amount")
)
if item.is_fixed_asset and item.landed_cost_voucher_amount:
self.update_net_purchase_amount_for_linked_assets(item)
def get_provisional_accounts(self):
doc = self.doc
self.provisional_accounts = frappe._dict()
linked_purchase_receipts = {d.purchase_receipt for d in doc.items if d.purchase_receipt}
if not linked_purchase_receipts:
return
pr_items = frappe.get_all(
"Purchase Receipt Item",
filters={"parent": ("in", linked_purchase_receipts)},
fields=["name", "provisional_expense_account", "qty", "base_rate", "rate"],
)
default_provisional_account = doc.get_company_default("default_provisional_account")
provisional_accounts = {
d.provisional_expense_account if d.provisional_expense_account else default_provisional_account
for d in pr_items
}
provisional_gl_entries = frappe.get_all(
"GL Entry",
filters={
"voucher_type": "Purchase Receipt",
"voucher_no": ("in", linked_purchase_receipts),
"account": ("in", provisional_accounts),
"is_cancelled": 0,
},
fields=["voucher_detail_no"],
)
rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries]
for item in pr_items:
self.provisional_accounts[item.name] = {
"provisional_account": item.provisional_expense_account or default_provisional_account,
"qty": item.qty,
"base_rate": item.base_rate,
"rate": item.rate,
"has_provisional_entry": item.name in rows_with_provisional_entries,
}
def make_provisional_gl_entry(self, gl_entries, item):
if item.purchase_receipt:
pr_item = self.provisional_accounts.get(item.pr_detail, {})
if pr_item.get("has_provisional_entry"):
purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt)
# Intentionally passing purchase invoice item to handle partial billing
purchase_receipt_doc.add_provisional_gl_entry(
item,
gl_entries,
self.doc.posting_date,
pr_item.get("provisional_account"),
reverse=1,
item_amount=(
(min(item.qty, pr_item.get("qty")) * pr_item.get("rate"))
* purchase_receipt_doc.get("conversion_rate")
),
)
def update_net_purchase_amount_for_linked_assets(self, item):
doc = self.doc
assets = frappe.db.get_all(
"Asset",
filters={
"purchase_invoice": doc.name,
"item_code": item.item_code,
"purchase_invoice_item": ("in", [item.name, ""]),
},
fields=["name", "asset_quantity"],
)
for asset in assets:
purchase_amount = flt(item.valuation_rate) * asset.asset_quantity
frappe.db.set_value(
"Asset",
asset.name,
{
"net_purchase_amount": purchase_amount,
"purchase_amount": purchase_amount,
},
)
def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
doc = self.doc
net_amt_precision = item.precision("base_net_amount")
val_rate_db_precision = 6 if cint(item.precision("valuation_rate")) <= 6 else 9
warehouse_debit_amount = flt(
flt(item.valuation_rate, val_rate_db_precision) * flt(item.qty) * flt(item.conversion_factor),
net_amt_precision,
)
if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
net_rate = item.base_net_amount
if item.sales_incoming_rate:
net_rate = item.qty * item.sales_incoming_rate
stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
warehouse_debit_amount = flt(
voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
self.get_gl_dict(
{
"account": cost_of_goods_sold_account,
"against": item.expense_account,
"debit": stock_adjustment_amt,
"debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
"remarks": doc.get("remarks") or _("Stock Adjustment"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
elif (
doc.update_stock
and voucher_wise_stock_value.get((item.name, item.warehouse))
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
stock_adjustment_amt = warehouse_debit_amount - stock_amount
gl_entries.append(
self.get_gl_dict(
{
"account": cost_of_goods_sold_account,
"against": item.expense_account,
"debit": stock_adjustment_amt,
"debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
"remarks": doc.get("remarks") or _("Stock Adjustment"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
warehouse_debit_amount = stock_amount
return warehouse_debit_amount
def make_tax_gl_entries(self, gl_entries):
doc = self.doc
tax_service = TaxService(doc)
valuation_tax = {}
for tax in doc.get("taxes"):
amount, base_amount = tax_service.get_tax_amounts(tax, None)
if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
account_currency = get_account_currency(tax.account_head)
dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit"
gl_entries.append(
self.get_gl_dict(
{
"account": tax.account_head,
"against": doc.supplier,
dr_or_cr: base_amount,
dr_or_cr + "_in_account_currency": base_amount
if account_currency == doc.company_currency
else amount,
dr_or_cr + "_in_transaction_currency": amount,
"cost_center": tax.cost_center,
},
account_currency,
item=tax,
)
)
if (
doc.is_opening == "No"
and tax.category in ("Valuation", "Valuation and Total")
and flt(base_amount)
and not doc.is_internal_transfer()
):
if doc.auto_accounting_for_stock and not tax.cost_center:
frappe.throw(
_("Cost Center is required in row {0} in Taxes table for type {1}").format(
tax.idx, _(tax.category)
)
)
valuation_tax.setdefault(tax.name, 0)
valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax:
total_valuation_amount = sum(valuation_tax.values())
amount_including_divisional_loss = doc.negative_expense_to_be_booked
i = 1
for tax in doc.get("taxes"):
if valuation_tax.get(tax.name):
if i == len(valuation_tax):
applicable_amount = amount_including_divisional_loss
else:
applicable_amount = doc.negative_expense_to_be_booked * (
valuation_tax[tax.name] / total_valuation_amount
)
amount_including_divisional_loss -= applicable_amount
gl_entries.append(
self.get_gl_dict(
{
"account": tax.account_head,
"cost_center": tax.cost_center,
"against": doc.supplier,
"credit": applicable_amount,
"credit_in_transaction_currency": flt(
applicable_amount / doc.conversion_rate,
frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
),
"remarks": doc.remarks or _("Accounting Entry for Stock"),
},
item=tax,
)
)
i += 1
if doc.auto_accounting_for_stock and doc.update_stock and valuation_tax:
for tax in doc.get("taxes"):
if valuation_tax.get(tax.name):
gl_entries.append(
self.get_gl_dict(
{
"account": tax.account_head,
"cost_center": tax.cost_center,
"against": doc.supplier,
"credit": valuation_tax[tax.name],
"credit_in_transaction_currency": flt(
valuation_tax[tax.name] / doc.conversion_rate,
frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
),
"remarks": doc.remarks or _("Accounting Entry for Stock"),
},
item=tax,
)
)
def make_internal_transfer_gl_entries(self, gl_entries):
doc = self.doc
if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges):
account_currency = get_account_currency(doc.unrealized_profit_loss_account)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.unrealized_profit_loss_account,
"against": doc.supplier,
"credit": flt(doc.total_taxes_and_charges),
"credit_in_transaction_currency": flt(doc.total_taxes_and_charges),
"credit_in_account_currency": flt(doc.base_total_taxes_and_charges),
"cost_center": doc.cost_center,
},
account_currency,
item=doc,
)
)
def make_gl_entries_for_tax_withholding(self, gl_entries):
"""Separate supplier GL entry for tax withholding (TDS) — not part of the supplier invoice amount."""
doc = self.doc
if not doc.apply_tds:
return
for row in doc.get("taxes"):
if not row.is_tax_withholding_account or not row.tax_amount:
continue
base_tds_amount = row.base_tax_amount_after_discount_amount
tds_amount = row.tax_amount_after_discount_amount
self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount)
self.add_supplier_gl_entry(
gl_entries,
-base_tds_amount,
-tds_amount,
against_account=row.account_head,
remarks=_("TDS Deducted"),
skip_merge=True,
)
def make_payment_gl_entries(self, gl_entries):
doc = self.doc
if cint(doc.is_paid) and doc.cash_bank_account and doc.paid_amount:
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
bank_account_currency = get_account_currency(doc.cash_bank_account)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.credit_to,
"party_type": "Supplier",
"party": doc.supplier,
"against": doc.cash_bank_account,
"debit": doc.base_paid_amount,
"debit_in_account_currency": doc.base_paid_amount
if doc.party_account_currency == doc.company_currency
else doc.paid_amount,
"debit_in_transaction_currency": doc.paid_amount,
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
},
doc.party_account_currency,
item=doc,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.cash_bank_account,
"against": doc.supplier,
"credit": doc.base_paid_amount,
"credit_in_account_currency": doc.base_paid_amount
if bank_account_currency == doc.company_currency
else doc.paid_amount,
"credit_in_transaction_currency": doc.paid_amount,
"cost_center": doc.cost_center,
},
bank_account_currency,
item=doc,
)
)
def make_write_off_gl_entry(self, gl_entries):
doc = self.doc
if doc.write_off_account and flt(doc.write_off_amount):
write_off_account_currency = get_account_currency(doc.write_off_account)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.credit_to,
"party_type": "Supplier",
"party": doc.supplier,
"against": doc.write_off_account,
"debit": doc.base_write_off_amount,
"debit_in_account_currency": doc.base_write_off_amount
if doc.party_account_currency == doc.company_currency
else doc.write_off_amount,
"debit_in_transaction_currency": doc.write_off_amount,
"against_voucher": doc.return_against
if cint(doc.is_return) and doc.return_against
else doc.name,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
},
doc.party_account_currency,
item=doc,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.write_off_account,
"against": doc.supplier,
"credit": flt(doc.base_write_off_amount),
"credit_in_account_currency": doc.base_write_off_amount
if write_off_account_currency == doc.company_currency
else doc.write_off_amount,
"credit_in_transaction_currency": doc.write_off_amount,
"cost_center": doc.cost_center or doc.write_off_cost_center,
},
item=doc,
)
)
def make_gle_for_rounding_adjustment(self, gl_entries):
doc = self.doc
if not doc.is_internal_transfer() and doc.rounding_adjustment and doc.base_rounding_adjustment:
(
round_off_account,
round_off_cost_center,
round_off_for_opening,
) = get_round_off_account_and_cost_center(
doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
)
if doc.is_opening == "Yes" and doc.rounding_adjustment:
if not round_off_for_opening:
frappe.throw(
_(
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
).format(
frappe.bold(doc.rounding_adjustment),
frappe.bold("Round Off for Opening"),
get_link_to_form("Company", doc.company),
frappe.bold("Disable Rounded Total"),
)
)
else:
round_off_account = round_off_for_opening
gl_entries.append(
self.get_gl_dict(
{
"account": round_off_account,
"against": doc.supplier,
"debit_in_account_currency": doc.rounding_adjustment,
"debit": doc.base_rounding_adjustment,
"cost_center": round_off_cost_center
if doc.use_company_roundoff_cost_center
else (doc.cost_center or round_off_cost_center),
},
item=doc,
)
)

View File

@@ -8,8 +8,8 @@ from frappe.utils import add_days, cint, flt, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice as make_pi_from_po
from erpnext.buying.doctype.purchase_order.mapper import get_mapped_purchase_invoice
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice as make_pi_from_po
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
create_pr_against_po,
create_purchase_order,
@@ -20,9 +20,9 @@ from erpnext.controllers.buying_controller import QtyMismatchError
from erpnext.exceptions import InvalidCurrency
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.material_request.material_request import make_purchase_order
from erpnext.stock.doctype.material_request.mapper import make_purchase_order
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as create_purchase_invoice_from_receipt,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
@@ -80,7 +80,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.delete()
def test_update_received_qty_in_material_request(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
"""
Test if the received_qty in Material Request is updated correctly when
@@ -346,7 +346,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
def test_purchase_invoice_with_exchange_rate_difference(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as create_purchase_invoice,
)
@@ -388,7 +388,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as create_purchase_invoice,
)
@@ -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
@@ -2162,7 +2162,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
create_pr_against_po,
create_purchase_order,
)
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_pi_from_pr,
)
@@ -2748,10 +2748,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
def test_invoice_against_returned_pr(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_purchase_invoice_from_pr,
)
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return_against_rejected_warehouse,
)
@@ -2892,7 +2892,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertEqual(invoice.grand_total, 300)
def test_pr_pi_over_billing(self):
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_purchase_invoice_from_pr,
)
@@ -2940,7 +2940,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertEqual(pi.discount_amount, discount_amount)
def test_returned_item_purchase_receipt(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
from erpnext.accounts.doctype.purchase_invoice.mapper import (
make_purchase_receipt as make_purchase_receipt_from_pi,
)

View File

@@ -0,0 +1,615 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_address_display
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.utils import flt, get_link_to_form, getdate
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, _get_party_details
@frappe.whitelist()
def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None):
doclist = get_mapped_doc(
"Sales Invoice",
source_name,
{
"Sales Invoice": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}},
"Sales Invoice Item": {
"doctype": "Maintenance Schedule Item",
},
},
target_doc,
)
return doclist
@frappe.whitelist()
def make_delivery_note(source_name: str, target_doc: Document | None = None):
def set_missing_values(source, target):
target.run_method("set_missing_values")
target.run_method("set_po_nos")
target.run_method("calculate_taxes_and_totals")
def update_item(source_doc, target_doc, source_parent):
target_doc.qty = flt(source_doc.qty) - flt(source_doc.delivered_qty)
target_doc.stock_qty = target_doc.qty * flt(source_doc.conversion_factor)
target_doc.base_amount = target_doc.qty * flt(source_doc.base_rate)
target_doc.amount = target_doc.qty * flt(source_doc.rate)
doclist = get_mapped_doc(
"Sales Invoice",
source_name,
{
"Sales Invoice": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
"Sales Invoice Item": {
"doctype": "Delivery Note Item",
"field_map": {
"name": "si_detail",
"parent": "against_sales_invoice",
"serial_no": "serial_no",
"sales_order": "against_sales_order",
"so_detail": "so_detail",
"cost_center": "cost_center",
},
"postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1
and not doc.scio_detail
and not doc.dn_detail
and doc.qty - doc.delivered_qty > 0,
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {
"doctype": "Sales Team",
"field_map": {"incentives": "incentives"},
"add_if_empty": True,
},
},
target_doc,
set_missing_values,
)
return doclist
@frappe.whitelist()
def make_sales_return(source_name: str, target_doc: Document | None = None):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
return make_return_doc("Sales Invoice", source_name, target_doc)
def get_inter_company_details(doc, doctype):
if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]:
parties = frappe.db.get_all(
"Supplier",
fields=["name"],
filters={"disabled": 0, "is_internal_supplier": 1, "represents_company": doc.company},
)
company = frappe.get_cached_value("Customer", doc.customer, "represents_company")
if not parties:
frappe.throw(
_("No Supplier found for Inter Company Transactions which represents company {0}").format(
frappe.bold(doc.company)
)
)
party = get_internal_party(parties, "Supplier", doc)
else:
parties = frappe.db.get_all(
"Customer",
fields=["name"],
filters={"disabled": 0, "is_internal_customer": 1, "represents_company": doc.company},
)
company = frappe.get_cached_value("Supplier", doc.supplier, "represents_company")
if not parties:
frappe.throw(
_("No Customer found for Inter Company Transactions which represents company {0}").format(
frappe.bold(doc.company)
)
)
party = get_internal_party(parties, "Customer", doc)
return {"party": party, "company": company}
def get_internal_party(parties, link_doctype, doc):
if len(parties) == 1:
party = parties[0].name
else:
# If more than one Internal Supplier/Customer, get supplier/customer on basis of address
if doc.get("company_address") or doc.get("shipping_address"):
party = frappe.db.get_value(
"Dynamic Link",
{
"parent": doc.get("company_address") or doc.get("shipping_address"),
"parenttype": "Address",
"link_doctype": link_doctype,
},
"link_name",
)
if not party:
party = parties[0].name
else:
party = parties[0].name
return party
def validate_inter_company_transaction(doc, doctype):
details = get_inter_company_details(doc, doctype)
price_list = (
doc.selling_price_list
if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]
else doc.buying_price_list
)
valid_price_list = frappe.db.get_value("Price List", {"name": price_list, "buying": 1, "selling": 1})
if not valid_price_list and not doc.is_internal_transfer():
frappe.throw(_("Selected Price List should have buying and selling fields checked."))
party = details.get("party")
if not party:
partytype = "Supplier" if doctype in ["Sales Invoice", "Sales Order"] else "Customer"
frappe.throw(_("No {0} found for Inter Company Transactions.").format(partytype))
company = details.get("company")
default_currency = frappe.get_cached_value("Company", company, "default_currency")
if default_currency != doc.currency:
frappe.throw(
_("Company currencies of both the companies should match for Inter Company Transactions.")
)
return
@frappe.whitelist()
def make_inter_company_purchase_invoice(source_name: str, target_doc: Document | None = None):
return make_inter_company_transaction("Sales Invoice", source_name, target_doc)
def make_inter_company_transaction(doctype, source_name, target_doc=None):
if doctype in ["Sales Invoice", "Sales Order"]:
source_doc = frappe.get_doc(doctype, source_name)
target_doctype = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
target_detail_field = "sales_invoice_item" if doctype == "Sales Invoice" else "sales_order_item"
source_document_warehouse_field = "target_warehouse"
target_document_warehouse_field = "from_warehouse"
received_items = get_received_items(source_name, target_doctype, target_detail_field)
else:
source_doc = frappe.get_doc(doctype, source_name)
target_doctype = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
source_document_warehouse_field = "from_warehouse"
target_document_warehouse_field = "target_warehouse"
received_items = {}
validate_inter_company_transaction(source_doc, doctype)
details = get_inter_company_details(source_doc, doctype)
def set_missing_values(source, target):
target.run_method("set_missing_values")
set_purchase_references(target)
def update_details(source_doc, target_doc, source_parent):
def _validate_address_link(address, link_doctype, link_name):
return frappe.db.get_value(
"Dynamic Link",
{
"parent": address,
"parenttype": "Address",
"link_doctype": link_doctype,
"link_name": link_name,
},
"parent",
)
target_doc.inter_company_invoice_reference = source_doc.name
if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.supplier = details.get("party")
target_doc.is_internal_supplier = 1
target_doc.ignore_pricing_rule = 1
target_doc.buying_price_list = source_doc.selling_price_list
# Invert Addresses
if source_doc.company_address and _validate_address_link(
source_doc.company_address, "Supplier", details.get("party")
):
update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
if source_doc.dispatch_address_name and _validate_address_link(
source_doc.dispatch_address_name, "Company", details.get("company")
):
update_address(
target_doc,
"dispatch_address",
"dispatch_address_display",
source_doc.dispatch_address_name,
)
if source_doc.shipping_address_name and _validate_address_link(
source_doc.shipping_address_name, "Company", details.get("company")
):
update_address(
target_doc,
"shipping_address",
"shipping_address_display",
source_doc.shipping_address_name,
)
if source_doc.customer_address and _validate_address_link(
source_doc.customer_address, "Company", details.get("company")
):
update_address(
target_doc, "billing_address", "billing_address_display", source_doc.customer_address
)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.supplier,
party_type="Supplier",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.supplier_address,
company_address=target_doc.shipping_address,
)
else:
currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
target_doc.company = details.get("company")
target_doc.customer = details.get("party")
target_doc.selling_price_list = source_doc.buying_price_list
if source_doc.supplier_address and _validate_address_link(
source_doc.supplier_address, "Company", details.get("company")
):
update_address(
target_doc, "company_address", "company_address_display", source_doc.supplier_address
)
if source_doc.shipping_address and _validate_address_link(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(
target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
)
if source_doc.shipping_address and _validate_address_link(
source_doc.shipping_address, "Customer", details.get("party")
):
update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
if currency:
target_doc.currency = currency
update_taxes(
target_doc,
party=target_doc.customer,
party_type="Customer",
company=target_doc.company,
doctype=target_doc.doctype,
party_address=target_doc.customer_address,
company_address=target_doc.company_address,
shipping_address_name=target_doc.shipping_address_name,
)
def update_item(source, target, source_parent):
target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
if source.doctype == "Purchase Order Item" and target.doctype == "Sales Order Item":
target.purchase_order = source.parent
target.purchase_order_item = source.name
target.material_request = source.material_request
target.material_request_item = source.material_request_item
if (
source.get("purchase_order")
and source.get("purchase_order_item")
and target.doctype == "Purchase Invoice Item"
):
target.purchase_order = source.purchase_order
target.po_detail = source.purchase_order_item
if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"):
target.use_serial_batch_fields = 1
item_field_map = {
"doctype": target_doctype + " Item",
"field_no_map": ["income_account", "expense_account", "cost_center", "warehouse"],
"field_map": {
"rate": "rate",
},
"postprocess": update_item,
"condition": lambda doc: doc.qty > 0,
}
if doctype in ["Sales Invoice", "Sales Order"]:
item_field_map["field_map"].update(
{
"name": target_detail_field,
}
)
if source_doc.get("update_stock"):
item_field_map["field_map"].update(
{
source_document_warehouse_field: target_document_warehouse_field,
"batch_no": "batch_no",
"serial_no": "serial_no",
}
)
elif target_doctype == "Sales Order":
item_field_map["field_map"].update(
{
source_document_warehouse_field: "warehouse",
}
)
doclist = get_mapped_doc(
doctype,
source_name,
{
doctype: {
"doctype": target_doctype,
"postprocess": update_details,
"set_target_warehouse": "set_from_warehouse",
"field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"],
},
doctype + " Item": item_field_map,
},
target_doc,
set_missing_values,
)
return doclist
def get_received_items(reference_name, doctype, reference_fieldname):
reference_field = "inter_company_invoice_reference"
if doctype == "Purchase Order":
reference_field = "inter_company_order_reference"
filters = {
reference_field: reference_name,
"docstatus": 1,
}
target_doctypes = frappe.get_all(
doctype,
filters=filters,
as_list=True,
)
if target_doctypes:
target_doctypes = list(target_doctypes[0])
received_items_map = frappe._dict(
frappe.get_all(
doctype + " Item",
filters={"parent": ("in", target_doctypes)},
fields=[reference_fieldname, "qty"],
as_list=1,
)
)
return received_items_map
def set_purchase_references(doc):
# add internal PO or PR links if any
if doc.is_internal_transfer():
if doc.doctype == "Purchase Receipt":
so_item_map = get_delivery_note_details(doc.inter_company_invoice_reference)
if so_item_map:
pd_item_map, parent_child_map, warehouse_map = get_pd_details(
"Purchase Order Item", so_item_map, "sales_order_item"
)
update_pr_items(doc, so_item_map, pd_item_map, parent_child_map, warehouse_map)
elif doc.doctype == "Purchase Invoice":
dn_item_map, so_item_map = get_sales_invoice_details(doc.inter_company_invoice_reference)
# First check for Purchase receipt
if list(dn_item_map.values()):
pd_item_map, parent_child_map, warehouse_map = get_pd_details(
"Purchase Receipt Item", dn_item_map, "delivery_note_item"
)
update_pi_items(
doc,
"pr_detail",
"purchase_receipt",
dn_item_map,
pd_item_map,
parent_child_map,
warehouse_map,
)
def update_pi_items(
doc,
detail_field,
parent_field,
sales_item_map,
purchase_item_map,
parent_child_map,
warehouse_map,
):
for item in doc.get("items"):
item.set(detail_field, purchase_item_map.get(sales_item_map.get(item.sales_invoice_item)))
item.set(parent_field, parent_child_map.get(sales_item_map.get(item.sales_invoice_item)))
if doc.update_stock:
item.warehouse = warehouse_map.get(sales_item_map.get(item.sales_invoice_item))
if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
item.warehouse = frappe.db.get_value(
"Purchase Order Item", item.purchase_order_item, "warehouse"
)
def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, warehouse_map):
for item in doc.get("items"):
item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item))
if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
item.warehouse = frappe.db.get_value("Purchase Order Item", item.purchase_order_item, "warehouse")
def get_delivery_note_details(internal_reference):
si_item_details = frappe.get_all(
"Delivery Note Item", fields=["name", "so_detail"], filters={"parent": internal_reference}
)
return {d.name: d.so_detail for d in si_item_details if d.so_detail}
def get_sales_invoice_details(internal_reference):
dn_item_map = {}
so_item_map = {}
si_item_details = frappe.get_all(
"Sales Invoice Item",
fields=["name", "so_detail", "dn_detail"],
filters={"parent": internal_reference},
)
for d in si_item_details:
if d.dn_detail:
dn_item_map.setdefault(d.name, d.dn_detail)
if d.so_detail:
so_item_map.setdefault(d.name, d.so_detail)
return dn_item_map, so_item_map
def get_pd_details(doctype, sd_detail_map, sd_detail_field):
pd_item_map = {}
accepted_warehouse_map = {}
parent_child_map = {}
pd_item_details = frappe.get_all(
doctype,
fields=[sd_detail_field, "name", "warehouse", "parent"],
filters={sd_detail_field: ("in", list(sd_detail_map.values()))},
)
for d in pd_item_details:
pd_item_map.setdefault(d.get(sd_detail_field), d.name)
parent_child_map.setdefault(d.get(sd_detail_field), d.parent)
accepted_warehouse_map.setdefault(d.get(sd_detail_field), d.warehouse)
return pd_item_map, parent_child_map, accepted_warehouse_map
def update_taxes(
doc,
party=None,
party_type=None,
company=None,
doctype=None,
party_address=None,
company_address=None,
shipping_address_name=None,
master_doctype=None,
):
# Update Party Details
party_details = _get_party_details(
party=party,
party_type=party_type,
company=company,
doctype=doctype,
party_address=party_address,
company_address=company_address,
shipping_address=shipping_address_name,
)
# Update taxes and charges if any
doc.taxes_and_charges = party_details.get("taxes_and_charges")
doc.set("taxes", party_details.get("taxes"))
def update_address(doc, address_field, address_display_field, address_name):
doc.set(address_field, address_name)
fetch_values = get_fetch_values(doc.doctype, address_field, address_name)
for key, value in fetch_values.items():
doc.set(key, value)
doc.set(address_display_field, get_address_display(doc.get(address_field)))
@frappe.whitelist()
def create_invoice_discounting(source_name: str, target_doc: str | Document | None = None):
invoice = frappe.get_doc("Sales Invoice", source_name)
invoice_discounting = frappe.new_doc("Invoice Discounting")
invoice_discounting.company = invoice.company
invoice_discounting.append(
"invoices",
{
"sales_invoice": source_name,
"customer": invoice.customer,
"posting_date": invoice.posting_date,
"outstanding_amount": invoice.outstanding_amount,
},
)
return invoice_discounting
@frappe.whitelist()
def create_dunning(
source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False
):
def postprocess_dunning(source, target):
from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
if dunning_type:
dunning_type = frappe.get_doc("Dunning Type", dunning_type)
target.dunning_type = dunning_type.name
target.rate_of_interest = dunning_type.rate_of_interest
target.dunning_fee = dunning_type.dunning_fee
target.income_account = dunning_type.income_account
target.cost_center = dunning_type.cost_center
letter_text = get_dunning_letter_text(
dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
)
if letter_text:
target.body_text = letter_text.get("body_text")
target.closing_text = letter_text.get("closing_text")
target.language = letter_text.get("language")
# update outstanding from doc
if source.payment_schedule and len(source.payment_schedule) == 1:
for row in target.overdue_payments:
if row.payment_schedule == source.payment_schedule[0].name:
row.outstanding = source.get("outstanding_amount")
target.validate()
return get_mapped_doc(
from_doctype="Sales Invoice",
from_docname=source_name,
target_doc=target_doc,
table_maps={
"Sales Invoice": {
"doctype": "Dunning",
"field_map": {"customer_address": "customer_address", "parent": "sales_invoice"},
},
"Payment Schedule": {
"doctype": "Overdue Payment",
"field_map": {"name": "payment_schedule", "parent": "sales_invoice"},
"condition": lambda doc: doc.outstanding > 0 and getdate(doc.due_date) < getdate(),
},
},
postprocess=postprocess_dunning,
ignore_permissions=ignore_permissions,
)

View File

@@ -197,21 +197,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
make_invoice_discounting() {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_invoice_discounting",
method: "erpnext.accounts.doctype.sales_invoice.mapper.create_invoice_discounting",
frm: this.frm,
});
}
make_dunning() {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning",
frm: this.frm,
});
}
make_maintenance_schedule() {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule",
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_maintenance_schedule",
frm: this.frm,
});
}
@@ -361,7 +361,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
__("Sales Order"),
function () {
erpnext.utils.map_current_doc({
method: "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
method: "erpnext.selling.doctype.sales_order.mapper.make_sales_invoice",
source_doctype: "Sales Order",
target: me.frm,
setters: {
@@ -383,7 +383,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
__("Quotation"),
function () {
erpnext.utils.map_current_doc({
method: "erpnext.selling.doctype.quotation.quotation.make_sales_invoice",
method: "erpnext.selling.doctype.quotation.mapper.make_sales_invoice",
source_doctype: "Quotation",
target: me.frm,
setters: [
@@ -421,7 +421,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
});
}
erpnext.utils.map_current_doc({
method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
source_doctype: "Delivery Note",
target: me.frm,
date_field: "posting_date",
@@ -501,7 +501,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
make_inter_company_invoice() {
let me = this;
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_inter_company_purchase_invoice",
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_inter_company_purchase_invoice",
frm: me.frm,
});
}
@@ -579,7 +579,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
make_sales_return() {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return",
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_sales_return",
frm: this.frm,
});
}
@@ -712,7 +712,7 @@ extend_cscript(cur_frm.cscript, new erpnext.accounts.SalesInvoiceController({ fr
cur_frm.cscript["Make Delivery Note"] = function () {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_delivery_note",
method: "erpnext.accounts.doctype.sales_invoice.mapper.make_delivery_note",
frm: cur_frm,
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Fixed asset lifecycle helpers for Sales Invoice."""
import frappe
from frappe import _
from frappe.utils import flt, get_link_to_form
from erpnext.assets.doctype.asset.depreciation import (
depreciate_asset,
reset_depreciation_schedule,
reverse_depreciation_entry_made_on_disposal,
)
from erpnext.assets.doctype.asset.mapper import split_asset
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
class FixedAssetService:
def __init__(self, doc):
self.doc = doc
def validate_fixed_asset(self) -> None:
doc = self.doc
if doc.doctype != "Sales Invoice":
return
for d in doc.get("items"):
if not d.is_fixed_asset:
continue
if d.asset:
if not doc.is_return:
asset_status = frappe.db.get_value("Asset", d.asset, "status")
if doc.update_stock:
frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
frappe.throw(
_("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
d.idx, d.asset, asset_status
)
)
elif asset_status == "Sold" and not doc.is_return:
frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
elif not doc.return_against:
frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(d.idx))
else:
frappe.throw(
_("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
title=_("Missing Asset"),
)
def set_income_account_for_fixed_assets(self) -> None:
for item in self.doc.items:
item.set_income_account_for_fixed_asset(self.doc.company)
def process_asset_depreciation(self) -> None:
doc = self.doc
if doc.is_internal_transfer():
return
if (doc.is_return and doc.docstatus == 2) or (not doc.is_return and doc.docstatus == 1):
self._depreciate_asset_on_sale()
else:
self._restore_asset()
self._update_asset()
def split_asset_based_on_sale_qty(self) -> None:
asset_qty_map = self._get_asset_qty()
for asset, qty in asset_qty_map.items():
if qty["actual_qty"] < qty["sale_qty"]:
frappe.throw(
_(
"Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)."
).format(asset, qty["actual_qty"])
)
remaining_qty = qty["actual_qty"] - qty["sale_qty"]
if remaining_qty > 0:
split_asset(asset, remaining_qty)
def get_disposal_date(self) -> str:
doc = self.doc
if doc.is_return:
return frappe.db.get_value("Sales Invoice", doc.return_against, "posting_date")
return doc.posting_date
def _depreciate_asset_on_sale(self) -> None:
disposal_date = self.get_disposal_date()
for d in self.doc.get("items"):
if d.asset:
asset = frappe.get_doc("Asset", d.asset)
if asset.calculate_depreciation and asset.status != "Fully Depreciated":
depreciate_asset(asset, disposal_date, self._get_note_for_asset_sale(asset))
def _restore_asset(self) -> None:
for d in self.doc.get("items"):
if d.asset:
asset = frappe.get_cached_doc("Asset", d.asset)
if asset.calculate_depreciation:
reverse_depreciation_entry_made_on_disposal(asset)
reset_depreciation_schedule(asset, self._get_note_for_asset_return(asset))
def _update_asset(self) -> None:
doc = self.doc
disposal_date = self.get_disposal_date()
for d in doc.get("items"):
if not d.asset:
continue
asset = frappe.get_cached_doc("Asset", d.asset)
if (doc.is_return and doc.docstatus == 1) or (not doc.is_return and doc.docstatus == 2):
note = _("Asset returned") if doc.is_return else _("Asset sold")
asset_status, disposal_date = None, None
else:
note = _("Asset sold") if not doc.is_return else _("Return invoice of asset cancelled")
asset_status = "Sold"
frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date)
add_asset_activity(asset.name, note)
asset.set_status(asset_status)
def _get_asset_qty(self) -> dict:
doc = self.doc
asset_qty_map = {}
assets = {row.asset for row in doc.items if row.is_fixed_asset and row.asset}
if not assets or doc.is_return:
return asset_qty_map
asset_actual_qty = dict(
frappe.db.get_all(
"Asset",
{"name": ["in", list(assets)]},
["name", "asset_quantity"],
as_list=True,
)
)
for row in doc.items:
if row.is_fixed_asset and row.asset:
actual_qty = asset_actual_qty.get(row.asset)
if row.asset in asset_qty_map:
asset_qty_map[row.asset]["sale_qty"] += flt(row.qty)
else:
asset_qty_map[row.asset] = {
"sale_qty": flt(row.qty),
"actual_qty": flt(actual_qty),
}
return asset_qty_map
def _get_note_for_asset_sale(self, asset) -> str:
doc = self.doc
return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format(
get_link_to_form(asset.doctype, asset.name),
_("returned") if doc.is_return else _("sold"),
get_link_to_form(doc.doctype, doc.get("name")),
)
def _get_note_for_asset_return(self, asset) -> str:
doc = self.doc
asset_link = get_link_to_form(asset.doctype, asset.name)
invoice_link = get_link_to_form(doc.doctype, doc.get("name"))
if doc.is_return:
return _(
"This schedule was created when Asset {0} was returned through Sales Invoice {1}."
).format(asset_link, invoice_link)
return _(
"This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation."
).format(asset_link, invoice_link)

View File

@@ -0,0 +1,661 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe import _
from frappe.utils import cint, cstr, flt, get_link_to_form
import erpnext
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.services.base_gl_composer import BaseGLComposer
from erpnext.accounts.services.taxes import TaxService
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.depreciation import (
get_gl_entries_on_asset_disposal,
get_gl_entries_on_asset_regain,
)
class SalesInvoiceGLComposer(BaseGLComposer):
"""Assembles the GL entries for a Sales Invoice."""
def compose(self, inventory_account_map=None):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_regional_gl_entries
from erpnext.accounts.general_ledger import merge_similar_entries
doc = self.doc
gl_entries = []
self.make_customer_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
self.make_item_gl_entries(gl_entries)
disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
if not (doc.is_return and disable_sdbnb_in_sr):
self.stock_delivered_but_not_billed_gl_entries(gl_entries)
self.make_precision_loss_gl_entry(gl_entries)
self.make_discount_gl_entries(gl_entries)
gl_entries = make_regional_gl_entries(gl_entries, doc)
# merge gl entries before adding pos entries
gl_entries = merge_similar_entries(gl_entries)
self.make_loyalty_point_redemption_gle(gl_entries)
self.make_pos_gl_entries(gl_entries)
self.make_write_off_gl_entry(gl_entries)
self.make_gle_for_rounding_adjustment(gl_entries)
doc.set_transaction_currency_and_rate_in_gl_map(gl_entries)
return gl_entries
def make_precision_loss_gl_entry(self, gl_entries):
doc = self.doc
(
round_off_account,
round_off_cost_center,
_round_off_for_opening,
) = get_round_off_account_and_cost_center(
doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center
)
precision_loss = doc.get("base_net_total") - flt(
doc.get("net_total") * doc.conversion_rate, doc.precision("net_total")
)
if precision_loss:
gl_entries.append(
doc.get_gl_dict(
{
"account": round_off_account,
"against": doc.customer,
"debit": precision_loss,
"cost_center": round_off_cost_center
if doc.use_company_roundoff_cost_center
else doc.cost_center or round_off_cost_center,
"remarks": _("Net total calculation precision loss"),
}
)
)
def make_discount_gl_entries(self, gl_entries):
doc = self.doc
enable_discount_accounting = cint(
frappe.get_single_value("Selling Settings", "enable_discount_accounting")
)
if enable_discount_accounting:
for item in doc.get("items"):
if item.get("discount_amount") and item.get("discount_account"):
discount_amount = item.discount_amount * item.qty
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
account_currency = get_account_currency(item.discount_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": item.discount_account,
"against": doc.customer,
"debit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"debit_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
"project": item.project,
},
account_currency,
item=item,
)
)
account_currency = get_account_currency(income_account)
gl_entries.append(
doc.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(
discount_amount * doc.get("conversion_rate"),
item.precision("discount_amount"),
),
"credit_in_transaction_currency": flt(
discount_amount, item.precision("discount_amount")
),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
if (
(enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
and doc.get("additional_discount_account")
and doc.get("discount_amount")
):
gl_entries.append(
doc.get_gl_dict(
{
"account": doc.additional_discount_account,
"against": doc.customer,
"debit": doc.base_discount_amount,
"cost_center": doc.cost_center or erpnext.get_default_cost_center(doc.company),
},
item=doc,
)
)
def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
doc = self.doc
if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)):
return
for item in doc.get("items"):
if not item.delivery_note and not item.dn_detail:
continue
if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
continue
dn_expense_account = frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "expense_account"
)
if (
not dn_expense_account
or frappe.get_cached_value("Account", dn_expense_account, "account_type")
!= "Stock Delivered But Not Billed"
or not item.expense_account
or dn_expense_account == item.expense_account
):
continue
delivery_note = item.delivery_note or frappe.get_cached_value(
"Delivery Note Item", item.dn_detail, "parent"
)
if not delivery_note:
continue
item_g = frappe.get_cached_value(
"Stock Ledger Entry",
{
"voucher_no": delivery_note,
"voucher_detail_no": item.dn_detail,
"item_code": item.item_code,
"is_cancelled": 0,
},
["stock_value_difference", "actual_qty"],
as_dict=True,
)
if not item_g or not flt(item_g.actual_qty):
continue
valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
valuation_amount = valuation_rate * item.stock_qty
dn_account_currency = get_account_currency(dn_expense_account)
item_account_currency = get_account_currency(item.expense_account)
gl_entries.append(
self.get_gl_dict(
{
"account": dn_expense_account,
"against": item.expense_account,
"credit": flt(valuation_amount),
"credit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
dn_account_currency,
item=item,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": item.expense_account,
"against": dn_expense_account,
"debit": flt(valuation_amount),
"debit_in_account_currency": flt(valuation_amount),
"cost_center": item.cost_center,
},
item_account_currency,
item=item,
)
)
def make_customer_gl_entry(self, gl_entries):
doc = self.doc
# Checked both rounding_adjustment and rounded_total
# because rounded_total had value even before introduction of posting GLE based on rounded total
grand_total = (
doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total
)
base_grand_total = flt(
doc.base_rounded_total
if (doc.base_rounding_adjustment and doc.base_rounded_total)
else doc.base_grand_total,
doc.precision("base_grand_total"),
)
if grand_total and not doc.is_internal_transfer():
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
# Did not use base_grand_total to book rounding loss gle
gl_entries.append(
self.get_gl_dict(
{
"account": doc.debit_to,
"party_type": "Customer",
"party": doc.customer,
"due_date": doc.due_date,
"against": doc.against_income_account,
"debit": base_grand_total,
"debit_in_account_currency": base_grand_total
if doc.party_account_currency == doc.company_currency
else grand_total,
"debit_in_transaction_currency": grand_total,
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
},
doc.party_account_currency,
item=doc,
)
)
def make_tax_gl_entries(self, gl_entries):
doc = self.doc
tax_service = TaxService(doc)
enable_discount_accounting = cint(
frappe.get_single_value("Selling Settings", "enable_discount_accounting")
)
for tax in doc.get("taxes"):
amount, base_amount = tax_service.get_tax_amounts(tax, enable_discount_accounting)
if flt(tax.base_tax_amount_after_discount_amount):
account_currency = get_account_currency(tax.account_head)
gl_entries.append(
self.get_gl_dict(
{
"account": tax.account_head,
"against": doc.customer,
"credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
"credit_in_account_currency": (
flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
if account_currency == doc.company_currency
else flt(amount, tax.precision("tax_amount_after_discount_amount"))
),
"credit_in_transaction_currency": flt(
amount, tax.precision("tax_amount_after_discount_amount")
),
"cost_center": tax.cost_center,
},
account_currency,
item=tax,
)
)
def make_internal_transfer_gl_entries(self, gl_entries):
doc = self.doc
if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges):
account_currency = get_account_currency(doc.unrealized_profit_loss_account)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.unrealized_profit_loss_account,
"against": doc.customer,
"debit": flt(doc.total_taxes_and_charges),
"debit_in_account_currency": flt(doc.base_total_taxes_and_charges),
"debit_in_transaction_currency": flt(doc.total_taxes_and_charges),
"cost_center": doc.cost_center,
},
account_currency,
item=doc,
)
)
def make_item_gl_entries(self, gl_entries):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
doc = self.doc
tax_service = TaxService(doc)
# income account gl entries
enable_discount_accounting = cint(
frappe.get_single_value("Selling Settings", "enable_discount_accounting")
)
for item in doc.get("items"):
if (
flt(item.base_net_amount, item.precision("base_net_amount"))
or item.is_fixed_asset
or enable_discount_accounting
):
# Do not book income for transfer within same company
if doc.is_internal_transfer():
continue
if item.is_fixed_asset and item.asset:
self.get_gl_entries_for_fixed_asset(item, gl_entries)
else:
income_account = (
item.income_account
if (not item.enable_deferred_revenue or doc.is_return)
else item.deferred_revenue_account
)
amount, base_amount = tax_service.get_amount_and_base_amount(
item, enable_discount_accounting
)
account_currency = get_account_currency(income_account)
gl_entries.append(
self.get_gl_dict(
{
"account": income_account,
"against": doc.customer,
"credit": flt(base_amount, item.precision("base_net_amount")),
"credit_in_account_currency": (
flt(base_amount, item.precision("base_net_amount"))
if account_currency == doc.company_currency
else flt(amount, item.precision("net_amount"))
),
"credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
# expense account gl entries
if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company):
gl_entries += super(SalesInvoice, doc).get_gl_entries()
def get_gl_entries_for_fixed_asset(self, item, gl_entries):
doc = self.doc
asset = frappe.get_cached_doc("Asset", item.asset)
if doc.is_return:
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
asset,
item.base_net_amount,
item.finance_book,
doc.get("doctype"),
doc.get("name"),
doc.get("posting_date"),
)
else:
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
asset,
item.base_net_amount,
item.finance_book,
doc.get("doctype"),
doc.get("name"),
doc.get("posting_date"),
)
for gle in fixed_asset_gl_entries:
gle["against"] = doc.customer
gl_entries.append(self.get_gl_dict(gle, item=item))
def make_loyalty_point_redemption_gle(self, gl_entries):
doc = self.doc
if cint(doc.redeem_loyalty_points and doc.loyalty_points and not doc.is_consolidated):
gl_entries.append(
self.get_gl_dict(
{
"account": doc.debit_to,
"party_type": "Customer",
"party": doc.customer,
"against": "Expense account - "
+ cstr(doc.loyalty_redemption_account)
+ " for the Loyalty Program",
"credit": doc.loyalty_amount,
"credit_in_transaction_currency": doc.loyalty_amount,
"against_voucher": doc.return_against if cint(doc.is_return) else doc.name,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
},
item=doc,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.loyalty_redemption_account,
"cost_center": doc.cost_center or doc.loyalty_redemption_cost_center,
"against": doc.customer,
"debit": doc.loyalty_amount,
"debit_in_transaction_currency": doc.loyalty_amount,
"remark": "Loyalty Points redeemed by the customer",
},
item=doc,
)
)
def make_pos_gl_entries(self, gl_entries):
doc = self.doc
if cint(doc.is_pos):
skip_change_gl_entries = not cint(
frappe.get_single_value("POS Settings", "post_change_gl_entries")
)
for payment_mode in doc.payments:
if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
payment_mode.base_amount -= flt(doc.change_amount)
against_voucher = doc.name
if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
against_voucher = doc.return_against
if payment_mode.base_amount:
# POS, make payment entries
gl_entries.append(
self.get_gl_dict(
{
"account": doc.debit_to,
"party_type": "Customer",
"party": doc.customer,
"against": payment_mode.account,
"credit": payment_mode.base_amount,
"credit_in_account_currency": payment_mode.base_amount
if doc.party_account_currency == doc.company_currency
else payment_mode.amount,
"credit_in_transaction_currency": payment_mode.amount,
"against_voucher": against_voucher,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
},
doc.party_account_currency,
item=doc,
)
)
payment_mode_account_currency = get_account_currency(payment_mode.account)
gl_entries.append(
self.get_gl_dict(
{
"account": payment_mode.account,
"against": doc.customer,
"debit": payment_mode.base_amount,
"debit_in_account_currency": payment_mode.base_amount
if payment_mode_account_currency == doc.company_currency
else payment_mode.amount,
"debit_in_transaction_currency": payment_mode.amount,
"cost_center": doc.cost_center,
},
payment_mode_account_currency,
item=doc,
)
)
if not skip_change_gl_entries:
gl_entries.extend(self.get_gle_for_change_amount())
def get_gle_for_change_amount(self) -> list[dict]:
doc = self.doc
if not doc.change_amount:
return []
if not doc.account_for_change_amount:
frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field"))
return [
self.get_gl_dict(
{
"account": doc.debit_to,
"party_type": "Customer",
"party": doc.customer,
"against": doc.account_for_change_amount,
"debit": flt(doc.base_change_amount),
"debit_in_account_currency": flt(doc.base_change_amount)
if doc.party_account_currency == doc.company_currency
else flt(doc.change_amount),
"debit_in_transaction_currency": flt(doc.change_amount),
"against_voucher": doc.return_against
if cint(doc.is_return) and doc.return_against
else doc.name,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
},
doc.party_account_currency,
item=doc,
),
self.get_gl_dict(
{
"account": doc.account_for_change_amount,
"against": doc.customer,
"credit": doc.base_change_amount,
"credit_in_transaction_currency": doc.change_amount,
"cost_center": doc.cost_center,
},
item=doc,
),
]
def make_write_off_gl_entry(self, gl_entries):
doc = self.doc
# write off entries, applicable if only pos
if (
doc.is_pos
and doc.write_off_account
and flt(doc.write_off_amount, doc.precision("write_off_amount"))
):
write_off_account_currency = get_account_currency(doc.write_off_account)
default_cost_center = frappe.get_cached_value("Company", doc.company, "cost_center")
gl_entries.append(
self.get_gl_dict(
{
"account": doc.debit_to,
"party_type": "Customer",
"party": doc.customer,
"against": doc.write_off_account,
"credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
"credit_in_account_currency": (
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
if doc.party_account_currency == doc.company_currency
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
),
"credit_in_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
),
"against_voucher": doc.return_against if cint(doc.is_return) else doc.name,
"against_voucher_type": doc.doctype,
"cost_center": doc.cost_center,
"project": doc.project,
},
doc.party_account_currency,
item=doc,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": doc.write_off_account,
"against": doc.customer,
"debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
"debit_in_account_currency": (
flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
if write_off_account_currency == doc.company_currency
else flt(doc.write_off_amount, doc.precision("write_off_amount"))
),
"debit_in_transaction_currency": flt(
doc.write_off_amount, doc.precision("write_off_amount")
),
"cost_center": doc.cost_center or doc.write_off_cost_center or default_cost_center,
},
write_off_account_currency,
item=doc,
)
)
def make_gle_for_rounding_adjustment(self, gl_entries):
doc = self.doc
if (
flt(doc.rounding_adjustment, doc.precision("rounding_adjustment"))
and doc.base_rounding_adjustment
and not doc.is_internal_transfer()
):
(
round_off_account,
round_off_cost_center,
round_off_for_opening,
) = get_round_off_account_and_cost_center(
doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center
)
if doc.is_opening == "Yes" and doc.rounding_adjustment:
if not round_off_for_opening:
frappe.throw(
_(
"Opening Invoice has rounding adjustment of {0}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> Or, '{3}' can be enabled to not post any rounding adjustment."
).format(
frappe.bold(doc.rounding_adjustment),
frappe.bold("Round Off for Opening"),
get_link_to_form("Company", doc.company),
frappe.bold("Disable Rounded Total"),
)
)
else:
round_off_account = round_off_for_opening
gl_entries.append(
self.get_gl_dict(
{
"account": round_off_account,
"against": doc.customer,
"credit_in_account_currency": flt(
doc.rounding_adjustment, doc.precision("rounding_adjustment")
),
"credit_in_transaction_currency": flt(
doc.rounding_adjustment, doc.precision("rounding_adjustment")
),
"credit": flt(
doc.base_rounding_adjustment, doc.precision("base_rounding_adjustment")
),
"cost_center": round_off_cost_center
if doc.use_company_roundoff_cost_center
else (doc.cost_center or round_off_cost_center),
},
item=doc,
)
)

View File

@@ -0,0 +1,68 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Inter-company transaction helpers for Sales Invoice."""
import frappe
from frappe import _
def validate_inter_company_party(
doctype: str, party: str, company: str, inter_company_reference: str | None
) -> None:
if not party:
return
if doctype in ["Sales Invoice", "Sales Order"]:
partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer"
ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
else:
partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier"
ref_doc = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
if inter_company_reference:
doc = frappe.get_doc(ref_doc, inter_company_reference)
ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer
if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party:
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype)))
if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company:
frappe.throw(_("Invalid Company for Inter Company Transaction."))
elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party:
companies = [
d.company
for d in frappe.get_all(
"Allowed To Transact With",
fields=["company"],
filters={"parenttype": partytype, "parent": party},
)
]
if company not in companies:
frappe.throw(
_(
"{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
).format(_(partytype), company)
)
def update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:
ref_field = (
"inter_company_invoice_reference"
if doctype in ["Sales Invoice", "Purchase Invoice"]
else "inter_company_order_reference"
)
if inter_company_reference:
frappe.db.set_value(doctype, inter_company_reference, ref_field, name)
def unlink_inter_company_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:
if doctype in ["Sales Invoice", "Purchase Invoice"]:
ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice"
ref_field = "inter_company_invoice_reference"
else:
ref_doc = "Purchase Order" if doctype == "Sales Order" else "Sales Order"
ref_field = "inter_company_order_reference"
if inter_company_reference:
frappe.db.set_value(doctype, name, ref_field, "")
frappe.db.set_value(ref_doc, inter_company_reference, ref_field, "")

View File

@@ -0,0 +1,163 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Loyalty program helpers for Sales Invoice."""
import frappe
from frappe import _
from frappe.utils import add_days, cint, flt, getdate
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points,
)
class LoyaltyService:
def __init__(self, doc):
self.doc = doc
def make_loyalty_point_entry(self) -> None:
doc = self.doc
returned_amount = self._get_returned_amount()
current_amount = flt(doc.grand_total) - cint(doc.loyalty_amount)
eligible_amount = current_amount - returned_amount
lp_details = get_loyalty_program_details_with_points(
doc.customer,
company=doc.company,
current_transaction_amount=current_amount,
loyalty_program=doc.loyalty_program,
expiry_date=doc.posting_date,
include_expired_entry=True,
)
if (
lp_details
and getdate(lp_details.from_date) <= getdate(doc.posting_date)
and (not lp_details.to_date or getdate(lp_details.to_date) >= getdate(doc.posting_date))
):
collection_factor = lp_details.collection_factor if lp_details.collection_factor else 1.0
points_earned = cint(eligible_amount / collection_factor)
entry = frappe.get_doc(
{
"doctype": "Loyalty Point Entry",
"company": doc.company,
"loyalty_program": lp_details.loyalty_program,
"loyalty_program_tier": lp_details.tier_name,
"customer": doc.customer,
"invoice_type": doc.doctype,
"invoice": doc.name,
"loyalty_points": points_earned,
"purchase_amount": eligible_amount,
"expiry_date": add_days(doc.posting_date, lp_details.expiry_duration),
"posting_date": doc.posting_date,
}
)
entry.flags.ignore_permissions = 1
entry.save()
self._set_loyalty_program_tier()
def delete_loyalty_point_entry(self) -> None:
doc = self.doc
lp_entry = frappe.db.get_all(
"Loyalty Point Entry", filters={"invoice": doc.name, "loyalty_points": (">", 0)}, fields=["name"]
)
if not lp_entry:
return
against_lp_entry = frappe.db.get_all(
"Loyalty Point Entry",
filters={"redeem_against": lp_entry[0].name},
fields=["name", "invoice"],
)
if against_lp_entry:
invoice_list = ", ".join([d.invoice for d in against_lp_entry])
frappe.throw(
_(
"{} can't be cancelled since the Loyalty Points earned has been redeemed. "
"First cancel the {} No {}"
).format(doc.doctype, doc.doctype, invoice_list)
)
else:
frappe.db.delete("Loyalty Point Entry", filters={"invoice": doc.name})
self._set_loyalty_program_tier()
def apply_loyalty_points(self) -> None:
from erpnext.accounts.doctype.loyalty_point_entry.loyalty_point_entry import (
get_loyalty_point_entries,
get_redemption_details,
)
doc = self.doc
loyalty_point_entries = get_loyalty_point_entries(
doc.customer, doc.loyalty_program, doc.company, doc.posting_date
)
redemption_details = get_redemption_details(doc.customer, doc.loyalty_program, doc.company)
points_to_redeem = doc.loyalty_points
for lp_entry in loyalty_point_entries:
if lp_entry.invoice_type != doc.doctype or lp_entry.invoice == doc.name:
continue
available_points = lp_entry.loyalty_points - flt(redemption_details.get(lp_entry.name))
redeemed_points = min(available_points, points_to_redeem)
entry = frappe.get_doc(
{
"doctype": "Loyalty Point Entry",
"company": doc.company,
"loyalty_program": doc.loyalty_program,
"loyalty_program_tier": lp_entry.loyalty_program_tier,
"customer": doc.customer,
"invoice_type": doc.doctype,
"invoice": doc.name,
"redeem_against": lp_entry.name,
"loyalty_points": -1 * redeemed_points,
"purchase_amount": doc.grand_total,
"expiry_date": lp_entry.expiry_date,
"posting_date": doc.posting_date,
}
)
entry.flags.ignore_permissions = 1
entry.save()
points_to_redeem -= redeemed_points
if points_to_redeem < 1:
break
def _set_loyalty_program_tier(self) -> None:
doc = self.doc
lp_details = get_loyalty_program_details_with_points(
doc.customer,
company=doc.company,
loyalty_program=doc.loyalty_program,
include_expired_entry=True,
)
customer = frappe.get_doc("Customer", doc.customer)
customer.db_set("loyalty_program_tier", lp_details.tier_name)
def _get_returned_amount(self) -> float:
from frappe.query_builder.functions import Sum
doc = frappe.qb.DocType(self.doc.doctype)
returned_amount = (
frappe.qb.from_(doc)
.select(Sum(doc.grand_total))
.where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.doc.name))
).run()
return abs(returned_amount[0][0]) if returned_amount[0][0] else 0
def get_loyalty_programs(customer: str) -> list:
"""Return applicable loyalty programs for the customer."""
from erpnext.selling.doctype.customer.customer import get_loyalty_programs as _get
customer_doc = frappe.get_doc("Customer", customer)
if customer_doc.loyalty_program:
return [customer_doc.loyalty_program]
lp_details = _get(customer_doc)
if len(lp_details) == 1:
customer_doc.db_set("loyalty_program", lp_details[0])
return lp_details

View File

@@ -0,0 +1,422 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""POS helpers for Sales Invoice."""
import frappe
from frappe import _, msgprint
from frappe.utils import cint, flt, get_link_to_form
class PartialPaymentValidationError(frappe.ValidationError):
pass
class POSService:
def __init__(self, doc):
self.doc = doc
def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None:
"""Populate POS-profile fields on the invoice; return the profile or None."""
doc = self.doc
if cint(doc.is_pos) != 1:
return None
if not doc.account_for_change_amount:
doc.account_for_change_amount = frappe.get_cached_value(
"Company", doc.company, "default_cash_account"
)
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_pos_profile,
get_pos_profile_item_details_,
)
if not doc.pos_profile and not doc.flags.ignore_pos_profile:
pos_profile = get_pos_profile(doc.company) or {}
if not pos_profile:
return None
doc.pos_profile = pos_profile.get("name")
pos = {}
if doc.pos_profile:
pos = frappe.get_doc("POS Profile", doc.pos_profile)
if pos:
if not for_validate:
update_multi_mode_option(doc, pos)
doc.tax_category = pos.get("tax_category")
if not for_validate and not doc.customer:
doc.customer = pos.customer
if not for_validate:
doc.ignore_pricing_rule = pos.ignore_pricing_rule
if pos.get("account_for_change_amount"):
doc.account_for_change_amount = pos.get("account_for_change_amount")
for fieldname in (
"currency",
"letter_head",
"tc_name",
"company",
"select_print_heading",
"write_off_account",
"taxes_and_charges",
"write_off_cost_center",
"apply_discount_on",
"cost_center",
):
if (not for_validate) or (for_validate and not doc.get(fieldname)):
doc.set(fieldname, pos.get(fieldname))
if pos.get("company_address"):
doc.company_address = pos.get("company_address")
if doc.customer:
customer_price_list, customer_group = frappe.get_value(
"Customer", doc.customer, ["default_price_list", "customer_group"]
)
customer_group_price_list = frappe.get_value(
"Customer Group", customer_group, "default_price_list"
)
selling_price_list = (
customer_price_list or customer_group_price_list or pos.get("selling_price_list")
)
else:
selling_price_list = pos.get("selling_price_list")
if selling_price_list:
doc.set("selling_price_list", selling_price_list)
if not for_validate:
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"):
profile_details = get_pos_profile_item_details_(
ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
if doc.tc_name and not doc.terms:
doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
if doc.taxes_and_charges and not len(doc.get("taxes")):
from erpnext.accounts.services.taxes import TaxService
TaxService(doc).set_taxes()
return pos
def set_paid_amount(self) -> None:
doc = self.doc
paid_amount = 0.0
base_paid_amount = 0.0
for data in doc.payments:
data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount"))
paid_amount += data.amount
base_paid_amount += data.base_amount
doc.paid_amount = paid_amount
doc.base_paid_amount = base_paid_amount
def set_account_for_mode_of_payment(self) -> None:
for payment in self.doc.payments:
payment.account = get_bank_cash_account(payment.mode_of_payment, self.doc.company).get("account")
def reset_mode_of_payments(self) -> None:
doc = self.doc
if doc.pos_profile:
pos_profile = frappe.get_cached_doc("POS Profile", doc.pos_profile)
update_multi_mode_option(doc, pos_profile)
doc.paid_amount = 0
def validate_pos_return(self) -> None:
doc = self.doc
if doc.is_consolidated:
return
if doc.is_pos and doc.is_return:
total_amount_in_payments = sum(payment.amount for payment in doc.payments)
invoice_total = doc.rounded_total or doc.grand_total
if total_amount_in_payments < invoice_total:
frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))
def validate_pos_paid_amount(self) -> None:
doc = self.doc
if len(doc.payments) == 0 and doc.is_pos and flt(doc.grand_total) > 0:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
def validate_pos(self) -> None:
doc = self.doc
if doc.is_return:
invoice_total = doc.rounded_total or doc.grand_total
if abs(flt(doc.paid_amount)) + abs(flt(doc.write_off_amount)) - abs(flt(invoice_total)) > 1.0 / (
10.0 ** (doc.precision("grand_total") + 1.0)
):
frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total"))
def validate_created_using_pos(self) -> None:
doc = self.doc
if doc.is_created_using_pos and not doc.pos_profile:
frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction."))
doc.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type")
if doc.invoice_type_in_pos == "POS Invoice" and not doc.is_return:
frappe.throw(_("Transactions using Sales Invoice in POS are disabled."))
self.validate_pos_opening_entry()
def validate_full_payment(self) -> None:
doc = self.doc
allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment")
invoice_total = flt(doc.rounded_total) or flt(doc.grand_total)
if (
doc.docstatus == 1
and not doc.is_return
and not allow_partial_payment
and doc.paid_amount < invoice_total
):
frappe.throw(
msg=_("Partial Payment in POS Transactions are not allowed."),
exc=PartialPaymentValidationError,
)
def validate_pos_opening_entry(self) -> None:
doc = self.doc
opening_entries = frappe.get_all(
"POS Opening Entry",
fields=["name", "period_start_date"],
filters={"pos_profile": doc.pos_profile, "status": "Open"},
order_by="period_start_date desc",
)
if not opening_entries:
frappe.throw(
title=_("POS Opening Entry Missing"),
msg=_("No open POS Opening Entry found for POS Profile {0}.").format(
frappe.bold(doc.pos_profile)
),
)
if len(opening_entries) > 1:
frappe.throw(
title=_("Multiple POS Opening Entry"),
msg=_(
"POS Profile - {0} has multiple open POS Opening Entries. Please close or cancel the existing entries before proceeding."
).format(doc.pos_profile),
)
if frappe.utils.get_date_str(opening_entries[0].get("period_start_date")) != frappe.utils.today():
frappe.throw(
title=_("Outdated POS Opening Entry"),
msg=_(
"POS Opening Entry - {0} is outdated. Please close the POS and create a new POS Opening Entry."
).format(opening_entries[0].get("name")),
)
def check_if_consolidated_invoice(self) -> None:
doc = self.doc
if doc.doctype == "Sales Invoice" and doc.is_consolidated:
invoice_or_credit_note = "consolidated_credit_note" if doc.is_return else "consolidated_invoice"
pos_closing_entry = frappe.get_all(
"POS Invoice Merge Log",
filters={invoice_or_credit_note: doc.name},
pluck="pos_closing_entry",
)
if pos_closing_entry and pos_closing_entry[0]:
msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
frappe.bold(_("Consolidated Sales Invoice")),
get_link_to_form("POS Closing Entry", pos_closing_entry[0]),
)
frappe.throw(msg, title=_("Not Allowed"))
def check_if_created_using_pos_and_pos_closing_entry_generated(self) -> None:
doc = self.doc
if doc.doctype == "Sales Invoice" and doc.is_created_using_pos and doc.pos_closing_entry:
pos_closing_entry_docstatus = frappe.db.get_value(
"POS Closing Entry", doc.pos_closing_entry, "docstatus"
)
if pos_closing_entry_docstatus == 1:
frappe.throw(
msg=_(
"To cancel this Sales Invoice you need to cancel the POS Closing Entry {0}."
).format(get_link_to_form("POS Closing Entry", doc.pos_closing_entry)),
title=_("Not Allowed"),
)
def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self) -> None:
pos_invoices = frappe.get_all(
"POS Invoice", filters={"consolidated_invoice": self.doc.name}, pluck="name"
)
for pos_invoice in pos_invoices:
frappe.get_doc("POS Invoice", pos_invoice).cancel()
def clear_unallocated_mode_of_payments(self) -> None:
doc = self.doc
doc.set("payments", doc.get("payments", {"amount": ["not in", [0, None, ""]]}))
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:
self.doc.write_off_account = None
def verify_payment_amount_is_positive(self) -> None:
for entry in self.doc.payments:
if entry.amount < 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx))
def verify_payment_amount_is_negative(self) -> None:
for entry in self.doc.payments:
if entry.amount > 0:
frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
def get_warehouse(self) -> str | None:
doc = self.doc
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_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:
msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
return warehouse
def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
account = frappe.db.get_value(
"Mode of Payment Account",
{"parent": mode_of_payment, "company": company},
"default_account",
)
if not account:
frappe.throw(
_("Please set default Cash or Bank account in Mode of Payment {0}").format(
get_link_to_form("Mode of Payment", mode_of_payment)
),
title=_("Missing Account"),
)
return {"account": account}
def update_multi_mode_option(doc, pos_profile) -> None:
def append_payment(payment_mode):
payment = doc.append("payments", {})
payment.default = payment_mode.default
payment.mode_of_payment = payment_mode.mop
payment.account = payment_mode.default_account
payment.type = payment_mode.type
mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
doc.set("payments", [])
invalid_modes = []
mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")]
mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company)
for row in pos_profile.get("payments"):
payment_mode = mode_of_payments_info.get(row.mode_of_payment)
if not payment_mode:
invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment))
continue
payment_mode.default = row.default
append_payment(payment_mode)
if invalid_modes:
if invalid_modes == 1:
msg = _("Please set default Cash or Bank account in Mode of Payment {}")
else:
msg = _("Please set default Cash or Bank account in Mode of Payments {}")
frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
if mop_refetched:
frappe.toast(
_("Payment methods refreshed. Please review before proceeding."),
indicator="orange",
)
def get_all_mode_of_payments(doc) -> list:
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:
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.as_("mop"),
ModeOfPayment.type.as_("type"),
)
.where(ModeOfPaymentAccount.company == company)
.where(ModeOfPayment.enabled == 1)
.where(ModeOfPayment.name.isin(mode_of_payments))
.groupby(ModeOfPayment.name)
)
data = query.run(as_dict=1)
return {row.get("mop"): row for row in data}
def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
query = (
frappe.qb.from_(ModeOfPayment)
.join(ModeOfPaymentAccount)
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
.select(
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
)
.where(ModeOfPaymentAccount.company == company)
.where(ModeOfPayment.enabled == 1)
.where(ModeOfPayment.name == mode_of_payment)
)
return query.run(as_dict=1)

View File

@@ -0,0 +1,134 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Status computation and display helpers for Sales Invoice."""
import frappe
from frappe import _
from frappe.utils import cint, flt, getdate, nowdate
class StatusService:
def __init__(self, doc):
self.doc = doc
def set_status(
self, update: bool = False, status: str | None = None, update_modified: bool = True
) -> None:
doc = self.doc
if doc.is_new():
if doc.get("amended_from"):
doc.status = "Draft"
return
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
total = get_total_in_party_account_currency(doc)
if not status:
if doc.docstatus == 2:
status = "Cancelled"
elif doc.docstatus == 1:
if doc.is_internal_transfer():
doc.status = "Internal Transfer"
elif is_overdue(doc, total):
doc.status = "Overdue"
elif 0 < outstanding_amount < total:
doc.status = "Partly Paid"
elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
doc.status = "Unpaid"
elif doc.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
):
doc.status = "Credit Note Issued"
elif doc.is_return == 1:
doc.status = "Return"
elif outstanding_amount <= 0:
doc.status = "Paid"
else:
doc.status = "Submitted"
if (
doc.status in ("Unpaid", "Partly Paid", "Overdue")
and doc.is_discounted
and get_discounting_status(doc.name) == "Disbursed"
):
doc.status += " and Discounted"
else:
doc.status = "Draft"
if update:
doc.db_set("status", doc.status, update_modified=update_modified)
def set_indicator(self) -> None:
doc = self.doc
if doc.outstanding_amount < 0:
doc.indicator_title = _("Credit Note Issued")
doc.indicator_color = "gray"
elif doc.outstanding_amount > 0 and getdate(doc.due_date) >= getdate(nowdate()):
doc.indicator_color = "orange"
doc.indicator_title = _("Unpaid")
elif doc.outstanding_amount > 0 and getdate(doc.due_date) < getdate(nowdate()):
doc.indicator_color = "red"
doc.indicator_title = _("Overdue")
elif cint(doc.is_return) == 1:
doc.indicator_title = _("Return")
doc.indicator_color = "gray"
else:
doc.indicator_color = "green"
doc.indicator_title = _("Paid")
def get_total_in_party_account_currency(doc) -> float:
total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total"
if doc.party_account_currency != doc.currency:
total_fieldname = "base_" + total_fieldname
return flt(doc.get(total_fieldname), doc.precision(total_fieldname))
def is_overdue(doc, total: float) -> bool | None:
outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
if outstanding_amount <= 0:
return
today = getdate()
if doc.get("is_pos") or not doc.get("payment_schedule"):
return getdate(doc.due_date) < today
payment_amount_field = (
"base_payment_amount" if doc.party_account_currency != doc.currency else "payment_amount"
)
payable_amount = flt(
sum(
payment.get(payment_amount_field)
for payment in doc.payment_schedule
if getdate(payment.due_date) < today
),
doc.precision("outstanding_amount"),
)
return flt(total - outstanding_amount, doc.precision("outstanding_amount")) < payable_amount
def get_discounting_status(sales_invoice: str) -> str | None:
status = None
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":
break
return status

View File

@@ -0,0 +1,121 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""Timesheet billing helpers for Sales Invoice."""
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data
class TimesheetBillingService:
def __init__(self, doc):
self.doc = doc
def validate_time_sheets_are_submitted(self) -> None:
for data in self.doc.timesheets:
if data.time_sheet and data.timesheet_detail:
if sales_invoice := frappe.db.get_value(
"Timesheet Detail", data.timesheet_detail, "sales_invoice"
):
frappe.throw(
_("Row {0}: Sales Invoice {1} is already created for {2}").format(
data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
)
)
if data.time_sheet:
status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
if status not in ["Submitted", "Payslip", "Partially Billed"]:
frappe.throw(
_("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
)
def update_time_sheet(self, sales_invoice: str | None) -> None:
for d in self.doc.timesheets:
if d.time_sheet:
timesheet = frappe.get_doc("Timesheet", d.time_sheet)
self._update_time_sheet_detail(timesheet, d, sales_invoice)
timesheet.calculate_total_amounts()
timesheet.calculate_percentage_billed()
timesheet.flags.ignore_validate_update_after_submit = True
timesheet.set_status()
timesheet.db_update_all()
def unlink_sales_invoice_from_timesheets(self) -> None:
for row in self.doc.timesheets:
timesheet = frappe.get_doc("Timesheet", row.time_sheet)
timesheet.unlink_sales_invoice(self.doc.name)
timesheet.flags.ignore_validate_update_after_submit = True
timesheet.db_update_all()
def set_billing_hours_and_amount(self) -> None:
doc = self.doc
if doc.project:
return
for timesheet in doc.timesheets:
ts_doc = frappe.get_doc("Timesheet", timesheet.time_sheet)
if not timesheet.billing_hours and ts_doc.total_billable_hours:
timesheet.billing_hours = ts_doc.total_billable_hours
if not timesheet.billing_amount and ts_doc.total_billable_amount:
timesheet.billing_amount = ts_doc.total_billable_amount
def update_timesheet_billing_for_project(self) -> None:
doc = self.doc
if (
not doc.is_return
and not doc.timesheets
and doc.project
and frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice")
):
self.add_timesheet_data()
else:
self.calculate_billing_amount_for_timesheet()
def add_timesheet_data(self) -> None:
doc = self.doc
doc.set("timesheets", [])
if doc.project:
for data in get_projectwise_timesheet_data(doc.project):
doc.append(
"timesheets",
{
"time_sheet": data.time_sheet,
"billing_hours": data.billing_hours,
"billing_amount": data.billing_amount,
"timesheet_detail": data.name,
"activity_type": data.activity_type,
"description": data.description,
},
)
self.calculate_billing_amount_for_timesheet()
def calculate_billing_amount_for_timesheet(self) -> None:
doc = self.doc
doc.total_billing_amount = sum(flt(ts.billing_amount) for ts in doc.timesheets)
doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets)
def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None:
doc = self.doc
for data in timesheet.time_logs:
if (
(doc.project and args.timesheet_detail == data.name)
or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name)
or (
not sales_invoice
and data.sales_invoice == doc.name
and args.timesheet_detail == data.name
)
or (
doc.is_return
and doc.return_against
and data.sales_invoice
and data.sales_invoice == doc.return_against
and not sales_invoice
and args.timesheet_detail == data.name
)
):
data.sales_invoice = sales_invoice

View File

@@ -19,7 +19,7 @@ from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import Warehouse
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
unlink_payment_on_cancel_of_invoice,
)
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
from erpnext.accounts.utils import PaymentEntryUnlinkError
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
from erpnext.assets.doctype.asset.test_asset import create_asset
@@ -30,7 +30,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError, update_invo
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data
from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency
from erpnext.selling.doctype.customer.test_customer import get_customer_dict
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
@@ -78,7 +78,7 @@ class TestSalesInvoice(ERPNextTestSuite):
def test_invalid_rate_without_override(self):
from frappe import ValidationError
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
si = create_sales_invoice(
customer="_Test Internal Customer 3", company="_Test Company", is_internal_customer=1, rate=100
@@ -383,6 +383,262 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(si.net_total, 3859.65)
self.assertEqual(si.grand_total, 4900.00)
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
def test_inclusive_tax_zero_decimal_currency(self):
"""Tax-included prices in zero-decimal currencies (e.g. JPY) must not produce
net + tax != gross due to double rounding of the net amount."""
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.insert()
# With currency_precision=0 (like JPY, KRW):
# 50,000 / 1.10 = 45,454.545... → net rounds to 45,455
# Tax from unrounded net: 0.10 * 45,454.545 = 4,545.4545 → rounds to 4,545
# The fix ensures net + tax = gross without double rounding error
self.assertEqual(si.items[0].net_amount, 45455)
self.assertEqual(si.taxes[0].tax_amount, 4545)
self.assertEqual(si.grand_total, 50000)
def test_inclusive_tax_decimal_value_currency(self):
"""Tax-included prices with decimal currency values must preserve gross total."""
si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.insert()
# 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95
# Tax from unrounded net: 0.10 * 9,090.94545... = 909.0945... → rounds to 909.09
# If tax were calculated from rounded net instead, it would become 909.10 and grand total 10,000.05.
self.assertEqual(si.items[0].net_amount, 9090.95)
self.assertEqual(si.taxes[0].tax_amount, 909.09)
self.assertEqual(si.grand_total, 10000.04)
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
def test_inclusive_tax_zero_decimal_currency_multiple_items(self):
"""Multiple items with tax-included prices in zero-decimal currency."""
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
create_item("_Test Inclusive Tax Item 2")
si.append(
"items",
{
"item_code": "_Test Inclusive Tax Item 2",
"warehouse": "_Test Warehouse - _TC",
"qty": 1,
"rate": 30000,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.insert()
# With currency_precision=0:
# Item 1: 50,000 / 1.10 = 45,454.545 → net 45,455, tax 4,545
# Item 2: 30,000 / 1.10 = 27,272.727 → net 27,273, tax 2,727
# Per-item: net + tax = gross holds (45455+4545=50000, 27273+2727=30000)
# Accumulated tax rounds separately: flt(7272.72, 0) = 7273
# adjust_grand_total_for_inclusive_tax patches grand_total back to 80000
self.assertEqual(si.items[0].net_amount, 45455)
self.assertEqual(si.items[1].net_amount, 27273)
self.assertEqual(si.net_total, 72728)
self.assertEqual(si.taxes[0].tax_amount, 7273)
self.assertEqual(si.grand_total, 80000)
@ERPNextTestSuite.change_settings("System Settings", {"number_format": "#,###", "currency_precision": 0})
def test_inclusive_tax_zero_decimal_currency_many_items(self):
"""Test with 10 items (mixed 10% and 5% tax) to verify tolerance of 1 is sufficient."""
si = create_sales_invoice(qty=1, rate=50000, do_not_save=True)
# Add 9 more items - mix of amounts and tax rates
# Using similar amounts to maximize same-direction rounding
item_configs = [
("_Test Inclusive Tax Item 2", 50100, None), # 10% (default)
("_Test Inclusive Tax Item 3", 50200, '{"_Test Account Service Tax - _TC": 5}'), # 5%
("_Test Inclusive Tax Item 4", 50300, None), # 10%
("_Test Inclusive Tax Item 5", 50400, '{"_Test Account Service Tax - _TC": 5}'), # 5%
("_Test Inclusive Tax Item 6", 50500, None), # 10%
("_Test Inclusive Tax Item 7", 50600, '{"_Test Account Service Tax - _TC": 5}'), # 5%
("_Test Inclusive Tax Item 8", 50700, None), # 10%
("_Test Inclusive Tax Item 9", 50800, None), # 10%
("_Test Inclusive Tax Item 10", 50900, '{"_Test Account Service Tax - _TC": 5}'), # 5%
]
for item_code, rate, item_tax_rate in item_configs:
create_item(item_code)
item_dict = {
"item_code": item_code,
"warehouse": "_Test Warehouse - _TC",
"qty": 1,
"rate": rate,
"income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
}
if item_tax_rate:
item_dict["item_tax_rate"] = item_tax_rate
si.append("items", item_dict)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.insert()
# Verify each item: net + tax = gross (within rounding tolerance)
total_gross = 0
for item in si.items:
total_gross += item.amount
# Grand total should match sum of gross amounts
# This tests that the tolerance of 1 handles mixed tax rates and similar amounts
self.assertEqual(si.grand_total, total_gross)
def test_inclusive_tax_with_decimal_value_on_previous_row_amount(self):
"""Inclusive tax with decimal value and On Previous Row Amount must not double-round net amount."""
si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.append(
"taxes",
{
"charge_type": "On Previous Row Amount",
"account_head": "_Test Account Education Cess - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Cess 5% on Tax 10%",
"rate": 5,
"row_id": 1,
"included_in_print_rate": 1,
},
)
si.insert()
# Tax fractions: 10% + (5% of 10%) = 10.5%
# 50,000.55 / 1.105 = 45,249.3665... → net rounds to 45,249.37
# Taxes are calculated from the unrounded net to keep the inclusive gross stable.
self.assertEqual(si.items[0].net_amount, 45249.37)
self.assertEqual(si.taxes[0].tax_amount, 4524.94)
self.assertEqual(si.taxes[1].tax_amount, 226.25)
self.assertEqual(si.grand_total, 50000.55)
def test_inclusive_tax_with_decimal_value_on_previous_row_amount_non_inclusive(self):
"""Non-inclusive previous-row tax should be added after inclusive tax extraction."""
si = create_sales_invoice(qty=1, rate=10000.04, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.append(
"taxes",
{
"charge_type": "On Previous Row Amount",
"account_head": "_Test Account Education Cess - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Cess 5% on Tax 10%",
"rate": 5,
"row_id": 1,
"included_in_print_rate": 0,
},
)
si.insert()
# Only the first tax is inclusive:
# 10,000.04 / 1.10 = 9,090.94545... → net rounds to 9,090.95
# Inclusive tax = 909.09, restoring the original gross of 10,000.04
# The non-inclusive previous-row tax is added afterward: 5% of 909.09 = 45.45
self.assertEqual(si.items[0].net_amount, 9090.95)
self.assertEqual(si.taxes[0].tax_amount, 909.09)
self.assertEqual(si.taxes[1].tax_amount, 45.45)
self.assertEqual(si.grand_total, 10045.49)
def test_inclusive_tax_with_decimal_value_on_previous_row_total(self):
"""Inclusive tax with decimal value and On Previous Row Total must not double-round net amount."""
si = create_sales_invoice(qty=1, rate=50000.55, do_not_save=True)
si.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account Service Tax - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Tax 10%",
"rate": 10,
"included_in_print_rate": 1,
},
)
si.append(
"taxes",
{
"charge_type": "On Previous Row Total",
"account_head": "_Test Account Education Cess - _TC",
"cost_center": "_Test Cost Center - _TC",
"description": "Cess 5% on Previous Total",
"rate": 5,
"row_id": 1,
"included_in_print_rate": 1,
},
)
si.insert()
# Tax fractions: 10% + (5% of 110%) = 15.5%
# 50,000.55 / 1.155 = 43,290.5195... → net rounds to 43,290.52
# Taxes are calculated from the unrounded net/previous total to keep the inclusive gross stable.
self.assertEqual(si.items[0].net_amount, 43290.52)
self.assertEqual(si.taxes[0].tax_amount, 4329.05)
self.assertEqual(si.taxes[1].tax_amount, 2380.98)
self.assertEqual(si.grand_total, 50000.55)
def test_sales_invoice_discount_amount(self):
si = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][3])
si.discount_amount = 104.94
@@ -881,7 +1137,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)
@@ -1022,7 +1278,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.validate_pos_gl_entry(si, pos, 50)
def test_pos_returns_with_repayment(self):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
pos_profile = make_pos_profile()
@@ -1141,7 +1397,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(pos.outstanding_amount, 0.0)
self.assertEqual(pos.status, "Paid")
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
pos_return = make_sales_return(pos.name)
pos_return.save().submit()
@@ -2662,6 +2918,34 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(target_doc.company, "_Test Company 1")
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
def test_inter_company_transaction_does_not_inherit_party_fields(self):
"""
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
"""
si = create_sales_invoice(
company="Wind Power LLC",
customer="_Test Internal Customer",
debit_to="Debtors - WP",
warehouse="Stores - WP",
income_account="Sales - WP",
expense_account="Cost of Goods Sold - WP",
cost_center="Main - WP",
currency="USD",
do_not_save=1,
)
si.selling_price_list = "_Test Price List Rest of the World"
si.tax_category = "_Test Tax Category 1"
si.language = "ar"
si.payment_terms_template = "_Test Payment Term Template"
si.submit()
pi = make_inter_company_transaction("Sales Invoice", si.name)
supplier = frappe.get_doc("Supplier", "_Test Internal Supplier")
self.assertEqual(pi.tax_category or None, supplier.tax_category or None)
self.assertEqual(pi.language or None, supplier.language or None)
self.assertEqual(pi.payment_terms_template or None, supplier.payment_terms or None)
def test_inter_company_transaction_without_default_warehouse(self):
"Check mapping (expense account) of inter company SI to PI in absence of default warehouse."
# setup
@@ -3493,6 +3777,14 @@ class TestSalesInvoice(ERPNextTestSuite):
si.submit()
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
def test_sales_invoice_cancellation_post_account_freezing_date(self):
si = create_sales_invoice()
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", add_days(getdate(), 1))
try:
self.assertRaises(frappe.ValidationError, si.cancel)
finally:
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_over_billing_case_against_delivery_note(self):
@@ -3517,7 +3809,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(
@@ -3630,9 +3922,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
@@ -3727,7 +4017,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)
@@ -3918,7 +4208,7 @@ class TestSalesInvoice(ERPNextTestSuite):
from erpnext.accounts.doctype.loyalty_program.test_loyalty_program import (
create_sales_invoice_record,
)
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
# Set up loyalty program
@@ -4056,7 +4346,7 @@ class TestSalesInvoice(ERPNextTestSuite):
from frappe.model.mapper import map_docs
map_docs(
method="erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
method="erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
source_names=json.dumps([dn1.name, dn2.name]),
target_doc=si,
args=json.dumps({"customer": dn1.customer, "merge_taxes": 1, "filtered_children": []}),
@@ -4099,7 +4389,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected, actual)
def test_pos_returns_without_update_outstanding_for_self(self):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
pos_profile = make_pos_profile()
pos_profile.payments = []
@@ -4469,7 +4759,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(project.total_billed_amount, 300)
def test_pos_returns_with_party_account_currency(self):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
pos_profile = make_pos_profile()
pos_profile.payments = []

View File

@@ -269,7 +269,7 @@ class Subscription(Document):
Returns `True` if the grace period for the `Subscription` has passed
"""
if not self.current_invoice_is_past_due():
return
return False
grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period"))
return getdate(posting_date) >= getdate(add_days(self.current_invoice.due_date, grace_period))
@@ -281,6 +281,9 @@ class Subscription(Document):
if not self.current_invoice or self.is_paid(self.current_invoice):
return False
if not self.current_invoice.due_date:
return False
return getdate(posting_date) >= getdate(self.current_invoice.due_date)
@property
@@ -345,7 +348,13 @@ class Subscription(Document):
frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date"))
def validate_end_date(self) -> None:
if not self.plans:
return
billing_cycle_info = self.get_billing_cycle_data()
if not billing_cycle_info:
return
end_date = add_to_date(self.start_date, **billing_cycle_info)
if self.end_date and getdate(self.end_date) <= getdate(end_date):
@@ -446,8 +455,10 @@ class Subscription(Document):
tax_template = self.purchase_tax_template
if tax_template:
from erpnext.accounts.services.taxes import TaxService
invoice.taxes_and_charges = tax_template
invoice.set_taxes()
TaxService(invoice).set_taxes()
# Due date
if self.days_until_due:
@@ -514,7 +525,7 @@ class Subscription(Document):
item_code = plan_doc.item
if self.party == "Customer":
if self.party_type == "Customer":
deferred_field = "enable_deferred_revenue"
else:
deferred_field = "enable_deferred_expense"
@@ -598,19 +609,28 @@ class Subscription(Document):
if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
return False
if self.generate_invoice_at == "Beginning of the current subscription period" and (
getdate(posting_date) == getdate(self.current_invoice_start)
):
return True
elif self.generate_invoice_at == "Days before the current subscription period" and (
getdate(posting_date) == getdate(add_days(self.current_invoice_start, -1 * self.number_of_days))
):
return True
elif getdate(posting_date) == getdate(self.current_invoice_end):
return True
posting = getdate(posting_date)
if self.generate_invoice_at == "Beginning of the current subscription period":
trigger = getdate(self.current_invoice_start)
elif self.generate_invoice_at == "Days before the current subscription period":
trigger = getdate(add_days(self.current_invoice_start, -1 * self.number_of_days))
else:
trigger = getdate(self.current_invoice_end)
if posting < trigger:
return False
# Cap the late-fire window at one billing cycle past the period end so a
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
billing_cycle_info = self.get_billing_cycle_data()
if billing_cycle_info:
upper = getdate(add_to_date(self.current_invoice_end, **billing_cycle_info))
else:
upper = getdate(self.current_invoice_end)
return posting <= upper
def is_current_invoice_generated(
self,
_current_start_date: DateTimeLikeObject | None = None,
@@ -650,13 +670,6 @@ class Subscription(Document):
if invoice:
return frappe.get_doc(self.invoice_document_type, invoice[0])
def cancel_subscription_at_period_end(self) -> None:
"""
Called when `Subscription.cancel_at_period_end` is truthy
"""
self.status = "Cancelled"
self.cancelation_date = nowdate()
@property
def invoices(self) -> list[dict]:
return frappe.get_all(
@@ -703,7 +716,7 @@ class Subscription(Document):
self.status = "Cancelled"
self.cancelation_date = nowdate()
if to_generate_invoice and self.cancelation_date >= self.current_invoice_start:
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.current_invoice_start):
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
self.save()
@@ -731,7 +744,7 @@ class Subscription(Document):
"""
# Don't process future subscriptions
if nowdate() < self.current_invoice_start:
if getdate(nowdate()) < getdate(self.current_invoice_start):
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
return
@@ -770,10 +783,10 @@ def process_all(subscription: list, posting_date: DateTimeLikeObject | None = No
for subscription_name in subscription:
try:
subscription = frappe.get_doc("Subscription", subscription_name)
subscription.process(posting_date)
sub = frappe.get_doc("Subscription", subscription_name)
sub.process(posting_date)
if not frappe.in_test:
frappe.db.commit()
except frappe.ValidationError:
frappe.db.rollback()
subscription.log_error("Subscription failed")
sub.log_error("Subscription failed")

View File

@@ -16,7 +16,7 @@ from frappe.utils.data import (
)
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.subscription.subscription import get_prorata_factor
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
from erpnext.tests.utils import ERPNextTestSuite
@@ -637,6 +637,62 @@ class TestSubscription(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
subscription = create_subscription(start_date="2018-01-01")
self.assertEqual(subscription.current_invoice_end, "2018-01-31")
subscription.process(posting_date="2018-02-01")
self.assertEqual(len(subscription.invoices), 1)
def test_deferred_revenue_applied_for_customer_subscription(self):
item_code = "_Test Non Stock Item"
frappe.db.set_value("Item", item_code, "enable_deferred_revenue", 1)
try:
subscription = create_subscription(start_date="2018-01-01")
items = subscription.get_items_from_plans(subscription.plans)
self.assertEqual(items[0].get("enable_deferred_revenue"), 1)
self.assertEqual(getdate(items[0]["service_start_date"]), getdate("2018-01-01"))
self.assertEqual(getdate(items[0]["service_end_date"]), getdate("2018-01-31"))
finally:
frappe.db.set_value("Item", item_code, "enable_deferred_revenue", 0)
def test_validate_end_date_with_no_plans_does_not_crash(self):
sub = frappe.new_doc("Subscription")
sub.party_type = "Customer"
sub.party = "_Test Customer"
sub.company = "_Test Company"
sub.start_date = "2018-01-01"
sub.end_date = "2018-03-01"
try:
sub.validate_end_date()
except TypeError as e:
self.fail(f"validate_end_date crashed with no plans: {e}")
def test_process_all_logs_error_when_first_subscription_fails(self):
sub1 = create_subscription(start_date="2018-01-01")
sub2 = create_subscription(start_date="2018-01-02")
processed = []
original_process = Subscription.process
original_rollback = frappe.db.rollback
def patched(self, posting_date=None):
processed.append(self.name)
if self.name == sub1.name:
raise frappe.ValidationError("forced failure")
Subscription.process = patched
# process_all calls frappe.db.rollback() on error which would otherwise wipe
# the test transaction; stub it so we can observe the iteration in isolation.
frappe.db.rollback = lambda *a, **kw: None
try:
process_all([sub1.name, sub2.name])
finally:
Subscription.process = original_process
frappe.db.rollback = original_rollback
self.assertEqual(processed, [sub1.name, sub2.name])
def test_subscription_auto_completion(self):
create_plan(
plan_name="_Test Plan 3 Day",

View File

@@ -4,7 +4,7 @@
import frappe
from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule, get_tax_template
from erpnext.crm.doctype.opportunity.opportunity import make_quotation
from erpnext.crm.doctype.opportunity.mapper import make_quotation
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.tests.utils import ERPNextTestSuite
@@ -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

@@ -9,7 +9,7 @@ from frappe.utils import add_days, add_months, getdate, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.utils import get_fiscal_year
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
from erpnext.tests.utils import ERPNextTestSuite
@@ -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)
@@ -3654,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):
@@ -3720,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

@@ -9,7 +9,7 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal
from erpnext.accounts.party import get_party_account
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite

View File

@@ -7,7 +7,7 @@ import copy
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
from frappe.utils import cint, flt, formatdate, get_link_to_form, getdate, now
from frappe.utils import cint, flt, get_link_to_form, getdate, now
from frappe.utils.caching import request_cache
import erpnext
@@ -18,11 +18,17 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import (
get_dimension_filter_map,
)
from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.services.gl_validator import (
check_freezing_date,
validate_accounting_period,
validate_against_pcv,
validate_allowed_dimensions,
validate_cwip_accounts,
validate_disabled_accounts,
)
from erpnext.accounts.utils import create_payment_ledger_entry, is_immutable_ledger_enabled
from erpnext.controllers.budget_controller import BudgetValidation
from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError
def make_gl_entries(
@@ -132,60 +138,6 @@ def get_accounting_dimensions_for_offsetting_entry(gl_map, company):
return accounting_dimensions_to_offset
def validate_disabled_accounts(gl_map):
accounts = [d.account for d in gl_map if d.account]
disabled_accounts = frappe.get_all(
"Account",
filters={"disabled": 1, "is_group": 0, "company": gl_map[0].company},
fields=["name"],
)
used_disabled_accounts = set(accounts).intersection(set([d.name for d in disabled_accounts]))
if used_disabled_accounts:
account_list = "<br>"
account_list += ", ".join([frappe.bold(d) for d in used_disabled_accounts])
frappe.throw(
_("Cannot create accounting entries against disabled accounts: {0}").format(account_list),
title=_("Disabled Account Selected"),
)
def validate_accounting_period(gl_map):
accounting_periods = frappe.db.sql(
""" SELECT
ap.name as name, ap.exempted_role as exempted_role
FROM
`tabAccounting Period` ap, `tabClosed Document` cd
WHERE
ap.name = cd.parent
AND ap.company = %(company)s
AND ap.disabled = 0
AND cd.closed = 1
AND cd.document_type = %(voucher_type)s
AND %(date)s between ap.start_date and ap.end_date
""",
{
"date": gl_map[0].posting_date,
"company": gl_map[0].company,
"voucher_type": gl_map[0].voucher_type,
},
as_dict=1,
)
if accounting_periods:
if accounting_periods[0].exempted_role:
exempted_roles = accounting_periods[0].exempted_role
if exempted_roles in frappe.get_roles():
return
frappe.throw(
_(
"You cannot create or cancel any accounting entries with in the closed Accounting Period {0}"
).format(frappe.bold(accounting_periods[0].name)),
ClosedAccountingPeriod,
)
def process_gl_map(gl_map, merge_entries=True, precision=None, from_repost=False):
if not gl_map:
return []
@@ -442,33 +394,6 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
validate_expense_against_budget(args)
def validate_cwip_accounts(gl_map):
"""Validate that CWIP account are not used in Journal Entry"""
if gl_map and gl_map[0].voucher_type != "Journal Entry":
return
cwip_enabled = any(
cint(ac.enable_cwip_accounting)
for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
)
if cwip_enabled:
cwip_accounts = [
d[0]
for d in frappe.db.sql(
"""select name from tabAccount
where account_type = 'Capital Work in Progress' and is_group=0"""
)
]
for entry in gl_map:
if entry.account in cwip_accounts:
frappe.throw(
_(
"Account: <b>{0}</b> is capital Work in progress and can not be updated by Journal Entry"
).format(entry.account)
)
def process_debit_credit_difference(gl_map):
precision = get_field_precision(
frappe.get_meta("GL Entry").get_field("debit"),
@@ -715,7 +640,7 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
@@ -796,48 +721,6 @@ def make_reverse_gl_entries(
make_entry(new_gle, adv_adj, "Yes")
def check_freezing_date(posting_date, company, adv_adj=False):
"""
Nobody can do GL Entries where posting date is before freezing date
except authorized person
Administrator has all the roles so this check will be bypassed if any role is allowed to post
Hence stop admin to bypass if accounts are freezed
"""
if not adv_adj:
acc_frozen_till_date = frappe.db.get_value("Company", company, "accounts_frozen_till_date")
if acc_frozen_till_date:
frozen_accounts_modifier = frappe.db.get_value(
"Company", company, "role_allowed_for_frozen_entries"
)
if getdate(posting_date) <= getdate(acc_frozen_till_date) and (
frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator"
):
frappe.throw(
_("You are not authorized to add or update entries before {0}").format(
formatdate(acc_frozen_till_date)
)
)
def validate_against_pcv(is_opening, posting_date, company):
if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
frappe.throw(
_("Opening Entry can not be created after Period Closing Voucher is created."),
title=_("Invalid Opening Entry"),
)
last_pcv_date = frappe.db.get_value(
"Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
)
if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date):
message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date))
message += "</br >"
message += _("You cannot create/amend any accounting entries till this date.")
frappe.throw(message, title=_("Period Closed"))
def set_as_cancel(voucher_type, voucher_no):
"""
Set is_cancelled=1 in all original gl entries for the voucher
@@ -848,39 +731,3 @@ def set_as_cancel(voucher_type, voucher_no):
where voucher_type=%s and voucher_no=%s and is_cancelled = 0""",
(now(), frappe.session.user, voucher_type, voucher_no),
)
def validate_allowed_dimensions(gl_entry, dimension_filter_map):
for key, value in dimension_filter_map.items():
dimension = key[0]
account = key[1]
if gl_entry.account == account:
if value["is_mandatory"] and not gl_entry.get(dimension):
frappe.throw(
_("{0} is mandatory for account {1}").format(
frappe.bold(frappe.unscrub(dimension)), frappe.bold(gl_entry.account)
),
MandatoryAccountDimensionError,
)
if value["allow_or_restrict"] == "Allow":
if gl_entry.get(dimension) and gl_entry.get(dimension) not in value["allowed_dimensions"]:
frappe.throw(
_("Invalid value {0} for {1} against account {2}").format(
frappe.bold(gl_entry.get(dimension)),
frappe.bold(frappe.unscrub(dimension)),
frappe.bold(gl_entry.account),
),
InvalidAccountDimensionError,
)
else:
if gl_entry.get(dimension) and gl_entry.get(dimension) in value["allowed_dimensions"]:
frappe.throw(
_("Invalid value {0} for {1} against account {2}").format(
frappe.bold(gl_entry.get(dimension)),
frappe.bold(frappe.unscrub(dimension)),
frappe.bold(gl_entry.account),
),
InvalidAccountDimensionError,
)

View File

View File

@@ -0,0 +1,26 @@
{
"align": "Left",
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:middle ! important\">\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\t\tcompany_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\">\n\t\t\t\t<div class=\"company-name\">{{ doc.company }}</div>\n\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\", \"city\",\n\t\t\t\t\"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address %} {{\n\t\t\t\tcompany_address.address_line1 or \"\" }}<br>\n\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\">\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %} {% set email =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"email\") %} {% set phone_no =\n\t\t\t\tfrappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ doc.doctype }}</span>\n\t\t\t\t\t<span>{{ doc.name }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>",
"creation": "2026-05-15 15:21:48.255627",
"custom_css": "\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tpadding-right: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\n\t.letter-head td {\n\t\tpadding: 0px !important;\n\t}\n\t.invoice-header {\n\t\twidth: 100%;\n\t}\n\t.logo-cell {\n\t\twidth: 100px;\n\t\ttext-align: center;\n\t\tposition: relative;\n\t}\n\t.logo-container {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t}\n\t.logo-container img {\n\t\tmax-width: 90px;\n\t\tmax-height: 90px;\n\t\tdisplay: inline-block;\n\t\tborder-radius: 15px;\n\t}\n\t.company-details {\n\t\twidth: 40%;\n\t\talign-content: center;\n\t}\n\t.company-name {\n\t\tfont-size: 14px;\n\t\tfont-weight: bold;\n\t\tcolor: #171717;\n\t\tmargin-bottom: 4px;\n\t}\n\t.invoice-info-cell {\n\t\tfloat: right;\n\t\tvertical-align: top;\n\t}\n\t.invoice-info {\n\t\tmargin-bottom: 2px;\n\t}\n\t.invoice-label {\n\t\tcolor: #7c7c7c;\n\t\tdisplay: inline-block;\n\t\tmargin-right: 5px;\n\t}",
"disabled": 0,
"docstatus": 0,
"doctype": "Letter Head",
"footer_align": "Left",
"footer_image_height": 0.0,
"footer_image_width": 0.0,
"footer_source": "Image",
"idx": 0,
"image_height": 0.0,
"image_width": 0.0,
"is_default": 0,
"letter_head_for": "DocType",
"letter_head_name": "Company Letterhead",
"modified": "2026-05-16 15:15:23.014622",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead",
"owner": "Administrator",
"source": "HTML",
"standard": "Yes"
}

View File

@@ -0,0 +1,26 @@
{
"align": "Left",
"content": "<table class=\"letterhead-container\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<td class=\"logo-address\">\n\t\t\t\t{% set company_logo = frappe.db.get_value(\"Company\", doc.company, \"company_logo\") %} {% if\n\t\t\t\tcompany_logo %}\n\t\t\t\t<div class=\"logo\">\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company_logo) }}\">\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t\t<div class=\"company-name\">{{ doc.company }}</div>\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{% if doc.company_address %} {% set company_address = frappe.db.get_value(\"Address\",\n\t\t\t\t\tdoc.company_address, [\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\",\n\t\t\t\t\t\"country\"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =\n\t\t\t\t\tfrappe.db.get_value(\"Address\", doc.billing_address, [\"address_line1\", \"address_line2\",\n\t\t\t\t\t\"city\", \"state\", \"pincode\", \"country\"], as_dict=True) %} {% endif %} {% if company_address\n\t\t\t\t\t%} {{ company_address.address_line1 or \"\" }}<br>\n\t\t\t\t\t{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br>\n\t\t\t\t\t{% endif %} {{ company_address.city or \"\" }}, {{ company_address.state or \"\" }} {{\n\t\t\t\t\tcompany_address.pincode or \"\" }}, {{ company_address.country or \"\"}}<br>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td style=\"vertical-align:top\">\n\t\t\t\t<div style=\"height:90px;margin-bottom:10px;text-align:right\">\n\t\t\t\t\t<div class=\"invoice-title\">{{ doc.doctype }}</div>\n\t\t\t\t\t<div class=\"invoice-number\">{{ doc.name }}</div>\n\t\t\t\t\t<br>\n\t\t\t\t</div>\n\t\t\t\t<div style=\"text-align:left;float:right\" class=\"other-details\">\n\t\t\t\t\t{% set company_details = frappe.db.get_value(\"Company\", doc.company, [\"website\", \"email\",\n\t\t\t\t\t\"phone_no\"], as_dict=True) %} {% set website = company_details.website %} {% set email =\n\t\t\t\t\tcompany_details.email %} {% set phone_no = company_details.phone_no %} {% if website %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Website:\") }}</span><span class=\"contact-value\">{{ website }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if email %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Email:\") }}</span><span class=\"contact-value\">{{ email }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %} {% if phone_no %}\n\t\t\t\t\t<div>\n\t\t\t\t\t\t<span class=\"contact-title\">{{ _(\"Contact:\") }}</span><span class=\"contact-value\">{{ phone_no }}</span>\n\t\t\t\t\t</div>\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\t\t</tr>\n\t</tbody>\n</table>\n",
"creation": "2026-05-15 15:21:48.373815",
"custom_css": "\t.print-format-preview {\n\t\tmargin-top: 12px;\n\t}\n\t.letter-head {\n\t\tborder-radius: 18px;\n\t\tbackground: #f8f8f8;\n\t\tpadding: 12px;\n\t\tmargin-left: 12px;\n\t\tmargin-right: 12px;\n\t}\n\t.letterhead-container {\n\t\twidth: 100%;\n\t}\n\t.letterhead-container .other-details {\n\t\tposition: absolute;\n\t\tright: 0;\n\t\tbottom: 0;\n\t}\n\t.logo-address {\n\t\twidth: 65%;\n\t\tvertical-align: top;\n\t}\n\n\t.letter-head .logo {\n\t\twidth: 90px;\n\t\tdisplay: block;\n\t\tmargin-bottom: 10px;\n\t}\n\n\t.letter-head .logo img {\n\t\tborder-radius: 15px;\n\t}\n\n\t.company-name {\n\t\tcolor: #171717;\n\t\tfont-weight: bold;\n\t\tline-height: 23px;\n\t\tmargin-bottom: 5px;\n\t}\n\n\t.company-address {\n\t\tcolor: #171717;\n\t\twidth: 300px;\n\t}\n\n\t.invoice-title {\n\t\tfont-weight: bold;\n\t}\n\n\t.invoice-number {\n\t\tcolor: #7c7c7c;\n\t}\n\n\t.contact-title {\n\t\tcolor: #7c7c7c;\n\t\twidth: 60px;\n\t\tdisplay: inline-block;\n\t\tvertical-align: top;\n\t\tmargin-right: 10px;\n\t}\n\n\t.contact-value {\n\t\tcolor: #171717;\n\t\tdisplay: inline-block;\n\t}\n\t.letterhead-container td {\n\t\tpadding: 0px !important;\n\t\tposition: relative;\n\t}",
"disabled": 0,
"docstatus": 0,
"doctype": "Letter Head",
"footer_align": "Left",
"footer_image_height": 0.0,
"footer_image_width": 0.0,
"footer_source": "Image",
"idx": 0,
"image_height": 0.0,
"image_width": 0.0,
"is_default": 0,
"letter_head_for": "DocType",
"letter_head_name": "Company Letterhead - Grey",
"modified": "2026-05-16 15:15:19.942207",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead - Grey",
"owner": "Administrator",
"source": "HTML",
"standard": "Yes"
}

View File

@@ -0,0 +1,26 @@
{
"align": "Left",
"content": "<table class=\"invoice-header\">\n\t<tbody>\n\t\t<tr>\n\n\t\t\t<td class=\"logo-cell\" style=\"vertical-align:top\">\n\t\t\t\t{% set company = frappe.get_doc(\"Company\", doc.company) %}\n\n\t\t\t\t<div class=\"logo-container\">\n\t\t\t\t\t{% if company.company_logo %}\n\t\t\t\t\t<img src=\"{{ frappe.utils.get_url(company.company_logo) }}\" alt=\"Company Logo\">\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t</td>\n\n\t\t\t<td class=\"company-details\" style=\"vertical-align:top\">\n\t\t\t\t<div class=\"company-name\">{{ company.name }}</div>\n\n\t\t\t\t{% set company_address_name = frappe.db.get_value(\n\t\t\t\t\t\"Dynamic Link\",\n\t\t\t\t\t{\n\t\t\t\t\t\t\"link_doctype\": \"Company\",\n\t\t\t\t\t\t\"link_name\": company.name,\n\t\t\t\t\t\t\"parenttype\": \"Address\"\n\t\t\t\t\t},\n\t\t\t\t\t\"parent\"\n\t\t\t\t) %}\n\n\t\t\t\t{% if company_address_name %}\n\t\t\t\t\t{% set company_address = frappe.db.get_value(\n\t\t\t\t\t\t\"Address\",\n\t\t\t\t\t\tcompany_address_name,\n\t\t\t\t\t\t[\"address_line1\", \"address_line2\", \"city\", \"state\", \"pincode\", \"country\"],\n\t\t\t\t\t\tas_dict=True\n\t\t\t\t\t) %}\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if company_address %}\n\t\t\t\t<div class=\"company-address\">\n\t\t\t\t\t{{ company_address.address_line1 or \"\" }}\n\n\t\t\t\t\t{% if company_address.address_line2 %}\n\t\t\t\t\t\t<br>{{ company_address.address_line2 }}\n\t\t\t\t\t{% endif %}\n\n\t\t\t\t\t<br>\n\n\t\t\t\t\t{{ company_address.city or \"\" }}\n\t\t\t\t\t{% if company_address.state %}, {{ company_address.state }}{% endif %}\n\t\t\t\t\t{{ company_address.pincode or \"\" }}\n\n\t\t\t\t\t{% if company_address.country %}\n\t\t\t\t\t\t, {{ company_address.country }}\n\t\t\t\t\t{% endif %}\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t\t<td class=\"invoice-info-cell\" style=\"vertical-align:top;text-align:right\">\n\n\t\t\t\t{% set website = frappe.db.get_value(\"Company\", doc.company, \"website\") %}\n\t\t\t\t{% set email = frappe.db.get_value(\"Company\", doc.company, \"email\") %}\n\t\t\t\t{% set phone_no = frappe.db.get_value(\"Company\", doc.company, \"phone_no\") %}\n\n\t\t\t\t{% if website %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Website:\") }}</span>\n\t\t\t\t\t<span>{{ website }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if email %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Email:\") }}</span>\n\t\t\t\t\t<span>{{ email }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\n\t\t\t\t{% if phone_no %}\n\t\t\t\t<div class=\"invoice-info\">\n\t\t\t\t\t<span class=\"invoice-label\">{{ _(\"Contact:\") }}</span>\n\t\t\t\t\t<span>{{ phone_no }}</span>\n\t\t\t\t</div>\n\t\t\t\t{% endif %}\n\t\t\t</td>\n\n\t\t</tr>\n\t</tbody>\n</table>",
"creation": "2026-05-15 19:49:47.582252",
"custom_css": ".letter-head {\n\tborder-radius: 18px;\n\tpadding: 8px 10px;\n\tmargin: 10px 0 14px;\n\tfont-family: Inter, sans-serif;\n\tfont-size: 14px;\n\tcolor: #171717;\n}\n\n.letter-head td {\n\tpadding: 0 !important;\n\tvertical-align: middle;\n}\n\n.invoice-header {\n\twidth: 100%;\n\tborder-collapse: collapse;\n\ttable-layout: fixed;\n\tborder-bottom: 1px solid #ededed;\n\tpadding-bottom: 10px;\n}\n\n.logo-cell {\n\twidth: 100px;\n\ttext-align: center;\n\twhite-space: nowrap;\n}\n\n.logo-container {\n\tdisplay: inline-block;\n\tmargin: auto;\n}\n\n.logo-container img {\n\tmax-width: 95px;\n\tmax-height: 95px;\n\tdisplay: block;\n\tborder-radius: 12px;\n}\n\n.company-details {\n\twidth: 55%;\n\tpadding-left: 10px !important;\n\tline-height: 1.5;\n}\n\n.company-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 4px;\n}\n\n.company-address {\n\tfont-size: 14px;\n\tline-height: 1.5;\n\tcolor: #171717;\n}\n\n.invoice-info-cell {\n\twidth: 240px;\n\ttext-align: right;\n\tvertical-align: top !important;\n\tline-height: 1.5;\n}\n\n.document-name {\n\tfont-size: 14px;\n\tfont-weight: 600;\n\tcolor: #171717;\n\tmargin-bottom: 6px;\n}\n\n.invoice-info {\n\tfont-size: 14px;\n\tcolor: #171717;\n\tmargin-bottom: 2px;\n\tfont-variant-numeric: tabular-nums;\n}\n\n.invoice-label {\n\tcolor: #7c7c7c;\n\tfont-weight: 500;\n\tmargin-right: 4px;\n\tdisplay: inline-block;\n}",
"disabled": 0,
"docstatus": 0,
"doctype": "Letter Head",
"footer_align": "Left",
"footer_image_height": 0.0,
"footer_image_width": 0.0,
"footer_source": "Image",
"idx": 0,
"image_height": 0.0,
"image_width": 0.0,
"is_default": 0,
"letter_head_for": "Report",
"letter_head_name": "Company Letterhead Report",
"modified": "2026-05-16 15:15:26.155770",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Company Letterhead Report",
"owner": "Administrator",
"source": "HTML",
"standard": "Yes"
}

View File

@@ -1,108 +0,0 @@
<style>
.letter-head {
border-radius: 18px;
padding-right: 12px;
margin-left: 12px;
margin-right: 12px;
}
.letter-head td {
padding: 0px !important;
}
.invoice-header {
width: 100%;
}
.logo-cell {
width: 100px;
text-align: center;
position: relative;
}
.logo-container {
width: 90px;
display: block;
}
.logo-container img {
max-width: 90px;
max-height: 90px;
display: inline-block;
border-radius: 15px;
}
.company-details {
width: 40%;
align-content: center;
}
.company-name {
font-size: 14px;
font-weight: bold;
color: #171717;
margin-bottom: 4px;
}
.invoice-info-cell {
float: right;
vertical-align: top;
}
.invoice-info {
margin-bottom: 2px;
}
.invoice-label {
color: #7c7c7c;
display: inline-block;
margin-right: 5px;
}
</style>
<table class="invoice-header">
<tbody>
<tr>
<td class="logo-cell" style="vertical-align: middle !important">
<div class="logo-container">
{% set company_logo = frappe.db.get_value("Company", doc.company, "company_logo") %} {% if
company_logo %}
<img src="{{ frappe.utils.get_url(company_logo) }}" alt="Company Logo" />
{% endif %}
</div>
</td>
<td class="company-details">
<div class="company-name">{{ doc.company }}</div>
{% if doc.company_address %} {% set company_address = frappe.db.get_value("Address",
doc.company_address, ["address_line1", "address_line2", "city", "state", "pincode",
"country"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =
frappe.db.get_value("Address", doc.billing_address, ["address_line1", "address_line2", "city",
"state", "pincode", "country"], as_dict=True) %} {% endif %} {% if company_address %} {{
company_address.address_line1 or "" }}<br />
{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br />
{% endif %} {{ company_address.city or "" }}, {{ company_address.state or "" }} {{
company_address.pincode or "" }}, {{ company_address.country or ""}}<br />
{% endif %}
</td>
<td class="invoice-info-cell">
{% set website = frappe.db.get_value("Company", doc.company, "website") %} {% set email =
frappe.db.get_value("Company", doc.company, "email") %} {% set phone_no =
frappe.db.get_value("Company", doc.company, "phone_no") %}
<div class="invoice-info">
<span class="invoice-label">{{ doc.doctype }}</span>
<span>{{ doc.name }}</span>
</div>
{% if website %}
<div class="invoice-info">
<span class="invoice-label">{{ _("Website:") }}</span>
<span>{{ website }}</span>
</div>
{% endif %} {% if email %}
<div class="invoice-info">
<span class="invoice-label">{{ _("Email:") }}</span>
<span>{{ email }}</span>
</div>
{% endif %} {% if phone_no %}
<div class="invoice-info">
<span class="invoice-label">{{ _("Contact:") }}</span>
<span>{{ phone_no }}</span>
</div>
{% endif %}
</td>
</tr>
</tbody>
</table>

View File

@@ -1,127 +0,0 @@
<style>
.print-format-preview {
margin-top: 12px;
}
.letter-head {
border-radius: 18px;
background: #f8f8f8;
padding: 12px;
margin-left: 12px;
margin-right: 12px;
}
.letterhead-container {
width: 100%;
}
.letterhead-container .other-details {
position: absolute;
right: 0;
bottom: 0;
}
.logo-address {
width: 65%;
vertical-align: top;
}
.letter-head .logo {
width: 90px;
display: block;
margin-bottom: 10px;
}
.letter-head .logo img {
border-radius: 15px;
}
.company-name {
color: #171717;
font-weight: bold;
line-height: 23px;
margin-bottom: 5px;
}
.company-address {
color: #171717;
width: 300px;
}
.invoice-title {
font-weight: bold;
}
.invoice-number {
color: #7c7c7c;
}
.contact-title {
color: #7c7c7c;
width: 60px;
display: inline-block;
vertical-align: top;
margin-right: 10px;
}
.contact-value {
color: #171717;
display: inline-block;
}
.letterhead-container td {
padding: 0px !important;
position: relative;
}
</style>
<table class="letterhead-container">
<tbody>
<tr>
<td class="logo-address">
{% set company_logo = frappe.db.get_value("Company", doc.company, "company_logo") %} {% if
company_logo %}
<div class="logo">
<img src="{{ frappe.utils.get_url(company_logo) }}" />
</div>
{% endif %}
<div class="company-name">{{ doc.company }}</div>
<div class="company-address">
{% if doc.company_address %} {% set company_address = frappe.db.get_value("Address",
doc.company_address, ["address_line1", "address_line2", "city", "state", "pincode",
"country"], as_dict=True) %} {% elif doc.billing_address %} {% set company_address =
frappe.db.get_value("Address", doc.billing_address, ["address_line1", "address_line2",
"city", "state", "pincode", "country"], as_dict=True) %} {% endif %} {% if company_address
%} {{ company_address.address_line1 or "" }}<br />
{% if company_address.address_line2 %} {{ company_address.address_line2 }}<br />
{% endif %} {{ company_address.city or "" }}, {{ company_address.state or "" }} {{
company_address.pincode or "" }}, {{ company_address.country or ""}}<br />
{% endif %}
</div>
</td>
<td style="vertical-align: top">
<div style="height: 90px; margin-bottom: 10px; text-align: right">
<div class="invoice-title">{{ doc.doctype }}</div>
<div class="invoice-number">{{ doc.name }}</div>
<br />
</div>
<div style="text-align: left; float: right" class="other-details">
{% set company_details = frappe.db.get_value("Company", doc.company, ["website", "email",
"phone_no"], as_dict=True) %} {% set website = company_details.website %} {% set email =
company_details.email %} {% set phone_no = company_details.phone_no %} {% if website %}
<div>
<span class="contact-title">{{ _("Website:") }}</span
><span class="contact-value">{{ website }}</span>
</div>
{% endif %} {% if email %}
<div>
<span class="contact-title">{{ _("Email:") }}</span
><span class="contact-value">{{ email }}</span>
</div>
{% endif %} {% if phone_no %}
<div>
<span class="contact-title">{{ _("Contact:") }}</span
><span class="contact-value">{{ phone_no }}</span>
</div>
{% endif %}
</div>
</td>
</tr>
</tbody>
</table>

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

@@ -49,6 +49,25 @@ SALES_TRANSACTION_TYPES = {
}
TRANSACTION_TYPES = PURCHASE_TRANSACTION_TYPES | SALES_TRANSACTION_TYPES
# Party-derived fields that must NOT be auto-copied by `get_mapped_doc` when the
# source and target documents belong to different parties (e.g. Sales Order →
# Purchase Order or inter-company Sales Invoice → Purchase Invoice).
CROSS_PARTY_FIELD_NO_MAP = [
"tax_category",
"tax_id",
"tax_withholding_category",
"taxes_and_charges",
"address_display",
"contact_display",
"contact_mobile",
"contact_email",
"contact_person",
"shipping_address",
"dispatch_address",
"payment_terms_template",
"language",
]
class DuplicatePartyAccountError(frappe.ValidationError):
pass
@@ -65,7 +84,6 @@ def get_party_details(
price_list: str | None = None,
currency: str | None = None,
doctype: str | None = None,
ignore_permissions: bool | None = False,
fetch_payment_terms_template: bool = True,
party_address: str | None = None,
company_address: str | None = None,
@@ -75,8 +93,6 @@ def get_party_details(
):
if not party:
return frappe._dict()
if not frappe.db.exists(party_type, party):
frappe.throw(_("{0}: {1} does not exists").format(party_type, party))
return _get_party_details(
party,
account,
@@ -87,7 +103,7 @@ def get_party_details(
price_list,
currency,
doctype,
ignore_permissions,
False,
fetch_payment_terms_template,
party_address,
company_address,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,32 +1,37 @@
{
"add_total_row": 1,
"apply_user_permissions": 1,
"creation": "2013-04-22 16:16:03",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 3,
"is_standard": "Yes",
"modified": "2017-02-24 20:09:46.150861",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
"owner": "Administrator",
"ref_doctype": "Purchase Invoice",
"report_name": "Accounts Payable",
"report_type": "Script Report",
"add_total_row": 1,
"add_translate_data": 0,
"columns": [],
"creation": "2013-04-22 16:16:03",
"default_print_format": "Accounts Payable Standard",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 3,
"is_standard": "Yes",
"modified": "2026-05-22 14:35:14.716933",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Purchase Invoice",
"report_name": "Accounts Payable",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
},
{
"role": "Purchase User"
},
},
{
"role": "Accounts Manager"
},
},
{
"role": "Auditor"
}
]
}
],
"timeout": 0
}

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