Compare commits

..

1 Commits

Author SHA1 Message Date
Nabin Hait
15d65e4626 test: keep created BOMs non-default so fixtures are not replaced 2026-07-01 16:10:22 +05:30
569 changed files with 85181 additions and 262410 deletions

View File

@@ -45,9 +45,7 @@ Flag a changed query that uses any of these:
- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in
`HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the
underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`.
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select
**only if it is single-valued per distinct row**; otherwise it grows the `DISTINCT` key and the
MariaDB row count (see §3) — drop the SQL `ORDER BY` and sort in Python instead.
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select.
- **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an
unquoted (or double-quoted) alias.
- **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use
@@ -121,7 +119,7 @@ These don't error, so a one-engine CI stays green. Flag them:
---
## 3. The row-count trap — `GROUP BY` **and** `DISTINCT` (the single most important rule)
## 3. The `GROUP BY` row-count trap (the single most important rule)
When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent
column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and
@@ -142,42 +140,6 @@ versa) to make a number "more correct" — that changes the MariaDB value. The w
MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of
scope for a portability fix.
**The same trap applies to `SELECT DISTINCT`.** To satisfy PostgreSQL's "an `ORDER BY` expr must
appear in the select list under `DISTINCT`" rule, **do not blindly add the ordered column to the
select** — if it is not single-valued per existing distinct row, the `DISTINCT` key grows and
MariaDB returns **more rows** (a regression), exactly as adding a non-FD column to `GROUP BY` does.
Add it only when it is functionally dependent on the existing select columns; otherwise drop the
SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is
unchanged.
### 3.1 Second-order traps — when the `Max()`/`Min()` wrap itself is the bug
The wrap is only a no-op when the column is provably single-valued per group (**"`Max()` means
provably constant"**). When the column can genuinely vary, the wrap is a decision, and a full
audit of these fixes found four recurring mistakes:
- **Incoherent pair** — two semantically-coupled columns (a flag + a link:
`is_phantom_item` + `bom_no`; a discriminator + its value) aggregated with *independent*
`Max()`/`Min()` can pair values from **different rows** — a chimera row that never existed.
MariaDB's loose pick was at least row-coherent. Fix: group by the pair (when consumers
tolerate the extra rows), or select one **representative row** (`Min(child.name)` subquery +
join-back) so every column comes from the same line.
- **NULL-skipping** — `MAX`/`MIN` ignore NULLs, so `Max()` over a mostly-NULL discriminator
(an `original_item`-style column) *deterministically* returns the non-NULL value where
MariaDB could return NULL — deterministically wrong where the old behavior was only
intermittently wrong. Flag it wherever "no value" is a meaningful state (fallback gates,
dict keys).
- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a
number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation,
budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`.
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
average for a rate. A blind `Max` can understate urgency or overstate a figure.
Review heuristic: **if choosing between `Max` and `Min` would change the answer, the column is
not functionally dependent** — wrapping either is the wrong fix. Group by it, restructure, or
pick a bound for a stated reason, and cover the varying-group case with a test.
---
## 4. False positives — do NOT flag these
@@ -205,44 +167,17 @@ These are auto-handled by the framework and are **not** breaks:
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
- **Recover the txn with a *scoped* savepoint, not a full `frappe.db.rollback()`, if any prior work
must survive.** A full rollback un-poisons the txn but also discards every row the handler committed
*before* the failure — which MariaDB kept (it has no statement-abort), so it's a **silent MariaDB
regression**. **"The background job / whitelist entrypoint owns the txn" does NOT make a full rollback
safe** if it did multiple inserts in a loop first — it drops the partial results MariaDB retained. A
full rollback is safe only when it (a) immediately re-`throw`s/`raise`s (MariaDB rolls back anyway),
(b) has nothing successful before it (a single op), or (c) the batch is genuinely meant to be
**atomic** (a partial result is an invalid state → rollback + mark *Failed* is correct). Otherwise use
a **per-iteration / per-record savepoint** — and keep the function's success/`None` return contract:
do **not** return the doc when the savepoint was rolled back.
---
## 6. Refactors and raw-SQL→ORM conversions are not automatically 1:1
A commit labeled a **refactor** or a **raw-`frappe.db.sql` → `frappe.qb`/ORM conversion** is meant
to preserve behaviour — but it easily doesn't, and the change passes the static checker and a
one-engine green run. **Diff the `WHERE`/predicate, the `JOIN`/`ON` conditions, and the resulting
row set — not just the `SELECT` shape.** A conversion that silently widens or narrows the filter
changes the rows touched on **both** engines and is a regression hiding under a "refactor" label.
Real example: an `UPDATE` whose bound was `posting_datetime > X` gained an
`OR (posting_datetime == X AND creation > args.creation)` branch during a "`sql` → `qb` refactor",
widening the rows updated on both engines. Even when such a change is a deliberate bug-fix it must
be called out and tested — it is **not** the no-op the refactor label implies. Confirm the
converted query touches exactly the same rows with the same values MariaDB produced before.
---
## How to review
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL),
(b) match a divergence in §2/§3 (different result across engines), or (c) change the row set under
a refactor/conversion label (§6)? If so, comment with the
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or
(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the
portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives.
Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY:
splits the row count") so the fix is unambiguous.
The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical*
§1 breaks; the **semantic** §2/§3 divergences and the §6 refactor/conversion row-set changes are
exactly what a reviewer (and this guide) must cover, because no static check can see them.
§1 breaks; the **semantic** §2/§3 divergences are exactly what a reviewer (and this guide) must
cover, because no static check can see them.

View File

@@ -297,10 +297,14 @@ if [ "$DB" == "postgres" ];then
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
# Durability-off for speed (no fsync/synchronous_commit/full_page_writes) is applied by
# start-db.sh's postgres `-o` flags on every start — setup job AND each test shard — so it is
# NOT repeated here. The postgres workflow runs in-runner via start-db.sh, not a service
# container.
# Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which
# dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a
# service-container DB and never calls start-db.sh, so the flags must be applied here.
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
-c "ALTER SYSTEM SET fsync = 'off'" \
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
-c "SELECT pg_reload_conf()";
fi
cd ~/frappe-bench || exit

View File

@@ -21,7 +21,7 @@ jobs:
cache: pip
- name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.1
uses: pre-commit/action@v3.0.0
semgrep:
name: semgrep

View File

@@ -66,7 +66,7 @@ jobs:
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
# it from the GitHub release every run.
# ~100MB from frappe.io every run.
- name: Cache erpnext v14 backup
id: cache-v14
uses: actions/cache@v4
@@ -76,10 +76,7 @@ jobs:
- name: Download erpnext v14 backup
if: steps.cache-v14.outputs.cache-hit != 'true'
run: |
curl -fSL --retry 5 --retry-all-errors --retry-delay 5 \
-o ~/erpnext-v14.sql.gz \
https://github.com/frappe/erpnext/releases/download/v14-baseline/erpnext-v14.sql.gz
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
- name: Cache pip
uses: actions/cache@v4

View File

@@ -107,13 +107,6 @@ jobs:
SKIP_SYSTEM_SETUP: "1"
SKIP_WKHTMLTOX_SETUP: "1"
- name: Warm up test data
run: |
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
cd ~/frappe-bench/
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
EOF
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
- name: Stop DB and stage datadir
run: |

View File

@@ -105,19 +105,10 @@ jobs:
FRAPPE_BRANCH: develop
BENCH_CACHE_DIR: /home/runner/bench-cache
- name: Warm up test data
run: |
cd ~/frappe-bench/
bench --site test_site run-tests --lightmode --module erpnext.tests.bootstrap_test_data
- name: Stop DB and stage datadir
run: |
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
# Clean shutdown so the baked datadir is consistent. Do NOT swallow a failed stop with
# `|| true`: moving and tarring a still-running cluster ships a torn datadir the shards
# cannot crash-recover (full_page_writes is off). Fail the job instead — mirrors the
# MariaDB sister's "don't bake a dirty datadir" guard.
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
- name: Package bench for test shards
@@ -137,7 +128,7 @@ jobs:
compression-level: 0
test:
name: Python Unit Tests
name: Python Unit Tests (PG)
needs: setup
runs-on: ubuntu-latest
timeout-minutes: 60

File diff suppressed because one or more lines are too long

View File

@@ -48,6 +48,7 @@ repos:
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
erpnext/public/js/controllers/.*|
erpnext/templates/pages/order.js|
erpnext/templates/includes/.*
)$

View File

@@ -14,35 +14,35 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.3.2",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.0.1",
"chrono-node": "^2.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.17.0",
"frappe-react-sdk": "^1.15.0",
"fuse.js": "^7.3.0",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.0",
"jotai-family": "^1.0.1",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.6.1",
"react": "^19.2.7",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-currency-input-field": "^4.0.5",
"react-day-picker": "9.14.0",
"react-dom": "^19.2.7",
"react-dom": "^19.2.6",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^8.1.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.0",
@@ -51,15 +51,15 @@
"vite": "^8.0.16"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.1",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.62.1"
"typescript-eslint": "^8.48.0"
}
}

View File

@@ -1,5 +1,5 @@
import { lazy, useEffect } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { FrappeProvider } from 'frappe-react-sdk'
import { Toaster } from '@/components/ui/sonner'
import BankReconciliation from '@/pages/BankReconciliation'

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo, useState } from "react"
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
@@ -25,7 +26,6 @@ import { Form } from "@/components/ui/form"
import { useForm } from "react-hook-form"
import { DateField } from "@/components/ui/form-elements"
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankClearanceSummary = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => {
[accountCurrency, bankAccount, companyID, mutate, onCopy],
)
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -18,7 +18,6 @@ import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Checkbox } from "@/components/ui/checkbox"
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
import { evaluateAmountFormula } from "@/lib/amountFormula"
import { flt, formatCurrency } from "@/lib/numbers"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
@@ -216,13 +215,38 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci
})
} else {
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
/**
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
* So we need to compute the value of the expression
* We can use the eval function to do this. But we need to expose certain variables to the expression.
* One of them is transaction_amount which is the unallocated amount of the selected transaction
* @param expression - The expression to compute
* @returns The computed value
*/
const computeExpression = (expression: string) => {
const script = `
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
${expression};
`
let value = 0;
try {
value = window.eval(script);
} catch (error: unknown) {
console.error(error);
value = 0;
}
return value;
}
if (!acc?.debit && !acc?.credit) {
hasTotallyEmptyRowEarlier = true;
}
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
totalDebits = flt(totalDebits + computedDebit, 2)
totalCredits = flt(totalCredits + computedCredit, 2)

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import { useCallback, useMemo } from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useFrappeGetCall } from "frappe-react-sdk"
@@ -18,7 +19,6 @@ import _ from "@/lib/translate"
import { toast } from "sonner"
import { useCopyToClipboard } from "usehooks-ts"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankReconciliationStatement = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => {
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
}, [data])
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
return <div className="space-y-4 py-2">
<div>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
}} />
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -1,6 +1,7 @@
import { useAtomValue, useSetAtom } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { Paragraph } from "@/components/ui/typography"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
@@ -22,7 +23,6 @@ import { useCallback, useMemo, useState } from "react"
import { Link } from "react-router"
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import MarkdownRenderer from "@/components/ui/markdown"
const BankTransactions = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
@@ -243,14 +243,14 @@ const BankTransactionListView = () => {
}, [data, search, amountFilter, typeFilter, status])
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-2 py-2">
<div className="flex gap-2 justify-between items-center">
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
<Button size='md' variant='subtle' asChild>
<Link to="/statement-importer">

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo } from "react"
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
@@ -17,7 +18,6 @@ import { PartyPopper } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import _ from "@/lib/translate"
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const IncorrectlyClearedEntries = () => {
const companyID = useCurrentCompany()
@@ -177,22 +177,22 @@ const IncorrectlyClearedEntriesView = () => {
[accountCurrency, onClearClick],
)
const content = _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
}} />
<br />
{data && data.message.result.length > 0 && <span>
<MarkdownRenderer content={entriesContent} />
<span dangerouslySetInnerHTML={{
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
<br />
{_("You can reset the clearing dates of these entries here.")}
</span>}
</span>
</Paragraph>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -11,7 +11,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { H4, Paragraph } from "@/components/ui/typography"
import { today } from "@/lib/date"
import { evaluateAmountFormula } from "@/lib/amountFormula"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
@@ -446,10 +445,11 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
// If it's a string and cannot be a number, then show it as a formula
if (isNaN(Number(value))) {
let calculatedValue = "";
try {
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
} catch (error: unknown) {
console.error(error);
calculatedValue = "Error";

View File

@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { Link, useNavigate } from 'react-router'
import { Link, useNavigate } from 'react-router-dom'
import { useMemo, useState } from 'react'
import { Progress } from '@/components/ui/progress'
import { useSetAtom } from 'jotai'

View File

@@ -1,26 +0,0 @@
import { Parser } from 'safe-expr-eval'
const parser = new Parser()
const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
const trimmed = expression.trim()
if (!trimmed) {
return 0
}
if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
return Number(trimmed)
}
try {
const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
if (typeof result !== 'number' || !Number.isFinite(result)) {
return 0
}
return result
} catch {
return 0
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -179,12 +179,7 @@ def normalize_ctx_input(T: type) -> callable:
def decorator(func: callable):
# conserve annotations for frappe.utils.typing_validations
@functools.wraps(
func,
assigned=(
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
),
)
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
if isinstance(ctx, Document):
ctx = T(**ctx.as_dict())

View File

@@ -1,129 +0,0 @@
import frappe
from frappe import _
from frappe.utils import flt
from erpnext.accounts.doctype.payment_entry.payment_entry import (
get_outstanding_reference_documents,
get_payment_entry,
)
from erpnext.utilities.bulk_transaction import transaction_processing
@frappe.whitelist(methods=["POST"])
def create_payment_entries(
grouped_invoices: str | list | None = None,
ungrouped_invoices: str | list | None = None,
):
"""Create draft Payment Entries from AP report invoice selection."""
frappe.has_permission("Payment Entry", "create", throw=True)
grouped_invoices = [d for d in frappe.parse_json(grouped_invoices or "[]") if d.get("voucher_no")]
ungrouped_invoices = [d for d in frappe.parse_json(ungrouped_invoices or "[]") if d.get("voucher_no")]
if not grouped_invoices and not ungrouped_invoices:
frappe.throw(_("No Purchase Invoices selected"))
if ungrouped_invoices:
data = [{"name": d["voucher_no"]} for d in ungrouped_invoices]
transaction_processing(data, "Purchase Invoice", "Payment Entry")
if grouped_invoices:
groups = {}
for d in grouped_invoices:
key = (d["supplier"], d["party_account"])
groups.setdefault(
key, {"supplier": d["supplier"], "party_account": d["party_account"], "vouchers": []}
)["vouchers"].append(d["voucher_no"])
frappe.msgprint(
_("Started a background job to create {0} Grouped Payment Entries").format(len(groups))
)
frappe.enqueue(
make_grouped_payment_entries,
queue="long",
timeout=1500,
groups=list(groups.values()),
)
def make_grouped_payment_entries(groups):
created, failed = 0, 0
for group in groups:
supplier = group["supplier"]
try:
frappe.db.savepoint("bulk_pe")
pe = _build_grouped_payment_entry(supplier, group["party_account"], group["vouchers"])
if not pe:
frappe.db.rollback(save_point="bulk_pe")
failed += 1
frappe.log_error(
title=_("Bulk Payment Entry skipped for {0}").format(supplier),
message=_(
"No outstanding invoices found for the selected vouchers in account {0}"
).format(group["party_account"]),
)
continue
pe.flags.ignore_validate = True
pe.set_title_field()
pe.insert(ignore_mandatory=True)
created += 1
except Exception:
frappe.db.rollback(save_point="bulk_pe")
failed += 1
frappe.log_error(title=_("Bulk Payment Entry creation failed for {0}").format(supplier))
message = _("Created {0} draft Grouped Payment Entries").format(created)
if failed:
message += "" + _("{0} skipped (see Error Log)").format(failed)
frappe.publish_realtime(
"msgprint",
{"message": message, "title": _("Bulk Payment Entries"), "indicator": "green"},
user=frappe.session.user,
after_commit=True,
)
def _build_grouped_payment_entry(supplier, party_account, names):
pe = get_payment_entry("Purchase Invoice", names[0])
pe.set("references", [])
refs = get_outstanding_reference_documents(
{
"party_type": "Supplier",
"party": supplier,
"party_account": party_account,
"company": pe.company,
"vouchers": [frappe._dict(voucher_type="Purchase Invoice", voucher_no=n) for n in names],
}
)
for r in refs:
if r.voucher_type != "Purchase Invoice":
continue
pe.append(
"references",
{
"reference_doctype": r.voucher_type,
"reference_name": r.voucher_no,
"bill_no": r.get("bill_no"),
"due_date": r.get("due_date"),
"payment_term": r.get("payment_term"),
"total_amount": r.invoice_amount,
"outstanding_amount": r.outstanding_amount,
"allocated_amount": r.outstanding_amount,
"exchange_rate": r.get("exchange_rate") or 1,
},
)
if not pe.references:
return None
# received_amount is in paid_to account currency; convert to paid_from account currency for paid_amount
pe.received_amount = sum(r.allocated_amount for r in pe.references)
pe.paid_amount = flt(pe.received_amount * pe.target_exchange_rate, pe.precision("paid_amount"))
pe.set_amounts()
return pe

View File

@@ -582,7 +582,6 @@ def make_gl_entries(
frappe.db.commit()
except Exception as e:
if frappe.in_test:
frappe.db.rollback()
doc.log_error(f"Error while processing deferred accounting for Invoice {doc.name}")
raise e
else:

View File

@@ -37,10 +37,6 @@
"account_type": "Stock",
"account_category": "Stock Assets"
},
"Stock Delivered But Not Billed": {
"account_type": "Stock Delivered But Not Billed",
"account_category": "Stock Assets"
},
"account_type": "Stock",
"account_category": "Stock Assets"
},
@@ -227,6 +223,10 @@
"Stock Received But Not Billed": {
"account_type": "Stock Received But Not Billed",
"account_category": "Trade Payables"
},
"Stock Delivered But Not Billed": {
"account_type": "Stock Delivered But Not Billed",
"account_category": "Trade Payables"
}
},
"Duties and Taxes": {

View File

@@ -1,59 +1,10 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
aggregate_with_last_account_closing_balance,
generate_key,
)
# import frappe
from erpnext.tests.utils import ERPNextTestSuite
def entry(**overrides):
row = {"debit": 0, "credit": 0, "debit_in_account_currency": 0, "credit_in_account_currency": 0}
row.update(overrides)
return row
class TestAccountClosingBalance(ERPNextTestSuite):
"""The closing-balance snapshot is built by merging this period's entries with the
previous period's. These lock the merge/key logic that drives that carry-forward."""
def test_matching_entries_are_summed(self):
# this is how a prior-period balance carries forward into the current one
merged = aggregate_with_last_account_closing_balance(
[
entry(account="Cash - _TC", debit=100, debit_in_account_currency=100),
entry(
account="Cash - _TC",
debit=50,
credit=20,
debit_in_account_currency=50,
credit_in_account_currency=20,
),
],
[],
)
self.assertEqual(len(merged), 1)
row = next(iter(merged.values()))
self.assertEqual(row["debit"], 150)
self.assertEqual(row["credit"], 20)
# the account-currency columns are accumulated in the same pass
self.assertEqual(row["debit_in_account_currency"], 150)
self.assertEqual(row["credit_in_account_currency"], 20)
def test_entries_are_kept_separate_per_dimension(self):
merged = aggregate_with_last_account_closing_balance(
[
entry(account="Cash - _TC", cost_center="CC1", debit=100, debit_in_account_currency=100),
entry(account="Cash - _TC", cost_center="CC2", debit=40, debit_in_account_currency=40),
],
[],
)
self.assertEqual(len(merged), 2)
def test_period_closing_flag_is_part_of_the_key(self):
# a P&L reversal (flag 0) and a closing-account entry (flag 1) for the same
# account must not merge, so the flag has to distinguish their keys
key_reversal, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=0), [])
key_closing, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=1), [])
self.assertNotEqual(key_reversal, key_closing)
pass

View File

@@ -6,7 +6,7 @@ frappe.ui.form.on("Accounting Dimension Filter", {
let help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<p>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
<i class="fa fa-hand-right"></i>
{{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}}
</p>
</td></tr>

View File

@@ -5,7 +5,6 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
class OverlapError(frappe.ValidationError):
@@ -37,20 +36,8 @@ class AccountingPeriod(Document):
# end: auto-generated types
def validate(self):
self.validate_dates()
self.validate_overlap()
def validate_dates(self):
if getdate(self.start_date) > getdate(self.end_date):
frappe.throw(_("Start Date cannot be after End Date"))
if getdate(self.end_date) > getdate(nowdate()):
frappe.throw(
_(
"Accounting Period cannot be created for a future date. End Date {0} is after today."
).format(frappe.bold(frappe.format(self.end_date, "Date")))
)
def before_insert(self):
self.bootstrap_doctypes_for_closing()

View File

@@ -2,7 +2,7 @@
# See license.txt
import frappe
from frappe.utils import nowdate
from frappe.utils import add_months, nowdate
from erpnext.accounts.doctype.accounting_period.accounting_period import (
ClosedAccountingPeriod,
@@ -93,7 +93,7 @@ def create_accounting_period(**args):
accounting_period = frappe.new_doc("Accounting Period")
accounting_period.start_date = args.start_date or nowdate()
accounting_period.end_date = args.end_date or nowdate()
accounting_period.end_date = args.end_date or add_months(nowdate(), 1)
accounting_period.company = args.company or "_Test Company"
accounting_period.period_name = args.period_name or "_Test_Period_Name_1"
accounting_period.append("closed_documents", {"document_type": "Sales Invoice", "closed": 1})

View File

@@ -188,7 +188,7 @@ def get_closing_balance_as_per_statement(bank_account: str, date: str):
return {"balance": 0, "date": None}
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def set_closing_balance_as_per_statement(bank_account: str, date: str | datetime.date, balance: float):
"""
Set the closing balance as per statement for a bank account and date

View File

@@ -1,76 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.accounts.doctype.bank_guarantee.bank_guarantee import get_voucher_details
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
BANK = "_Test BG Bank"
class TestBankGuarantee(ERPNextTestSuite):
"""Bank Guarantee records a guarantee issued/received against a customer or
supplier. validate() needs a party; on_submit() needs the bank details filled in."""
def setUp(self):
frappe.set_user("Administrator")
if not frappe.db.exists("Bank", BANK):
frappe.get_doc({"doctype": "Bank", "bank_name": BANK}).insert()
def make_bg(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Bank Guarantee")
doc.bg_type = args.bg_type or "Receiving"
doc.amount = args.amount if args.amount is not None else 1000
doc.start_date = args.start_date or "2026-06-01"
if args.end_date:
doc.end_date = args.end_date
doc.customer = args.get("customer", "_Test Customer")
doc.supplier = args.get("supplier")
# fields on_submit requires — present by default, cleared per-test to assert the guard
doc.bank_guarantee_number = args.get("bank_guarantee_number", "BG-001")
doc.name_of_beneficiary = args.get("name_of_beneficiary", "Test Beneficiary")
doc.bank = args.get("bank", BANK)
return doc
def test_validate_requires_customer_or_supplier(self):
doc = self.make_bg(customer=None)
self.assertRaises(frappe.ValidationError, doc.insert)
def test_submit_requires_guarantee_number(self):
doc = self.make_bg(bank_guarantee_number="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_submit_requires_beneficiary_name(self):
doc = self.make_bg(name_of_beneficiary="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_submit_requires_bank(self):
doc = self.make_bg(bank="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_valid_guarantee_submits(self):
doc = self.make_bg()
doc.insert()
doc.submit()
self.assertEqual(frappe.db.get_value("Bank Guarantee", doc.name, "docstatus"), 1)
def test_get_voucher_details_for_receiving(self):
so = make_sales_order()
details = get_voucher_details("Receiving", so.name)
self.assertEqual(details.customer, so.customer)
self.assertEqual(flt(details.grand_total), flt(so.grand_total))
def test_end_date_before_start_date_is_not_validated(self):
# SUSPECTED BUG: validate() never checks that end_date >= start_date, so a
# guarantee that expires before it starts saves cleanly. Locking the current
# (wrong) behaviour so a future fix that adds the check trips this test.
doc = self.make_bg(start_date="2026-06-30", end_date="2026-06-01")
doc.insert()
self.assertTrue(frappe.db.exists("Bank Guarantee", doc.name))
pass

View File

@@ -116,7 +116,7 @@ def get_account_balance(bank_account: str, till_date: str | date, company: str):
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def update_bank_transaction(
bank_transaction_name: str, reference_number: str, party_type: str | None = None, party: str | None = None
):
@@ -146,7 +146,7 @@ def update_bank_transaction(
)[0]
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_journal_entry_bts(
bank_transaction_name: str,
reference_number: str | None = None,
@@ -305,7 +305,7 @@ def create_journal_entry_bts(
return reconcile_vouchers(bank_transaction_name, vouchers, is_new_voucher=True)
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_payment_entry_bts(
bank_transaction_name: str,
reference_number: str | None = None,
@@ -500,7 +500,7 @@ def create_bulk_internal_transfer(bank_transaction_names: list[str | int], bank_
return output
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_internal_transfer(
bank_transaction_name: str | int,
posting_date: str | date,
@@ -1057,7 +1057,7 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
return alert_message, indicator
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
# updated clear date of all the vouchers based on the bank transaction
vouchers = frappe.parse_json(vouchers)

View File

@@ -8,7 +8,6 @@ from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
get_auto_reconcile_message,
get_bank_transactions,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
@@ -98,40 +97,3 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def make_bank_transaction(self, date, deposit=100):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": date,
"deposit": deposit,
"bank_account": self.bank_account,
"currency": "INR",
}
)
.save()
.submit()
)
def test_get_bank_transactions_excludes_dates_after_to_date(self):
self.make_bank_transaction(date=today())
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_auto_reconcile_message_for_no_matches(self):
message, indicator = get_auto_reconcile_message([], [])
self.assertEqual(indicator, "blue")
self.assertIn("No matches", message)
def test_auto_reconcile_message_counts_and_pluralizes(self):
# reconciled count is reported and the indicator turns green
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
self.assertEqual(indicator, "green")
self.assertIn("2 Transaction(s) Reconciled", message)
# partially-reconciled label is singular for one, plural for many
singular, _ = get_auto_reconcile_message(["p1"], [])
self.assertIn("1 Transaction Partially Reconciled", singular)
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
self.assertIn("2 Transactions Partially Reconciled", plural)

View File

@@ -829,9 +829,7 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
if amount_format == 'Amount column has "CR"/"DR" values':
amount = transaction_row.get("amount")
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
float_amount = abs(get_float_amount(amount) or 0)
float_amount = get_float_amount(amount)
if "cr" in amount.lower():
return 0, float_amount
else:
@@ -934,18 +932,14 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
if reader.is_encrypted:
# Try opening the PDF with a password - if no password is provided, try with a blank password
if not password:
password = ""
if not reader.decrypt(password):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
if reader.is_encrypted and (not password or not reader.decrypt(password)):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
tables = []

View File

@@ -397,7 +397,7 @@ def unreconcile_transaction(transaction_name: str | int):
frappe.get_doc(voucher["doctype"], voucher["name"]).cancel()
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type: str, voucher_id: str | int):
"""
Removes a single payment entry from a bank transaction - for example only undoing one voucher instead of undoing the entire transaction

View File

@@ -34,7 +34,7 @@ def upload_bank_statement():
return {"columns": columns, "data": data}
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_bank_entries(columns: str, data: str | list, bank_account: str):
header_map = get_header_mapping(columns, bank_account)
@@ -47,7 +47,6 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
for key, value in header_map.items():
fields.update({key: d[int(value) - 1]})
frappe.db.savepoint("bank_entry")
try:
bank_transaction = frappe.get_doc({"doctype": "Bank Transaction"})
bank_transaction.update(fields)
@@ -57,8 +56,7 @@ def create_bank_entries(columns: str, data: str | list, bank_account: str):
bank_transaction.submit()
success += 1
except Exception:
frappe.db.rollback(save_point="bank_entry")
frappe.log_error(title="Bank entry creation failed")
bank_transaction.log_error("Bank entry creation failed")
errors += 1
return {"success": success, "errors": errors}

View File

@@ -9,48 +9,6 @@ from frappe.model.document import Document
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
PLAIN_NUMBER_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
# Tokens accepted by safe-expr-eval on the frontend (must stay in sync).
ALLOWED_FORMULA_TOKEN = re.compile(r"\s+|transaction_amount|\d+(?:\.\d+)?|[+\-*/%^()]")
PYTHON_ONLY_OPERATORS = ("**", "//")
def _is_expr_eval_formula(formula: str) -> bool:
position = 0
while position < len(formula):
match = ALLOWED_FORMULA_TOKEN.match(formula, position)
if not match:
return False
position = match.end()
return formula.count("(") == formula.count(")")
def validate_amount_formula(formula: str) -> None:
if not formula:
return
stripped = formula.strip()
if PLAIN_NUMBER_PATTERN.match(stripped):
return
if any(operator in stripped for operator in PYTHON_ONLY_OPERATORS):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
if not _is_expr_eval_formula(stripped):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
# expr-eval uses ^ for exponentiation; translate for a smoke-test evaluation only.
python_formula = stripped.replace("^", "**")
try:
result = frappe.safe_eval(python_formula, eval_globals=None, eval_locals={"transaction_amount": 1})
except Exception:
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
if not isinstance(result, (int | float)):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
class BankTransactionRule(Document):
# begin: auto-generated types
@@ -128,11 +86,6 @@ class BankTransactionRule(Document):
frappe.throw(
_("The last account row must not have any debit or credit amounts set.")
)
else:
if account.debit:
validate_amount_formula(account.debit)
if account.credit:
validate_amount_formula(account.credit)
# Validate regex
for rule in self.description_rules:

View File

@@ -231,45 +231,3 @@ class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
doc = self._rule("bad_rx", [{"check": "Regex", "value": "["}])
with self.assertRaises(ValidationError):
doc.insert()
def _multiple_accounts_rule(self, prefix: str, accounts, **fields):
return self._rule(
prefix,
[{"check": "Contains", "value": "x"}],
classify_as="Bank Entry",
bank_entry_type="Multiple Accounts",
accounts=accounts,
**fields,
)
def test_validate_bank_entry_multiple_valid_amount_formulas(self):
doc = self._multiple_accounts_rule(
"be_formula",
accounts=[
{"account": self.bank, "debit": "200", "credit": ""},
{"account": self.cash, "debit": "", "credit": "transaction_amount * 0.25"},
{"account": self.cash, "debit": "", "credit": ""},
],
)
doc.insert()
self.assertTrue(doc.name)
def test_validate_bank_entry_multiple_invalid_amount_formulas(self):
malicious_formulas = [
"__import__('os')",
"eval('1+1')",
"open('/etc/passwd')",
"transaction_amount ** 2",
"transaction_amount // 2",
]
for formula in malicious_formulas:
with self.subTest(formula=formula):
doc = self._multiple_accounts_rule(
"be_bad_formula",
accounts=[
{"account": self.bank, "debit": formula, "credit": ""},
{"account": self.cash, "debit": "", "credit": ""},
],
)
with self.assertRaises(ValidationError):
doc.insert()

View File

@@ -184,7 +184,7 @@ class BisectAccountingStatements(Document):
self.get_report_summary()
self.update_node()
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def bisect_left(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
@@ -198,7 +198,7 @@ class BisectAccountingStatements(Document):
else:
frappe.msgprint(_("No more children on Left"))
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def bisect_right(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
@@ -212,7 +212,7 @@ class BisectAccountingStatements(Document):
else:
frappe.msgprint(_("No more children on Right"))
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def move_up(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)

View File

@@ -1,47 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import datetime
# import frappe
import frappe
from frappe.utils import getdate
from erpnext.tests.utils import ERPNextTestSuite
class TestBisectAccountingStatements(ERPNextTestSuite):
"""The tool bisects a date range into a tree of Bisect Nodes down to single days.
These cover the date validation and that the bisection cleanly partitions the range."""
def setUp(self):
frappe.set_user("Administrator")
frappe.db.delete("Bisect Nodes")
def _leaf_days(self):
leaves = frappe.get_all(
"Bisect Nodes",
filters={"left_child": ["is", "not set"]},
fields=["period_from_date", "period_to_date"],
)
# every leaf spans a single day
for leaf in leaves:
self.assertEqual(getdate(leaf.period_from_date), getdate(leaf.period_to_date))
return sorted(getdate(leaf.period_from_date) for leaf in leaves)
def test_validate_dates_rejects_reversed_range(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.from_date = "2026-01-08"
doc.to_date = "2026-01-01"
self.assertRaises(frappe.ValidationError, doc.validate)
def test_bfs_partitions_range_into_single_days(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.bfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
# the 8-day span Jan 1..Jan 8 becomes exactly 8 contiguous single-day leaves
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
def test_dfs_produces_the_same_partition_as_bfs(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.dfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
pass

View File

@@ -878,7 +878,7 @@ def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
return from_year.year_start_date, to_year.year_end_date
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def revise_budget(budget_name: str):
old_budget = frappe.get_doc("Budget", budget_name)

View File

@@ -1,67 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.tests.utils import ERPNextTestSuite
DATE = "2026-06-15"
class TestCashierClosing(ERPNextTestSuite):
"""Cashier Closing reconciles a shift: it pulls outstanding invoices in a
date/time window and rolls payments, expense, custody and returns into net_amount."""
def setUp(self):
frappe.set_user("Administrator")
def make_invoice_in_window(self, rate=100):
si = create_sales_invoice(rate=rate, qty=1, posting_date=DATE, do_not_submit=True)
si.posting_time = "10:30:00"
si.submit()
si.reload() # read outstanding_amount as persisted after submit
return si
def make_closing(self, user="Administrator", payments=None, **args):
doc = frappe.new_doc("Cashier Closing")
doc.user = user
doc.date = args.get("date", DATE)
doc.from_time = args.get("from_time", "09:00:00")
doc.time = args.get("time", "18:00:00")
for amount in payments or []:
doc.append("payments", {"mode_of_payment": "Cash", "amount": amount})
doc.expense = args.get("expense", 0)
doc.custody = args.get("custody", 0)
doc.returns = args.get("returns", 0)
return doc
def test_from_time_must_be_before_to_time(self):
doc = self.make_closing(from_time="18:00:00", time="09:00:00")
self.assertRaises(frappe.ValidationError, doc.save)
def test_equal_from_and_to_time_is_rejected(self):
# validate_time uses >=, so a zero-length window is also blocked
doc = self.make_closing(from_time="09:00:00", time="09:00:00")
self.assertRaises(frappe.ValidationError, doc.save)
def test_net_amount_rolls_up_outstanding_and_adjustments(self):
si = self.make_invoice_in_window(rate=100)
doc = self.make_closing(payments=[500], expense=50, custody=30, returns=20)
doc.save()
# the in-window invoice is picked up as outstanding
self.assertEqual(doc.outstanding_amount, si.outstanding_amount)
# net = payments + outstanding + expense - custody + returns
self.assertEqual(doc.net_amount, 500 + si.outstanding_amount + 50 - 30 + 20)
def test_outstanding_is_scoped_to_the_invoice_owner(self):
# The invoice is created by Administrator; a closing for a different user does
# not see it. NOTE: get_outstanding keys on Sales Invoice.owner (the document
# creator) rather than an explicit cashier/POS-user field, which is fragile when
# invoices are created by a shared or system user.
self.make_invoice_in_window(rate=100)
doc = self.make_closing(user="Guest", payments=[500])
doc.save()
self.assertEqual(doc.outstanding_amount, 0)
self.assertEqual(doc.net_amount, 500)
pass

View File

@@ -1,54 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer import (
build_forest,
validate_columns,
validate_missing_roots,
)
from erpnext.tests.utils import ERPNextTestSuite
# columns: account_name, parent_account, account_number, parent_account_number,
# is_group, account_type, root_type, account_currency
ROOT = ["Assets", "Assets", "", "", 1, "", "Asset", "INR"]
CHILD = ["Cash", "Assets", "", "", 0, "Cash", "Asset", "INR"]
class TestChartofAccountsImporter(ERPNextTestSuite):
"""The importer parses an uploaded CoA into a nested tree and validates its
shape. These cover the parsing/validation helpers without a file upload."""
def test_validate_columns_rejects_blank_file(self):
self.assertRaises(frappe.ValidationError, validate_columns, [])
def test_validate_columns_requires_eight_columns(self):
self.assertRaises(frappe.ValidationError, validate_columns, [["a", "b", "c"]])
# the standard template width passes
validate_columns([ROOT])
def test_build_forest_nests_child_under_parent(self):
forest = build_forest([ROOT, CHILD])
self.assertIn("Assets", forest)
self.assertIn("Cash", forest["Assets"])
def test_build_forest_rejects_unknown_parent(self):
orphan = ["Cash", "Missing Parent", "", "", 0, "Cash", "Asset", "INR"]
self.assertRaises(frappe.ValidationError, build_forest, [orphan])
def test_build_forest_requires_account_name(self):
nameless = ["", "Assets", "", "", 0, "Cash", "Asset", "INR"]
self.assertRaises(frappe.ValidationError, build_forest, [ROOT, nameless])
def test_validate_missing_roots_requires_all_root_types(self):
present = ("Asset", "Liability", "Expense", "Income") # Equity missing
self.assertRaises(
frappe.ValidationError,
validate_missing_roots,
[{"root_type": rt} for rt in present],
)
# all five root types present -> no error
validate_missing_roots(
[{"root_type": rt} for rt in ("Asset", "Liability", "Expense", "Income", "Equity")]
)
pass

View File

@@ -46,7 +46,7 @@ class ChequePrintTemplate(Document):
pass
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_or_update_cheque_print_format(template_name: str):
frappe.only_for("System Manager")

View File

@@ -12,7 +12,6 @@ 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 (
create_sales_invoice,
create_sales_invoice_against_cost_center,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -153,37 +152,6 @@ class TestDunning(ERPNextTestSuite):
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
@ERPNextTestSuite.change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
def test_dunning_outstanding_uses_transaction_currency(self):
"""
Regression for #56006: dunning outstanding must be in the invoice transaction
currency, not in the party account currency.
A USD invoice posted against an INR receivable account stores
outstanding_amount in INR (party account currency). The overdue payment
row on the resulting Dunning must carry the USD amount, not the INR amount.
"""
si = create_sales_invoice(
posting_date=add_days(today(), -10),
currency="USD",
conversion_rate=50,
rate=100,
debit_to="Debtors - _TC",
)
# Sanity-check the invoice state before creating the dunning
self.assertEqual(si.currency, "USD")
self.assertEqual(si.outstanding_amount, 5000.0) # INR (party account currency)
self.assertEqual(si.payment_schedule[0].outstanding, 100.0) # USD (transaction currency)
dunning = create_dunning_from_sales_invoice(si.name)
self.assertEqual(len(dunning.overdue_payments), 1)
# Must reflect 100 USD, not 5000 INR mislabelled as USD
self.assertEqual(dunning.overdue_payments[0].outstanding, 100.0)
def test_dunning_not_affected_by_standalone_credit_note(self):
"""
Test that dunning is NOT resolved when a credit note has update_outstanding_for_self checked.

View File

@@ -73,7 +73,7 @@ class ExchangeRateRevaluation(Document):
def validate_mandatory(self):
if not (self.company and self.posting_date):
frappe.throw(_("Please select Company and Posting Date to get entries"))
frappe.throw(_("Please select Company and Posting Date to getting entries"))
def before_submit(self):
self.remove_accounts_without_gain_loss()
@@ -601,7 +601,6 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
.select(gl.voucher_type, gl.voucher_no)
.where(Criterion.all(conditions))
.orderby(gl.posting_date, order=Order.desc)
.orderby(gl.name, order=Order.desc)
.limit(1)
.run()[0]
)
@@ -616,7 +615,6 @@ def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
(gl.voucher_type == voucher_type) & (gl.voucher_no == voucher_no) & (gl.account == account)
)
.orderby(gl.posting_date, order=Order.desc)
.orderby(gl.name, order=Order.desc)
.limit(1)
.run()[0][0]
)

View File

@@ -298,65 +298,3 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
class TestExchangeRateRevaluationValidation(ERPNextTestSuite):
"""Validation and gain/loss calculation paths, exercised on the document directly
so they don't need the multi-currency GL setup the integration tests above build."""
def setUp(self):
frappe.set_user("Administrator")
self.company = "_Test Company"
def _revaluation_with_rows(self, rows, rounding_loss_allowance=0.05):
doc = frappe.new_doc("Exchange Rate Revaluation")
doc.company = self.company
doc.posting_date = today()
doc.rounding_loss_allowance = rounding_loss_allowance
for row in rows:
doc.append("accounts", row)
return doc
def test_rounding_loss_allowance_must_be_between_0_and_1(self):
for bad in (-0.1, 1, 1.5):
doc = self._revaluation_with_rows([], rounding_loss_allowance=bad)
self.assertRaises(frappe.ValidationError, doc.validate)
# values inside [0, 1) are accepted, at the lower bound and mid-range
for good in (0.0, 0.5):
self._revaluation_with_rows([], rounding_loss_allowance=good).validate()
def test_gain_loss_computed_and_split_by_zero_balance(self):
doc = self._revaluation_with_rows(
[
# open (unbooked) row: base balance moved 1000 -> 1100, a 100 gain
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
# already-settled (zero_balance) row carries a booked loss of 40
{"zero_balance": 1, "gain_loss": -40},
]
)
doc.validate()
# gain_loss is derived only for open rows; the zero-balance row keeps its value
self.assertEqual(doc.accounts[0].gain_loss, 100)
self.assertEqual(doc.gain_loss_unbooked, 100)
self.assertEqual(doc.gain_loss_booked, -40)
self.assertEqual(doc.total_gain_loss, 60)
def test_before_submit_drops_rows_without_gain_loss(self):
doc = self._revaluation_with_rows(
[
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500},
]
)
doc.validate() # second row nets to a 0 gain_loss
doc.remove_accounts_without_gain_loss()
self.assertEqual(len(doc.accounts), 1)
self.assertEqual(doc.accounts[0].gain_loss, 100)
def test_before_submit_requires_at_least_one_gain_loss_row(self):
doc = self._revaluation_with_rows(
[{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}]
)
doc.validate()
self.assertRaises(frappe.ValidationError, doc.remove_accounts_without_gain_loss)

View File

@@ -107,9 +107,6 @@ def auto_create_fiscal_year():
)
for d in fiscal_year:
# savepoint so a duplicate-year INSERT (Fiscal Year autoname=field:year) that aborts the
# statement doesn't poison the whole scheduler transaction on Postgres and kill the next iteration
frappe.db.savepoint("auto_create_fiscal_year")
try:
current_fy = frappe.get_doc("Fiscal Year", d[0])
@@ -130,7 +127,7 @@ def auto_create_fiscal_year():
new_fy.insert(ignore_permissions=True)
except frappe.NameError:
frappe.db.rollback(save_point="auto_create_fiscal_year")
pass
def get_from_and_to_date(fiscal_year):

View File

@@ -471,25 +471,6 @@ def on_doctype_update():
frappe.db.add_index("GL Entry", ["posting_date", "company"])
frappe.db.add_index("GL Entry", ["party_type", "party"])
if frappe.db.db_type == "postgres":
# Postgres-only partial/covering indexes for the financial reports (General Ledger, Trial
# Balance, Balance Sheet, P&L), which always filter `is_cancelled = 0` and scope by company.
# `where`/`include` are no-ops on MariaDB and its optimizer ignores these anyway, so they are
# added only on postgres to avoid dead write overhead on this insert-hot table.
frappe.db.add_index(
"GL Entry",
["company", "posting_date", "account"],
index_name="gle_active_detail",
where="is_cancelled = 0",
)
frappe.db.add_index(
"GL Entry",
["company", "account", "posting_date"],
index_name="gle_active_cover",
where="is_cancelled = 0",
include=["debit", "credit"],
)
def rename_gle_sle_docs():
for doctype in ["GL Entry", "Stock Ledger Entry"]:

View File

@@ -1,62 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
TAX_ACCOUNT = "_Test Account VAT - _TC"
RECEIVABLE_ACCOUNT = "Debtors - _TC"
class TestItemTaxTemplate(ERPNextTestSuite):
"""Item Tax Template validates its tax rows: each account must belong to the
company, be a tax-like account type, and appear only once."""
def setUp(self):
frappe.set_user("Administrator")
def make_template(self, rows, title="_Test ITT"):
doc = frappe.new_doc("Item Tax Template")
doc.title = f"{title} {frappe.generate_hash(length=6)}"
doc.company = COMPANY
for account, rate, not_applicable in rows:
doc.append(
"taxes",
{"tax_type": account, "tax_rate": rate, "not_applicable": not_applicable},
)
return doc
def test_valid_template_saves_and_is_named_with_abbr(self):
doc = self.make_template([(TAX_ACCOUNT, 9, 0)])
doc.insert()
self.assertTrue(doc.name.endswith(" - _TC"))
self.assertTrue(doc.name.startswith(doc.title))
def test_duplicate_tax_type_throws(self):
doc = self.make_template([(TAX_ACCOUNT, 9, 0), (TAX_ACCOUNT, 5, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_account_of_wrong_company_throws(self):
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
doc = self.make_template([(other_account, 9, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_disallowed_account_type_throws(self):
# a Receivable account is not Tax/Chargeable/Income/Expense
doc = self.make_template([(RECEIVABLE_ACCOUNT, 9, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_not_applicable_row_has_rate_zeroed(self):
doc = self.make_template([(TAX_ACCOUNT, 18, 1)])
doc.insert()
self.assertEqual(doc.taxes[0].tax_rate, 0)
def test_negative_tax_rate_is_accepted(self):
# SUSPECTED BUG: validate never bounds tax_rate, so a negative (or >100) rate
# saves silently. Locking the current (wrong) behaviour.
doc = self.make_template([(TAX_ACCOUNT, -5, 0)])
doc.insert()
self.assertEqual(doc.taxes[0].tax_rate, -5)
pass

View File

@@ -29,7 +29,7 @@ frappe.ui.form.on("Journal Entry", {
refresh(frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
erpnext.journal_entry.lock_reversal_entry(frm);
frm.set_read_only();
}
erpnext.toggle_naming_series();
@@ -232,13 +232,6 @@ Object.assign(erpnext.journal_entry, {
}
},
lock_reversal_entry(frm) {
frm.fields
.filter((field) => field.has_input)
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},
add_custom_buttons(frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(

View File

@@ -45,20 +45,6 @@ class JournalEntryTemplate(Document):
def validate(self):
self.validate_party()
self.validate_account_company()
def validate_account_company(self):
"""Each row's account must belong to the template's company."""
for account in self.accounts:
if (
account.account
and frappe.get_cached_value("Account", account.account, "company") != self.company
):
frappe.throw(
_("Row {0}: Account {1} does not belong to company {2}").format(
account.idx, account.account, self.company
)
)
def validate_party(self):
"""

View File

@@ -1,45 +1,9 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
# import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestJournalEntryTemplate(ERPNextTestSuite):
"""Journal Entry Template's only real rule is validate_party: party_type is
allowed only on Receivable/Payable accounts, and a party needs a party_type."""
def setUp(self):
frappe.set_user("Administrator")
def make_template(self, rows, company=COMPANY):
doc = frappe.new_doc("Journal Entry Template")
doc.template_title = f"_Test JET {frappe.generate_hash(length=6)}"
doc.company = company
doc.voucher_type = "Journal Entry"
doc.naming_series = frappe.get_meta("Journal Entry").get_field("naming_series").options.split("\n")[0]
for row in rows:
doc.append("accounts", row)
return doc
def test_party_type_only_on_receivable_or_payable_account(self):
# Cash is neither Receivable nor Payable, so a party_type here is invalid
doc = self.make_template([{"account": "Cash - _TC", "party_type": "Customer"}])
self.assertRaises(frappe.ValidationError, doc.validate)
def test_party_requires_party_type(self):
doc = self.make_template([{"account": "Debtors - _TC", "party": "_Test Customer"}])
self.assertRaises(frappe.ValidationError, doc.validate)
def test_account_from_other_company_is_rejected(self):
other_receivable = frappe.db.get_value(
"Account", {"company": "_Test Company 1", "account_type": "Receivable", "is_group": 0}, "name"
)
self.assertTrue(other_receivable, "need a receivable account in _Test Company 1")
doc = self.make_template(
[{"account": other_receivable, "party_type": "Customer", "party": "_Test Customer"}]
)
self.assertRaises(frappe.ValidationError, doc.insert)
pass

View File

@@ -65,7 +65,6 @@ def start_merge(docname):
total = len(ledger_merge.merge_accounts)
for row in ledger_merge.merge_accounts:
if not row.merged:
frappe.db.savepoint("ledger_merge_row")
try:
merge_account(
row.account,
@@ -80,7 +79,8 @@ def start_merge(docname):
{"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total},
)
except Exception:
frappe.db.rollback(save_point="ledger_merge_row")
if not frappe.in_test:
frappe.db.rollback()
ledger_merge.log_error("Ledger merge failed")
finally:
if successful_merges == total:

View File

@@ -2,7 +2,6 @@
# See license.txt
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import today
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -64,9 +63,13 @@ class TestLoyaltyPointEntry(ERPNextTestSuite):
self.assertEqual(doc.loyalty_points, -7)
# Check balance
lpe = frappe.qb.DocType("Loyalty Point Entry")
balance = (
frappe.qb.from_(lpe).select(Sum(lpe.loyalty_points)).where(lpe.customer == self.customer_name)
).run()[0][0]
balance = frappe.db.sql(
"""
SELECT SUM(loyalty_points)
FROM `tabLoyalty Point Entry`
WHERE customer = %s
""",
(self.customer_name,),
)[0][0]
self.assertEqual(balance, 3) # 10 added, 7 redeemed

View File

@@ -8,7 +8,7 @@ frappe.ui.form.on("Loyalty Program", {
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
<i class="fa fa-hand-right"></i>
${__("Notes")}
</h4>
<ul>

View File

@@ -3,7 +3,6 @@
import unittest
import frappe
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, getdate, today
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
@@ -263,12 +262,14 @@ class TestLoyaltyProgram(ERPNextTestSuite):
def get_points_earned(self):
def get_returned_amount():
si = frappe.qb.DocType("Sales Invoice")
returned_amount = (
frappe.qb.from_(si)
.select(Sum(si.grand_total))
.where((si.docstatus == 1) & (si.is_return == 1) & (si.return_against == self.name))
).run()
returned_amount = frappe.db.sql(
"""
select sum(grand_total)
from `tabSales Invoice`
where docstatus=1 and is_return=1 and ifnull(return_against, '')=%s
""",
self.name,
)
return abs(flt(returned_amount[0][0])) if returned_amount else 0
lp_details = get_loyalty_program_details_with_points(

View File

@@ -5,59 +5,9 @@ import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestModeofPayment(ERPNextTestSuite):
"""Mode of Payment validates its per-company default accounts (account company
must match the row, no company twice) and blocks disabling while a POS Profile
still references it."""
def setUp(self):
frappe.set_user("Administrator")
def make_mop(self, accounts=None, enabled=1):
doc = frappe.new_doc("Mode of Payment")
doc.mode_of_payment = f"_Test MoP {frappe.generate_hash(length=6)}"
doc.type = "General"
doc.enabled = enabled
for company, account in accounts or []:
doc.append("accounts", {"company": company, "default_account": account})
return doc
def test_valid_mode_of_payment_saves(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")])
doc.insert()
self.assertTrue(doc.name)
def test_account_of_wrong_company_throws(self):
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
doc = self.make_mop(accounts=[(COMPANY, other_account)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_repeating_company_throws(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC"), (COMPANY, "Debtors - _TC")])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_disabling_mode_referenced_by_pos_profile_is_not_blocked(self):
# SUSPECTED BUG: validate_pos_mode_of_payment queries "Sales Invoice Payment"
# rows with parenttype "POS Profile", but a POS Profile's payments are stored
# as "POS Payment Method" rows. The filter never matches, so the guard is dead
# and a mode still referenced by a POS Profile disables without complaint.
# Locking the current (wrong) behaviour so a fix to the guard trips this test.
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
make_pos_profile() # its payments row references the "Cash" mode of payment
cash = frappe.get_doc("Mode of Payment", "Cash")
cash.enabled = 0
cash.save()
self.assertEqual(frappe.db.get_value("Mode of Payment", "Cash", "enabled"), 0)
def test_disabling_unreferenced_mode_succeeds(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")], enabled=0)
doc.insert()
self.assertEqual(doc.enabled, 0)
pass
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):

View File

@@ -1,67 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
# See license.txt
import frappe
from frappe.utils import getdate
from erpnext.accounts.doctype.monthly_distribution.monthly_distribution import (
get_percentage,
get_periodwise_distribution_data,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestMonthlyDistribution(ERPNextTestSuite):
"""Monthly Distribution spreads an amount across months. validate() enforces a
100% total; get_percentage() sums the months that fall inside a period window."""
def setUp(self):
frappe.set_user("Administrator")
def make_distribution(self, allocations):
doc = frappe.new_doc("Monthly Distribution")
doc.distribution_id = f"_Test MD {frappe.generate_hash(length=6)}"
for month, pct in allocations:
doc.append("percentages", {"month": month, "percentage_allocation": pct})
return doc
def test_get_months_populates_twelve_even_rows(self):
doc = frappe.new_doc("Monthly Distribution")
doc.distribution_id = "_Test MD Even"
doc.get_months()
self.assertEqual(len(doc.percentages), 12)
self.assertEqual(doc.percentages[0].month, "January")
self.assertEqual(doc.percentages[-1].month, "December")
self.assertEqual([d.idx for d in doc.percentages], list(range(1, 13)))
for d in doc.percentages:
self.assertAlmostEqual(d.percentage_allocation, 100.0 / 12, places=4)
# the auto-populated rows round to exactly 100 and pass validation
doc.validate()
def test_validate_rejects_total_other_than_100(self):
doc = self.make_distribution([("January", 50), ("February", 30)]) # sums to 80
self.assertRaises(frappe.ValidationError, doc.insert)
def test_get_percentage_sums_period_window(self):
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
doc.insert() # total is 100, so validate passes
# a quarter starting in January covers Jan+Feb+Mar
self.assertEqual(get_percentage(doc, getdate("2026-01-01"), 3), 100)
# a single month picks up only that month
self.assertEqual(get_percentage(doc, getdate("2026-02-01"), 1), 30)
# months with no row simply contribute 0 (there is no guard that all 12 exist)
self.assertEqual(get_percentage(doc, getdate("2026-04-01"), 1), 0)
def test_periodwise_distribution_maps_each_period(self):
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
doc.insert()
period_list = [
frappe._dict(key="q1", from_date=getdate("2026-01-01")),
frappe._dict(key="q2", from_date=getdate("2026-04-01")),
]
data = get_periodwise_distribution_data(doc.name, period_list, "Quarterly")
self.assertEqual(data["q1"], 100) # Jan+Feb+Mar
self.assertEqual(data["q2"], 0) # Apr+May+Jun carry no allocation
pass

View File

@@ -1,67 +1,9 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.accounts.doctype.party_link.party_link import create_party_link
from erpnext.tests.utils import ERPNextTestSuite
CUSTOMER = "_Test Customer"
SUPPLIER = "_Test Supplier"
SUPPLIER_2 = "_Test Supplier 1"
class TestPartyLink(ERPNextTestSuite):
"""Party Link ties a Customer and a Supplier together as one underlying party.
validate() constrains the primary role and blocks duplicate links."""
def setUp(self):
frappe.set_user("Administrator")
def test_create_party_link_with_customer_primary(self):
link = create_party_link("Customer", CUSTOMER, SUPPLIER)
self.assertEqual(link.primary_role, "Customer")
self.assertEqual(link.secondary_role, "Supplier")
self.assertEqual(link.primary_party, CUSTOMER)
self.assertEqual(link.secondary_party, SUPPLIER)
self.assertTrue(frappe.db.exists("Party Link", link.name))
def test_create_party_link_with_supplier_primary(self):
link = create_party_link("Supplier", SUPPLIER, CUSTOMER)
self.assertEqual(link.primary_role, "Supplier")
self.assertEqual(link.secondary_role, "Customer")
self.assertEqual(link.primary_party, SUPPLIER)
self.assertEqual(link.secondary_party, CUSTOMER)
self.assertTrue(frappe.db.exists("Party Link", link.name))
def test_primary_role_must_be_customer_or_supplier(self):
doc = frappe.new_doc("Party Link")
doc.primary_role = "Employee"
doc.primary_party = CUSTOMER
doc.secondary_role = "Supplier"
doc.secondary_party = SUPPLIER
# validate() alone isolates the role rule from the dynamic-link checks
self.assertRaises(frappe.ValidationError, doc.validate)
def test_duplicate_link_throws(self):
create_party_link("Customer", CUSTOMER, SUPPLIER)
dup = frappe.new_doc("Party Link")
dup.primary_role = "Customer"
dup.primary_party = CUSTOMER
dup.secondary_role = "Supplier"
dup.secondary_party = SUPPLIER
self.assertRaises(frappe.ValidationError, dup.insert)
def test_party_can_wrongly_be_primary_in_two_links(self):
# SUSPECTED BUG: the uniqueness checks are asymmetric - a party already a
# *primary* in another link isn't blocked, so one customer can be linked to two
# different suppliers, breaking the 1:1 mapping. Locking the current (wrong)
# behaviour so a fix that blocks primary reuse trips this test.
create_party_link("Customer", CUSTOMER, SUPPLIER)
link2 = frappe.new_doc("Party Link")
link2.primary_role = "Customer"
link2.primary_party = CUSTOMER
link2.secondary_role = "Supplier"
link2.secondary_party = SUPPLIER_2
link2.insert()
self.assertTrue(frappe.db.exists("Party Link", link2.name))
pass

View File

@@ -414,17 +414,21 @@ frappe.ui.form.on("Payment Entry", {
show_general_ledger: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(__("Ledger"), function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
});
frm.add_custom_button(
__("Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
},
"fa fa-table"
);
}
},

View File

@@ -514,12 +514,10 @@ class PaymentEntry(AccountsController):
invoice_names.add((ref.reference_doctype, ref.reference_name))
for doctype, name in invoice_names:
frappe.db.savepoint("subscription_update")
try:
doc = frappe.get_doc(doctype, name)
doc.refresh_subscription_status()
except Exception:
frappe.db.rollback(save_point="subscription_update")
frappe.log_error(_("Failed to update subscription status for {0} {1}").format(doctype, name))
def set_missing_values(self):
@@ -2795,9 +2793,6 @@ def get_open_payment_requests_for_references(references=None):
.where(PR.docstatus == 1)
.where(PR.outstanding_amount > 0) # to avoid old PRs with 0 outstanding amount
.orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
# unique tiebreaker so PRs sharing a transaction_date allocate in the same order on both engines
.orderby(PR.creation, order=frappe.qb.asc)
.orderby(PR.name, order=frappe.qb.asc)
).run(as_dict=True)
if not response:

View File

@@ -246,62 +246,6 @@ class TestPaymentEntry(ERPNextTestSuite):
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", pi.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0)
def test_pay_multiple_purchase_invoices_in_one_entry(self):
pi1 = make_purchase_invoice() # outstanding 250
pi2 = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi1.name, bank_account="_Test Cash - _TC")
pe.append(
"references",
{
"reference_doctype": "Purchase Invoice",
"reference_name": pi2.name,
"total_amount": pi2.grand_total,
"outstanding_amount": pi2.outstanding_amount,
"allocated_amount": pi2.outstanding_amount,
},
)
pe.paid_amount = pe.received_amount = (
pe.references[0].allocated_amount + pe.references[1].allocated_amount
)
pe.insert()
pe.submit()
self.assertEqual(pe.total_allocated_amount, 500)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi1.name, "outstanding_amount"), 0)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi2.name, "outstanding_amount"), 0)
def test_unallocated_amount_on_overpaid_purchase_payment(self):
pi = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
pe.paid_amount = pe.references[0].allocated_amount + 200 # overpay -> 200 advance
pe.received_amount = pe.paid_amount
pe.insert()
pe.submit()
self.assertEqual(pe.docstatus, 1)
self.assertEqual(pe.unallocated_amount, 200)
# end-to-end: submitting posts a balanced GL for the full paid amount (250
# settling the invoice + 200 advance)
gl_entries = frappe.get_all(
"GL Entry",
filters={"voucher_no": pe.name, "is_cancelled": 0},
fields=["debit", "credit"],
)
self.assertTrue(gl_entries, "Submitted payment produced no GL entries")
self.assertEqual(flt(sum(e.debit for e in gl_entries)), flt(sum(e.credit for e in gl_entries)))
self.assertEqual(flt(sum(e.debit for e in gl_entries)), 450)
def test_overallocation_against_purchase_invoice_throws(self):
pi = make_purchase_invoice() # outstanding 250
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
pe.references[0].allocated_amount += 100 # 350 > 250 outstanding
pe.paid_amount = pe.received_amount = pe.references[0].allocated_amount
self.assertRaises(frappe.ValidationError, pe.insert)
def test_payment_against_sales_invoice_to_check_status(self):
si = create_sales_invoice(
customer="_Test Customer USD",
@@ -2373,65 +2317,3 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
customer.save()
customer = customer.name
return customer
class TestPaymentEntryValidation(ERPNextTestSuite):
"""Field-level validations invoked on the document directly, covering branches the
integration suite above doesn't reach (no GL / reconciliation setup needed)."""
def make_pe(self, **fields):
doc = frappe.new_doc("Payment Entry")
doc.update(fields)
return doc
def test_payment_type_must_be_a_known_value(self):
self.assertRaises(frappe.ValidationError, self.make_pe(payment_type="Foo").validate_payment_type)
self.make_pe(payment_type="Receive").validate_payment_type() # valid value passes
def test_nonexistent_party_is_rejected(self):
doc = self.make_pe(party_type="Customer", party="__No Such Customer__")
self.assertRaises(frappe.ValidationError, doc.validate_party_details)
def test_amount_and_exchange_rate_fields_are_mandatory(self):
# every field but target_exchange_rate is set, so that missing one raises
doc = self.make_pe(
paid_amount=100, received_amount=100, source_exchange_rate=1, target_exchange_rate=0
)
self.assertRaises(frappe.ValidationError, doc.validate_mandatory)
def test_received_amount_cannot_exceed_paid_in_same_currency(self):
doc = self.make_pe(
paid_from_account_currency="INR",
paid_to_account_currency="INR",
paid_amount=100,
received_amount=150,
)
self.assertRaises(frappe.ValidationError, doc.validate_received_amount)
# received <= paid is fine
doc.received_amount = 50
doc.validate_received_amount()
def test_duplicate_reference_rows_are_rejected(self):
doc = self.make_pe()
for _ in range(2):
doc.append(
"references",
{"reference_doctype": "Sales Invoice", "reference_name": "SI-X", "allocated_amount": 100},
)
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_entry)
def test_receive_from_customer_against_negative_outstanding_is_rejected(self):
doc = self.make_pe(party_type="Customer", payment_type="Receive")
doc.append(
"references",
{"reference_doctype": "Sales Invoice", "reference_name": "SI-Y", "allocated_amount": -100},
)
self.assertRaises(frappe.ValidationError, doc.validate_payment_type_with_outstanding)
def test_bank_transaction_requires_a_reference_number(self):
doc = self.make_pe(payment_type="Pay", paid_from="_Test Bank - _TC")
self.assertRaises(frappe.ValidationError, doc.validate_transaction_reference)
# supplying the reference details clears the requirement
doc.reference_no = "TXN-1"
doc.reference_date = "2026-06-15"
doc.validate_transaction_reference()

View File

@@ -833,17 +833,10 @@ class PaymentReconciliation(Document):
def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None):
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount")
)
for inv in dr_cr_notes:
if (
flt(
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
- inv.allocated_amount,
allocated_amount_precision,
)
< 0
abs(frappe.db.get_value(inv.voucher_type, inv.voucher_no, "outstanding_amount"))
< inv.allocated_amount
):
frappe.throw(
_("{0} has been modified after you pulled it. Please pull it again.").format(inv.voucher_type)

View File

@@ -48,7 +48,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
sinv = create_sales_invoice(
qty=qty,
rate=rate,
posting_date=posting_date,
company=self.company,
customer=self.customer,
item_code=self.item,
@@ -2111,7 +2110,7 @@ class TestPaymentReconciliation(ERPNextTestSuite):
pr.reconcile()
si.reload()
self.assertEqual(si.status, "Overdue")
self.assertEqual(si.status, "Partly Paid")
# check PR tool output post reconciliation
self.assertEqual(len(pr.get("invoices")), 1)
self.assertEqual(pr.get("invoices")[0].get("outstanding_amount"), 120)
@@ -2507,76 +2506,6 @@ class TestPaymentReconciliation(ERPNextTestSuite):
self.assertEqual(flt(pr.allocation[0].difference_amount), 5000.0)
pr.reconcile()
def test_cr_note_split_across_invoices_floating_point_precision(self):
"""Regression: when a credit note is split across multiple invoices, floating-point
arithmetic (150 - 8.45 - 90.72 = 50.83000000000001) must not cause reconcile() to fail.
The test environment rounds INR totals to whole rupees (smallest_currency_fraction_value=0),
so the invoices are created with round-number totals (100, 200, 100) and then partially paid
down to the decimal outstanding amounts (8.45, 90.72, 72.57) via payment entries.
"""
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
# Create invoices on different posting dates to control sort-order in Payment Reconciliation
# (invoices are sorted by posting_date ascending, so si_a is processed first).
# Processing order 8.45 → 90.72 → 72.57 produces the float chain:
# 150 - 8.45 = 141.55 → 141.55 - 90.72 = 50.83000000000001
# The last allocation row will therefore carry allocated_amount = 50.83000000000001.
si_a = self.create_sales_invoice(qty=1, rate=100, posting_date=add_days(nowdate(), -2))
si_b = self.create_sales_invoice(qty=1, rate=200, posting_date=add_days(nowdate(), -1))
si_c = self.create_sales_invoice(qty=1, rate=100, posting_date=nowdate())
# Partially pay each invoice so the remaining outstanding is a clean decimal value.
# INR rounds the invoice total to a whole rupee, so we achieve decimal outstandings
# by subtracting a decimal-valued payment from the integer total:
# 100 - 91.55 = 8.45
# 200 - 109.28 = 90.72
# 100 - 27.43 = 72.57
for si, partial_paid in ((si_a, 91.55), (si_b, 109.28), (si_c, 27.43)):
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = partial_paid
pe.received_amount = partial_paid
pe.references[0].allocated_amount = partial_paid
pe.save().submit()
cr_note = self.create_sales_invoice(
qty=-1, rate=150, posting_date=nowdate(), do_not_save=True, do_not_submit=True
)
cr_note.is_return = 1
cr_note = cr_note.save().submit()
pr = self.create_payment_reconciliation()
# Widen date range so all three invoices (oldest is -2 days) are fetched
pr.from_invoice_date = add_days(nowdate(), -2)
pr.to_invoice_date = nowdate()
pr.from_payment_date = nowdate()
pr.to_payment_date = nowdate()
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 3)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
# Credit note (150) covers all of si_a (8.45) and si_b (90.72), then partially si_c
self.assertEqual(len(pr.allocation), 3)
last_row = pr.allocation[-1]
# Last allocated amount should be ~50.83 (possibly 50.83000000000001 due to float arithmetic)
self.assertAlmostEqual(flt(last_row.allocated_amount), 50.83, places=2)
# reconcile() must not raise "has been modified after you pulled it" due to float imprecision
pr.reconcile()
si_a.reload()
si_b.reload()
si_c.reload()
self.assertEqual(si_a.outstanding_amount, 0)
self.assertEqual(si_b.outstanding_amount, 0)
# si_c is only partially settled: 72.57 - 50.83 = 21.74
self.assertAlmostEqual(si_c.outstanding_amount, 21.74, places=2)
def create_fiscal_year(company, year_start_date, year_end_date):
fy_docname = frappe.db.exists(

View File

@@ -718,7 +718,7 @@ class PaymentRequest(Document):
row_number += TO_SKIP_NEW_ROW
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def make_payment_request(**args):
"""Make payment request"""

View File

@@ -41,17 +41,21 @@ frappe.ui.form.on("Period Closing Voucher", {
refresh: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(__("Ledger"), function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
});
frm.add_custom_button(
__("Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
},
"fa fa-table"
);
}
},
});

View File

@@ -360,15 +360,12 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.make_period_closing_voucher(posting_date="2021-03-31")
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", "2021-12-31")
try:
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
)
finally:
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", None)
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
totals_after_cancel = frappe.get_all(
"GL Entry",

View File

@@ -663,6 +663,7 @@ class POSInvoice(SalesInvoice):
def set_pos_fields(self, for_validate=False):
"""Set retail related fields from POS Profiles"""
from erpnext.stock.get_item_details import (
ItemDetailsCtx,
get_pos_profile,
get_pos_profile_item_details_,
)
@@ -735,7 +736,7 @@ class POSInvoice(SalesInvoice):
for item in self.get("items"):
if item.get("item_code"):
profile_details = get_pos_profile_item_details_(
frappe._dict(item.as_dict()), profile.get("company"), profile
ItemDetailsCtx(item.as_dict()), profile.get("company"), profile
)
for fname, val in profile_details.items():
if (not for_validate) or (for_validate and not item.get(fname)):

View File

@@ -10,9 +10,9 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_pu
class TestPOSInvoiceMerging(POSInvoiceTestMixin):
def clear_pos_data(self):
frappe.db.delete("POS Opening Entry")
frappe.db.delete("POS Closing Entry")
frappe.db.delete("POS Invoice")
frappe.db.sql("delete from `tabPOS Opening Entry`;")
frappe.db.sql("delete from `tabPOS Closing Entry`;")
frappe.db.sql("delete from `tabPOS Invoice`;")
def setUp(self):
self.clear_pos_data()

View File

@@ -1,34 +0,0 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# Regression test for https://github.com/frappe/erpnext/issues/56501
# AttributeError: 'POSInvoice' object has no attribute 'is_created_using_pos'
# when calling reset_mode_of_payments on a draft POS Invoice.
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import (
POSInvoiceTestMixin,
create_pos_invoice,
)
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
class TestPOSInvoiceResetModeOfPayments(POSInvoiceTestMixin):
def setUp(self):
super().setUp()
create_opening_entry(self.pos_profile, self.test_user.name)
def test_reset_mode_of_payments_does_not_raise_attribute_error(self):
"""Calling reset_mode_of_payments on a draft POS Invoice must not raise
AttributeError for the missing is_created_using_pos attribute.
update_multi_mode_option accesses doc.is_created_using_pos, which is a
field on SalesInvoice but does not exist on POSInvoice, causing the error
reported in #56501 when a user tries to edit a saved draft order.
"""
inv = create_pos_invoice(do_not_submit=True)
# This call must not raise AttributeError on the missing field.
inv.reset_mode_of_payments()
# Payments should have been repopulated from the POS profile.
self.assertTrue(len(inv.payments) > 0, "Payments should be populated after reset")

View File

@@ -25,11 +25,15 @@ class TestPOSProfile(ERPNextTestSuite):
items = get_items_list(doc, doc.company)
customers = get_customers_list(doc)
products_count = frappe.db.count("Item", {"item_group": "_Test Item Group"})
customers_count = frappe.db.count("Customer", {"customer_group": "_Test Customer Group"})
products_count = frappe.db.sql(
""" select count(name) from tabItem where item_group = '_Test Item Group'""", as_list=1
)
customers_count = frappe.db.sql(
""" select count(name) from tabCustomer where customer_group = '_Test Customer Group'"""
)
self.assertEqual(len(items), products_count)
self.assertEqual(len(customers), customers_count)
self.assertEqual(len(items), products_count[0][0])
self.assertEqual(len(customers), customers_count[0][0])
def test_disabled_pos_profile_creation(self):
make_pos_profile(name="_Test POS Profile 001", disabled=1)
@@ -79,6 +83,7 @@ class TestPOSProfile(ERPNextTestSuite):
def get_customers_list(pos_profile=None):
if pos_profile is None:
pos_profile = {}
cond = "1=1"
customer_groups = []
if pos_profile.get("customer_groups"):
# Get customers based on the customer groups defined in the POS profile
@@ -86,16 +91,14 @@ def get_customers_list(pos_profile=None):
customer_groups.extend(
[d.get("name") for d in get_child_nodes("Customer Group", d.get("customer_group"))]
)
filters = {"disabled": 0}
if customer_groups:
filters["customer_group"] = ["in", customer_groups]
cond = "customer_group in ({})".format(", ".join(["%s"] * len(customer_groups)))
return (
frappe.get_all(
"Customer",
filters=filters,
fields=["name", "customer_name", "customer_group", "territory"],
frappe.db.sql(
f""" select name, customer_name, customer_group, territory from tabCustomer where disabled = 0
and {cond}""",
tuple(customer_groups),
as_dict=1,
)
or {}
)
@@ -132,8 +135,8 @@ def get_items_list(pos_profile, company):
def make_pos_profile(**args):
frappe.db.delete("POS Payment Method")
frappe.db.delete("POS Profile")
frappe.db.sql("delete from `tabPOS Payment Method`")
frappe.db.sql("delete from `tabPOS Profile`")
args = frappe._dict(args)

View File

@@ -40,7 +40,7 @@ frappe.ui.form.on("Pricing Rule", {
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
<i class="fa fa-hand-right"></i>
${__("Notes")}
</h4>
<ul>
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
</ul>
</td></tr>
<tr><td>
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
<h4><i class="fa fa-question-sign"></i>
${__("How Pricing Rule is applied?")}
</h4>
<ol>

View File

@@ -156,24 +156,6 @@ class PricingRule(Document):
if len(values) != len(set(values)):
frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on))
if self.apply_on == "Item Code":
self.validate_template_with_variant(values)
def validate_template_with_variant(self, item_codes):
# throws if a template and its variant both exist in one rule
variants = frappe.get_all(
"Item",
filters={"name": ("in", item_codes), "variant_of": ("in", item_codes)},
fields=["name", "variant_of"],
)
if variants:
variant = variants[0]
frappe.throw(
_("Variant {0} and its template {1} cannot both be added to the same Pricing Rule").format(
frappe.bold(variant.name), frappe.bold(variant.variant_of)
)
)
def validate_mandatory(self):
if self.has_priority and not self.priority:
throw(_("Priority is mandatory"), frappe.MandatoryError, _("Please Set Priority"))

View File

@@ -91,9 +91,7 @@ class TestPricingRule(ERPNextTestSuite):
details = get_item_details(args)
self.assertEqual(details.get("discount_percentage"), 5)
frappe.db.set_value(
"Pricing Rule", {"campaign": "_Test Campaign"}, "priority", None, update_modified=False
)
frappe.db.sql("update `tabPricing Rule` set priority=NULL where campaign='_Test Campaign'")
from erpnext.accounts.doctype.pricing_rule.utils import MultiplePricingRuleConflict
self.assertRaises(MultiplePricingRuleConflict, get_item_details, args)
@@ -333,31 +331,6 @@ class TestPricingRule(ERPNextTestSuite):
details = get_item_details(args)
self.assertEqual(details.get("discount_percentage"), 17.5)
def test_pricing_rule_with_template_and_its_variant(self):
if not frappe.db.exists("Item", "Test Variant PRT"):
variant = frappe.new_doc("Item")
variant.item_code = "Test Variant PRT"
variant.item_name = "Test Variant PRT"
variant.item_group = "_Test Item Group"
variant.is_stock_item = 1
variant.variant_of = "_Test Variant Item"
variant.stock_uom = "_Test UOM"
variant.append("attributes", {"attribute": "Test Size", "attribute_value": "Medium"})
variant.insert()
rule = frappe.new_doc("Pricing Rule")
rule.title = "_Test Pricing Rule Template Variant"
rule.apply_on = "Item Code"
rule.currency = "USD"
rule.selling = 1
rule.rate_or_discount = "Discount Percentage"
rule.discount_percentage = 10
rule.company = "_Test Company"
rule.append("items", {"item_code": "_Test Variant Item"})
rule.append("items", {"item_code": "Test Variant PRT"})
self.assertRaises(frappe.ValidationError, rule.insert)
def test_pricing_rule_for_stock_qty(self):
test_record = {
"doctype": "Pricing Rule",

View File

@@ -106,8 +106,6 @@ def get_pr_instance(doc: str):
"party",
"receivable_payable_account",
"default_advance_account",
"bank_cash_account",
"cost_center",
"from_invoice_date",
"to_invoice_date",
"from_payment_date",

View File

@@ -1,73 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
# import frappe
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
get_pr_instance,
)
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestProcessPaymentReconciliation(ERPNextTestSuite):
"""Process Payment Reconciliation validates its accounts against the company,
moves to Queued on submit, and hands its filters to a Payment Reconciliation run."""
def setUp(self):
frappe.set_user("Administrator")
def make_ppr(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Process Payment Reconciliation")
doc.company = COMPANY
doc.party_type = "Customer"
doc.party = "_Test Customer"
doc.receivable_payable_account = args.get("receivable_payable_account", "Debtors - _TC")
doc.bank_cash_account = args.get("bank_cash_account")
doc.from_invoice_date = args.get("from_invoice_date")
doc.to_invoice_date = args.get("to_invoice_date")
return doc
def other_company_account(self, **extra):
filters = {"company": "_Test Company 1", "is_group": 0, **extra}
account = frappe.db.get_value("Account", filters, "name")
self.assertTrue(account, "need a matching account in _Test Company 1")
return account
def test_receivable_account_must_belong_to_company(self):
doc = self.make_ppr(receivable_payable_account=self.other_company_account(account_type="Receivable"))
self.assertRaises(frappe.ValidationError, doc.insert)
def test_bank_cash_account_must_belong_to_company(self):
doc = self.make_ppr(bank_cash_account=self.other_company_account())
self.assertRaises(frappe.ValidationError, doc.insert)
def test_submit_sets_status_to_queued(self):
doc = self.make_ppr()
doc.insert()
doc.submit()
self.assertEqual(doc.status, "Queued")
def test_get_pr_instance_copies_filters_and_caps_limits(self):
doc = self.make_ppr(from_invoice_date="2026-01-01", to_invoice_date="2026-06-30")
doc.insert()
pr = get_pr_instance(doc.name)
self.assertEqual(pr.company, COMPANY)
self.assertEqual(pr.party, "_Test Customer")
self.assertEqual(pr.receivable_payable_account, "Debtors - _TC")
self.assertEqual(str(pr.from_invoice_date), "2026-01-01")
# the tool run is capped so a single process can't fetch unbounded rows
self.assertEqual(pr.invoice_limit, 1000)
self.assertEqual(pr.payment_limit, 1000)
def test_get_pr_instance_copies_bank_cash_and_cost_center(self):
doc = self.make_ppr(bank_cash_account="Cash - _TC")
doc.cost_center = "_Test Cost Center - _TC"
doc.insert()
pr = get_pr_instance(doc.name)
self.assertEqual(pr.bank_cash_account, "Cash - _TC")
self.assertEqual(pr.cost_center, "_Test Cost Center - _TC")
pass

View File

@@ -89,55 +89,50 @@ class ProcessPeriodClosingVoucher(Document):
cancel_pcv_processing(self.name)
def initialize_parallel_threads(docname: str):
threads = 4
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(threads)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
x.name,
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=x.name,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@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 Period Closing Voucher", "write", doc=docname, throw=True)
initialize_parallel_threads(docname)
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(4)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": x.processing_date,
"parent": docname,
"report_type": x.report_type,
"parentfield": x.parentfield,
},
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@frappe.whitelist()
@@ -255,11 +250,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if to_process := (
qb.from_(ppcvd)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(1)
@@ -269,15 +264,15 @@ def schedule_next_date(docname: str):
if not is_scheduler_inactive():
frappe.db.set_value(
"Process Period Closing Voucher Detail",
to_process[0].name,
{
"processing_date": to_process[0].processing_date,
"parent": docname,
"report_type": to_process[0].report_type,
"parentfield": to_process[0].parentfield,
},
"status",
"Running",
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
@@ -285,7 +280,6 @@ def schedule_next_date(docname: str):
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=to_process[0].name,
date=to_process[0].processing_date,
report_type=to_process[0].report_type,
parentfield=to_process[0].parentfield,
@@ -450,11 +444,6 @@ def summarize_and_post_ledger_entries(docname):
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@@ -540,10 +529,10 @@ def build_dimension_wise_balance_dict(gl_entries):
return dimension_balances
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
def process_individual_date(docname: str, date, report_type, parentfield):
current_date_status = frappe.db.get_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
"status",
)
if current_date_status != "Running":
@@ -591,20 +580,17 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie
# save results
frappe.db.set_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
"closing_balance",
frappe.json.dumps(res),
)
frappe.db.set_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
"status",
"Completed",
)
# commit heavy computation before touching PPCV or PPCVD
if not frappe.in_test:
frappe.db.commit() # nosemgrep
# chain call
schedule_next_date(docname)

View File

@@ -48,27 +48,18 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
ppcv.save()
return ppcv
def set_processing_date_status(self, row_name, status):
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
frappe.db.set_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
"status",
status,
)
def get_row_name(self, ppcv_name, rpt_type, parentfield):
return frappe.db.get_all(
"Process Period Closing Voucher Detail",
filters={"parent": ppcv_name, "report_type": rpt_type, "parentfield": parentfield},
order_by="report_type, idx",
pluck="name",
limit=1,
)[0]
def get_processing_date_closing_balance(self, row_name):
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
return frappe.db.get_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
"closing_balance",
)
@@ -106,10 +97,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
parentfield = "normal_balances"
rpt_type = "Profit and Loss"
# status has to be set to 'Running' for logic to run
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 1)
expected_pl = {
"account": "Sales - _TC",
@@ -125,10 +117,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Balance sheet balance
rpt_type = "Balance Sheet"
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 1)
expected_bs = {
"account": "Debtors - _TC",
@@ -145,10 +138,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Opening balance
parentfield = "z_opening_balances"
rpt_type = "Balance Sheet"
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 2)
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
expected_opening_cash = {

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
# import frappe
from frappe.model.document import Document
@@ -24,10 +24,3 @@ class ProcessPeriodClosingVoucherDetail(Document):
# end: auto-generated types
pass
def on_doctype_update():
frappe.db.add_index(
"Process Period Closing Voucher Detail",
["parent", "status", "parentfield", "idx", "processing_date"],
)

View File

@@ -13,7 +13,7 @@
</div>
{% endif %}
</div>
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
<div>
{% if filters.party[0] == filters.party_name[0] %}
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>

View File

@@ -113,38 +113,3 @@ def create_process_soa(**args):
process_soa.update(soa_dict)
process_soa.save()
return process_soa
class TestProcessStatementOfAccountsValidation(ERPNextTestSuite):
"""validate() fills in default subject/body/pdf templates and enforces the
basic constraints. Exercised on the document directly (no email/PDF flow)."""
def make_soa(self, report="Accounts Receivable", with_customer=True, **overrides):
doc = frappe.new_doc("Process Statement Of Accounts")
doc.report = report
doc.company = "_Test Company"
if with_customer:
doc.append("customers", {"customer": "_Test Customer"})
doc.update(overrides)
return doc
def test_customers_are_required(self):
self.assertRaises(frappe.ValidationError, self.make_soa(with_customer=False).validate)
def test_general_ledger_body_uses_a_date_range(self):
doc = self.make_soa(report="General Ledger")
doc.validate()
self.assertIn("from {{ doc.from_date }} to {{ doc.to_date }}", doc.body)
# subject and pdf name are also defaulted
self.assertTrue(doc.subject)
self.assertTrue(doc.pdf_name)
def test_receivable_body_uses_the_posting_date(self):
doc = self.make_soa(report="Accounts Receivable")
doc.validate()
self.assertIn("until {{ doc.posting_date }}", doc.body)
def test_account_must_belong_to_company(self):
other = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other, "need an account in _Test Company 1")
self.assertRaises(frappe.ValidationError, self.make_soa(account=other).validate)

View File

@@ -1,56 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
# import frappe
import frappe
from erpnext.accounts.doctype.process_subscription.process_subscription import (
create_subscription_process,
)
from erpnext.accounts.doctype.subscription.test_subscription import (
create_parties,
create_subscription,
make_plans,
reset_settings,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessSubscription(ERPNextTestSuite):
"""Process Subscription is a batch driver: on submit it enqueues subscription.process_all
for every non-cancelled Subscription (or just one when a subscription is named)."""
def setUp(self):
frappe.set_user("Administrator")
# mirror TestSubscription setup so subscriptions build against known settings
make_plans()
create_parties()
reset_settings()
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
def enqueued_subscriptions(self, subscription=None):
"""Submit a Process Subscription while capturing what gets enqueued."""
calls = []
def capture(*args, **kwargs):
calls.append(kwargs)
with patch("frappe.enqueue", side_effect=capture):
create_subscription_process(subscription=subscription, posting_date="2026-06-15")
# each enqueue is handed a batch (list) of subscription names
return [name for call in calls for name in call.get("subscription", [])]
def test_named_subscription_is_the_only_one_enqueued(self):
sub = create_subscription(start_date="2026-01-01")
self.assertEqual(self.enqueued_subscriptions(subscription=sub.name), [sub.name])
def test_cancelled_subscriptions_are_skipped(self):
active = create_subscription(start_date="2026-01-01")
cancelled = create_subscription(start_date="2026-01-01")
cancelled.cancel_subscription()
enqueued = self.enqueued_subscriptions()
self.assertIn(active.name, enqueued)
self.assertNotIn(cancelled.name, enqueued)
pass

View File

@@ -329,11 +329,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
self.make_provisional_gl_entry(gl_entries, item)
if not doc.is_internal_transfer():
# When Update Stock is disabled, this invoice has no stock impact: the linked
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
# amount against the supplier - booking PPV again would double count it and leave
# SRBNB partially uncleared.
gl_entries.append(
self.get_gl_dict(
{
@@ -520,18 +515,6 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
},
)
def get_stock_variance_account(self, item):
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
Variance; for all other items it keeps the existing behaviour (default expense account)."""
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_purchase_price_variance_account,
)
from erpnext.stock.utils import get_valuation_method
if item.item_code and get_valuation_method(item.item_code, self.doc.company) == "Standard Cost":
return get_purchase_price_variance_account(item.item_code, self.doc.company)
return self.doc.get_company_default("default_expense_account")
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")
@@ -553,7 +536,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = self.get_stock_variance_account(item)
cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
@@ -578,7 +561,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
cost_of_goods_sold_account = self.get_stock_variance_account(item)
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

View File

@@ -1,55 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
# import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestRepostPaymentLedger(ERPNextTestSuite):
"""Repost Payment Ledger auto-selects submitted vouchers on/after a cutoff date
(unless rows are added manually) and queues them for a ledger rebuild."""
def setUp(self):
frappe.set_user("Administrator")
def make_repost(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Repost Payment Ledger")
doc.company = COMPANY
doc.posting_date = args.get("posting_date", "2026-06-01")
doc.voucher_type = args.get("voucher_type", "Sales Invoice")
doc.add_manually = args.get("add_manually", 0)
return doc
def test_loads_submitted_vouchers_on_or_after_cutoff(self):
after_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
on_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-01", rate=100, qty=1)
before_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
doc = self.make_repost(posting_date="2026-06-01", voucher_type="Sales Invoice")
doc.save() # before_validate loads the vouchers and sets status
loaded = {v.voucher_no for v in doc.repost_vouchers}
self.assertIn(after_cutoff.name, loaded)
# the filter is >= so an invoice posted exactly on the cutoff is included
self.assertIn(on_cutoff.name, loaded)
self.assertNotIn(before_cutoff.name, loaded)
self.assertEqual(doc.repost_status, "Queued")
def test_add_manually_preserves_user_rows(self):
# manually add a BEFORE-cutoff invoice (which the filter would never load) while a
# matching after-cutoff invoice also exists. If auto-loading wrongly ran it would
# drop the manual row and pull the after-cutoff one, so this distinguishes the modes.
manual_si = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
doc = self.make_repost(add_manually=1, posting_date="2026-06-01")
doc.append("repost_vouchers", {"voucher_type": "Sales Invoice", "voucher_no": manual_si.name})
doc.save()
rows = [(v.voucher_type, v.voucher_no) for v in doc.repost_vouchers]
self.assertEqual(rows, [("Sales Invoice", manual_si.name)])
pass

View File

@@ -594,15 +594,7 @@ def create_dunning(
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:
# outstanding_amount is in the party account currency, but the Overdue Payment
# row is in the invoice's transaction currency. When they differ, use the
# payment schedule's own outstanding — it is kept in transaction currency and
# updated as payments are allocated, so it stays correct even when the invoice
# and its payments post at different exchange rates (#56006).
if source.party_account_currency and source.party_account_currency != source.currency:
row.outstanding = source.payment_schedule[0].outstanding
else:
row.outstanding = source.get("outstanding_amount")
row.outstanding = source.get("outstanding_amount")
target.validate()

View File

@@ -33,11 +33,9 @@ class SalesInvoiceGLComposer(BaseGLComposer):
self.make_item_gl_entries(gl_entries)
disable_sdbnb_in_sr, is_sdbnb_enabled = frappe.get_cached_value(
"Company", doc.company, ["disable_sdbnb_in_sr", "enable_stock_delivered_but_not_billed"]
)
disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
if is_sdbnb_enabled and not (doc.is_return and 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)

View File

@@ -126,13 +126,13 @@ class POSService:
doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
def _apply_pos_item_defaults(self, pos, for_validate: bool) -> None:
from erpnext.stock.get_item_details import get_pos_profile_item_details_
from erpnext.stock.get_item_details import ItemDetailsCtx, get_pos_profile_item_details_
for item in self.doc.get("items"):
if not item.get("item_code"):
continue
profile_details = get_pos_profile_item_details_(
frappe._dict(item.as_dict()), pos, pos, update_data=True
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)):
@@ -344,9 +344,7 @@ def update_multi_mode_option(doc, pos_profile) -> None:
payment.account = payment_mode.default_account
payment.type = payment_mode.type
# is_created_using_pos exists on Sales Invoice but not POS Invoice; use get() so this
# shared helper doesn't raise AttributeError when called on a POS Invoice
mop_refetched = bool(doc.payments) and not doc.get("is_created_using_pos")
mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
doc.set("payments", [])
invalid_modes = []

View File

@@ -1576,14 +1576,14 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_stock_delivered_but_not_billed_gl_on_invoice(self):
company = "_Test SDBNB Company"
company = "_Test Company with perpetual inventory"
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
make_purchase_receipt(
company=company,
item_code="_Test FG Item",
warehouse="Stores - _TSDBNB",
cost_center="Main - _TSDBNB",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
qty=5,
rate=100,
)
@@ -1591,13 +1591,13 @@ class TestSalesInvoice(ERPNextTestSuite):
dn = create_delivery_note(
company=company,
item_code="_Test FG Item",
warehouse="Stores - _TSDBNB",
cost_center="Main - _TSDBNB",
warehouse="Stores - TCP1",
cost_center="Main - TCP1",
qty=2,
rate=300,
)
# A perpetual-inventory Delivery Note books the cost to the SDBNB account
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - _TSDBNB")
self.assertEqual(dn.items[0].expense_account, "Stock Delivered But Not Billed - TCP1")
si = make_sales_invoice(dn.name)
si.insert()
@@ -1609,9 +1609,9 @@ class TestSalesInvoice(ERPNextTestSuite):
fields=["account", "debit", "credit"],
)
sdbnb_credit = sum(
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - _TSDBNB"
row.credit for row in gl_entries if row.account == "Stock Delivered But Not Billed - TCP1"
)
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - _TSDBNB")
cogs_debit = sum(row.debit for row in gl_entries if row.account == "Cost of Goods Sold - TCP1")
# Billing reverses SDBNB and recognises the cost in COGS for an equal amount
self.assertTrue(sdbnb_credit > 0)

View File

@@ -121,65 +121,3 @@ class TestShareTransfer(ERPNextTestSuite):
}
)
self.assertRaises(ShareDontExists, doc.insert)
class TestShareTransferValidation(ERPNextTestSuite):
"""basic_validations() enforces the transfer's internal consistency. Exercised
directly (to_folio_no set to skip folio auto-naming) so no shareholder fixtures
are needed - it only reasons about the document's own fields."""
def make_transfer(self, **overrides):
doc = frappe.new_doc("Share Transfer")
doc.update(
{
"transfer_type": "Transfer",
"date": "2026-01-01",
"from_shareholder": "SH-A",
"to_shareholder": "SH-B",
"to_folio_no": "1",
"share_type": "Equity",
"from_no": 1,
"to_no": 100,
"no_of_shares": 100,
"rate": 10,
"amount": 1000,
"company": "_Test Company",
"equity_or_liability_account": "Creditors - _TC",
}
)
doc.update(overrides)
return doc
def test_baseline_transfer_is_consistent(self):
# the helper's defaults must pass, otherwise the negative cases prove nothing
self.make_transfer().basic_validations()
def test_seller_and_buyer_must_differ(self):
doc = self.make_transfer(to_shareholder="SH-A")
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_share_count_must_match_the_number_range(self):
# 1..100 is 100 shares, not 50
doc = self.make_transfer(no_of_shares=50)
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_amount_must_equal_rate_times_shares(self):
doc = self.make_transfer(amount=999) # 10 * 100 = 1000
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_amount_is_derived_when_left_blank(self):
doc = self.make_transfer(amount=0)
doc.basic_validations()
self.assertEqual(doc.amount, 1000)
def test_equity_or_liability_account_is_required(self):
doc = self.make_transfer(equity_or_liability_account=None)
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_issue_requires_a_to_shareholder(self):
doc = self.make_transfer(transfer_type="Issue", to_shareholder="", asset_account="Cash - _TC")
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_purchase_requires_a_from_shareholder(self):
doc = self.make_transfer(transfer_type="Purchase", from_shareholder="", asset_account="Cash - _TC")
self.assertRaises(frappe.ValidationError, doc.basic_validations)

View File

@@ -79,9 +79,7 @@ def get_plan_rate(
start_date = getdate(start_date)
end_date = getdate(end_date)
delta = relativedelta.relativedelta(end_date, start_date)
# include the years component so cross-year spans aren't under-counted
no_of_months = delta.years * 12 + delta.months + 1
no_of_months = relativedelta.relativedelta(end_date, start_date).months + 1
cost = plan.cost * no_of_months
# Adjust cost if start or end date is not month start or end

View File

@@ -1,54 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.tests.utils import ERPNextTestSuite
class TestSubscriptionPlan(ERPNextTestSuite):
"""Subscription Plan validates its interval and computes a rate. The Monthly
Rate branch multiplies cost by the number of months in the billing window."""
def setUp(self):
frappe.set_user("Administrator")
def make_plan(self, **args):
args = frappe._dict(args)
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = f"_Test Plan {frappe.generate_hash(length=6)}"
plan.item = args.item or "_Test Item"
plan.currency = args.currency or "INR"
plan.price_determination = args.price_determination
plan.cost = args.cost or 0
plan.billing_interval = args.billing_interval or "Month"
plan.billing_interval_count = (
args.billing_interval_count if args.billing_interval_count is not None else 1
)
return plan
def test_billing_interval_count_must_be_positive(self):
plan = self.make_plan(price_determination="Fixed Rate", cost=100, billing_interval_count=0)
self.assertRaises(frappe.ValidationError, plan.insert)
def test_fixed_rate_applies_prorate_factor(self):
plan = self.make_plan(price_determination="Fixed Rate", cost=100)
plan.insert()
self.assertEqual(get_plan_rate(plan.name), 100)
self.assertEqual(get_plan_rate(plan.name, prorate_factor=0.5), 50)
def test_monthly_rate_within_year(self):
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
plan.insert()
# Jan 1 - Mar 31 is 3 whole months; month-aligned so proration is 0
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2026-03-31")
self.assertEqual(rate, 300)
def test_monthly_rate_across_year_boundary(self):
# a 14-month span (Jan 2026 to Feb 2027) bills all 14 months, not just the
# 2-month remainder that relativedelta.months alone would give
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
plan.insert()
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2027-02-28")
self.assertEqual(rate, 1400)
pass

View File

@@ -640,15 +640,13 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
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)
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -717,7 +715,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -173,20 +173,6 @@ frappe.query_reports["Accounts Payable"] = {
return value;
},
get_datatable_options(options) {
return Object.assign(options, {
checkboxColumn: true,
events: {
onCheckRow: () => erpnext.accounts.toggle_create_pe_primary_action(frappe.query_report),
},
});
},
after_refresh: function (report) {
report.datatable?.rowmanager?.checkAll(false);
report.page.clear_primary_action();
},
onload: function (report) {
report.page.add_inner_button(__("Accounts Payable Summary"), function () {
var filters = report.get_values();
@@ -199,149 +185,6 @@ frappe.query_reports["Accounts Payable"] = {
},
};
frappe.provide("erpnext.accounts");
erpnext.accounts.toggle_create_pe_primary_action = function (report) {
if (!report || !report.datatable || !frappe.model.can_create("Payment Entry")) return;
const has_purchase_invoice = report.datatable.rowmanager
.getCheckedRows()
.some((i) => report.datatable.datamanager.data[i]?.voucher_type === "Purchase Invoice");
if (has_purchase_invoice) {
report.page.set_primary_action(__("Create Payment Entries"), () =>
erpnext.accounts.create_payment_entries_from_payable_report(report)
);
} else {
report.page.clear_primary_action();
}
};
erpnext.accounts.create_payment_entries_from_payable_report = function (report) {
const datatable = report.datatable;
if (!datatable) return;
const rows = datatable.rowmanager
.getCheckedRows()
.map((i) => datatable.datamanager.data[i])
.filter((r) => r && r.voucher_type === "Purchase Invoice" && r.voucher_no);
if (!rows.length) {
frappe.msgprint(__("Select one or more Purchase Invoice rows"));
return;
}
// build per-(supplier, party_account) summary to match backend grouping key
const supplierMap = {};
for (const r of rows) {
const key = `${r.party}||${r.party_account}`;
if (!supplierMap[key]) {
supplierMap[key] = {
supplier: r.party,
party_account: r.party_account,
count: 0,
outstanding: 0,
};
}
supplierMap[key].count += 1;
supplierMap[key].outstanding += r.outstanding || 0;
}
const overviewFields = [
{
fieldtype: "Data",
fieldname: "supplier",
label: __("Supplier"),
read_only: 1,
in_list_view: 1,
width: 150,
},
{
fieldtype: "Data",
fieldname: "party_account",
label: __("Payable Account"),
read_only: 1,
in_list_view: 1,
width: 130,
},
{
fieldtype: "Int",
fieldname: "invoices",
label: __("Invoices"),
read_only: 1,
in_list_view: 1,
width: 70,
},
{
fieldtype: "Float",
fieldname: "payable_amount",
label: __("Payable Amount"),
read_only: 1,
in_list_view: 1,
},
];
const dialog = new frappe.ui.Dialog({
title: __("Create Payment Entries"),
fields: [
{
fieldname: "supplier_overview",
fieldtype: "Table",
label: __("Supplier Overview"),
cannot_add_rows: true,
cannot_delete_rows: true,
fields: overviewFields,
data: Object.values(supplierMap).map((d) => ({
supplier: d.supplier,
party_account: d.party_account,
invoices: d.count,
payable_amount: d.outstanding,
})),
},
],
primary_action_label: __("Create"),
secondary_action_label: __("Cancel"),
secondary_action() {
dialog.hide();
report.datatable.rowmanager.checkAll(false);
},
primary_action() {
dialog.hide();
const groupedKeys = new Set(
Object.values(supplierMap)
.filter((d) => d.count > 1)
.map((d) => `${d.supplier}||${d.party_account}`)
);
const grouped_invoices = [];
const ungrouped_invoices = [];
for (const r of rows) {
const payload = {
voucher_no: r.voucher_no,
supplier: r.party,
party_account: r.party_account,
};
(groupedKeys.has(`${r.party}||${r.party_account}`)
? grouped_invoices
: ungrouped_invoices
).push(payload);
}
const clearSelection = () => report.datatable.rowmanager.checkAll(false);
frappe
.call({
method: "erpnext.accounts.bulk_payment.create_payment_entries",
args: { grouped_invoices, ungrouped_invoices },
})
.then(clearSelection)
.catch(clearSelection);
},
});
dialog.show();
};
erpnext.utils.add_dimensions("Accounts Payable", 10);
function get_party_type_options() {

View File

@@ -17,7 +17,7 @@
"generate_csv": 0,
"idx": 3,
"is_standard": "Yes",
"modified": "2026-07-01 13:37:41.185347",
"modified": "2026-06-25 12:03:36.559152",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Payable",
@@ -40,6 +40,6 @@
"role": "Auditor"
}
],
"snapshot_report": 0,
"synced_report": 0,
"timeout": 0
}

View File

@@ -135,9 +135,38 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms
template = frappe.get_doc("Payment Terms Template", "_Test Payment Term Template")
first_term = frappe.get_doc("Payment Term", template.terms[0].payment_term)
expected_payment_term = first_term.description or first_term.name
payment_term1 = frappe.get_doc(
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 15 Days"}
).insert()
payment_term2 = frappe.get_doc(
{"doctype": "Payment Term", "payment_term_name": "_Test 50% on 30 Days"}
).insert()
template = frappe.get_doc(
{
"doctype": "Payment Terms Template",
"template_name": "_Test 50-50",
"terms": [
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term": payment_term1.name,
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 15,
},
{
"doctype": "Payment Terms Template Detail",
"due_date_based_on": "Day(s) after invoice date",
"payment_term": payment_term2.name,
"description": "_Test 50-50",
"invoice_portion": 50,
"credit_days": 30,
},
],
}
)
template.insert()
filters = {
"company": self.company,
@@ -164,10 +193,12 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
row = report[1][0]
self.assertEqual(len(report[1]), 2)
self.assertEqual([pi.name, expected_payment_term], [row.voucher_no, row.payment_term])
self.assertEqual([pi.name, payment_term1.payment_term_name], [row.voucher_no, row.payment_term])
def test_project_filter(self):
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AP Project", "company": self.company}
).insert()
pi = self.create_purchase_invoice(do_not_submit=True)
pi.project = project.name
@@ -196,7 +227,9 @@ class TestAccountsPayable(ERPNextTestSuite, AccountsTestMixin):
"range": "30, 60, 90, 120",
}
project = frappe.get_doc("Project", {"project_name": "_Test Project"})
project = frappe.get_doc(
{"doctype": "Project", "project_name": "_Test AP Project Output", "company": self.company}
).insert()
pi = self.create_purchase_invoice(do_not_submit=True)
pi.project = project.name

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