Compare commits

...

388 Commits

Author SHA1 Message Date
Frappe PR Bot
4aee12e16c chore(release): Bumped to Version 15.121.4
## [15.121.4](https://github.com/frappe/erpnext/compare/v15.121.3...v15.121.4) (2026-09-23)

### Bug Fixes

* **accounts:** clarify revaluation journal filters ([a602153](a602153753))
* **accounts:** keep price list within user permissions ([#59231](https://github.com/frappe/erpnext/issues/59231)) ([004633a](004633a97e))
* apply journal entry reversal backport to version 15 controller ([5237c81](5237c8189a))
* clear stock value for zero-quantity balances ([#58899](https://github.com/frappe/erpnext/issues/58899)) ([65d2a07](65d2a07596))
* continuous raw material consumption with bom validation (backport [#51914](https://github.com/frappe/erpnext/issues/51914)) ([#59314](https://github.com/frappe/erpnext/issues/59314)) ([7fe230a](7fe230ac6e))
* do not zero out backdated stock at a stock reco adjustment entry ([#59271](https://github.com/frappe/erpnext/issues/59271)) ([1017286](10172869fe))
* **journal-entry:** carry reference fields into reverse journal entry ([f4ce7b2](f4ce7b2677))
* **manufacturing:** finish a work order after partial material consumption ([#59311](https://github.com/frappe/erpnext/issues/59311)) ([6d3304d](6d3304d3ec))
* negative stock value for moving average item with mixed batchwise valuation ([#59102](https://github.com/frappe/erpnext/issues/59102)) ([a37725b](a37725b6b9))
* **permissions:** grant select on link targets to roles with write access ([#59232](https://github.com/frappe/erpnext/issues/59232)) ([89de899](89de899c95))
* **pos:** quote the reversed row's rate on a consolidated credit note (backport [#59320](https://github.com/frappe/erpnext/issues/59320)) ([#59322](https://github.com/frappe/erpnext/issues/59322)) ([879c5c7](879c5c72a1))
* prevent max recursion on supplier scorecard save ([#58706](https://github.com/frappe/erpnext/issues/58706)) ([a7aae8b](a7aae8b21a))
* render letter head jinja in statement of accounts pdf ([b8a53b8](b8a53b800a))
* resolve code lists by URI and version (backport [#58770](https://github.com/frappe/erpnext/issues/58770)) ([#58771](https://github.com/frappe/erpnext/issues/58771)) ([e283008](e2830089fb))
* resolve conflicts ([32c8a95](32c8a9507d))
* resolve conflicts ([ed33afd](ed33afd0fa))
* round running allocation balance in payment reconciliation (backport [#58393](https://github.com/frappe/erpnext/issues/58393)) ([#59356](https://github.com/frappe/erpnext/issues/59356)) ([3a7d756](3a7d756714))
* **selling:** use preferred addresses in customer quotations ([e291ce2](e291ce2616))
* **stock:** allow None for for_validate in get_item_details ([#59290](https://github.com/frappe/erpnext/issues/59290)) ([a41858a](a41858a47d))
* **stock:** allow zero completed quantity and handle process loss in job cards ([#59104](https://github.com/frappe/erpnext/issues/59104)) ([1e862b2](1e862b232c))
* **stock:** apply transit warehouse filter to target warehouse in items table ([#59192](https://github.com/frappe/erpnext/issues/59192)) ([e139084](e13908400b))
* **stock:** calculate batch bundle valuation per unit ([#58994](https://github.com/frappe/erpnext/issues/58994)) ([7df94d8](7df94d87de))
* **stock:** compare transit quantities in stock UOM (backport [#59202](https://github.com/frappe/erpnext/issues/59202)) ([#59243](https://github.com/frappe/erpnext/issues/59243)) ([7d5293e](7d5293e83e))
* **stock:** continue putaway allocation after undersized whole-uom rules ([#59155](https://github.com/frappe/erpnext/issues/59155)) ([e693031](e6930317ae))
* **stock:** deduct rejected qty from the in-transit warehouse ([#59300](https://github.com/frappe/erpnext/issues/59300)) ([83ab572](83ab572208))
* **stock:** filter item group defaults by company and non-group records ([#58923](https://github.com/frappe/erpnext/issues/58923)) ([50b6b85](50b6b850e4))
* **stock:** forward company to outward batch lookup ([#59160](https://github.com/frappe/erpnext/issues/59160)) ([f5f7d04](f5f7d04fd5))
* **stock:** keep mapped cost center in stock entry items ([#59286](https://github.com/frappe/erpnext/issues/59286)) ([091fcfe](091fcfee7e))
* **stock:** use net purchase values in landed cost vouchers (backport [#59274](https://github.com/frappe/erpnext/issues/59274)) ([#59279](https://github.com/frappe/erpnext/issues/59279)) ([3b9c740](3b9c74024a))
* **stock:** use stored posting_datetime when reposting stock ledger entries ([#58998](https://github.com/frappe/erpnext/issues/58998)) ([c7893b8](c7893b8444))
* **stock:** validate warehouse account belongs to selected company ([#59191](https://github.com/frappe/erpnext/issues/59191)) ([5b2a0e7](5b2a0e73c3))
* update modified for last purchase rate (backport [#59044](https://github.com/frappe/erpnext/issues/59044)) ([#59105](https://github.com/frappe/erpnext/issues/59105)) ([db2e9ef](db2e9efab4))
* validate bank reconciliation date ranges ([dcff5c3](dcff5c3149))
* validate coupon pricing rules and validity dates ([3fbf440](3fbf440c77))
* validate shipping rule account company ([e54bf6f](e54bf6fe43))
* write off stranded stock value only when the warehouse is empty ([#59219](https://github.com/frappe/erpnext/issues/59219)) ([7a27634](7a276344a3))

### Performance Improvements

* filter by company in get_returned_invoice_items ([4fbc279](4fbc2791b8))
* reduce memory consumption during reposting ([#59117](https://github.com/frappe/erpnext/issues/59117)) ([dc08520](dc08520197)), closes [#52152](https://github.com/frappe/erpnext/issues/52152)
* skip legacy batch ledger lookups when no legacy entry exists ([#59110](https://github.com/frappe/erpnext/issues/59110)) ([0130d28](0130d287f6))
2026-09-23 23:04:36 +00:00
Diptanil Saha
3fb8adbf24 Merge pull request #59275 from frappe/version-15-hotfix
chore: release v15
2026-09-24 04:32:58 +05:30
mergify[bot]
3a7d756714 fix: round running allocation balance in payment reconciliation (backport #58393) (#59356)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-09-23 22:25:40 +00:00
Diptanil Saha
aea326869b fix(permissions)!: add permission checks to whitelisted methods and the DocPerm rows they need (#59283) 2026-09-23 23:01:55 +05:30
Khushi Rawat
6c55b16f8f Merge pull request #59221 from frappe/mergify/bp/version-15-hotfix/pr-59128
fix: validate coupon pricing rules and validity dates (backport #59128)
2026-09-23 19:26:20 +05:30
Sudharsanan Ashok
e7e76c164f Merge pull request #59338 from frappe/mergify/bp/version-15-hotfix/pr-59336
fix(selling): use preferred addresses in customer quotations (backport #59336)
2026-09-23 19:16:41 +05:30
pandiyan
2f663d6da7 test: cover coupon pricing rule and date validation 2026-09-23 18:55:44 +05:30
pandiyan
3fbf440c77 fix: validate coupon pricing rules and validity dates 2026-09-23 18:54:44 +05:30
pandiyan
e291ce2616 fix(selling): use preferred addresses in customer quotations 2026-09-23 18:48:10 +05:30
mergify[bot]
879c5c72a1 fix(pos): quote the reversed row's rate on a consolidated credit note (backport #59320) (#59322)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-23 15:42:43 +05:30
Mihir Kandoi
7fe230ac6e fix: continuous raw material consumption with bom validation (backport #51914) (#59314) 2026-09-23 08:12:34 +00:00
Mihir Kandoi
6d3304d3ec fix(manufacturing): finish a work order after partial material consumption (#59311) 2026-09-23 07:49:59 +00:00
Sudharsanan Ashok
42aaeb924e Merge pull request #59296 from frappe/mergify/bp/version-15-hotfix/pr-59231
fix(accounts): keep price list within user permissions (backport #59231)
2026-09-23 12:03:34 +05:30
Sudharsanan11
672518756a test: add stock for internal transfer price list validation 2026-09-23 11:37:22 +05:30
Sudharsanan11
32c8a9507d fix: resolve conflicts 2026-09-23 11:37:22 +05:30
Sudharsanan Ashok
004633a97e fix(accounts): keep price list within user permissions (#59231)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit e814d13126)

# Conflicts:
#	erpnext/accounts/party.py
#	erpnext/accounts/test_party.py
2026-09-23 11:37:22 +05:30
Sudharsanan Ashok
8985c36397 Merge pull request #59294 from frappe/mergify/bp/version-15-hotfix/pr-59286
fix(stock): keep mapped cost center in stock entry items (backport #59286)
2026-09-22 23:45:53 +05:30
Khushi Rawat
eb75b22c9a Merge pull request #59288 from frappe/mergify/bp/version-15-hotfix/pr-59262
fix(journal-entry): carry reference fields into reverse journal entry (backport #59262)
2026-09-22 23:17:01 +05:30
Mihir Kandoi
83ab572208 fix(stock): deduct rejected qty from the in-transit warehouse (#59300) 2026-09-22 14:33:56 +00:00
Afsal Syed
a41858a47d fix(stock): allow None for for_validate in get_item_details (#59290) 2026-09-22 19:37:15 +05:30
Sudharsanan Ashok
091fcfee7e fix(stock): keep mapped cost center in stock entry items (#59286)
(cherry picked from commit 7f608f31eb)
2026-09-22 12:35:38 +00:00
khushi8112
5237c8189a fix: apply journal entry reversal backport to version 15 controller 2026-09-22 17:33:39 +05:30
Mihir Kandoi
3b9c74024a fix(stock): use net purchase values in landed cost vouchers (backport #59274) (#59279)
Co-authored-by: pandiyan <pandiyanpalani37@gmail.com>
2026-09-22 12:02:46 +00:00
ervishnucs
f4ce7b2677 fix(journal-entry): carry reference fields into reverse journal entry
(cherry picked from commit 9e01992217)

# Conflicts:
#	erpnext/accounts/doctype/journal_entry/mapper.py
2026-09-22 11:47:21 +00:00
rohitwaghchaure
10172869fe fix: do not zero out backdated stock at a stock reco adjustment entry (#59271)
* fix: do not zero out backdated stock at a stock reco adjustment entry

* fix: keep a stock reco adjustment entry value-only on cancel and refresh

* fix: read stock reco adjustment rows once and value them from the ledger
2026-09-22 16:29:45 +05:30
mergify[bot]
7d5293e83e fix(stock): compare transit quantities in stock UOM (backport #59202) (#59243)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 08:50:17 +00:00
Sudharsanan Ashok
9fe04988c2 Merge pull request #59254 from frappe/mergify/bp/version-15-hotfix/pr-59192
fix(stock): apply transit warehouse filter to target warehouse in items table (backport #59192)
2026-09-22 13:38:45 +05:30
Sudharsanan Ashok
f3078ed56b Merge pull request #59193 from frappe/mergify/bp/version-15-hotfix/pr-59191
fix(stock): validate warehouse account belongs to selected company (backport #59191)
2026-09-22 13:38:32 +05:30
Afsal Syed
e13908400b fix(stock): apply transit warehouse filter to target warehouse in items table (#59192)
(cherry picked from commit 893fc75df4)
2026-09-22 12:32:48 +05:30
Afsal Syed
5b2a0e73c3 fix(stock): validate warehouse account belongs to selected company (#59191)
(cherry picked from commit db6e089109)
2026-09-22 11:40:44 +05:30
Diptanil Saha
89de899c95 fix(permissions): grant select on link targets to roles with write access (#59232) 2026-09-21 17:28:56 +05:30
Shllokkk
4f122ab2b3 Merge pull request #59229 from frappe/mergify/bp/version-15-hotfix/pr-59204
perf: filter by company in get_returned_invoice_items (backport #59204)
2026-09-21 15:14:06 +05:30
Shllokkk
4fbc2791b8 perf: filter by company in get_returned_invoice_items
(cherry picked from commit 423aa09f59)
2026-09-21 09:02:25 +00:00
rohitwaghchaure
7a276344a3 fix: write off stranded stock value only when the warehouse is empty (#59219) 2026-09-21 13:30:14 +05:30
Shllokkk
e4ea6e0a04 Merge pull request #59205 from frappe/mergify/bp/version-15-hotfix/pr-59203
refactor: migrate raw SQL queries in gross profit report to query builder (backport #59203)
2026-09-21 02:44:56 +05:30
Shllokkk
9e5baaa571 refactor: migrate raw SQL queries in gross profit report to query builder
(cherry picked from commit fd369a5a0f)

# Conflicts:
#	erpnext/accounts/report/gross_profit/gross_profit.py
2026-09-21 02:28:30 +05:30
Sudharsanan Ashok
54ac519bec Merge pull request #59164 from frappe/mergify/bp/version-15-hotfix/pr-59160
fix(stock): forward company to outward batch lookup (backport #59160)
2026-09-18 18:27:01 +05:30
Khushi Rawat
a7151374bb Merge pull request #59183 from frappe/mergify/bp/version-15-hotfix/pr-59181
fix(accounts): clarify revaluation journal filters (backport #59181)
2026-09-18 17:57:44 +05:30
pandiyan
a602153753 fix(accounts): clarify revaluation journal filters
(cherry picked from commit dc4b390a9b)
2026-09-18 12:23:17 +00:00
Sudharsanan Ashok
f73cdb37bf Merge pull request #59158 from frappe/mergify/bp/version-15-hotfix/pr-59155
fix(stock): continue putaway allocation after undersized whole-uom rules (backport #59155)
2026-09-18 15:16:10 +05:30
Pandiyan P
f5f7d04fd5 fix(stock): forward company to outward batch lookup (#59160)
(cherry picked from commit 622b7e0caa)
2026-09-18 09:07:07 +00:00
Khushi Rawat
fc1d5975e9 Merge pull request #59162 from frappe/mergify/bp/version-15-hotfix/pr-59142
fix: validate shipping rule account company (backport #59142)
2026-09-18 14:36:57 +05:30
Khushi Rawat
0983fb1e9c Merge pull request #59156 from frappe/mergify/bp/version-15-hotfix/pr-59081
fix(accounts): validate bank reconciliation date ranges (backport #59081)
2026-09-18 14:23:38 +05:30
pandiyan
e6930317ae fix(stock): continue putaway allocation after undersized whole-uom rules (#59155)
(cherry picked from commit 6cc500e28d)
2026-09-18 14:16:00 +05:30
pandiyan
7186e45de9 test: cover shipping rule account company validation
(cherry picked from commit 022ee73d78)

# Conflicts:
#	erpnext/accounts/doctype/shipping_rule/test_shipping_rule.py
2026-09-18 14:11:20 +05:30
pandiyan
e54bf6fe43 fix: validate shipping rule account company
(cherry picked from commit 340feb94a1)
2026-09-18 08:37:12 +00:00
pandiyan
355033d40f test: cover bank reconciliation date validation
(cherry picked from commit 62d0e2c82e)

# Conflicts:
#	erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py
2026-09-18 13:54:26 +05:30
pandiyan
dcff5c3149 fix: validate bank reconciliation date ranges
(cherry picked from commit 970f32a342)

# Conflicts:
#	erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
2026-09-18 13:53:14 +05:30
Sudharsanan Ashok
22a860d380 Merge pull request #59089 from frappe/mergify/bp/version-15-hotfix/pr-58923
fix(stock): filter item group defaults by company and non-group records (backport #58923)
2026-09-18 11:12:11 +05:30
rohitwaghchaure
dc08520197 perf: reduce memory consumption during reposting (#59117)
* perf: reduce memory consumption during reposting

The reposting queue introduced in #52152 keeps the complete future stock
ledger of every transitively dependent item-warehouse in a single deque.
Each `select *` row is ~3.5KB, so a repost spanning a million entries
needs several GB and gets OOM killed on smaller workers.

- Queue only the identity and sort keys of an entry, and fetch the full
  row in batches of 500 just before it is processed
- Move `for update` off the bulk prefetch onto the batch, so millions of
  rows are no longer locked for the whole duration of the repost
- Drop the process local document cache and message log at every
  checkpoint, neither is evicted within a long running job

* fix: keep recent repost messages and log skipped entries

Trimming `frappe.local.message_log` to empty at every checkpoint discarded
warnings that the Repost Item Valuation failure handler reads back when
building the error log. Keep the most recent messages instead, which bounds
the growth just as well.

Also log when a queued entry is no longer active by the time its batch is
loaded, rather than skipping it silently.

* fix: skip reposts already covered by a Manufacture/Repack dependant repost

While reposting a raw material, the finished goods produced from it are
reposted as dependants, from the posting datetime of the manufacture entry
through to the end of their ledger. A repost queued separately for the same
finished good and warehouse at a later datetime therefore has nothing left to
do, but it was still picked up and walked the same entries again.

Track the item-warehouse combinations pulled in as dependants of a Manufacture
or Repack entry, and mark the redundant queued reposts as Skipped once the
dependants have been reposted. This runs per item being reposted, so the
finished good's queued repost is released without waiting for the whole raw
material repost to finish.

Only 'Item and Warehouse' reposts are skipped. A 'Transaction' repost spans
several item-warehouse combinations, so covering one says nothing about the
rest. Reposts starting before the manufacture entry still have work to do and
are left queued.

* fix: don't lock the whole repost queue prefetch

* test: repost covers every entry once across batches
2026-09-18 09:02:56 +05:30
Sudharsanan Ashok
1ad584fddc Merge pull request #59118 from frappe/mergify/bp/version-15-hotfix/pr-59104
fix(stock): allow zero completed quantity and handle process loss in job cards (backport #59104)
2026-09-17 19:22:55 +05:30
Sudharsanan Ashok
d6d0400f76 Merge pull request #59087 from frappe/mergify/bp/version-15-hotfix/pr-58998
fix(stock): use stored posting_datetime when reposting stock ledger entries (backport #58998)
2026-09-17 18:47:19 +05:30
Sudharsanan11
ed33afd0fa fix: resolve conflicts 2026-09-17 18:28:07 +05:30
Sudharsanan Ashok
c7893b8444 fix(stock): use stored posting_datetime when reposting stock ledger entries (#58998)
* fix(stock): use stored posting_datetime for repost boundary

get_stock_ledger_entries re-derived posting_datetime from posting_date and
posting_time on every call, discarding the stored value its callers pass in.
when a row's stored posting_datetime differs from that pair, the replay window
is built from the wrong instant: the row falls outside the range filter and is
never recomputed, while get_previous_sle still selects it as the opening
balance and reuses its stale qty_after_transaction. every later entry inherits
the error, leaving bin qty adrift from the sum of its ledger.

derive the boundary only when the caller has not supplied one.

* fix(stock): match current voucher sle on stored posting_datetime

get_sle_against_current_voucher selected rows with an equality check against a
posting_datetime re-derived from posting_date and posting_time. a row whose
stored posting_datetime differs from that pair matches nothing, so reposting
the voucher silently processes zero entries and the row can never be corrected
through its own voucher.

read the timestamp from the stored row when the sle is known, and derive it
only as a fallback.

* test(stock): cover repost with diverged posting_datetime

add a repack scenario whose incoming entry stores a posting_datetime one
microsecond before its own posting_time. asserts the voucher lookup still
finds that entry, and that reposting replays it instead of reusing its stale
qty_after_transaction, which otherwise left bin qty at 115 against 615 of
recorded movements.

(cherry picked from commit 6cee9c330c)

# Conflicts:
#	erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
2026-09-17 18:28:07 +05:30
Afsal Syed
1e862b232c fix(stock): allow zero completed quantity and handle process loss in job cards (#59104)
(cherry picked from commit 1d8ce1ee8c)
2026-09-17 18:07:54 +05:30
Sudharsanan Ashok
f6b2708f4e Merge pull request #59095 from frappe/mergify/bp/version-15-hotfix/pr-58899
fix: clear stock value for zero-quantity balances (backport #58899)
2026-09-17 11:10:24 +05:30
pandiyan
65d2a07596 fix: clear stock value for zero-quantity balances (#58899)
* fix: clear stock value for zero-quantity balances

* test(stock): cover zero-quantity residual stock values

(cherry picked from commit b439bdda68)
2026-09-17 10:52:56 +05:30
rohitwaghchaure
0130d287f6 perf: skip legacy batch ledger lookups when no legacy entry exists (#59110)
* perf: skip legacy batch ledger lookups when no legacy entry exists
2026-09-16 11:45:49 +00:00
Shllokkk
30cfb2094f Merge pull request #59107 from frappe/mergify/bp/version-15-hotfix/pr-59078
fix: render letter head jinja in statement of accounts pdf (backport #59078)
2026-09-16 16:44:20 +05:30
rohitwaghchaure
a37725b6b9 fix: negative stock value for moving average item with mixed batchwise valuation (#59102)
* fix: negative stock value for moving average item with mixed batchwise valuation

* test: cover fallback rate and equal timestamp valuation paths
2026-09-16 16:29:06 +05:30
mergify[bot]
db2e9efab4 fix: update modified for last purchase rate (backport #59044) (#59105)
fix: update modified for last purchase rate (#59044)

(cherry picked from commit f6b907ef9d)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-16 10:14:44 +00:00
Shllokkk
b8a53b800a fix: render letter head jinja in statement of accounts pdf
(cherry picked from commit da377683d6)
2026-09-16 10:10:12 +00:00
Vishnu Priya Baskaran
a7aae8b21a fix: prevent max recursion on supplier scorecard save (#58706) 2026-09-16 13:12:03 +05:30
pandiyan
50b6b850e4 fix(stock): filter item group defaults by company and non-group records (#58923)
(cherry picked from commit 00ee694444)
2026-09-16 12:05:13 +05:30
Sudharsanan Ashok
b4dd2ee80f Merge pull request #59015 from frappe/mergify/bp/version-15-hotfix/pr-58994
fix(stock): calculate batch bundle valuation per unit (backport #58994)
2026-09-16 11:03:50 +05:30
mergify[bot]
e2830089fb fix: resolve code lists by URI and version (backport #58770) (#58771)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-16 02:37:15 +02:00
Frappe PR Bot
26f0687834 chore(release): Bumped to Version 15.121.3
## [15.121.3](https://github.com/frappe/erpnext/compare/v15.121.2...v15.121.3) (2026-09-15)

### Bug Fixes

* % Amount Billed exceeds 100% on fully rejected Purchase Receipts ([#58897](https://github.com/frappe/erpnext/issues/58897)) ([c31ae57](c31ae577e4))
* **accounts:** add permission checks on `execute_repost_payment_ledger` (backport [#58980](https://github.com/frappe/erpnext/issues/58980)) ([#58981](https://github.com/frappe/erpnext/issues/58981)) ([85a1529](85a152946e))
* **accounts:** add permission checks on `invoice_discounting.get_invoices` (backport [#58975](https://github.com/frappe/erpnext/issues/58975)) ([#58989](https://github.com/frappe/erpnext/issues/58989)) ([62b9f31](62b9f31297))
* **accounts:** reject disabled price lists in buying and selling ([#58891](https://github.com/frappe/erpnext/issues/58891)) ([47662b9](47662b9af2))
* **accounts:** stop counting received qty on non-stock invoice returns (backport [#58924](https://github.com/frappe/erpnext/issues/58924)) ([#58928](https://github.com/frappe/erpnext/issues/58928)) ([228108a](228108ad4c))
* Backfill `not_applicable` on Item Tax Template Details for German companies (backport [#54682](https://github.com/frappe/erpnext/issues/54682)) (backport [#54686](https://github.com/frappe/erpnext/issues/54686)) ([#58952](https://github.com/frappe/erpnext/issues/58952)) ([f943368](f9433683ec))
* keep a dunning claimable until its interest is paid too ([11c6f0d](11c6f0d02c))
* **manufacturing:** handle empty raw materials in workstation ([#58927](https://github.com/frappe/erpnext/issues/58927)) ([c72bc69](c72bc6925b))
* prevent disabled items from being used in bom ([#58997](https://github.com/frappe/erpnext/issues/58997)) ([7429a26](7429a260f6))
* reset price list fallback when changing parties ([#58893](https://github.com/frappe/erpnext/issues/58893)) ([f981791](f981791012))
* resolve conflicts ([e84a946](e84a94642d))
* resolve conflicts ([00729a8](00729a8d62))
* **selling:** add email permission check on `send_emails` (backport [#58935](https://github.com/frappe/erpnext/issues/58935)) ([#58937](https://github.com/frappe/erpnext/issues/58937)) ([e37ba3f](e37ba3f8cf))
* **selling:** exclude fully billed orders from the invoice picker (backport [#58966](https://github.com/frappe/erpnext/issues/58966)) ([#58968](https://github.com/frappe/erpnext/issues/58968)) ([52ea7ed](52ea7ed606))
* **stock:** distribute additional costs when incoming items have no value ([10987b1](10987b11cc))
* **stock:** do not rebuild the Warehouse tree on an account map miss (backport [#58956](https://github.com/frappe/erpnext/issues/58956)) ([#58972](https://github.com/frappe/erpnext/issues/58972)) ([f59a6ec](f59a6ec882))
* **stock:** guard inventory dimension lookup without reference document ([#58889](https://github.com/frappe/erpnext/issues/58889)) ([7ee54ba](7ee54ba08b))
* **stock:** preserve rates for unsaved mapped rows (backport [#58852](https://github.com/frappe/erpnext/issues/58852)) ([#58898](https://github.com/frappe/erpnext/issues/58898)) ([32d8c35](32d8c359ed))
* **stock:** recalculate billing status of returned delivery notes (backport [#58953](https://github.com/frappe/erpnext/issues/58953)) ([#58986](https://github.com/frappe/erpnext/issues/58986)) ([563615c](563615c77e))
* **stock:** remove empty delivery stops after mapping ([#58896](https://github.com/frappe/erpnext/issues/58896)) ([0099e7e](0099e7ec4f))
* **stock:** set item_name to fetch if empty in packing slip item ([#58925](https://github.com/frappe/erpnext/issues/58925)) ([99afc2e](99afc2e6eb))
* **stock:** set item_name to fetch if empty in packing slip item ([#58925](https://github.com/frappe/erpnext/issues/58925)) ([9ba2159](9ba2159720))
* **stock:** show actual outgoing rate in ledger preview ([#58700](https://github.com/frappe/erpnext/issues/58700)) ([372244e](372244e6b2))
* **subcontracting:** validate project across the subcontracting flow ([#58965](https://github.com/frappe/erpnext/issues/58965)) ([1150d8e](1150d8e9b0))
* user not able to set valuation rate zero in stock reco (backport [#58800](https://github.com/frappe/erpnext/issues/58800)) ([#58821](https://github.com/frappe/erpnext/issues/58821)) ([3bd194e](3bd194e986))
* validate shelf life for automatically created expiring batches ([#58911](https://github.com/frappe/erpnext/issues/58911)) ([00e02f0](00e02f07ac))
2026-09-15 18:35:09 +00:00
Diptanil Saha
f2cfe30596 Merge pull request #59072 from frappe/version-15-hotfix
chore: release v15
2026-09-16 00:03:25 +05:30
Sudharsanan Ashok
641f8e56a2 Merge pull request #59012 from frappe/mergify/bp/version-15-hotfix/pr-58997
fix: prevent disabled items from being used in BOM (backport #58997)
2026-09-15 22:07:10 +05:30
Sudharsanan Ashok
f6bac4c6ee Merge pull request #59077 from frappe/mergify/bp/version-15-hotfix/pr-58925
fix(stock): set item_name to fetch if empty in packing slip item (backport #58925)
2026-09-15 18:55:46 +05:30
mergify[bot]
112350dd65 fix(regional)!: remove irs_1099_print whitelisted method (backport #58976) (#58991)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-15 18:40:22 +05:30
Afsal Syed
99afc2e6eb fix(stock): set item_name to fetch if empty in packing slip item (#58925)
(cherry picked from commit f130c64530)

# Conflicts:
#	erpnext/stock/doctype/packing_slip_item/packing_slip_item.json
2026-09-15 18:35:56 +05:30
Sudharsanan Ashok
1c576a6f1c Merge pull request #59075 from frappe/revert-58942-mergify/bp/version-15-hotfix/pr-58925
Revert "fix(stock): set item_name to fetch if empty in packing slip item (backport #58925)"
2026-09-15 18:05:44 +05:30
Sudharsanan Ashok
618864a051 Revert "fix(stock): set item_name to fetch if empty in packing slip item (backport #58925)" 2026-09-15 17:47:47 +05:30
Sudharsanan Ashok
d9ea9a5430 Merge pull request #58942 from frappe/mergify/bp/version-15-hotfix/pr-58925
fix(stock): set item_name to fetch if empty in packing slip item (backport #58925)
2026-09-15 17:42:52 +05:30
Afsal Syed
9ba2159720 fix(stock): set item_name to fetch if empty in packing slip item (#58925)
(cherry picked from commit f130c64530)

# Conflicts:
#	erpnext/stock/doctype/packing_slip_item/packing_slip_item.json
2026-09-15 16:38:36 +05:30
Sudharsanan Ashok
602803cce0 Merge pull request #59040 from frappe/mergify/bp/version-15-hotfix/pr-58965
fix(subcontracting): validate project across the subcontracting flow  (backport #58965)
2026-09-13 17:53:20 +05:30
Sudharsanan11
e84a94642d fix: resolve conflicts 2026-09-13 12:12:16 +05:30
Sudharsanan Ashok
1150d8e9b0 fix(subcontracting): validate project across the subcontracting flow (#58965)
(cherry picked from commit fe25746feb)

# Conflicts:
#	erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py
#	erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py
#	erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py
2026-09-13 06:29:16 +00:00
pandiyan
7429a260f6 fix: prevent disabled items from being used in bom (#58997)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
(cherry picked from commit e6f431a8d6)
2026-09-12 21:58:25 +05:30
pandiyan
7df94d87de fix(stock): calculate batch bundle valuation per unit (#58994)
(cherry picked from commit 825d24f406)
2026-09-12 21:58:24 +05:30
mergify[bot]
f9433683ec fix: Backfill not_applicable on Item Tax Template Details for German companies (backport #54682) (backport #54686) (#58952)
* fix: Backfill `not_applicable` on Item Tax Template Details for German companies (backport #54682) (#54686)

Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
fix: Backfill `not_applicable` on Item Tax Template Details for German companies (#54682)
(cherry picked from commit a22d773341)

# Conflicts:
#	erpnext/patches.txt

* chore: resolve conflict

* fix: compare zero-rate row count, not just the identifier set

* chore: correct comment on account name uniqueness

* test: cover German `not_applicable` backfill patch

Run the patch against a company seeded from the shipped German defaults:
matching defaults are backfilled, customised templates and templates with a
duplicate account name are left alone, and a rerun changes nothing.

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: barredterra <14891507+barredterra@users.noreply.github.com>
2026-09-11 13:21:39 +02:00
mergify[bot]
62b9f31297 fix(accounts): add permission checks on invoice_discounting.get_invoices (backport #58975) (#58989)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-11 04:42:54 +00:00
mergify[bot]
85a152946e fix(accounts): add permission checks on execute_repost_payment_ledger (backport #58980) (#58981)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-11 09:38:50 +05:30
mergify[bot]
563615c77e fix(stock): recalculate billing status of returned delivery notes (backport #58953) (#58986)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-11 00:56:26 +02:00
Mihir Kandoi
52ea7ed606 fix(selling): exclude fully billed orders from the invoice picker (backport #58966) (#58968) 2026-09-10 20:51:03 +05:30
mergify[bot]
f59a6ec882 fix(stock): do not rebuild the Warehouse tree on an account map miss (backport #58956) (#58972)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-10 13:09:00 +00:00
Sudharsanan Ashok
bb0140ff3f Merge pull request #58946 from frappe/mergify/bp/version-15-hotfix/pr-58927
fix(manufacturing): handle empty raw materials in workstation (backport #58927)
2026-09-10 18:08:52 +05:30
pandiyan
c72bc6925b fix(manufacturing): handle empty raw materials in workstation (#58927)
(cherry picked from commit 3be0c7801a)
2026-09-10 11:44:42 +05:30
mergify[bot]
e37ba3f8cf fix(selling): add email permission check on send_emails (backport #58935) (#58937)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-09 10:58:21 +00:00
Shllokkk
46c792f976 Merge pull request #58934 from frappe/mergify/bp/version-15-hotfix/pr-58897
fix: % Amount Billed exceeds 100% on fully rejected Purchase Receipts (backport #58897)
2026-09-09 15:33:07 +05:30
Shllokkk
c31ae577e4 fix: % Amount Billed exceeds 100% on fully rejected Purchase Receipts (#58897)
* fix: include rejected qty in Purchase Receipt billing base

* test: per billed stays 100% for fully rejected receipt

(cherry picked from commit 79c8e4db7f)
2026-09-09 09:34:29 +00:00
Frappe PR Bot
df8b7f9648 chore(release): Bumped to Version 15.121.2
## [15.121.2](https://github.com/frappe/erpnext/compare/v15.121.1...v15.121.2) (2026-09-09)

### Bug Fixes

* **stock:** preserve rates for unsaved mapped rows (backport [#58852](https://github.com/frappe/erpnext/issues/58852)) (backport [#58898](https://github.com/frappe/erpnext/issues/58898)) ([#58916](https://github.com/frappe/erpnext/issues/58916)) ([0deed27](0deed27cfe))
2026-09-09 09:24:59 +00:00
mergify[bot]
0deed27cfe fix(stock): preserve rates for unsaved mapped rows (backport #58852) (backport #58898) (#58916)
fix(stock): preserve rates for unsaved mapped rows (backport #58852) (#58898)

* fix: keep each row's own rate under maintain same rate

* test: cover maintain-same-rate rate lock for batch items

(cherry picked from commit 32d8c359ed)

Co-authored-by: Shllokkk <140623894+Shllokkk@users.noreply.github.com>
2026-09-09 14:53:20 +05:30
mergify[bot]
228108ad4c fix(accounts): stop counting received qty on non-stock invoice returns (backport #58924) (#58928)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 09:20:26 +00:00
Sudharsanan Ashok
75a5d2b766 Merge pull request #58920 from frappe/mergify/bp/version-15-hotfix/pr-58896
fix(stock): remove empty delivery stops after mapping (backport #58896)
2026-09-09 13:33:13 +05:30
Sudharsanan Ashok
27f93a83ef Merge pull request #58914 from frappe/mergify/bp/version-15-hotfix/pr-58911
fix: validate shelf life for automatically created expiring batches (backport #58911)
2026-09-09 13:24:59 +05:30
Sudharsanan Ashok
9822ffc334 Merge pull request #58912 from frappe/mergify/bp/version-15-hotfix/pr-58893
fix: reset price list fallback when changing parties (backport #58893)
2026-09-09 13:24:12 +05:30
Sudharsanan Ashok
fc6791e57b Merge pull request #58903 from frappe/mergify/bp/version-15-hotfix/pr-58889
fix(stock): guard inventory dimension lookup without reference document (backport #58889)
2026-09-09 13:23:46 +05:30
Sudharsanan Ashok
916b581562 Merge pull request #58900 from frappe/mergify/bp/version-15-hotfix/pr-58891
fix(accounts): reject disabled price lists in buying and selling (backport #58891)
2026-09-09 13:23:13 +05:30
Khushi Rawat
63e7c2e44f Merge pull request #58907 from frappe/mergify/bp/version-15-hotfix/pr-58227
fix: keep a dunning claimable until its interest is paid too  (backport #58227)
2026-09-09 13:09:48 +05:30
pandiyan
0099e7ec4f fix(stock): remove empty delivery stops after mapping (#58896)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit 4b23cee2ea)
2026-09-09 13:03:06 +05:30
pandiyan
11c6f0d02c fix: keep a dunning claimable until its interest is paid too
a dunning was resolved as soon as the invoiced sum was settled, because the
status was derived from the invoice outstanding alone. paying an invoice
without the interest and fee therefore closed the dunning and lost the
interest: a fresh dunning finds nothing overdue to charge it on.

the dunning amount is never a receivable, it only reaches the ledger as a
negative deduction on a payment entry made from the dunning. link that row
to the dunning so what has been collected is known, and resolve a dunning
only once the invoiced sum and the dunning amount are both paid. a dunning
resolved by hand keeps its status, so waiving the interest stays possible.

the deduction is a company currency field, so book and measure the dunning
amount through base_dunning_amount instead of the transaction currency one.

an interest-only payment leaves every invoice outstanding untouched, so
update the linked dunnings from the payment entry itself instead of relying
on the outstanding amount to change. such a payment also has to be built
from what is left to collect, not from the totals the dunning was raised
with, which are stale by then.

(cherry picked from commit d5a9d158f9)
2026-09-09 12:39:18 +05:30
pandiyan
da53283659 test: dunning must stay unpaid when only the invoiced sum is paid
paying a dunned invoice without its interest and fee marks the dunning
resolved, and the interest can no longer be collected.

(cherry picked from commit fa89552d10)
2026-09-09 12:39:18 +05:30
Shllokkk
32d8c359ed fix(stock): preserve rates for unsaved mapped rows (backport #58852) (#58898)
* fix: keep each row's own rate under maintain same rate

* test: cover maintain-same-rate rate lock for batch items
2026-09-09 12:38:58 +05:30
Pandiyan P
00e02f07ac fix: validate shelf life for automatically created expiring batches (#58911)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
(cherry picked from commit b2bdeaa672)
2026-09-09 07:07:22 +00:00
Pandiyan P
f981791012 fix: reset price list fallback when changing parties (#58893)
(cherry picked from commit 4c32acf300)
2026-09-09 06:56:26 +00:00
Sudharsanan Ashok
f6870786c5 Merge pull request #58888 from frappe/mergify/bp/version-15-hotfix/pr-58887
fix(stock): distribute additional costs when incoming items have no value (backport #58842) (backport #58887)
2026-09-09 11:14:21 +05:30
Sudharsanan11
00729a8d62 fix: resolve conflicts 2026-09-09 10:38:34 +05:30
Sudharsanan11
10987b11cc fix(stock): distribute additional costs when incoming items have no value
(cherry picked from commit b6035810f0)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/test_stock_entry.py
2026-09-09 10:38:34 +05:30
pandiyan
47662b9af2 fix(accounts): reject disabled price lists in buying and selling (#58891)
(cherry picked from commit f8c2f3440b)
2026-09-09 09:55:37 +05:30
mergify[bot]
3bd194e986 fix: user not able to set valuation rate zero in stock reco (backport #58800) (#58821)
* fix: user not able to set valuation rate zero in stock reco (#58800)

* fix: user not able to set valuation rate zero in stock reco

* fix: wrong difference amount when valuation rate is zero

* fix: blank valuation rate should not be treated as a change

(cherry picked from commit e85e300f8f)

# Conflicts:
#	erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py

* chore: fix conflicts

Removed unused import statements and cleaned up the code.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-09 04:20:51 +00:00
Pandiyan P
7ee54ba08b fix(stock): guard inventory dimension lookup without reference document (#58889)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit afd93cf867)
2026-09-09 02:12:59 +00:00
Sudharsanan Ashok
b6120ea56a Merge pull request #58882 from frappe/mergify/bp/version-15-hotfix/pr-58700
fix(stock): show actual outgoing rate in ledger preview (backport #58700)
2026-09-08 19:32:09 +05:30
Frappe PR Bot
fdc48277a8 chore(release): Bumped to Version 15.121.1
## [15.121.1](https://github.com/frappe/erpnext/compare/v15.121.0...v15.121.1) (2026-09-08)

### Bug Fixes

* **accounts:** reject same-account internal transfers ([#58529](https://github.com/frappe/erpnext/issues/58529)) ([05a1127](05a1127587))
* **accounts:** round journal entry totals to field precision (backport [#58629](https://github.com/frappe/erpnext/issues/58629)) ([#58874](https://github.com/frappe/erpnext/issues/58874)) ([96357b4](96357b4fdc))
* **accounts:** support drop-shipped items in Gross Profit report ([#58686](https://github.com/frappe/erpnext/issues/58686)) ([6c6ee7c](6c6ee7c524))
* add reconciliation after submit logic for bank transactions ([#57330](https://github.com/frappe/erpnext/issues/57330)) ([2ee5f6b](2ee5f6b1c4))
* **batch:** show Expired status only after expiry date has passed ([#58736](https://github.com/frappe/erpnext/issues/58736)) ([cd93007](cd93007780))
* check material request price list permission ([#58740](https://github.com/frappe/erpnext/issues/58740)) ([a348d41](a348d413d0))
* check write permission in whitelisted document methods (backport [#58689](https://github.com/frappe/erpnext/issues/58689)) ([#58702](https://github.com/frappe/erpnext/issues/58702)) ([baa30e7](baa30e703d))
* **crm:** check read permission on lead in add_lead_to_prospect ([4dd56ec](4dd56ec4e6))
* **crm:** validate contact email before saving an email campaign (backport [#58667](https://github.com/frappe/erpnext/issues/58667)) ([#58671](https://github.com/frappe/erpnext/issues/58671)) ([1ea597d](1ea597d6b5))
* fetch accounts by company (backport [#58523](https://github.com/frappe/erpnext/issues/58523)) ([#58878](https://github.com/frappe/erpnext/issues/58878)) ([2c2a3e9](2c2a3e9f10))
* filter cancelled BOMs in BOM Stock Analysis ([#58647](https://github.com/frappe/erpnext/issues/58647)) ([fff2616](fff261680c))
* **gross-profit:** include item_name in export ([#58631](https://github.com/frappe/erpnext/issues/58631)) ([c752bb3](c752bb34a8))
* include payment deductions in sales/purchase register ledger bal… (backport [#58437](https://github.com/frappe/erpnext/issues/58437)) ([#58679](https://github.com/frappe/erpnext/issues/58679)) ([24e7ff5](24e7ff55de))
* persist rendered terms on transaction save ([#58858](https://github.com/frappe/erpnext/issues/58858)) ([24cac30](24cac30aa9))
* **pos:** use company-currency change amount when netting pos gl entries (backport [#58599](https://github.com/frappe/erpnext/issues/58599)) ([#58677](https://github.com/frappe/erpnext/issues/58677)) ([c7d865e](c7d865ef3b))
* resolve conflicts ([95b2030](95b203079a))
* **selling:** fetch orders within billing allowance (backport [#58751](https://github.com/frappe/erpnext/issues/58751)) ([#58822](https://github.com/frappe/erpnext/issues/58822)) ([a64b78d](a64b78d283))
* **setup:** strict permissions for transaction deletion record (backport [#58687](https://github.com/frappe/erpnext/issues/58687)) ([#58722](https://github.com/frappe/erpnext/issues/58722)) ([934c7d8](934c7d833c))
* show cost center for unmatched payments in receivables (backport [#58453](https://github.com/frappe/erpnext/issues/58453)) ([#58880](https://github.com/frappe/erpnext/issues/58880)) ([69f38a0](69f38a04c8))
* skip zero quantity items in production plan material requests ([#58642](https://github.com/frappe/erpnext/issues/58642)) ([3217a69](3217a69fc2))
* **stock:** recalculate delivery note billing after return ([#58869](https://github.com/frappe/erpnext/issues/58869)) ([b409ded](b409ded2f6))

### Reverts

* **manufacturing:** remove material coverage changes on version 15 ([#58715](https://github.com/frappe/erpnext/issues/58715)) ([7cbf8b8](7cbf8b8660))
2026-09-08 13:48:11 +00:00
Diptanil Saha
8635652c90 Merge pull request #58867 from frappe/version-15-hotfix
chore: release v15
2026-09-08 19:16:20 +05:30
Afsal Syed
372244e6b2 fix(stock): show actual outgoing rate in ledger preview (#58700)
(cherry picked from commit 71f1edeb1c)
2026-09-08 19:11:06 +05:30
mergify[bot]
c7d865ef3b fix(pos): use company-currency change amount when netting pos gl entries (backport #58599) (#58677)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-09-08 13:23:45 +00:00
mergify[bot]
2c2a3e9f10 fix: fetch accounts by company (backport #58523) (#58878)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-09-08 18:18:36 +05:30
mergify[bot]
96357b4fdc fix(accounts): round journal entry totals to field precision (backport #58629) (#58874)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-09-08 12:47:54 +00:00
Sudharsanan Ashok
13367a5c54 Merge pull request #58872 from frappe/mergify/bp/version-15-hotfix/pr-58869
fix(stock): recalculate delivery note billing after return (backport #58869)
2026-09-08 18:10:08 +05:30
Sudharsanan Ashok
28918c8338 Merge pull request #58876 from frappe/mergify/bp/version-15-hotfix/pr-58529
fix(accounts): reject same-account internal transfers (backport #58529)
2026-09-08 18:09:11 +05:30
Sudharsanan Ashok
341089ce35 Merge pull request #58863 from frappe/mergify/bp/version-15-hotfix/pr-58631
fix(gross-profit): include item_name in export (backport #58631)
2026-09-08 18:05:08 +05:30
Sudharsanan Ashok
a8c6cec5a3 Merge pull request #58861 from frappe/mergify/bp/version-15-hotfix/pr-58858
fix: persist rendered terms on transaction save (backport #58858)
2026-09-08 18:04:24 +05:30
Sudharsanan Ashok
89b29eee94 Merge pull request #58762 from frappe/mergify/bp/version-15-hotfix/pr-57330
fix: add reconciliation after submit logic for bank transactions (backport #57330)
2026-09-08 18:02:38 +05:30
Pandiyan P
b409ded2f6 fix(stock): recalculate delivery note billing after return (#58869)
(cherry picked from commit f864333afa)

# Conflicts:
#	erpnext/stock/doctype/delivery_note/services/billing_status.py
2026-09-08 17:38:48 +05:30
mergify[bot]
69f38a04c8 fix: show cost center for unmatched payments in receivables (backport #58453) (#58880)
Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
2026-09-08 17:33:31 +05:30
Pandiyan P
05a1127587 fix(accounts): reject same-account internal transfers (#58529)
(cherry picked from commit 36a4dfe797)

# Conflicts:
#	erpnext/accounts/doctype/payment_entry/test_payment_entry.py
2026-09-08 16:51:53 +05:30
Sudharsanan Ashok
940fda3327 Merge pull request #58859 from frappe/mergify/bp/version-15-hotfix/pr-58841
fix: skip zero quantity items in production plan material requests (backport #58841)
2026-09-08 15:59:18 +05:30
Sudharsanan11
95b203079a fix: resolve conflicts 2026-09-08 15:21:30 +05:30
Vishnu Priya Baskaran
c752bb34a8 fix(gross-profit): include item_name in export (#58631)
(cherry picked from commit 467f54162f)
2026-09-08 09:13:58 +00:00
Pandiyan P
24cac30aa9 fix: persist rendered terms on transaction save (#58858)
(cherry picked from commit cadc0ca86d)
2026-09-08 07:58:53 +00:00
Mihir Kandoi
a64b78d283 fix(selling): fetch orders within billing allowance (backport #58751) (#58822) 2026-09-08 09:16:56 +05:30
Mihir Kandoi
4a3a2cbdc4 test(stock): use existing warehouse fixtures in Pick List tests (v15) (#58840) 2026-09-07 15:22:12 +00:00
Mihir Kandoi
6c6ee7c524 fix(accounts): support drop-shipped items in Gross Profit report (#58686)
* fix(reports): apportion drop-ship buying cost and fix return-matching bug in Gross Profit report

* test(accounts): fix Gross Profit test setup

---------

Co-authored-by: ervishnucs <ervishnucs369@gmail.com>
2026-09-07 15:52:57 +05:30
rohitwaghchaure
baa30e703d fix: check write permission in whitelisted document methods (backport #58689) (#58702)
* fix: check write permission in whitelisted document methods

* test: permission coverage for production plan status roll-ups

* fix: add type hints to whitelisted arguments and submit MR in test

* test: clear request cache before reading non-completed production plans
2026-09-07 15:20:22 +05:30
Mihir Kandoi
684def8818 test(manufacturing): refresh cached production plans before assertion (#58801) 2026-09-07 09:13:45 +00:00
Vishnu Priya Baskaran
2ee5f6b1c4 fix: add reconciliation after submit logic for bank transactions (#57330)
Co-authored-by: Poovetha <poovethapalanivelu@gmail.com>
(cherry picked from commit c3319d74cf)
2026-09-04 11:35:56 +00:00
Mihir Kandoi
7cbf8b8660 revert(manufacturing): remove material coverage changes on version 15 (#58715) 2026-09-04 14:32:57 +05:30
Sudharsanan Ashok
0611a6a12b Merge pull request #58753 from frappe/mergify/bp/version-15-hotfix/pr-58740
fix: check material request price list permission (backport #58740)
2026-09-04 13:07:44 +05:30
Pandiyan P
a348d413d0 fix: check material request price list permission (#58740)
(cherry picked from commit 0b1f1d6851)
2026-09-04 07:33:07 +00:00
Sudharsanan Ashok
452db26ccb Merge pull request #58748 from frappe/mergify/bp/version-15-hotfix/pr-58736
fix(batch): show Expired status only after expiry date has passed (backport #58736)
2026-09-04 13:00:37 +05:30
mergify[bot]
934c7d833c fix(setup): strict permissions for transaction deletion record (backport #58687) (#58722)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-04 06:53:44 +00:00
Pandiyan P
cd93007780 fix(batch): show Expired status only after expiry date has passed (#58736)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
(cherry picked from commit 00f04fc084)
2026-09-04 06:51:10 +00:00
mergify[bot]
24e7ff55de fix: include payment deductions in sales/purchase register ledger bal… (backport #58437) (#58679)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-09-02 17:01:20 +05:30
Shllokkk
5f647f0702 Merge pull request #58690 from frappe/mergify/bp/version-15-hotfix/pr-58684
fix(crm): check read permission on lead in add_lead_to_prospect (backport #58684)
2026-09-02 16:01:56 +05:30
Krishna Pramod Shirsath
7ab4092561 Merge pull request #58695 from frappe/mergify/bp/version-15-hotfix/pr-58647
fix: filter cancelled BOMs in BOM Stock Analysis (backport #58647)
2026-09-02 15:31:03 +05:30
Krishna Pramod Shirsath
fff261680c fix: filter cancelled BOMs in BOM Stock Analysis (#58647)
(cherry picked from commit a2071a6fdd)

# Conflicts:
#	erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js
2026-09-02 09:59:16 +00:00
mergify[bot]
1ea597d6b5 fix(crm): validate contact email before saving an email campaign (backport #58667) (#58671)
Co-authored-by: kaulith <64089478+kaulith@users.noreply.github.com>
2026-09-02 15:12:57 +05:30
Shllokkk
4dd56ec4e6 fix(crm): check read permission on lead in add_lead_to_prospect
(cherry picked from commit 02fcdc0337)

# Conflicts:
#	erpnext/crm/doctype/lead/lead.py
2026-09-02 15:01:41 +05:30
Krishna Pramod Shirsath
3217a69fc2 fix: skip zero quantity items in production plan material requests (#58642) 2026-09-02 13:32:41 +05:30
Frappe PR Bot
1a0bf0bf6c chore(release): Bumped to Version 15.121.0
# [15.121.0](https://github.com/frappe/erpnext/compare/v15.120.0...v15.121.0) (2026-09-02)

### Bug Fixes

* **accounts:** set pos profile on invoices respecting user permissions (backport [#58508](https://github.com/frappe/erpnext/issues/58508)) ([#58518](https://github.com/frappe/erpnext/issues/58518)) ([1187fb8](1187fb8e01))
* **accounts:** validate frozen accounts in period closing voucher ([#58477](https://github.com/frappe/erpnext/issues/58477)) ([045d708](045d7086d1))
* add missing perm checks on various whitelisted methods (backport [#58652](https://github.com/frappe/erpnext/issues/58652)) ([#58653](https://github.com/frappe/erpnext/issues/58653)) ([547d82d](547d82da56))
* add type hints to make_request_for_quotation ([567730e](567730e38e))
* **bank_statement_import:** add missing permission check on multiple whitelisted methods (backport [#58221](https://github.com/frappe/erpnext/issues/58221)) ([#58223](https://github.com/frappe/erpnext/issues/58223)) ([fa903f4](fa903f40fd))
* clarify duplicate internal party messages ([#58469](https://github.com/frappe/erpnext/issues/58469)) ([17208e7](17208e7434))
* **company:** throw if linked to demo_company field ([de260bd](de260bd43c))
* **crm:** add missing permission validation on `get_contract_template` (backport [#58621](https://github.com/frappe/erpnext/issues/58621)) ([#58622](https://github.com/frappe/erpnext/issues/58622)) ([d1b615b](d1b615b851))
* **crm:** check write permission in edit_note ([2b2a005](2b2a005682))
* do not map the same row twice in "Get Items From" (backport [#58617](https://github.com/frappe/erpnext/issues/58617)) ([#58618](https://github.com/frappe/erpnext/issues/58618)) ([28175a5](28175a5c9d))
* filter fully ordered items when creating RFQ from Material Request ([08ec311](08ec31174c))
* keep Currency and Price List section open for foreign currency (backport [#58074](https://github.com/frappe/erpnext/issues/58074)) ([#58112](https://github.com/frappe/erpnext/issues/58112)) ([f5ce20f](f5ce20f122))
* keep source rate on re-fetch when maintain same rate is enabled (backport [#57479](https://github.com/frappe/erpnext/issues/57479)) ([#58332](https://github.com/frappe/erpnext/issues/58332)) ([228ab2d](228ab2d97e)), closes [frappe/erpnext#57436](https://github.com/frappe/erpnext/issues/57436)
* **manufacturing:** preserve job card qty in mr transfer (backport [#58482](https://github.com/frappe/erpnext/issues/58482)) ([#58498](https://github.com/frappe/erpnext/issues/58498)) ([099a5a7](099a5a71d9))
* **manufacturing:** use packed row delivery date ([#58569](https://github.com/frappe/erpnext/issues/58569)) ([9c19b02](9c19b024db))
* mark item tax templates as not applicable (backport [#54673](https://github.com/frappe/erpnext/issues/54673)) (backport [#54677](https://github.com/frappe/erpnext/issues/54677)) ([#58462](https://github.com/frappe/erpnext/issues/58462)) ([d62f6e7](d62f6e715f))
* persist redistributed additional costs during stock entry repost (backport [#58433](https://github.com/frappe/erpnext/issues/58433)) ([#58532](https://github.com/frappe/erpnext/issues/58532)) ([c4e7cfa](c4e7cfa6a0))
* **projects:** check read permission on source in create_duplicate_project ([af8c010](af8c010aa8))
* **projects:** ignore cancelled invoices in timesheet portal (backport [#58501](https://github.com/frappe/erpnext/issues/58501)) ([#58503](https://github.com/frappe/erpnext/issues/58503)) ([5a51895](5a518951e4))
* reset hardcoded letter head on Incorrect Serial and Batch Bundle report ([ccdaa92](ccdaa92de8))
* resolve analytics backport conflicts ([605918d](605918d3a2))
* resolve conflicts ([c6717fb](c6717fb758))
* resolve stock entry backport conflicts ([e126c45](e126c4597b))
* round party balances to currency precision in Trial Balance for Party ([b98d193](b98d193b77))
* **selling:** check quotation write permission before marking lost ([#58493](https://github.com/frappe/erpnext/issues/58493)) ([33ebc7d](33ebc7d7e3))
* **selling:** check sales order permission before work order creation ([#58492](https://github.com/frappe/erpnext/issues/58492)) ([0609b06](0609b06564))
* **sms_settings:** add patch to pre-fill roles into SMS Settings Roles Table ([0cef90d](0cef90d079))
* **stock:** keep pick list links when refetching stock entry items ([22377fa](22377fa86d))
* **stock:** load available serial no report (backport [#58558](https://github.com/frappe/erpnext/issues/58558)) ([#58561](https://github.com/frappe/erpnext/issues/58561)) ([2d46bb9](2d46bb962c))
* **stock:** preserve exchange gain loss journals in lcv ([#58505](https://github.com/frappe/erpnext/issues/58505)) ([c6fedf4](c6fedf4aca))
* **stock:** reset bin when a repost finds no stock ledger entries  ([#58434](https://github.com/frappe/erpnext/issues/58434)) ([b00062d](b00062df86)), closes [#58362](https://github.com/frappe/erpnext/issues/58362)
* **stock:** validate serial inventory dimensions ([#58394](https://github.com/frappe/erpnext/issues/58394)) ([bc071d7](bc071d7793))
* sum item and its alternate transferred qty on work order required items ([5851bb3](5851bb3b6e))
* **taxes:** skip tax addition for invoice created from opening invoice tool ([7090c6f](7090c6fdab))
* **timesheet:** scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport [#58267](https://github.com/frappe/erpnext/issues/58267)) ([#58576](https://github.com/frappe/erpnext/issues/58576)) ([c656497](c656497aac))
* translate doctype in map msg (backport [#58515](https://github.com/frappe/erpnext/issues/58515)) ([#58516](https://github.com/frappe/erpnext/issues/58516)) ([d87a4b2](d87a4b2541))
* translate label in party validation (backport [#58624](https://github.com/frappe/erpnext/issues/58624)) ([#58625](https://github.com/frappe/erpnext/issues/58625)) ([307ffcd](307ffcd5c3))
* validate items against source Sales Order in Material Request ([32d44c6](32d44c6e91))
* validate POS Settings invoice and search fields on the server (backport [#58611](https://github.com/frappe/erpnext/issues/58611)) ([#58612](https://github.com/frappe/erpnext/issues/58612)) ([c5bc5f9](c5bc5f9c31))
* Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows ([#58527](https://github.com/frappe/erpnext/issues/58527)) ([7454735](7454735595))

### Features

* **analytics:** filter sales and purchase analytics by entity ([#58402](https://github.com/frappe/erpnext/issues/58402)) ([0dddd00](0dddd00073))
2026-09-02 07:16:53 +00:00
Diptanil Saha
57266ab474 Merge pull request #58646 from frappe/version-15-hotfix
chore: release v15
2026-09-02 12:45:04 +05:30
Shllokkk
865b207340 Merge pull request #58676 from frappe/mergify/bp/version-15-hotfix/pr-58535
fix: filter fully ordered items when creating RFQ from Material Request (backport #58535)
2026-09-02 12:19:57 +05:30
Sudharsanan Ashok
05199aa3bb Merge pull request #58674 from frappe/mergify/bp/version-15-hotfix/pr-58505
fix(stock): preserve exchange gain loss journals in lcv (backport #58505)
2026-09-02 12:13:02 +05:30
Shllokkk
4a4e1908e3 chore: resolve backport conflict 2026-09-02 11:50:21 +05:30
Shllokkk
b439deda88 test: set schedule date on PO before insert
(cherry picked from commit 5af60cf2e3)
2026-09-02 06:16:30 +00:00
Shllokkk
567730e38e fix: add type hints to make_request_for_quotation
(cherry picked from commit 4435f166ea)
2026-09-02 06:16:30 +00:00
Shllokkk
3d563a2e08 test: RFQ from Material Request skips fully ordered items
(cherry picked from commit 5adcb28b3c)

# Conflicts:
#	erpnext/stock/doctype/material_request/test_material_request.py
2026-09-02 06:16:29 +00:00
Shllokkk
08ec31174c fix: filter fully ordered items when creating RFQ from Material Request
(cherry picked from commit 9b1d907b6a)
2026-09-02 06:16:29 +00:00
Pandiyan P
c6fedf4aca fix(stock): preserve exchange gain loss journals in lcv (#58505)
(cherry picked from commit 2b54a581f8)
2026-09-02 06:14:58 +00:00
mergify[bot]
547d82da56 fix: add missing perm checks on various whitelisted methods (backport #58652) (#58653)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-01 14:16:38 +00:00
Shllokkk
d704dc474e Merge pull request #58648 from frappe/mergify/bp/version-15-hotfix/pr-58607
fix: round party balances to currency precision in Trial Balance for Party (backport #58607)
2026-09-01 16:42:52 +05:30
Aarol D'Souza
46a99ec216 Merge pull request #58636 from frappe/mergify/bp/version-15-hotfix/pr-58586
fix(sms_settings): add patch to pre-fill roles into SMS Settings Role Table (backport #58586)
2026-09-01 15:46:14 +05:30
Shllokkk
b98d193b77 fix: round party balances to currency precision in Trial Balance for Party
(cherry picked from commit b1c7657dfa)
2026-09-01 09:49:32 +00:00
Diptanil Saha
5289982059 fix(accounts)!: add permission checks on payment entry whitelisted methods (#58643) 2026-09-01 15:17:57 +05:30
AarDG10
2f2a9301d8 chore: resolve conflicts 2026-09-01 12:55:27 +05:30
AarDG10
0cef90d079 fix(sms_settings): add patch to pre-fill roles into SMS Settings Roles Table
(cherry picked from commit 3501beb2bd)

# Conflicts:
#	erpnext/patches.txt
2026-09-01 07:18:22 +00:00
mergify[bot]
307ffcd5c3 fix: translate label in party validation (backport #58624) (#58625)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-01 00:54:08 +02:00
mergify[bot]
d1b615b851 fix(crm): add missing permission validation on get_contract_template (backport #58621) (#58622)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 19:49:07 +00:00
mergify[bot]
fa903f40fd fix(bank_statement_import): add missing permission check on multiple whitelisted methods (backport #58221) (#58223)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 19:39:12 +00:00
Diptanil Saha
c5bc5f9c31 fix: validate POS Settings invoice and search fields on the server (backport #58611) (#58612) 2026-08-31 18:39:27 +00:00
mergify[bot]
28175a5c9d fix: do not map the same row twice in "Get Items From" (backport #58617) (#58618)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-31 19:00:31 +02:00
Lakshit Jain
4cff4b7cec Merge pull request #58601 from frappe/mergify/bp/version-15-hotfix/pr-58483
fix(taxes): skip tax addition for invoice created from opening invoice tool (backport #58483)
2026-08-31 18:39:01 +05:30
ljain112
4ad0180dff chore: resolve conflicts 2026-08-31 17:20:46 +05:30
ljain112
eed5969c28 chore: resolve conflicts 2026-08-31 17:08:48 +05:30
ljain112
ae01c21614 chore: resolve conflicts 2026-08-31 16:56:39 +05:30
ljain112
7090c6fdab fix(taxes): skip tax addition for invoice created from opening invoice tool
(cherry picked from commit e08a166281)

# Conflicts:
#	erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
#	erpnext/accounts/services/taxes.py
2026-08-31 09:35:34 +00:00
Nishka Gosalia
0f84b25964 Merge pull request #58593 from frappe/mergify/bp/version-15-hotfix/pr-58325
fix(projects): check read permission on source in create_duplicate_project (backport #58325)
2026-08-31 14:31:04 +05:30
Shllokkk
0ea03a688b chore(projects): add type hints to create_duplicate_project 2026-08-31 14:04:32 +05:30
Shllokkk
af8c010aa8 fix(projects): check read permission on source in create_duplicate_project
(cherry picked from commit 8c8b282a2e)
2026-08-31 07:11:10 +00:00
mergify[bot]
c656497aac fix(timesheet): scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport #58267) (#58576)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-30 18:36:29 +00:00
Mihir Kandoi
9c19b024db fix(manufacturing): use packed row delivery date (#58569) 2026-08-30 11:14:14 +00:00
Henil Maru
7454735595 fix: Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows (#58527) 2026-08-30 15:56:38 +05:30
mergify[bot]
2d46bb962c fix(stock): load available serial no report (backport #58558) (#58561)
fix(stock): load available serial no report (#58558)

(cherry picked from commit 8b43394533)

Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-29 14:56:06 +00:00
mergify[bot]
6a632d6d1c refactor(stock): remove dead warehouse_condition branch on get_stock_ledger_entries (backport #58552) (#58553)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-29 17:55:45 +05:30
Shllokkk
e19686053f Merge pull request #58543 from frappe/mergify/bp/version-15-hotfix/pr-58324
fix(crm): check write permission in edit_note (backport #58324)
2026-08-29 13:28:02 +05:30
Shllokkk
f0ac82605c chore: resolve merge conflicts 2026-08-29 13:08:42 +05:30
Shllokkk
2b2a005682 fix(crm): check write permission in edit_note
(cherry picked from commit eb49f51d29)

# Conflicts:
#	erpnext/crm/utils.py
2026-08-28 15:05:16 +00:00
mergify[bot]
c4e7cfa6a0 fix: persist redistributed additional costs during stock entry repost (backport #58433) (#58532)
* fix: persist redistributed additional costs during stock entry repost (#58433)

* fix: persist redistributed additional costs during stock entry repost

* test: cover additional cost persistence on stock entry recalculation

(cherry picked from commit 074c84e880)

# Conflicts:
#	erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py

* chore: fix conflicts

Removed unused import 'flt' from test file.

---------

Co-authored-by: Afsal Syed <146159709+Afsalsyed@users.noreply.github.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-28 10:42:27 +00:00
Sudharsanan Ashok
1aa7528119 Merge pull request #58494 from frappe/mergify/bp/version-15-hotfix/pr-58493
fix(selling): check quotation write permission before marking lost (backport #58493)
2026-08-28 13:29:46 +05:30
Sudharsanan Ashok
1a92047887 Merge pull request #58496 from frappe/mergify/bp/version-15-hotfix/pr-58492
fix(selling): check sales order permission before work order creation (backport #58492)
2026-08-28 13:15:00 +05:30
pandiyan
33ebc7d7e3 fix(selling): check quotation write permission before marking lost (#58493)
(cherry picked from commit 5f99a3418d)

# Conflicts:
#	erpnext/selling/doctype/quotation/quotation.py
2026-08-28 13:04:34 +05:30
pandiyan
0609b06564 fix(selling): check sales order permission before work order creation (#58492)
(cherry picked from commit a8ba713f80)

# Conflicts:
#	erpnext/selling/doctype/sales_order/mapper.py
2026-08-28 12:55:57 +05:30
Shllokkk
b8163e271d Merge pull request #58500 from Shllokkk/work-order-transferred-qty-sum-alternate-items
fix: sum item and its alternate transferred qty on work order
2026-08-28 12:47:53 +05:30
Aarol D'Souza
44cb0c5141 Merge pull request #58426 from frappe/mergify/bp/version-15-hotfix/pr-58351
fix(company): throw if linked to demo_company field (backport #58351)
2026-08-28 12:14:29 +05:30
mergify[bot]
1187fb8e01 fix(accounts): set pos profile on invoices respecting user permissions (backport #58508) (#58518)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-28 06:42:02 +00:00
Aarol D'Souza
be0fc95758 Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-58351 2026-08-28 11:48:39 +05:30
AarDG10
3edd8ee8be refactor: remove bold from formatter 2026-08-28 11:48:07 +05:30
Sudharsanan Ashok
d5318fbabc Merge pull request #58522 from frappe/mergify/bp/version-15-hotfix/pr-58521
Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)" (backport #58521)
2026-08-27 23:10:06 +05:30
mergify[bot]
d87a4b2541 fix: translate doctype in map msg (backport #58515) (#58516)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-27 19:26:19 +02:00
Sudharsanan11
c6717fb758 fix: resolve conflicts 2026-08-27 22:51:14 +05:30
Sudharsanan Ashok
0e7be81f86 Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)"
(cherry picked from commit 8287906a71)

# Conflicts:
#	erpnext/stock/stock_ledger.py
2026-08-27 17:16:30 +00:00
mergify[bot]
5a518951e4 fix(projects): ignore cancelled invoices in timesheet portal (backport #58501) (#58503)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-27 13:24:40 +00:00
Mihir Kandoi
099a5a71d9 fix(manufacturing): preserve job card qty in mr transfer (backport #58482) (#58498) 2026-08-27 12:39:48 +00:00
Shllokkk
70dff35736 test: transferred qty sums item and its alternate 2026-08-27 17:54:07 +05:30
Shllokkk
5851bb3b6e fix: sum item and its alternate transferred qty on work order required items 2026-08-27 17:47:05 +05:30
Sudharsanan Ashok
b00062df86 fix(stock): reset bin when a repost finds no stock ledger entries (#58434)
* fix(stock): reset bin when a repost finds no stock ledger entries

`build()` wipes `prev_sle_dict` in `initialize_reposting()` before `update_bin()`
runs, so a repost over an item and warehouse whose ledger is empty writes no bin
at all. `actual_qty`, `stock_value` and `valuation_rate` keep their last values,
bin totals drift from the stock balance, and reposting again cannot heal it.

Reset those bins as a terminal step in `update_bin()`, guarded by a re-check that
no live SLE exists so ordinary valuation is untouched.

Cancel is already correct on this branch, since `initialize_previous_data()` seeds
zeros when there is no previous SLE and the sle_id path never calls
`initialize_reposting()`. That seeding stays; this is a repost-path fix only.
develop drops it in #58362 because the `cancelled` guard makes it dead code there.

* test(stock): cover bin reset when the stock ledger is empty
2026-08-27 16:23:10 +05:30
Sudharsanan Ashok
2ce0bb1009 Merge pull request #58474 from frappe/mergify/bp/version-15-hotfix/pr-58469
fix: clarify duplicate internal party messages (backport #58469)
2026-08-27 16:13:40 +05:30
Sudharsanan Ashok
25ec044c09 Merge pull request #58428 from frappe/mergify/bp/version-15-hotfix/pr-58402
feat(analytics): filter sales and purchase analytics by entity  (backport #58402)
2026-08-27 16:12:13 +05:30
Aarol D'Souza
81cf874fd7 Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-58351 2026-08-27 15:42:27 +05:30
mergify[bot]
d62f6e715f fix: mark item tax templates as not applicable (backport #54673) (backport #54677) (#58462)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-27 09:46:30 +00:00
Pandiyan P
045d7086d1 fix(accounts): validate frozen accounts in period closing voucher (#58477) 2026-08-27 08:46:01 +00:00
Shllokkk
6f95c1ff92 Merge pull request #58473 from frappe/mergify/bp/version-15-hotfix/pr-58470
fix: validate items against source Sales Order in Material Request (backport #58470)
2026-08-27 14:04:44 +05:30
pandiyan
17208e7434 fix: clarify duplicate internal party messages (#58469)
(cherry picked from commit 4d4cf034b5)

# Conflicts:
#	erpnext/selling/doctype/customer/customer.py
2026-08-27 13:53:43 +05:30
Shllokkk
59a9819dae test: block item change on Sales Order-sourced Material Request row
(cherry picked from commit 07f8250e40)

# Conflicts:
#	erpnext/stock/doctype/material_request/test_material_request.py
2026-08-27 13:17:29 +05:30
Shllokkk
32d44c6e91 fix: validate items against source Sales Order in Material Request
(cherry picked from commit 65466464df)
2026-08-27 07:36:46 +00:00
Sudharsanan11
605918d3a2 fix: resolve analytics backport conflicts 2026-08-26 16:10:56 +05:30
Sudharsanan Ashok
0dddd00073 feat(analytics): filter sales and purchase analytics by entity (#58402)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit 3f29cdf8d2)

# Conflicts:
#	erpnext/buying/report/purchase_analytics/purchase_analytics.js
#	erpnext/buying/report/purchase_analytics/test_purchase_analytics.py
#	erpnext/selling/report/sales_analytics/sales_analytics.py
#	erpnext/selling/report/sales_analytics/test_sales_analytics.py
2026-08-26 16:10:56 +05:30
Shllokkk
3bf19df761 Merge pull request #58440 from Shllokkk/incorrect-serial-batch-bundle-letter-head
fix: reset hardcoded letter head on Incorrect Serial and Batch Bundle report
2026-08-26 15:49:34 +05:30
Shllokkk
ccdaa92de8 fix: reset hardcoded letter head on Incorrect Serial and Batch Bundle report 2026-08-26 14:50:32 +05:30
Sudharsanan Ashok
6d59065d79 Merge pull request #58424 from frappe/mergify/bp/version-15-hotfix/pr-58405
fix(stock): keep pick list links when refetching stock entry items (backport #58374) (backport #58405)
2026-08-26 13:40:13 +05:30
Jatin3128
228ab2d97e fix: keep source rate on re-fetch when maintain same rate is enabled (backport #57479) (#58332)
* fix: keep source rate on re-fetch when maintain same rate is enabled (backport #57479)

With "maintain same rate" on, re-fetching item details on a row mapped from a
source document (e.g. a Purchase Order) pulled the latest Item Price, giving a
rate the document can never be saved with. Skip the price list fetch for such
rows and keep the source rate, both for a single-row re-fetch and the bulk
apply_price_list path (price list / party / conversion rate change).

The rate is read from the linked source row in the database (not the mutable
target row) and permission-checked against the source document, so an unsaved
edit can't lock in a different rate and a crafted request can't disclose
another document's pricing.

Fixes frappe/erpnext#57436

* fix: resolve linter findings in get_item_details

Add missing type hints on the whitelisted get_item_details
signature and rename maintain_same_rate_enabled's sole "args"
parameter, both flagged by the semgrep security/code-quality
rules. Also drops an extra blank line that ruff-format rejected.

* fix: widen get_item_details doc type hint to include Document

accounts_controller.py calls get_item_details(args, self, ...)
during validate, passing the transaction Document itself, not
a dict/JSON string. The narrower hint tripped Frappe's runtime
argument type validation on every whitelisted call with a live
Document, failing test-record creation across the suite.
2026-08-26 13:14:25 +05:30
Sudharsanan11
e126c4597b fix: resolve stock entry backport conflicts 2026-08-26 13:09:08 +05:30
Sudharsanan11
22377fa86d fix(stock): keep pick list links when refetching stock entry items
(cherry picked from commit 01e0844d40)

# Conflicts:
#	erpnext/stock/doctype/stock_entry/stock_entry.js
2026-08-26 13:09:08 +05:30
AarDG10
de260bd43c fix(company): throw if linked to demo_company field
Throws an error msg if user is deleting demo company directly.

(cherry picked from commit aaa3100efc)
2026-08-26 07:24:05 +00:00
Sudharsanan Ashok
9e082a96f7 Merge pull request #58418 from frappe/mergify/bp/version-15-hotfix/pr-58394
fix(stock): validate serial inventory dimensions (backport #58394)
2026-08-26 12:53:35 +05:30
Pandiyan P
bc071d7793 fix(stock): validate serial inventory dimensions (#58394)
* fix(stock): validate serial inventory dimensions

* test(stock): cover serial inventory dimensions

(cherry picked from commit c940bd1e66)

# Conflicts:
#	erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py
#	erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py
2026-08-26 12:28:31 +05:30
mergify[bot]
f5ce20f122 fix: keep Currency and Price List section open for foreign currency (backport #58074) (#58112)
* fix: keep Currency and Price List section open for foreign currency (#58074)

The section is marked collapsible with no condition, so it always
rendered collapsed. When the transaction currency differs from the
company currency the exchange rate is relevant and was hidden behind
a click.

Adds collapsible_depends_on so the section starts expanded whenever
the transaction currency differs from the company currency, and stays
collapsed otherwise.

(cherry picked from commit 4a8342496e)

# Conflicts:
#	erpnext/accounts/doctype/pos_invoice/pos_invoice.json
#	erpnext/accounts/doctype/sales_invoice/sales_invoice.json
#	erpnext/buying/doctype/purchase_order/purchase_order.json
#	erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
#	erpnext/selling/doctype/sales_order/sales_order.json
#	erpnext/stock/doctype/delivery_note/delivery_note.json
#	erpnext/stock/doctype/purchase_receipt/purchase_receipt.json

* fix: resolve unresolved cherry-pick conflict markers in modified timestamps

Mergify's automatic cherry-pick of #58074 left conflict markers
committed on the modified field, breaking JSON parsing.

---------

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-08-26 11:35:24 +05:30
Frappe PR Bot
945e825bee chore(release): Bumped to Version 15.120.0
# [15.120.0](https://github.com/frappe/erpnext/compare/v15.119.3...v15.120.0) (2026-08-25)

### Bug Fixes

* **accounts:** disallow reversing a reverse journal entry ([15041a6](15041a62dd))
* **accounts:** supplier group filter not applied on accounts payable report ([743f7d8](743f7d8713))
* aggregate child warehouses in Stock Qty vs Serial No Count report (backport [#58134](https://github.com/frappe/erpnext/issues/58134)) ([#58365](https://github.com/frappe/erpnext/issues/58365)) ([61238e7](61238e7c4c))
* allow custom remark on reversal journal entry ([#58308](https://github.com/frappe/erpnext/issues/58308)) ([b578fb5](b578fb52d5))
* asset scrap flow related changes (backport [#55126](https://github.com/frappe/erpnext/issues/55126)) ([#58144](https://github.com/frappe/erpnext/issues/58144)) ([167cc1e](167cc1e5b9))
* **assets:** split FIFO/LIFO rate across grouped stock item rows ([c2c4e5e](c2c4e5ee8b))
* attributeerror on budget against a non profit and loss account ([2095411](2095411a28))
* **bank_statement_import:** add missing permission check on `get_import_status` (backport [#58217](https://github.com/frappe/erpnext/issues/58217)) ([#58218](https://github.com/frappe/erpnext/issues/58218)) ([1464a34](1464a34fc6))
* block disabled/frozen party on Opportunity and Request for Quotation (backport [#57983](https://github.com/frappe/erpnext/issues/57983)) ([#58034](https://github.com/frappe/erpnext/issues/58034)) ([88a36a8](88a36a800a))
* **buying:** allow purchase returns against a closed purchase order ([#58140](https://github.com/frappe/erpnext/issues/58140)) ([81c5393](81c53931b5))
* correct Item Group doctype name in item tax template dashboard  (backport [#58192](https://github.com/frappe/erpnext/issues/58192)) ([#58212](https://github.com/frappe/erpnext/issues/58212)) ([46d883d](46d883d00d))
* **crm_settings:** create custom fields for Frappe CRM on enabling synchronization ([eb85ca6](eb85ca68f6))
* don't set work order status to In Process only due to skip material transfer ([#58246](https://github.com/frappe/erpnext/issues/58246)) ([73b7ec3](73b7ec32b6))
* drop removed Restaurant doctype from sales tax template dashboard  (backport [#58191](https://github.com/frappe/erpnext/issues/58191)) ([#58210](https://github.com/frappe/erpnext/issues/58210)) ([32a5b23](32a5b23b3c))
* **email_digest:** added permission check for `get_msg_html` (backport [#58197](https://github.com/frappe/erpnext/issues/58197)) ([#58199](https://github.com/frappe/erpnext/issues/58199)) ([c6211eb](c6211eb075))
* escape interpolated values in portal, print and desk templates (backport [#58273](https://github.com/frappe/erpnext/issues/58273)) ([#58277](https://github.com/frappe/erpnext/issues/58277)) ([37f2770](37f2770809))
* escape interpolated values in text positions across portal and desk templates (backport [#58286](https://github.com/frappe/erpnext/issues/58286)) ([#58287](https://github.com/frappe/erpnext/issues/58287)) ([1210c61](1210c6187d))
* escape on status image for workstations in production status (backport [#58279](https://github.com/frappe/erpnext/issues/58279)) ([#58280](https://github.com/frappe/erpnext/issues/58280)) ([d047caf](d047caf4aa))
* filter available batch report by company (backport [#57995](https://github.com/frappe/erpnext/issues/57995)) ([#58076](https://github.com/frappe/erpnext/issues/58076)) ([8c8a4b6](8c8a4b6f20))
* fix conflicts ([f8e614f](f8e614f0c7))
* get items from sales order in sales invoice (backport [#58163](https://github.com/frappe/erpnext/issues/58163)) ([#58187](https://github.com/frappe/erpnext/issues/58187)) ([89d3701](89d3701e3b))
* hide rfq status in supplier portal ([#58368](https://github.com/frappe/erpnext/issues/58368)) ([2dbd224](2dbd224643))
* hide supplier name in rfq portal (backport [#58373](https://github.com/frappe/erpnext/issues/58373)) ([#58375](https://github.com/frappe/erpnext/issues/58375)) ([91fc999](91fc999575))
* ignore historical negative batch stock in outward validation (backport [#58148](https://github.com/frappe/erpnext/issues/58148)) ([#58150](https://github.com/frappe/erpnext/issues/58150)) ([9cc9aa0](9cc9aa0fa5))
* include time logs ending at midnight in timesheet billing summary (backport [#58355](https://github.com/frappe/erpnext/issues/58355)) ([#58356](https://github.com/frappe/erpnext/issues/58356)) ([3d4245b](3d4245b9b4))
* **italy:** handle none price_list_rate in e-invoice xml generation (backport [#58242](https://github.com/frappe/erpnext/issues/58242)) ([#58369](https://github.com/frappe/erpnext/issues/58369)) ([cb8ae93](cb8ae93fa3))
* **manufacturing:** derive material transfers from actual coverage ([#58114](https://github.com/frappe/erpnext/issues/58114)) ([a1ecea1](a1ecea1794))
* **manufacturing:** fall back to item group defaults for work order w… ([#58236](https://github.com/frappe/erpnext/issues/58236)) ([48bd313](48bd3139f3))
* mirror rounding adjustment on distributed_discount_amount (backport [#58047](https://github.com/frappe/erpnext/issues/58047)) ([#58054](https://github.com/frappe/erpnext/issues/58054)) ([b03e098](b03e098684))
* new docs should refetch incoming rates (backport [#58097](https://github.com/frappe/erpnext/issues/58097)) ([#58294](https://github.com/frappe/erpnext/issues/58294)) ([fd82c7d](fd82c7d691))
* **party_ledger_summary:** added missing filters for `cost_center` and `projects` ([#58411](https://github.com/frappe/erpnext/issues/58411)) ([d4815cb](d4815cb231))
* patch to delete the `crm_deal` custom fields ([ff1a191](ff1a1914a1))
* prevent duplicate supplier quotations from portal ([e1246ae](e1246ae95d))
* remove ignore_permissions from get_party_details signature ([#55491](https://github.com/frappe/erpnext/issues/55491)) ([4cd39aa](4cd39aa147))
* render missing terms before printing ([#58367](https://github.com/frappe/erpnext/issues/58367)) ([b33475e](b33475e7cf))
* respect zero currency precision ([#58395](https://github.com/frappe/erpnext/issues/58395)) ([6a84621](6a8462116b))
* **stock:** confirm before changing item qty from the batch selector (backport [#58123](https://github.com/frappe/erpnext/issues/58123)) ([#58124](https://github.com/frappe/erpnext/issues/58124)) ([954a5ec](954a5ec006))
* **stock:** fetch item stock UOM in stock reconciliation (backport [#58284](https://github.com/frappe/erpnext/issues/58284)) ([#58290](https://github.com/frappe/erpnext/issues/58290)) ([7883f59](7883f595d7))
* **stock:** honour pick serial / batch based on in the batch selector (backport [#58176](https://github.com/frappe/erpnext/issues/58176)) ([#58181](https://github.com/frappe/erpnext/issues/58181)) ([6d06b43](6d06b43434))
* update stock variance account logic which defaults to default expense (backport [#57656](https://github.com/frappe/erpnext/issues/57656)) ([#57675](https://github.com/frappe/erpnext/issues/57675)) ([c132b99](c132b99b4e))
* use user data fields hook (backport [#58274](https://github.com/frappe/erpnext/issues/58274)) ([#58282](https://github.com/frappe/erpnext/issues/58282)) ([74c3eea](74c3eeaa44))
* validation for task end date check ([8dc9919](8dc9919691))

### Features

* **accounts:** opt-in 'Consider Accounting Dimension' filter on General Ledger Report (backport [#58156](https://github.com/frappe/erpnext/issues/58156)) ([#58157](https://github.com/frappe/erpnext/issues/58157)) ([e704e58](e704e589ca))
* add status filter to Supplier Quotation Comparison report ([9e6a166](9e6a16658d))
* Belgian Charts of Accounts (commercial + non-profit, FR + NL) (backport [#54679](https://github.com/frappe/erpnext/issues/54679)) ([#58185](https://github.com/frappe/erpnext/issues/58185)) ([4c9a76e](4c9a76ef9f))
* sync serial no status from stock ledger in Stock Qty vs Serial No Count report (version-15-hotfix) ([#57865](https://github.com/frappe/erpnext/issues/57865)) ([faaaa07](faaaa0776d))
* taxable-base resolver hook for custom charge types ([#56175](https://github.com/frappe/erpnext/issues/56175)) ([8c9e941](8c9e941614))
* validate purchase receipt exchange rate parity on purchase invoice (backport [#58177](https://github.com/frappe/erpnext/issues/58177)) ([#58189](https://github.com/frappe/erpnext/issues/58189)) ([a5f4d3a](a5f4d3abeb))
2026-08-25 16:59:18 +00:00
Diptanil Saha
5d60bd9f54 Merge pull request #58396 from frappe/version-15-hotfix
chore: release v15
2026-08-25 22:27:36 +05:30
Diptanil Saha
d4815cb231 fix(party_ledger_summary): added missing filters for cost_center and projects (#58411) 2026-08-25 22:09:13 +05:30
Raffael Meyer
3ae3009129 fix!: tax net_amount and not_applicable (#54687) 2026-08-25 13:50:03 +02:00
Sudharsanan Ashok
07b6b3defd Merge pull request #58398 from frappe/mergify/bp/version-15-hotfix/pr-58395
fix: respect zero currency precision (backport #58395)
2026-08-25 16:45:17 +05:30
Pandiyan P
6a8462116b fix: respect zero currency precision (#58395)
(cherry picked from commit ce23fcc055)

# Conflicts:
#	erpnext/accounts/test/test_utils.py
2026-08-25 15:51:20 +05:30
Sudharsanan Ashok
1b5239c498 Merge pull request #58354 from frappe/mergify/bp/version-15-hotfix/pr-58179
fix(accounts): supplier group filter not applied on accounts payable … (backport #58179)
2026-08-25 13:04:42 +05:30
Sudharsanan Ashok
7fcbf085a5 Merge pull request #58389 from frappe/mergify/bp/version-15-hotfix/pr-58384
fix: prevent duplicate supplier quotations from portal (backport #58377) (backport #58384)
2026-08-25 12:04:54 +05:30
Sudharsanan11
f8e614f0c7 fix: fix conflicts 2026-08-25 11:47:06 +05:30
pandiyan
c861fbf438 test: verify duplicate supplier quotations are rejected
(cherry picked from commit efe5571ca7)
2026-08-25 05:43:13 +00:00
pandiyan
e1246ae95d fix: prevent duplicate supplier quotations from portal
(cherry picked from commit 39e15c7b2d)

# Conflicts:
#	erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
2026-08-25 05:43:12 +00:00
mergify[bot]
91fc999575 fix: hide supplier name in rfq portal (backport #58373) (#58375)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-24 17:40:38 +05:30
mergify[bot]
4b569c3ec3 Fix/return qty validation different uom (backport #58298) (#58363)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
2026-08-24 17:39:56 +05:30
mergify[bot]
cb8ae93fa3 fix(italy): handle none price_list_rate in e-invoice xml generation (backport #58242) (#58369)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-08-24 17:39:24 +05:30
Sudharsanan Ashok
91548461c0 Merge pull request #58371 from frappe/mergify/bp/version-15-hotfix/pr-58368
fix: hide rfq status in supplier portal (backport #58368)
2026-08-24 16:35:55 +05:30
Pandiyan P
2dbd224643 fix: hide rfq status in supplier portal (#58368)
(cherry picked from commit 75d6183bb6)
2026-08-24 10:59:54 +00:00
Pandiyan P
b33475e7cf fix: render missing terms before printing (#58367) 2026-08-24 16:28:17 +05:30
mergify[bot]
61238e7c4c fix: aggregate child warehouses in Stock Qty vs Serial No Count report (backport #58134) (#58365)
Co-authored-by: Mohd Haris <haris@frappe.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-24 10:33:16 +00:00
mergify[bot]
3d4245b9b4 fix: include time logs ending at midnight in timesheet billing summary (backport #58355) (#58356)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-24 14:46:36 +05:30
ervishnucs
743f7d8713 fix(accounts): supplier group filter not applied on accounts payable report
(cherry picked from commit 513f19924d)
2026-08-24 07:12:19 +00:00
Vishnu Priya Baskaran
b578fb52d5 fix: allow custom remark on reversal journal entry (#58308)
fix: allow user remark on reversal journal entry
2026-08-19 22:18:13 +05:30
mergify[bot]
fd82c7d691 fix: new docs should refetch incoming rates (backport #58097) (#58294)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 11:58:32 +00:00
mergify[bot]
88a36a800a fix: block disabled/frozen party on Opportunity and Request for Quotation (backport #57983) (#58034)
* fix: block disabled/frozen customers on Opportunity

Opportunity inherits TransactionBase instead of AccountsController, so
it never ran validate_party_frozen_disabled like Quotation, Sales Order
and Sales Invoice do. A disabled Customer could be saved as an
Opportunity's party and only get caught later at Quotation stage.

Also fixes the party_name Link query on the client: it referenced
erpnext.queries.customer, which was never defined, so disabled
customers showed up in the picker.

(cherry picked from commit 90937ce6d9)

# Conflicts:
#	erpnext/crm/doctype/opportunity/test_opportunity.py

* fix: block disabled/frozen suppliers on Request for Quotation

Request for Quotation overrides validate() entirely and never calls
super().validate(), so it never goes through AccountsController's
party validation. Suppliers also sit in a child table, so the shared
PartyValidator wouldn't have caught it anyway (it only checks a single
top-level party field). A disabled or frozen Supplier could be added
to an RFQ and the RFQ submitted without any warning.

Also filters the suppliers grid's supplier Link field to disabled=0,
matching the same client-side fix applied to Opportunity's party_name.

(cherry picked from commit 4bf65ffc1d)

# Conflicts:
#	erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
#	erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py

* fix: scope Opportunity party validation to Customer only

validate_party_frozen_disabled only enforces Customer/Supplier/Employee,
so passing opportunity_from straight through silently no-op'd for Lead
and Prospect. Made the Customer-only scope explicit instead of relying
on that implicit fallthrough.

Lead.disabled is not enforced anywhere else in the codebase (lead_query,
the picker used for this same field, only filters status/docstatus), so
deliberately not extending validation to Lead-sourced Opportunities.

(cherry picked from commit 8c0a945417)

* refactor: move RFQ supplier disabled filter to link_filters

Static filters with no doc-dependent values belong on the field
definition, not in JS. Matches the existing pattern used for
Warehouse/Item link_filters elsewhere (e.g. job_card_item.json,
product_bundle_item.json).

(cherry picked from commit 6b35c51ff1)

* fix: resolve backport conflicts for disabled/frozen party validation

The automated backport left unresolved merge conflict markers committed
in request_for_quotation.py, test_request_for_quotation.py and
test_opportunity.py. Also fixes validate_party_frozen_disabled being
called with 3 args here, this branch's version only takes
(party_type, party_name), unlike develop's (company, party_type, party_name).

Dropped test_duplicate_supplier_rejected, test_rfq_blocked_for_supplier_with_prevent_rfqs
and test_rfq_status_lifecycle from the conflict resolution, they don't exist on
this branch and aren't part of this backport.

---------

Co-authored-by: Jatin3128 <jatinsarna8@gmail.com>
2026-08-19 16:58:21 +05:30
mergify[bot]
7883f595d7 fix(stock): fetch item stock UOM in stock reconciliation (backport #58284) (#58290)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 10:36:34 +00:00
mergify[bot]
c132b99b4e fix: update stock variance account logic which defaults to default expense (backport #57656) (#57675)
Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-19 14:41:41 +05:30
mergify[bot]
74c3eeaa44 fix: use user data fields hook (backport #58274) (#58282)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 10:25:47 +05:30
mergify[bot]
1210c6187d fix: escape interpolated values in text positions across portal and desk templates (backport #58286) (#58287)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-19 02:44:35 +05:30
mergify[bot]
d047caf4aa fix: escape on status image for workstations in production status (backport #58279) (#58280)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-18 20:27:31 +00:00
mergify[bot]
37f2770809 fix: escape interpolated values in portal, print and desk templates (backport #58273) (#58277)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-19 00:35:45 +05:30
mergify[bot]
b03e098684 fix: mirror rounding adjustment on distributed_discount_amount (backport #58047) (#58054)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-18 12:07:43 +02:00
Frappe PR Bot
0526834071 chore(release): Bumped to Version 15.119.3
## [15.119.3](https://github.com/frappe/erpnext/compare/v15.119.2...v15.119.3) (2026-08-18)

### Bug Fixes

* get items from sales order in sales invoice (backport [#58163](https://github.com/frappe/erpnext/issues/58163)) (backport [#58187](https://github.com/frappe/erpnext/issues/58187)) ([#58259](https://github.com/frappe/erpnext/issues/58259)) ([88f32f1](88f32f1d1b))
2026-08-18 07:37:05 +00:00
mergify[bot]
88f32f1d1b fix: get items from sales order in sales invoice (backport #58163) (backport #58187) (#58259)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-18 07:35:00 +00:00
Lakshit Jain
41aba183de Merge pull request #58160 from frappe/mergify/bp/version-15-hotfix/pr-56175
feat: taxable-base resolver hook for custom charge types (backport #56175)
2026-08-18 11:58:24 +05:30
rohitwaghchaure
73b7ec32b6 fix: don't set work order status to In Process only due to skip material transfer (#58246) 2026-08-17 12:43:13 +00:00
Pandiyan P
48bd3139f3 fix(manufacturing): fall back to item group defaults for work order w… (#58236) 2026-08-17 15:26:05 +05:30
mergify[bot]
1464a34fc6 fix(bank_statement_import): add missing permission check on get_import_status (backport #58217) (#58218)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 15:11:25 +00:00
mergify[bot]
0c625ff69b fix(crm)!: remove unused get_last_interaction endpoint (backport #58214) (#58215)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 14:21:44 +00:00
Shllokkk
2aa1bf54f2 Merge pull request #58209 from frappe/mergify/bp/version-15-hotfix/pr-58208
feat: add status filter to Supplier Quotation Comparison report (backport #58208)
2026-08-16 19:48:40 +05:30
mergify[bot]
46d883d00d fix: correct Item Group doctype name in item tax template dashboard (backport #58192) (#58212) 2026-08-16 18:42:58 +05:30
mergify[bot]
32a5b23b3c fix: drop removed Restaurant doctype from sales tax template dashboard (backport #58191) (#58210) 2026-08-16 17:22:06 +05:30
Shllokkk
9e6a16658d feat: add status filter to Supplier Quotation Comparison report
(cherry picked from commit 2b84ed78e8)
2026-08-16 10:22:38 +00:00
mergify[bot]
c6211eb075 fix(email_digest): added permission check for get_msg_html (backport #58197) (#58199)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-15 16:47:02 +00:00
mergify[bot]
a5f4d3abeb feat: validate purchase receipt exchange rate parity on purchase invoice (backport #58177) (#58189)
* feat: validate purchase receipt exchange rate parity on purchase invoice (#58177)

(cherry picked from commit 70a8a2d0c5)

# Conflicts:
#	erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py

* chore: fix conflicts

Removed assertion for exchange rate discrepancy in purchase invoice test.

* test: fix backport of exchange rate difference test for non stock item

The conflict resolution left behind stale amount/discrepancy lookups
referencing a removed second item row (IndexError in CI and F841 ruff
failures). Align the test with the develop version: single non stock
item, PR at 80 / PI at 70, and assert no exchange gain/loss GL entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:51:12 +00:00
mergify[bot]
89d3701e3b fix: get items from sales order in sales invoice (backport #58163) (#58187)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-15 08:02:23 +00:00
mergify[bot]
4c9a76ef9f feat: Belgian Charts of Accounts (commercial + non-profit, FR + NL) (backport #54679) (#58185)
Co-authored-by: Antoine Maas <antoine.maas@okte.io>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-14 16:56:24 +00:00
mergify[bot]
6d06b43434 fix(stock): honour pick serial / batch based on in the batch selector (backport #58176) (#58181)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-14 13:57:27 +00:00
Khushi Rawat
78d780d49e Merge pull request #58175 from aerele/backport-58092-version-15-hotfix
fix(accounts): disallow reversing a reverse journal entry
2026-08-14 17:43:53 +05:30
pandiyan
15041a62dd fix(accounts): disallow reversing a reverse journal entry
check read permission on the source entry before the guard runs, so the reversal relationship is not disclosed to a user who cannot read it.
2026-08-14 17:06:23 +05:30
pandiyan
754e7052ca test(accounts): cover reversal of a reverse journal entry
also assert that a user without read access on the entry gets a permission error instead of the reversal relationship.
2026-08-14 16:56:06 +05:30
Nishka Gosalia
3b2bb23306 Merge pull request #58170 from frappe/mergify/bp/version-15-hotfix/pr-58167
fix: validation for task end date check (backport #58167)
2026-08-14 16:42:31 +05:30
mergify[bot]
e704e589ca feat(accounts): opt-in 'Consider Accounting Dimension' filter on General Ledger Report (backport #58156) (#58157)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-14 16:27:37 +05:30
nishkagosalia
8dc9919691 fix: validation for task end date check
(cherry picked from commit 7c6da80f98)
2026-08-14 10:52:46 +00:00
vorasmit
e7e2358cb5 chore: resolve conflicts 2026-08-14 15:12:42 +05:30
Smit Vora
8c9e941614 feat: taxable-base resolver hook for custom charge types (#56175)
(cherry picked from commit 986cea2331)

# Conflicts:
#	erpnext/controllers/taxes_and_totals.py
#	erpnext/controllers/tests/test_taxes_and_totals.py
#	erpnext/public/js/controllers/taxes_and_totals.js
2026-08-14 08:19:25 +00:00
rohitwaghchaure
faaaa0776d feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report (version-15-hotfix) (#57865)
* feat: sync serial no status from stock ledger in Stock Qty vs Serial No Count report

* fix: pick last bundle move in SQL ordered by posting datetime and SLE creation

* fix: derive synced serial no status from stock ledger helper and validate sync args

* fix: use posting datetime for last bundle move after version-15 field rename

* fix: order last bundle moves by bundle posting datetime
2026-08-14 11:51:18 +05:30
Frappe PR Bot
d707cb1e0e chore(release): Bumped to Version 15.119.2
## [15.119.2](https://github.com/frappe/erpnext/compare/v15.119.1...v15.119.2) (2026-08-14)

### Bug Fixes

* ignore historical negative batch stock in outward validation (backport [#58148](https://github.com/frappe/erpnext/issues/58148)) (backport [#58150](https://github.com/frappe/erpnext/issues/58150)) ([#58152](https://github.com/frappe/erpnext/issues/58152)) ([ac5b4d1](ac5b4d185b))
2026-08-14 06:20:14 +00:00
mergify[bot]
ac5b4d185b fix: ignore historical negative batch stock in outward validation (backport #58148) (backport #58150) (#58152)
fix: ignore historical negative batch stock in outward validation (backport #58148) (#58150)

fix: ignore historical negative batch stock in outward validation (#58148)

(cherry picked from commit 9239d1c2a3)


(cherry picked from commit 9cc9aa0fa5)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-14 11:48:34 +05:30
Khushi Rawat
15d430b959 Merge pull request #58093 from aerele/fix/budget-non-pnl-account-message
fix(accounts): attributeerror on budget against a non profit and loss account
2026-08-14 11:27:35 +05:30
mergify[bot]
9cc9aa0fa5 fix: ignore historical negative batch stock in outward validation (backport #58148) (#58150)
fix: ignore historical negative batch stock in outward validation (#58148)

(cherry picked from commit 9239d1c2a3)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-14 05:48:33 +00:00
Pandiyan P
81c53931b5 fix(buying): allow purchase returns against a closed purchase order (#58140) 2026-08-14 09:01:03 +05:30
mergify[bot]
167cc1e5b9 fix: asset scrap flow related changes (backport #55126) (#58144)
Co-authored-by: khushi8112 <khushirawat23@navgurukul.org>
2026-08-13 18:41:15 +00:00
Diptanil Saha
c6e6bdccae Merge pull request #58142 from frappe/mergify/bp/version-15-hotfix/pr-58141
fix(crm_settings): create `crm_deal` fields on enabling frappe crm data synchronization (backport #58141)
2026-08-13 23:28:36 +05:30
diptanilsaha
ff1a1914a1 fix: patch to delete the crm_deal custom fields
(cherry picked from commit 9613d72d81)
2026-08-13 17:39:51 +00:00
diptanilsaha
eb85ca68f6 fix(crm_settings): create custom fields for Frappe CRM on enabling synchronization
(cherry picked from commit be2dea0ba2)
2026-08-13 17:39:50 +00:00
mergify[bot]
954a5ec006 fix(stock): confirm before changing item qty from the batch selector (backport #58123) (#58124)
fix(stock): confirm before changing item qty from the batch selector (#58123)

the batch selector silently overwrote the item qty with the bundle total,
so editing a row qty in the dialog changed the delivered qty without any
warning. prompt for confirmation when the rows do not add up to the qty
to fetch, and only proceed if the user agrees.

(cherry picked from commit a2976dd29e)

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-13 16:40:26 +05:30
Mihir Kandoi
a1ecea1794 fix(manufacturing): derive material transfers from actual coverage (#58114) 2026-08-13 09:39:21 +00:00
Diptanil Saha
a7e21e834d Merge pull request #58111 from frappe/mergify/bp/version-15-hotfix/pr-55491
fix: remove ignore_permissions from get_party_details signature (backport #55491)
2026-08-13 13:26:09 +05:30
diptanilsaha
23919967f6 chore: resolve conflicts 2026-08-13 13:01:53 +05:30
Shllokkk
4cd39aa147 fix: remove ignore_permissions from get_party_details signature (#55491)
(cherry picked from commit efb8336bf8)

# Conflicts:
#	erpnext/accounts/doctype/sales_invoice/sales_invoice.py
#	erpnext/accounts/party.py
2026-08-13 07:23:22 +00:00
Khushi Rawat
afa2437910 Merge pull request #57564 from khushi8112/refactor-depreciation-entry-v15
refactor: split make_depreciation_entry into public and internal helpers
2026-08-13 12:33:09 +05:30
Khushi Rawat
9638e81762 Merge pull request #58088 from frappe/mergify/bp/version-15-hotfix/pr-57820
fix(assets): split fifo/lifo rate across grouped stock item rows (backport #57820)
2026-08-13 12:28:26 +05:30
ervishnucs
1e23d48a5b test(assets): cover grouped stock item rows splitting FIFO rate
(cherry picked from commit 2cbc5b89d6)

# Conflicts:
#	erpnext/assets/doctype/asset_capitalization/test_asset_capitalization.py
2026-08-13 12:01:31 +05:30
khushi8112
6650ac8c36 refactor: split make_depreciation_entry into public and internal helpers 2026-08-13 11:39:33 +05:30
mergify[bot]
bfa3edbf95 Fix/item description in the item price list (backport #58084) (#58101)
Co-authored-by: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-13 04:42:02 +00:00
mergify[bot]
8c8a4b6f20 fix: filter available batch report by company (backport #57995) (#58076)
Co-authored-by: Krishna Shirsath <shirsathkrishna19@gmail.com>
2026-08-12 17:33:56 +05:30
pandiyan
2095411a28 fix: attributeerror on budget against a non profit and loss account 2026-08-12 16:12:55 +05:30
pandiyan
5700384989 test: budget against a balance sheet account 2026-08-12 16:12:55 +05:30
ervishnucs
c2c4e5ee8b fix(assets): split FIFO/LIFO rate across grouped stock item rows
(cherry picked from commit a05ec49062)
2026-08-12 09:04:48 +00:00
Frappe PR Bot
be176617cc chore(release): Bumped to Version 15.119.1
## [15.119.1](https://github.com/frappe/erpnext/compare/v15.119.0...v15.119.1) (2026-08-11)

### Bug Fixes

* **accounts receivable:** made territory field multi select (backport [#57322](https://github.com/frappe/erpnext/issues/57322)) ([#57808](https://github.com/frappe/erpnext/issues/57808)) ([69c00b3](69c00b3362))
* allow selecting a warehouse for new items in the update items dialog ([#57876](https://github.com/frappe/erpnext/issues/57876)) ([76f485b](76f485ba43))
* clear deferred revenue/expense fields on uncheck (backport [#57140](https://github.com/frappe/erpnext/issues/57140)) ([ce898a9](ce898a9695))
* **coa_importer:** added server-side validations for importing chart of accounts (backport [#58065](https://github.com/frappe/erpnext/issues/58065)) ([#58067](https://github.com/frappe/erpnext/issues/58067)) ([d0ef967](d0ef967890))
* **coa_importer:** allow importing COA through `import_coa` only for `Accounts Manager` (backport [#56132](https://github.com/frappe/erpnext/issues/56132)) ([#58064](https://github.com/frappe/erpnext/issues/58064)) ([00d8dc7](00d8dc73b9))
* convert hours to minutes in workstation complete_job ([5e753ec](5e753ec6e4))
* escape `customer_details` on lead creation from appointment (backport [#57947](https://github.com/frappe/erpnext/issues/57947)) ([#57948](https://github.com/frappe/erpnext/issues/57948)) ([2ca71f1](2ca71f16c4))
* field validation and perm checks on `get_stock_reservation_entries_for_voucher` (backport [#57968](https://github.com/frappe/erpnext/issues/57968)) ([#57986](https://github.com/frappe/erpnext/issues/57986)) ([a0d1561](a0d156120b))
* get_valuation_method takes no company argument on version-15 ([c45ea35](c45ea3545d))
* guard reconciliation table deletes when tables are missing ([140e06d](140e06dfa4))
* incorrect entry detection in Stock Ledger Invariant Check ([#57886](https://github.com/frappe/erpnext/issues/57886)) ([529ff25](529ff251fb))
* keep asset repair downtime in sync with entered dates ([0e9c08d](0e9c08d494))
* **manufacturing:** avoid child BOM cache lookups ([5c2123f](5c2123f6ed))
* **manufacturing:** correct nested BOM quantities ([a310346](a3103469e2))
* **manufacturing:** keep item code searchable when a barcode matches the same text ([0310db2](0310db22ec))
* optimize product bundle item search ([52a1b12](52a1b124b2))
* preserve custom title on new JV (backport [#57987](https://github.com/frappe/erpnext/issues/57987)) ([#57988](https://github.com/frappe/erpnext/issues/57988)) ([713af31](713af31eda))
* purchase return of batchwise valuation batch valued at original receipt rate instead of batch avg rate (version-15-hotfix) ([#57837](https://github.com/frappe/erpnext/issues/57837)) ([e2ded11](e2ded11e09))
* re-check future sle before queuing repost on submit ([#57664](https://github.com/frappe/erpnext/issues/57664)) ([#57960](https://github.com/frappe/erpnext/issues/57960)) ([d510978](d510978a44))
* reflect in-invoice receivable settlements in Sales Register ledger view ([91afc04](91afc04d9f))
* repost read stale sibling SLE rate for moving average returns ([5738cfc](5738cfce79))
* require material transfer before job card time logs ([2e5ae18](2e5ae188d6))
* resolve version-15 backport conflicts ([b25a2f2](b25a2f2cdf))
* **selling:** bill re-delivered sales order quantities ([47c6274](47c6274b13))
* **selling:** reset stale item details on item change (backport [#58051](https://github.com/frappe/erpnext/issues/58051)) ([#58052](https://github.com/frappe/erpnext/issues/58052)) ([01372cf](01372cf295))
* set `restrict_globals=True` in `frappe.render_template` (backport [#57899](https://github.com/frappe/erpnext/issues/57899)) ([#57901](https://github.com/frappe/erpnext/issues/57901)) ([af7953a](af7953a933))
* **setup:** fetch driver address by supplier link ([2a4a9ff](2a4a9fff3d))
* skip incoming rate calc when serial no qty is zero (backport [#57427](https://github.com/frappe/erpnext/issues/57427)) ([#57956](https://github.com/frappe/erpnext/issues/57956)) ([f9a09f0](f9a09f0ac4))
* **stock:** grant account access in blanket order role test ([5e78603](5e7860362e))
* **stock:** handle multi-item opening balance in Stock Ledger report (backport [#57591](https://github.com/frappe/erpnext/issues/57591)) ([#57795](https://github.com/frappe/erpnext/issues/57795)) ([ebdc0cd](ebdc0cdf7f))
* **stock:** scope over deliver/receive role check to delivery and receipt overflow ([328c72d](328c72da18))
* **stock:** validate new warehouse inventory account after naming ([5e3d094](5e3d0947c8))
* **stock:** validate over delivery/receipt allowance in stock settings ([7be8a99](7be8a99a23))
* **stock:** validate warehouse accounts when used ([d620720](d620720445))
* **subscription:** don't reactivate a cancelled subscription (backport [#57774](https://github.com/frappe/erpnext/issues/57774)) ([#57780](https://github.com/frappe/erpnext/issues/57780)) ([edc8daa](edc8daae21)), closes [#57761](https://github.com/frappe/erpnext/issues/57761) [#57761](https://github.com/frappe/erpnext/issues/57761)
* sync open reference forms after Quality Inspection updates them ([c1d198d](c1d198d205))
* tolerate floating-point drift in sales team allocated percentage ([262fbc5](262fbc52d8))
* use stock settings for warehouse defaults ([d4ed207](d4ed2074d5))
* validate webform for project ([680ce70](680ce70c35))
* zero-rate repost fallback could still read sibling SLE ([8131af9](8131af9405))
2026-08-11 23:19:46 +00:00
Diptanil Saha
942a1ddb2c Merge pull request #58025 from frappe/version-15-hotfix
chore: release v15
2026-08-12 04:47:53 +05:30
Diptanil Saha
d0ef967890 fix(coa_importer): added server-side validations for importing chart of accounts (backport #58065) (#58067) 2026-08-11 22:54:34 +00:00
Diptanil Saha
00d8dc73b9 fix(coa_importer): allow importing COA through import_coa only for Accounts Manager (backport #56132) (#58064)
Co-authored-by: Pratheep S <pratheeps2024@gmail.com>
2026-08-11 21:16:40 +00:00
mergify[bot]
01372cf295 fix(selling): reset stale item details on item change (backport #58051) (#58052) 2026-08-11 15:54:04 +00:00
Mihir Kandoi
9c9bc8e9bf Merge pull request #58039 from mihir-kandoi/backport-58036-version-15-hotfix
fix(stock): validate warehouse accounts when used (backport #58036)
2026-08-11 19:15:29 +05:30
Mihir Kandoi
5a61ea6496 test(stock): isolate warehouse account fixtures from class-level state
FrappeTestCase on this branch rolls back per class, not per test, so
sibling tests leak state. test_new_warehouse_can_inherit_inventory_account
left an explicit account on the root group, which made later ambiguous
fixtures resolve through the root: the insert validation stopped raising
and the unresolved warehouse stayed in the map.

The fixture helper now clears group warehouse accounts so every call
re-establishes ambiguity. The fallback test also clears the account of
the warehouse it picks, since a leftover explicit account skips the
single-account fallback it asserts.
2026-08-11 18:58:49 +05:30
Mihir Kandoi
da2c422bf6 test(stock): cover named validation error and same-transaction parent inheritance 2026-08-11 18:31:25 +05:30
Mihir Kandoi
5e3d0947c8 fix(stock): validate new warehouse inventory account after naming
Move the insert-time check from before_insert to validate. before_insert
runs before set_new_name, so the validation message rendered the
warehouse name as None. validate runs after naming and only applies to
new documents via is_new().

Resolve inheritance through the parent's lft/rgt bounds instead of the
request-cached warehouse account map. The cached map can be stale within
a request (a parent created moments earlier is missing from it), which
made get_warehouse_account trigger a full nested-set rebuild_tree and
could falsely reject a child whose parent carries a valid account.
rebuild_tree enables auto_commit_on_many_writes, which must not run
inside a document insert.
2026-08-11 18:31:25 +05:30
Mihir Kandoi
d620720445 fix(stock): validate warehouse accounts when used 2026-08-11 18:04:30 +05:30
Mihir Kandoi
f5e411669a Merge pull request #58022 from frappe/mergify/bp/version-15-hotfix/pr-58020
fix(setup): fetch driver address by supplier link (backport #58020)
2026-08-11 15:31:09 +05:30
Mihir Kandoi
2a4a9fff3d fix(setup): fetch driver address by supplier link
(cherry picked from commit 3ffb888d26)
2026-08-11 09:47:54 +00:00
Mihir Kandoi
5c3063fa43 Merge pull request #58011 from mihir-kandoi/backport-58003-version-15-hotfix
fix(selling): bill re-delivered sales order quantities (backport #58003)
2026-08-11 14:10:29 +05:30
Mihir Kandoi
39e4641a29 Merge pull request #58015 from mihir-kandoi/require-transfer-before-job-card-start-v15
fix: require material transfer before job card time logs (v15)
2026-08-11 14:04:17 +05:30
Mihir Kandoi
ac4e9c34cf chore(selling): annotate make_sales_invoice arguments 2026-08-11 13:49:48 +05:30
Mihir Kandoi
b70fa4fdd1 test: job card time log blocked until material transfer 2026-08-11 13:41:59 +05:30
Mihir Kandoi
2e5ae188d6 fix: require material transfer before job card time logs
v15 adaptation of the develop fix: the timer flow here runs through
add_time_log via make_time_log, so gate that instead of the
start_timer/complete_job_card methods that only exist on develop, and
hide Start Job while transfer is pending. Corrective job cards and
job cards without items (transfer against Work Order) stay exempt,
matching validate_transfer_qty on submit.
2026-08-11 13:41:57 +05:30
Mihir Kandoi
a99b10d213 Merge pull request #58008 from frappe/codex/backport-57963-version-15
fix(manufacturing): correct nested BOM Explorer quantities
2026-08-11 13:34:29 +05:30
Mihir Kandoi
220b25db09 test(selling): cover invoicing after returns and re-deliveries 2026-08-11 13:26:00 +05:30
Mihir Kandoi
47c6274b13 fix(selling): bill re-delivered sales order quantities 2026-08-11 13:25:59 +05:30
Mihir Kandoi
5c2123f6ed fix(manufacturing): avoid child BOM cache lookups 2026-08-11 13:14:49 +05:30
Mihir Kandoi
bb72ae6a3c Merge pull request #58001 from frappe/mergify/bp/version-15-hotfix/pr-57997
fix: keep item code searchable when a barcode matches the same text (backport #57997)
2026-08-11 13:08:48 +05:30
Mihir Kandoi
a3103469e2 fix(manufacturing): correct nested BOM quantities 2026-08-11 13:01:41 +05:30
Mihir Kandoi
43b874f0d4 Merge pull request #57998 from frappe/mergify/bp/version-15-hotfix/pr-57972
refactor: drop redundant time_in_mins assignment in workstation complete_job (backport #57972)
2026-08-11 12:28:42 +05:30
Kaushal Shriwas
a9743ae5da chore: resolve backport conflict in bom.py 2026-08-11 12:25:29 +05:30
Kaushal Shriwas
760b365225 test(manufacturing): cover BOM item search when item code collides with a barcode
(cherry picked from commit 23024d1ea9)
2026-08-11 06:42:55 +00:00
Kaushal Shriwas
0310db22ec fix(manufacturing): keep item code searchable when a barcode matches the same text
(cherry picked from commit bf5d506637)

# Conflicts:
#	erpnext/manufacturing/doctype/bom/mapper.py
2026-08-11 06:42:55 +00:00
Mihir Kandoi
496883f4e1 refactor: drop redundant time_in_mins assignment in complete_job
(cherry picked from commit 3cffeb68e3)
2026-08-11 06:39:14 +00:00
pandiyan
5e753ec6e4 fix: convert hours to minutes in workstation complete_job
`time_diff_in_hours` returns hours, so `time_in_mins` needs `* 60`, not
`/ 60`. Matches `Job Card.validate_time_log_row`.

No behaviour change: the `doc.save()` on the next line runs Job Card's
`validate`, which recomputes `time_in_mins` correctly before the row is
written. This only stops the expression from reading as a bug.

(cherry picked from commit 422a9161dd)
2026-08-11 06:39:14 +00:00
mergify[bot]
27af9baaa5 refactor(queries): using frappe.get_query in get_filtered_child_rows (backport #57991) (#57992)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-10 19:51:55 +00:00
mergify[bot]
a0d156120b fix: field validation and perm checks on get_stock_reservation_entries_for_voucher (backport #57968) (#57986)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-10 22:04:42 +05:30
mergify[bot]
713af31eda fix: preserve custom title on new JV (backport #57987) (#57988)
Co-authored-by: rehanrehman389 <rehanrehman389@gmail.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-10 16:27:03 +00:00
ruthra kumar
df8edcdc91 Merge pull request #57830 from aerele/backport-57140-v15
fix: clear deferred revenue/expense fields on uncheck (backport #57140)
2026-08-10 15:04:11 +05:30
Pandiyan P
d510978a44 fix: re-check future sle before queuing repost on submit (#57664) (#57960)
* test: cover both repost branches and the no-repost case

* fix: queue repost for entries backdated by a concurrent submit

---------


(cherry picked from commit 399ff463cc)

Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
2026-08-10 14:03:41 +05:30
mergify[bot]
f9a09f0ac4 fix: skip incoming rate calc when serial no qty is zero (backport #57427) (#57956)
fix: skip incoming rate calc when serial no qty is zero (#57427)

(cherry picked from commit a25decfa50)

Co-authored-by: Shllokkk <140623894+Shllokkk@users.noreply.github.com>
2026-08-10 06:48:58 +00:00
Khushi Rawat
a2b2f5173e Merge pull request #57823 from Shllokkk/asset-repair-downtime-recalc-v15
fix: keep asset repair downtime in sync with entered dates
2026-08-10 12:05:01 +05:30
mergify[bot]
2ca71f16c4 fix: escape customer_details on lead creation from appointment (backport #57947) (#57948)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-09 19:30:11 +00:00
Shllokkk
ae18d13e6e Merge pull request #57928 from Shllokkk/sales-register-ledger-pos-paid-v15
fix: reflect in-invoice receivable credits in Sales Register ledger view
2026-08-09 23:39:00 +05:30
Shllokkk
7edb95628c test: cover POS-paid invoice in Sales Register ledger view 2026-08-09 20:22:31 +05:30
Shllokkk
91afc04d9f fix: reflect in-invoice receivable settlements in Sales Register ledger view 2026-08-09 20:22:31 +05:30
Mihir Kandoi
3c5b2ededf Merge pull request #57920 from frappe/mergify/bp/version-15-hotfix/pr-57918
fix: sync open reference forms after Quality Inspection submit (backport #57918)
2026-08-09 16:38:01 +05:30
Mihir Kandoi
94d1dfdcb4 chore: resolve conflict 2026-08-09 16:18:49 +05:30
Mihir Kandoi
2270b22400 test: doc_update published for reference on Quality Inspection submit
(cherry picked from commit e8a6884d5e)

# Conflicts:
#	erpnext/stock/doctype/quality_inspection/test_quality_inspection.py
2026-08-09 10:39:05 +00:00
Mihir Kandoi
c1d198d205 fix: sync open reference forms after Quality Inspection updates them
update_qc_reference() writes the QI link and bumps the reference
document's modified timestamp via raw db writes, which emit no realtime
event. A reference form (Purchase Receipt, Delivery Note, Stock Entry,
Job Card) still open in the browser keeps the old timestamp and fails
the timestamp conflict check on the next save/submit, forcing a manual
refresh after every QI submit/cancel/delete.

Calling notify_update() on the reference publishes the standard
doc_update event, so an open, unedited form silently reloads and syncs
its timestamp. get_lazy_doc skips child table loading since
notify_update only needs the parent row.

(cherry picked from commit 647452c95b)
2026-08-09 10:39:05 +00:00
Mihir Kandoi
db4f6db753 Merge pull request #57909 from frappe/mergify/bp/version-15-hotfix/pr-57876
fix: allow selecting a warehouse for new items in the update items dialog (backport #57876)
2026-08-09 13:17:57 +05:30
Mihir Kandoi
ee27df7d2b Merge pull request #57912 from frappe/mergify/bp/version-15-hotfix/pr-57911
fix: tolerate floating-point drift in sales team allocated percentage  (backport #57911)
2026-08-09 13:07:26 +05:30
Mihir Kandoi
d4ed2074d5 fix: use stock settings for warehouse defaults 2026-08-09 13:01:01 +05:30
Mihir Kandoi
206d291817 chore: resolve conflict 2026-08-09 12:50:38 +05:30
Mihir Kandoi
c23aa72dff chore: resolve conflict 2026-08-09 12:41:55 +05:30
pandiyan
1a89609044 test: sales team allocation totalling 100 in floating point
covers the case where the percentages are correct but the accumulated
sum is 100.00000000000001. two rows can never drift, since the second
reconstructs exactly as 100 - first, so the case needs three rows.

(cherry picked from commit 4afba94d1c)

# Conflicts:
#	erpnext/selling/doctype/sales_order/test_sales_order.py
2026-08-09 06:59:23 +00:00
pandiyan
262fbc52d8 fix: tolerate floating-point drift in sales team allocated percentage
the total of allocated_percentage was compared to 100 with exact float
equality, so a correct allocation could be rejected when the sum drifts
in binary floating point (10.0 + 58.02 + 31.98 -> 100.00000000000001).

round the total to the field precision before comparing, in both
SellingController.calculate_contribution and Customer.validate.

(cherry picked from commit f7b2775829)
2026-08-09 06:59:22 +00:00
mergify[bot]
7d9abee6b4 refactor(accounts)!: rework Purchase Invoice hold actions and enforce them on Journal Entry (backport #57825) (#57867)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-09 06:50:44 +00:00
Pandiyan P
76f485ba43 fix: allow selecting a warehouse for new items in the update items dialog (#57876)
(cherry picked from commit 55fe269046)

# Conflicts:
#	erpnext/accounts/services/child_item_update.py
#	erpnext/public/js/utils.js
#	erpnext/selling/doctype/sales_order/test_sales_order.py
2026-08-09 06:01:36 +00:00
Mihir Kandoi
8fc5cb2444 Merge pull request #57907 from frappe/mergify/bp/version-15-hotfix/pr-57832
fix: validate webform for project (backport #57832)
2026-08-09 09:59:47 +05:30
nishkagosalia
680ce70c35 fix: validate webform for project
(cherry picked from commit 126966d1db)
2026-08-09 04:12:10 +00:00
mergify[bot]
af7953a933 fix: set restrict_globals=True in frappe.render_template (backport #57899) (#57901)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-09 00:04:14 +05:30
Mihir Kandoi
de9d306bfd Merge pull request #57891 from mihir-kandoi/backport-57887-v15
fix: repost read stale sibling SLE rate for moving average returns (backport #57887)
2026-08-08 12:02:32 +05:30
Mihir Kandoi
e63a81b269 Merge pull request #57888 from frappe/mergify/bp/version-15-hotfix/pr-57886
fix: incorrect entry detection in Stock Ledger Invariant Check (backport #57886)
2026-08-08 11:43:21 +05:30
Mihir Kandoi
c45ea3545d fix: get_valuation_method takes no company argument on version-15 2026-08-08 11:24:44 +05:30
Mihir Kandoi
8131af9405 fix: zero-rate repost fallback could still read sibling SLE
When the in-memory running rate is zero, the fallback went through
get_incoming_rate, whose previous-SLE lookup matches the same
posting_datetime and can land on a sibling line of the voucher being
replayed. Replace it with get_previous_sle_of_current_voucher excluding
the current voucher, keeping the get_valuation_rate chain when no
previous entry exists. get_incoming_rate is no longer used in this
module.
2026-08-08 11:12:41 +05:30
Mihir Kandoi
9c8d5ac8a5 test: repost of multi-line moving average return is idempotent
Reposting a return that removes most of the stock across several lines
of the same item must keep every line at the running average and produce
identical results on a second repost. Before the fix the first repost
already drifted, seeding each line from a sibling row of the same
voucher.
2026-08-08 11:12:41 +05:30
Mihir Kandoi
5738cfce79 fix: repost read stale sibling SLE rate for moving average returns
During repost, a return line with recalculate_rate resolved its moving
average rate through get_incoming_rate -> get_previous_sle, which matches
posting_datetime <= and orders by creation desc. For a multi-line return
of the same item, every line shares one posting_datetime, so the query
landed on a sibling line of the same voucher whose stored valuation_rate
was still the previous repost run's output, not the rate before the
voucher.

Each repost run therefore re-seeded the voucher from its own prior
output. The error gain per run is (qty returned at the stale rate) /
(qty remaining after the return), so whenever a return removes most of
the stock the loop diverges instead of converging, alternating sign and
growing until stock_value overflows decimal(21,9) and the repost dies
with 'Out of range value for column stock_value'.

Use the in-memory running valuation rate that update_entries_after
already tracks for the warehouse at this point in the repost. It is the
authoritative pre-entry state, is immune to sibling rows, and makes the
repost idempotent. The database lookup is kept only as a fallback for a
zero in-memory rate, preserving the existing zero-rate fallback chain.
2026-08-08 11:12:41 +05:30
Mihir Kandoi
529ff251fb fix: incorrect entry detection in Stock Ledger Invariant Check (#57886)
(cherry picked from commit b3f97cd389)
2026-08-08 05:31:48 +00:00
Shllokkk
ec7d7cce5c Merge pull request #57881 from frappe/mergify/bp/version-15-hotfix/pr-57880
fix: guard reconciliation table deletes when tables are missing (backport #57880)
2026-08-07 22:00:33 +05:30
Shllokkk
140e06dfa4 fix: guard reconciliation table deletes when tables are missing
(cherry picked from commit 8a2b2a2b68)
2026-08-07 14:34:58 +00:00
Mihir Kandoi
522d7bb1a9 Merge pull request #57869 from frappe/mergify/bp/version-15-hotfix/pr-57866
fix: optimize product bundle item search (backport #57866)
2026-08-07 16:05:07 +05:30
Krishna Shirsath
52a1b124b2 fix: optimize product bundle item search
(cherry picked from commit b3867f1428)
2026-08-07 10:33:15 +00:00
rohitwaghchaure
e2ded11e09 fix: purchase return of batchwise valuation batch valued at original receipt rate instead of batch avg rate (version-15-hotfix) (#57837)
* fix: use current batch avg rate for outward returns of batchwise valuation batches

* fix: honor zero batch average and avoid duplicate batch classification query
2026-08-06 15:20:34 +05:30
pandiyan
ce898a9695 fix: clear deferred revenue/expense fields on uncheck (backport #57140) 2026-08-06 11:25:35 +05:30
Shllokkk
242c950d1b test: assert asset repair downtime recalculates on date change 2026-08-06 00:33:58 +05:30
Shllokkk
0e9c08d494 fix: keep asset repair downtime in sync with entered dates 2026-08-06 00:33:58 +05:30
Mihir Kandoi
1bfe2e25ef Merge pull request #57727 from frappe/mergify/bp/version-15-hotfix/pr-57725
fix(stock): scope over deliver/receive role check to delivery and receipt overflow (backport #57725)
2026-08-05 16:05:25 +05:30
Sudharsanan11
5e7860362e fix(stock): grant account access in blanket order role test 2026-08-05 15:16:27 +05:30
Mihir Kandoi
b25a2f2cdf fix: resolve version-15 backport conflicts
Keep validate_warehouses() and add validate_over_delivery_receipt_allowance();
validate_serial_and_batch_no_settings() does not exist on this branch.

Keep this branch's field labels and add only the non_negative flag the
backported commit introduced.

Drop test_over_order_allowance_against_material_request and
test_blanket_order_over_order_aggregated_across_rows: both are develop-only
context the cherry-pick swallowed into the conflicts, and Buying Settings has
no over_order_allowance field here.

Use frappe.tests.utils.change_settings instead of ERPNextTestSuite, which this
branch does not have.

Revert the valuation_method literal to the three options this branch offers -
Standard Cost rode along from a regenerated develop type block.
2026-08-05 15:16:27 +05:30
Afsal Syed
21c3e07f48 test(stock): prevent settings leakage in purchase order tests
(cherry picked from commit 99630f40eb)

# Conflicts:
#	erpnext/buying/doctype/purchase_order/test_purchase_order.py
2026-08-05 15:16:27 +05:30
Afsal Syed
f5ab55c38d test(stock): add test cases verifying stock over delivery role does not bypass order allowance
(cherry picked from commit 0b271e24b6)

# Conflicts:
#	erpnext/buying/doctype/purchase_order/test_purchase_order.py
#	erpnext/manufacturing/doctype/blanket_order/test_blanket_order.py
2026-08-05 15:16:27 +05:30
Afsal Syed
328c72da18 fix(stock): scope over deliver/receive role check to delivery and receipt overflow
(cherry picked from commit 248873034d)

# Conflicts:
#	erpnext/controllers/status_updater.py
2026-08-05 15:16:27 +05:30
Afsal Syed
7be8a99a23 fix(stock): validate over delivery/receipt allowance in stock settings
(cherry picked from commit 446ec6030a)

# Conflicts:
#	erpnext/stock/doctype/stock_settings/stock_settings.json
#	erpnext/stock/doctype/stock_settings/stock_settings.py
2026-08-05 15:16:27 +05:30
mergify[bot]
ebdc0cdf7f fix(stock): handle multi-item opening balance in Stock Ledger report (backport #57591) (#57795)
* fix(stock): handle multi-item opening balance in Stock Ledger report (#57591)

* fix(stock): handle multi-item opening balance in Stock

* test(stock): add unit test for multi-item Stock Ledger report

---------

Co-authored-by: Afsal Syed <afsalsyed12@gmail.com>
(cherry picked from commit 0dbe410414)

# Conflicts:
#	erpnext/stock/report/stock_ledger/stock_ledger.py
#	erpnext/stock/report/stock_ledger/test_stock_ledger_report.py

* fix(stock): resolve stock ledger backport conflicts

---------

Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
Co-authored-by: Sudharsanan11 <sudharsananashok1975@gmail.com>
2026-08-05 13:15:59 +05:30
mergify[bot]
69c00b3362 fix(accounts receivable): made territory field multi select (backport #57322) (#57808)
fix(accounts receivable): made territory field multi select (#57322)

(cherry picked from commit 1029cd988a)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-08-05 07:35:28 +00:00
Jatin3128
edc8daae21 fix(subscription): don't reactivate a cancelled subscription (backport #57774) (#57780)
* fix(subscription): don't reactivate a cancelled subscription

Backport of #57774 to version-15-hotfix.

set_subscription_status() unconditionally set status to Active once
there was no outstanding invoice, with no check for whether the
subscription had been intentionally cancelled. process()'s
cancel_at_period_end check compared posting_date against
getdate(self.end_date), and getdate(None) returns today, so an empty
end_date was silently treated as "cancel now" on every reprocess.

version-15-hotfix does not yet have the Payment Entry -> invoice ->
refresh_subscription_status() hook chain that #57761 reports (that
was added later), and the scheduler already excludes Cancelled
subscriptions, so the exact automatic repro in the issue does not
apply here. The underlying status logic is defective the same way
though, and reachable directly (e.g. any caller invoking the
whitelisted process()/set_subscription_status() on a cancelled
subscription), so fixing it here too rather than leaving the same
class of bug in place.

Related to #57761

* fix(test): avoid tripping the unrelated period-end cancel clause

The test backdated start_date to 2018-01-01, so by the time process()
ran, current_invoice_end was long past. That made the *other* half of
the cancel_at_period_end OR-condition (period end reached) true on
its own, so process() tried to cancel an already-cancelled
subscription and hit cancel_subscription()'s "already cancelled"
guard - unrelated to the empty end_date bug being tested.

Use nowdate() as start_date so current_invoice_end sits safely in the
future, isolating the assertion to the empty end_date guard. Also
drop the fragile intermediate "Unpaid" status assertion, which
wasn't part of what this test verifies.

* fix(subscription): don't let period rollover defeat cancel_at_period_end

process() can advance current_invoice_end to the next period (via
update_subscription_period(), when generating the current period's
invoice) before the cancel_at_period_end check further down runs. For
a subscription with no end_date, that check now compared posting_date
against the already-rolled-forward current_invoice_end, which is
always in the future, so cancel_at_period_end was silently never
honored.

Snapshot current_invoice_end before any rollover and use that in the
check instead, so it still targets the period that just ended.

Fixes the P1 flagged by Greptile review on this PR.

---------

Co-authored-by: test <test@test.com>
2026-08-05 12:26:01 +05:30
405 changed files with 22577 additions and 3489 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.119.0"
__version__ = "15.121.4"
def get_default_company(user=None):

View File

@@ -49,6 +49,10 @@ class ERPNextAddress(Address):
@frappe.whitelist()
def get_shipping_address(company, address=None):
# `select`, not `read`: the roles that fill in these transactions hold no Company `read` row.
# doc= so the named company is evaluated and User Permissions apply.
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
filters = [
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],
@@ -65,4 +69,6 @@ def get_shipping_address(company, address=None):
if address:
address_as_dict = address[0]
name, address_template = get_address_templates(address_as_dict)
return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict)
return address_as_dict.get("name"), frappe.render_template(
address_template, address_as_dict, restrict_globals=True
)

View File

@@ -25,6 +25,7 @@ def get(
):
if chart_name:
chart = frappe.get_doc("Dashboard Chart", chart_name)
chart.check_permission("read")
else:
chart = frappe._dict(frappe.parse_json(chart))
timespan = chart.timespan
@@ -53,6 +54,9 @@ def get(
)
)
# authorise the account itself, as get_balance_on() does; doc= brings User Permissions with it
frappe.has_permission("Account", doc=account, throw=True)
if not to_date:
to_date = nowdate()
if not from_date:

View File

@@ -191,7 +191,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2024-08-19 15:19:11.095045",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",
@@ -244,6 +244,46 @@
"role": "Accounts Manager",
"share": 1,
"write": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"search_fields": "account_number",

View File

@@ -502,12 +502,19 @@ class Account(NestedSet):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_parent_account(doctype, txt, searchfield, start, page_len, filters):
return frappe.db.sql(
"""select name from tabAccount
where is_group = 1 and docstatus != 2 and company = {}
and {} like {} order by name limit {} offset {}""".format("%s", searchfield, "%s", "%s", "%s"),
(filters["company"], "%%%s%%" % txt, page_len, start),
as_list=1,
return frappe.get_list(
"Account",
filters=[
["is_group", "=", 1],
["docstatus", "!=", 2],
["company", "=", filters["company"]],
[searchfield, "like", f"%{txt}%"],
],
fields=["name"],
order_by="name",
limit_start=start,
limit_page_length=page_len,
as_list=True,
)

View File

@@ -120,6 +120,8 @@ def identify_is_group(child):
def get_chart(chart_template, existing_company=None):
chart = {}
if existing_company:
frappe.has_permission("Company", doc=existing_company, throw=True)
return get_account_tree_from_existing_company(existing_company)
elif chart_template == "Standard":

View File

@@ -207,8 +207,11 @@ def delete_accounting_dimension(doc):
frappe.clear_cache(doctype=doctype)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def disable_dimension(doc):
# toggle_disabling rewrites a Custom Field site-wide, so demand the write that configures dimensions
frappe.has_permission("Accounting Dimension", "write", throw=True)
if frappe.flags.in_test:
toggle_disabling(doc=doc)
else:

View File

@@ -89,13 +89,14 @@
"enable_fuzzy_matching",
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"disable_include_dimensions",
"column_break_lvjk",
"receivable_payable_remarks_length",
"general_ledger_remarks_length",
"accounts_receivable_payable_tuning_section",
"receivable_payable_fetch_method",
"default_ageing_range",
"column_break_ntmi",
"receivable_payable_remarks_length",
"legacy_section",
"ignore_is_opening_check_for_reporting",
"payment_request_settings",
@@ -222,7 +223,8 @@
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
"fieldname": "over_billing_allowance",
"fieldtype": "Currency",
"label": "Over Billing Allowance (%)"
"label": "Over Billing Allowance (%)",
"non_negative": 1
},
{
"default": "1",
@@ -483,7 +485,7 @@
{
"fieldname": "remarks_section",
"fieldtype": "Section Break",
"label": "Remarks Column Length"
"label": "General Ledger Report"
},
{
"default": "0",
@@ -566,7 +568,7 @@
{
"fieldname": "accounts_receivable_payable_tuning_section",
"fieldtype": "Section Break",
"label": "Accounts Receivable / Payable Tuning"
"label": "Accounts Receivable / Payable Report"
},
{
"fieldname": "legacy_section",
@@ -665,6 +667,12 @@
"fieldname": "default_ageing_range",
"fieldtype": "Data",
"label": "Default Ageing Range"
},
{
"default": "0",
"fieldname": "disable_include_dimensions",
"fieldtype": "Check",
"label": "Disable \"Consider Accounting Dimension\" Filter"
}
],
"icon": "icon-cog",
@@ -672,7 +680,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-06-24 12:59:41.868865",
"modified": "2026-09-04 10:08:30.115003",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -44,6 +44,7 @@ class AccountsSettings(Document):
default_ageing_range: DF.Data | None
delete_linked_ledger_entries: DF.Check
determine_address_tax_category_from: DF.Literal["Billing Address", "Shipping Address"]
disable_include_dimensions: DF.Check
enable_common_party_accounting: DF.Check
enable_fuzzy_matching: DF.Check
enable_immutable_ledger: DF.Check

View File

@@ -94,11 +94,11 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"fieldname": "section_break_8",
@@ -187,12 +187,14 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-11-25 11:10:10.945027",
"modified": "2026-05-01 00:38:53.368737",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Advance Taxes and Charges",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "ASC"
}
"sort_order": "ASC",
"states": []
}

View File

@@ -30,6 +30,7 @@ class AdvanceTaxesandCharges(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project: DF.Link | None
rate: DF.Float
row_id: DF.Data | None
tax_amount: DF.Currency

View File

@@ -101,7 +101,7 @@
}
],
"links": [],
"modified": "2020-07-17 14:00:13.105433",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank",
@@ -118,6 +118,14 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "Accounts User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -254,7 +254,7 @@
"link_fieldname": "default_bank_account"
}
],
"modified": "2026-01-20 00:46:16.633364",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",
@@ -284,6 +284,22 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -60,6 +60,9 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
if not isinstance(reference_name, str):
raise TypeError("reference_name must be a string")
# the form is the boundary, not the referenced order: an order guard would break one of the two roles
frappe.has_permission("Bank Guarantee", throw=True)
fields_to_fetch = ["grand_total"]
if bank_guarantee_type == "Receiving":

View File

@@ -68,6 +68,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
frappe.msgprint(__("Please select Bank Account"));
return;
}
frm.events.validate_dates(frm);
frappe.call({
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.auto_reconcile_vouchers",
args: {
@@ -82,7 +83,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
});
frm.add_custom_button(__("Get Unreconciled Entries"), function () {
frm.trigger("make_reconciliation_tool");
return frm.trigger("make_reconciliation_tool");
});
frm.change_custom_button_type(__("Get Unreconciled Entries"), null, "primary");
},
@@ -101,7 +102,24 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
frm.trigger("get_account_opening_balance");
},
validate_dates(frm) {
const from_date = frm.doc.filter_by_reference_date
? frm.doc.from_reference_date
: frm.doc.bank_statement_from_date;
const to_date = frm.doc.filter_by_reference_date
? frm.doc.to_reference_date
: frm.doc.bank_statement_to_date;
if (from_date && to_date && from_date > to_date) {
frappe.throw(
frm.doc.filter_by_reference_date
? __("From Reference Date cannot be greater than To Reference Date")
: __("From Date cannot be greater than To Date")
);
}
},
make_reconciliation_tool(frm) {
frm.events.validate_dates(frm);
frm.get_field("reconciliation_tool_cards").$wrapper.empty();
if (frm.doc.company && frm.doc.bank_account && frm.doc.bank_statement_to_date) {
frm.trigger("get_cleared_balance").then(() => {

View File

@@ -3,13 +3,14 @@
import json
from datetime import date
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import cint, create_batch, flt
from frappe.utils import cint, create_batch, flt, getdate
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
@@ -47,8 +48,12 @@ class BankReconciliationTool(Document):
@frappe.whitelist()
def get_bank_transactions(bank_account, from_date=None, to_date=None):
def get_bank_transactions(
bank_account: str, from_date: str | date | None = None, to_date: str | date | None = None
):
# returns bank transactions for a bank account
validate_date_range(from_date, to_date)
filters = []
filters.append(["bank_account", "=", bank_account])
filters.append(["docstatus", "=", 1])
@@ -372,13 +377,14 @@ def create_payment_entry_bts(
@frappe.whitelist()
def auto_reconcile_vouchers(
bank_account,
from_date=None,
to_date=None,
filter_by_reference_date=None,
from_reference_date=None,
to_reference_date=None,
bank_account: str,
from_date: str | date | None = None,
to_date: str | date | None = None,
filter_by_reference_date: bool | None = None,
from_reference_date: str | date | None = None,
to_reference_date: str | date | None = None,
):
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
bank_transactions = get_bank_transactions(bank_account)
if len(bank_transactions) > 10:
@@ -488,15 +494,16 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
@frappe.whitelist()
def get_linked_payments(
bank_transaction_name,
document_types=None,
from_date=None,
to_date=None,
filter_by_reference_date=None,
from_reference_date=None,
to_reference_date=None,
bank_transaction_name: str,
document_types: str | list[str] | None = None,
from_date: str | date | None = None,
to_date: str | date | None = None,
filter_by_reference_date: bool | None = None,
from_reference_date: str | date | None = None,
to_reference_date: str | date | None = None,
):
# get all matching payments for a bank transaction
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_account = frappe.db.get_values(
"Bank Account", transaction.bank_account, ["account", "company"], as_dict=True
@@ -516,6 +523,23 @@ def get_linked_payments(
return subtract_allocations(gl_account, matching)
def validate_date_range(
from_date,
to_date,
filter_by_reference_date=False,
from_reference_date=None,
to_reference_date=None,
):
if cint(filter_by_reference_date):
from_date, to_date = from_reference_date, to_reference_date
message = _("From Reference Date cannot be greater than To Reference Date")
else:
message = _("From Date cannot be greater than To Date")
if from_date and to_date and getdate(from_date) > getdate(to_date):
frappe.throw(message)
def subtract_allocations(gl_account, vouchers):
"Look up & subtract any existing Bank Transaction allocations"
copied = []

View File

@@ -10,6 +10,7 @@ from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
get_bank_transactions,
get_linked_payments,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -98,3 +99,42 @@ class TestBankReconciliationTool(AccountsTestMixin, FrappeTestCase):
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def test_rejects_reversed_date_ranges(self):
from_date, to_date = today(), add_days(today(), -1)
with self.assertRaisesRegex(frappe.ValidationError, "From Date cannot be greater than To Date"):
get_bank_transactions(self.bank_account, from_date, to_date)
with self.assertRaisesRegex(
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
):
auto_reconcile_vouchers(
self.bank_account,
filter_by_reference_date=True,
from_reference_date=from_date,
to_reference_date=to_date,
)
transaction = (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": today(),
"deposit": 100,
"bank_account": self.bank_account,
"currency": "INR",
}
)
.insert()
.submit()
)
with self.assertRaisesRegex(
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
):
get_linked_payments(
transaction.name,
["payment_entry"],
filter_by_reference_date=True,
from_reference_date=from_date,
to_reference_date=to_date,
)

View File

@@ -105,25 +105,30 @@ class BankStatementImport(DataImport):
@frappe.whitelist()
def get_preview_from_template(data_import, import_file=None, google_sheets_url=None):
return frappe.get_doc("Bank Statement Import", data_import).get_preview_from_template(
import_file, google_sheets_url
)
bsi = frappe.get_doc("Bank Statement Import", data_import)
bsi.check_permission()
return bsi.get_preview_from_template(import_file, google_sheets_url)
@frappe.whitelist()
def form_start_import(data_import):
return frappe.get_doc("Bank Statement Import", data_import).start_import()
bsi = frappe.get_doc("Bank Statement Import", data_import)
bsi.check_permission("write")
return bsi.start_import()
@frappe.whitelist()
def download_errored_template(data_import_name):
data_import = frappe.get_doc("Bank Statement Import", data_import_name)
data_import.check_permission()
data_import.export_errored_rows()
@frappe.whitelist()
def download_import_log(data_import_name):
return frappe.get_doc("Bank Statement Import", data_import_name).download_import_log()
bsi = frappe.get_doc("Bank Statement Import", data_import_name)
bsi.check_permission()
return bsi.download_import_log()
def parse_data_from_template(raw_data):
@@ -252,6 +257,7 @@ def get_import_status(docname):
import_status = {}
data_import = frappe.get_doc("Bank Statement Import", docname)
data_import.check_permission()
import_status["status"] = data_import.status
logs = frappe.get_all(
@@ -290,6 +296,10 @@ def get_import_logs(docname: str):
@frappe.whitelist()
def upload_bank_statement(**args):
# `create`: the sole caller is the Bank Reconciliation Tool's upload button, which routes
# straight into a new Bank Statement Import form.
frappe.has_permission("Bank Statement Import", "create", throw=True)
args = frappe._dict(args)
bsi = frappe.new_doc("Bank Statement Import")

View File

@@ -11,6 +11,9 @@ from frappe.utils.dateutils import parse_date
@frappe.whitelist()
def upload_bank_statement():
# parsing a statement is the first step of creating Bank Transactions from it
frappe.has_permission("Bank Transaction", "create", throw=True)
if getattr(frappe, "uploaded_file", None):
with open(frappe.uploaded_file, "rb") as upfile:
fcontent = upfile.read()
@@ -36,6 +39,11 @@ def upload_bank_statement():
@frappe.whitelist()
def create_bank_entries(columns, data, bank_account):
# insert()/submit() enforce this per document, but only after the row loop has written an
# Error Log for every rejected row -- so check once up front.
frappe.has_permission("Bank Transaction", "create", throw=True)
frappe.has_permission("Bank Account", doc=bank_account, throw=True)
header_map = get_header_mapping(columns, bank_account)
success = 0

View File

@@ -98,13 +98,13 @@ class Budget(Document):
frappe.throw(_("Budget cannot be assigned against Group Account {0}").format(d.account))
elif account_details.company != self.company:
frappe.throw(
_("Account {0} does not belongs to company {1}").format(d.account, self.company)
_("Account {0} does not belong to company {1}").format(d.account, self.company)
)
elif account_details.report_type != "Profit and Loss":
frappe.throw(
_(
"Budget cannot be assigned against {0}, as its Root Type is not of Income or Expense"
).format(self.account)
).format(d.account)
)
if d.account in account_list:

View File

@@ -357,6 +357,16 @@ class TestBudget(unittest.TestCase):
self.assertRaises(BudgetError, jv.submit)
def test_budget_against_balance_sheet_account(self):
budget = frappe.new_doc("Budget")
budget.budget_against = "Cost Center"
budget.cost_center = "_Test Cost Center - _TC"
budget.company = "_Test Company"
budget.fiscal_year = get_fiscal_year(nowdate())[0]
budget.append("accounts", {"account": "_Test Bank - _TC", "budget_amount": 200000})
self.assertRaisesRegex(frappe.ValidationError, "_Test Bank - _TC", budget.insert)
def set_total_expense_zero(posting_date, budget_against_field=None, budget_against_CC=None):
if budget_against_field == "project":

View File

@@ -110,18 +110,6 @@ frappe.ui.form.on("Chart of Accounts Importer", {
args: {
company: frm.doc.company,
},
callback: function (r) {
if (r.message === false) {
frm.set_value("company", "");
frappe.throw(
__(
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
)
);
} else {
frm.trigger("refresh");
}
},
});
}
},

View File

@@ -8,6 +8,7 @@ from functools import reduce
import frappe
from frappe import _
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.desk.form.linked_with import get_linked_fields
from frappe.model.document import Document
from frappe.utils import cint, cstr
@@ -58,6 +59,8 @@ def validate_columns(data):
@frappe.whitelist()
def validate_company(company):
frappe.has_permission("Chart of Accounts Importer", throw=True)
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
)
@@ -70,22 +73,37 @@ def validate_company(company):
frappe.throw(msg, title=_("Wrong Company"))
if frappe.db.get_all("GL Entry", {"company": company}, "name", limit=1):
return False
frappe.throw(
_(
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
)
)
validate_user_perms(company)
@frappe.whitelist()
def import_coa(file_name, company):
frappe.only_for("Accounts Manager")
# delete existing data for accounts
unset_existing_data(company)
frappe.has_permission("Company", "write", company, throw=True)
# create accounts
file_doc, extension = get_file(file_name)
validate_accounts(file_doc, extension)
if extension == "csv":
data = generate_data_from_csv(file_doc)
else:
data = generate_data_from_excel(file_doc, extension)
validate_columns(data)
validate_company(company)
unset_existing_data(company)
frappe.local.flags.ignore_root_company_validation = True
forest = build_forest(data)
create_charts(company, custom_chart=forest, from_coa_importer=True)
@@ -95,7 +113,11 @@ def import_coa(file_name, company):
def get_file(file_name):
file_doc = frappe.get_doc("File", {"file_url": file_name})
# look the file up through find_file_by_url, which returns it only when the caller may download it
file_doc = find_file_by_url(file_name)
if not file_doc:
raise frappe.PermissionError
parts = file_doc.get_extension()
extension = parts[1]
extension = extension.lstrip(".")
@@ -161,6 +183,7 @@ def generate_data_from_excel(file_doc, extension, as_dict=False):
@frappe.whitelist()
def get_coa(doctype, parent, is_root=False, file_name=None, for_validate=0):
"""called by tree view (to fetch node's children)"""
frappe.has_permission("Chart of Accounts Importer", throw=True)
file_doc, extension = get_file(file_name)
parent = None if parent == _("All Accounts") else parent
@@ -309,6 +332,8 @@ def build_response_as_excel(writer):
@frappe.whitelist()
def download_template(file_type, template_type, company):
frappe.has_permission("Chart of Accounts Importer", throw=True)
writer = get_template(template_type, company)
if file_type == "CSV":
@@ -452,6 +477,7 @@ def unset_existing_data(company):
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
linked = [{"fieldname": name} for name in fieldnames]
update_values = {d.get("fieldname"): "" for d in linked}
frappe.db.set_value("Company", company, update_values, update_values)
# remove accounts data from various doctypes
@@ -467,6 +493,19 @@ def unset_existing_data(company):
frappe.qb.from_(dt).where(dt.company == company).delete().run()
def validate_user_perms(company):
# User Permission Check for Account Deletion
company_accounts = frappe.get_query("Account", filters={"company": company}).run(as_dict=1)
for d in company_accounts:
if not frappe.get_cached_doc("Account", d.name).has_permission():
frappe.throw(
_(
"Accounts cannot be removed, as user doesn't have access to all the accounts of {0}."
).format(frappe.bold(company))
)
def set_default_accounts(company):
from erpnext.setup.doctype.company.company import install_country_fixtures

View File

@@ -125,7 +125,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2024-04-24 10:55:54.083042",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cost Center",
@@ -172,6 +172,54 @@
"role": "Employee",
"select": 1,
"share": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Projects Manager",
"select": 1
},
{
"role": "Projects User",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"search_fields": "parent_cost_center, is_group",

View File

@@ -5,7 +5,10 @@ frappe.ui.form.on("Coupon Code", {
setup: function (frm) {
frm.set_query("pricing_rule", function () {
return {
filters: [["Pricing Rule", "coupon_code_based", "=", "1"]],
filters: {
coupon_code_based: 1,
disable: 0,
},
};
});
},

View File

@@ -115,7 +115,7 @@
"read_only": 1
}
],
"modified": "2019-10-19 14:48:14.602481",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Coupon Code",
@@ -168,6 +168,14 @@
"role": "Website Manager",
"share": 1,
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
}
],
"sort_field": "modified",

View File

@@ -41,7 +41,23 @@ class CouponCode(Document):
self.coupon_code = frappe.generate_hash()[:10].upper()
def validate(self):
self.validate_from_to_dates("valid_from", "valid_upto")
self.validate_pricing_rule()
if self.coupon_type == "Gift Card":
self.maximum_use = 1
if not self.customer:
frappe.throw(_("Please select the customer."))
def validate_pricing_rule(self):
if not self.pricing_rule or self.get("from_external_ecomm_platform"):
return
# Allow existing coupons to be updated after their pricing rule is disabled.
if not (
self.has_value_changed("pricing_rule") or self.has_value_changed("from_external_ecomm_platform")
):
return
if frappe.db.get_value("Pricing Rule", self.pricing_rule, "disable"):
frappe.throw(_("Pricing Rule {0} is disabled").format(frappe.bold(self.pricing_rule)))

View File

@@ -115,8 +115,46 @@ class TestCouponCode(unittest.TestCase):
test_create_test_data()
def tearDown(self):
frappe.db.rollback()
frappe.set_user("Administrator")
def test_disabled_pricing_rule_validation(self):
coupon = frappe.get_doc("Coupon Code", "SAVE30")
rule = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
rule.disable = 1
rule.save()
with self.subTest("new coupon cannot select a disabled rule"):
new_coupon = frappe.copy_doc(coupon)
new_coupon.coupon_name = "Festival Savings"
new_coupon.coupon_code = "FESTSAVE"
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
new_coupon.insert()
with self.subTest("existing coupon can retain a disabled rule"):
coupon.description = "Offer paused"
coupon.save()
coupon.reload()
self.assertEqual(coupon.description, "Offer paused")
self.assertEqual(coupon.pricing_rule, rule.name)
with self.subTest("existing coupon cannot switch to a disabled rule"):
disabled_rule = frappe.copy_doc(rule)
disabled_rule.insert()
coupon.reload()
coupon.pricing_rule = disabled_rule.name
with self.assertRaisesRegex(frappe.ValidationError, "is disabled"):
coupon.save()
coupon.reload()
self.assertEqual(coupon.pricing_rule, rule.name)
def test_cannot_save_coupon_with_reversed_validity_dates(self):
coupon = frappe.get_doc("Coupon Code", "SAVE30")
coupon.valid_from = "2026-09-17"
coupon.valid_upto = "2026-09-02"
with self.assertRaises(frappe.exceptions.InvalidDates):
coupon.save()
def test_sales_order_with_coupon_code(self):
frappe.db.set_value("Coupon Code", "SAVE30", "used", 0)

View File

@@ -234,8 +234,10 @@ frappe.ui.form.on("Dunning", {
dn: frm.doc.name,
},
callback: function (r) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
if (!r.exc) {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
}
},
});
},

View File

@@ -17,7 +17,8 @@ import json
import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_address_display
from frappe.utils import getdate
from frappe.query_builder.functions import Sum
from frappe.utils import flt, getdate
from erpnext.controllers.accounts_controller import AccountsController
@@ -140,6 +141,31 @@ class Dunning(AccountsController):
)
row.dunning_level = len(past_dunnings) + 1
def get_unpaid_base_dunning_amount(self):
"""Interest and dunning fee that is still to be collected, in company currency."""
if not self.base_dunning_amount:
return 0.0
return flt(
flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name),
self.precision("base_dunning_amount"),
)
def get_unpaid_dunning_amount(self):
"""Interest and dunning fee that is still to be collected, in the dunning currency."""
return flt(
self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1),
self.precision("dunning_amount"),
)
def get_unpaid_overdue_payments(self):
"""Overdue payments with their outstanding as of now, not as of dunning creation."""
return [
(row, outstanding)
for row in self.overdue_payments
if (outstanding := get_current_outstanding(row)) > 0
]
def on_cancel(self):
super().on_cancel()
self.ignore_linked_doctypes = [
@@ -154,6 +180,7 @@ class Dunning(AccountsController):
"Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
"Payment Entry",
]
@frappe.whitelist()
@@ -252,11 +279,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount):
if has_outstanding:
break
new_status = "Resolved" if not has_outstanding else "Unresolved"
set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True)
if dunning.status != new_status:
dunning.status = new_status
dunning.save()
def update_dunnings_linked_to_payment(payment_entry):
"""Refresh dunnings whose interest and fee are settled by this payment."""
dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning}
for name in dunnings:
dunning = frappe.get_doc("Dunning", name)
if dunning.docstatus != 1:
continue
set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments()))
def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False):
"""A dunning is only resolved once the invoiced sum *and* its interest and fee are paid."""
has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0
new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved"
# resolving by hand waives the interest, only an invoice that is owed again reopens it
if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments:
return
if dunning.status != new_status:
dunning.db_set("status", new_status, notify=True)
def get_paid_dunning_amount(dunning: str) -> float:
"""Interest and fee collected for this dunning, in company currency."""
deduction = frappe.qb.DocType("Payment Entry Deduction")
payment_entry = frappe.qb.DocType("Payment Entry")
paid = (
frappe.qb.from_(deduction)
.join(payment_entry)
.on(payment_entry.name == deduction.parent)
.select(Sum(deduction.amount))
.where((deduction.dunning == dunning) & (payment_entry.docstatus == 1))
).run()
# the dunning amount is booked as a negative deduction, against the income account
return -flt(paid[0][0]) if paid else 0.0
def get_current_outstanding(overdue_payment) -> float:
"""Outstanding of an overdue payment as of now, in the invoice's transaction currency."""
invoice = frappe.db.get_value(
"Sales Invoice",
overdue_payment.sales_invoice,
["outstanding_amount", "currency", "party_account_currency"],
as_dict=True,
)
schedule_outstanding = (
flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding"))
if overdue_payment.payment_schedule
else flt(overdue_payment.outstanding)
)
if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0:
return 0.0
outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding))
if invoice.currency == invoice.party_account_currency:
outstanding = min(outstanding, flt(invoice.outstanding_amount))
return outstanding
def get_linked_dunnings_as_per_state(sales_invoice, state):

View File

@@ -16,6 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice 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,
)
@@ -71,6 +72,123 @@ class TestDunning(FrappeTestCase):
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self):
"""
Regression for #58220: paying the invoice without the interest and fee must not
resolve the dunning, the interest is still owed and has to stay claimable.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
sales_invoice = dunning.overdue_payments[0].sales_invoice
pe = get_payment_entry("Sales Invoice", sales_invoice)
pe.reference_no, pe.reference_date = "4", nowdate()
pe.insert()
pe.submit()
self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0)
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
# the interest and fee can still be collected on their own
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "5", nowdate()
self.assertEqual(pe.references, [])
self.assertEqual(round(pe.paid_amount, 2), 10.41)
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
self.assertEqual(dunning.get_unpaid_dunning_amount(), 0)
# cancelling the interest payment makes the dunning claimable again
pe.cancel()
dunning.reload()
self.assertEqual(dunning.status, "Unresolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
def test_dunning_can_be_cancelled_after_its_interest_was_paid(self):
"""
The payment collecting the interest links back to the dunning, which must not stand in
the way of cancelling it.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "6", nowdate()
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
dunning.cancel()
self.assertEqual(dunning.docstatus, 2)
def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self):
"""
Resolving a dunning by hand waives its interest, so a later payment of the invoice
must not reopen it.
"""
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
sales_invoice = dunning.overdue_payments[0].sales_invoice
# what the "Resolve" button does
dunning.reload()
dunning.status = "Resolved"
dunning.save()
pe = get_payment_entry("Sales Invoice", sales_invoice)
pe.reference_no, pe.reference_date = "7", nowdate()
pe.insert()
pe.submit()
dunning.reload()
self.assertEqual(dunning.status, "Resolved")
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
def test_unpaid_dunning_amount_is_tracked_in_company_currency(self):
"""
The interest and fee are collected as a Payment Entry deduction, a company currency
field, so what is left to collect has to be measured in the same currency.
"""
si = create_sales_invoice(
posting_date=add_days(today(), -15),
customer="_Test Customer USD",
currency="USD",
conversion_rate=50,
rate=100,
debit_to="_Test Receivable USD - _TC",
)
dunning = create_dunning_from_sales_invoice(si.name)
dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC")
dunning.dunning_type = dunning_type.name
dunning.rate_of_interest = dunning_type.rate_of_interest
dunning.dunning_fee = dunning_type.dunning_fee
dunning.income_account = dunning_type.income_account
dunning.cost_center = dunning_type.cost_center
dunning.save()
self.assertEqual(dunning.currency, "USD")
self.assertEqual(dunning.conversion_rate, 50)
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55)
# nothing collected yet, in either currency
self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55)
self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41)
# the deduction booking the interest is in company currency
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
self.assertEqual(round(pe.deductions[0].amount, 2), -520.55)
def test_fetch_overdue_payments(self):
"""
Create SI with overdue payment. Check if overdue payment is fetched in Dunning.

View File

@@ -56,7 +56,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2018-05-09 14:55:01.394387",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Finance Book",
@@ -119,6 +119,18 @@
"share": 1,
"submit": 0,
"write": 0
},
{
"role": "HR Manager",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -82,7 +82,7 @@
"icon": "fa fa-calendar",
"idx": 1,
"links": [],
"modified": "2024-05-27 17:29:55.560840",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Fiscal Year",
@@ -131,6 +131,10 @@
{
"read": 1,
"role": "Auditor"
},
{
"role": "Sales Master Manager",
"select": 1
}
],
"show_name_in_global_search": 1,

View File

@@ -136,6 +136,7 @@ frappe.ui.form.on("Invoice Discounting", {
],
primary_action: function () {
var data = d.get_values();
data.company = frm.doc.company;
frappe.call({
method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices",

View File

@@ -168,7 +168,7 @@
}
],
"is_submittable": 1,
"modified": "2019-05-30 19:08:21.199759",
"modified": "2026-09-09 17:04:59.512294",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Invoice Discounting",
@@ -185,7 +185,7 @@
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"role": "Accounts Manager",
"share": 1,
"submit": 1,
"write": 1
@@ -194,4 +194,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
def get_invoices(filters):
filters = frappe._dict(json.loads(filters))
if not filters.get("company"):
frappe.throw(_("Please set company on the Document before requesting for invoices."))
frappe.has_permission("Company", doc=filters.get("company"), throw=True)
frappe.has_permission("Invoice Discounting", throw=True)
cond = []
if filters.customer:
cond.append("customer=%(customer)s")

View File

@@ -47,3 +47,12 @@ frappe.ui.form.on("Item Tax Template", {
});
},
});
frappe.ui.form.on("Item Tax Template Detail", {
not_applicable: function (frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.not_applicable) {
frappe.model.set_value(cdt, cdn, "tax_rate", 0);
}
},
});

View File

@@ -57,7 +57,7 @@
}
],
"links": [],
"modified": "2023-07-09 18:11:23.105589",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Item Tax Template",
@@ -95,6 +95,54 @@
"report": 1,
"role": "Accounts User",
"share": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Item Manager",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
},
{
"role": "Stock User",
"select": 1
}
],
"show_name_in_global_search": 1,

View File

@@ -27,8 +27,15 @@ class ItemTaxTemplate(Document):
# end: auto-generated types
def validate(self):
self.set_zero_rate_for_not_applicable_tax()
self.validate_tax_accounts()
def set_zero_rate_for_not_applicable_tax(self):
"""Ensure tax_rate is 0 for any row marked as not applicable."""
for row in self.get("taxes"):
if row.not_applicable:
row.tax_rate = 0
def autoname(self):
if self.company and self.title:
abbr = frappe.get_cached_value("Company", self.company, "abbr")

View File

@@ -8,6 +8,6 @@ def get_data():
{"label": _("Pre Sales"), "items": ["Quotation", "Supplier Quotation"]},
{"label": _("Sales"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
{"label": _("Purchase"), "items": ["Purchase Invoice", "Purchase Order", "Purchase Receipt"]},
{"label": _("Stock"), "items": ["Item Groups", "Item"]},
{"label": _("Stock"), "items": ["Item Group", "Item"]},
],
}

View File

@@ -3,6 +3,142 @@
import unittest
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates import (
execute as backfill_not_applicable,
)
class TestItemTaxTemplate(unittest.TestCase):
pass
class TestGermanNotApplicableBackfill(FrappeTestCase):
"""Run the `not_applicable` backfill patch against a seeded German company.
The company is created from the shipped German defaults, so the templates the
patch has to recognise are the ones a real site got. Each test resets the flag
to its pre-patch state (`not_applicable = 0`) and runs the patch.
"""
TITLES = ("19 %", "7 %", "0%")
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = "_Test German Item Tax Templates"
if not frappe.db.exists("Company", cls.company):
frappe.get_doc(
{
"doctype": "Company",
"company_name": cls.company,
"abbr": "_TGITT",
"country": "Germany",
"default_currency": "EUR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
).insert()
cls.templates = {
title: frappe.db.get_value("Item Tax Template", {"company": cls.company, "title": title}, "name")
for title in cls.TITLES
}
assert all(cls.templates.values()), f"German defaults not seeded: {cls.templates}"
def setUp(self):
frappe.db.savepoint("before_backfill_test")
self.addCleanup(frappe.db.rollback, save_point="before_backfill_test")
self.seeded_flags = self.flagged_rows()
# every default template ships not-applicable rows, otherwise the patch
# would be tested against effectively empty data
for title in self.TITLES:
self.assertTrue(self.seeded_flags[title], f"no not-applicable rows seeded in {title}")
def flagged_rows(self, title=None) -> dict[str, set]:
"""Detail rows currently marked as not applicable, per template title."""
return {
t: {
d.name
for d in frappe.get_all(
"Item Tax Template Detail",
filters={"parent": name, "not_applicable": 1},
fields=["name"],
)
}
for t, name in self.templates.items()
if title in (None, t)
}
def clear_flags(self):
"""Restore the pre-patch state: zero rate, no flag."""
for name in self.templates.values():
frappe.db.set_value(
"Item Tax Template Detail",
{"parent": name},
"not_applicable",
0,
update_modified=False,
)
self.assertEqual(self.flagged_rows(), {t: set() for t in self.TITLES})
def add_zero_rate_row(self, title, account_name, account_number):
"""Add a user-defined zero-rate row, as a customised site would have."""
like_account = frappe.db.get_value(
"Account", {"company": self.company, "account_name": "Umsatzsteuer 19 %"}, "name"
)
account = frappe.get_doc(
{
"doctype": "Account",
"company": self.company,
"account_name": account_name,
"account_number": account_number,
"account_type": "Tax",
"parent_account": frappe.db.get_value("Account", like_account, "parent_account"),
}
).insert()
template = frappe.get_doc("Item Tax Template", self.templates[title])
template.append("taxes", {"tax_type": account.name, "tax_rate": 0})
template.save()
def test_backfills_unmodified_defaults(self):
self.clear_flags()
backfill_not_applicable()
self.assertEqual(self.flagged_rows(), self.seeded_flags)
def test_keeps_customised_template_untouched(self):
self.clear_flags()
self.add_zero_rate_row("19 %", "Sonstige Umsatzsteuer", "9998")
backfill_not_applicable()
self.assertEqual(self.flagged_rows("19 %"), {"19 %": set()})
self.assertEqual(self.flagged_rows("7 %"), {"7 %": self.seeded_flags["7 %"]})
def test_keeps_duplicate_account_name_untouched(self):
"""A numbered account can share `account_name` with a default one.
Its identifier collapses onto the default's, so only the row count tells
the customised template apart from an untouched one.
"""
self.clear_flags()
self.add_zero_rate_row("7 %", "Umsatzsteuer 19 %", "9999")
backfill_not_applicable()
self.assertEqual(self.flagged_rows("7 %"), {"7 %": set()})
self.assertEqual(self.flagged_rows("19 %"), {"19 %": self.seeded_flags["19 %"]})
def test_rerun_changes_nothing(self):
def snapshot():
return frappe.get_all(
"Item Tax Template Detail",
filters={"parent": ("in", tuple(self.templates.values()))},
fields=["name", "not_applicable", "tax_rate", "modified"],
order_by="name",
)
before = snapshot()
backfill_not_applicable()
self.assertEqual(snapshot(), before)

View File

@@ -6,7 +6,8 @@
"engine": "InnoDB",
"field_order": [
"tax_type",
"tax_rate"
"tax_rate",
"not_applicable"
],
"fields": [
{
@@ -21,12 +22,21 @@
"fieldname": "tax_rate",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Tax Rate"
"label": "Tax Rate",
"read_only_depends_on": "eval:doc.not_applicable"
},
{
"default": "0",
"description": "Check if this tax is not applicable to items (distinct from 0% rate)",
"fieldname": "not_applicable",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Not Applicable"
}
],
"istable": 1,
"links": [],
"modified": "2026-04-30 23:49:27.020639",
"modified": "2026-04-30 23:59:22.020639",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Item Tax Template Detail",

View File

@@ -14,6 +14,7 @@ class ItemTaxTemplateDetail(Document):
if TYPE_CHECKING:
from frappe.types import DF
not_applicable: DF.Check
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -65,7 +65,7 @@ frappe.ui.form.on("Journal Entry", {
);
}
if (frm.doc.docstatus == 1) {
if (frm.doc.docstatus == 1 && !frm.doc.reversal_of) {
frm.add_custom_button(
__("Reverse Journal Entry"),
function () {
@@ -421,8 +421,8 @@ cur_frm.cscript.update_totals = function (doc) {
tc += flt(accounts[i].credit, precision("credit", accounts[i]));
}
doc = locals[doc.doctype][doc.name];
doc.total_debit = td;
doc.total_credit = tc;
doc.total_debit = flt(td, precision("total_debit"));
doc.total_credit = flt(tc, precision("total_credit"));
doc.difference = flt(td - tc, precision("difference"));
refresh_many(["total_debit", "total_credit", "difference"]);
};
@@ -516,7 +516,7 @@ $.extend(erpnext.journal_entry, {
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => field.df.fieldname != "posting_date")
.filter((field) => !["posting_date", "user_remark"].includes(field.df.fieldname))
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -7,7 +7,8 @@ import json
import frappe
from frappe import _, msgprint, scrub
from frappe.core.doctype.submission_queue.submission_queue import queue_submission
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, nowdate
from frappe.model.document import Document
from frappe.utils import comma_and, cstr, flt, fmt_money, formatdate, get_link_to_form, getdate, nowdate
import erpnext
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
@@ -154,7 +155,8 @@ class JournalEntry(AccountsController):
if self.docstatus == 0:
self.apply_tax_withholding()
if self.is_new() or not self.title:
if not self.title or (self.is_new() and self.amended_from):
self.title = self.get_title()
def validate_advance_accounts(self):
@@ -798,6 +800,23 @@ class JournalEntry(AccountsController):
)
)
if reference_type == "Purchase Invoice":
on_hold, release_date = frappe.db.get_value(
reference_type, reference_name, ["on_hold", "release_date"]
)
if not on_hold or (release_date and getdate(release_date) <= getdate(nowdate())):
continue
msg = (
_("{0} {1} is blocked and on hold until {2}.").format(
reference_type, reference_name, release_date
)
if release_date
else _("{0} {1} is blocked.").format(reference_type, reference_name)
)
frappe.throw(msg)
def set_against_account(self):
accounts_debited, accounts_credited = [], []
if self.voucher_type in ("Deferred Revenue", "Deferred Expense"):
@@ -842,12 +861,14 @@ class JournalEntry(AccountsController):
if d.debit and d.credit:
frappe.throw(_("You cannot credit and debit same account at the same time"))
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
self.total_debit = flt(
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
)
self.total_credit = flt(
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
)
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
self.total_credit, self.precision("total_credit")
)
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
def validate_multi_currency(self):
alternate_currency = []
@@ -1458,6 +1479,10 @@ class JournalEntryTaxWithholding:
def get_default_bank_cash_account(company, account_type=None, mode_of_payment=None, account=None):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
# `select`, not `read`: also runs server-side from get_payment_entry, and Auditor/HR User/
# Desk User hold only the select row on Company. doc= for User Permissions.
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
if mode_of_payment:
account = get_bank_cash_account(mode_of_payment, company).get("account")
@@ -1486,6 +1511,10 @@ def get_default_bank_cash_account(company, account_type=None, mode_of_payment=No
account = account_list[0].name
if account:
# `account` is caller-supplied; get_balance_on() checks it only on the branch that reads a
# balance, and `fetch_balance` is caller-supplied too.
frappe.has_permission("Account", doc=account, throw=True)
account_details = frappe.get_cached_value(
"Account", account, ["account_currency", "account_type"], as_dict=1
)
@@ -1507,6 +1536,7 @@ def get_payment_entry_against_order(
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
):
ref_doc = frappe.get_doc(dt, dn)
ref_doc.check_permission()
if flt(ref_doc.per_billed, 2) > 0:
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
@@ -1552,6 +1582,8 @@ def get_payment_entry_against_invoice(
dt, dn, amount=None, debit_in_account_currency=None, journal_entry=False, bank_account=None
):
ref_doc = frappe.get_doc(dt, dn)
ref_doc.check_permission()
if dt == "Sales Invoice":
party_type = "Customer"
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
@@ -1587,6 +1619,8 @@ def get_payment_entry_against_invoice(
def get_payment_entry(ref_doc, args):
frappe.has_permission("Journal Entry", ptype="create", throw=True)
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
"Company", ref_doc.company, "cost_center"
)
@@ -1666,29 +1700,39 @@ def get_against_jv(doctype, txt, searchfield, start, page_len, filters):
if not frappe.db.has_column("Journal Entry", searchfield):
return []
return frappe.db.sql(
f"""
SELECT jv.name, jv.posting_date, jv.user_remark
FROM `tabJournal Entry` jv, `tabJournal Entry Account` jv_detail
WHERE jv_detail.parent = jv.name
AND jv_detail.account = %(account)s
AND IFNULL(jv_detail.party, '') = %(party)s
AND (
jv_detail.reference_type IS NULL
OR jv_detail.reference_type = ''
)
AND jv.docstatus = 1
AND jv.`{searchfield}` LIKE %(txt)s
ORDER BY jv.name DESC
LIMIT %(limit)s offset %(offset)s
""",
dict(
account=filters.get("account"),
party=cstr(filters.get("party")),
txt=f"%{txt}%",
offset=start,
limit=page_len,
),
account = filters.get("account")
party = filters.get("party")
# each names one value. A list would be read as a filter operator below and widen the search
# past what the caller named.
for value in (account, party):
if value and not isinstance(value, str):
frappe.throw(_("Invalid filter"), frappe.PermissionError)
# get_list applies the permission query conditions; the child-table filter resolves the check to `read`
je_filters = [
["docstatus", "=", 1],
[searchfield, "like", f"%{txt}%"],
["Journal Entry Account", "account", "=", account],
["Journal Entry Account", "reference_type", "is", "not set"],
]
je_filters.append(
["Journal Entry Account", "party", "=", party]
if party
else ["Journal Entry Account", "party", "is", "not set"]
)
return frappe.get_list(
"Journal Entry",
filters=je_filters,
fields=["name", "posting_date", "user_remark"],
order_by="name desc",
limit_start=start,
limit_page_length=page_len,
as_list=True,
# one row per entry, not per matching account row. group_by rather than distinct: frappe
# drops ORDER BY from a distinct query on postgres, which would lose the ordering above.
group_by="name",
)
@@ -1874,11 +1918,29 @@ def make_inter_company_journal_entry(name, voucher_type, company):
@frappe.whitelist()
def make_reverse_journal_entry(source_name, target_doc=None):
def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Document | None = None) -> Document:
# `get_mapped_doc` checks this as well, but the guard below discloses which entry
# reverses which, so read access has to be settled before it runs
if not frappe.has_permission("Journal Entry", doc=source_name):
frappe.throw(_("Not permitted"), frappe.PermissionError)
reversal_of = frappe.db.get_value("Journal Entry", source_name, "reversal_of")
if reversal_of:
frappe.throw(
_("{0} is already a Reverse Journal Entry of {1}. Cancel it instead of reversing it.").format(
get_link_to_form("Journal Entry", source_name),
get_link_to_form("Journal Entry", reversal_of),
)
)
from frappe.model.mapper import get_mapped_doc
def post_process(source, target):
target.reversal_of = source.name
target.naming_series = source.naming_series
if source.voucher_type == "Bank Entry":
target.cheque_no = source.cheque_no
target.cheque_date = source.cheque_date
doclist = get_mapped_doc(
"Journal Entry",

View File

@@ -6,7 +6,7 @@ import unittest
import frappe
from frappe.tests.utils import change_settings
from frappe.utils import flt, nowdate
from frappe.utils import add_days, flt, nowdate
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction
@@ -249,6 +249,27 @@ class TestJournalEntry(unittest.TestCase):
self.check_gl_entries()
def test_disallow_reversal_of_a_reversal_journal_entry(self):
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
jv = make_journal_entry("_Test Bank - _TC", "Sales - _TC", 100, submit=True)
rjv = make_reverse_journal_entry(jv.name)
rjv.posting_date = nowdate()
rjv.submit()
self.assertRaisesRegex(
frappe.ValidationError,
"is already a Reverse Journal Entry",
make_reverse_journal_entry,
rjv.name,
)
# the guard must not disclose the reversal to a user who cannot read the entry
frappe.set_user("Guest")
self.addCleanup(frappe.set_user, "Administrator")
self.assertRaises(frappe.PermissionError, make_reverse_journal_entry, rjv.name)
def test_disallow_change_in_account_currency_for_a_party(self):
# create jv in USD
jv = make_journal_entry("_Test Bank USD - _TC", "_Test Receivable USD - _TC", 100, save=False)
@@ -389,6 +410,59 @@ class TestJournalEntry(unittest.TestCase):
self.check_gl_entries()
def make_jv_with_fractional_totals(self):
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
jv = frappe.new_doc("Journal Entry")
jv.posting_date = nowdate()
jv.company = "_Test Company"
jv.voucher_type = "Journal Entry"
jv.remark = "test"
for amount in (0.10, 0.20):
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": amount,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 0.30,
},
)
jv.insert()
return jv
def test_totals_are_rounded_to_precision(self):
jv = self.make_jv_with_fractional_totals()
jv.submit()
stored = frappe.db.get_value(
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
)
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
self.assertEqual(jv.total_debit, stored.total_debit)
self.assertEqual(jv.total_credit, stored.total_credit)
self.assertEqual(jv.difference, stored.difference)
def test_update_after_submit_with_fractional_totals(self):
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
jv = self.make_jv_with_fractional_totals()
jv.submit()
jv.pay_to_recd_from = "_Test Supplier"
jv.save()
self.assertEqual(jv.docstatus, 1)
self.assertEqual(
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
)
def test_jv_account_and_party_balance_with_cost_centre(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.utils import get_balance_on
@@ -602,6 +676,69 @@ class TestJournalEntry(unittest.TestCase):
jv.save()
self.assertRaises(frappe.ValidationError, jv.submit)
def make_jv_against_purchase_invoice(self, invoice, amount=100):
jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False)
jv.accounts[0].party_type = "Supplier"
jv.accounts[0].party = invoice.supplier
jv.accounts[0].reference_type = "Purchase Invoice"
jv.accounts[0].reference_name = invoice.name
return jv
def test_jv_against_purchase_invoice_respects_hold_state(self):
"""Payment can be booked against a Purchase Invoice only while it is not on hold."""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
release_date = add_days(nowdate(), 10)
def never_held():
return make_purchase_invoice()
def held_until_a_future_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
return invoice
def held_without_a_release_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Under dispute")
return invoice
def held_until_a_date_that_has_passed():
invoice = held_until_a_future_date()
frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1))
return invoice
def unblocked_again():
invoice = held_until_a_future_date()
invoice.unblock_invoice()
return invoice
for build_invoice in (held_until_a_future_date, held_without_a_release_date):
with self.subTest(build_invoice.__name__):
jv = self.make_jv_against_purchase_invoice(build_invoice())
self.assertRaisesRegex(frappe.ValidationError, "is blocked", jv.insert)
for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again):
with self.subTest(build_invoice.__name__):
invoice = build_invoice()
jv = self.make_jv_against_purchase_invoice(invoice)
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice")
def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self):
"""A Sales Invoice has no hold state, so the check must skip it rather than fail."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
def make_journal_entry(
account1,

View File

@@ -56,7 +56,9 @@ class LedgerMerge(Document):
@frappe.whitelist()
def form_start_merge(docname):
return frappe.get_doc("Ledger Merge", docname).start_merge()
lm_doc = frappe.get_doc("Ledger Merge", docname)
lm_doc.check_permission("write")
return lm_doc.start_merge()
def start_merge(docname):

View File

@@ -152,7 +152,7 @@
"options": "Project"
}
],
"modified": "2019-05-26 09:11:46.120251",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Loyalty Program",
@@ -169,6 +169,14 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -86,6 +86,8 @@ def get_loyalty_program_details_with_points(
include_expired_entry=False,
current_transaction_amount=0,
):
frappe.has_permission("Customer", doc=customer, throw=True)
lp_details = get_loyalty_program_details(customer, loyalty_program, company=company, silent=silent)
loyalty_program = frappe.get_doc("Loyalty Program", loyalty_program)
lp_details.update(
@@ -116,6 +118,10 @@ def get_loyalty_program_details(
silent=False,
include_expired_entry=False,
):
# authorise the customer, not the programme: a Loyalty Program check is read-only to System
# Manager and would deny every role that fills in the two calling forms.
frappe.has_permission("Customer", doc=customer, throw=True)
lp_details = frappe._dict()
if not loyalty_program:
@@ -137,6 +143,13 @@ def get_loyalty_program_details(
@frappe.whitelist()
def get_redeemption_factor(loyalty_program=None, customer=None):
# both call sites send only `loyalty_program`, so the calling form is the boundary; the customer branch stays guarded
if not (frappe.has_permission("Sales Invoice") or frappe.has_permission("POS Invoice")):
frappe.throw(_("Not permitted"), frappe.PermissionError)
if customer:
frappe.has_permission("Customer", doc=customer, throw=True)
customer_loyalty_program = None
if not loyalty_program:
customer_loyalty_program = frappe.db.get_value("Customer", customer, "loyalty_program")

View File

@@ -48,7 +48,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-09-18 17:57:23.835236",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Mode of Payment",
@@ -68,6 +68,30 @@
"read": 1,
"report": 1,
"role": "Accounts User"
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -46,7 +46,7 @@
"icon": "fa fa-bar-chart",
"idx": 1,
"links": [],
"modified": "2024-01-30 13:57:55.802744",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Monthly Distribution",
@@ -69,6 +69,10 @@
"read": 1,
"report": 1,
"role": "Accounts Manager"
},
{
"role": "Sales Master Manager",
"select": 1
}
],
"sort_field": "modified",

View File

@@ -282,6 +282,9 @@ def start_import(invoices):
invoice_number = d.invoice_number
doc = frappe.get_doc(d)
doc.flags.ignore_mandatory = True
# the outstanding amount is entered inclusive of tax, so taxes must not
# be added on top of it
doc.flags.dont_auto_add_taxes = True
doc.insert(set_name=invoice_number)
doc.submit()
frappe.db.commit()

View File

@@ -2,9 +2,10 @@
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, today
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
@@ -12,6 +13,7 @@ from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension imp
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
from erpnext.projects.doctype.project.test_project import make_project
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
@@ -140,6 +142,55 @@ class TestOpeningInvoiceCreationTool(FrappeTestCase):
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
@change_settings(
"Accounts Settings",
{"add_taxes_from_taxes_and_charges_template": 1, "add_taxes_from_item_tax_template": 0},
)
def test_opening_invoice_creation_without_taxes(self):
company = "_Test Opening Invoice Company"
template = frappe.get_doc(
{
"doctype": "Sales Taxes and Charges Template",
"company": company,
"title": "_Test Opening Invoice Tax",
"taxes": [
{
"charge_type": "On Net Total",
"account_head": create_account(
account_name="_Test Opening Tax Account",
parent_account="Duties and Taxes - _TOIC",
account_type="Tax",
company=company,
),
"description": "Test taxes",
"rate": 9,
}
],
}
).insert()
# makes the template the default for the party, as it would be on a live site
make_tax_rule(tax_type="Sales", company=company, sales_tax_template=template.name, save=1)
tool = self.make_invoices(company=company, return_doc=True)
invoices = tool.make_invoices()
self.assertEqual(len(invoices), 2)
# outstanding amount is entered inclusive of tax, so taxes must not be added on top of it
for invoice in invoices:
si = frappe.get_doc("Sales Invoice", invoice)
self.assertFalse(si.taxes)
self.assertEqual(si.grand_total, 200)
self.assertEqual(si.outstanding_amount, 200)
# the same invoice created outside the tool keeps the default taxes,
# since adding them there is the user's decision
si = frappe.get_doc(tool.get_invoices()[0])
si.flags.ignore_mandatory = True
si.insert()
self.assertTrue(si.taxes)
self.assertEqual(si.grand_total, 218)
def test_opening_entry_project_linking(self):
doc = self.make_invoices(
company="_Test Opening Invoice Company", invoice_type="Sales", return_doc=True

View File

@@ -45,23 +45,27 @@ frappe.ui.form.on("Payment Entry", {
},
setup: function (frm) {
frm.set_query("paid_from", function () {
frm.set_query("paid_from", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
filters.name = ["!=", doc.paid_to];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});
@@ -105,21 +109,25 @@ frappe.ui.form.on("Payment Entry", {
}
});
frm.set_query("paid_to", function () {
frm.set_query("paid_to", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
filters.name = ["!=", doc.paid_from];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});

View File

@@ -3,6 +3,7 @@
import json
from datetime import date
from functools import reduce
import frappe
@@ -90,6 +91,7 @@ class PaymentEntry(AccountsController):
self.set_liability_account()
self.set_missing_ref_details(force=True)
self.validate_payment_type()
self.validate_internal_transfer_accounts()
self.validate_party_details()
self.set_exchange_rate()
self.validate_mandatory()
@@ -121,8 +123,14 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule()
self.make_gl_entries()
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.set_status()
def update_linked_dunnings(self):
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
update_dunnings_linked_to_payment(self)
def validate_for_repost(self):
validate_docs_for_voucher_types(["Payment Entry"])
validate_docs_for_deferred_accounting([self.name], [])
@@ -224,6 +232,7 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule(cancel=1)
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.delink_advance_entry_references()
self.set_status()
@@ -531,6 +540,10 @@ class PaymentEntry(AccountsController):
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
frappe.throw(_("Payment Type must be one of Receive, Pay and Internal Transfer"))
def validate_internal_transfer_accounts(self):
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
def validate_party_details(self):
if self.party and not frappe.db.exists(self.party_type, self.party):
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
@@ -2886,18 +2899,21 @@ def get_reference_details(
@frappe.whitelist()
def get_payment_entry(
dt,
dn,
party_amount=None,
bank_account=None,
bank_amount=None,
party_type=None,
payment_type=None,
reference_date=None,
ignore_permissions=False,
created_from_payment_request=False,
dt: str,
dn: str,
party_amount: int | float | None = None,
bank_account: str | None = None,
bank_amount: int | float | None = None,
party_type: str | None = None,
payment_type: str | None = None,
reference_date: str | date | None = None,
created_from_payment_request: bool | None = False,
):
frappe.has_permission("Payment Entry", ptype="create", throw=True)
doc = frappe.get_doc(dt, dn)
doc.check_permission()
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (100.0 + over_billing_allowance):
frappe.throw(_("Can only make payment against unbilled {0}").format(_(dt)))
@@ -2992,7 +3008,7 @@ def get_payment_entry(
pe.append("references", reference)
else:
if dt == "Dunning":
for overdue_payment in doc.overdue_payments:
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
pe.append(
"references",
{
@@ -3000,21 +3016,23 @@ def get_payment_entry(
"reference_name": overdue_payment.sales_invoice,
"payment_term": overdue_payment.payment_term,
"due_date": overdue_payment.due_date,
"total_amount": overdue_payment.outstanding,
"outstanding_amount": overdue_payment.outstanding,
"allocated_amount": overdue_payment.outstanding,
"total_amount": outstanding,
"outstanding_amount": outstanding,
"allocated_amount": outstanding,
},
)
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * doc.dunning_amount,
"description": _("Interest and/or dunning fee"),
},
)
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * unpaid_dunning_amount,
"description": _("Interest and/or dunning fee"),
"dunning": doc.name,
},
)
else:
pe.append(
"references",
@@ -3296,8 +3314,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
grand_total = doc.rounded_total or doc.grand_total
outstanding_amount = doc.outstanding_amount
elif dt == "Dunning":
grand_total = doc.grand_total
outstanding_amount = doc.grand_total
# only what is left to collect, the totals on the dunning are the ones it was raised with
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
grand_total += doc.get_unpaid_dunning_amount()
outstanding_amount = grand_total
else:
if party_account_currency == doc.company_currency:
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))

View File

@@ -733,6 +733,23 @@ class TestPaymentEntry(FrappeTestCase):
self.validate_gl_entries(pe.name, expected_gle)
def test_internal_transfer_rejects_same_account(self):
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
pe.company = "_Test Company"
pe.paid_from = "_Test Bank - _TC"
pe.paid_to = "_Test Bank - _TC"
pe.paid_amount = 100
pe.received_amount = 100
pe.reference_no = "same-account-transfer"
pe.reference_date = nowdate()
self.assertRaisesRegex(
frappe.ValidationError,
"Paid From and Paid To accounts must be different",
pe.insert,
)
def test_payment_against_negative_sales_invoice(self):
si1 = create_sales_invoice()

View File

@@ -10,7 +10,8 @@
"amount",
"column_break_2",
"is_exchange_gain_loss",
"description"
"description",
"dunning"
],
"fields": [
{
@@ -55,12 +56,21 @@
"fieldtype": "Check",
"label": "Is Exchange Gain / Loss?",
"read_only": 1
},
{
"fieldname": "dunning",
"fieldtype": "Link",
"label": "Dunning",
"no_copy": 1,
"options": "Dunning",
"print_hide": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-11 14:26:11.312950",
"modified": "2026-08-17 11:20:35.482913",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Deduction",

View File

@@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document):
amount: DF.Currency
cost_center: DF.Link
description: DF.SmallText | None
dunning: DF.Link | None
is_exchange_gain_loss: DF.Check
parent: DF.Data
parentfield: DF.Data

View File

@@ -57,32 +57,63 @@ class PaymentOrder(Document):
frappe.db.set_value(self.payment_order_type, d.get(ref_doc_field), ref_field, status)
def _readable_payment_order(filters):
"""Authorise the parent before reading its rows.
A child table carries no permissions of its own, so a read of it has to be authorised on the
Payment Order the rows belong to.
"""
parent = filters.get("parent")
if not parent or not frappe.db.exists("Payment Order", parent):
return None
ptype = "select" if frappe.only_has_select_perm("Payment Order") else "read"
frappe.has_permission("Payment Order", ptype, doc=parent, throw=True)
return parent
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_mop_query(doctype, txt, searchfield, start, page_len, filters):
return frappe.db.sql(
""" select mode_of_payment from `tabPayment Order Reference`
where parent = %(parent)s and mode_of_payment like %(txt)s
limit %(page_len)s offset %(start)s""",
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
if not _readable_payment_order(filters):
return []
return frappe.get_all(
"Payment Order Reference",
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
fields=["mode_of_payment"],
limit_start=start,
limit_page_length=page_len,
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
as_list=True,
)
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
return frappe.db.sql(
""" select supplier from `tabPayment Order Reference`
where parent = %(parent)s and supplier like %(txt)s and
(payment_reference is null or payment_reference='')
limit %(page_len)s offset %(start)s""",
{"parent": filters.get("parent"), "start": start, "page_len": page_len, "txt": "%%%s%%" % txt},
if not _readable_payment_order(filters):
return []
return frappe.get_all(
"Payment Order Reference",
filters={
"parent": filters.get("parent"),
"supplier": ["like", f"%{txt}%"],
"payment_reference": ["is", "not set"],
},
fields=["supplier"],
limit_start=start,
limit_page_length=page_len,
order_by="", # match the original query (no ORDER BY); avoid get_all's default sort
as_list=True,
)
@frappe.whitelist()
def make_payment_records(name, supplier, mode_of_payment=None):
doc = frappe.get_doc("Payment Order", name)
doc.check_permission()
make_journal_entry(doc, supplier, mode_of_payment)

View File

@@ -455,6 +455,13 @@ class PaymentReconciliation(Document):
default_exchange_gain_loss_account = frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
)
account_currency = frappe.get_cached_value(
"Account", self.receivable_payable_account, "account_currency"
)
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount"),
currency=account_currency,
)
entries = []
for pay in args.get("payments"):
@@ -462,11 +469,17 @@ class PaymentReconciliation(Document):
for inv in args.get("invoices"):
if pay.get("amount") >= inv.get("outstanding_amount"):
res = self.get_allocated_entry(pay, inv, inv["outstanding_amount"])
pay["amount"] = flt(pay.get("amount")) - flt(inv.get("outstanding_amount"))
pay["amount"] = flt(
flt(pay.get("amount")) - flt(inv.get("outstanding_amount")),
allocated_amount_precision,
)
inv["outstanding_amount"] = 0
else:
res = self.get_allocated_entry(pay, inv, pay["amount"])
inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount"))
inv["outstanding_amount"] = flt(
flt(inv.get("outstanding_amount")) - flt(pay.get("amount")),
allocated_amount_precision,
)
pay["amount"] = 0
inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number"))

View File

@@ -1484,6 +1484,75 @@ class TestPaymentReconciliation(FrappeTestCase):
# Should not raise frappe.exceptions.ValidationError: Payment Entry has been modified after you pulled it. Please pull it again.
pr.reconcile()
@change_settings("System Settings", {"currency_precision": 2})
def test_allocate_entries_rounds_running_balance_to_currency_precision(self):
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
pr.party_type = "Customer"
pr.party = self.customer
pr.receivable_payable_account = self.debit_to
pr.set("invoices", [{"invoice_number": "INV-1"}])
pr.set("payments", [{"reference_name": "PAY-1"}])
invoices = [
{
"invoice_type": "Sales Invoice",
"invoice_number": "INV-1",
"outstanding_amount": 17592.415,
"currency": "INR",
},
]
payments = [
{
"reference_type": "Payment Entry",
"reference_name": "PAY-1",
"amount": 18230,
"currency": "INR",
}
]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(payments[0]["amount"], flt(637.585, 2))
@change_settings("System Settings", {"currency_precision": "", "use_number_format_from_currency": 1})
def test_allocate_entries_rounds_running_balance_to_account_currency_precision(self):
account_currency = frappe.get_cached_value("Account", self.debit_to, "account_currency")
original_number_format = frappe.db.get_value("Currency", account_currency, "number_format")
frappe.db.set_value("Currency", account_currency, "number_format", "#,###.###")
self.addCleanup(
frappe.db.set_value, "Currency", account_currency, "number_format", original_number_format
)
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
pr.party_type = "Customer"
pr.party = self.customer
pr.receivable_payable_account = self.debit_to
pr.set("invoices", [{"invoice_number": "INV-1"}])
pr.set("payments", [{"reference_name": "PAY-1"}])
invoices = [
{
"invoice_type": "Sales Invoice",
"invoice_number": "INV-1",
"outstanding_amount": 17592.415,
"currency": account_currency,
},
]
payments = [
{
"reference_type": "Payment Entry",
"reference_name": "PAY-1",
"amount": 18230,
"currency": account_currency,
}
]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
self.assertEqual(payments[0]["amount"], flt(637.585, 3))
def test_reverse_payment_against_payment_for_supplier(self):
"""
Reconcile a payment against a reverse payment, for a supplier.

View File

@@ -461,7 +461,7 @@ class PaymentRequest(Document):
}
if self.message:
return frappe.render_template(self.message, context)
return frappe.render_template(self.message, context, restrict_globals=True)
def set_failed(self):
pass

View File

@@ -116,7 +116,7 @@
}
],
"links": [],
"modified": "2021-02-15 20:30:56.256403",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Term",
@@ -157,6 +157,30 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -54,7 +54,7 @@ class PeriodClosingVoucher(AccountsController):
if for_cancellation and is_immutable_ledger_enabled():
posting_date = getdate()
check_freezing_date(posting_date, self.company)
check_freezing_date(posting_date)
def validate_start_and_end_date(self):
self.fy_start_date, self.fy_end_date = frappe.db.get_value(

View File

@@ -147,12 +147,22 @@ class POSClosingEntry(StatusUpdater):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
pos_profile = filters.get("parent")
if not pos_profile or not frappe.db.exists("POS Profile", pos_profile):
return []
ptype = "select" if frappe.only_has_select_perm("POS Profile") else "read"
frappe.has_permission("POS Profile", ptype, doc=pos_profile, throw=True)
cashiers_list = frappe.get_all("POS Profile User", filters=filters, fields=["user"], as_list=1)
return [c for c in cashiers_list]
@frappe.whitelist()
def get_pos_invoices(start, end, pos_profile, user):
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
frappe.has_permission("POS Invoice", throw=True)
data = frappe.db.sql(
"""
select

View File

@@ -499,6 +499,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"depends_on": "customer",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
@@ -1571,7 +1572,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-02-22 04:18:50.691218",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
@@ -1614,6 +1615,14 @@
"permlevel": 1,
"read": 1,
"role": "All"
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -801,6 +801,21 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist()
def get_stock_availability(item_code, warehouse):
# POS Profile is the only boundary that fits: Item/Bin `read` exclude Accounts Manager, Item
# `select` is granted to every desk user, and POS Invoice `read` is granted to `All`.
# select-or-read: the shipped rows give Sales Manager only `select`.
ptype = "select" if frappe.only_has_select_perm("POS Profile") else "read"
frappe.has_permission("POS Profile", ptype, throw=True)
# the caller picks the warehouse when allow_warehouse_change is set; costs nobody who has no
# Warehouse User Permission
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
if warehouse_permissions := get_user_permissions(frappe.session.user).get("Warehouse"):
allowed_warehouses = get_allowed_docs_for_doctype(warehouse_permissions, "POS Invoice")
if allowed_warehouses and warehouse not in allowed_warehouses:
frappe.throw(_("Not permitted for {0}").format(warehouse), frappe.PermissionError)
if frappe.db.get_value("Item", item_code, "is_stock_item"):
is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse)

View File

@@ -217,6 +217,8 @@ class POSInvoiceMergeLog(Document):
loyalty_amount_sum, loyalty_points_sum, idx = 0, 0, 1
reversed_rows = get_reversed_rows([doc.return_against for doc in data if doc.is_return])
for doc in data:
map_doc(doc, invoice, table_map={"doctype": invoice.doctype})
@@ -239,9 +241,13 @@ class POSInvoiceMergeLog(Document):
si_item.pos_invoice = doc.name
si_item.pos_invoice_item = item.name
if doc.is_return:
si_item.sales_invoice_item = get_sales_invoice_item(
doc.return_against, item.pos_invoice_item
)
reversed_row = reversed_rows.get(item.pos_invoice_item) or frappe._dict()
si_item.sales_invoice_item = reversed_row.get("name")
# quote the rate of the row being reversed: rounding an invoice-level discount
# can leave a return's net rate a minor unit above the sale's, and
# validate_returned_items refuses a return priced above its original
if si_item.sales_invoice_item:
si_item.rate = reversed_row.rate
if item.serial_and_batch_bundle:
si_item.serial_and_batch_bundle = item.serial_and_batch_bundle
items.append(si_item)
@@ -439,6 +445,29 @@ def update_item_wise_tax_detail(consolidate_tax_row, tax_row):
consolidate_tax_row.item_wise_tax_detail = json.dumps(consolidated_tax_detail, separators=(",", ":"))
def get_reversed_rows(return_against):
"""Rows of the consolidated sales these returns reverse, keyed by the POS invoice row."""
if not return_against:
return {}
sales_invoice = DocType("Sales Invoice")
sales_invoice_item = DocType("Sales Invoice Item")
rows = (
frappe.qb.from_(sales_invoice)
.from_(sales_invoice_item)
.select(sales_invoice_item.name, sales_invoice_item.rate, sales_invoice_item.pos_invoice_item)
.where(
(sales_invoice.name == sales_invoice_item.parent)
& (sales_invoice.is_return == 0)
& (sales_invoice_item.pos_invoice.isin(return_against))
& (sales_invoice.docstatus == 1)
)
).run(as_dict=True)
return {row.pos_invoice_item: row for row in rows}
def get_all_unconsolidated_invoices():
filters = {
"consolidated_invoice": ["in", ["", None]],
@@ -683,27 +712,3 @@ def get_error_message(message) -> str:
return message["message"]
except Exception:
return str(message)
def get_sales_invoice_item(return_against_pos_invoice, pos_invoice_item):
try:
SalesInvoice = DocType("Sales Invoice")
SalesInvoiceItem = DocType("Sales Invoice Item")
query = (
frappe.qb.from_(SalesInvoice)
.from_(SalesInvoiceItem)
.select(SalesInvoiceItem.name)
.where(
(SalesInvoice.name == SalesInvoiceItem.parent)
& (SalesInvoice.is_return == 0)
& (SalesInvoiceItem.pos_invoice == return_against_pos_invoice)
& (SalesInvoiceItem.pos_invoice_item == pos_invoice_item)
& (SalesInvoice.docstatus == 1)
)
)
result = query.run(as_dict=True)
return result[0].name if result else None
except Exception:
return None

View File

@@ -3,9 +3,11 @@
import json
import unittest
from contextlib import contextmanager
import frappe
from frappe.tests.utils import change_settings
from frappe.utils import flt
from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
@@ -19,6 +21,67 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@contextmanager
def rounding_method(method):
"""System Settings is also cached on frappe.local, so that copy has to go as well."""
previous = frappe.db.get_single_value("System Settings", "rounding_method")
try:
frappe.db.set_single_value("System Settings", "rounding_method", method)
frappe.local.system_settings = None
yield
finally:
frappe.db.set_single_value("System Settings", "rounding_method", previous)
frappe.local.system_settings = None
def sell_over_the_counter(lines, discount_percentage=0):
item_code, qty, rate = lines[0]
sale = create_pos_invoice(item_code=item_code, qty=qty, rate=rate, do_not_save=True)
for item_code, qty, rate in lines[1:]:
sale.append(
"items",
{
"item_code": item_code,
"qty": qty,
"rate": rate,
"price_list_rate": rate,
"warehouse": "_Test Warehouse - _TC",
"income_account": "Sales - _TC",
"cost_center": "_Test Cost Center - _TC",
},
)
if discount_percentage:
sale.apply_discount_on = "Net Total"
sale.additional_discount_percentage = discount_percentage
sale.run_method("calculate_taxes_and_totals")
payable = sale.rounded_total or sale.grand_total
sale.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": payable})
sale.paid_amount = sale.base_paid_amount = payable
sale.insert()
sale.submit()
return sale
def refund_over_the_counter(sale, qty=None):
"""Hand back every line of `sale`, `qty` of each when fewer units come back."""
note = make_sales_return(sale.name)
if qty is not None:
for item in note.items:
item.qty = qty
note.run_method("calculate_taxes_and_totals")
refundable = note.rounded_total or note.grand_total
note.payments[0].amount = refundable
for spare in note.payments[1:]:
spare.amount = 0
note.paid_amount = note.base_paid_amount = refundable
note.insert()
note.submit()
return note
class TestPOSInvoiceMergeLog(unittest.TestCase):
def test_consolidated_invoice_creation(self):
frappe.db.sql("delete from `tabPOS Invoice`")
@@ -510,3 +573,92 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")
@change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_consolidating_returns_priced_off_a_rounded_invoice_discount(self):
"""A return works out its own share of an invoice-level discount, so rounding can leave
it a minor unit above the sale's, and validate_returned_items then refuses it.
Every shape that reaches a consolidated credit note goes through one consolidation:
a split landing on a half minor unit, the same item on two rows so the rows can only
be paired through sales_invoice_item, fewer units coming back than went out, and — as
a control — a sale with no invoice-level discount to split at all.
"""
frappe.db.sql("delete from `tabPOS Invoice`")
try:
for item_code in ("_Test Item", "_Test Item 2"):
make_stock_entry(to_warehouse="_Test Warehouse - _TC", item_code=item_code, rate=100, qty=40)
init_user_and_profile()
with rounding_method("Banker's Rounding (legacy)"):
tied = sell_over_the_counter(
[("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)], discount_percentage=25
)
repeated = sell_over_the_counter(
[("_Test Item", 1, 42.86), ("_Test Item", 1, 57.14)], discount_percentage=25
)
oversold = sell_over_the_counter(
[("_Test Item", 3, 42.86), ("_Test Item 2", 3, 57.14)], discount_percentage=25
)
undiscounted = sell_over_the_counter([("_Test Item", 1, 42.86), ("_Test Item 2", 1, 57.14)])
# the sale and the return really do round the split apart
self.assertEqual(
{item.item_code: item.net_rate for item in tied.items},
{"_Test Item": 32.15, "_Test Item 2": 42.85},
)
returns = [
refund_over_the_counter(tied),
refund_over_the_counter(repeated),
refund_over_the_counter(oversold, qty=-1),
refund_over_the_counter(undiscounted),
]
self.assertEqual(
{item.item_code: item.net_rate for item in returns[0].items},
{"_Test Item": 32.14, "_Test Item 2": 42.86},
)
consolidate_pos_invoices()
for pos_invoice in [tied, repeated, oversold, undiscounted, *returns]:
pos_invoice.load_from_db()
self.assertTrue(
frappe.db.exists("Sales Invoice", pos_invoice.consolidated_invoice),
f"{pos_invoice.name} was not consolidated",
)
self.assertEqual(
frappe.db.get_value(
"Sales Invoice", pos_invoice.consolidated_invoice, "outstanding_amount"
),
0,
)
for note in returns:
# no returned row may be priced above the row it reverses
for row in frappe.get_all(
"Sales Invoice Item",
filters={"parent": note.consolidated_invoice},
fields=["item_code", "rate", "sales_invoice_item"],
):
self.assertTrue(row.sales_invoice_item, f"{row.item_code} lost its link to the sale")
sold_rate = frappe.db.get_value("Sales Invoice Item", row.sales_invoice_item, "rate")
self.assertLessEqual(row.rate, sold_rate)
# returns for one customer land on a single credit note, which still adds up to
# everything handed back over the counter
refunded = {}
for note in returns:
refunded[note.consolidated_invoice] = refunded.get(note.consolidated_invoice, 0) + flt(
note.grand_total
)
for consolidated_name, handed_back in refunded.items():
self.assertEqual(
flt(frappe.db.get_value("Sales Invoice", consolidated_name, "grand_total"), 2),
flt(handed_back, 2),
)
finally:
frappe.set_user("Administrator")
frappe.db.sql("delete from `tabPOS Profile`")
frappe.db.sql("delete from `tabPOS Invoice`")

View File

@@ -422,7 +422,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2026-02-22 04:17:03.308876",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",
@@ -446,6 +446,10 @@
"read": 1,
"report": 1,
"role": "Accounts User"
},
{
"role": "Sales Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -271,40 +271,40 @@ def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
user = frappe.session["user"]
company = filters.get("company") or frappe.defaults.get_user_default("company")
args = {
"user": user,
"start": start,
"company": company,
"page_len": page_len,
"txt": "%%%s%%" % txt,
}
allowed_pos_profiles = frappe.get_list("POS Profile", pluck="name")
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf, `tabPOS Profile User` pfu
where
pfu.parent = pf.name and pfu.user = %(user)s and pf.company = %(company)s
and (pf.name like %(txt)s)
and pf.disabled = 0 limit %(page_len)s offset %(start)s""",
args,
if not allowed_pos_profiles:
return {}
pf = frappe.qb.DocType("POS Profile")
pfu = frappe.qb.DocType("POS Profile User")
pos_profile = (
frappe.qb.from_(pf)
.inner_join(pfu)
.on(pfu.parent == pf.name)
.select(pf.name)
.where((pfu.user == user) & (pf.company == company) & pf.name.like(f"%{txt}%") & (pf.disabled == 0))
.where(pf.name.isin(allowed_pos_profiles))
.limit(page_len)
.offset(start)
.run()
)
if not pos_profile:
del args["user"]
pos_profile = frappe.db.sql(
"""select pf.name
from
`tabPOS Profile` pf left join `tabPOS Profile User` pfu
on
pf.name = pfu.parent
where
ifnull(pfu.user, '') = ''
and pf.company = %(company)s
and pf.name like %(txt)s
and pf.disabled = 0""",
args,
pos_profile = (
frappe.qb.from_(pf)
.left_join(pfu)
.on(pf.name == pfu.parent)
.select(pf.name)
.where(
(pfu.user.isnull() | (pfu.user == ""))
& (pf.company == company)
& pf.name.like(f"%{txt}%")
& (pf.disabled == 0)
& (pf.name.isin(allowed_pos_profiles))
)
.run()
)
return pos_profile

View File

@@ -12,20 +12,22 @@
{
"fieldname": "fieldname",
"fieldtype": "Data",
"hidden": 1,
"label": "Fieldname"
"in_list_view": 1,
"label": "Fieldname",
"read_only": 1
},
{
"fieldname": "field",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Field"
"label": "Field",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-04-21 11:12:54.632093",
"modified": "2026-08-31 20:41:12.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Search Fields",
@@ -34,4 +36,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -1,40 +1,9 @@
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
let search_fields_datatypes = [
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
];
let do_not_include_fields = [
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
];
function is_valid_invoice_field(df) {
return frappe.model.no_value_type.indexOf(df.fieldtype) === -1 || df.fieldtype === "Button";
}
frappe.ui.form.on("POS Settings", {
onload: function (frm) {
@@ -44,57 +13,46 @@ frappe.ui.form.on("POS Settings", {
get_invoice_fields: function (frm) {
frappe.model.with_doctype("POS Invoice", () => {
var fields = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
if (
frappe.model.no_value_type.indexOf(d.fieldtype) === -1 ||
["Button"].includes(d.fieldtype)
) {
return { label: d.label + " (" + d.fieldtype + ")", value: d.fieldname };
} else {
return null;
}
});
const fields = frappe.get_doc("DocType", "POS Invoice").fields.filter(is_valid_invoice_field);
frm.fields_dict.invoice_fields.grid.update_docfield_property(
"fieldname",
"options",
[""].concat(fields)
[""].concat(
fields.map((df) => {
return { label: `${df.label} (${df.fieldtype})`, value: df.fieldname };
})
)
);
});
},
add_search_options: function (frm) {
frappe.model.with_doctype("Item", () => {
var fields = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (
search_fields_datatypes.includes(d.fieldtype) &&
!do_not_include_fields.includes(d.fieldname)
) {
return [d.label];
} else {
return null;
}
});
frappe.call({
method: "erpnext.accounts.doctype.pos_settings.pos_settings.get_pos_search_field_options",
callback: ({ message }) => {
const fields = message || [];
fields.unshift("");
frm.fields_dict.pos_search_fields.grid.update_docfield_property("field", "options", fields);
frm.searchable_item_fields = Object.fromEntries(
fields.map((df) => [df.option, df.fieldname])
);
frm.fields_dict.pos_search_fields.grid.update_docfield_property(
"field",
"options",
[""].concat(fields.map((df) => df.option))
);
},
});
},
});
frappe.ui.form.on("POS Search Fields", {
field: function (frm, doctype, name) {
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "Item").fields, function (d) {
if (doc.field == d.label && search_fields_datatypes.includes(d.fieldtype)) {
return d;
} else {
return null;
}
})[0];
const doc = frappe.get_doc(doctype, name);
doc.fieldname = df.fieldname;
frm.refresh_field("fields");
doc.fieldname = frm.searchable_item_fields?.[doc.field] || "";
frm.refresh_field("pos_search_fields");
},
});
@@ -110,6 +68,6 @@ frappe.ui.form.on("POS Field", {
doc.options = df.options;
doc.fieldtype = df.fieldtype;
doc.default_value = df.default;
frm.refresh_field("fields");
frm.refresh_field("invoice_fields");
},
});

View File

@@ -1,9 +1,50 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from collections import Counter
import frappe
from frappe import _
from frappe.model import no_value_fields
from frappe.model.document import Document
SEARCH_FIELD_TYPES = (
"Data",
"Link",
"Dynamic Link",
"Long Text",
"Select",
"Small Text",
"Text",
"Text Editor",
)
# Item fields that are of a searchable fieldtype, but are not meaningful to search a POS item by
DO_NOT_INCLUDE_FIELDS = (
"naming_series",
"item_code",
"item_name",
"stock_uom",
"asset_naming_series",
"default_material_request_type",
"valuation_method",
"warranty_period",
"weight_uom",
"batch_number_series",
"serial_no_series",
"purchase_uom",
"customs_tariff_number",
"sales_uom",
"deferred_revenue_account",
"deferred_expense_account",
"quality_inspection_template",
"route",
"slideshow",
"website_image_alt",
"thumbnail",
"web_long_description",
)
class POSSettings(Document):
# begin: auto-generated types
@@ -22,4 +63,95 @@ class POSSettings(Document):
# end: auto-generated types
def validate(self):
pass
self.validate_duplicate_invoice_fields()
self.validate_invoice_fields()
self.validate_duplicate_pos_search_fields()
self.validate_pos_search_fields()
def validate_duplicate_invoice_fields(self):
fieldnames = [field.fieldname for field in self.invoice_fields]
for fieldname, count in Counter(fieldnames).items():
if count > 1:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(fieldname)
)
def validate_invoice_fields(self):
# the POS screen only ever creates a POS Invoice
meta = frappe.get_meta("POS Invoice")
for field in self.invoice_fields:
df = meta.get_field(field.fieldname)
if not df or not is_valid_invoice_field(df):
frappe.throw(
title=_("Invalid POS Field"),
msg=_("Row #{0}: '{1}' is not a valid field of {2}.").format(
field.idx, frappe.bold(field.fieldname or ""), frappe.bold(_("POS Invoice"))
),
)
# read only in the form, so keep them in sync with the invoice
field.label = df.label
field.fieldtype = df.fieldtype
field.options = df.options
def validate_duplicate_pos_search_fields(self):
fieldnames = [field.fieldname for field in self.pos_search_fields]
for fieldname, count in Counter(fieldnames).items():
if count > 1:
frappe.throw(
title=_("Duplicate POS Search Fields"),
msg=_("'{0}' has been already added.").format(fieldname),
)
def validate_pos_search_fields(self):
searchable_fields = {df.fieldname: df for df in get_searchable_item_fields()}
for field in self.pos_search_fields:
df = searchable_fields.get(field.fieldname)
if not df:
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' cannot be used to search items.").format(
field.idx, frappe.bold(field.fieldname or "")
),
)
if field.field != get_search_field_option(df):
frappe.throw(
title=_("Invalid POS Search Field"),
msg=_("Row #{0}: '{1}' does not match {2}.").format(
field.idx, frappe.bold(field.field or ""), frappe.bold(df.fieldname)
),
)
def is_valid_invoice_field(df):
return df.fieldtype not in no_value_fields or df.fieldtype == "Button"
def get_searchable_item_fields():
return [
df
for df in frappe.get_meta("Item").fields
if df.fieldtype in SEARCH_FIELD_TYPES and df.fieldname not in DO_NOT_INCLUDE_FIELDS
]
def get_search_field_option(df):
# the fieldname keeps the option unique, two Item fields can share a label
return f"{df.label} ({df.fieldname})"
@frappe.whitelist()
def get_pos_search_field_options():
frappe.has_permission("POS Settings", throw=True)
return [
{"option": get_search_field_option(df), "fieldname": df.fieldname}
for df in get_searchable_item_fields()
]

View File

@@ -3,6 +3,119 @@
import unittest
import frappe
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
class TestPOSSettings(unittest.TestCase):
pass
def setUp(self):
self.settings = frappe.get_single("POS Settings")
self.settings.invoice_fields = []
self.settings.pos_search_fields = []
def tearDown(self):
frappe.db.rollback()
def assertInvalid(self, message):
with self.assertRaises(frappe.ValidationError) as context:
self.settings.save()
self.assertIn(message, str(context.exception))
def test_duplicate_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.assertInvalid("'customer' has been already added.")
def test_unknown_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "not_a_field"})
self.assertInvalid("is not a valid field of")
def test_layout_invoice_field_is_not_allowed(self):
self.settings.append("invoice_fields", {"fieldname": "accounting_dimensions_section"})
self.assertInvalid("is not a valid field of")
def test_invoice_field_properties_are_set_from_the_invoice(self):
self.settings.append(
"invoice_fields", {"fieldname": "customer", "label": "Tampered", "fieldtype": "Data"}
)
self.settings.save()
field = self.settings.invoice_fields[0]
self.assertEqual(field.label, "Customer")
self.assertEqual(field.fieldtype, "Link")
self.assertEqual(field.options, "Customer")
def test_searchable_item_field_is_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
self.assertEqual(self.settings.pos_search_fields[0].fieldname, "description")
def test_excluded_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Item Name (item_name)", "fieldname": "item_name"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_of_unsearchable_type_is_not_allowed(self):
# maintain stock is a Check field
self.settings.append(
"pos_search_fields", {"field": "Maintain Stock (is_stock_item)", "fieldname": "is_stock_item"}
)
self.assertInvalid("cannot be used to search items")
def test_unknown_search_field_is_not_allowed(self):
self.settings.append(
"pos_search_fields", {"field": "Nope (not_an_item_field)", "fieldname": "not_an_item_field"}
)
self.assertInvalid("cannot be used to search items")
def test_search_field_without_a_fieldname_is_not_allowed(self):
# the form fills the fieldname in, it cannot be picked on its own
self.settings.append("pos_search_fields", {"field": "Description (description)"})
self.assertInvalid("cannot be used to search items")
def test_search_field_option_must_match_its_fieldname(self):
self.settings.append("pos_search_fields", {"field": "Brand (brand)", "fieldname": "description"})
self.assertInvalid("does not match")
def test_bare_label_is_not_accepted_as_a_search_field(self):
# the stored option carries the fieldname, the patch backfills older rows
self.settings.append("pos_search_fields", {"field": "Description", "fieldname": "description"})
self.assertInvalid("does not match")
def test_duplicate_search_fields_are_not_allowed(self):
for _ in range(2):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.assertInvalid("has been already added")
def test_patch_appends_the_fieldname_to_a_legacy_search_field(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
row = self.settings.pos_search_fields[0].name
frappe.db.set_value("POS Search Fields", row, "field", "Description", update_modified=False)
append_fieldname()
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")
def test_patch_leaves_an_already_migrated_search_field_alone(self):
self.settings.append(
"pos_search_fields", {"field": "Description (description)", "fieldname": "description"}
)
self.settings.save()
append_fieldname()
row = self.settings.pos_search_fields[0].name
self.assertEqual(frappe.db.get_value("POS Search Fields", row, "field"), "Description (description)")

View File

@@ -12,6 +12,29 @@ from frappe import _, throw
from frappe.model.document import Document
from frappe.utils import cint, flt
# the transactions the pricing engine is called for, from transaction.js and the POS
PRICING_TRANSACTION_DOCTYPES = frozenset(
{
"Quotation",
"Sales Order",
"Delivery Note",
"Sales Invoice",
"POS Invoice",
"Supplier Quotation",
"Purchase Order",
"Purchase Receipt",
"Purchase Invoice",
"Material Request",
# these three also extend a controller that calls the pricing engine: BOM and BOM Creator
# through TransactionController, Request for Quotation through BuyingController
"BOM",
"BOM Creator",
"Request for Quotation",
# no client sends this one, but set_transaction_type below still branches on it
"Opportunity",
}
)
apply_on_dict = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}
other_fields = ["other_item_code", "other_item_group", "other_brand"]
@@ -364,6 +387,18 @@ def apply_pricing_rule(args, doc=None):
args = frappe._dict(args)
# an allow-list, not a type check: `doctype` is caller-chosen, and any doctype the caller can
# read would otherwise satisfy the has_permission below while the pricing engine still ran
transaction_doctype = args.get("doctype")
if transaction_doctype not in PRICING_TRANSACTION_DOCTYPES:
frappe.throw(_("Invalid doctype"), frappe.PermissionError)
transaction_name = args.get("name")
if not isinstance(transaction_name, str) or not frappe.db.exists(transaction_doctype, transaction_name):
transaction_name = None
frappe.has_permission(transaction_doctype, doc=transaction_name, throw=True)
set_transaction_type(args)
# list of dictionaries
@@ -733,14 +768,18 @@ def make_pricing_rule(doctype, docname):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_item_uoms(doctype, txt, searchfield, start, page_len, filters):
items = [filters.get("value")]
if filters.get("apply_on") != "Item Code":
field = frappe.scrub(filters.get("apply_on"))
items = [d.name for d in frappe.db.get_all("Item", filters={field: filters.get("value")})]
if filters.get("apply_on") == "Item Code":
item_filters = [["name", "=", filters.get("value")]]
else:
item_filters = [[frappe.scrub(filters.get("apply_on")), "=", filters.get("value")]]
items = frappe.get_list("Item", filters=item_filters, pluck="name")
if not items:
return []
return frappe.get_all(
"UOM Conversion Detail",
filters={"parent": ("in", items), "uom": ("like", f"{txt}%")},
filters={"parent": ("in", items), "parenttype": "Item", "uom": ("like", f"{txt}%")},
fields=["distinct uom"],
as_list=1,
)

View File

@@ -130,15 +130,20 @@ def initialize_parallel_threads(docname: str):
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def start_pcv_processing(docname: str):
# checked before the status is read, not inside the branch: otherwise an unentitled caller
# learns the document's status from whether this returns or throws
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
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.whitelist()
@frappe.whitelist(methods=["POST"])
def pause_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
@@ -152,8 +157,10 @@ def pause_pcv_processing(docname: str):
qb.update(ppcvd).set(ppcvd.status, "Paused").where(ppcvd.name.isin(queued_dates)).run()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def cancel_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="cancel", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
@@ -166,8 +173,10 @@ def cancel_pcv_processing(docname: str):
qb.update(ppcvd).set(ppcvd.status, "Cancelled").where(ppcvd.name.isin(queued_dates)).run()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def resume_pcv_processing(docname: str):
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()
@@ -249,8 +258,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
return gl_entry
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def schedule_next_date(docname: str):
# marks a row Running and enqueues a long job, so it needs the same write check as the sibling controls
frappe.has_permission("Process Period Closing Voucher", ptype="write", doc=docname, throw=True)
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")

View File

@@ -293,6 +293,13 @@ def get_html(doc, filters, entry, col, res, ageing):
from frappe.www.printview import get_letter_head
letter_head = get_letter_head(doc, 0)
# render letter head content as a template so its Jinja resolves against the doc
if letter_head.get("content"):
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
letter_head["content"] = frappe.render_template(letter_head["content"], {"doc": doc})
if letter_head.get("footer"):
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
letter_head["footer"] = frappe.render_template(letter_head["footer"], {"doc": doc})
html = frappe.render_template(
template_path,
{

View File

@@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
const me = this;
super.refresh();
hide_fields(this.frm.doc);
hide_fields(this.frm);
// Show / Hide button
this.show_general_ledger();
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm);
@@ -237,10 +237,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
unblock_invoice() {
const me = this;
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice",
args: { name: me.frm.doc.name },
callback: (r) => me.frm.reload_doc(),
me.frm.call("unblock_invoice", null, () => {
me.frm.reload_doc();
});
}
@@ -291,15 +289,16 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
this.dialog.set_primary_action(__("Save"), function () {
const dialog_data = me.dialog.get_values();
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice",
args: {
name: me.frm.doc.name,
me.frm.call(
"block_invoice",
{
hold_comment: dialog_data.hold_comment,
release_date: dialog_data.release_date,
},
callback: (r) => me.frm.reload_doc(),
});
() => {
me.frm.reload_doc();
}
);
me.dialog.hide();
});
@@ -338,10 +337,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
set_release_date(data) {
return frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date",
args: data,
callback: (r) => this.frm.reload_doc(),
const me = this;
return me.frm.call("change_release_date", { release_date: data.release_date }, () => {
me.frm.reload_doc();
});
}
@@ -437,7 +435,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
is_paid() {
hide_fields(this.frm.doc);
hide_fields(this.frm);
if (cint(this.frm.doc.is_paid)) {
this.frm.set_value("allocate_advances_automatically", 0);
this.frm.set_value("payment_terms_template", "");
@@ -501,28 +499,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice);
// Hide Fields
// ------------
function hide_fields(doc) {
var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
function hide_fields(frm) {
const doc = frm.doc;
const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"];
if (cint(doc.is_paid) == 1) {
hide_field(parent_fields);
frm.toggle_display(parent_fields, false);
} else {
for (var i in parent_fields) {
var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]];
if (!docfield.hidden) unhide_field(parent_fields[i]);
for (const fieldname of parent_fields) {
const docfield = frappe.meta.docfield_map[doc.doctype][fieldname];
if (!docfield.hidden) frm.toggle_display(fieldname, true);
}
}
var item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
const item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"];
if (cur_frm.fields_dict["items"]) {
cur_frm.fields_dict["items"].grid.set_column_disp(
item_fields_stock,
cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false
);
if (frm.fields_dict["items"]) {
frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1);
}
cur_frm.refresh_fields();
frm.refresh_fields();
}
cur_frm.fields_dict.cash_bank_account.get_query = function (doc) {
@@ -738,7 +734,7 @@ frappe.ui.form.on("Purchase Invoice", {
},
update_stock: function (frm) {
hide_fields(frm.doc);
hide_fields(frm);
frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false);
},

View File

@@ -352,6 +352,7 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.on_hold",
"depends_on": "eval:doc.on_hold",
"fieldname": "sb_14",
"fieldtype": "Section Break",
"label": "Hold Invoice"
@@ -501,6 +502,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
"label": "Currency and Price List",
@@ -1662,7 +1664,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-07-12 23:54:21.263951",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
@@ -1715,6 +1717,18 @@
"read": 1,
"role": "Accounts Manager",
"write": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -8,7 +8,7 @@ import frappe
from frappe import _, qb, throw
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date
@@ -40,6 +40,7 @@ from erpnext.assets.doctype.asset_category.asset_category import get_asset_categ
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.stock import get_warehouse_account_map
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
get_item_account_wise_additional_cost,
@@ -291,6 +292,7 @@ class PurchaseInvoice(BuyingController):
self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
self.set_status()
self.validate_purchase_receipt_if_update_stock()
self.validate_exchange_rate_with_purchase_receipt()
validate_inter_company_party(
self.doctype, self.supplier, self.company, self.inter_company_invoice_reference
)
@@ -299,6 +301,9 @@ class PurchaseInvoice(BuyingController):
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
self.set_percentage_received()
if self.on_hold:
self.validate_invoice_hold()
def set_percentage_received(self):
total_billed_qty = 0.0
total_received_qty = 0.0
@@ -310,6 +315,54 @@ class PurchaseInvoice(BuyingController):
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_exchange_rate_with_purchase_receipt(self):
if self.is_internal_transfer() or not erpnext.is_perpetual_inventory_enabled(self.company):
return
stock_items = self.get_stock_items()
receipts = {
item.purchase_receipt
for item in self.items
if item.purchase_receipt and item.item_code in stock_items
}
if not receipts:
return
if frappe.db.get_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"):
return
mismatched = [
f"{frappe.bold(row.name)} ({row.conversion_rate})"
for row in frappe.get_all(
"Purchase Receipt",
filters={"name": ("in", list(receipts))},
fields=["name", "currency", "conversion_rate"],
)
if row.currency == self.currency
and flt(row.conversion_rate)
and flt(row.conversion_rate) != flt(self.conversion_rate)
]
if not mismatched:
return
frappe.throw(
_(
"Exchange rate {0} does not match the exchange rate of Purchase Receipt {1}. Use the same exchange rate as the Purchase Receipt or enable {2} in {3} to adjust the landed cost based on this invoice."
).format(
frappe.bold(self.conversion_rate),
", ".join(mismatched),
frappe.bold(_("Set Landed Cost Based on Purchase Invoice Rate")),
get_link_to_form("Buying Settings", "Buying Settings", _("Buying Settings")),
)
)
def validate_invoice_hold(self):
if self.is_return:
frappe.throw(_("Return Purchase Invoice cannot be held."))
if self.docstatus < 1:
frappe.throw(_("Purchase Invoice can be held after submitting."))
def validate_release_date(self):
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
frappe.throw(_("Release date must be in the future"))
@@ -386,6 +439,9 @@ class PurchaseInvoice(BuyingController):
self.party_account_currency = account.account_currency
def check_on_hold_or_closed_status(self):
if self.get("is_return"):
return
check_list = []
for d in self.get("items"):
@@ -1365,7 +1421,20 @@ class PurchaseInvoice(BuyingController):
)
if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_adjustment_amt = stock_amount - warehouse_debit_amount
gl_entries.append(
@@ -1390,7 +1459,20 @@ class PurchaseInvoice(BuyingController):
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
cost_of_goods_sold_account = self.get_company_default("default_expense_account")
stock_asset_rbnb = (
self.get_company_default("asset_received_but_not_billed", ignore_validation=True)
if item.is_fixed_asset
else self.get_company_default("stock_received_but_not_billed", ignore_validation=True)
)
fallback_account = (
(item.expense_account or stock_asset_rbnb)
if self.is_return
else (stock_asset_rbnb or item.expense_account)
)
cost_of_goods_sold_account = (
self.get_company_default("default_expense_account", ignore_validation=True)
or fallback_account
)
stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
stock_adjustment_amt = warehouse_debit_amount - stock_amount
@@ -1855,14 +1937,38 @@ class PurchaseInvoice(BuyingController):
def on_recurring(self, reference_doc, auto_repeat_doc):
self.due_date = None
def block_invoice(self, hold_comment=None, release_date=None):
self.db_set("on_hold", 1)
self.db_set("hold_comment", cstr(hold_comment))
@frappe.whitelist(methods=["POST"])
def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
self.on_hold = 1
self.release_date = release_date
self.validate_block_invoice()
self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date})
@frappe.whitelist(methods=["POST"])
def unblock_invoice(self):
self.check_permission("write")
self.db_set({"on_hold": 0, "release_date": None})
@frappe.whitelist(methods=["POST"])
def change_release_date(self, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
if not self.on_hold:
frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date."))
self.release_date = release_date
self.validate_block_invoice()
self.db_set("release_date", release_date)
def unblock_invoice(self):
self.db_set("on_hold", 0)
self.db_set("release_date", None)
def validate_block_invoice(self):
self.validate_invoice_hold()
if self.outstanding_amount <= 0:
frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held."))
self.validate_release_date()
def set_tax_withholding(self):
self.set("advance_tax", [])
@@ -2082,28 +2188,6 @@ def make_stock_entry(source_name, target_doc=None):
return doc
@frappe.whitelist()
def change_release_date(name, release_date=None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_doc("Purchase Invoice", name)
pi.check_permission()
pi.db_set("release_date", release_date)
@frappe.whitelist()
def unblock_invoice(name):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_doc("Purchase Invoice", name)
pi.unblock_invoice()
@frappe.whitelist()
def block_invoice(name, release_date, hold_comment=None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_doc("Purchase Invoice", name)
pi.block_invoice(hold_comment, release_date)
@frappe.whitelist()
def make_inter_company_sales_invoice(source_name, target_doc=None):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
@@ -2118,6 +2202,11 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
if isinstance(args, str):
args = json.loads(args)
mapped_qty_by_item = get_qty_already_mapped(target_doc, "purchase_invoice_item")
def received_and_mapped_qty(obj):
return flt(obj.received_qty) + flt(mapped_qty_by_item.get(obj.name, 0))
def post_parent_process(source_parent, target_parent):
remove_items_with_zero_qty(target_parent)
set_missing_values(source_parent, target_parent)
@@ -2132,13 +2221,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
target_parent.run_method("calculate_taxes_and_totals")
def update_item(obj, target, source_parent):
target.qty = flt(obj.qty) - flt(obj.received_qty)
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
target.qty = pending_qty
target.received_qty = pending_qty
target.stock_qty = pending_qty * flt(obj.conversion_factor)
target.amount = pending_qty * flt(obj.rate)
target.base_amount = pending_qty * flt(obj.rate) * flt(source_parent.conversion_rate)
def select_item(d):
filtered_items = args.get("filtered_children", [])
@@ -2168,7 +2257,8 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
"wip_composite_asset": "wip_composite_asset",
},
"postprocess": update_item,
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
"condition": lambda doc: abs(received_and_mapped_qty(doc)) < abs(doc.qty)
and select_item(doc),
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",

View File

@@ -287,14 +287,166 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
def test_purchase_invoice_explicit_block(self):
pi = make_purchase_invoice()
pi.block_invoice()
release_date = add_days(nowdate(), 10)
pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
self.assertEqual(pi.on_hold, 1)
on_hold, hold_comment, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"]
)
self.assertEqual(on_hold, 1)
self.assertEqual(hold_comment, "Waiting for the goods")
self.assertEqual(getdate(saved_release_date), getdate(release_date))
pi.unblock_invoice()
self.assertEqual(pi.on_hold, 0)
on_hold, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "release_date"]
)
self.assertEqual(on_hold, 0)
self.assertIsNone(saved_release_date)
def test_purchase_invoice_cannot_be_held_before_submission(self):
pi = make_purchase_invoice(do_not_save=True)
pi.on_hold = 1
self.assertRaises(frappe.ValidationError, pi.save)
pi.on_hold = 0
pi.save()
pi.submit()
pi.block_invoice()
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1)
def test_return_purchase_invoice_cannot_be_held(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.on_hold = 1
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save)
return_pi.on_hold = 0
return_pi.save()
return_pi.submit()
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice)
def test_return_purchase_invoice_is_not_affected_by_hold_validations(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
# a return has a negative outstanding amount, which must not be mistaken
# for an invalid hold on a document that was never held
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.save()
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
self.assertEqual(return_pi.on_hold, 0)
self.assertLess(return_pi.outstanding_amount, 0)
def test_settled_purchase_invoice_cannot_be_held(self):
pi = make_purchase_invoice()
pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC")
pe.reference_no = "1"
pe.reference_date = nowdate()
pe.save()
pe.submit()
pi.reload()
self.assertEqual(pi.outstanding_amount, 0)
self.assertRaises(frappe.ValidationError, pi.block_invoice)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_release_date_of_held_invoice_must_be_in_future(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate())
def test_rejected_hold_does_not_partially_update_invoice(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
pi.reload()
self.assertEqual(pi.on_hold, 0)
self.assertIsNone(pi.release_date)
def test_change_release_date_of_held_invoice(self):
pi = make_purchase_invoice()
pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10))
new_release_date = add_days(nowdate(), 20)
pi.change_release_date(new_release_date)
self.assertEqual(
getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")),
getdate(new_release_date),
)
self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1))
def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self):
pi = make_purchase_invoice()
self.assertRaisesRegex(
frappe.ValidationError,
"Invoice is not blocked",
pi.change_release_date,
add_days(nowdate(), 10),
)
self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date"))
def test_hold_methods_are_whitelisted_document_methods(self):
import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module
pi = frappe.new_doc("Purchase Invoice")
for method in ("block_invoice", "unblock_invoice", "change_release_date"):
# raises if the method is not whitelisted for client side calls
pi.is_whitelisted(method)
self.assertFalse(
hasattr(purchase_invoice_module, method),
f"{method} should only be exposed as a document method",
)
def test_hold_methods_require_write_permission(self):
pi = make_purchase_invoice()
user = "test_pi_hold_permission@example.com"
if not frappe.db.exists("User", user):
frappe.get_doc(
{
"doctype": "User",
"email": user,
"first_name": "Test PI Hold",
"roles": [{"role": "Employee"}],
}
).insert(ignore_permissions=True)
frappe.set_user(user)
try:
self.assertRaises(frappe.PermissionError, pi.block_invoice)
self.assertRaises(frappe.PermissionError, pi.unblock_invoice)
self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10))
finally:
frappe.set_user("Administrator")
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_gl_entries_with_perpetual_inventory_against_pr(self):
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
@@ -361,6 +513,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
@@ -372,25 +530,15 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 80
self.assertRaises(frappe.ValidationError, pi.insert)
pi.conversion_rate = 70
pi.insert()
pi.submit()
# Get exchnage gain and loss account
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
# fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[0].base_net_amount - pr.items[0].base_net_amount
)
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
frappe.db.set_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
self.assertFalse(
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
@@ -398,11 +546,21 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
make_purchase_invoice as create_purchase_invoice,
)
# Creating Purchase Invoice with USD currency
original_value = frappe.db.get_single_value(
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
)
frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0)
self.addCleanup(
frappe.db.set_single_value,
"Buying Settings",
"set_landed_cost_based_on_purchase_invoice_rate",
original_value,
)
pr = frappe.new_doc("Purchase Receipt")
pr.currency = "USD"
pr.company = "_Test Company with perpetual inventory"
pr.conversion_rate = (70,)
pr.conversion_rate = 80
pr.supplier = "_Test Supplier USD"
pr.append(
"items",
@@ -412,34 +570,20 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
"rate": 100,
},
)
pr.append(
"items",
{"item_code": "_Test Item", "qty": 1, "rate": 5, "warehouse": "Stores - TCP1"},
)
pr.insert()
pr.submit()
# Createing purchase invoice against Purchase Receipt
pi = create_purchase_invoice(pr.name)
pi.conversion_rate = 80
pi.conversion_rate = 70
pi.credit_to = "_Test Payable USD - TCP1"
pi.insert()
pi.submit()
# Get exchnage gain and loss account
exchange_gain_loss_account = frappe.db.get_value("Company", pi.company, "exchange_gain_loss_account")
# fetching the latest GL Entry with exchange gain and loss account account
amount = frappe.db.get_value(
"GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit"
self.assertFalse(
frappe.db.exists("GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name})
)
discrepancy_caused_by_exchange_rate_diff = abs(
pi.items[1].base_net_amount - pr.items[1].base_net_amount
)
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
def test_purchase_invoice_change_naming_series(self):
pi = frappe.copy_doc(test_records[1])
pi.insert()
@@ -1510,6 +1654,96 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
)
frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", original_account)
def test_stock_adjustment_account_fallbacks_when_default_expense_account_unset(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import PurchaseInvoice
class StockAdjustmentInvoice:
company = "_Test Company"
conversion_rate = 1
update_stock = 1
is_internal_supplier = 0
return_against = None
project = None
def __init__(self, is_return, defaults):
self.is_return = is_return
self.defaults = defaults
def get(self, fieldname):
return None
def get_company_default(self, fieldname, ignore_validation=False):
return self.defaults.get(fieldname)
def get_gl_dict(self, args, *unused_args, **unused_kwargs):
return frappe._dict(args)
def make_invoice(is_return, defaults):
return StockAdjustmentInvoice(is_return, defaults)
def make_item(is_fixed_asset=0, expense_account="Item Expense - _TC"):
return frappe._dict(
{
"name": "row-1",
"warehouse": "Stores - _TC",
"valuation_rate": 10,
"qty": 10,
"conversion_factor": 1,
"base_net_amount": 100,
"item_tax_amount": 0,
"landed_cost_voucher_amount": 0,
"sales_incoming_rate": 0,
"is_fixed_asset": is_fixed_asset,
"expense_account": expense_account,
"cost_center": "Main - _TC",
"project": None,
"precision": lambda fieldname: 2,
}
)
defaults = {
"default_expense_account": None,
"stock_received_but_not_billed": "Stock Received But Not Billed - _TC",
"asset_received_but_not_billed": "Asset Received But Not Billed - _TC",
}
test_cases = (
(
"company default expense",
0,
make_item(),
{**defaults, "default_expense_account": "Default Expense - _TC"},
"Default Expense - _TC",
),
("stock rbnb", 0, make_item(), defaults, "Stock Received But Not Billed - _TC"),
(
"asset rbnb",
0,
make_item(is_fixed_asset=1),
defaults,
"Asset Received But Not Billed - _TC",
),
("return item expense", 1, make_item(), defaults, "Item Expense - _TC"),
(
"return without item expense",
1,
make_item(expense_account=None),
defaults,
"Stock Received But Not Billed - _TC",
),
)
for label, is_return, item, company_defaults, expected_account in test_cases:
with self.subTest(label=label):
invoice = make_invoice(is_return, company_defaults)
gl_entries = []
PurchaseInvoice.make_stock_adjustment_entry(
invoice, gl_entries, item, {(item.name, item.warehouse): 90}, "INR"
)
self.assertEqual(gl_entries[0].account, expected_account)
self.assertEqual(gl_entries[0].debit, 10)
self.assertEqual(gl_entries[0].debit_in_transaction_currency, 10)
@change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
@@ -2457,6 +2691,39 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
self.assertEqual(row.serial_no, "\n".join(serial_nos[:2]))
self.assertEqual(row.rejected_serial_no, serial_nos[2])
def test_purchase_invoice_return_against_closed_purchase_order(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
po = create_purchase_order(qty=2, rate=100)
invoices = []
for _ in range(2):
pi = make_pi_from_po(po.name)
pi.items[0].qty = 1
pi.submit()
invoices.append(pi)
make_return_doc("Purchase Invoice", invoices[0].name).submit()
po.reload()
po.update_status("Closed")
# a debit note against a closed Purchase Order should still go through,
# the same way a Sales Invoice return does against a closed Sales Order
debit_note = make_return_doc("Purchase Invoice", invoices[1].name)
debit_note.submit()
self.assertEqual(debit_note.docstatus, 1)
self.assertEqual(frappe.db.get_value("Purchase Order", po.name, "status"), "Closed")
# cancelling the debit note runs the same check on the closed order
debit_note.reload()
debit_note.cancel()
# a regular invoice against the closed order must still be blocked
blocked_pi = make_pi_from_po(po.name)
self.assertRaisesRegex(frappe.InvalidStatusError, "Closed", blocked_pi.save)
def test_make_pr_and_pi_from_po(self):
from erpnext.assets.doctype.asset.test_asset import create_asset_category
@@ -2814,6 +3081,23 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
self.assertRaises(StockOverReturnError, return_doc.save)
def test_partial_returns_ignore_received_qty_without_update_stock(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
invoice = make_purchase_invoice(qty=10, received_qty=10)
first_return = make_return_doc(invoice.doctype, invoice.name)
first_return.items[0].qty = -4
first_return.save().submit()
self.assertEqual(first_return.items[0].received_qty, -10)
second_return = make_return_doc(invoice.doctype, invoice.name)
second_return.items[0].qty = -6
second_return.save().submit()
self.assertEqual(second_return.docstatus, 1)
def test_apply_discount_on_grand_total(self):
"""
To test if after applying discount on grand total,

View File

@@ -25,10 +25,12 @@
"project",
"section_break_9",
"account_currency",
"net_amount",
"tax_amount",
"tax_amount_after_discount_amount",
"total",
"column_break_14",
"base_net_amount",
"base_tax_amount",
"base_total",
"base_tax_amount_after_discount_amount",
@@ -213,11 +215,11 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"default": "0",
@@ -241,20 +243,38 @@
"fieldtype": "Check",
"label": "Is Tax Withholding Account",
"read_only": 1
},
{
"description": "Basis for tax calculation",
"fieldname": "net_amount",
"fieldtype": "Currency",
"label": "Net Amount",
"options": "currency",
"read_only": 1
},
{
"description": "Basis for tax calculation",
"fieldname": "base_net_amount",
"fieldtype": "Currency",
"label": "Net Amount (Company Currency)",
"options": "Company:company:default_currency",
"read_only": 1
}
],
"grid_page_length": 50,
"idx": 1,
"istable": 1,
"links": [],
"modified": "2025-04-15 13:14:48.936047",
"modified": "2026-05-01 00:38:29.543523",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges",
"naming_rule": "Random",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -17,6 +17,7 @@ class PurchaseTaxesandCharges(Document):
account_currency: DF.Link | None
account_head: DF.Link
add_deduct_tax: DF.Literal["Add", "Deduct"]
base_net_amount: DF.Currency
base_tax_amount: DF.Currency
base_tax_amount_after_discount_amount: DF.Currency
base_total: DF.Currency
@@ -35,9 +36,11 @@ class PurchaseTaxesandCharges(Document):
included_in_print_rate: DF.Check
is_tax_withholding_account: DF.Check
item_wise_tax_detail: DF.Code | None
net_amount: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project: DF.Link | None
rate: DF.Float
row_id: DF.Data | None
tax_amount: DF.Currency

View File

@@ -77,7 +77,7 @@
"icon": "fa fa-money",
"idx": 1,
"links": [],
"modified": "2024-01-30 13:08:09.537242",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Taxes and Charges Template",
@@ -104,6 +104,22 @@
{
"read": 1,
"role": "Purchase User"
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "Accounts User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"show_title_field_in_link": 1,

View File

@@ -524,6 +524,10 @@ def validate_docs_for_voucher_types(doc_voucher_types):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_repost_allowed_types(doctype, txt, searchfield, start, page_len, filters):
# `filters` goes straight to db.get_all, so reaching this needs gating rather than typing.
# Reached only from this doctype's own form, which is System-Manager-only.
frappe.has_permission("Repost Accounting Ledger", throw=True)
filters = {"allowed": True}
if txt:

View File

@@ -27,6 +27,7 @@ def start_payment_ledger_repost(docname=None):
"""
if docname:
repost_doc = frappe.get_doc("Repost Payment Ledger", docname)
repost_doc.check_permission("submit")
if repost_doc.docstatus.is_submitted() and repost_doc.repost_status in ["Queued", "Failed"]:
try:
for entry in repost_doc.repost_vouchers:
@@ -122,6 +123,8 @@ class RepostPaymentLedger(Document):
def execute_repost_payment_ledger(docname):
"""Repost Payment Ledger Entries by background job."""
frappe.has_permission("Repost Payment Ledger", ptype="submit", doc=docname, throw=True)
job_name = "payment_ledger_repost_" + docname
frappe.enqueue(

View File

@@ -375,9 +375,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
get_query_filters: {
docstatus: 1,
status: ["not in", ["Closed", "On Hold"]],
per_billed: ["<", 99.99],
company: me.frm.doc.company,
},
get_query_method:
"erpnext.selling.doctype.sales_order.sales_order.get_potentially_billable_sales_orders",
allow_child_item_selection: true,
child_fieldname: "items",
child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"],

View File

@@ -597,6 +597,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.currency && doc.currency != erpnext.get_currency(doc.company)",
"depends_on": "customer",
"fieldname": "currency_and_price_list",
"fieldtype": "Section Break",
@@ -2198,7 +2199,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-04-06 22:30:28.513139",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -25,9 +25,9 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import (
CROSS_PARTY_FIELD_NO_MAP,
_get_party_details,
get_due_date,
get_party_account,
get_party_details,
)
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
@@ -1637,7 +1637,7 @@ class SalesInvoice(SellingController):
for payment_mode in self.payments:
if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount:
payment_mode.base_amount -= flt(self.change_amount)
payment_mode.base_amount -= flt(self.base_change_amount)
against_voucher = self.name
if self.is_return and self.return_against and not self.update_outstanding_for_self:
@@ -2266,9 +2266,9 @@ def make_delivery_note(source_name, target_doc=None):
"cost_center": "cost_center",
},
"postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1
and not doc.dn_detail
and doc.qty - doc.delivered_qty > 0,
"condition": lambda doc: (
doc.delivered_by_supplier != 1 and not doc.dn_detail and doc.qty - doc.delivered_qty > 0
),
},
"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {
@@ -2590,9 +2590,28 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
@frappe.whitelist()
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
reference_field = "inter_company_invoice_reference"
if doctype == "Purchase Order":
reference_field = "inter_company_order_reference"
# The only two targets this resolves a reference field for. Stating them rejects a caller
# supplied doctype that would otherwise be filtered on a column it does not have.
reference_fields = {
"Purchase Invoice": ("inter_company_invoice_reference", "Sales Invoice", "sales_invoice_item"),
"Purchase Order": ("inter_company_order_reference", "Sales Order", "sales_order_item"),
}
if doctype not in reference_fields:
frappe.throw(_("Invalid doctype {0}").format(doctype), frappe.PermissionError)
reference_field, source_doctype, expected_fieldname = reference_fields[doctype]
# the source document decides access, not the targets: those belong to the counterpart company
# and the caller legitimately may not read them. doc= for User Permissions.
frappe.has_permission(source_doctype, doc=reference_name, throw=True)
# `reference_fieldname` becomes a selected column and the result key, so it has to be this
# target's own reference field: any other item-table column would be returned from unauthorised rows.
if reference_fieldname != expected_fieldname:
frappe.throw(
_("{0} is not a valid reference field for {1}").format(reference_fieldname, doctype),
frappe.ValidationError,
)
filters = {
reference_field: reference_name,
@@ -2737,7 +2756,7 @@ def update_taxes(
master_doctype=None,
):
# Update Party Details
party_details = get_party_details(
party_details = _get_party_details(
party=party,
party_type=party_type,
company=company,

View File

@@ -47,7 +47,7 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
create_stock_reconciliation,
)
from erpnext.stock.get_item_details import get_item_tax_map
from erpnext.stock.utils import get_incoming_rate, get_stock_balance
from erpnext.stock.utils import _get_incoming_rate, get_stock_balance
class TestSalesInvoice(FrappeTestCase):
@@ -1235,6 +1235,33 @@ class TestSalesInvoice(FrappeTestCase):
frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1)
def test_pos_change_amount_multi_currency_gl_entry(self):
frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 0)
si = create_sales_invoice(do_not_save=True)
si.is_pos = 1
si.currency = "USD"
si.conversion_rate = 50
si.party_account_currency = "USD"
si.account_for_change_amount = "Cash - _TC"
si.change_amount = 50
si.base_change_amount = 2500
si.append(
"payments",
{"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500},
)
gl_entries = []
si.make_pos_gl_entries(gl_entries)
debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to)
cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC")
self.assertEqual(flt(debtors_entry["credit"]), 5000.0)
self.assertEqual(flt(cash_entry["debit"]), 5000.0)
frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1)
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
if validate_without_change_gle:
cash_amount -= pos.change_amount
@@ -2916,7 +2943,7 @@ class TestSalesInvoice(FrappeTestCase):
rate = 0.0
for d in si.get("items"):
rate = get_incoming_rate(
rate = _get_incoming_rate(
{
"item_code": d.item_code,
"warehouse": d.warehouse,

View File

@@ -21,10 +21,12 @@
"rate",
"section_break_9",
"account_currency",
"net_amount",
"tax_amount",
"total",
"tax_amount_after_discount_amount",
"column_break_13",
"base_net_amount",
"base_tax_amount",
"base_total",
"base_tax_amount_after_discount_amount",
@@ -190,11 +192,11 @@
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"allow_on_submit": 1,
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"default": "0",
@@ -220,19 +222,36 @@
"label": "Account Currency",
"options": "Currency",
"read_only": 1
},
{
"description": "Basis for tax calculation",
"fieldname": "net_amount",
"fieldtype": "Currency",
"label": "Net Amount",
"options": "currency",
"read_only": 1
},
{
"description": "Basis for tax calculation",
"fieldname": "base_net_amount",
"fieldtype": "Currency",
"label": "Net Amount (Company Currency)",
"options": "Company:company:default_currency",
"read_only": 1
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2024-01-14 10:08:17.776528",
"modified": "2026-05-01 00:37:57.880071",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Taxes and Charges",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "ASC",
"states": []
}
}

View File

@@ -16,6 +16,7 @@ class SalesTaxesandCharges(Document):
account_currency: DF.Link | None
account_head: DF.Link
base_net_amount: DF.Currency
base_tax_amount: DF.Currency
base_tax_amount_after_discount_amount: DF.Currency
base_total: DF.Currency
@@ -33,9 +34,11 @@ class SalesTaxesandCharges(Document):
included_in_paid_amount: DF.Check
included_in_print_rate: DF.Check
item_wise_tax_detail: DF.Code | None
net_amount: DF.Currency
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
project: DF.Link | None
rate: DF.Float
row_id: DF.Data | None
tax_amount: DF.Currency

View File

@@ -79,7 +79,7 @@
"icon": "fa fa-money",
"idx": 1,
"links": [],
"modified": "2024-01-30 13:07:28.801104",
"modified": "2026-09-16 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Taxes and Charges Template",
@@ -113,6 +113,26 @@
"role": "Sales Master Manager",
"share": 1,
"write": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"show_title_field_in_link": 1,

View File

@@ -7,10 +7,9 @@ def get_data():
"non_standard_fieldnames": {
"Tax Rule": "sales_tax_template",
"Subscription": "sales_tax_template",
"Restaurant": "default_tax_template",
},
"transactions": [
{"label": _("Transactions"), "items": ["Sales Invoice", "Sales Order", "Delivery Note"]},
{"label": _("References"), "items": ["POS Profile", "Subscription", "Restaurant", "Tax Rule"]},
{"label": _("References"), "items": ["POS Profile", "Subscription", "Tax Rule"]},
],
}

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