mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-10 16:33:04 +00:00
Compare commits
271 Commits
assets-dev
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69075c7db0 | ||
|
|
ffd0dbdfd9 | ||
|
|
3ad32f4030 | ||
|
|
dfc824ded6 | ||
|
|
dfd7cd0bae | ||
|
|
e083aa4c86 | ||
|
|
c4fbc745db | ||
|
|
2b6234f7af | ||
|
|
88b9911136 | ||
|
|
360f52e636 | ||
|
|
6201fefdfb | ||
|
|
08129ff71c | ||
|
|
5357634b70 | ||
|
|
20ba97aa7d | ||
|
|
d90d4c29e1 | ||
|
|
ddbd61b2a2 | ||
|
|
6a7c9f616e | ||
|
|
a3194720b4 | ||
|
|
7825ddf989 | ||
|
|
e9b67ff682 | ||
|
|
4c3aa9b4f3 | ||
|
|
ca77145522 | ||
|
|
5753c23ccf | ||
|
|
a397e82278 | ||
|
|
9c23229cbf | ||
|
|
08f6af867a | ||
|
|
6988781f81 | ||
|
|
49093b326e | ||
|
|
9503dd0c7f | ||
|
|
bd0acf4413 | ||
|
|
969cdf1b26 | ||
|
|
8db1eb0d27 | ||
|
|
d146dc5435 | ||
|
|
0ca38517f3 | ||
|
|
5d1af7fc93 | ||
|
|
1fab935434 | ||
|
|
d6ba0f0eca | ||
|
|
49164f41b1 | ||
|
|
e36426e235 | ||
|
|
ba936eefab | ||
|
|
5eb9461cfd | ||
|
|
e1e588e416 | ||
|
|
00880eb657 | ||
|
|
ae6aef91bd | ||
|
|
faf92b1368 | ||
|
|
a52c8fdaea | ||
|
|
030e1a77e6 | ||
|
|
d2306b1b29 | ||
|
|
601f39dda7 | ||
|
|
047e4faa90 | ||
|
|
8d7edafc99 | ||
|
|
8f15dd4d5d | ||
|
|
bf769a52c0 | ||
|
|
1e238678d8 | ||
|
|
bb36e956ac | ||
|
|
5641f37381 | ||
|
|
577a79471b | ||
|
|
c2e472b03c | ||
|
|
e5f9698055 | ||
|
|
e45b027a22 | ||
|
|
78cc06f127 | ||
|
|
00646b7ed3 | ||
|
|
9267bd9eea | ||
|
|
298d3d9016 | ||
|
|
a9f0ec83a4 | ||
|
|
f33de37da0 | ||
|
|
2a6d9be18a | ||
|
|
d1765e85aa | ||
|
|
3df8e7bfe6 | ||
|
|
f7460f7be3 | ||
|
|
920abdc0e2 | ||
|
|
e0e3dcc8bf | ||
|
|
9d020365e0 | ||
|
|
0f876c10aa | ||
|
|
7f3ddfb3a1 | ||
|
|
268d98d5f7 | ||
|
|
1be84112a7 | ||
|
|
fcff212eec | ||
|
|
9b1157c914 | ||
|
|
0ba2961103 | ||
|
|
37d2adc74b | ||
|
|
859d4caae4 | ||
|
|
3a50056968 | ||
|
|
e1f6bb70bc | ||
|
|
734fe874f2 | ||
|
|
5aab5502f0 | ||
|
|
5873f55cf0 | ||
|
|
df03524b19 | ||
|
|
18dbc7887b | ||
|
|
7c6b13a838 | ||
|
|
7d72d21bbe | ||
|
|
62fdc4c457 | ||
|
|
b41eb6876a | ||
|
|
9bb71e5ec4 | ||
|
|
c5ff1009b2 | ||
|
|
ff2b9a99e7 | ||
|
|
b82b2c2ebd | ||
|
|
5dbf3fdde0 | ||
|
|
4b0b7adeee | ||
|
|
8db05fc4da | ||
|
|
6a064765d1 | ||
|
|
78d5fbaca4 | ||
|
|
3dba21f814 | ||
|
|
f4705fd5a8 | ||
|
|
f1f66bdf2f | ||
|
|
a02ef40a5b | ||
|
|
1a4b61a822 | ||
|
|
34a0aa2ee9 | ||
|
|
e2a1f6057d | ||
|
|
34d128d752 | ||
|
|
d6a201ed4a | ||
|
|
0a07fb3a4e | ||
|
|
9cecf2e6f9 | ||
|
|
d1fd91a542 | ||
|
|
8e41e75d89 | ||
|
|
7c2406077a | ||
|
|
926bdf5a20 | ||
|
|
b447cbc3c1 | ||
|
|
4affdd51f6 | ||
|
|
a26d8d448c | ||
|
|
8de259a669 | ||
|
|
2ecf8b0466 | ||
|
|
700a7fdad3 | ||
|
|
ca310693ff | ||
|
|
e842812ba5 | ||
|
|
5289752c5f | ||
|
|
3757544359 | ||
|
|
51fee2d602 | ||
|
|
d54db2e0ca | ||
|
|
cb84678198 | ||
|
|
40bcf6e3b6 | ||
|
|
3294490040 | ||
|
|
855eeb1078 | ||
|
|
ef8cc166c1 | ||
|
|
3c5cb8d579 | ||
|
|
5adeca44da | ||
|
|
371b5c7593 | ||
|
|
c271826130 | ||
|
|
4c6f33000b | ||
|
|
635d291b62 | ||
|
|
092d8f771c | ||
|
|
4ee8bbb06b | ||
|
|
53dfef8030 | ||
|
|
d2d28c9e03 | ||
|
|
8b916b40ee | ||
|
|
bca917380d | ||
|
|
64a3be8163 | ||
|
|
3337b47182 | ||
|
|
dfe3280737 | ||
|
|
8a8b89e5dd | ||
|
|
a75693a81f | ||
|
|
d0d9411700 | ||
|
|
c4d28a2612 | ||
|
|
6c46692cc4 | ||
|
|
68b8ba7235 | ||
|
|
e0c285e27e | ||
|
|
b72cde73ba | ||
|
|
260cec3b86 | ||
|
|
cfed16ab6c | ||
|
|
d8760b76a8 | ||
|
|
0b4e20ae98 | ||
|
|
a2a2e1020b | ||
|
|
86726bbd85 | ||
|
|
8164782263 | ||
|
|
0c61ad4e6d | ||
|
|
5074597d00 | ||
|
|
42383c3f36 | ||
|
|
3b2f2168d0 | ||
|
|
36dc196a1d | ||
|
|
04443ae29e | ||
|
|
da82ac86b5 | ||
|
|
efb8336bf8 | ||
|
|
b1882dc83a | ||
|
|
41884cfd2a | ||
|
|
48700a8aa3 | ||
|
|
c34eeee096 | ||
|
|
016b64df6d | ||
|
|
cd7fa56ec4 | ||
|
|
e94bd51764 | ||
|
|
e1ea14b135 | ||
|
|
7afe5d4ee3 | ||
|
|
d154796c82 | ||
|
|
d6f9e4ac3f | ||
|
|
10c18ca801 | ||
|
|
0a49403838 | ||
|
|
f0ba54d957 | ||
|
|
7ee7c4253b | ||
|
|
519dc0b958 | ||
|
|
85be72a403 | ||
|
|
78f9434d14 | ||
|
|
530e587bf2 | ||
|
|
c68918bc18 | ||
|
|
e8fff2fdad | ||
|
|
e460e83516 | ||
|
|
498cd2b371 | ||
|
|
9084570d18 | ||
|
|
c324c823fb | ||
|
|
516406c25b | ||
|
|
61da2302ba | ||
|
|
35ac7155e8 | ||
|
|
28c3d24b86 | ||
|
|
9b85773757 | ||
|
|
341fad04c9 | ||
|
|
0a4fa5e35e | ||
|
|
f9d67ebb1e | ||
|
|
7b456c6405 | ||
|
|
92983255b3 | ||
|
|
7b9f61e058 | ||
|
|
0968adafc8 | ||
|
|
220b6fe572 | ||
|
|
8192d70f83 | ||
|
|
2cf51a0367 | ||
|
|
01e7224210 | ||
|
|
18d1a88a64 | ||
|
|
cfd37f22db | ||
|
|
cfff10463c | ||
|
|
25e3d6042a | ||
|
|
0a02727638 | ||
|
|
a12d666037 | ||
|
|
c7b4806117 | ||
|
|
6c1ac51d7a | ||
|
|
8aaa3a72ef | ||
|
|
2c0f6c50df | ||
|
|
0ee0d6f0c5 | ||
|
|
bb803a8f82 | ||
|
|
983d80f7c5 | ||
|
|
cba6a31497 | ||
|
|
9ad046109c | ||
|
|
29261c5fc2 | ||
|
|
58c90ad651 | ||
|
|
8783689ec5 | ||
|
|
8d3efe287e | ||
|
|
b63e1fd796 | ||
|
|
18188cb1b2 | ||
|
|
001c70831c | ||
|
|
b68daea365 | ||
|
|
e8f9cf6e3f | ||
|
|
55368256fd | ||
|
|
8f05e0596e | ||
|
|
473f6e833a | ||
|
|
d775d540c4 | ||
|
|
b381061742 | ||
|
|
90801550eb | ||
|
|
8677e2df40 | ||
|
|
9c78c9ab7b | ||
|
|
32c4b1d98a | ||
|
|
6467f07459 | ||
|
|
b5c96dfef0 | ||
|
|
cf1817c1ea | ||
|
|
3ec6387425 | ||
|
|
234c4a45b8 | ||
|
|
064340cafb | ||
|
|
dfbd8db9d3 | ||
|
|
58f24c83c0 | ||
|
|
d57786caa2 | ||
|
|
a2f877cee6 | ||
|
|
814c11200a | ||
|
|
f7c744350c | ||
|
|
cf597361f6 | ||
|
|
88f6f182e3 | ||
|
|
4c8f95a1a5 | ||
|
|
9ea56910a1 | ||
|
|
d2b09f71c3 | ||
|
|
f31b3749bc | ||
|
|
30b9e11303 | ||
|
|
4b1d369ac6 | ||
|
|
3592c3086d | ||
|
|
bdf0136fc5 | ||
|
|
7335011814 | ||
|
|
671555edbc | ||
|
|
df6fd782b7 |
25
.github/workflows/review-translation-changes.yaml
vendored
Normal file
25
.github/workflows/review-translation-changes.yaml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Review translation PRs
|
||||
description: "Posts review comments with relevant translation changes that are hard to inspect in the diff view."
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "**/*.po"
|
||||
- "**/*.pot"
|
||||
|
||||
concurrency:
|
||||
group: po-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review-po-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.0.0
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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]'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -592,10 +592,12 @@ def update_account_number(
|
||||
@frappe.whitelist()
|
||||
def merge_account(old: str, new: str):
|
||||
_ensure_idle_system()
|
||||
# Validate properties before merging
|
||||
new_account = frappe.get_cached_doc("Account", new)
|
||||
old_account = frappe.get_cached_doc("Account", old)
|
||||
|
||||
new_account.check_permission("write")
|
||||
old_account.check_permission("write")
|
||||
|
||||
if not new_account:
|
||||
throw(_("Account {0} does not exist").format(new))
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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", "", "", "", "", "", ""],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -121,7 +121,7 @@ class BisectAccountingStatements(Document):
|
||||
|
||||
cur_node.save()
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def build_tree(self):
|
||||
frappe.db.delete("Bisect Nodes")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print");
|
||||
frappe.ui.form.on("Cheque Print Template", {
|
||||
refresh: function (frm) {
|
||||
if (!frm.doc.__islocal) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
if (frappe.user.has_role("System Manager")) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
}
|
||||
|
||||
$(frm.fields_dict.cheque_print_preview.wrapper).empty();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "field:bank_name",
|
||||
"creation": "2016-05-04 14:35:00.402544",
|
||||
"doctype": "DocType",
|
||||
@@ -294,7 +295,7 @@
|
||||
],
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2024-03-27 13:06:44.654989",
|
||||
"modified": "2026-06-08 12:10:35.829531",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cheque Print Template",
|
||||
@@ -325,19 +326,17 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
if not frappe.db.exists("Print Format", template_name):
|
||||
cheque_print = frappe.new_doc("Print Format")
|
||||
cheque_print.update(
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import frappe
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry_against_invoice
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -178,7 +178,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
voucher_type: frm.doc.voucher_type,
|
||||
company: args.company,
|
||||
},
|
||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_inter_company_journal_entry",
|
||||
method: "erpnext.accounts.doctype.journal_entry.mapper.make_inter_company_journal_entry",
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
var doc = frappe.model.sync(r.message)[0];
|
||||
@@ -731,7 +731,7 @@ $.extend(erpnext.journal_entry, {
|
||||
|
||||
reverse_journal_entry: function (frm) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_reverse_journal_entry",
|
||||
method: "erpnext.accounts.doctype.journal_entry.mapper.make_reverse_journal_entry",
|
||||
frm: frm,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -11,10 +11,16 @@ from frappe.model.document import Document
|
||||
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
|
||||
# Re-exported so existing call paths (including custom apps) referencing
|
||||
# erpnext.accounts.doctype.journal_entry.journal_entry.<fn> keep working.
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import (
|
||||
get_payment_entry_against_invoice,
|
||||
get_payment_entry_against_order,
|
||||
)
|
||||
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
|
||||
validate_docs_for_deferred_accounting,
|
||||
validate_docs_for_voucher_types,
|
||||
@@ -24,14 +30,10 @@ 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,
|
||||
)
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_depr_schedule,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import AccountsController
|
||||
from erpnext.setup.utils import get_exchange_rate as _get_exchange_rate
|
||||
|
||||
@@ -126,6 +128,11 @@ class JournalEntry(AccountsController):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate(self):
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
from erpnext.accounts.doctype.journal_entry.services.reference_validator import (
|
||||
JournalEntryReferenceValidator,
|
||||
)
|
||||
|
||||
if self.voucher_type == "Opening Entry":
|
||||
self.is_opening = "Yes"
|
||||
|
||||
@@ -145,7 +152,7 @@ class JournalEntry(AccountsController):
|
||||
self.validate_against_jv()
|
||||
self.validate_stock_accounts()
|
||||
|
||||
self.validate_reference_doc()
|
||||
JournalEntryReferenceValidator(self).validate()
|
||||
if self.docstatus == 0:
|
||||
self.set_against_account()
|
||||
self.create_remarks()
|
||||
@@ -153,7 +160,7 @@ class JournalEntry(AccountsController):
|
||||
self.validate_credit_debit_note()
|
||||
self.validate_empty_accounts_table()
|
||||
self.validate_inter_company_accounts()
|
||||
self.validate_depr_account_and_depr_entry_voucher_type()
|
||||
AssetService(self).validate_depr_account_and_depr_entry_voucher_type()
|
||||
self.validate_company_in_accounting_dimension()
|
||||
self.validate_advance_accounts()
|
||||
|
||||
@@ -187,7 +194,9 @@ class JournalEntry(AccountsController):
|
||||
return self._submit()
|
||||
|
||||
def before_cancel(self):
|
||||
self.has_asset_adjustment_entry()
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
|
||||
AssetService(self).has_asset_adjustment_entry()
|
||||
|
||||
def cancel(self):
|
||||
if len(self.accounts) > 100:
|
||||
@@ -201,10 +210,12 @@ class JournalEntry(AccountsController):
|
||||
self.validate_total_debit_and_credit()
|
||||
|
||||
def on_submit(self):
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
|
||||
self.validate_cheque_info()
|
||||
self.make_gl_entries()
|
||||
self.check_credit_limit()
|
||||
self.update_asset_value()
|
||||
AssetService(self).update_asset_value()
|
||||
self.update_inter_company_jv()
|
||||
self.update_invoice_discounting()
|
||||
JournalTaxWithholding(self).on_submit()
|
||||
@@ -293,6 +304,8 @@ class JournalEntry(AccountsController):
|
||||
def on_cancel(self):
|
||||
# Cancel tax withholding entries
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.services.asset_service import AssetService
|
||||
|
||||
# References for this Journal are removed on the `on_cancel` event in accounts_controller
|
||||
super().on_cancel()
|
||||
|
||||
@@ -317,9 +330,9 @@ class JournalEntry(AccountsController):
|
||||
self.make_gl_entries(1)
|
||||
JournalTaxWithholding(self).on_cancel()
|
||||
self.unlink_advance_entry_reference()
|
||||
self.unlink_asset_reference()
|
||||
AssetService(self).unlink_asset_reference()
|
||||
self.unlink_inter_company_jv()
|
||||
self.unlink_asset_adjustment_entry()
|
||||
AssetService(self).unlink_asset_adjustment_entry()
|
||||
self.update_invoice_discounting()
|
||||
|
||||
def get_title(self):
|
||||
@@ -343,17 +356,6 @@ class JournalEntry(AccountsController):
|
||||
):
|
||||
frappe.throw(_("Total Credit/ Debit Amount should be same as linked Journal Entry"))
|
||||
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self):
|
||||
for d in self.get("accounts"):
|
||||
if d.account_type == "Depreciation":
|
||||
if self.voucher_type != "Depreciation Entry":
|
||||
frappe.throw(
|
||||
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
|
||||
)
|
||||
|
||||
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
|
||||
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
|
||||
|
||||
def validate_stock_accounts(self):
|
||||
if (
|
||||
not erpnext.is_perpetual_inventory_enabled(self.company)
|
||||
@@ -374,75 +376,6 @@ class JournalEntry(AccountsController):
|
||||
StockAccountInvalidTransaction,
|
||||
)
|
||||
|
||||
def update_asset_value(self):
|
||||
self.update_asset_on_depreciation()
|
||||
self.update_asset_on_disposal()
|
||||
|
||||
def update_asset_on_depreciation(self):
|
||||
if self.voucher_type != "Depreciation Entry":
|
||||
return
|
||||
|
||||
for d in self.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_cached_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
self.update_journal_entry_link_on_depr_schedule(asset, d)
|
||||
self.update_value_after_depreciation(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def update_value_after_depreciation(self, asset, depr_amount):
|
||||
fb_idx = 1
|
||||
if self.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation -= depr_amount
|
||||
frappe.db.set_value(
|
||||
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
|
||||
)
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.finance_book)
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.posting_date
|
||||
and not d.journal_entry
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.name)
|
||||
|
||||
def update_asset_on_disposal(self):
|
||||
if self.voucher_type == "Asset Disposal":
|
||||
disposed_assets = []
|
||||
for d in self.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and d.reference_name not in disposed_assets
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Asset",
|
||||
d.reference_name,
|
||||
{
|
||||
"disposal_date": self.posting_date,
|
||||
"journal_entry_for_scrap": self.name,
|
||||
},
|
||||
)
|
||||
asset_doc = frappe.get_doc("Asset", d.reference_name)
|
||||
asset_doc.set_status()
|
||||
disposed_assets.append(d.reference_name)
|
||||
|
||||
def update_inter_company_jv(self):
|
||||
if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference:
|
||||
frappe.db.set_value(
|
||||
@@ -505,59 +438,6 @@ class JournalEntry(AccountsController):
|
||||
d.reference_name = ""
|
||||
d.db_update()
|
||||
|
||||
def unlink_asset_reference(self):
|
||||
for d in self.get("accounts"):
|
||||
if (
|
||||
self.voucher_type == "Depreciation Entry"
|
||||
and d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
je_found = False
|
||||
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if je_found:
|
||||
break
|
||||
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||
|
||||
for s in depr_schedule or []:
|
||||
if s.journal_entry == self.name:
|
||||
s.db_set("journal_entry", None)
|
||||
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
|
||||
je_found = True
|
||||
break
|
||||
if not je_found:
|
||||
fb_idx = 1
|
||||
if self.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
elif self.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name:
|
||||
journal_entry_for_scrap = frappe.db.get_value(
|
||||
"Asset", d.reference_name, "journal_entry_for_scrap"
|
||||
)
|
||||
|
||||
if journal_entry_for_scrap == self.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
|
||||
def unlink_inter_company_jv(self):
|
||||
if self.voucher_type == "Inter Company Journal Entry" and self.inter_company_journal_entry_reference:
|
||||
frappe.db.set_value(
|
||||
@@ -568,28 +448,6 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
frappe.db.set_value("Journal Entry", self.name, "inter_company_journal_entry_reference", "")
|
||||
|
||||
def has_asset_adjustment_entry(self):
|
||||
if self.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
asset_value_adjustment = frappe.db.get_value(
|
||||
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.name}, "name"
|
||||
)
|
||||
if asset_value_adjustment:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
.set(AssetValueAdjustment.journal_entry, None)
|
||||
.where(AssetValueAdjustment.journal_entry == self.name)
|
||||
).run()
|
||||
|
||||
def validate_party(self):
|
||||
for d in self.get("accounts"):
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
@@ -742,166 +600,6 @@ class JournalEntry(AccountsController):
|
||||
)
|
||||
)
|
||||
|
||||
def validate_reference_doc(self):
|
||||
"""Validates reference document"""
|
||||
field_dict = {
|
||||
"Sales Invoice": ["Customer", "Debit To"],
|
||||
"Purchase Invoice": ["Supplier", "Credit To"],
|
||||
"Sales Order": ["Customer"],
|
||||
"Purchase Order": ["Supplier"],
|
||||
}
|
||||
|
||||
self.reference_totals = {}
|
||||
self.reference_types = {}
|
||||
self.reference_accounts = {}
|
||||
|
||||
for d in self.get("accounts"):
|
||||
if not d.reference_type:
|
||||
d.reference_name = None
|
||||
if not d.reference_name:
|
||||
d.reference_type = None
|
||||
if d.reference_type and d.reference_name and (d.reference_type in list(field_dict)):
|
||||
dr_or_cr = (
|
||||
"credit_in_account_currency"
|
||||
if d.reference_type in ("Sales Order", "Sales Invoice")
|
||||
else "debit_in_account_currency"
|
||||
)
|
||||
|
||||
# check debit or credit type Sales / Purchase Order
|
||||
if d.reference_type == "Sales Order" and flt(d.debit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Debit entry can not be linked with a {1}").format(d.idx, d.reference_type)
|
||||
)
|
||||
|
||||
if d.reference_type == "Purchase Order" and flt(d.credit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Credit entry can not be linked with a {1}").format(
|
||||
d.idx, d.reference_type
|
||||
)
|
||||
)
|
||||
|
||||
# set totals
|
||||
if d.reference_name not in self.reference_totals:
|
||||
self.reference_totals[d.reference_name] = 0.0
|
||||
|
||||
if self.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
|
||||
self.reference_totals[d.reference_name] += flt(d.get(dr_or_cr))
|
||||
|
||||
self.reference_types[d.reference_name] = d.reference_type
|
||||
self.reference_accounts[d.reference_name] = d.account
|
||||
|
||||
against_voucher = frappe.db.get_value(
|
||||
d.reference_type, d.reference_name, [scrub(dt) for dt in field_dict.get(d.reference_type)]
|
||||
)
|
||||
|
||||
if not against_voucher:
|
||||
frappe.throw(_("Row {0}: Invalid reference {1}").format(d.idx, d.reference_name))
|
||||
|
||||
# check if party and account match
|
||||
if d.reference_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
if (
|
||||
self.voucher_type in ("Deferred Revenue", "Deferred Expense")
|
||||
and d.reference_detail_no
|
||||
):
|
||||
debit_or_credit = "Debit" if d.debit else "Credit"
|
||||
party_account = get_deferred_booking_accounts(
|
||||
d.reference_type, d.reference_detail_no, debit_or_credit
|
||||
)
|
||||
against_voucher = ["", against_voucher[1]]
|
||||
else:
|
||||
if d.reference_type == "Sales Invoice":
|
||||
party_account = (
|
||||
get_party_account_based_on_invoice_discounting(d.reference_name)
|
||||
or against_voucher[1]
|
||||
)
|
||||
else:
|
||||
party_account = against_voucher[1]
|
||||
|
||||
if (
|
||||
against_voucher[0] != cstr(d.party) or party_account != d.account
|
||||
) and self.voucher_type != "Exchange Gain Or Loss":
|
||||
frappe.throw(
|
||||
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
|
||||
d.idx,
|
||||
field_dict.get(d.reference_type)[0],
|
||||
field_dict.get(d.reference_type)[1],
|
||||
d.reference_type,
|
||||
d.reference_name,
|
||||
)
|
||||
)
|
||||
|
||||
# check if party matches for Sales / Purchase Order
|
||||
if d.reference_type in ("Sales Order", "Purchase Order"):
|
||||
# set totals
|
||||
if against_voucher != d.party:
|
||||
frappe.throw(
|
||||
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||
d.idx, d.party_type, d.party, d.reference_type
|
||||
)
|
||||
)
|
||||
|
||||
self.validate_orders()
|
||||
self.validate_invoices()
|
||||
|
||||
def validate_orders(self):
|
||||
"""Validate totals, closed and docstatus for orders"""
|
||||
for reference_name, total in self.reference_totals.items():
|
||||
reference_type = self.reference_types[reference_name]
|
||||
account = self.reference_accounts[reference_name]
|
||||
|
||||
if reference_type in ("Sales Order", "Purchase Order"):
|
||||
order = frappe.get_doc(reference_type, reference_name)
|
||||
|
||||
if order.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
if flt(order.per_billed) >= 100:
|
||||
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
|
||||
|
||||
if cstr(order.status) == "Closed":
|
||||
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
|
||||
|
||||
account_currency = get_account_currency(account)
|
||||
if account_currency == self.company_currency:
|
||||
voucher_total = order.base_grand_total
|
||||
formatted_voucher_total = fmt_money(
|
||||
voucher_total, order.precision("base_grand_total"), currency=account_currency
|
||||
)
|
||||
else:
|
||||
voucher_total = order.grand_total
|
||||
formatted_voucher_total = fmt_money(
|
||||
voucher_total, order.precision("grand_total"), currency=account_currency
|
||||
)
|
||||
|
||||
if flt(voucher_total) < (flt(order.advance_paid) + total):
|
||||
frappe.throw(
|
||||
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
|
||||
reference_type, reference_name, formatted_voucher_total
|
||||
)
|
||||
)
|
||||
|
||||
def validate_invoices(self):
|
||||
"""Validate totals and docstatus for invoices"""
|
||||
for reference_name, total in self.reference_totals.items():
|
||||
reference_type = self.reference_types[reference_name]
|
||||
|
||||
if reference_type in ("Sales Invoice", "Purchase Invoice") and self.voucher_type not in [
|
||||
"Debit Note",
|
||||
"Credit Note",
|
||||
]:
|
||||
invoice = frappe.get_doc(reference_type, reference_name)
|
||||
|
||||
if invoice.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
precision = invoice.precision("outstanding_amount")
|
||||
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
|
||||
frappe.throw(
|
||||
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
|
||||
reference_type, reference_name, invoice.outstanding_amount
|
||||
)
|
||||
)
|
||||
|
||||
def set_against_account(self):
|
||||
accounts_debited, accounts_credited = [], []
|
||||
if self.voucher_type in ("Deferred Revenue", "Deferred Expense"):
|
||||
@@ -1120,87 +818,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 +912,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(
|
||||
@@ -1384,174 +1008,6 @@ def get_default_bank_cash_account(
|
||||
return frappe._dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_order(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | float | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
|
||||
if dt == "Sales Order":
|
||||
party_type = "Customer"
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||
party_account_currency = get_account_currency(party_account)
|
||||
|
||||
if not amount:
|
||||
if party_account_currency == ref_doc.company_currency:
|
||||
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
|
||||
else:
|
||||
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount,
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Advance Payment received against {dt} {dn}",
|
||||
"is_advance": "Yes",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_invoice(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
party_account = ref_doc.credit_to
|
||||
|
||||
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
|
||||
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
|
||||
):
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": ref_doc.party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount if amount else abs(ref_doc.outstanding_amount),
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
|
||||
"is_advance": "No",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
exchange_rate = 1
|
||||
if args.get("party_account"):
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date in the reference document
|
||||
exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
args.get("party_account"),
|
||||
args.get("party_account_currency"),
|
||||
ref_doc.company,
|
||||
ref_doc.doctype,
|
||||
ref_doc.name,
|
||||
)
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
|
||||
party_row = je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": args.get("party_account"),
|
||||
"party_type": args.get("party_type"),
|
||||
"party": ref_doc.get(args.get("party_type").lower()),
|
||||
"cost_center": cost_center,
|
||||
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
|
||||
"account_currency": args.get("party_account_currency")
|
||||
or get_account_currency(args.get("party_account")),
|
||||
"exchange_rate": exchange_rate,
|
||||
args.get("amount_field_party"): args.get("amount"),
|
||||
"is_advance": args.get("is_advance"),
|
||||
"reference_type": ref_doc.doctype,
|
||||
"reference_name": ref_doc.name,
|
||||
},
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
|
||||
# Make it bank_details
|
||||
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||
if bank_account:
|
||||
bank_row.update(bank_account)
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date of the reference date
|
||||
bank_row.exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
bank_account["account"],
|
||||
bank_account["account_currency"],
|
||||
ref_doc.company,
|
||||
)
|
||||
|
||||
bank_row.cost_center = cost_center
|
||||
|
||||
amount = args.get("debit_in_account_currency") or args.get("amount")
|
||||
|
||||
if bank_row.account_currency == args.get("party_account_currency"):
|
||||
bank_row.set(args.get("amount_field_bank"), amount)
|
||||
else:
|
||||
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
|
||||
|
||||
# Multi currency check again
|
||||
if party_row.account_currency != ref_doc.company_currency or (
|
||||
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||
):
|
||||
je.multi_currency = 1
|
||||
|
||||
je.set_amounts_in_company_currency()
|
||||
je.set_total_debit_credit()
|
||||
|
||||
return je if args.get("journal_entry") else je.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_against_jv(
|
||||
@@ -1774,54 +1230,3 @@ def get_average_exchange_rate(account: str):
|
||||
exchange_rate = bank_balance_in_company_currency / bank_balance_in_account_currency
|
||||
|
||||
return exchange_rate
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = voucher_type
|
||||
journal_entry.company = company
|
||||
journal_entry.posting_date = nowdate()
|
||||
journal_entry.inter_company_journal_entry_reference = name
|
||||
return journal_entry.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
|
||||
get_link_to_form("Journal Entry", existing_reverse)
|
||||
)
|
||||
)
|
||||
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target):
|
||||
target.reversal_of = source.name
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
source_name,
|
||||
{
|
||||
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
|
||||
"Journal Entry Account": {
|
||||
"doctype": "Journal Entry Account",
|
||||
"field_map": {
|
||||
"account_currency": "account_currency",
|
||||
"exchange_rate": "exchange_rate",
|
||||
"debit_in_account_currency": "credit_in_account_currency",
|
||||
"debit": "credit",
|
||||
"credit_in_account_currency": "debit_in_account_currency",
|
||||
"credit": "debit",
|
||||
"reference_type": "reference_type",
|
||||
"reference_name": "reference_name",
|
||||
},
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_process,
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
240
erpnext/accounts/doctype/journal_entry/mapper.py
Normal file
240
erpnext/accounts/doctype/journal_entry/mapper.py
Normal file
@@ -0,0 +1,240 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Document builders that map a source document to a Journal Entry or to a
|
||||
Payment Entry raised against it."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt, get_link_to_form, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_order(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | float | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
|
||||
if dt == "Sales Order":
|
||||
party_type = "Customer"
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||
party_account_currency = get_account_currency(party_account)
|
||||
|
||||
if not amount:
|
||||
if party_account_currency == ref_doc.company_currency:
|
||||
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
|
||||
else:
|
||||
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount,
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Advance Payment received against {dt} {dn}",
|
||||
"is_advance": "Yes",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_invoice(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
):
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
party_account = ref_doc.credit_to
|
||||
|
||||
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
|
||||
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
|
||||
):
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": ref_doc.party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount if amount else abs(ref_doc.outstanding_amount),
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
|
||||
"is_advance": "No",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import (
|
||||
get_default_bank_cash_account,
|
||||
get_exchange_rate,
|
||||
)
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
exchange_rate = 1
|
||||
if args.get("party_account"):
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date in the reference document
|
||||
exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
args.get("party_account"),
|
||||
args.get("party_account_currency"),
|
||||
ref_doc.company,
|
||||
ref_doc.doctype,
|
||||
ref_doc.name,
|
||||
)
|
||||
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
|
||||
party_row = je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": args.get("party_account"),
|
||||
"party_type": args.get("party_type"),
|
||||
"party": ref_doc.get(args.get("party_type").lower()),
|
||||
"cost_center": cost_center,
|
||||
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
|
||||
"account_currency": args.get("party_account_currency")
|
||||
or get_account_currency(args.get("party_account")),
|
||||
"exchange_rate": exchange_rate,
|
||||
args.get("amount_field_party"): args.get("amount"),
|
||||
"is_advance": args.get("is_advance"),
|
||||
"reference_type": ref_doc.doctype,
|
||||
"reference_name": ref_doc.name,
|
||||
},
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
|
||||
# Make it bank_details
|
||||
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||
if bank_account:
|
||||
bank_row.update(bank_account)
|
||||
# Modified to include the posting date for which the exchange rate is required.
|
||||
# Assumed to be the posting date of the reference date
|
||||
bank_row.exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
bank_account["account"],
|
||||
bank_account["account_currency"],
|
||||
ref_doc.company,
|
||||
)
|
||||
|
||||
bank_row.cost_center = cost_center
|
||||
|
||||
amount = args.get("debit_in_account_currency") or args.get("amount")
|
||||
|
||||
if bank_row.account_currency == args.get("party_account_currency"):
|
||||
bank_row.set(args.get("amount_field_bank"), amount)
|
||||
else:
|
||||
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
|
||||
|
||||
# Multi currency check again
|
||||
if party_row.account_currency != ref_doc.company_currency or (
|
||||
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||
):
|
||||
je.multi_currency = 1
|
||||
|
||||
je.set_amounts_in_company_currency()
|
||||
je.set_total_debit_credit()
|
||||
|
||||
return je if args.get("journal_entry") else je.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str):
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = voucher_type
|
||||
journal_entry.company = company
|
||||
journal_entry.posting_date = nowdate()
|
||||
journal_entry.inter_company_journal_entry_reference = name
|
||||
return journal_entry.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None):
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
|
||||
get_link_to_form("Journal Entry", existing_reverse)
|
||||
)
|
||||
)
|
||||
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target):
|
||||
target.reversal_of = source.name
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
source_name,
|
||||
{
|
||||
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
|
||||
"Journal Entry Account": {
|
||||
"doctype": "Journal Entry Account",
|
||||
"field_map": {
|
||||
"account_currency": "account_currency",
|
||||
"exchange_rate": "exchange_rate",
|
||||
"debit_in_account_currency": "credit_in_account_currency",
|
||||
"debit": "credit",
|
||||
"credit_in_account_currency": "debit_in_account_currency",
|
||||
"credit": "debit",
|
||||
"reference_type": "reference_type",
|
||||
"reference_name": "reference_name",
|
||||
},
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_process,
|
||||
)
|
||||
|
||||
return doclist
|
||||
181
erpnext/accounts/doctype/journal_entry/services/asset_service.py
Normal file
181
erpnext/accounts/doctype/journal_entry/services/asset_service.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# 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.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_depr_schedule,
|
||||
)
|
||||
|
||||
|
||||
class AssetService:
|
||||
"""Keeps Assets in sync with the Journal Entries that depreciate, dispose or
|
||||
adjust them.
|
||||
|
||||
On submit of a Depreciation Entry it reduces the asset value and links the
|
||||
depreciation schedule; on submit of an Asset Disposal it marks the asset
|
||||
disposed. On cancel it reverses those links. It also guards cancellation of
|
||||
Journal Entries tied to asset scrapping or value adjustments.
|
||||
"""
|
||||
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self):
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.account_type == "Depreciation":
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
frappe.throw(
|
||||
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
|
||||
)
|
||||
|
||||
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
|
||||
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
|
||||
|
||||
def has_asset_adjustment_entry(self):
|
||||
if self.doc.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
asset_value_adjustment = frappe.db.get_value(
|
||||
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name"
|
||||
)
|
||||
if asset_value_adjustment:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def update_asset_value(self):
|
||||
self.update_asset_on_depreciation()
|
||||
self.update_asset_on_disposal()
|
||||
|
||||
def update_asset_on_depreciation(self):
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
return
|
||||
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_cached_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
self.update_journal_entry_link_on_depr_schedule(asset, d)
|
||||
self.update_value_after_depreciation(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def update_value_after_depreciation(self, asset, depr_amount):
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation -= depr_amount
|
||||
frappe.db.set_value(
|
||||
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
|
||||
)
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.doc.posting_date
|
||||
and not d.journal_entry
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
|
||||
|
||||
def update_asset_on_disposal(self):
|
||||
if self.doc.voucher_type == "Asset Disposal":
|
||||
disposed_assets = []
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and d.reference_name not in disposed_assets
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Asset",
|
||||
d.reference_name,
|
||||
{
|
||||
"disposal_date": self.doc.posting_date,
|
||||
"journal_entry_for_scrap": self.doc.name,
|
||||
},
|
||||
)
|
||||
asset_doc = frappe.get_doc("Asset", d.reference_name)
|
||||
asset_doc.set_status()
|
||||
disposed_assets.append(d.reference_name)
|
||||
|
||||
def unlink_asset_reference(self):
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
self.doc.voucher_type == "Depreciation Entry"
|
||||
and d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
je_found = False
|
||||
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if je_found:
|
||||
break
|
||||
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||
|
||||
for s in depr_schedule or []:
|
||||
if s.journal_entry == self.doc.name:
|
||||
s.db_set("journal_entry", None)
|
||||
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
|
||||
je_found = True
|
||||
break
|
||||
if not je_found:
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation += d.debit
|
||||
fb_row.db_update()
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
elif (
|
||||
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
|
||||
):
|
||||
journal_entry_for_scrap = frappe.db.get_value(
|
||||
"Asset", d.reference_name, "journal_entry_for_scrap"
|
||||
)
|
||||
|
||||
if journal_entry_for_scrap == self.doc.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self):
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
.set(AssetValueAdjustment.journal_entry, None)
|
||||
.where(AssetValueAdjustment.journal_entry == self.doc.name)
|
||||
).run()
|
||||
103
erpnext/accounts/doctype/journal_entry/services/gl_composer.py
Normal file
103
erpnext/accounts/doctype/journal_entry/services/gl_composer.py
Normal 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
|
||||
@@ -0,0 +1,191 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.utils import cstr, flt, fmt_money
|
||||
|
||||
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
REFERENCE_PARTY_ACCOUNT_FIELDS = {
|
||||
"Sales Invoice": ["Customer", "Debit To"],
|
||||
"Purchase Invoice": ["Supplier", "Credit To"],
|
||||
"Sales Order": ["Customer"],
|
||||
"Purchase Order": ["Supplier"],
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryReferenceValidator:
|
||||
"""Validates Journal Entry account rows against their referenced documents.
|
||||
|
||||
For each row that links a Sales/Purchase Invoice or Order, this checks the
|
||||
debit/credit direction, party and account match, and aggregates per-reference
|
||||
totals (held on the document as ``reference_totals``/``reference_types``/
|
||||
``reference_accounts``) which are then validated against the referenced
|
||||
orders and invoices.
|
||||
"""
|
||||
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def validate(self):
|
||||
self.doc.reference_totals = {}
|
||||
self.doc.reference_types = {}
|
||||
self.doc.reference_accounts = {}
|
||||
for row in self.doc.get("accounts"):
|
||||
self._normalize_reference_fields(row)
|
||||
if not self._has_party_reference(row):
|
||||
continue
|
||||
self._validate_order_direction(row)
|
||||
self._register_reference(row)
|
||||
self._validate_reference_party_and_account(row)
|
||||
|
||||
self._validate_orders()
|
||||
self._validate_invoices()
|
||||
|
||||
def _normalize_reference_fields(self, row):
|
||||
if not row.reference_type:
|
||||
row.reference_name = None
|
||||
if not row.reference_name:
|
||||
row.reference_type = None
|
||||
|
||||
def _has_party_reference(self, row):
|
||||
return bool(
|
||||
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
|
||||
)
|
||||
|
||||
def _reference_amount_field(self, row):
|
||||
if row.reference_type in ("Sales Order", "Sales Invoice"):
|
||||
return "credit_in_account_currency"
|
||||
return "debit_in_account_currency"
|
||||
|
||||
def _validate_order_direction(self, row):
|
||||
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
if row.reference_type == "Purchase Order" and flt(row.credit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
|
||||
def _register_reference(self, row):
|
||||
if row.reference_name not in self.doc.reference_totals:
|
||||
self.doc.reference_totals[row.reference_name] = 0.0
|
||||
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
|
||||
self.doc.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row)))
|
||||
self.doc.reference_types[row.reference_name] = row.reference_type
|
||||
self.doc.reference_accounts[row.reference_name] = row.account
|
||||
|
||||
def _validate_reference_party_and_account(self, row):
|
||||
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
|
||||
against_voucher = frappe.db.get_value(
|
||||
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
|
||||
)
|
||||
if not against_voucher:
|
||||
frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name))
|
||||
|
||||
if row.reference_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
self._validate_invoice_party_and_account(row, against_voucher, party_fields)
|
||||
elif row.reference_type in ("Sales Order", "Purchase Order"):
|
||||
self._validate_order_party(row, against_voucher)
|
||||
|
||||
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields):
|
||||
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
|
||||
if self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
return
|
||||
if against_party != cstr(row.party) or party_account != row.account:
|
||||
frappe.throw(
|
||||
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
|
||||
row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_invoice_party_account(self, row, against_voucher):
|
||||
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
|
||||
debit_or_credit = "Debit" if row.debit else "Credit"
|
||||
party_account = get_deferred_booking_accounts(
|
||||
row.reference_type, row.reference_detail_no, debit_or_credit
|
||||
)
|
||||
return party_account, ""
|
||||
if row.reference_type == "Sales Invoice":
|
||||
party_account = (
|
||||
get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1]
|
||||
)
|
||||
else:
|
||||
party_account = against_voucher[1]
|
||||
return party_account, against_voucher[0]
|
||||
|
||||
def _validate_order_party(self, row, against_voucher):
|
||||
if against_voucher != row.party:
|
||||
frappe.throw(
|
||||
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||
row.idx, row.party_type, row.party, row.reference_type
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_orders(self):
|
||||
"""Validate totals, closed and docstatus for orders"""
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
account = self.doc.reference_accounts[reference_name]
|
||||
if reference_type not in ("Sales Order", "Purchase Order"):
|
||||
continue
|
||||
|
||||
order = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_order_status(order, reference_type, reference_name)
|
||||
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
|
||||
|
||||
def _validate_order_status(self, order, reference_type, reference_name):
|
||||
if order.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
if flt(order.per_billed) >= 100:
|
||||
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
|
||||
if cstr(order.status) == "Closed":
|
||||
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
|
||||
|
||||
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name):
|
||||
account_currency = get_account_currency(account)
|
||||
if account_currency == self.doc.company_currency:
|
||||
voucher_total = order.base_grand_total
|
||||
field = "base_grand_total"
|
||||
else:
|
||||
voucher_total = order.grand_total
|
||||
field = "grand_total"
|
||||
|
||||
if flt(voucher_total) < (flt(order.advance_paid) + total):
|
||||
formatted_voucher_total = fmt_money(
|
||||
voucher_total, order.precision(field), currency=account_currency
|
||||
)
|
||||
frappe.throw(
|
||||
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
|
||||
reference_type, reference_name, formatted_voucher_total
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_invoices(self):
|
||||
"""Validate totals and docstatus for invoices"""
|
||||
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
|
||||
return
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
if reference_type not in ("Sales Invoice", "Purchase Invoice"):
|
||||
continue
|
||||
invoice = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
|
||||
|
||||
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name):
|
||||
if invoice.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
precision = invoice.precision("outstanding_amount")
|
||||
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
|
||||
frappe.throw(
|
||||
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
|
||||
reference_type, reference_name, invoice.outstanding_amount
|
||||
)
|
||||
)
|
||||
@@ -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":
|
||||
@@ -204,7 +204,7 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
self.assertFalse(gle)
|
||||
|
||||
def test_reverse_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||
|
||||
jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False)
|
||||
|
||||
@@ -609,6 +609,85 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
jv.save()
|
||||
self.assertRaises(frappe.ValidationError, jv.submit)
|
||||
|
||||
def test_validate_reference_doc_debit_against_sales_order_throws(self):
|
||||
"""Characterize: a debit entry linked to a Sales Order is rejected."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
jv = make_journal_entry("Debtors - _TC", "_Test Cash - _TC", 100, save=False)
|
||||
jv.accounts[0].party_type = "Customer"
|
||||
jv.accounts[0].party = "_Test Customer"
|
||||
jv.accounts[0].reference_type = "Sales Order"
|
||||
jv.accounts[0].reference_name = sales_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Debit entry can not be linked", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_credit_against_purchase_order_throws(self):
|
||||
"""Characterize: a credit entry linked to a Purchase Order is rejected."""
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
|
||||
purchase_order = create_purchase_order()
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Creditors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Supplier"
|
||||
jv.accounts[1].party = "_Test Supplier"
|
||||
jv.accounts[1].reference_type = "Purchase Order"
|
||||
jv.accounts[1].reference_name = purchase_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Credit entry can not be linked", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_nonexistent_reference_rejected(self):
|
||||
"""Characterize: a JE referencing a non-existent invoice is rejected by link validation.
|
||||
|
||||
Note: the controller's own "Invalid reference" branch is unreachable in normal flow
|
||||
because Frappe link validation rejects the missing reference before validate_reference_doc.
|
||||
"""
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = "NON-EXISTENT-SI"
|
||||
self.assertRaises(frappe.LinkValidationError, jv.insert)
|
||||
|
||||
def test_validate_reference_doc_invoice_party_mismatch_throws(self):
|
||||
"""Characterize: an invoice reference whose party differs from the row party is rejected."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = other_customer
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Party / Account does not match", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_order_party_mismatch_throws(self):
|
||||
"""Characterize: a Sales Order reference whose party differs from the row party is rejected."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = other_customer
|
||||
jv.accounts[1].is_advance = "Yes"
|
||||
jv.accounts[1].reference_type = "Sales Order"
|
||||
jv.accounts[1].reference_name = sales_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_populates_reference_side_effects(self):
|
||||
"""Characterize: a valid invoice reference populates reference_totals/types/accounts."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
jv.insert()
|
||||
self.assertEqual(jv.reference_totals[invoice.name], 100.0)
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
|
||||
|
||||
|
||||
def make_journal_entry(
|
||||
account1,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -208,6 +208,7 @@ class PaymentEntry(AccountsController):
|
||||
self.make_gl_entries()
|
||||
self.update_outstanding_amounts()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def validate_for_repost(self):
|
||||
validate_docs_for_voucher_types(["Payment Entry"])
|
||||
@@ -314,6 +315,7 @@ class PaymentEntry(AccountsController):
|
||||
self.update_outstanding_amounts()
|
||||
self.delink_advance_entry_references()
|
||||
self.set_status()
|
||||
self.trigger_invoice_update_for_subscriptions()
|
||||
|
||||
def update_payment_requests(self, cancel=False):
|
||||
from erpnext.accounts.doctype.payment_request.payment_request import (
|
||||
@@ -505,6 +507,19 @@ class PaymentEntry(AccountsController):
|
||||
doc = frappe.get_lazy_doc(reference.reference_doctype, reference.reference_name)
|
||||
doc.delink_advance_entries(self.name)
|
||||
|
||||
def trigger_invoice_update_for_subscriptions(self):
|
||||
invoice_names = set()
|
||||
for ref in self.references:
|
||||
if ref.reference_doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
invoice_names.add((ref.reference_doctype, ref.reference_name))
|
||||
|
||||
for doctype, name in invoice_names:
|
||||
try:
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
doc.refresh_subscription_status()
|
||||
except Exception:
|
||||
frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name))
|
||||
|
||||
def set_missing_values(self):
|
||||
if self.payment_type == "Internal Transfer":
|
||||
for field in (
|
||||
@@ -1287,17 +1302,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 +1320,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 +1441,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
|
||||
@@ -2281,6 +2036,9 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
|
||||
if args.get("party_type") == "Member":
|
||||
return
|
||||
|
||||
if args.get("party_type") and args.get("party"):
|
||||
frappe.has_permission(args["party_type"], "read", args["party"], throw=True)
|
||||
|
||||
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
|
||||
args["get_outstanding_invoices"] = True
|
||||
|
||||
@@ -2776,6 +2534,7 @@ def get_reference_details(
|
||||
):
|
||||
total_amount = outstanding_amount = exchange_rate = account = None
|
||||
|
||||
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
|
||||
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
|
||||
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
|
||||
|
||||
@@ -3574,3 +3333,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",
|
||||
)
|
||||
|
||||
293
erpnext/accounts/doctype/payment_entry/services/gl_composer.py
Normal file
293
erpnext/accounts/doctype/payment_entry/services/gl_composer.py
Normal 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,
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ 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
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.stock_ledger import is_negative_stock_allowed
|
||||
|
||||
@@ -241,13 +243,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 +290,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")
|
||||
|
||||
@@ -402,7 +404,7 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.serial_and_batch_bundle:
|
||||
if frappe.db.exists("Product Bundle", d.item_code):
|
||||
if get_active_product_bundle(d.item_code):
|
||||
(
|
||||
availability,
|
||||
is_stock_item,
|
||||
@@ -745,7 +747,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(
|
||||
@@ -913,7 +917,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
return bin_qty - pos_sales_qty, is_stock_item, is_negative_stock_allowed(item_code=item_code)
|
||||
else:
|
||||
is_stock_item = True
|
||||
if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}):
|
||||
if get_active_product_bundle(item_code):
|
||||
return get_bundle_availability(item_code, warehouse), is_stock_item, False
|
||||
else:
|
||||
is_stock_item = False
|
||||
@@ -923,7 +927,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
|
||||
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
is_stock_item = True
|
||||
bundle = frappe.get_doc("Product Bundle", item_code)
|
||||
bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(item_code))
|
||||
availabilities = []
|
||||
for bundle_item in bundle.items:
|
||||
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
|
||||
@@ -942,7 +946,7 @@ def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
|
||||
|
||||
def get_bundle_availability(bundle_item_code, warehouse):
|
||||
product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)
|
||||
product_bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(bundle_item_code))
|
||||
|
||||
bundle_bin_qty = 1000000
|
||||
for item in product_bundle.items:
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -125,6 +127,23 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -858,7 +877,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-20 16:16:12.322024",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -315,32 +315,3 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_default_profile(pos_profile: str, company: str):
|
||||
modified = now()
|
||||
user = frappe.session.user
|
||||
|
||||
if pos_profile and company:
|
||||
frappe.db.sql(
|
||||
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
set
|
||||
pfu.default = 0, pf.modified = %s, pf.modified_by = %s
|
||||
where
|
||||
pfu.user = %s and pf.name = pfu.parent and pf.company = %s
|
||||
and pfu.default = 1""",
|
||||
(modified, user, user, company),
|
||||
auto_commit=1,
|
||||
)
|
||||
|
||||
frappe.db.sql(
|
||||
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
set
|
||||
pfu.default = 1, pf.modified = %s, pf.modified_by = %s
|
||||
where
|
||||
pfu.user = %s and pf.name = pfu.parent and pf.company = %s and pf.name = %s
|
||||
""",
|
||||
(modified, user, user, company, pos_profile),
|
||||
auto_commit=1,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -131,6 +131,7 @@ def is_job_running(job_name: str) -> bool:
|
||||
@frappe.whitelist()
|
||||
def pause_job_for_doc(docname: str | None = None):
|
||||
if docname:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Paused")
|
||||
log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname})
|
||||
if log:
|
||||
@@ -145,6 +146,8 @@ def trigger_job_for_doc(docname: str | None = None):
|
||||
if not docname:
|
||||
return
|
||||
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "auto_reconcile_payments"):
|
||||
frappe.throw(
|
||||
_("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format(
|
||||
@@ -218,10 +221,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)
|
||||
|
||||
@@ -92,6 +92,7 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
@@ -101,6 +101,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -521,6 +522,7 @@ def download_statements(document_name: str):
|
||||
@frappe.whitelist()
|
||||
def send_emails(document_name: str, from_scheduler: bool = False, posting_date: str | None = None):
|
||||
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
|
||||
doc.check_permission()
|
||||
report = get_report_pdf(doc, consolidated=False)
|
||||
|
||||
if report:
|
||||
@@ -577,6 +579,7 @@ def send_emails(document_name: str, from_scheduler: bool = False, posting_date:
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_auto_email():
|
||||
frappe.has_permission("Process Statement Of Accounts", throw=True)
|
||||
selected = frappe.get_list(
|
||||
"Process Statement Of Accounts",
|
||||
filters={"enable_auto_email": 1},
|
||||
|
||||
@@ -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()
|
||||
|
||||
129
erpnext/accounts/doctype/purchase_invoice/mapper.py
Normal file
129
erpnext/accounts/doctype/purchase_invoice/mapper.py
Normal 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
|
||||
@@ -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
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Purchase Receipt billing sync and provisional-entry cancellation for Purchase Invoice."""
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
|
||||
update_billed_amount_based_on_po,
|
||||
update_billing_percentage,
|
||||
)
|
||||
|
||||
|
||||
class BillingStatusService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def update_billing_status_in_pr(self, update_modified: bool = True) -> None:
|
||||
doc = self.doc
|
||||
if doc.is_return and not doc.update_billed_amount_in_purchase_receipt:
|
||||
return
|
||||
|
||||
updated_pr = []
|
||||
po_details = []
|
||||
|
||||
pr_details_billed_amt = self.get_pr_details_billed_amt()
|
||||
|
||||
for d in doc.get("items"):
|
||||
if d.pr_detail:
|
||||
frappe.db.set_value(
|
||||
"Purchase Receipt Item",
|
||||
d.pr_detail,
|
||||
"billed_amt",
|
||||
flt(pr_details_billed_amt.get(d.pr_detail)),
|
||||
update_modified=update_modified,
|
||||
)
|
||||
updated_pr.append(d.purchase_receipt)
|
||||
elif d.po_detail:
|
||||
po_details.append(d.po_detail)
|
||||
|
||||
if po_details:
|
||||
updated_pr += update_billed_amount_based_on_po(po_details, update_modified)
|
||||
|
||||
adjust_incoming_rate = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
|
||||
)
|
||||
|
||||
for pr in set(updated_pr):
|
||||
pr_doc = frappe.get_lazy_doc("Purchase Receipt", pr)
|
||||
update_billing_percentage(
|
||||
pr_doc, update_modified=update_modified, adjust_incoming_rate=adjust_incoming_rate
|
||||
)
|
||||
|
||||
def get_pr_details_billed_amt(self) -> dict:
|
||||
# Get billed amount based on purchase receipt item reference (pr_detail) in purchase invoice
|
||||
|
||||
pr_details_billed_amt = {}
|
||||
pr_details = [d.get("pr_detail") for d in self.doc.get("items") if d.get("pr_detail")]
|
||||
if pr_details:
|
||||
doctype = frappe.qb.DocType("Purchase Invoice Item")
|
||||
query = (
|
||||
frappe.qb.from_(doctype)
|
||||
.select(doctype.pr_detail, Sum(doctype.amount))
|
||||
.where(doctype.pr_detail.isin(pr_details) & doctype.docstatus == 1)
|
||||
.groupby(doctype.pr_detail)
|
||||
)
|
||||
|
||||
pr_details_billed_amt = frappe._dict(query.run(as_list=1))
|
||||
|
||||
return pr_details_billed_amt
|
||||
|
||||
def cancel_provisional_entries(self) -> None:
|
||||
rows = set()
|
||||
purchase_receipts = set()
|
||||
for d in self.doc.items:
|
||||
if d.purchase_receipt:
|
||||
purchase_receipts.add(d.purchase_receipt)
|
||||
rows.add(d.name)
|
||||
|
||||
if rows:
|
||||
# cancel gl entries
|
||||
gle = qb.DocType("GL Entry")
|
||||
gle_update_query = (
|
||||
qb.update(gle)
|
||||
.set(gle.is_cancelled, 1)
|
||||
.where(
|
||||
(gle.voucher_type == "Purchase Receipt")
|
||||
& (gle.voucher_no.isin(purchase_receipts))
|
||||
& (gle.voucher_detail_no.isin(rows))
|
||||
)
|
||||
)
|
||||
gle_update_query.run()
|
||||
@@ -0,0 +1,183 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Expense account resolution for Purchase Invoice items."""
|
||||
|
||||
import frappe
|
||||
from frappe import _, throw
|
||||
from frappe.utils import get_link_to_form
|
||||
|
||||
import erpnext
|
||||
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
|
||||
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
|
||||
from erpnext.controllers.accounts_controller import validate_account_head
|
||||
|
||||
|
||||
class ExpenseAccountService:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
|
||||
def set_expense_account(self, for_validate: bool = False) -> None:
|
||||
doc = self.doc
|
||||
auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company)
|
||||
|
||||
if auto_accounting_for_stock:
|
||||
stock_not_billed_account = doc.get_company_default("stock_received_but_not_billed")
|
||||
stock_items = doc.get_stock_items()
|
||||
|
||||
doc.asset_received_but_not_billed = None
|
||||
|
||||
inventory_account_map = {}
|
||||
if doc.update_stock:
|
||||
doc.validate_item_code()
|
||||
doc.validate_warehouse(for_validate)
|
||||
if auto_accounting_for_stock:
|
||||
inventory_account_map = doc.get_inventory_account_map()
|
||||
|
||||
for item in doc.get("items"):
|
||||
# in case of auto inventory accounting,
|
||||
# expense account is always "Stock Received But Not Billed" for a stock item
|
||||
# except opening entry, drop-ship entry and fixed asset items
|
||||
if (
|
||||
auto_accounting_for_stock
|
||||
and item.item_code in stock_items
|
||||
and doc.is_opening == "No"
|
||||
and not item.is_fixed_asset
|
||||
and (
|
||||
not item.po_detail
|
||||
or not frappe.db.get_value("Purchase Order Item", item.po_detail, "delivered_by_supplier")
|
||||
)
|
||||
):
|
||||
if doc.update_stock and item.warehouse and (not item.from_warehouse):
|
||||
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
|
||||
|
||||
if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]:
|
||||
msg = _(
|
||||
"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account"
|
||||
).format(
|
||||
item.idx,
|
||||
frappe.bold(_inv_dict["account"]),
|
||||
frappe.bold(item.expense_account),
|
||||
frappe.bold(item.warehouse),
|
||||
)
|
||||
frappe.msgprint(msg, title=_("Expense Head Changed"))
|
||||
item.expense_account = _inv_dict["account"]
|
||||
else:
|
||||
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
|
||||
if item.purchase_receipt:
|
||||
negative_expense_booked_in_pr = frappe.db.sql(
|
||||
"""select name from `tabGL Entry`
|
||||
where voucher_type='Purchase Receipt' and voucher_no=%s and account = %s""",
|
||||
(item.purchase_receipt, stock_not_billed_account),
|
||||
)
|
||||
|
||||
if negative_expense_booked_in_pr:
|
||||
if (
|
||||
for_validate
|
||||
and item.expense_account
|
||||
and item.expense_account != stock_not_billed_account
|
||||
):
|
||||
msg = _(
|
||||
"Row {0}: Expense Head changed to {1} because expense is booked against this account in Purchase Receipt {2}"
|
||||
).format(
|
||||
item.idx,
|
||||
frappe.bold(stock_not_billed_account),
|
||||
frappe.bold(item.purchase_receipt),
|
||||
)
|
||||
frappe.msgprint(msg, title=_("Expense Head Changed"))
|
||||
|
||||
item.expense_account = stock_not_billed_account
|
||||
else:
|
||||
# If no purchase receipt present then book expense in 'Stock Received But Not Billed'
|
||||
# This is done in cases when Purchase Invoice is created before Purchase Receipt
|
||||
if (
|
||||
for_validate
|
||||
and item.expense_account
|
||||
and item.expense_account != stock_not_billed_account
|
||||
):
|
||||
msg = _(
|
||||
"Row {0}: Expense Head changed to {1} as no Purchase Receipt is created against Item {2}."
|
||||
).format(
|
||||
item.idx, frappe.bold(stock_not_billed_account), frappe.bold(item.item_code)
|
||||
)
|
||||
msg += "<br>"
|
||||
msg += _(
|
||||
"This is done to handle accounting for cases when Purchase Receipt is created after Purchase Invoice"
|
||||
)
|
||||
frappe.msgprint(msg, title=_("Expense Head Changed"))
|
||||
|
||||
item.expense_account = stock_not_billed_account
|
||||
elif item.is_fixed_asset:
|
||||
account = None
|
||||
if not item.pr_detail and item.po_detail:
|
||||
receipt_item = frappe.get_cached_value(
|
||||
"Purchase Receipt Item",
|
||||
{
|
||||
"purchase_order": item.purchase_order,
|
||||
"purchase_order_item": item.po_detail,
|
||||
"docstatus": 1,
|
||||
},
|
||||
["name", "parent"],
|
||||
as_dict=1,
|
||||
)
|
||||
if receipt_item:
|
||||
item.pr_detail = receipt_item.name
|
||||
item.purchase_receipt = receipt_item.parent
|
||||
|
||||
if item.pr_detail:
|
||||
if not doc.asset_received_but_not_billed:
|
||||
doc.asset_received_but_not_billed = doc.get_company_default(
|
||||
"asset_received_but_not_billed"
|
||||
)
|
||||
|
||||
# check if 'Asset Received But Not Billed' account is credited in Purchase receipt or not
|
||||
arbnb_booked_in_pr = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": item.purchase_receipt,
|
||||
"account": doc.asset_received_but_not_billed,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
if arbnb_booked_in_pr:
|
||||
account = doc.asset_received_but_not_billed
|
||||
|
||||
if not account:
|
||||
account_type = (
|
||||
"capital_work_in_progress_account"
|
||||
if is_cwip_accounting_enabled(item.asset_category)
|
||||
else "fixed_asset_account"
|
||||
)
|
||||
account = get_asset_category_account(
|
||||
account_type, item=item.item_code, company=doc.company
|
||||
)
|
||||
if not account:
|
||||
form_link = get_link_to_form("Asset Category", item.asset_category)
|
||||
throw(
|
||||
_("Please set Fixed Asset Account in {} against {}.").format(
|
||||
form_link, doc.company
|
||||
),
|
||||
title=_("Missing Account"),
|
||||
)
|
||||
item.expense_account = account
|
||||
elif not item.expense_account and for_validate:
|
||||
throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name))
|
||||
|
||||
def validate_expense_account(self) -> None:
|
||||
for item in self.doc.get("items"):
|
||||
validate_account_head(item.idx, item.expense_account, self.doc.company, _("Expense"))
|
||||
|
||||
def set_against_expense_account(self) -> None:
|
||||
doc = self.doc
|
||||
against_accounts = []
|
||||
for item in doc.get("items"):
|
||||
if item.expense_account and (item.expense_account not in against_accounts):
|
||||
against_accounts.append(item.expense_account)
|
||||
|
||||
doc.against_expense_account = ",".join(against_accounts)
|
||||
|
||||
def force_set_against_expense_account(self) -> None:
|
||||
doc = self.doc
|
||||
self.set_against_expense_account()
|
||||
frappe.db.set_value(doc.doctype, doc.name, "against_expense_account", doc.against_expense_account)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -886,8 +886,10 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
@@ -1008,7 +1010,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-06 08:08:40.782395",
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
622
erpnext/accounts/doctype/sales_invoice/mapper.py
Normal file
622
erpnext/accounts/doctype/sales_invoice/mapper.py
Normal file
@@ -0,0 +1,622 @@
|
||||
# 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 - received_items.get(doc.name, 0.0) > 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,
|
||||
)
|
||||
if not doclist.get("items"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. "
|
||||
"Please check the existing linked {2}s."
|
||||
).format(target_doctype, doctype, target_doctype)
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
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,
|
||||
pluck="name",
|
||||
)
|
||||
received_items_map = {}
|
||||
if target_doctypes:
|
||||
received_items_data = frappe.get_all(
|
||||
doctype + " Item",
|
||||
filters={"parent": ("in", target_doctypes)},
|
||||
fields=[reference_fieldname, "qty"],
|
||||
)
|
||||
for item in received_items_data:
|
||||
key = item.get(reference_fieldname)
|
||||
if key:
|
||||
received_items_map[key] = received_items_map.get(key, 0.0) + flt(item.qty)
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -179,12 +179,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
: "Inter Company Purchase Invoice";
|
||||
|
||||
me.frm.add_custom_button(
|
||||
button_label,
|
||||
__(button_label),
|
||||
function () {
|
||||
me.make_inter_company_invoice();
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.get_received_items",
|
||||
args: {
|
||||
reference_name: me.frm.doc.name,
|
||||
doctype: "Purchase Invoice",
|
||||
reference_fieldname: "sales_invoice_item",
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const received_items = r.message || {};
|
||||
const has_pending_qty = me.frm.doc.items.some(
|
||||
(item) => flt(item.qty) - flt(received_items[item.name] || 0) > 0
|
||||
);
|
||||
if (!has_pending_qty) {
|
||||
me.frm.remove_custom_button(__(button_label), __("Create"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,21 +216,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 +380,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 +402,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 +440,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 +520,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,
|
||||
});
|
||||
}
|
||||
@@ -567,6 +586,8 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
set_dynamic_labels() {
|
||||
super.set_dynamic_labels();
|
||||
this.frm.events.hide_fields(this.frm);
|
||||
const hide_update_stock = cint(this.frm.doc.is_debit_note) || cint(this.frm.doc.has_subcontracted);
|
||||
this.frm.set_df_property("update_stock", "hidden", hide_update_stock);
|
||||
}
|
||||
|
||||
items_on_form_rendered() {
|
||||
@@ -579,7 +600,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 +733,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,
|
||||
});
|
||||
};
|
||||
@@ -1155,13 +1176,20 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
);
|
||||
},
|
||||
|
||||
is_debit_note: function (frm) {
|
||||
if (frm.doc.is_debit_note) {
|
||||
frm.set_value("update_stock", 0);
|
||||
}
|
||||
// visibility handled by set_dynamic_labels()
|
||||
frm.cscript.set_dynamic_labels();
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.is_debit_note) {
|
||||
frm.set_df_property("return_against", "label", __("Adjustment Against"));
|
||||
}
|
||||
|
||||
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
|
||||
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
173
erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py
Normal file
173
erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py
Normal 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)
|
||||
661
erpnext/accounts/doctype/sales_invoice/services/gl_composer.py
Normal file
661
erpnext/accounts/doctype/sales_invoice/services/gl_composer.py
Normal 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,
|
||||
)
|
||||
)
|
||||
@@ -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, "")
|
||||
163
erpnext/accounts/doctype/sales_invoice/services/loyalty.py
Normal file
163
erpnext/accounts/doctype/sales_invoice/services/loyalty.py
Normal 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
|
||||
422
erpnext/accounts/doctype/sales_invoice/services/pos.py
Normal file
422
erpnext/accounts/doctype/sales_invoice/services/pos.py
Normal 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)
|
||||
134
erpnext/accounts/doctype/sales_invoice/services/status.py
Normal file
134
erpnext/accounts/doctype/sales_invoice/services/status.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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,95 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(target_doc.company, "_Test Company 1")
|
||||
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
|
||||
|
||||
def test_restrict_inter_company_pi_when_sales_invoice_qty_fully_consumed(self):
|
||||
item_code_1 = "_Test IC Item 1"
|
||||
item_code_2 = "_Test IC Item 2"
|
||||
|
||||
create_item(item_code_1, is_stock_item=1)
|
||||
create_item(item_code_2, is_stock_item=1)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
item_code=item_code_1,
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
qty=3,
|
||||
do_not_save=1,
|
||||
)
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code_2,
|
||||
"item_name": item_code_2,
|
||||
"description": item_code_2,
|
||||
"warehouse": "Stores - WP",
|
||||
"qty": 2,
|
||||
"uom": "Nos",
|
||||
"stock_uom": "Nos",
|
||||
"rate": 100,
|
||||
"price_list_rate": 100,
|
||||
"income_account": "Sales - WP",
|
||||
"expense_account": "Cost of Goods Sold - WP",
|
||||
"cost_center": "Main - WP",
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
|
||||
si.submit()
|
||||
|
||||
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
for item in target_doc.items:
|
||||
item.update(
|
||||
{
|
||||
"expense_account": "Cost of Goods Sold - _TC1",
|
||||
"cost_center": "Main - _TC1",
|
||||
}
|
||||
)
|
||||
|
||||
target_doc.submit()
|
||||
self.assertEqual(len(target_doc.items), 2)
|
||||
self.assertEqual([item.qty for item in target_doc.items], [3, 2])
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"already been fully invoiced",
|
||||
):
|
||||
make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
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 +3838,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 +3870,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 +3983,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 +4078,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 +4269,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 +4407,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 +4450,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 +4820,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 = []
|
||||
@@ -4862,6 +5213,13 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
|
||||
|
||||
def test_debit_note_with_update_stock_validation(self):
|
||||
"""Test that saving a Debit Note with Update Stock enabled raises ValidationError."""
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_debit_note = 1
|
||||
si.update_stock = 1
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
|
||||
|
||||
def make_item_for_si(item_code, properties=None):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -104,6 +106,7 @@
|
||||
"sales_order",
|
||||
"so_detail",
|
||||
"sales_invoice_item",
|
||||
"pick_list_item",
|
||||
"column_break_74",
|
||||
"delivery_note",
|
||||
"dn_detail",
|
||||
@@ -112,6 +115,7 @@
|
||||
"pos_invoice",
|
||||
"pos_invoice_item",
|
||||
"scio_detail",
|
||||
"against_pick_list",
|
||||
"internal_transfer_section",
|
||||
"purchase_order",
|
||||
"column_break_92",
|
||||
@@ -142,6 +146,23 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail || doc.dn_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -855,8 +876,8 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Rate of Stock UOM",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -1011,13 +1032,30 @@
|
||||
"label": "Consider for Tax Withholding",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "against_pick_list",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Against Pick List",
|
||||
"no_copy": 1,
|
||||
"options": "Pick List",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "pick_list_item",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Pick List Item",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-29 12:23:28.259905",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -20,6 +20,7 @@ class SalesInvoiceItem(Document):
|
||||
|
||||
actual_batch_qty: DF.Float
|
||||
actual_qty: DF.Float
|
||||
against_pick_list: DF.Link | None
|
||||
allow_zero_valuation_rate: DF.Check
|
||||
amount: DF.Currency
|
||||
apply_tds: DF.Check
|
||||
@@ -70,6 +71,7 @@ class SalesInvoiceItem(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
pick_list_item: DF.Data | None
|
||||
pos_invoice: DF.Link | None
|
||||
pos_invoice_item: DF.Data | None
|
||||
price_list_rate: DF.Currency
|
||||
|
||||
@@ -29,7 +29,13 @@ frappe.ui.form.on("Subscription", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.is_new()) return;
|
||||
if (frm.is_new()) {
|
||||
// The field wrapper is reused across docs; clear any stale heatmap.
|
||||
frm.get_field("billing_heatmap").$wrapper.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
frm.trigger("render_billing_heatmap");
|
||||
|
||||
if (frm.doc.status !== "Cancelled") {
|
||||
frm.add_custom_button(
|
||||
@@ -95,4 +101,88 @@ frappe.ui.form.on("Subscription", {
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
render_billing_heatmap: function (frm) {
|
||||
frm.call("get_billing_heatmap").then((r) => {
|
||||
if (!r.message || !r.message.length) return;
|
||||
render_heatmap(frm.get_field("billing_heatmap").$wrapper, r.message, frm.doc);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Status -> colour and label for the calendar heatmap. Keys are Title-case to
|
||||
// match the value frappe-charts shows in its hover tooltip.
|
||||
const HEATMAP_COLORS = {
|
||||
Paid: "#39d353",
|
||||
Unpaid: "#388bfd",
|
||||
Overdue: "#f0883e",
|
||||
Cancelled: "#f85149",
|
||||
Refunded: "#a371f7",
|
||||
Planned: "#87ceeb",
|
||||
};
|
||||
|
||||
// Days inside the window but outside the subscription's active span stay faded.
|
||||
const EMPTY_COLOR = "#ebedf0";
|
||||
|
||||
function title_case(status) {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
function render_heatmap($wrapper, days, doc) {
|
||||
const data_points = {};
|
||||
days.forEach((day) => {
|
||||
data_points[day.date] = title_case(day.status);
|
||||
});
|
||||
|
||||
$wrapper.empty();
|
||||
const chart_el = $('<div class="subscription-billing-heatmap"></div>').appendTo($wrapper)[0];
|
||||
|
||||
new frappe.Chart(chart_el, {
|
||||
type: "heatmap",
|
||||
data: {
|
||||
dataPoints: data_points,
|
||||
start: new Date(days[0].date),
|
||||
end: new Date(days[days.length - 1].date),
|
||||
},
|
||||
discreteDomains: 1,
|
||||
showLegend: 0,
|
||||
// frappe-charts only does an intensity scale; we recolour each square by
|
||||
// its own status below, so the scale colours are placeholders.
|
||||
colors: ["#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0"],
|
||||
});
|
||||
|
||||
// Paint every day square with its status colour (data-value holds the status).
|
||||
// The chart re-renders once for its entry animation, so repaint on each redraw.
|
||||
const within_subscription = (date) =>
|
||||
(!doc.start_date || date >= doc.start_date) && (!doc.end_date || date <= doc.end_date);
|
||||
|
||||
const paint = () =>
|
||||
chart_el.querySelectorAll("[data-date]").forEach((square) => {
|
||||
const status = square.getAttribute("data-value");
|
||||
if (status === "Planned" && !within_subscription(square.getAttribute("data-date"))) {
|
||||
// Outside the subscription's span: render blank and drop the status so the
|
||||
// hover tooltip shows only the date, not "Planned".
|
||||
square.setAttribute("fill", EMPTY_COLOR);
|
||||
square.setAttribute("data-value", "");
|
||||
return;
|
||||
}
|
||||
square.setAttribute("fill", HEATMAP_COLORS[status] || EMPTY_COLOR);
|
||||
});
|
||||
|
||||
paint();
|
||||
new MutationObserver(paint).observe(chart_el, { childList: true, subtree: true });
|
||||
|
||||
const legend = Object.keys(HEATMAP_COLORS)
|
||||
.map(
|
||||
(status) =>
|
||||
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">
|
||||
<span style="width:11px;height:11px;border-radius:2px;background:${HEATMAP_COLORS[status]};"></span>
|
||||
${__(status)}
|
||||
</span>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
$(`<div style="margin-top:8px;font-size:11px;color:var(--text-muted);">${legend}</div>`).appendTo(
|
||||
$wrapper
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"billing_history_section",
|
||||
"billing_heatmap",
|
||||
"section_break_jznv",
|
||||
"party_type",
|
||||
"party",
|
||||
"cb_1",
|
||||
@@ -21,12 +24,16 @@
|
||||
"generate_new_invoices_past_due_date",
|
||||
"submit_invoice",
|
||||
"column_break_11",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"days_until_due",
|
||||
"generate_invoice_at",
|
||||
"number_of_days",
|
||||
"cancel_at_period_end",
|
||||
"billing_period_section",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"billing_period_cb",
|
||||
"next_billing_period_start",
|
||||
"next_billing_period_end",
|
||||
"sb_4",
|
||||
"plans",
|
||||
"sb_1",
|
||||
@@ -51,7 +58,7 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted",
|
||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted\nRefunded",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -83,17 +90,40 @@
|
||||
"fieldname": "column_break_11",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_period_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing Period"
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice Start Date",
|
||||
"label": "Current Invoice Start",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice End Date",
|
||||
"label": "Current Invoice End",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_period_cb",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "next_billing_period_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Next Billing Period Start",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "next_billing_period_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Next Billing Period End",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -108,7 +138,18 @@
|
||||
"default": "0",
|
||||
"fieldname": "cancel_at_period_end",
|
||||
"fieldtype": "Check",
|
||||
"label": "Cancel At End Of Period"
|
||||
"label": "Cancel When Period Ends"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "billing_history_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing History"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_heatmap",
|
||||
"fieldtype": "HTML",
|
||||
"label": "Billing Heatmap"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
@@ -206,7 +247,7 @@
|
||||
"description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date",
|
||||
"fieldname": "generate_new_invoices_past_due_date",
|
||||
"fieldtype": "Check",
|
||||
"label": "Generate New Invoices Past Due Date"
|
||||
"label": "Bill Even If Previous Invoice Unpaid"
|
||||
},
|
||||
{
|
||||
"fieldname": "end_date",
|
||||
@@ -239,19 +280,23 @@
|
||||
"label": "Submit Generated Invoices"
|
||||
},
|
||||
{
|
||||
"default": "End of the current subscription period",
|
||||
"default": "Postpaid (bill at period end)",
|
||||
"fieldname": "generate_invoice_at",
|
||||
"fieldtype": "Select",
|
||||
"label": "Generate Invoice At",
|
||||
"options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period",
|
||||
"options": "Postpaid (bill at period end)\nPrepaid (bill at period start)\nBill N days before period start",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\"",
|
||||
"fieldname": "number_of_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Number of Days",
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\""
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jznv",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@@ -267,11 +312,11 @@
|
||||
"link_fieldname": "subscription"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-23 19:42:52.036034",
|
||||
"modified": "2026-06-04 07:21:15.938170",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscription",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ from frappe.utils.data import (
|
||||
cint,
|
||||
date_diff,
|
||||
flt,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
@@ -35,6 +36,24 @@ class InvoiceNotCancelled(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
GENERATE_AT_END = "Postpaid (bill at period end)"
|
||||
GENERATE_AT_BEGINNING = "Prepaid (bill at period start)"
|
||||
GENERATE_AT_DAYS_BEFORE = "Bill N days before period start"
|
||||
|
||||
STATUS_TRIALING = "Trialing"
|
||||
STATUS_ACTIVE = "Active"
|
||||
STATUS_GRACE_PERIOD = "Grace Period"
|
||||
STATUS_CANCELLED = "Cancelled"
|
||||
STATUS_UNPAID = "Unpaid"
|
||||
STATUS_COMPLETED = "Completed"
|
||||
STATUS_REFUNDED = "Refunded"
|
||||
|
||||
PARTY_CUSTOMER = "Customer"
|
||||
PARTY_SUPPLIER = "Supplier"
|
||||
|
||||
INVOICE_PAID = "Paid"
|
||||
|
||||
|
||||
DateTimeLikeObject = str | date
|
||||
|
||||
|
||||
@@ -64,11 +83,13 @@ class Subscription(Document):
|
||||
end_date: DF.Date | None
|
||||
follow_calendar_months: DF.Check
|
||||
generate_invoice_at: DF.Literal[
|
||||
"End of the current subscription period",
|
||||
"Beginning of the current subscription period",
|
||||
"Days before the current subscription period",
|
||||
"Postpaid (bill at period end)",
|
||||
"Prepaid (bill at period start)",
|
||||
"Bill N days before period start",
|
||||
]
|
||||
generate_new_invoices_past_due_date: DF.Check
|
||||
next_billing_period_end: DF.Date | None
|
||||
next_billing_period_start: DF.Date | None
|
||||
number_of_days: DF.Int
|
||||
party: DF.DynamicLink
|
||||
party_type: DF.Link
|
||||
@@ -76,7 +97,9 @@ class Subscription(Document):
|
||||
purchase_tax_template: DF.Link | None
|
||||
sales_tax_template: DF.Link | None
|
||||
start_date: DF.Date | None
|
||||
status: DF.Literal["", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed"]
|
||||
status: DF.Literal[
|
||||
"", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed", "Refunded"
|
||||
]
|
||||
submit_invoice: DF.Check
|
||||
trial_period_end: DF.Date | None
|
||||
trial_period_start: DF.Date | None
|
||||
@@ -86,22 +109,56 @@ class Subscription(Document):
|
||||
# update start just before the subscription doc is created
|
||||
self.update_subscription_period(self.start_date)
|
||||
|
||||
def after_insert(self) -> None:
|
||||
if frappe.flags.in_import or frappe.flags.in_migrate:
|
||||
return
|
||||
|
||||
if getdate(self.start_date) > getdate(nowdate()):
|
||||
return
|
||||
|
||||
self.generate_invoices_till_date()
|
||||
|
||||
def generate_invoices_till_date(self) -> None:
|
||||
"""
|
||||
Catch up a freshly created subscription by billing every elapsed period
|
||||
from the start date up to today, then advancing the status (e.g. cancelling
|
||||
if the end date has been crossed). Stops early when no further invoice is due
|
||||
or an outstanding invoice blocks billing (per `generate_new_invoices_past_due_date`).
|
||||
"""
|
||||
while getdate(self._next_invoice_trigger_date()) <= getdate(nowdate()):
|
||||
period_start = self.next_billing_period_start
|
||||
self.process(posting_date=self._next_invoice_trigger_date())
|
||||
|
||||
if self.status == STATUS_CANCELLED or getdate(self.next_billing_period_start) == getdate(
|
||||
period_start
|
||||
):
|
||||
break
|
||||
|
||||
if not self.generate_new_invoices_past_due_date:
|
||||
break
|
||||
|
||||
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
|
||||
return self._invoice_date_for_period(self.next_billing_period_start, self.next_billing_period_end)
|
||||
|
||||
def _invoice_date_for_period(
|
||||
self, period_start: DateTimeLikeObject, period_end: DateTimeLikeObject
|
||||
) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
return period_start
|
||||
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
return add_days(period_start, -self.number_of_days)
|
||||
return period_end
|
||||
|
||||
def update_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
"""
|
||||
Subscription period is the period to be billed. This method updates the
|
||||
beginning of the billing period and end of the billing period.
|
||||
The beginning of the billing period is represented in the doctype as
|
||||
`current_invoice_start` and the end of the billing period is represented
|
||||
as `current_invoice_end`.
|
||||
`next_billing_period_start` and the end of the billing period is represented
|
||||
as `next_billing_period_end`.
|
||||
"""
|
||||
self.current_invoice_start = self.get_current_invoice_start(date)
|
||||
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
|
||||
|
||||
def _get_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
_current_invoice_start = self.get_current_invoice_start(date)
|
||||
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
|
||||
|
||||
return _current_invoice_start, _current_invoice_end
|
||||
self.next_billing_period_start = self.get_current_invoice_start(date)
|
||||
self.next_billing_period_end = self.get_current_invoice_end(self.next_billing_period_start)
|
||||
|
||||
def get_current_invoice_start(self, date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||
"""
|
||||
@@ -142,7 +199,7 @@ class Subscription(Document):
|
||||
_current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
|
||||
|
||||
# For cases where trial period is for an entire billing interval
|
||||
if getdate(self.current_invoice_end) < getdate(date):
|
||||
if getdate(self.next_billing_period_end) < getdate(date):
|
||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||
else:
|
||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||
@@ -220,21 +277,35 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
self._set_current_invoice_dates()
|
||||
if self.is_trialling():
|
||||
self.status = "Trialing"
|
||||
self.status = STATUS_TRIALING
|
||||
elif self.is_fully_refunded() and self.has_outstanding_invoice():
|
||||
self.status = STATUS_REFUNDED
|
||||
elif (
|
||||
not self.has_outstanding_invoice()
|
||||
and self.end_date
|
||||
and getdate(posting_date) > getdate(self.end_date)
|
||||
):
|
||||
self.status = "Completed"
|
||||
self.status = STATUS_COMPLETED
|
||||
elif self.is_past_grace_period():
|
||||
self.status = self.get_status_for_past_grace_period()
|
||||
self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
|
||||
self.cancelation_date = getdate(posting_date) if self.status == STATUS_CANCELLED else None
|
||||
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
|
||||
self.status = "Grace Period"
|
||||
self.status = STATUS_GRACE_PERIOD
|
||||
elif not self.has_outstanding_invoice():
|
||||
self.status = "Active"
|
||||
self.status = STATUS_ACTIVE
|
||||
|
||||
def _set_current_invoice_dates(self) -> None:
|
||||
invoice = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||
fields=["from_date", "to_date"],
|
||||
order_by="to_date desc",
|
||||
limit=1,
|
||||
)
|
||||
self.current_invoice_start = invoice[0].from_date if invoice else None
|
||||
self.current_invoice_end = invoice[0].to_date if invoice else None
|
||||
|
||||
def is_trialling(self) -> bool:
|
||||
"""
|
||||
@@ -249,7 +320,6 @@ class Subscription(Document):
|
||||
"""
|
||||
Returns true if the given `end_date` has passed
|
||||
"""
|
||||
# todo: test for illegal time
|
||||
if not end_date:
|
||||
return True
|
||||
|
||||
@@ -257,10 +327,10 @@ class Subscription(Document):
|
||||
|
||||
def get_status_for_past_grace_period(self) -> str:
|
||||
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
|
||||
status = "Unpaid"
|
||||
status = STATUS_UNPAID
|
||||
|
||||
if cancel_after_grace:
|
||||
status = "Cancelled"
|
||||
status = STATUS_CANCELLED
|
||||
|
||||
return status
|
||||
|
||||
@@ -269,7 +339,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,11 +351,14 @@ 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
|
||||
def invoice_document_type(self) -> str:
|
||||
return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
|
||||
return "Sales Invoice" if self.party_type == PARTY_CUSTOMER else "Purchase Invoice"
|
||||
|
||||
def validate(self) -> None:
|
||||
self.validate_trial_period()
|
||||
@@ -345,7 +418,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):
|
||||
@@ -371,11 +450,7 @@ class Subscription(Document):
|
||||
to_date: DateTimeLikeObject | None = None,
|
||||
posting_date: DateTimeLikeObject | None = None,
|
||||
) -> Document:
|
||||
"""
|
||||
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
|
||||
saves the `Subscription`.
|
||||
Backwards compatibility
|
||||
"""
|
||||
"""Public alias for `create_invoice`; kept for external integrations."""
|
||||
return self.create_invoice(from_date=from_date, to_date=to_date, posting_date=posting_date)
|
||||
|
||||
def create_invoice(
|
||||
@@ -387,8 +462,19 @@ class Subscription(Document):
|
||||
"""
|
||||
Creates a `Invoice`, submits it and returns it
|
||||
"""
|
||||
# For backward compatibility
|
||||
# Earlier subscription didn't had any company field
|
||||
company = self._resolve_company()
|
||||
invoice = self._init_invoice_doc(company, posting_date)
|
||||
self._set_invoice_party(invoice)
|
||||
self._set_invoice_currency(invoice)
|
||||
self._apply_accounting_dimensions(invoice)
|
||||
self._append_invoice_items(invoice)
|
||||
self._apply_taxes(invoice)
|
||||
self._apply_payment_schedule(invoice)
|
||||
self._apply_discounts(invoice)
|
||||
return self._finalize_invoice(invoice, from_date, to_date)
|
||||
|
||||
def _resolve_company(self) -> str:
|
||||
# Earlier subscriptions didn't have a company field
|
||||
company = self.get("company") or get_default_company()
|
||||
if not company:
|
||||
frappe.throw(
|
||||
@@ -396,48 +482,49 @@ class Subscription(Document):
|
||||
"Company is mandatory for generating an invoice. Please set a default company in Global Defaults."
|
||||
)
|
||||
)
|
||||
return company
|
||||
|
||||
def _init_invoice_doc(self, company: str, posting_date: DateTimeLikeObject | None = None) -> Document:
|
||||
invoice = frappe.new_doc(self.invoice_document_type)
|
||||
invoice.company = company
|
||||
invoice.set_posting_time = 1
|
||||
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
invoice.posting_date = self.current_invoice_start
|
||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
||||
invoice.posting_date = posting_date or self.current_invoice_start
|
||||
else:
|
||||
invoice.posting_date = self.current_invoice_end
|
||||
|
||||
invoice.posting_date = self._invoice_posting_date(posting_date)
|
||||
invoice.cost_center = self.cost_center
|
||||
return invoice
|
||||
|
||||
def _invoice_posting_date(self, posting_date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
return self.next_billing_period_start
|
||||
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
return posting_date or self.next_billing_period_start
|
||||
return self.next_billing_period_end
|
||||
|
||||
def _set_invoice_party(self, invoice: Document) -> None:
|
||||
if self.invoice_document_type == "Sales Invoice":
|
||||
invoice.customer = self.party
|
||||
else:
|
||||
invoice.supplier = self.party
|
||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||
)
|
||||
if tax_withholding_category or tax_withholding_group:
|
||||
invoice.apply_tds = 1
|
||||
return
|
||||
|
||||
# Add currency to invoice
|
||||
invoice.supplier = self.party
|
||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||
)
|
||||
if tax_withholding_category or tax_withholding_group:
|
||||
invoice.apply_tds = 1
|
||||
|
||||
def _set_invoice_currency(self, invoice: Document) -> None:
|
||||
invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
|
||||
|
||||
# Add dimensions in invoice for subscription:
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
|
||||
for dimension in accounting_dimensions:
|
||||
def _apply_accounting_dimensions(self, invoice: Document) -> None:
|
||||
for dimension in get_accounting_dimensions():
|
||||
if self.get(dimension):
|
||||
invoice.update({dimension: self.get(dimension)})
|
||||
|
||||
# Subscription is better suited for service items. I won't update `update_stock`
|
||||
# for that reason
|
||||
items_list = self.get_items_from_plans(self.plans, is_prorate())
|
||||
|
||||
for item in items_list:
|
||||
def _append_invoice_items(self, invoice: Document) -> None:
|
||||
# Subscription is better suited for service items, so `update_stock` is left untouched
|
||||
for item in self.get_items_from_plans(self.plans, is_prorate()):
|
||||
invoice.append("items", item)
|
||||
|
||||
# Taxes
|
||||
def _apply_taxes(self, invoice: Document) -> None:
|
||||
tax_template = ""
|
||||
|
||||
if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
|
||||
@@ -446,40 +533,48 @@ 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:
|
||||
invoice.append(
|
||||
"payment_schedule",
|
||||
{
|
||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||
"invoice_portion": 100,
|
||||
},
|
||||
)
|
||||
def _apply_payment_schedule(self, invoice: Document) -> None:
|
||||
if not self.days_until_due:
|
||||
return
|
||||
|
||||
# Discounts
|
||||
invoice.append(
|
||||
"payment_schedule",
|
||||
{
|
||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||
"invoice_portion": 100,
|
||||
},
|
||||
)
|
||||
|
||||
def _apply_discounts(self, invoice: Document) -> None:
|
||||
if self.is_trialling():
|
||||
invoice.additional_discount_percentage = 100
|
||||
else:
|
||||
if self.additional_discount_percentage:
|
||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||
return
|
||||
|
||||
if self.additional_discount_amount:
|
||||
invoice.discount_amount = self.additional_discount_amount
|
||||
if self.additional_discount_percentage:
|
||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||
|
||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||
discount_on = self.apply_additional_discount
|
||||
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
|
||||
if self.additional_discount_amount:
|
||||
invoice.discount_amount = self.additional_discount_amount
|
||||
|
||||
# Subscription period
|
||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||
invoice.apply_discount_on = self.apply_additional_discount or "Grand Total"
|
||||
|
||||
def _finalize_invoice(
|
||||
self,
|
||||
invoice: Document,
|
||||
from_date: DateTimeLikeObject | None = None,
|
||||
to_date: DateTimeLikeObject | None = None,
|
||||
) -> Document:
|
||||
invoice.subscription = self.name
|
||||
invoice.from_date = from_date or self.current_invoice_start
|
||||
invoice.to_date = to_date or self.current_invoice_end
|
||||
invoice.from_date = from_date or self.next_billing_period_start
|
||||
invoice.to_date = to_date or self.next_billing_period_end
|
||||
|
||||
invoice.flags.ignore_mandatory = True
|
||||
|
||||
invoice.set_missing_values()
|
||||
invoice.save()
|
||||
|
||||
@@ -496,15 +591,9 @@ class Subscription(Document):
|
||||
prorate_factor = 1
|
||||
if prorate:
|
||||
prorate_factor = get_prorata_factor(
|
||||
self.current_invoice_end,
|
||||
self.current_invoice_start,
|
||||
cint(
|
||||
self.generate_invoice_at
|
||||
in [
|
||||
"Beginning of the current subscription period",
|
||||
"Days before the current subscription period",
|
||||
]
|
||||
),
|
||||
self.next_billing_period_end,
|
||||
self.next_billing_period_start,
|
||||
cint(self.generate_invoice_at in [GENERATE_AT_BEGINNING, GENERATE_AT_DAYS_BEFORE]),
|
||||
)
|
||||
|
||||
items = []
|
||||
@@ -514,7 +603,7 @@ class Subscription(Document):
|
||||
|
||||
item_code = plan_doc.item
|
||||
|
||||
if self.party == "Customer":
|
||||
if self.party_type == PARTY_CUSTOMER:
|
||||
deferred_field = "enable_deferred_revenue"
|
||||
else:
|
||||
deferred_field = "enable_deferred_expense"
|
||||
@@ -528,8 +617,8 @@ class Subscription(Document):
|
||||
plan.plan,
|
||||
plan.qty,
|
||||
party,
|
||||
self.current_invoice_start,
|
||||
self.current_invoice_end,
|
||||
self.next_billing_period_start,
|
||||
self.next_billing_period_end,
|
||||
prorate_factor,
|
||||
),
|
||||
"cost_center": plan_doc.cost_center,
|
||||
@@ -539,8 +628,8 @@ class Subscription(Document):
|
||||
item.update(
|
||||
{
|
||||
deferred_field: deferred,
|
||||
"service_start_date": self.current_invoice_start,
|
||||
"service_end_date": self.current_invoice_end,
|
||||
"service_start_date": self.next_billing_period_start,
|
||||
"service_end_date": self.next_billing_period_end,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -563,11 +652,11 @@ class Subscription(Document):
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
if not self.is_current_invoice_generated(
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
self.next_billing_period_start, self.next_billing_period_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
self.generate_invoice(posting_date=posting_date)
|
||||
if self.end_date:
|
||||
next_start = add_days(self.current_invoice_end, 1)
|
||||
next_start = add_days(self.next_billing_period_end, 1)
|
||||
|
||||
if getdate(next_start) > getdate(self.end_date):
|
||||
if self.cancel_at_period_end:
|
||||
@@ -577,12 +666,12 @@ class Subscription(Document):
|
||||
|
||||
self.save()
|
||||
return
|
||||
self.update_subscription_period(add_days(self.current_invoice_end, 1))
|
||||
elif posting_date and getdate(posting_date) > getdate(self.current_invoice_end):
|
||||
self.update_subscription_period(add_days(self.next_billing_period_end, 1))
|
||||
elif posting_date and getdate(posting_date) > getdate(self.next_billing_period_end):
|
||||
self.update_subscription_period()
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
getdate(posting_date) >= getdate(self.next_billing_period_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
):
|
||||
self.cancel_subscription()
|
||||
@@ -598,28 +687,30 @@ 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
|
||||
else:
|
||||
posting = getdate(posting_date)
|
||||
trigger = getdate(self._next_invoice_trigger_date())
|
||||
|
||||
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.next_billing_period_end, **billing_cycle_info))
|
||||
else:
|
||||
upper = getdate(self.next_billing_period_end)
|
||||
|
||||
return posting <= upper
|
||||
|
||||
def is_current_invoice_generated(
|
||||
self,
|
||||
_current_start_date: DateTimeLikeObject | None = None,
|
||||
_current_end_date: DateTimeLikeObject | None = None,
|
||||
) -> bool:
|
||||
if not (_current_start_date and _current_end_date):
|
||||
_current_start_date, _current_end_date = self._get_subscription_period(
|
||||
date=add_days(self.current_invoice_end, 1)
|
||||
)
|
||||
_current_start_date = self.get_current_invoice_start(add_days(self.next_billing_period_end, 1))
|
||||
_current_end_date = self.get_current_invoice_end(_current_start_date)
|
||||
|
||||
if self.current_invoice and getdate(_current_start_date) <= getdate(
|
||||
self.current_invoice.posting_date
|
||||
@@ -641,7 +732,7 @@ class Subscription(Document):
|
||||
"""
|
||||
invoice = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
{"subscription": self.name, "docstatus": ("<", 2)},
|
||||
{"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||
limit=1,
|
||||
order_by="to_date desc",
|
||||
pluck="name",
|
||||
@@ -650,13 +741,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(
|
||||
@@ -670,41 +754,70 @@ class Subscription(Document):
|
||||
"""
|
||||
Return `True` if the given invoice is paid
|
||||
"""
|
||||
return invoice.status == "Paid"
|
||||
return invoice.status == INVOICE_PAID
|
||||
|
||||
def has_outstanding_invoice(self) -> int:
|
||||
"""
|
||||
Returns `True` if the most recent invoice for the `Subscription` is not paid
|
||||
Returns the count of submitted, non-return invoices that are not yet paid.
|
||||
"""
|
||||
return frappe.db.count(
|
||||
self.invoice_document_type,
|
||||
{
|
||||
"subscription": self.name,
|
||||
"docstatus": 1,
|
||||
"status": ["!=", "Paid"],
|
||||
"is_return": 0,
|
||||
"status": ["!=", INVOICE_PAID],
|
||||
},
|
||||
)
|
||||
|
||||
def is_fully_refunded(self) -> bool:
|
||||
"""
|
||||
`True` only when every submitted, not-`Paid` invoice on the subscription has
|
||||
credit notes whose absolute total covers its outstanding amount.
|
||||
"""
|
||||
unpaid_invoices = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={
|
||||
"subscription": self.name,
|
||||
"docstatus": 1,
|
||||
"is_return": 0,
|
||||
"status": ["!=", INVOICE_PAID],
|
||||
},
|
||||
fields=["name", "outstanding_amount"],
|
||||
)
|
||||
if not unpaid_invoices:
|
||||
return False
|
||||
|
||||
return all(self._is_invoice_fully_credited(invoice) for invoice in unpaid_invoices)
|
||||
|
||||
def _is_invoice_fully_credited(self, invoice: dict) -> bool:
|
||||
credit_notes = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"return_against": invoice.name, "docstatus": 1},
|
||||
pluck="grand_total",
|
||||
)
|
||||
credited = sum(flt(amount) for amount in credit_notes)
|
||||
return abs(credited) >= flt(invoice.outstanding_amount)
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_subscription(self) -> None:
|
||||
"""
|
||||
This sets the subscription as cancelled. It will stop invoices from being generated
|
||||
but it will not affect already created invoices.
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
if self.status == STATUS_CANCELLED:
|
||||
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
|
||||
|
||||
to_generate_invoice = (
|
||||
True
|
||||
if self.status == "Active"
|
||||
and self.generate_invoice_at != "Beginning of the current subscription period"
|
||||
if self.status == STATUS_ACTIVE and self.generate_invoice_at != GENERATE_AT_BEGINNING
|
||||
else False
|
||||
)
|
||||
self.status = "Cancelled"
|
||||
self.status = STATUS_CANCELLED
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
if to_generate_invoice and self.cancelation_date >= self.current_invoice_start:
|
||||
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
|
||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.next_billing_period_start):
|
||||
self.generate_invoice(self.next_billing_period_start, self.cancelation_date)
|
||||
|
||||
self.save()
|
||||
|
||||
@@ -715,10 +828,10 @@ class Subscription(Document):
|
||||
subscription and the `Subscription` will lose all the history of generated invoices
|
||||
it has.
|
||||
"""
|
||||
if self.status != "Cancelled":
|
||||
if self.status != STATUS_CANCELLED:
|
||||
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
|
||||
|
||||
self.status = "Active"
|
||||
self.status = STATUS_ACTIVE
|
||||
self.cancelation_date = None
|
||||
self.update_subscription_period(posting_date or nowdate())
|
||||
self.save()
|
||||
@@ -726,25 +839,130 @@ class Subscription(Document):
|
||||
@frappe.whitelist()
|
||||
def force_fetch_subscription_updates(self):
|
||||
"""
|
||||
Process Subscription and create Invoices even if current date doesn't lie between current_invoice_start and currenct_invoice_end
|
||||
Process Subscription and create Invoices even if current date doesn't lie between next_billing_period_start and next_billing_period_end
|
||||
It makes use of 'Proces Subscription' to force processing in a specific 'posting_date'
|
||||
"""
|
||||
|
||||
# Don't process future subscriptions
|
||||
if nowdate() < self.current_invoice_start:
|
||||
if getdate(nowdate()) < getdate(self.next_billing_period_start):
|
||||
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
|
||||
return
|
||||
|
||||
processing_date = None
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
processing_date = self.current_invoice_start
|
||||
elif self.generate_invoice_at == "End of the current subscription period":
|
||||
processing_date = self.current_invoice_end
|
||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
||||
processing_date = add_days(self.current_invoice_start, -self.number_of_days)
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
processing_date = self.next_billing_period_start
|
||||
elif self.generate_invoice_at == GENERATE_AT_END:
|
||||
processing_date = self.next_billing_period_end
|
||||
elif self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
processing_date = add_days(self.next_billing_period_start, -self.number_of_days)
|
||||
|
||||
self.process(posting_date=processing_date)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_billing_heatmap(self) -> list[dict]:
|
||||
"""
|
||||
One cell per calendar day for a fixed 12-month window starting at the first day of
|
||||
the subscription's first month. Each day is coloured by the status of the billing
|
||||
period it falls into; days with no invoice yet are `planned`.
|
||||
"""
|
||||
periods = self._billing_periods()
|
||||
window_start = get_first_day(self.start_date) if self.start_date else get_first_day(nowdate())
|
||||
window_end = get_last_day(add_months(window_start, 11))
|
||||
|
||||
cells = []
|
||||
day = window_start
|
||||
while day <= window_end:
|
||||
cells.append(self._heatmap_cell(day, periods))
|
||||
day = add_days(day, 1)
|
||||
|
||||
return cells
|
||||
|
||||
def _billing_periods(self) -> list[dict]:
|
||||
invoices = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name},
|
||||
fields=[
|
||||
"name",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"status",
|
||||
"due_date",
|
||||
"grand_total",
|
||||
"docstatus",
|
||||
"is_return",
|
||||
"return_against",
|
||||
],
|
||||
order_by="from_date asc",
|
||||
)
|
||||
|
||||
credited = {
|
||||
invoice.return_against
|
||||
for invoice in invoices
|
||||
if invoice.is_return and invoice.docstatus == 1 and invoice.return_against
|
||||
}
|
||||
|
||||
periods = [
|
||||
{
|
||||
"period_start": str(invoice.from_date),
|
||||
"period_end": str(invoice.to_date),
|
||||
"invoice": invoice.name,
|
||||
"amount": flt(invoice.grand_total),
|
||||
"status": self._heatmap_status(invoice, invoice.name in credited),
|
||||
}
|
||||
for invoice in invoices
|
||||
if not invoice.is_return and invoice.from_date and invoice.to_date
|
||||
]
|
||||
|
||||
return [*periods, *self._planned_periods(periods)]
|
||||
|
||||
def _heatmap_status(self, invoice: dict, is_credited: bool) -> str:
|
||||
if invoice.docstatus == 2:
|
||||
return "cancelled"
|
||||
if is_credited:
|
||||
return "refunded"
|
||||
if invoice.status == INVOICE_PAID:
|
||||
return "paid"
|
||||
if invoice.due_date and getdate(invoice.due_date) < getdate(nowdate()):
|
||||
return "overdue"
|
||||
return "unpaid"
|
||||
|
||||
def _planned_periods(self, invoiced_periods: list[dict]) -> list[dict]:
|
||||
invoiced = {(period["period_start"], period["period_end"]) for period in invoiced_periods}
|
||||
planned = []
|
||||
for start, end in self._upcoming_periods():
|
||||
if start and end and (str(start), str(end)) not in invoiced:
|
||||
planned.append(
|
||||
{
|
||||
"period_start": str(start),
|
||||
"period_end": str(end),
|
||||
"invoice": None,
|
||||
"amount": 0.0,
|
||||
"status": "planned",
|
||||
}
|
||||
)
|
||||
return planned
|
||||
|
||||
def _upcoming_periods(self) -> list[tuple]:
|
||||
"""The open billing period and the one immediately after it."""
|
||||
open_period = (self.next_billing_period_start, self.next_billing_period_end)
|
||||
after_start = add_days(self.next_billing_period_end, 1) if self.next_billing_period_end else None
|
||||
after_end = self.get_current_invoice_end(after_start) if after_start else None
|
||||
return [open_period, (after_start, after_end)]
|
||||
|
||||
def _heatmap_cell(self, day: date, periods: list[dict]) -> dict:
|
||||
for period in periods:
|
||||
if getdate(period["period_start"]) <= day <= getdate(period["period_end"]):
|
||||
return {"date": str(day), **period}
|
||||
|
||||
return {
|
||||
"date": str(day),
|
||||
"status": "planned",
|
||||
"invoice": None,
|
||||
"amount": 0.0,
|
||||
"period_start": None,
|
||||
"period_end": None,
|
||||
}
|
||||
|
||||
|
||||
def is_prorate() -> int:
|
||||
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
|
||||
@@ -770,10 +988,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")
|
||||
|
||||
@@ -11,12 +11,15 @@ from frappe.utils.data import (
|
||||
date_diff,
|
||||
flt,
|
||||
get_date_str,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
getdate,
|
||||
nowdate,
|
||||
)
|
||||
|
||||
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.accounts.utils import update_subscription_on_invoice_update
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -34,11 +37,11 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(subscription.trial_period_start, nowdate())
|
||||
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
|
||||
self.assertEqual(
|
||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.current_invoice_start)
|
||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.next_billing_period_start)
|
||||
)
|
||||
self.assertEqual(
|
||||
add_to_date(subscription.current_invoice_start, months=1, days=-1),
|
||||
get_date_str(subscription.current_invoice_end),
|
||||
add_to_date(subscription.next_billing_period_start, months=1, days=-1),
|
||||
get_date_str(subscription.next_billing_period_end),
|
||||
)
|
||||
self.assertEqual(subscription.invoices, [])
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
@@ -47,8 +50,8 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription()
|
||||
self.assertEqual(subscription.trial_period_start, None)
|
||||
self.assertEqual(subscription.trial_period_end, None)
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
# No invoice is created
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
@@ -60,20 +63,17 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertRaises(frappe.ValidationError, subscription.save)
|
||||
|
||||
def test_invoice_is_generated_at_end_of_billing_period(self):
|
||||
# Back-dated postpaid period has already ended, so catch-up bills it on creation
|
||||
# and advances to the next period.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-01-31")
|
||||
|
||||
subscription.process(posting_date="2018-01-31")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-02-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-02-28")
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
self.assertEqual(getdate(subscription.next_billing_period_start), getdate("2018-02-01"))
|
||||
self.assertEqual(getdate(subscription.next_billing_period_end), getdate("2018-02-28"))
|
||||
|
||||
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01") # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
@@ -91,7 +91,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.process()
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
|
||||
self.assertEqual(subscription.next_billing_period_start, add_months(subscription.start_date, 1))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_subscription_cancel_after_grace_period(self):
|
||||
@@ -99,12 +99,10 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
# Back-dated unpaid invoice is already past its (zero) grace period, so catch-up
|
||||
# cancels the subscription on creation.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
subscription.process(posting_date="2018-01-31") # generate first invoice
|
||||
# This should change status to Cancelled since grace period is 0
|
||||
# And is backdated subscription so subscription will be cancelled after processing
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
def test_subscription_unpaid_after_grace_period(self):
|
||||
@@ -126,7 +124,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
_date = add_months(nowdate(), -1)
|
||||
subscription = create_subscription(start_date=_date, days_until_due=10)
|
||||
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
@@ -138,7 +136,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), -1000))
|
||||
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||
self.assertEqual(subscription.status, "Grace Period")
|
||||
|
||||
subscription.process()
|
||||
@@ -158,20 +156,20 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription() # no changes expected
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
subscription.process() # no changes expected still
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
subscription.process() # no changes expected yet still
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_subscription_cancellation(self):
|
||||
@@ -195,16 +193,18 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
||||
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||
plan_days = flt(
|
||||
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||
)
|
||||
prorate_factor = flt(diff / plan_days)
|
||||
|
||||
self.assertEqual(
|
||||
flt(
|
||||
get_prorata_factor(
|
||||
subscription.current_invoice_end,
|
||||
subscription.current_invoice_start,
|
||||
cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
|
||||
subscription.next_billing_period_end,
|
||||
subscription.next_billing_period_start,
|
||||
cint(subscription.generate_invoice_at == "Prepaid (bill at period start)"),
|
||||
),
|
||||
2,
|
||||
),
|
||||
@@ -241,8 +241,10 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.cancel_subscription()
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
||||
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||
plan_days = flt(
|
||||
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||
)
|
||||
prorate_factor = flt(diff / plan_days)
|
||||
|
||||
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
|
||||
@@ -256,18 +258,12 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
# Back-dated unpaid invoice past grace -> cancelled with one invoice on creation.
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
subscription.process() # generate first invoice
|
||||
|
||||
# Generate an invoice for the cancelled period
|
||||
subscription.cancel_subscription()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
# Re-processing a cancelled subscription is a no-op.
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
@@ -313,9 +309,9 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.save()
|
||||
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||
)
|
||||
subscription.process(subscription.current_invoice_start) # generate first invoice
|
||||
subscription.process(subscription.next_billing_period_start) # generate first invoice
|
||||
# This should change status to Unpaid since grace period is 0
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
@@ -327,7 +323,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
# A new invoice is generated
|
||||
subscription.process(posting_date=subscription.current_invoice_start)
|
||||
subscription.process(posting_date=subscription.next_billing_period_start)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
@@ -364,7 +360,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
# Change the subscription type to prebilled and process it.
|
||||
# Prepaid invoice should be generated
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||
subscription.save()
|
||||
subscription.process()
|
||||
|
||||
@@ -376,7 +372,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.prorate = 1
|
||||
settings.save()
|
||||
|
||||
subscription = create_subscription(generate_invoice_at="Beginning of the current subscription period")
|
||||
subscription = create_subscription(generate_invoice_at="Prepaid (bill at period start)")
|
||||
subscription.process()
|
||||
subscription.cancel_subscription()
|
||||
|
||||
@@ -397,7 +393,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.company = "_Test Company"
|
||||
subscription.party_type = "Supplier"
|
||||
subscription.party = "_Test Supplier"
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||
subscription.follow_calendar_months = 1
|
||||
|
||||
# select subscription start date as "2018-01-15"
|
||||
@@ -406,36 +402,35 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
|
||||
subscription.save()
|
||||
|
||||
# even though subscription starts at "2018-01-15" and Billing interval is Month and count 3
|
||||
# First invoice will end at "2018-03-31" instead of "2018-04-14"
|
||||
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
|
||||
# The first (prepaid) period is billed on creation. Even though the subscription
|
||||
# starts at "2018-01-15" with a 3-month interval, follow_calendar_months ends the
|
||||
# first invoice at "2018-03-31" instead of "2018-04-14".
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Purchase Invoice", subscription.invoices[0].name, "to_date")),
|
||||
getdate("2018-03-31"),
|
||||
)
|
||||
|
||||
def test_subscription_generate_invoice_past_due(self):
|
||||
# With `generate_new_invoices_past_due_date` enabled, catch-up bills every elapsed
|
||||
# 3-month period up to the end date on creation, even while previous ones are unpaid.
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
end_date="2018-12-31",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
# Process subscription and create first invoice
|
||||
# Subscription status will be unpaid since due date has already passed
|
||||
subscription.process(posting_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(len(subscription.invoices), 4)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
# Now the Subscription is unpaid
|
||||
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
|
||||
# subscription and the interval between the subscriptions is 3 months
|
||||
subscription.process(posting_date="2018-04-01")
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
def test_subscription_without_generate_invoice_past_due(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
@@ -453,7 +448,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||
party=party,
|
||||
)
|
||||
@@ -475,7 +470,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||
party=party,
|
||||
)
|
||||
@@ -492,16 +487,13 @@ class TestSubscription(ERPNextTestSuite):
|
||||
"""Test if Subscription recovers when start/end date run out of sync with created invoices."""
|
||||
subscription = create_subscription(
|
||||
start_date="2021-01-01",
|
||||
end_date="2021-02-28",
|
||||
submit_invoice=0,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
party="_Test Subscription Customer John Doe",
|
||||
)
|
||||
|
||||
# create invoices for the first two moths
|
||||
subscription.process(posting_date="2021-01-31")
|
||||
|
||||
subscription.process(posting_date="2021-02-28")
|
||||
|
||||
# Catch-up bills both elapsed months on creation.
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
|
||||
@@ -512,7 +504,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
getdate("2021-02-01"),
|
||||
)
|
||||
|
||||
# recreate most recent invoice
|
||||
# Re-processing much later must not duplicate the already-billed periods.
|
||||
subscription.process(posting_date="2022-01-31")
|
||||
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
@@ -526,17 +518,16 @@ class TestSubscription(ERPNextTestSuite):
|
||||
)
|
||||
|
||||
def test_subscription_invoice_generation_before_days(self):
|
||||
# "Days before" trigger fires 10 days ahead of each period; catch-up bills both
|
||||
# elapsed periods (within the end date) on creation.
|
||||
subscription = create_subscription(
|
||||
start_date="2023-01-01",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
end_date="2023-02-28",
|
||||
generate_invoice_at="Bill N days before period start",
|
||||
number_of_days=10,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
|
||||
subscription.process(posting_date="2022-12-22")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date="2023-01-22")
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
def test_future_subscription(self):
|
||||
@@ -570,7 +561,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
start_date=start_date,
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
generate_invoice_at="Bill N days before period start",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
number_of_days=2,
|
||||
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
|
||||
@@ -592,16 +583,10 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=add_days(start_date, 8),
|
||||
cancel_at_period_end=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
subscription.process(posting_date=add_days(start_date, 2))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 5))
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 8))
|
||||
# Catch-up billing on creation generates every elapsed period and cancels at end
|
||||
self.assertEqual(len(subscription.invoices), 3)
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
@@ -619,24 +604,75 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=add_days(start_date, 6),
|
||||
cancel_at_period_end=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 2))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date=add_days(start_date, 5))
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
# partial last cycle invoice
|
||||
subscription.process(posting_date=add_days(start_date, 6))
|
||||
# Catch-up billing on creation incl. the partial last cycle, then cancels at end
|
||||
self.assertEqual(len(subscription.invoices), 3)
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
|
||||
|
||||
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
|
||||
# The trigger date (period end) is long past, yet catch-up still bills the period
|
||||
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
|
||||
subscription = create_subscription(start_date="2018-01-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:
|
||||
# Build the period without saving, so on-create billing doesn't try to post an
|
||||
# invoice (the deferred item has no account configured). This only exercises the
|
||||
# item-mapping helper.
|
||||
subscription = create_subscription(start_date="2018-01-01", do_not_save=True)
|
||||
subscription.update_subscription_period("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",
|
||||
@@ -654,7 +690,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=end_date,
|
||||
party_type="Customer",
|
||||
party="_Test Customer",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
|
||||
)
|
||||
@@ -673,10 +709,257 @@ class TestSubscription(ERPNextTestSuite):
|
||||
for invoice in invoices:
|
||||
pi = get_payment_entry("Sales Invoice", invoice.name)
|
||||
pi.submit()
|
||||
# Paying the invoices refreshes the subscription via the Payment Entry hook, so
|
||||
# reload before processing the stale in-memory copy.
|
||||
subscription.reload()
|
||||
# After processing through all days, subscription should be completed
|
||||
subscription.process(posting_date=add_days(end_date, 1))
|
||||
self.assertEqual(subscription.status, "Completed")
|
||||
|
||||
def test_status_updates_immediately_when_invoice_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
payment = get_payment_entry("Sales Invoice", invoice.name)
|
||||
payment.submit()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_invoice_update_hook_refreshes_subscription_status(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
invoice.db_set("status", "Paid")
|
||||
|
||||
update_subscription_on_invoice_update(invoice)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_payment_entry_triggers_subscription_status_update(self):
|
||||
# Test that payment entry → invoice → subscription status update chain works
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
self.assertIsNotNone(invoice)
|
||||
self.assertGreater(invoice.outstanding_amount, 0)
|
||||
|
||||
# Create and submit payment entry
|
||||
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
|
||||
payment_entry.reference_no = "12345"
|
||||
payment_entry.reference_date = nowdate()
|
||||
payment_entry.submit()
|
||||
|
||||
# Subscription status should now be Active (via on_update_after_submit hook)
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_current_invoice_dates_reflect_latest_invoice(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01")
|
||||
invoice = subscription.get_current_invoice()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate(invoice.from_date))
|
||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate(invoice.to_date))
|
||||
# `next_billing_period_start` tracks the next (unbilled) period.
|
||||
self.assertEqual(
|
||||
getdate(subscription.next_billing_period_start), getdate(add_days(invoice.to_date, 1))
|
||||
)
|
||||
|
||||
def test_first_invoice_not_generated_on_create_during_trial(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
trial_period_start=nowdate(),
|
||||
trial_period_end=add_days(nowdate(), 30),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
|
||||
def test_first_invoice_not_generated_during_bulk_import(self):
|
||||
frappe.flags.in_import = True
|
||||
try:
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
finally:
|
||||
frappe.flags.in_import = False
|
||||
|
||||
def test_first_invoice_not_generated_for_future_dated_subscription(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_generate_invoice_at_migration_patch(self):
|
||||
from erpnext.patches.v16_0.migrate_subscription_generate_invoice_at import VALUE_MAP, execute
|
||||
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), 10))
|
||||
for old_value, new_value in VALUE_MAP.items():
|
||||
frappe.db.set_value("Subscription", subscription.name, "generate_invoice_at", old_value)
|
||||
execute()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Subscription", subscription.name, "generate_invoice_at"), new_value
|
||||
)
|
||||
|
||||
def test_next_billing_period_populated_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(getdate(subscription.next_billing_period_start), getdate(add_days(nowdate(), 10)))
|
||||
self.assertGreater(
|
||||
getdate(subscription.next_billing_period_end), getdate(subscription.next_billing_period_start)
|
||||
)
|
||||
|
||||
def test_status_becomes_refunded_when_only_invoice_credited(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
make_full_credit_note(subscription.get_current_invoice().name)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Refunded")
|
||||
|
||||
def test_status_stays_unpaid_when_one_of_two_invoices_credited(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_months(nowdate(), -2),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
invoices = frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={"subscription": subscription.name, "docstatus": 1, "is_return": 0},
|
||||
pluck="name",
|
||||
order_by="from_date asc",
|
||||
)
|
||||
self.assertGreaterEqual(len(invoices), 2)
|
||||
|
||||
make_full_credit_note(invoices[0])
|
||||
|
||||
subscription.reload()
|
||||
self.assertNotEqual(subscription.status, "Refunded")
|
||||
|
||||
def test_refunded_reverts_to_active_after_full_settlement(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
make_full_credit_note(invoice.name)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Refunded")
|
||||
|
||||
invoice.db_set("status", "Paid")
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_heatmap_spans_twelve_months_from_start_month(self):
|
||||
start_date = getdate("2024-03-14")
|
||||
subscription = create_subscription(start_date=start_date)
|
||||
heatmap = subscription.get_billing_heatmap()
|
||||
self.assertEqual(getdate(heatmap[0]["date"]), get_first_day(start_date))
|
||||
self.assertEqual(
|
||||
getdate(heatmap[-1]["date"]), get_last_day(add_months(get_first_day(start_date), 11))
|
||||
)
|
||||
self.assertIn("status", heatmap[0])
|
||||
|
||||
def test_heatmap_marks_paid_days_green(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
invoice.db_set("status", "Paid")
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
|
||||
subscription.reload()
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "paid")
|
||||
|
||||
def test_heatmap_marks_future_planned_days(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
today = getdate(nowdate())
|
||||
planned = [
|
||||
cell
|
||||
for cell in subscription.get_billing_heatmap()
|
||||
if cell["status"] == "planned" and getdate(cell["date"]) > today
|
||||
]
|
||||
self.assertTrue(planned)
|
||||
|
||||
def test_heatmap_marks_refunded_days_for_credited_periods(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
make_full_credit_note(invoice.name)
|
||||
|
||||
subscription.reload()
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded")
|
||||
|
||||
|
||||
def make_full_credit_note(invoice_name):
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
credit_note = make_sales_return(invoice_name)
|
||||
credit_note.insert()
|
||||
credit_note.submit()
|
||||
return credit_note
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
0
erpnext/accounts/letter_head/__init__.py
Normal file
0
erpnext/accounts/letter_head/__init__.py
Normal 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"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user