Compare commits

..

343 Commits

Author SHA1 Message Date
Frappe PR Bot
12cd563fb9 chore(release): Bumped to Version 16.35.0
# [16.35.0](https://github.com/frappe/erpnext/compare/v16.34.2...v16.35.0) (2026-09-15)

### Bug Fixes

* % Amount Billed exceeds 100% on fully rejected Purchase Receipts ([#58897](https://github.com/frappe/erpnext/issues/58897)) ([79c8e4d](79c8e4db7f))
* **accounts:** add permission checks on `execute_repost_payment_ledger` (backport [#58980](https://github.com/frappe/erpnext/issues/58980)) ([#58982](https://github.com/frappe/erpnext/issues/58982)) ([9bc98ef](9bc98efe2f))
* **accounts:** add permission checks on `invoice_discounting.get_invoices` (backport [#58975](https://github.com/frappe/erpnext/issues/58975)) ([#58990](https://github.com/frappe/erpnext/issues/58990)) ([a9f3d69](a9f3d69fbe))
* **accounts:** reject disabled price lists in buying and selling ([#58891](https://github.com/frappe/erpnext/issues/58891)) ([5bd623c](5bd623c4dd))
* **accounts:** stop counting received qty on non-stock invoice returns (backport [#58924](https://github.com/frappe/erpnext/issues/58924)) ([#58929](https://github.com/frappe/erpnext/issues/58929)) ([3856638](3856638d01))
* **asset:** recompute reference doc reqd on asset type change ([6d447f6](6d447f6847))
* **assets:** build Finance Books even when Calculate Depreciation is checked before Net Purchase Amount is entered ([1652f6e](1652f6ea42))
* **assets:** discard stale set_finance_book responses ([dfde7d8](dfde7d811c))
* avoid TypeError on new Work Order when company is not set ([#58969](https://github.com/frappe/erpnext/issues/58969)) ([e20501a](e20501a15c))
* calculate holiday list totals for half days ([#58792](https://github.com/frappe/erpnext/issues/58792)) ([3d6afff](3d6afffcef))
* coerce cost_allocation_per to float in BOM cost allocation ([1918ed6](1918ed6a3c))
* keep a dunning claimable until its interest is paid too ([7723f5a](7723f5aaed))
* keep each row's own rate under maintain same rate ([855d038](855d0385da))
* **manufacturing:** handle empty bom cost allocation ([#58939](https://github.com/frappe/erpnext/issues/58939)) ([77d8d5b](77d8d5b3e2))
* **manufacturing:** handle empty raw materials in workstation ([#58927](https://github.com/frappe/erpnext/issues/58927)) ([3b787c9](3b787c9443))
* **mrp:** align monthly bucket labels with demand dates ([#58963](https://github.com/frappe/erpnext/issues/58963)) ([c5773e7](c5773e7899))
* prevent disabled items from being used in bom ([#58997](https://github.com/frappe/erpnext/issues/58997)) ([f1a7fd0](f1a7fd006d))
* read posting date confirmation setting from boot data ([1349e0b](1349e0b3ab))
* refresh posting date confirmation setting ([a404d9b](a404d9b4e8))
* reset price list fallback when changing parties ([#58893](https://github.com/frappe/erpnext/issues/58893)) ([6751264](6751264ba5))
* resolve conflicts ([2add72b](2add72ba36))
* **selling:** add email permission check on `send_emails` (backport [#58935](https://github.com/frappe/erpnext/issues/58935)) ([#58938](https://github.com/frappe/erpnext/issues/58938)) ([a79abc0](a79abc0462))
* **selling:** exclude fully billed orders from the invoice picker (backport [#58966](https://github.com/frappe/erpnext/issues/58966)) ([#58967](https://github.com/frappe/erpnext/issues/58967)) ([5d9cbd9](5d9cbd9755))
* **selling:** list billable sales orders oldest first (backport [#59010](https://github.com/frappe/erpnext/issues/59010)) ([#59011](https://github.com/frappe/erpnext/issues/59011)) ([8672a6e](8672a6e604))
* **stock:** distribute additional costs when incoming items have no value ([b603581](b6035810f0))
* **stock:** do not rebuild the Warehouse tree on an account map miss (backport [#58956](https://github.com/frappe/erpnext/issues/58956)) ([#58973](https://github.com/frappe/erpnext/issues/58973)) ([6fb76eb](6fb76eba71))
* **stock:** guard inventory dimension lookup without reference document ([#58889](https://github.com/frappe/erpnext/issues/58889)) ([b21ca36](b21ca36428))
* **stock:** ignore disabled price lists on transactions (backport [#58926](https://github.com/frappe/erpnext/issues/58926)) ([#58931](https://github.com/frappe/erpnext/issues/58931)) ([53e5339](53e53390f4))
* **stock:** remove empty delivery stops after mapping ([#58896](https://github.com/frappe/erpnext/issues/58896)) ([ca33019](ca33019fdf))
* **stock:** set item_name to fetch if empty in packing slip item ([#58925](https://github.com/frappe/erpnext/issues/58925)) ([9e5d87a](9e5d87ac4a))
* **stock:** show actual outgoing rate in ledger preview ([#58700](https://github.com/frappe/erpnext/issues/58700)) ([2932793](2932793af0))
* **stock:** use correct field when reading previous stock closing balance (backport [#55898](https://github.com/frappe/erpnext/issues/55898)) ([#58854](https://github.com/frappe/erpnext/issues/58854)) ([70f391d](70f391d5e7)), closes [#54819](https://github.com/frappe/erpnext/issues/54819)
* **stock:** value stock entry rows as of the posting date (backport [#59042](https://github.com/frappe/erpnext/issues/59042)) ([#59048](https://github.com/frappe/erpnext/issues/59048)) ([7db6303](7db6303ac2))
* **subcontracting:** cannot select Against Finished Good on customer receipt (backport [#58949](https://github.com/frappe/erpnext/issues/58949)) ([#58951](https://github.com/frappe/erpnext/issues/58951)) ([df62766](df62766157))
* **subcontracting:** validate project across the subcontracting flow ([#58965](https://github.com/frappe/erpnext/issues/58965)) ([0095560](0095560dc7))
* sync translations from crowdin (version-16-hotfix) ([#59052](https://github.com/frappe/erpnext/issues/59052)) ([442fcd3](442fcd33e9))
* validate shelf life for automatically created expiring batches ([#58911](https://github.com/frappe/erpnext/issues/58911)) ([fa504b8](fa504b87db))

### Features

* **stock:** multi select item and warehouse filters in warehouse wise item balance ([bf33bc1](bf33bc17c0))
2026-09-15 18:46:11 +00:00
Diptanil Saha
6441ecd784 Merge pull request #59073 from frappe/version-16-hotfix
chore: release v16
2026-09-16 00:14:39 +05:30
Khushi Rawat
78cac8ed09 Merge pull request #59083 from frappe/mergify/bp/version-16-hotfix/pr-58941
fix: asset depreciation table stays empty if you check the box before entering the amount (backport #58941)
2026-09-16 00:03:31 +05:30
Khushi Rawat
9cf6582b4b chore: remove unnecessary comments
(cherry picked from commit 514c3b45d0)
2026-09-15 18:28:32 +00:00
Henil
dfde7d811c fix(assets): discard stale set_finance_book responses
Rapid successive edits to Net Purchase Amount could fire overlapping
set_finance_book calls; if an older request's response arrived after a
newer one, it could overwrite Finance Books with values computed from
a stale amount. Now the callback only applies a response if the fields
it was based on still match the form's current values.

(cherry picked from commit 5a8126412a)
2026-09-15 18:28:32 +00:00
Henil
1652f6ea42 fix(assets): build Finance Books even when Calculate Depreciation is checked before Net Purchase Amount is entered
Previously, checking "Calculate Depreciation" (or picking the Item)
before typing in "Net Purchase Amount" left the Finance Books table
empty, because the depreciation schedule was only built at the moment
those fields already had values. Entering the amount afterward only
updated existing Finance Books rows, so an empty table stayed empty.

Now, entering the amount also builds Finance Books from scratch if it
was left empty, regardless of the order fields were filled in.

(cherry picked from commit 5d1ffa7fca)
2026-09-15 18:28:32 +00:00
Sudharsanan Ashok
105f62fa2c Merge pull request #59013 from frappe/mergify/bp/version-16-hotfix/pr-58997
fix: prevent disabled items from being used in BOM (backport #58997)
2026-09-15 22:07:31 +05:30
Sudharsanan Ashok
05e69f6777 Merge pull request #58943 from frappe/mergify/bp/version-16-hotfix/pr-58925
fix(stock): set item_name to fetch if empty in packing slip item (backport #58925)
2026-09-15 17:59:09 +05:30
MochaMind
6f01469613 chore: update POT file (#59046) 2026-09-15 12:14:29 +02:00
Krishna Pramod Shirsath
05e89fe53f Merge pull request #59005 from frappe/mergify/bp/version-16-hotfix/pr-58037
feat(stock): multi select item and warehouse filters in warehouse wise item balance (backport #58037)
2026-09-15 15:00:21 +05:30
Mihir Kandoi
7db6303ac2 fix(stock): value stock entry rows as of the posting date (backport #59042) (#59048) 2026-09-15 12:14:04 +05:30
MochaMind
442fcd33e9 fix: sync translations from crowdin (version-16-hotfix) (#59052)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-14 19:05:44 +02:00
Sudharsanan Ashok
d40d16c260 Merge pull request #59041 from frappe/mergify/bp/version-16-hotfix/pr-58965
fix(subcontracting): validate project across the subcontracting flow  (backport #58965)
2026-09-13 17:53:51 +05:30
Sudharsanan11
2add72ba36 fix: resolve conflicts 2026-09-13 12:12:10 +05:30
Sudharsanan Ashok
0095560dc7 fix(subcontracting): validate project across the subcontracting flow (#58965)
(cherry picked from commit fe25746feb)

# Conflicts:
#	erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py
#	erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py
#	erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
2026-09-13 06:29:25 +00:00
pandiyan
f1a7fd006d 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
Sudharsanan11
b86e7c672c test(stock): cover multi select item and warehouse filters in warehouse wise item balance
three cases: multiple warehouses get a column each plus the total qty
column, a selected group warehouse still expands to its children, and
multiple items report side by side while unselected items stay out.

(cherry picked from commit eb4bb2cbce)

# Conflicts:
#	erpnext/stock/report/warehouse_wise_item_balance_age_and_value/test_warehouse_wise_item_balance_age_and_value.py
2026-09-12 14:31:28 +05:30
Sudharsanan11
bf33bc17c0 feat(stock): multi select item and warehouse filters in warehouse wise item balance
the item and warehouse filters took one value at a time, so comparing a
few warehouses meant re-running the report for each one.

both are multiselectlist now, matching the stock balance report. the
warehouse column list unions the subtree of every selected warehouse,
and get_items passes a list through instead of wrapping it. plain string
values still work, so saved filters and existing callers are unaffected.

(cherry picked from commit 3c92a9e853)
2026-09-12 14:31:28 +05:30
mergify[bot]
d4bacb7354 refactor(manufacturing): rename secondary item valuation type to "% of Component Cost" (backport #59021) (#59022)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-12 06:19:45 +00:00
mergify[bot]
8672a6e604 fix(selling): list billable sales orders oldest first (backport #59010) (#59011)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-11 12:19:12 +00:00
Shllokkk
ee2474aa9c Merge pull request #58984 from Shllokkk/bom-cost-allocation-per-type-v16
fix: coerce cost_allocation_per to float in BOM cost allocation
2026-09-11 14:17:07 +05:30
mergify[bot]
a9f3d69fbe fix(accounts): add permission checks on invoice_discounting.get_invoices (backport #58975) (#58990)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-11 04:30:00 +00:00
mergify[bot]
d0b8379404 fix(regional)!: remove irs_1099_print whitelisted method (backport #58976) (#58992)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-11 04:22:56 +00:00
mergify[bot]
9bc98efe2f fix(accounts): add permission checks on execute_repost_payment_ledger (backport #58980) (#58982)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-11 09:38:32 +05:30
Shllokkk
1918ed6a3c fix: coerce cost_allocation_per to float in BOM cost allocation 2026-09-10 22:23:57 +05:30
Mihir Kandoi
5d9cbd9755 fix(selling): exclude fully billed orders from the invoice picker (backport #58966) (#58967) 2026-09-10 20:50:40 +05:30
Khushi Rawat
c1f37750af Merge pull request #58983 from khushi8112/fix/asset-type-toggle-reqd
fix(asset): recompute reference doc reqd on asset type change
2026-09-10 20:22:28 +05:30
khushi8112
6d447f6847 fix(asset): recompute reference doc reqd on asset type change
The asset_type change handler no longer called toggle_reference_doc, so
switching the asset type never cleared the mandatory flags on
purchase_receipt/purchase_invoice, throwing a mandatory error on the
first save.
2026-09-10 20:20:37 +05:30
mergify[bot]
6fb76eba71 fix(stock): do not rebuild the Warehouse tree on an account map miss (backport #58956) (#58973)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-10 13:00:18 +00:00
Sudharsanan Ashok
8e73c94976 Merge pull request #58971 from frappe/mergify/bp/version-16-hotfix/pr-58969
fix: avoid TypeError on new Work Order when company is not set (backport #58969)
2026-09-10 18:09:35 +05:30
Sudharsanan Ashok
2b15ad1389 Merge pull request #58947 from frappe/mergify/bp/version-16-hotfix/pr-58927
fix(manufacturing): handle empty raw materials in workstation (backport #58927)
2026-09-10 18:09:19 +05:30
Sudharsanan Ashok
baf68a9ce7 Merge pull request #58945 from frappe/mergify/bp/version-16-hotfix/pr-58939
fix(manufacturing): handle empty bom cost allocation (backport #58939)
2026-09-10 18:08:11 +05:30
Pandiyan P
e20501a15c fix: avoid TypeError on new Work Order when company is not set (#58969)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
(cherry picked from commit d82c35aae9)
2026-09-10 12:33:20 +00:00
Sudharsanan Ashok
2788704d81 Merge pull request #58970 from frappe/mergify/bp/version-16-hotfix/pr-58963
fix(mrp): align monthly bucket labels with demand dates  (backport #58963)
2026-09-10 17:57:57 +05:30
Sudharsanan Ashok
c5773e7899 fix(mrp): align monthly bucket labels with demand dates (#58963)
(cherry picked from commit b1e99a70ac)
2026-09-10 12:14:04 +00:00
pandiyan
3b787c9443 fix(manufacturing): handle empty raw materials in workstation (#58927)
(cherry picked from commit 3be0c7801a)
2026-09-10 11:38:15 +05:30
mergify[bot]
df62766157 fix(subcontracting): cannot select Against Finished Good on customer receipt (backport #58949) (#58951)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 14:50:43 +00:00
Pandiyan P
77d8d5b3e2 fix(manufacturing): handle empty bom cost allocation (#58939)
(cherry picked from commit 33a066d568)
2026-09-09 12:34:42 +00:00
Shllokkk
33a74a2730 Merge pull request #58944 from Shllokkk/maintain-same-rate-unsaved-rows-v16
fix(stock): preserve rates for unsaved mapped rows (backport #58852)
2026-09-09 17:35:17 +05:30
Shllokkk
277c9a3b63 test: cover maintain-same-rate rate lock for batch items 2026-09-09 17:17:56 +05:30
Shllokkk
855d0385da fix: keep each row's own rate under maintain same rate 2026-09-09 17:17:56 +05:30
Afsal Syed
9e5d87ac4a fix(stock): set item_name to fetch if empty in packing slip item (#58925)
(cherry picked from commit f130c64530)
2026-09-09 11:37:38 +00:00
mergify[bot]
a79abc0462 fix(selling): add email permission check on send_emails (backport #58935) (#58938)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-09 16:04:33 +05:30
mergify[bot]
53e53390f4 fix(stock): ignore disabled price lists on transactions (backport #58926) (#58931)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 10:10:27 +00:00
Shllokkk
79c8e4db7f 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
2026-09-09 15:03:59 +05:30
mergify[bot]
3856638d01 fix(accounts): stop counting received qty on non-stock invoice returns (backport #58924) (#58929)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 09:11:46 +00:00
Sudharsanan Ashok
777480bab3 Merge pull request #58921 from frappe/mergify/bp/version-16-hotfix/pr-58896
fix(stock): remove empty delivery stops after mapping (backport #58896)
2026-09-09 13:29:47 +05:30
Sudharsanan Ashok
834afed494 Merge pull request #58902 from frappe/mergify/bp/version-16-hotfix/pr-58792
fix: calculate holiday list totals for half days (backport #58792)
2026-09-09 13:25:46 +05:30
Sudharsanan Ashok
4e3a3a3300 Merge pull request #58915 from frappe/mergify/bp/version-16-hotfix/pr-58911
fix: validate shelf life for automatically created expiring batches (backport #58911)
2026-09-09 13:25:07 +05:30
Sudharsanan Ashok
38f26d248e Merge pull request #58913 from frappe/mergify/bp/version-16-hotfix/pr-58893
fix: reset price list fallback when changing parties (backport #58893)
2026-09-09 13:24:23 +05:30
Sudharsanan Ashok
add1d9f4e4 Merge pull request #58904 from frappe/mergify/bp/version-16-hotfix/pr-58889
fix(stock): guard inventory dimension lookup without reference document (backport #58889)
2026-09-09 13:23:54 +05:30
Sudharsanan Ashok
7e16239d9a Merge pull request #58901 from frappe/mergify/bp/version-16-hotfix/pr-58891
fix(accounts): reject disabled price lists in buying and selling (backport #58891)
2026-09-09 13:23:23 +05:30
pandiyan
ca33019fdf 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:10:34 +05:30
Khushi Rawat
40d3ce5a5d Merge pull request #58918 from frappe/mergify/bp/version-16-hotfix/pr-58909
fix: read posting date confirmation setting from boot data (backport #58909)
2026-09-09 13:03:37 +05:30
khushi8112
a404d9b4e8 fix: refresh posting date confirmation setting
(cherry picked from commit bcade8f0ba)
2026-09-09 07:21:05 +00:00
khushi8112
1349e0b3ab fix: read posting date confirmation setting from boot data
(cherry picked from commit db9e93306a)
2026-09-09 07:21:04 +00:00
Khushi Rawat
a4a2e5e36c Merge pull request #58908 from frappe/mergify/bp/version-16-hotfix/pr-58227
fix: keep a dunning claimable until its interest is paid too  (backport #58227)
2026-09-09 12:42:41 +05:30
Pandiyan P
fa504b87db 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:29 +00:00
Pandiyan P
6751264ba5 fix: reset price list fallback when changing parties (#58893)
(cherry picked from commit 4c32acf300)
2026-09-09 06:56:32 +00:00
pandiyan
7723f5aaed 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:20:58 +05:30
pandiyan
d2920915d2 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:20:58 +05:30
Krishna Pramod Shirsath
70f391d5e7 fix(stock): use correct field when reading previous stock closing balance (backport #55898) (#58854)
Co-authored-by: Nabin Hait <nabinhait@gmail.com>
Fixes #54819
2026-09-09 09:52:00 +05:30
pandiyan
3d6afffcef fix: calculate holiday list totals for half days (#58792)
(cherry picked from commit 51fb261b6b)

Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 09:40:32 +05:30
Pandiyan P
b21ca36428 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:13:05 +00:00
Pandiyan P
5bd623c4dd fix(accounts): reject disabled price lists in buying and selling (#58891)
(cherry picked from commit f8c2f3440b)
2026-09-09 01:28:13 +00:00
Sudharsanan Ashok
596e086e1c Merge pull request #58887 from aerele/fix/additional-cost-zero-valued-items-v16
fix(stock): distribute additional costs when incoming items have no value (backport #58842)
2026-09-08 19:55:54 +05:30
Sudharsanan11
b6035810f0 fix(stock): distribute additional costs when incoming items have no value 2026-09-08 19:13:04 +05:30
Sudharsanan Ashok
2e6a9aad60 Merge pull request #58883 from frappe/mergify/bp/version-16-hotfix/pr-58700
fix(stock): show actual outgoing rate in ledger preview (backport #58700)
2026-09-08 19:08:04 +05:30
Afsal Syed
5edb7e2abc test(stock): add ledger preview unit tests (#58700) 2026-09-08 18:56:21 +05:30
Afsal Syed
2932793af0 fix(stock): show actual outgoing rate in ledger preview (#58700) 2026-09-08 18:56:21 +05:30
Frappe PR Bot
4048fb70e1 chore(release): Bumped to Version 16.34.2
## [16.34.2](https://github.com/frappe/erpnext/compare/v16.34.1...v16.34.2) (2026-09-08)

### Bug Fixes

* **accounts:** enforce account field allow-list on financial report filters (backport [#58790](https://github.com/frappe/erpnext/issues/58790)) ([#58849](https://github.com/frappe/erpnext/issues/58849)) ([6e39b42](6e39b42137))
* **accounts:** reject same-account internal transfers (backport [#58529](https://github.com/frappe/erpnext/issues/58529)) ([#58877](https://github.com/frappe/erpnext/issues/58877)) ([6ec3035](6ec30350d2))
* **accounts:** round journal entry totals to field precision (backport [#58629](https://github.com/frappe/erpnext/issues/58629)) ([#58875](https://github.com/frappe/erpnext/issues/58875)) ([9fff01d](9fff01db32))
* add reconciliation after submit logic for bank transactions ([#57330](https://github.com/frappe/erpnext/issues/57330)) ([0a60d68](0a60d6805f))
* **bank reconciliation:** match Payment Entries on the bank-side amount (backport [#57740](https://github.com/frappe/erpnext/issues/57740)) ([#58765](https://github.com/frappe/erpnext/issues/58765)) ([189bd1f](189bd1f39d))
* **banking:** Federal bank dark logo (backport [#58844](https://github.com/frappe/erpnext/issues/58844)) ([#58845](https://github.com/frappe/erpnext/issues/58845)) ([3cc73e4](3cc73e4282))
* **banking:** find transfers on the same day (backport [#58766](https://github.com/frappe/erpnext/issues/58766)) ([#58767](https://github.com/frappe/erpnext/issues/58767)) ([8229aea](8229aeaead))
* **banking:** UI cleanup and better statement parsing (backport [#58817](https://github.com/frappe/erpnext/issues/58817)) ([#58824](https://github.com/frappe/erpnext/issues/58824)) ([0610708](0610708d78))
* **batch:** show Expired status only after expiry date has passed ([#58736](https://github.com/frappe/erpnext/issues/58736)) ([0684599](0684599bdb))
* check material request price list permission ([#58740](https://github.com/frappe/erpnext/issues/58740)) ([e5b1ff6](e5b1ff667d))
* check write permission in whitelisted document methods (backport [#58689](https://github.com/frappe/erpnext/issues/58689)) ([#58701](https://github.com/frappe/erpnext/issues/58701)) ([074f9f0](074f9f0828))
* **crm:** check read permission on lead in add_lead_to_prospect ([2cc1a51](2cc1a51d9a))
* fetch accounts by company (backport [#58523](https://github.com/frappe/erpnext/issues/58523)) ([#58879](https://github.com/frappe/erpnext/issues/58879)) ([75cb796](75cb796477))
* **gross-profit:** include item_name in export (backport [#58631](https://github.com/frappe/erpnext/issues/58631)) ([#58864](https://github.com/frappe/erpnext/issues/58864)) ([59b80b9](59b80b903a))
* **journal-entry:** avoid full grid re-render per row in set_exchange_rate (backport [#58328](https://github.com/frappe/erpnext/issues/58328)) ([#58803](https://github.com/frappe/erpnext/issues/58803)) ([8b51005](8b51005525))
* **manufacturing:** account for process loss in Production Plan Work Orders (backport [#58799](https://github.com/frappe/erpnext/issues/58799)) ([#58838](https://github.com/frappe/erpnext/issues/58838)) ([cfdf976](cfdf97601a))
* **manufacturing:** apply MOQ once across Production Plan rows (v16) ([#58831](https://github.com/frappe/erpnext/issues/58831)) ([f6dbb31](f6dbb3131d))
* **manufacturing:** apply safety stock once across Production Plan rows (v16) ([#58832](https://github.com/frappe/erpnext/issues/58832)) ([4719ad9](4719ad9b91))
* **manufacturing:** share transfer stock across Production Plan rows (v16) ([#58834](https://github.com/frappe/erpnext/issues/58834)) ([e59fb39](e59fb396e5))
* **manufacturing:** sum consolidated sub-assembly required quantity (v16) ([#58833](https://github.com/frappe/erpnext/issues/58833)) ([a7c5ab8](a7c5ab89e8))
* minor improvements to financial report template validation ([#58724](https://github.com/frappe/erpnext/issues/58724)) ([d23b407](d23b407ec7))
* order smallest purchase UOM qty that meets min order qty (backport [#57883](https://github.com/frappe/erpnext/issues/57883)) ([#58813](https://github.com/frappe/erpnext/issues/58813)) ([28f0ac5](28f0ac5287))
* persist rendered terms on transaction save ([#58858](https://github.com/frappe/erpnext/issues/58858)) ([89872eb](89872ebb37))
* **pos:** use company-currency change amount when netting pos gl entries (backport [#58599](https://github.com/frappe/erpnext/issues/58599)) ([#58678](https://github.com/frappe/erpnext/issues/58678)) ([06057cc](06057ccfc0))
* prevent duplicate Batch messages and Project links ([#58705](https://github.com/frappe/erpnext/issues/58705)) ([2dc2a04](2dc2a04522))
* resolve code lists by URI and version (backport [#58770](https://github.com/frappe/erpnext/issues/58770)) ([#58772](https://github.com/frappe/erpnext/issues/58772)) ([31319bd](31319bd36e))
* resolve conflicts ([d4b52c2](d4b52c236a))
* restore hover tooltip on Profit and Loss dashboard chart ([bb26f8f](bb26f8f7b9))
* **selling:** fetch orders within billing allowance (backport [#58751](https://github.com/frappe/erpnext/issues/58751)) ([#58820](https://github.com/frappe/erpnext/issues/58820)) ([817926c](817926ca2e))
* **setup:** strict permissions for transaction deletion record (backport [#58687](https://github.com/frappe/erpnext/issues/58687)) ([#58723](https://github.com/frappe/erpnext/issues/58723)) ([dfb64d7](dfb64d7635))
* show cost center for unmatched payments in receivables (backport [#58453](https://github.com/frappe/erpnext/issues/58453)) ([#58881](https://github.com/frappe/erpnext/issues/58881)) ([51bf7cb](51bf7cb241))
* **stock:** allow creating stock closing balances (backport [#58590](https://github.com/frappe/erpnext/issues/58590)) ([#58685](https://github.com/frappe/erpnext/issues/58685)) ([a1c8dc8](a1c8dc878d))
* **stock:** carry accounting dimensions from landed cost voucher charges into gl entries ([0c7be31](0c7be311b8))
* **stock:** recalculate delivery note billing after return ([#58869](https://github.com/frappe/erpnext/issues/58869)) ([7bb19c8](7bb19c89b1))
* **subcontracting:** add condition to exclude tax withholding accounts in purchase receipt mapping ([15a531f](15a531f03f))
* sync translations from crowdin (version-16-hotfix) ([#58580](https://github.com/frappe/erpnext/issues/58580)) ([6971c80](6971c80743))
* **timesheet:** handle empty allowed projects (backport [#58745](https://github.com/frappe/erpnext/issues/58745)) ([#58746](https://github.com/frappe/erpnext/issues/58746)) ([f75601e](f75601e9b1))
* use account currency in Bank Reconciliation Statement print (backport [#58692](https://github.com/frappe/erpnext/issues/58692)) ([#58871](https://github.com/frappe/erpnext/issues/58871)) ([b1bf95d](b1bf95d5b3))
* user not able to set valuation rate zero in stock reco (backport [#58800](https://github.com/frappe/erpnext/issues/58800)) ([#58823](https://github.com/frappe/erpnext/issues/58823)) ([fb13222](fb132225d7))

### Reverts

* **manufacturing:** remove material coverage changes on version 16 ([#58716](https://github.com/frappe/erpnext/issues/58716)) ([82392fa](82392fa747))
2026-09-08 13:23:31 +00:00
Diptanil Saha
cc57a7cf55 Merge pull request #58868 from frappe/version-16-hotfix
chore: release v16
2026-09-08 18:50:53 +05:30
mergify[bot]
59b80b903a fix(gross-profit): include item_name in export (backport #58631) (#58864)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-09-08 18:20:34 +05:30
mergify[bot]
06057ccfc0 fix(pos): use company-currency change amount when netting pos gl entries (backport #58599) (#58678)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-09-08 12:47:05 +00:00
mergify[bot]
9fff01db32 fix(accounts): round journal entry totals to field precision (backport #58629) (#58875)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-09-08 12:43:14 +00:00
Sudharsanan Ashok
db8974fb95 Merge pull request #58873 from frappe/mergify/bp/version-16-hotfix/pr-58869
fix(stock): recalculate delivery note billing after return (backport #58869)
2026-09-08 18:08:17 +05:30
Sudharsanan Ashok
bf8a703709 Merge pull request #58862 from frappe/mergify/bp/version-16-hotfix/pr-58858
fix: persist rendered terms on transaction save (backport #58858)
2026-09-08 18:04:30 +05:30
Sudharsanan Ashok
a8c6cf4ad3 Merge pull request #58763 from frappe/mergify/bp/version-16-hotfix/pr-57330
fix: add reconciliation after submit logic for bank transactions (backport #57330)
2026-09-08 18:03:02 +05:30
Sudharsanan Ashok
6d3cf262ed Merge pull request #58760 from frappe/mergify/bp/version-16-hotfix/pr-58705
fix: prevent duplicate Batch messages and Project links (backport #58705)
2026-09-08 18:01:38 +05:30
Sudharsanan Ashok
dd60f2867e Merge pull request #58548 from aerele/lcv-taxes-accounting-dimensions-v16
fix(stock): carry accounting dimensions from Landed Cost Voucher char…
2026-09-08 17:59:20 +05:30
mergify[bot]
6ec30350d2 fix(accounts): reject same-account internal transfers (backport #58529) (#58877)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-09-08 17:43:05 +05:30
mergify[bot]
75cb796477 fix: fetch accounts by company (backport #58523) (#58879)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-09-08 17:41:09 +05:30
mergify[bot]
51bf7cb241 fix: show cost center for unmatched payments in receivables (backport #58453) (#58881)
fix: show cost center for unmatched payments in receivables (#58453)

(cherry picked from commit cee9f4949a)

Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
2026-09-08 17:33:13 +05:30
Pandiyan P
7bb19c89b1 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 16:46:23 +05:30
Lakshit Jain
fdac2843fa Merge pull request #58865 from frappe/mergify/bp/version-16-hotfix/pr-58682
fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping (backport #58682)
2026-09-08 16:36:45 +05:30
mergify[bot]
b1bf95d5b3 fix: use account currency in Bank Reconciliation Statement print (backport #58692) (#58871)
Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
2026-09-08 10:48:15 +00:00
Sudharsanan Ashok
605ec444ed Merge pull request #58860 from frappe/mergify/bp/version-16-hotfix/pr-58841
fix: skip zero quantity items in production plan material requests (backport #58841)
2026-09-08 15:40:47 +05:30
Sudharsanan11
d4b52c236a fix: resolve conflicts 2026-09-08 15:21:30 +05:30
ljain112
1998027177 chore: resolve conflicts 2026-09-08 14:57:13 +05:30
ljain112
15a531f03f fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping
(cherry picked from commit 13031d6d5d)

# Conflicts:
#	erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py
2026-09-08 09:24:14 +00:00
Pandiyan P
89872ebb37 fix: persist rendered terms on transaction save (#58858)
(cherry picked from commit cadc0ca86d)
2026-09-08 07:58:59 +00:00
mergify[bot]
6e39b42137 fix(accounts): enforce account field allow-list on financial report filters (backport #58790) (#58849)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-08 11:58:51 +05:30
Mihir Kandoi
cfdf97601a fix(manufacturing): account for process loss in Production Plan Work Orders (backport #58799) (#58838) 2026-09-08 04:20:47 +00:00
Mihir Kandoi
f6dbb3131d fix(manufacturing): apply MOQ once across Production Plan rows (v16) (#58831) 2026-09-08 04:11:27 +00:00
Mihir Kandoi
4719ad9b91 fix(manufacturing): apply safety stock once across Production Plan rows (v16) (#58832) 2026-09-08 04:11:24 +00:00
Mihir Kandoi
e59fb396e5 fix(manufacturing): share transfer stock across Production Plan rows (v16) (#58834) 2026-09-08 09:29:46 +05:30
Mihir Kandoi
a7c5ab89e8 fix(manufacturing): sum consolidated sub-assembly required quantity (v16) (#58833) 2026-09-08 09:29:34 +05:30
mergify[bot]
3cc73e4282 fix(banking): Federal bank dark logo (backport #58844) (#58845)
fix(banking): Federal bank dark logo (#58844)

(cherry picked from commit f2d72f973d)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-09-08 04:06:19 +05:30
mergify[bot]
189bd1f39d fix(bank reconciliation): match Payment Entries on the bank-side amount (backport #57740) (#58765)
* fix(bank reconciliation): match Payment Entries on the bank-side amount (#57740)

* fix(bank reconciliation): match Payment Entries on the bank-side amount

get_pe_matching_query() ranked and filtered on pe.paid_amount while the
match card displayed pe.base_paid_amount_after_tax, so the amount used for
the exact match never matched the amount shown.

Both now use the amount that actually hits the bank account, in that
account's currency: received_amount_after_tax when the bank account is
paid_to (deposit) and paid_amount_after_tax when it is paid_from
(withdrawal). This is the same convention as the Bank Reconciliation
Statement report and matches the bank GL entry that reconciliation
allocates against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(bank reconciliation): cover bank-side amount matching

Two cases the previous behaviour got wrong or could regress on:

- A deposit from an internal transfer where the paid and received sides
  differ by a charge. The match must show, and compare against, the
  amount that reached this bank account.
- A withdrawal, which still matches on the paid side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 154c6fb943)

# Conflicts:
#	erpnext/accounts/doctype/bank_reconciliation_tool/test_bank_reconciliation_tool.py

* fix: conflicts

* fix: add missing import

* chore: linting

---------

Co-authored-by: Hussain Nagaria <34810212+NagariaHussain@users.noreply.github.com>
Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-09-07 15:22:14 +00:00
mergify[bot]
0610708d78 fix(banking): UI cleanup and better statement parsing (backport #58817) (#58824)
fix(banking): UI cleanup and better statement parsing (#58817)

* fix(banking): reset scroll on searching accounts

* fix(banking): show only past dates in date filter

* fix(banking): clean up line heights and remove beta badge

* fix(banking): show accurate count of import progress
fix(banking): show latest 20 imports instead of 10

* fix(banking): layout sizing needs to be preserved on page change

* fix(banking): cleaner bank balance UI

* fix(banking): correctly parse Cr/Dr values in statement importer

* Update banking/src/components/features/BankReconciliation/BankBalance.tsx



---------


(cherry picked from commit ebe5decb96)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-09-07 20:31:45 +05:30
Mihir Kandoi
817926ca2e fix(selling): fetch orders within billing allowance (backport #58751) (#58820) 2026-09-07 19:27:15 +05:30
mergify[bot]
fb132225d7 fix: user not able to set valuation rate zero in stock reco (backport #58800) (#58823)
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)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-07 13:14:16 +00:00
Mihir Kandoi
28f0ac5287 fix: order smallest purchase UOM qty that meets min order qty (backport #57883) (#58813)
* fix: round purchase quantities up to minimum order qty

Backport #57883 to version-16-hotfix. Adapt the purchase quantity conversion to the monolithic Production Plan controller and retain the Purchase Order rounding notice.

Add coverage for the complete Production Plan to Material Request to supplier-selected Purchase Order flow.

* fix(buying): skip rounding notice for mixed UOM increments

Require one shared rounding increment across the Purchase Order rows for each item before attributing the total excess to UOM rounding.

Cover mixed UOMs in both row orders, three mixed rows, and matching UOM rows that still require the notice.
2026-09-07 12:45:39 +00:00
MochaMind
6971c80743 fix: sync translations from crowdin (version-16-hotfix) (#58580)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-07 13:09:39 +02:00
Jatin3128
8b51005525 fix(journal-entry): avoid full grid re-render per row in set_exchange_rate (backport #58328) (#58803)
refresh() loops over every row in the accounts child table and calls
set_exchange_rate() for each one. On v16 that function ended with
refresh_field("exchange_rate", cdn, "accounts"), which only takes the
cheap per-field path when the row is currently rendered. For every row
outside the visible page grid_rows_by_docname has no entry, so the
helper falls back to a full grid.refresh(): header, pagination and the
whole current page get rebuilt once per off-screen row.

Use grid.refresh_row(cdn) instead, which re-renders only the row that
actually changed and is a no-op for rows outside the current page. This
also matches what develop does after #58328.

Measured on a 1000-row Journal Entry (v16.local, Chromium): 950 of the
1000 rows triggered a full grid rebuild before, none after. Time to
first rendered row ~5.3s to ~1.7s, time to network-idle ~5.9s to ~2.3s,
and the set_exchange_rate loop itself ~4.7s to ~1.3s.

As a side effect the visible row now stays in sync: previously only the
exchange_rate cell was repainted, so the debit/credit columns that
set_debit_credit_in_company_currency had just recomputed kept showing
stale amounts.

Co-authored-by: jatin3128 <jatinsarna64@gmail.com>
2026-09-07 15:27:02 +05:30
MochaMind
189bfd5a22 chore: update POT file (#58785) 2026-09-06 15:56:20 +02:00
Shllokkk
853ac859ca Merge pull request #58778 from frappe/mergify/bp/version-16-hotfix/pr-58769
fix: restore hover tooltip on Profit and Loss dashboard chart (backport #58769)
2026-09-05 19:39:03 +05:30
Shllokkk
bb26f8f7b9 fix: restore hover tooltip on Profit and Loss dashboard chart
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 9e9c4b8102)
2026-09-05 09:05:14 +00:00
rohitwaghchaure
074f9f0828 fix: check write permission in whitelisted document methods (backport #58689) (#58701)
* 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
2026-09-05 13:54:20 +05:30
mergify[bot]
31319bd36e fix: resolve code lists by URI and version (backport #58770) (#58772)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-04 20:39:23 +02:00
mergify[bot]
8229aeaead fix(banking): find transfers on the same day (backport #58766) (#58767)
fix(banking): find transfers on the same day (#58766)

(cherry picked from commit 04b84ef069)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-09-04 20:07:51 +05:30
Vishnu Priya Baskaran
0a60d6805f 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:36:02 +00:00
Shllokkk
d48bb40969 Merge pull request #58691 from frappe/mergify/bp/version-16-hotfix/pr-58684
fix(crm): check read permission on lead in add_lead_to_prospect (backport #58684)
2026-09-04 16:45:00 +05:30
Mihir Kandoi
82392fa747 revert(manufacturing): remove material coverage changes on version 16 (#58716)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-04 14:32:47 +05:30
Pandiyan P
2dc2a04522 fix: prevent duplicate Batch messages and Project links (#58705)
(cherry picked from commit 5895ed0ee9)
2026-09-04 09:01:37 +00:00
Sagar Vora
48983c5ef0 Merge pull request #58756 from frappe/mergify/bp/version-16-hotfix/pr-58724
fix: minor improvements to financial report template validation (backport #58724)
2026-09-04 14:22:37 +05:30
Abdeali Chharchhodawala
d23b407ec7 fix: minor improvements to financial report template validation (#58724)
* fix: address review comments on financial report template validation

* refactor: minor fixes

(cherry picked from commit 1b7da82669)
2026-09-04 08:31:55 +00:00
Sudharsanan Ashok
770726d8f5 Merge pull request #58754 from frappe/mergify/bp/version-16-hotfix/pr-58740
fix: check material request price list permission (backport #58740)
2026-09-04 13:07:14 +05:30
Pandiyan P
e5b1ff667d fix: check material request price list permission (#58740)
(cherry picked from commit 0b1f1d6851)
2026-09-04 07:33:18 +00:00
Sudharsanan Ashok
d8236548be Merge pull request #58749 from frappe/mergify/bp/version-16-hotfix/pr-58736
fix(batch): show Expired status only after expiry date has passed (backport #58736)
2026-09-04 13:00:00 +05:30
mergify[bot]
f75601e9b1 fix(timesheet): handle empty allowed projects (backport #58745) (#58746)
Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-04 07:02:33 +00:00
Pandiyan P
0684599bdb 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:16 +00:00
mergify[bot]
a1c8dc878d fix(stock): allow creating stock closing balances (backport #58590) (#58685)
* fix(stock): allow creating stock closing balances (#58590)

(cherry picked from commit 2918e98a2b)

# Conflicts:
#	erpnext/stock/doctype/stock_closing_entry/test_stock_closing_entry.py

* chore: fix conflicts

Removed redundant test cases and cleaned up the test structure for StockClosingEntry.

---------

Co-authored-by: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-03 10:29:34 +00:00
mergify[bot]
dfb64d7635 fix(setup): strict permissions for transaction deletion record (backport #58687) (#58723)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-03 15:07:48 +05:30
mergify[bot]
b0ddca0455 ci: authenticate github clones in install.sh (backport #58718) (#58720)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-03 09:27:42 +00:00
Frappe PR Bot
0b50853985 chore(release): Bumped to Version 16.34.1
## [16.34.1](https://github.com/frappe/erpnext/compare/v16.34.0...v16.34.1) (2026-09-02)

### Bug Fixes

* filter cancelled BOMs in BOM Stock Analysis (backport [#58647](https://github.com/frappe/erpnext/issues/58647)) ([#58693](https://github.com/frappe/erpnext/issues/58693)) ([d6f9dde](d6f9dde3cb))
* improve message formatting and translation for validation issues ([#58425](https://github.com/frappe/erpnext/issues/58425)) ([a43de7c](a43de7ce95))
* include payment deductions in sales/purchase register ledger bal… (backport [#58437](https://github.com/frappe/erpnext/issues/58437)) ([#58680](https://github.com/frappe/erpnext/issues/58680)) ([4137401](41374019ba))
2026-09-02 12:24:04 +00:00
Sagar Vora
c19ddf187e Merge pull request #58707 from frappe/version-16-hotfix
chore: release v16
2026-09-02 17:51:22 +05:30
Sagar Vora
5f13a04633 Merge pull request #58704 from frappe/mergify/bp/version-16-hotfix/pr-58697
fix!: improve validation in financial report template (backport #58697)
2026-09-02 17:41:50 +05:30
Sagar Vora
7aad59b129 fix!: improve validation in financial report template 2026-09-02 17:31:36 +05:30
Sagar Vora
7c61dfe3e0 Merge pull request #58487 from frappe/mergify/bp/version-16-hotfix/pr-58425
fix: improve message formatting and translation for validation issues (backport #58425)
2026-09-02 17:24:03 +05:30
mergify[bot]
41374019ba fix: include payment deductions in sales/purchase register ledger bal… (backport #58437) (#58680) 2026-09-02 17:00:42 +05:30
mergify[bot]
d6f9dde3cb fix: filter cancelled BOMs in BOM Stock Analysis (backport #58647) (#58693) 2026-09-02 15:28:17 +05:30
Shllokkk
2cc1a51d9a 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:00:56 +05:30
Frappe PR Bot
80c2c9e0e0 chore(release): Bumped to Version 16.34.0
# [16.34.0](https://github.com/frappe/erpnext/compare/v16.33.0...v16.34.0) (2026-09-02)

### Bug Fixes

* **accounts:** add permission checks on payment entry whitelisted methods ([#58644](https://github.com/frappe/erpnext/issues/58644)) ([c745558](c745558168))
* **accounts:** added permission checks on `get_available_payment_schedules` (backport [#58588](https://github.com/frappe/erpnext/issues/58588)) ([#58595](https://github.com/frappe/erpnext/issues/58595)) ([765a773](765a7736ed))
* **accounts:** guard get_gl_dict for v16 and drop unused base_gl_composer ([abf311e](abf311e8ca))
* **accounts:** prevent child table doctypes as accounting dimensions ([6dbaa50](6dbaa5048e))
* **accounts:** resolve subscription plans for any reference doctype in Payment Request (backport [#58438](https://github.com/frappe/erpnext/issues/58438)) ([#58450](https://github.com/frappe/erpnext/issues/58450)) ([3c63fff](3c63fffcb6))
* **accounts:** set pos profile on invoices respecting user permissions (backport [#58508](https://github.com/frappe/erpnext/issues/58508)) ([#58519](https://github.com/frappe/erpnext/issues/58519)) ([23290a6](23290a60da))
* add missing perm checks on various whitelisted methods (backport [#58652](https://github.com/frappe/erpnext/issues/58652)) ([#58654](https://github.com/frappe/erpnext/issues/58654)) ([86e9480](86e9480362))
* add type hints to make_request_for_quotation ([4435f16](4435f166ea))
* clarify duplicate internal party messages ([#58469](https://github.com/frappe/erpnext/issues/58469)) ([f0e603a](f0e603aec5))
* **company:** throw if linked to demo_company field ([9f3dd7e](9f3dd7eabd))
* correct is_frozen description on customer and supplier ([#58168](https://github.com/frappe/erpnext/issues/58168)) ([9e00888](9e00888d67))
* **crm:** add missing permission validation on `get_contract_template` (backport [#58621](https://github.com/frappe/erpnext/issues/58621)) ([#58623](https://github.com/frappe/erpnext/issues/58623)) ([30f0cd9](30f0cd9c95))
* **crm:** check write permission in edit_note ([c2261f6](c2261f6129))
* **crm:** validate contact email before saving an email campaign (backport [#58667](https://github.com/frappe/erpnext/issues/58667)) ([#58672](https://github.com/frappe/erpnext/issues/58672)) ([f22f84a](f22f84a58a))
* do not map the same row twice in "Get Items From" (backport [#58617](https://github.com/frappe/erpnext/issues/58617)) ([#58619](https://github.com/frappe/erpnext/issues/58619)) ([f3379b4](f3379b4694))
* explain missing fields when loading party details or taxes (backport [#58627](https://github.com/frappe/erpnext/issues/58627)) ([#58628](https://github.com/frappe/erpnext/issues/58628)) ([8a8bafd](8a8bafd6cb))
* filter fully ordered items when creating RFQ from Material Request ([9b1d907](9b1d907b6a))
* handle BOM price list currency update (backport [#58638](https://github.com/frappe/erpnext/issues/58638)) ([#58639](https://github.com/frappe/erpnext/issues/58639)) ([fb74a03](fb74a030de))
* **manufacturing:** account for pending job card qty ([#58466](https://github.com/frappe/erpnext/issues/58466)) ([7b9cdb4](7b9cdb4926))
* **manufacturing:** classify MRP items without a BOM as Purchase (backport [#58509](https://github.com/frappe/erpnext/issues/58509)) ([#58513](https://github.com/frappe/erpnext/issues/58513)) ([ee3b224](ee3b22403d))
* **manufacturing:** handle duplicate root BOM items (backport [#58614](https://github.com/frappe/erpnext/issues/58614)) ([#58616](https://github.com/frappe/erpnext/issues/58616)) ([22755c4](22755c4ad6))
* **manufacturing:** preserve job card qty in mr transfer (backport [#58482](https://github.com/frappe/erpnext/issues/58482)) ([#58499](https://github.com/frappe/erpnext/issues/58499)) ([76d0cb3](76d0cb382d))
* **manufacturing:** use item warehouses in production plan work orders ([#58663](https://github.com/frappe/erpnext/issues/58663)) ([ec392a7](ec392a7bcf))
* **manufacturing:** use packed row delivery date ([#58570](https://github.com/frappe/erpnext/issues/58570)) ([e64fddf](e64fddfad5))
* persist redistributed additional costs during stock entry repost (backport [#58433](https://github.com/frappe/erpnext/issues/58433)) ([#58533](https://github.com/frappe/erpnext/issues/58533)) ([629bc20](629bc20e90))
* **pos:** add permission checks on `get_invoices` (backport [#58591](https://github.com/frappe/erpnext/issues/58591)) ([#58597](https://github.com/frappe/erpnext/issues/58597)) ([d95eeb0](d95eeb0f6a))
* **projects:** check read permission on source in create_duplicate_project ([bd35957](bd35957582))
* **projects:** ignore cancelled invoices in timesheet portal (backport [#58501](https://github.com/frappe/erpnext/issues/58501)) ([#58504](https://github.com/frappe/erpnext/issues/58504)) ([d732d06](d732d06e25))
* round party balances to currency precision in Trial Balance for Party ([956a6ed](956a6ed407))
* **selling:** check quotation write permission before marking lost ([#58493](https://github.com/frappe/erpnext/issues/58493)) ([4e74831](4e74831a08))
* **selling:** check sales order permission before work order creation (backport [#58492](https://github.com/frappe/erpnext/issues/58492)) ([#58497](https://github.com/frappe/erpnext/issues/58497)) ([2b8e12e](2b8e12ef44))
* **sms_settings:** add patch to pre-fill roles into SMS Settings Roles Table ([4b89ddf](4b89ddffde))
* **stock:** auto-select batch no before saving transaction records (backport [#58536](https://github.com/frappe/erpnext/issues/58536)) ([#58539](https://github.com/frappe/erpnext/issues/58539)) ([eb4c327](eb4c327a2b))
* **stock:** ignore cancelled batch entries in valuation (backport [#58659](https://github.com/frappe/erpnext/issues/58659)) ([#58660](https://github.com/frappe/erpnext/issues/58660)) ([f4ed4ca](f4ed4cae64))
* **stock:** keep pick list links when refetching stock entry items ([01e0844](01e0844d40))
* **stock:** load available serial no report (backport [#58558](https://github.com/frappe/erpnext/issues/58558)) ([#58562](https://github.com/frappe/erpnext/issues/58562)) ([06758f2](06758f2792))
* **stock:** preserve exchange gain loss journals in lcv ([#58505](https://github.com/frappe/erpnext/issues/58505)) ([1242316](124231681a))
* **stock:** validate serial inventory dimensions ([#58394](https://github.com/frappe/erpnext/issues/58394)) ([a1aabaf](a1aabaff18))
* sum item and its alternate transferred qty on work order required items ([1411835](1411835507))
* sync translations from crowdin (version-16-hotfix) ([#58455](https://github.com/frappe/erpnext/issues/58455)) ([c924de3](c924de3c55))
* **taxes:** skip tax addition for invoice created from opening invoice tool ([bfbc13d](bfbc13d147))
* **timesheet:** scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport [#58267](https://github.com/frappe/erpnext/issues/58267)) ([#58577](https://github.com/frappe/erpnext/issues/58577)) ([4f409e5](4f409e5ccf))
* translate doctype in map msg (backport [#58515](https://github.com/frappe/erpnext/issues/58515)) ([#58517](https://github.com/frappe/erpnext/issues/58517)) ([f0d2d40](f0d2d40d35))
* translate label in party validation (backport [#58624](https://github.com/frappe/erpnext/issues/58624)) ([#58626](https://github.com/frappe/erpnext/issues/58626)) ([9bb8d4f](9bb8d4ff93))
* validate items against source Sales Order in Material Request ([6546646](65466464df))
* validate POS Settings invoice and search fields on the server (backport [#58611](https://github.com/frappe/erpnext/issues/58611)) ([#58620](https://github.com/frappe/erpnext/issues/58620)) ([f8c2927](f8c29274a5))
* Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows ([#58526](https://github.com/frappe/erpnext/issues/58526)) ([cb4ec72](cb4ec72370))

### Features

* **analytics:** filter sales and purchase analytics by entity ([#58402](https://github.com/frappe/erpnext/issues/58402)) ([655ed81](655ed81575))
* **manufacturing:** add multi-currency support to Blanket Orders (backport [#58472](https://github.com/frappe/erpnext/issues/58472)) ([#58630](https://github.com/frappe/erpnext/issues/58630)) ([ff656b9](ff656b9ad2))
* valuation type for BOM secondary items (backport [#58431](https://github.com/frappe/erpnext/issues/58431)) ([#58632](https://github.com/frappe/erpnext/issues/58632)) ([1b44df4](1b44df480f))
2026-09-02 07:13:39 +00:00
Diptanil Saha
600cbf36af Merge pull request #58645 from frappe/version-16-hotfix
chore: release v16
2026-09-02 12:42:03 +05:30
mergify[bot]
3c63fffcb6 fix(accounts): resolve subscription plans for any reference doctype in Payment Request (backport #58438) (#58450)
Co-authored-by: jatin3128 <jatinsarna64@gmail.com>
2026-09-02 12:18:17 +05:30
Sudharsanan Ashok
d3b8324819 Merge pull request #58665 from frappe/mergify/bp/version-16-hotfix/pr-58663
fix(manufacturing): use item warehouses in production plan work orders (backport #58663)
2026-09-02 12:05:04 +05:30
Sudharsanan Ashok
46db04ecf8 Merge pull request #58675 from frappe/mergify/bp/version-16-hotfix/pr-58505
fix(stock): preserve exchange gain loss journals in lcv (backport #58505)
2026-09-02 12:03:51 +05:30
Shllokkk
75356265be Merge pull request #58535 from Shllokkk/mr-rfq-filter-pending-qty-v16
fix: filter fully ordered items when creating RFQ from Material Request
2026-09-02 11:46:07 +05:30
Pandiyan P
124231681a fix(stock): preserve exchange gain loss journals in lcv (#58505)
(cherry picked from commit 2b54a581f8)
2026-09-02 06:15:05 +00:00
mergify[bot]
f22f84a58a fix(crm): validate contact email before saving an email campaign (backport #58667) (#58672)
Co-authored-by: kaulith <64089478+kaulith@users.noreply.github.com>
2026-09-02 11:42:32 +05:30
Shllokkk
5af60cf2e3 test: set schedule date on PO before insert 2026-09-02 11:33:03 +05:30
Shllokkk
4435f166ea fix: add type hints to make_request_for_quotation 2026-09-02 11:33:03 +05:30
Shllokkk
5adcb28b3c test: RFQ from Material Request skips fully ordered items 2026-09-02 11:33:02 +05:30
Shllokkk
9b1d907b6a fix: filter fully ordered items when creating RFQ from Material Request 2026-09-02 11:33:02 +05:30
pandiyan
ec392a7bcf fix(manufacturing): use item warehouses in production plan work orders (#58663)
(cherry picked from commit e74ab38eeb)

# Conflicts:
#	erpnext/manufacturing/doctype/production_plan/services/work_order_planning.py
2026-09-02 10:36:32 +05:30
mergify[bot]
f4ed4cae64 fix(stock): ignore cancelled batch entries in valuation (backport #58659) (#58660)
fix(stock): ignore cancelled batch entries in valuation (#58659)

(cherry picked from commit a9b857bdfe)

# Conflicts:
#	erpnext/stock/serial_batch_bundle.py

Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-09-01 16:08:38 +00:00
mergify[bot]
2b8e12ef44 fix(selling): check sales order permission before work order creation (backport #58492) (#58497)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-09-01 21:30:33 +05:30
mergify[bot]
86e9480362 fix: add missing perm checks on various whitelisted methods (backport #58652) (#58654)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-01 14:01:40 +00:00
mergify[bot]
4f409e5ccf fix(timesheet): scoping whitelisted methods output to projects and timesheets that are acccessible to users (backport #58267) (#58577)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-01 12:09:00 +00:00
Shllokkk
01fecfe093 Merge pull request #58649 from frappe/mergify/bp/version-16-hotfix/pr-58607
fix: round party balances to currency precision in Trial Balance for Party (backport #58607)
2026-09-01 16:42:57 +05:30
Shllokkk
956a6ed407 fix: round party balances to currency precision in Trial Balance for Party
(cherry picked from commit b1c7657dfa)
2026-09-01 09:49:43 +00:00
Diptanil Saha
c745558168 fix(accounts): add permission checks on payment entry whitelisted methods (#58644) 2026-09-01 09:24:40 +00:00
mergify[bot]
fb74a030de fix: handle BOM price list currency update (backport #58638) (#58639)
fix: handle BOM price list currency update (#58638)

(cherry picked from commit db52e465d0)

Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-09-01 13:40:51 +05:30
Aarol D'Souza
de28957008 Merge pull request #58637 from frappe/mergify/bp/version-16-hotfix/pr-58586
fix(sms_settings): add patch to pre-fill roles into SMS Settings Role Table (backport #58586)
2026-09-01 13:05:22 +05:30
AarDG10
9ee421424f chore: resolve conflicts 2026-09-01 12:51:04 +05:30
AarDG10
4b89ddffde 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:30 +00:00
Mihir Kandoi
1b44df480f feat: valuation type for BOM secondary items (backport #58431) (#58632) 2026-09-01 11:45:52 +05:30
Mihir Kandoi
ff656b9ad2 feat(manufacturing): add multi-currency support to Blanket Orders (backport #58472) (#58630) 2026-09-01 11:39:26 +05:30
mergify[bot]
22755c4ad6 fix(manufacturing): handle duplicate root BOM items (backport #58614) (#58616)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-01 02:54:09 +00:00
mergify[bot]
8a8bafd6cb fix: explain missing fields when loading party details or taxes (backport #58627) (#58628)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-01 01:53:59 +02:00
mergify[bot]
9bb8d4ff93 fix: translate label in party validation (backport #58624) (#58626)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-01 00:54:12 +02:00
mergify[bot]
30f0cd9c95 fix(crm): add missing permission validation on get_contract_template (backport #58621) (#58623)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 19:40:50 +00:00
mergify[bot]
f8c29274a5 fix: validate POS Settings invoice and search fields on the server (backport #58611) (#58620)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-01 00:08:37 +05:30
mergify[bot]
f3379b4694 fix: do not map the same row twice in "Get Items From" (backport #58617) (#58619)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-31 19:40:59 +02:00
Lakshit Jain
2390d48c01 Merge pull request #58602 from frappe/mergify/bp/version-16-hotfix/pr-58483
fix(taxes): skip tax addition for invoice created from opening invoice tool (backport #58483)
2026-08-31 18:38:56 +05:30
mergify[bot]
765a7736ed fix(accounts): added permission checks on get_available_payment_schedules (backport #58588) (#58595)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 17:14:27 +05:30
mergify[bot]
d95eeb0f6a fix(pos): add permission checks on get_invoices (backport #58591) (#58597)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-31 17:14:16 +05:30
ljain112
32393caa39 chore: resolve conflicts 2026-08-31 16:54:30 +05:30
mergify[bot]
c7582e986d fix/payment-request-subscription-plans-population (backport #57494) (#58430)
* fix(payment-request): populate subscription plans (#57494)

* fix(payment-request): populate subscription plans

* test: add coverage for subscription plans in payment request

---------

Co-authored-by: Dharanidharan2813 <dharanidharans1328@gmail.com>
(cherry picked from commit 1b81db4754)

# Conflicts:
#	erpnext/accounts/doctype/payment_request/payment_request.py
#	erpnext/accounts/doctype/payment_request/test_payment_request.py

* fix(payment-request): resolve cherry-pick conflicts for version-16-hotfix backport

---------

Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
Co-authored-by: ervishnucs <ervishnucs369@gmail.com>
Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-08-31 16:28:20 +05:30
ljain112
bfbc13d147 fix(taxes): skip tax addition for invoice created from opening invoice tool
(cherry picked from commit e08a166281)

# Conflicts:
#	erpnext/accounts/services/taxes.py
2026-08-31 09:35:42 +00:00
Nishka Gosalia
40fe739c9e Merge pull request #58592 from frappe/mergify/bp/version-16-hotfix/pr-58325
fix(projects): check read permission on source in create_duplicate_project (backport #58325)
2026-08-31 14:17:07 +05:30
Shllokkk
bd35957582 fix(projects): check read permission on source in create_duplicate_project
(cherry picked from commit 8c8b282a2e)
2026-08-31 07:10:45 +00:00
ervishnucs
6e22947c2c test(stock): cover accounting dimensions on landed cost vouchers 2026-08-30 22:59:38 +05:30
ervishnucs
0c7be311b8 fix(stock): carry accounting dimensions from landed cost voucher charges into gl entries 2026-08-30 22:59:36 +05:30
Mihir Kandoi
e64fddfad5 fix(manufacturing): use packed row delivery date (#58570) 2026-08-30 11:06:06 +00:00
MochaMind
ac61e6ac0d chore: update POT file (#58567) 2026-08-30 13:01:08 +02:00
Henil Maru
cb4ec72370 fix: Work Order picks wrong Delivery Date when Sales Order has the same item in multiple rows (#58526) 2026-08-30 15:56:28 +05:30
mergify[bot]
06758f2792 fix(stock): load available serial no report (backport #58558) (#58562)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-29 14:56:27 +00:00
mergify[bot]
01a398f263 refactor(stock): remove dead warehouse_condition branch on get_stock_ledger_entries (backport #58552) (#58554)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-29 17:55:34 +05:30
Shllokkk
446b5375e4 Merge pull request #58544 from frappe/mergify/bp/version-16-hotfix/pr-58324
fix(crm): check write permission in edit_note (backport #58324)
2026-08-29 13:27:56 +05:30
Shllokkk
c6082617ec chore: resolving conflicts 2026-08-29 13:03:59 +05:30
Shllokkk
c2261f6129 fix(crm): check write permission in edit_note
(cherry picked from commit eb49f51d29)

# Conflicts:
#	erpnext/crm/utils.py
2026-08-28 15:05:24 +00:00
mergify[bot]
eb4c327a2b fix(stock): auto-select batch no before saving transaction records (backport #58536) (#58539)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-28 12:06:38 +00:00
mergify[bot]
629bc20e90 fix: persist redistributed additional costs during stock entry repost (backport #58433) (#58533)
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)

Co-authored-by: Afsal Syed <146159709+Afsalsyed@users.noreply.github.com>
2026-08-28 10:34:20 +00:00
Sudharsanan Ashok
cc73c3b879 Merge pull request #58495 from frappe/mergify/bp/version-16-hotfix/pr-58493
fix(selling): check quotation write permission before marking lost (backport #58493)
2026-08-28 13:11:58 +05:30
pandiyan
4e74831a08 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:01:15 +05:30
mergify[bot]
23290a60da fix(accounts): set pos profile on invoices respecting user permissions (backport #58508) (#58519)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-28 06:48:18 +00:00
mergify[bot]
ee3b22403d fix(manufacturing): classify MRP items without a BOM as Purchase (backport #58509) (#58513)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-27 17:37:09 +00:00
mergify[bot]
f0d2d40d35 fix: translate doctype in map msg (backport #58515) (#58517)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-27 19:25:51 +02:00
Sudharsanan Ashok
60252cacdd Merge pull request #58521 from frappe/revert-58489-mergify/bp/version-16-hotfix/pr-58486
Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)"
2026-08-27 22:46:17 +05:30
Sudharsanan Ashok
8287906a71 Revert "refactor(stock): use db.count for the empty ledger check (backport #58486)" 2026-08-27 22:34:55 +05:30
mergify[bot]
d732d06e25 fix(projects): ignore cancelled invoices in timesheet portal (backport #58501) (#58504)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-27 13:04:09 +00:00
Shllokkk
4538689e5f Merge pull request #58502 from Shllokkk/work-order-transferred-qty-sum-alternate-items-v16
fix: sum item and its alternate transferred qty on work order
2026-08-27 18:27:56 +05:30
Shllokkk
7464f6c76e test: transferred qty sums item and its alternate 2026-08-27 18:07:57 +05:30
Shllokkk
1411835507 fix: sum item and its alternate transferred qty on work order required items 2026-08-27 18:07:57 +05:30
Mihir Kandoi
76d0cb382d fix(manufacturing): preserve job card qty in mr transfer (backport #58482) (#58499) 2026-08-27 12:19:33 +00:00
Sudharsanan Ashok
eedf991b43 Merge pull request #58489 from frappe/mergify/bp/version-16-hotfix/pr-58486
refactor(stock): use db.count for the empty ledger check (backport #58486)
2026-08-27 16:50:09 +05:30
Sudharsanan Ashok
38aaceafc6 refactor(stock): use db.count for the empty ledger check (#58486)
Align the existence check in `reset_bin_without_stock_ledger_entries()` with
the version-15-hotfix backport in #58434, per review feedback there.

(cherry picked from commit 2866be2816)
2026-08-27 11:07:21 +00:00
Abdeali Chharchhodawala
a43de7ce95 fix: improve message formatting and translation for validation issues (#58425)
(cherry picked from commit 6842ebb186)
2026-08-27 10:57:38 +00:00
Sudharsanan Ashok
f7f34ffd3b Merge pull request #58467 from frappe/mergify/bp/version-16-hotfix/pr-58466
fix(manufacturing): account for pending job card qty (backport #58466)
2026-08-27 16:13:54 +05:30
Sudharsanan Ashok
f19450858c Merge pull request #58475 from frappe/mergify/bp/version-16-hotfix/pr-58469
fix: clarify duplicate internal party messages (backport #58469)
2026-08-27 16:13:28 +05:30
Sudharsanan Ashok
8dc3bc3485 Merge pull request #58429 from frappe/mergify/bp/version-16-hotfix/pr-58402
feat(analytics): filter sales and purchase analytics by entity  (backport #58402)
2026-08-27 16:12:20 +05:30
pandiyan
7b9cdb4926 fix(manufacturing): account for pending job card qty (#58466)
(cherry picked from commit 0d90608bc1)

# Conflicts:
#	erpnext/manufacturing/doctype/job_card/job_card.py
#	erpnext/manufacturing/doctype/job_card/test_job_card.py
2026-08-27 14:03:00 +05:30
pandiyan
f0e603aec5 fix: clarify duplicate internal party messages (#58469)
(cherry picked from commit 4d4cf034b5)

# Conflicts:
#	erpnext/selling/doctype/customer/customer.py
2026-08-27 13:57:12 +05:30
Shllokkk
bfb27039ed Merge pull request #58470 from Shllokkk/material-request-validate-with-so-v16
fix: validate items against source Sales Order in Material Request
2026-08-27 13:06:38 +05:30
Shllokkk
07f8250e40 test: block item change on Sales Order-sourced Material Request row 2026-08-27 12:48:45 +05:30
Shllokkk
65466464df fix: validate items against source Sales Order in Material Request 2026-08-27 12:48:45 +05:30
Diptanil Saha
60599ec692 ci: crowdin actions to upload main.pot file (#58461) 2026-08-26 18:31:04 +00:00
Aarol D'Souza
6093316ba3 Merge pull request #58427 from frappe/mergify/bp/version-16-hotfix/pr-58351
fix(company): throw if linked to demo_company field (backport #58351)
2026-08-26 23:14:32 +05:30
mergify[bot]
cabecd2527 ci: ignore python tests on update of **.po (backport #58457) (#58459)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-26 20:43:42 +05:30
MochaMind
c924de3c55 fix: sync translations from crowdin (version-16-hotfix) (#58455)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-08-26 18:31:46 +05:30
Sudharsanan Ashok
655ed81575 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/test_purchase_analytics.py
#	erpnext/selling/report/sales_analytics/test_sales_analytics.py
2026-08-26 07:33:42 +00:00
AarDG10
9f3dd7eabd 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:12 +00:00
Sudharsanan Ashok
2cd865fffc Merge pull request #58405 from aerele/fix/pick-list-transfer-qty-lost-v16
fix(stock): keep pick list links when refetching stock entry items (backport #58374)
2026-08-26 12:45:46 +05:30
Sudharsanan Ashok
e32a8f008e Merge pull request #58422 from frappe/mergify/bp/version-16-hotfix/pr-58168
fix: correct is_frozen description on customer and supplier (backport #58168)
2026-08-26 12:39:35 +05:30
Pandiyan P
9e00888d67 fix: correct is_frozen description on customer and supplier (#58168)
(cherry picked from commit cc60c80e68)

# Conflicts:
#	erpnext/buying/doctype/supplier/supplier.json
#	erpnext/selling/doctype/customer/customer.json
2026-08-26 12:08:14 +05:30
Sudharsanan Ashok
5b92dff1df Merge pull request #58419 from frappe/mergify/bp/version-16-hotfix/pr-58394
fix(stock): validate serial inventory dimensions (backport #58394)
2026-08-26 11:51:09 +05:30
Pandiyan P
a1aabaff18 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/stock_ledger_entry/stock_ledger_entry.py
2026-08-26 11:37:57 +05:30
Khushi Rawat
1a3e02ee5b Merge pull request #58414 from frappe/mergify/bp/version-16-hotfix/pr-58412
fix(accounts): prevent child table doctypes as accounting dimensions (backport #58412)
2026-08-26 11:35:41 +05:30
khushi8112
abf311e8ca fix(accounts): guard get_gl_dict for v16 and drop unused base_gl_composer 2026-08-26 11:05:19 +05:30
khushi8112
fe98974cb6 test(accounts): cover single doctype and non-scalar dimension safeguards
(cherry picked from commit 55dd11f977)
2026-08-25 19:18:25 +00:00
khushi8112
6dbaa5048e fix(accounts): prevent child table doctypes as accounting dimensions
(cherry picked from commit 7b32d07d1c)

# Conflicts:
#	erpnext/accounts/services/base_gl_composer.py
2026-08-25 19:18:24 +00:00
Frappe PR Bot
b24c9eba55 chore(release): Bumped to Version 16.33.0
# [16.33.0](https://github.com/frappe/erpnext/compare/v16.32.3...v16.33.0) (2026-08-25)

### Bug Fixes

* **accounts:** allocate drop-ship cost by invoice quantity (backport [#58226](https://github.com/frappe/erpnext/issues/58226)) ([#58263](https://github.com/frappe/erpnext/issues/58263)) ([a62a949](a62a949b2a))
* **accounts:** disallow reversing a reverse journal entry ([8a26834](8a26834704))
* **accounts:** match returns to source invoice items (backport [#58250](https://github.com/frappe/erpnext/issues/58250)) ([#58264](https://github.com/frappe/erpnext/issues/58264)) ([9b0db26](9b0db26c25))
* **accounts:** supplier group filter not applied on accounts payable report ([d23b919](d23b9197d5))
* aggregate child warehouses in Stock Qty vs Serial No Count report (backport [#58134](https://github.com/frappe/erpnext/issues/58134)) ([#58366](https://github.com/frappe/erpnext/issues/58366)) ([abe4c0a](abe4c0a6b7))
* apply Sales Person user permissions in Accounts Receivable ([956be58](956be58c9c))
* **assets:** split FIFO/LIFO rate across grouped stock item rows ([98bef1c](98bef1cac8))
* **bank_statement_import:** add missing permission check on `get_import_status` (backport [#58217](https://github.com/frappe/erpnext/issues/58217)) ([#58219](https://github.com/frappe/erpnext/issues/58219)) ([2b685ed](2b685ed982))
* **bank_statement_import:** add missing permission check on multiple whitelisted methods (backport [#58221](https://github.com/frappe/erpnext/issues/58221)) ([#58224](https://github.com/frappe/erpnext/issues/58224)) ([81461ea](81461ea56c))
* block disabled/frozen party on Opportunity and Request for Quotation (backport [#57983](https://github.com/frappe/erpnext/issues/57983)) ([#58035](https://github.com/frappe/erpnext/issues/58035)) ([9f99ba6](9f99ba6ddc))
* **buying:** allow purchase returns against a closed purchase order ([#58139](https://github.com/frappe/erpnext/issues/58139)) ([61b5499](61b549960e))
* conflicts ([cbe7931](cbe7931b3b))
* correct Item Group doctype name in item tax template dashboard  (backport [#58192](https://github.com/frappe/erpnext/issues/58192)) ([#58213](https://github.com/frappe/erpnext/issues/58213)) ([56ac872](56ac87292f))
* correct to and from date filters in timesheet billing summary re… (backport [#58317](https://github.com/frappe/erpnext/issues/58317)) ([#58318](https://github.com/frappe/erpnext/issues/58318)) ([2077e84](2077e844d4))
* **crm_settings:** create custom fields for Frappe CRM on enabling synchronization ([795cf85](795cf8544c))
* describe stale exchange rate settings ([1ec2a2c](1ec2a2cd5d))
* don't set work order status to In Process only due to skip material transfer ([#58245](https://github.com/frappe/erpnext/issues/58245)) ([dd4d108](dd4d10862d))
* drop removed Restaurant doctype from sales tax template dashboard  (backport [#58191](https://github.com/frappe/erpnext/issues/58191)) ([#58211](https://github.com/frappe/erpnext/issues/58211)) ([c3cf7f2](c3cf7f2e91))
* **email_digest:** added permission check for `get_msg_html` (backport [#58197](https://github.com/frappe/erpnext/issues/58197)) ([#58200](https://github.com/frappe/erpnext/issues/58200)) ([32ebd69](32ebd69abc))
* escape interpolated values in portal, print and desk templates (backport [#58273](https://github.com/frappe/erpnext/issues/58273)) ([#58278](https://github.com/frappe/erpnext/issues/58278)) ([6b7b479](6b7b4796b4))
* escape interpolated values in text positions across portal and desk templates (backport [#58286](https://github.com/frappe/erpnext/issues/58286)) ([#58288](https://github.com/frappe/erpnext/issues/58288)) ([d357310](d3573105c4))
* escape on status image for workstations in production status (backport [#58279](https://github.com/frappe/erpnext/issues/58279)) ([#58281](https://github.com/frappe/erpnext/issues/58281)) ([9e2173e](9e2173e4c2))
* filter available batch report by company (backport [#57995](https://github.com/frappe/erpnext/issues/57995)) ([#58077](https://github.com/frappe/erpnext/issues/58077)) ([5933476](5933476778))
* get items from sales order in sales invoice ([#58163](https://github.com/frappe/erpnext/issues/58163)) ([0255314](0255314ea7))
* hide rfq status in supplier portal ([#58368](https://github.com/frappe/erpnext/issues/58368)) ([8a2109e](8a2109e62a))
* hide supplier name in rfq portal (backport [#58373](https://github.com/frappe/erpnext/issues/58373)) ([#58376](https://github.com/frappe/erpnext/issues/58376)) ([2329ef6](2329ef6424))
* ignore historical negative batch stock in outward validation (backport [#58148](https://github.com/frappe/erpnext/issues/58148)) ([#58151](https://github.com/frappe/erpnext/issues/58151)) ([c18881b](c18881b37d))
* Ignore permission while deleting user permission (backport [#57296](https://github.com/frappe/erpnext/issues/57296)) ([#57760](https://github.com/frappe/erpnext/issues/57760)) ([98d69b2](98d69b2ae3))
* include time logs ending at midnight in timesheet billing summary (backport [#58355](https://github.com/frappe/erpnext/issues/58355)) ([#58357](https://github.com/frappe/erpnext/issues/58357)) ([0bf34c0](0bf34c054a))
* **italy:** handle none price_list_rate in e-invoice xml generation (backport [#58242](https://github.com/frappe/erpnext/issues/58242)) ([#58370](https://github.com/frappe/erpnext/issues/58370)) ([feb51a4](feb51a475a))
* keep Currency and Price List section open for foreign currency (backport [#58074](https://github.com/frappe/erpnext/issues/58074)) ([#58113](https://github.com/frappe/erpnext/issues/58113)) ([a3a6efb](a3a6efbcea))
* **manufacturing:** derive material transfers from actual coverage ([#58115](https://github.com/frappe/erpnext/issues/58115)) ([2a9e430](2a9e4304a2))
* **manufacturing:** fall back to item group defaults for work order w… ([#58237](https://github.com/frappe/erpnext/issues/58237)) ([861fb26](861fb26b2c))
* **manufacturing:** keep MRP chart dates distinct ([1b37fd2](1b37fd2edc))
* **Material Requirements Planning Report:** detailed-view chart timescale ([de06cb7](de06cb7f45)), closes [#52632](https://github.com/frappe/erpnext/issues/52632)
* new docs should refetch incoming rates (backport [#58097](https://github.com/frappe/erpnext/issues/58097)) ([#58295](https://github.com/frappe/erpnext/issues/58295)) ([edb9b33](edb9b33264))
* patch to delete the `crm_deal` custom fields ([d66dc14](d66dc143e3))
* prevent duplicate supplier quotations from portal ([39e15c7](39e15c7b2d))
* prevent TimestampMismatchError resolving Dunning with multiple overdue installments ([5764d1d](5764d1d173))
* **projects:** respect permissions in timesheet billing summary (backport [#58320](https://github.com/frappe/erpnext/issues/58320)) ([#58322](https://github.com/frappe/erpnext/issues/58322)) ([647c933](647c93321b))
* Qty and UOM not fetched when adding Item in Material Request ([#58118](https://github.com/frappe/erpnext/issues/58118)) ([aecc551](aecc551b5e))
* renaming the overdue billing checkbox (backport [#58165](https://github.com/frappe/erpnext/issues/58165)) ([#58166](https://github.com/frappe/erpnext/issues/58166)) ([1ea1d5d](1ea1d5d6a5))
* render missing terms before printing ([#58358](https://github.com/frappe/erpnext/issues/58358)) ([f56cdac](f56cdac45f))
* resolve backport conflicts ([3fd3f94](3fd3f9485b))
* respect zero currency precision ([#58395](https://github.com/frappe/erpnext/issues/58395)) ([6b61146](6b61146d2f))
* rewriting the description of the overdue billing checkbox (backport [#58172](https://github.com/frappe/erpnext/issues/58172)) ([#58174](https://github.com/frappe/erpnext/issues/58174)) ([4bb7799](4bb7799919))
* run set_missing_values before creating Purchase Order from MRP report ([72114fa](72114fa738))
* **selling:** read overdue amount from payment ledger, not gl tags  (backport [#57786](https://github.com/frappe/erpnext/issues/57786)) ([#58026](https://github.com/frappe/erpnext/issues/58026)) ([29f6a5b](29f6a5b9cf))
* send auto reorder email to all managers in single company setup ([ac1c692](ac1c6921da))
* skip covered rows when ordering from the mrp report ([#58249](https://github.com/frappe/erpnext/issues/58249)) ([#58312](https://github.com/frappe/erpnext/issues/58312)) ([dfc2301](dfc2301f08))
* **stock:** confirm before changing item qty from the batch selector (backport [#58123](https://github.com/frappe/erpnext/issues/58123)) ([#58125](https://github.com/frappe/erpnext/issues/58125)) ([4f3ffd3](4f3ffd3820))
* **stock:** fetch item stock UOM in stock reconciliation (backport [#58284](https://github.com/frappe/erpnext/issues/58284)) ([#58291](https://github.com/frappe/erpnext/issues/58291)) ([1f21c0b](1f21c0b0af))
* **stock:** honour pick serial / batch based on in the batch selector (backport [#58176](https://github.com/frappe/erpnext/issues/58176)) ([#58182](https://github.com/frappe/erpnext/issues/58182)) ([d3a4d47](d3a4d476eb))
* **stock:** preserve item UOM conversion factor (backport [#58299](https://github.com/frappe/erpnext/issues/58299)) ([#58304](https://github.com/frappe/erpnext/issues/58304)) ([53b61e5](53b61e5ac6))
* **stock:** reset bin when no stock ledger entries remain ([#58362](https://github.com/frappe/erpnext/issues/58362)) ([37a1fd1](37a1fd11e9))
* stop doubling totals in timesheet billing summary (backport [#58311](https://github.com/frappe/erpnext/issues/58311)) ([#58315](https://github.com/frappe/erpnext/issues/58315)) ([c3313f5](c3313f5b34))
* use user data fields hook (backport [#58274](https://github.com/frappe/erpnext/issues/58274)) ([#58283](https://github.com/frappe/erpnext/issues/58283)) ([94b6622](94b6622d9d))
* validation for task end date check ([7b0df4b](7b0df4b28b))
* work order finish dialog with process loss qty from job card (backport [#58256](https://github.com/frappe/erpnext/issues/58256)) ([#58262](https://github.com/frappe/erpnext/issues/58262)) ([27a04d8](27a04d8e08))

### Features

* **accounts:** opt-in 'Consider Accounting Dimension' filter on General Ledger Report (backport [#58156](https://github.com/frappe/erpnext/issues/58156)) ([#58158](https://github.com/frappe/erpnext/issues/58158)) ([f8c3270](f8c3270049))
* add status filter to Supplier Quotation Comparison report ([2b84ed7](2b84ed78e8))
* Belgian Charts of Accounts (commercial + non-profit, FR + NL) (backport [#54679](https://github.com/frappe/erpnext/issues/54679)) ([#58186](https://github.com/frappe/erpnext/issues/58186)) ([d805f4d](d805f4d3fc))
* taxable-base resolver hook for custom charge types ([#56175](https://github.com/frappe/erpnext/issues/56175)) ([ea238a9](ea238a90ac))
* validate purchase receipt exchange rate parity on purchase invoice (backport [#58177](https://github.com/frappe/erpnext/issues/58177)) ([#58190](https://github.com/frappe/erpnext/issues/58190)) ([900064e](900064e395))
2026-08-25 17:04:28 +00:00
Diptanil Saha
1a745cd433 Merge pull request #58397 from frappe/version-16-hotfix
chore: release v16
2026-08-25 22:21:37 +05:30
Shllokkk
7238ecb306 Merge pull request #58407 from frappe/mergify/bp/version-16-hotfix/pr-58400
fix: send auto reorder email to all managers in single company setup (backport #58400)
2026-08-25 18:30:12 +05:30
Shllokkk
97b2e07d5e test: auto reorder email reaches managers without company user permission
(cherry picked from commit a01cc92184)
2026-08-25 12:08:44 +00:00
Shllokkk
ac1c6921da fix: send auto reorder email to all managers in single company setup
(cherry picked from commit ae119b1c29)
2026-08-25 12:08:43 +00:00
Sudharsanan11
01e0844d40 fix(stock): keep pick list links when refetching stock entry items 2026-08-25 17:05:23 +05:30
Sudharsanan Ashok
c9550023f8 Merge pull request #58399 from frappe/mergify/bp/version-16-hotfix/pr-58395
fix: respect zero currency precision (backport #58395)
2026-08-25 16:45:34 +05:30
Pandiyan P
6b61146d2f fix: respect zero currency precision (#58395)
(cherry picked from commit ce23fcc055)
2026-08-25 10:06:39 +00:00
Sudharsanan Ashok
9209912374 Merge pull request #58353 from frappe/mergify/bp/version-16-hotfix/pr-58179
fix(accounts): supplier group filter not applied on accounts payable … (backport #58179)
2026-08-25 13:04:52 +05:30
Sudharsanan Ashok
f1181fa1e3 Merge pull request #58391 from frappe/mergify/bp/version-16-hotfix/pr-58362
fix(stock): reset bin when no stock ledger entries remain (backport #58362)
2026-08-25 12:46:31 +05:30
Sudharsanan Ashok
37a1fd11e9 fix(stock): reset bin when no stock ledger entries remain (#58362)
* fix(stock): reset bin when no stock ledger entries remain

update_bin() only writes bins reachable through prev_sle_dict, and that
dict is empty once the last live sle for an item and warehouse is
cancelled or deleted. actual_qty is still recomputed, but stock_value
and valuation_rate stay stale and a repost cannot heal them, so bin
totals drift permanently from the stock balance.

zero those bins after the normal update, guarded by a re-check that no
live sle exists. also drop the prev_sle_dict seeding added earlier in
initialize_previous_data, which never took effect because
initialize_reposting() discards the dict before update_bin() reads it.

* test(stock): cover bin reset when ledger is empty

three cases that all leave an item and warehouse with no live sle:
cancelling the only voucher, deleting it with delete_linked_ledger_entries
on, and reposting over an already emptied ledger. each asserts actual_qty,
valuation_rate and stock_value are all zero.

(cherry picked from commit 6fbcfade6c)
2026-08-25 06:10:18 +00:00
Sudharsanan Ashok
f71bc4d3ef Merge pull request #58384 from aerele/backport-58377-version-16-hotfix
fix: prevent duplicate supplier quotations from portal (backport #58377)
2026-08-25 11:12:10 +05:30
pandiyan
efe5571ca7 test: verify duplicate supplier quotations are rejected 2026-08-24 23:57:59 +05:30
pandiyan
39e15c7b2d fix: prevent duplicate supplier quotations from portal 2026-08-24 23:57:59 +05:30
mergify[bot]
2329ef6424 fix: hide supplier name in rfq portal (backport #58373) (#58376)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-24 12:11:31 +00:00
mergify[bot]
83cc51a5d2 Fix/return qty validation different uom (backport #58298) (#58364)
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:41 +05:30
mergify[bot]
feb51a475a fix(italy): handle none price_list_rate in e-invoice xml generation (backport #58242) (#58370)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-08-24 17:39:16 +05:30
mergify[bot]
5933476778 fix: filter available batch report by company (backport #57995) (#58077)
Co-authored-by: Krishna Shirsath <shirsathkrishna19@gmail.com>
2026-08-24 11:19:45 +00:00
Sudharsanan Ashok
fb9edb3bf8 Merge pull request #58361 from frappe/mergify/bp/version-16-hotfix/pr-58358
fix: render missing terms before printing (backport #58358)
2026-08-24 16:37:06 +05:30
Sudharsanan Ashok
9d9d9f09fd Merge pull request #58372 from frappe/mergify/bp/version-16-hotfix/pr-58368
fix: hide rfq status in supplier portal (backport #58368)
2026-08-24 16:36:08 +05:30
Pandiyan P
8a2109e62a fix: hide rfq status in supplier portal (#58368)
(cherry picked from commit 75d6183bb6)
2026-08-24 11:00:24 +00:00
mergify[bot]
abe4c0a6b7 fix: aggregate child warehouses in Stock Qty vs Serial No Count report (backport #58134) (#58366)
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:24:40 +00:00
Pandiyan P
f56cdac45f fix: render missing terms before printing (#58358)
(cherry picked from commit 59d80b29c4)
2026-08-24 09:24:08 +00:00
mergify[bot]
0bf34c054a fix: include time logs ending at midnight in timesheet billing summary (backport #58355) (#58357)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-24 14:46:46 +05:30
ervishnucs
d23b9197d5 fix(accounts): supplier group filter not applied on accounts payable report
(cherry picked from commit 513f19924d)
2026-08-24 07:09:12 +00:00
mergify[bot]
8dbe4bd7fa ci: bump po-review-action (backport #58346) (#58348)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-08-23 18:22:37 +00:00
MochaMind
d321eb4a8a chore: update POT file (#58344) 2026-08-23 13:33:34 +02:00
Mihir Kandoi
a1520d06ee chore: rename type field to secondary_item_type (backport #55469) (#58342) 2026-08-23 14:07:59 +05:30
mergify[bot]
647c93321b fix(projects): respect permissions in timesheet billing summary (backport #58320) (#58322)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-20 15:48:55 +05:30
mergify[bot]
2077e844d4 fix: correct to and from date filters in timesheet billing summary re… (backport #58317) (#58318)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-20 11:13:42 +05:30
mergify[bot]
c3313f5b34 fix: stop doubling totals in timesheet billing summary (backport #58311) (#58315)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-20 03:03:06 +00:00
Pandiyan P
dfc2301f08 fix: skip covered rows when ordering from the mrp report (#58249) (#58312) 2026-08-20 07:43:16 +05:30
mergify[bot]
53b61e5ac6 fix(stock): preserve item UOM conversion factor (backport #58299) (#58304)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 12:01:42 +00:00
mergify[bot]
a3a6efbcea fix: keep Currency and Price List section open for foreign currency (backport #58074) (#58113)
* 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/stock/doctype/delivery_note/delivery_note.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-19 16:59:07 +05:30
mergify[bot]
9f99ba6ddc fix: block disabled/frozen party on Opportunity and Request for Quotation (backport #57983) (#58035)
* 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)

* 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 and test_request_for_quotation.py.

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:57:42 +05:30
mergify[bot]
1f21c0b0af fix(stock): fetch item stock UOM in stock reconciliation (backport #58284) (#58291)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 10:09:44 +00:00
Khushi Rawat
f813774f63 Merge pull request #57771 from frappe/mergify/bp/version-16-hotfix/pr-57539
fix: prevent TimestampMismatchError resolving Dunning with multiple overdue installments (backport #57539)
2026-08-19 15:05:36 +05:30
mergify[bot]
98d69b2ae3 fix: Ignore permission while deleting user permission (backport #57296) (#57760)
Co-authored-by: Deepesh Garg <deepeshgarg6@gmail.com>
2026-08-19 14:49:33 +05:30
mergify[bot]
29f6a5b9cf fix(selling): read overdue amount from payment ledger, not gl tags (backport #57786) (#58026)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-19 14:41:47 +05:30
mergify[bot]
edb9b33264 fix: new docs should refetch incoming rates (backport #58097) (#58295)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 07:19:06 +00:00
mergify[bot]
94b6622d9d fix: use user data fields hook (backport #58274) (#58283)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-19 10:25:55 +05:30
mergify[bot]
d3573105c4 fix: escape interpolated values in text positions across portal and desk templates (backport #58286) (#58288) 2026-08-19 02:43:18 +05:30
mergify[bot]
9e2173e4c2 fix: escape on status image for workstations in production status (backport #58279) (#58281)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-19 00:17:17 +05:30
mergify[bot]
6b7b4796b4 fix: escape interpolated values in portal, print and desk templates (backport #58273) (#58278)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-19 00:16:52 +05:30
Frappe PR Bot
11e0ba0a1c chore(release): Bumped to Version 16.32.3
## [16.32.3](https://github.com/frappe/erpnext/compare/v16.32.2...v16.32.3) (2026-08-18)

### Bug Fixes

* Qty and UOM not fetched when adding Item in Material Request (backport [#58118](https://github.com/frappe/erpnext/issues/58118)) ([#58266](https://github.com/frappe/erpnext/issues/58266)) ([f01f741](f01f7415e1))
2026-08-18 09:57:04 +00:00
mergify[bot]
f01f7415e1 fix: Qty and UOM not fetched when adding Item in Material Request (backport #58118) (#58266) 2026-08-18 15:25:21 +05:30
rohitwaghchaure
27a04d8e08 fix: work order finish dialog with process loss qty from job card (backport #58256) (#58262)
* fix(manufacturing): cap job card completed qty by previous operation and show process loss on finish dialog

* fix: avoid double booking process loss on partial manufacture entries
2026-08-18 09:47:32 +00:00
mergify[bot]
9b0db26c25 fix(accounts): match returns to source invoice items (backport #58250) (#58264)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-18 09:08:31 +00:00
mergify[bot]
a62a949b2a fix(accounts): allocate drop-ship cost by invoice quantity (backport #58226) (#58263)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-18 08:41:58 +00:00
Frappe PR Bot
5412ea0b8c chore(release): Bumped to Version 16.32.2
## [16.32.2](https://github.com/frappe/erpnext/compare/v16.32.1...v16.32.2) (2026-08-18)

### Bug Fixes

* get items from sales order in sales invoice (backport [#58163](https://github.com/frappe/erpnext/issues/58163)) ([#58258](https://github.com/frappe/erpnext/issues/58258)) ([204f979](204f979f33))
2026-08-18 07:29:42 +00:00
mergify[bot]
204f979f33 fix: get items from sales order in sales invoice (backport #58163) (#58258)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-08-18 07:27:23 +00:00
rohitwaghchaure
dd4d10862d fix: don't set work order status to In Process only due to skip material transfer (#58245) 2026-08-17 18:28:46 +05:30
Pandiyan P
861fb26b2c fix(manufacturing): fall back to item group defaults for work order w… (#58237) 2026-08-17 15:26:15 +05:30
mergify[bot]
56ac87292f fix: correct Item Group doctype name in item tax template dashboard (backport #58192) (#58213)
Co-authored-by: Pandiyan P <pandiyanpalani37@gmail.com>
2026-08-17 10:52:42 +05:30
mergify[bot]
81461ea56c fix(bank_statement_import): add missing permission check on multiple whitelisted methods (backport #58221) (#58224)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 18:03:24 +00:00
mergify[bot]
2b685ed982 fix(bank_statement_import): add missing permission check on get_import_status (backport #58217) (#58219)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 15:04:21 +00:00
mergify[bot]
6a0b9e1c70 fix(crm)!: remove unused get_last_interaction endpoint (backport #58214) (#58216)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-08-16 14:16:29 +00:00
MochaMind
28fe021d79 chore: update POT file (#58207) 2026-08-16 18:03:26 +05:30
mergify[bot]
c3cf7f2e91 fix: drop removed Restaurant doctype from sales tax template dashboard (backport #58191) (#58211) 2026-08-16 17:28:32 +05:30
Shllokkk
832a0bd1ed Merge pull request #58208 from Shllokkk/supplier-quotation-comparison-status-filter-v16
feat: add status filter to Supplier Quotation Comparison report
2026-08-16 15:52:17 +05:30
Shllokkk
2b84ed78e8 feat: add status filter to Supplier Quotation Comparison report 2026-08-16 15:34:10 +05:30
mergify[bot]
32ebd69abc fix(email_digest): added permission check for get_msg_html (backport #58197) (#58200)
Co-authored-by: diptanilsaha <diptanil@frappe.io>
2026-08-15 16:40:09 +00:00
mergify[bot]
900064e395 feat: validate purchase receipt exchange rate parity on purchase invoice (backport #58177) (#58190)
feat: validate purchase receipt exchange rate parity on purchase invoice (#58177)

(cherry picked from commit 70a8a2d0c5)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-15 15:20:17 +05:30
mergify[bot]
d805f4d3fc feat: Belgian Charts of Accounts (commercial + non-profit, FR + NL) (backport #54679) (#58186)
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:57:25 +00:00
mergify[bot]
d3a4d476eb fix(stock): honour pick serial / batch based on in the batch selector (backport #58176) (#58182)
Co-authored-by: Sudharsanan Ashok <135326972+Sudharsanan11@users.noreply.github.com>
2026-08-14 13:58:07 +00:00
mergify[bot]
4bb7799919 fix: rewriting the description of the overdue billing checkbox (backport #58172) (#58174)
fix: rewriting the description of the overdue billing checkbox (#58172)

fix: rewritting the description of the overdue billing checkbox
(cherry picked from commit cbf8f61101)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-08-14 17:43:14 +05:30
Nishka Gosalia
c15d5d1a0b Merge pull request #58171 from frappe/mergify/bp/version-16-hotfix/pr-58167
fix: validation for task end date check (backport #58167)
2026-08-14 16:36:33 +05:30
nishkagosalia
7b0df4b28b fix: validation for task end date check
(cherry picked from commit 7c6da80f98)
2026-08-14 10:53:06 +00:00
mergify[bot]
1ea1d5d6a5 fix: renaming the overdue billing checkbox (backport #58165) (#58166)
fix: renaming the overdue billing checkbox (#58165)

(cherry picked from commit 917badb82b)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-08-14 16:05:22 +05:30
Smit Vora
aa03ddc8e6 Merge pull request #58161 from frappe/mergify/bp/version-16-hotfix/pr-56175
feat: taxable-base resolver hook for custom charge types (backport #56175)
2026-08-14 15:16:39 +05:30
Mihir Kandoi
0255314ea7 fix: get items from sales order in sales invoice (#58163) 2026-08-14 09:20:10 +00:00
mergify[bot]
f8c3270049 feat(accounts): opt-in 'Consider Accounting Dimension' filter on General Ledger Report (backport #58156) (#58158) 2026-08-14 08:25:09 +00:00
Smit Vora
ea238a90ac feat: taxable-base resolver hook for custom charge types (#56175)
(cherry picked from commit 986cea2331)
2026-08-14 08:19:48 +00:00
Khushi Rawat
f03b7ba40b Merge pull request #58155 from frappe/mergify/bp/version-16-hotfix/pr-58092
fix(accounts): disallow reversing a reverse journal entry (backport #58092)
2026-08-14 13:20:43 +05:30
Frappe PR Bot
21d1873020 chore(release): Bumped to Version 16.32.1
## [16.32.1](https://github.com/frappe/erpnext/compare/v16.32.0...v16.32.1) (2026-08-14)

### Bug Fixes

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

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

(cherry picked from commit 9239d1c2a3)


(cherry picked from commit c18881b37d)

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-08-14 11:48:55 +05:30
pandiyan
8a26834704 fix(accounts): disallow reversing a reverse journal entry
check read permission on the source entry before the guards run, so the
reversal relationship is not disclosed to a user who cannot read it.

(cherry picked from commit 9dd37d5f32)
2026-08-14 11:37:06 +05:30
pandiyan
271d22fff1 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.

(cherry picked from commit 80422d2108)
2026-08-14 06:01:39 +00:00
mergify[bot]
c18881b37d fix: ignore historical negative batch stock in outward validation (backport #58148) (#58151)
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 11:18:50 +05:30
Pandiyan P
61b549960e fix(buying): allow purchase returns against a closed purchase order (#58139) 2026-08-14 09:00:55 +05:30
Diptanil Saha
ea52ab36d1 Merge pull request #58143 from frappe/mergify/bp/version-16-hotfix/pr-58141
fix(crm_settings): create `crm_deal` fields on enabling frappe crm data synchronization (backport #58141)
2026-08-13 23:20:41 +05:30
diptanilsaha
d66dc143e3 fix: patch to delete the crm_deal custom fields
(cherry picked from commit 9613d72d81)
2026-08-13 17:40:12 +00:00
diptanilsaha
795cf8544c fix(crm_settings): create custom fields for Frappe CRM on enabling synchronization
(cherry picked from commit be2dea0ba2)
2026-08-13 17:40:12 +00:00
mergify[bot]
4f3ffd3820 fix(stock): confirm before changing item qty from the batch selector (backport #58123) (#58125) 2026-08-13 19:37:31 +05:30
mergify[bot]
f9fd3bc526 chore(deps): update eslint in banking app (backport #58129) (#58137)
chore(deps): update eslint in banking app (#58129)

(cherry picked from commit 0bbaf4da3e)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-08-13 13:30:40 +00:00
mergify[bot]
5afaffe7b4 chore: update deps in banking app (backport #57971) (#58130)
chore: update deps in banking app (#57971)

(cherry picked from commit 5b2952aa23)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-08-13 12:32:12 +00:00
Khushi Rawat
569ad8e89b Merge pull request #58122 from frappe/mergify/bp/version-16-hotfix/pr-58120
fix: describe stale exchange rate settings (backport #58120)
2026-08-13 15:33:29 +05:30
Khushi Rawat
cbe7931b3b fix: conflicts 2026-08-13 15:22:38 +05:30
Lakshit Jain
03124b9560 Merge pull request #58027 from frappe/mergify/bp/version-16-hotfix/pr-57965
fix: run set_missing_values before creating Purchase Order from MRP report (backport #57965)
2026-08-13 15:21:11 +05:30
khushi8112
1ec2a2cd5d fix: describe stale exchange rate settings
(cherry picked from commit 84cdd0daf0)

# Conflicts:
#	erpnext/accounts/doctype/accounts_settings/accounts_settings.json
2026-08-13 09:41:37 +00:00
Mihir Kandoi
2a9e4304a2 fix(manufacturing): derive material transfers from actual coverage (#58115) 2026-08-13 15:06:14 +05:30
Henil Maru
aecc551b5e fix: Qty and UOM not fetched when adding Item in Material Request (#58118) 2026-08-13 14:46:05 +05:30
ljain112
72114fa738 fix: run set_missing_values before creating Purchase Order from MRP report
(cherry picked from commit 94d363851f)
2026-08-13 14:12:53 +05:30
Lakshit Jain
e51df54439 Merge pull request #57120 from frappe/mergify/bp/version-16-hotfix/pr-56912
refactor: move functionality in postprocess for mapped doc (backport #56912)
2026-08-13 14:06:27 +05:30
Khushi Rawat
cf6c058f53 Merge pull request #58104 from frappe/mergify/bp/version-16-hotfix/pr-58078
fix: apply Sales Person user permissions in Accounts Receivable (backport #58078)
2026-08-13 12:28:49 +05:30
khushi8112
3fd3f9485b fix: resolve backport conflicts 2026-08-13 11:52:26 +05:30
Khushi Rawat
007748346c Merge pull request #58089 from frappe/mergify/bp/version-16-hotfix/pr-57820
fix(assets): split fifo/lifo rate across grouped stock item rows (backport #57820)
2026-08-13 11:49:15 +05:30
khushi8112
956be58c9c fix: apply Sales Person user permissions in Accounts Receivable
The report only narrowed by sales person when the filter was set, so a user
restricted to a Sales Person saw every row once the filter was cleared.

Resolve the permitted Sales Persons from user permissions and apply them on
top of the filter. Each Sales Team parent type is matched against its own
applicable_for scope, so a permission scoped to one doctype cannot authorise
rows through the other. Descendants are already expanded by
get_user_permissions, so Hide Descendants is respected. Gated to Receivable,
since the class is shared with Accounts Payable.

(cherry picked from commit 8b09ba429e)

# Conflicts:
#	erpnext/accounts/report/accounts_receivable/accounts_receivable.py
2026-08-13 06:16:07 +00:00
mergify[bot]
0fe5436a13 Fix/item description in the item price list (backport #58084) (#58102)
Co-authored-by: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com>
2026-08-13 04:23:10 +00:00
ervishnucs
c014e01144 test(assets): cover grouped stock item rows splitting FIFO rate
(cherry picked from commit 2cbc5b89d6)
2026-08-12 09:05:15 +00:00
ervishnucs
98bef1cac8 fix(assets): split FIFO/LIFO rate across grouped stock item rows
(cherry picked from commit a05ec49062)
2026-08-12 09:05:15 +00:00
Frappe PR Bot
81a6f97566 chore(release): Bumped to Version 16.32.0
# [16.32.0](https://github.com/frappe/erpnext/compare/v16.31.1...v16.32.0) (2026-08-11)

### Bug Fixes

* add raw material to its operation even when another operation uses the item ([35f9bec](35f9bec988))
* add type hints to conversion factor API ([dbfe7e1](dbfe7e199e))
* allow non-admin roles to import chart of accounts (backport [#57454](https://github.com/frappe/erpnext/issues/57454)) ([#58060](https://github.com/frappe/erpnext/issues/58060)) ([a707c82](a707c82c0c))
* allow selecting a warehouse for new items in the update items dialog ([#57876](https://github.com/frappe/erpnext/issues/57876)) ([c9977be](c9977be5d4))
* cap a manufacture entry at the job card's pending production ([79ad410](79ad410cc1))
* clear deferred revenue/expense fields on uncheck (backport [#57140](https://github.com/frappe/erpnext/issues/57140)) ([7e72e70](7e72e70cc1))
* **coa_importer:** added server-side validations for importing chart of accounts (backport [#58065](https://github.com/frappe/erpnext/issues/58065)) ([#58066](https://github.com/frappe/erpnext/issues/58066)) ([db22fcb](db22fcb157))
* condition check with empty object for falsy case ([838fb8e](838fb8e8df))
* **consolidated cash flow:** correct totals and labels in section foo… (backport [#57336](https://github.com/frappe/erpnext/issues/57336)) ([#58056](https://github.com/frappe/erpnext/issues/58056)) ([f11d0d5](f11d0d5cec))
* **controllers:** correct negative rate settings link ([51aecec](51aecec598))
* convert hours to minutes in workstation complete_job ([822e6d8](822e6d8924))
* declare precision 9 on all conversion_factor fields ([db49b03](db49b03913))
* derive operation FG items before material expansion, keep the final one the BOM's item ([3e0d0b2](3e0d0b2d68))
* don't demand raw material transfer for semi FG job cards on submit ([99d9d84](99d9d845bd))
* escape `customer_details` on lead creation from appointment (backport [#57947](https://github.com/frappe/erpnext/issues/57947)) ([#57949](https://github.com/frappe/erpnext/issues/57949)) ([dff4ec6](dff4ec6a74))
* field validation and perm checks on `get_stock_reservation_entries_for_voucher` (backport [#57968](https://github.com/frappe/erpnext/issues/57968)) ([#57970](https://github.com/frappe/erpnext/issues/57970)) ([0a9b632](0a9b632925))
* generate the next manufacture entry net of booked process loss ([2548751](2548751673))
* guard reconciliation table deletes when tables are missing ([21fbfa5](21fbfa541d))
* handling negative grand total ([e657a7f](e657a7f19f))
* incorrect batch-wise valuation rate for entries with same posting datetime (backport [#57794](https://github.com/frappe/erpnext/issues/57794)) ([#57797](https://github.com/frappe/erpnext/issues/57797)) ([adfa676](adfa6768c9))
* incorrect entry detection in Stock Ledger Invariant Check ([#57886](https://github.com/frappe/erpnext/issues/57886)) ([f7bae88](f7bae888cf))
* item property updates in POS and transactions and add styling (backport [#57189](https://github.com/frappe/erpnext/issues/57189)) ([#58058](https://github.com/frappe/erpnext/issues/58058)) ([5f43c89](5f43c89c45))
* **job_card:** apply the completion dialog's qty to manufacture ([#57685](https://github.com/frappe/erpnext/issues/57685)) ([d97cf13](d97cf131a1))
* **job_card:** leave the pending qty out of the job card's own output ([#57686](https://github.com/frappe/erpnext/issues/57686)) ([0c79194](0c7919429e))
* **job_card:** reject a completion split that cannot add up ([#57687](https://github.com/frappe/erpnext/issues/57687)) ([3907d93](3907d93f9f))
* **job_card:** require the previous operation to be manufactured ([#57684](https://github.com/frappe/erpnext/issues/57684)) ([a22a7fd](a22a7fddba))
* **journal_entry:** validate blocked purchase invoices ([04718e1](04718e15c9))
* **journal_entry:** validation message for blocked purchase invoice ([#57896](https://github.com/frappe/erpnext/issues/57896)) ([f0adbd2](f0adbd2bd3))
* keep asset repair downtime in sync with entered dates ([ca5418e](ca5418e2c9))
* keep job card actions visible for corrective job cards ([4ee276d](4ee276d3db))
* keep source rate on re-fetch when maintain same rate is enabled (backport [#57479](https://github.com/frappe/erpnext/issues/57479)) ([#57792](https://github.com/frappe/erpnext/issues/57792)) ([ee5316e](ee5316ecd0))
* keep Target Warehouse optional for work orders tracking semi finished goods ([c598cf9](c598cf9010))
* keep the transfer qty check for legacy semi FG cards without an FG item ([f810d78](f810d780c0))
* **manufacturing:** adapt version 16 compatibility ([a8060d5](a8060d5e99))
* **manufacturing:** align quantity split rounding ([317dd18](317dd18ce5))
* **manufacturing:** avoid child BOM cache lookups ([fb87751](fb877515ca))
* **manufacturing:** correct nested BOM quantities ([b4929e2](b4929e2737))
* **manufacturing:** keep item code searchable when a barcode matches the same text ([87c4009](87c4009572))
* **manufacturing:** type whitelisted BOM arguments ([87b456f](87b456faa2))
* mirror rounding adjustment on distributed_discount_amount (backport [#58047](https://github.com/frappe/erpnext/issues/58047)) ([#58055](https://github.com/frappe/erpnext/issues/58055)) ([d680115](d680115fcb))
* optimize product bundle item search ([2934971](29349711e4))
* preserve custom title on new JV (backport [#57987](https://github.com/frappe/erpnext/issues/57987)) ([#57989](https://github.com/frappe/erpnext/issues/57989)) ([4a2163e](4a2163ebf8))
* preserve original operation idx in manually created Job Cards ([fee003a](fee003a82d))
* purchase return of batchwise valuation batch valued at original receipt rate instead of batch avg rate (version-16-hotfix) ([#57836](https://github.com/frappe/erpnext/issues/57836)) ([aa70d9b](aa70d9bbc3))
* re-check future sle before queuing repost on submit ([#57664](https://github.com/frappe/erpnext/issues/57664)) ([#57961](https://github.com/frappe/erpnext/issues/57961)) ([2b020c2](2b020c2fcf))
* reflect in-invoice receivable settlements in Sales Register ledger view ([a90907f](a90907fda6))
* **regional:** rename Italy's duplicate Customer name fields ([f56867d](f56867d843)), closes [#46281](https://github.com/frappe/erpnext/issues/46281) [#50915](https://github.com/frappe/erpnext/issues/50915) [#57215](https://github.com/frappe/erpnext/issues/57215) [#50921](https://github.com/frappe/erpnext/issues/50921) [#53409](https://github.com/frappe/erpnext/issues/53409)
* repost read stale sibling SLE rate for moving average returns ([ffa65b0](ffa65b0c48))
* require FG / Semi FG Item on operations when tracking semi finished goods ([03eeb83](03eeb839fb))
* require material transfer before job card start and completion ([a7f75de](a7f75de1aa))
* require WIP warehouse for work orders tracking semi finished goods ([24cd5f2](24cd5f22b5))
* roll up process loss to the work order for semi finished goods ([27130d8](27130d8e49))
* round computed conversion factors to field precision ([206ed28](206ed28924))
* round production plan mr_items quantity to field precision ([2d056ae](2d056aee3d))
* **sales-invoice:** respect Customize Form hidden setting on Update Stock ([#57819](https://github.com/frappe/erpnext/issues/57819)) ([0e26f9b](0e26f9b1db))
* scale generated raw materials to the manufacture entry's production share ([acba994](acba9945b0))
* scope manufacture entry process loss to its own job card ([a0b370b](a0b370b2e9)), closes [frappe/erpnext#57892](https://github.com/frappe/erpnext/issues/57892)
* **selling:** bill re-delivered sales order quantities ([6fb09b1](6fb09b1e21))
* **selling:** preserve explicit UOM during item selection ([218397e](218397e78d))
* **selling:** read overdue amount from payment ledger, not gl tags (backport [#57786](https://github.com/frappe/erpnext/issues/57786)) ([#58057](https://github.com/frappe/erpnext/issues/58057)) ([b89229a](b89229a93d))
* **selling:** reset stale item details on item change ([b5d727f](b5d727fb81))
* set `restrict_globals=True` in `frappe.render_template` (backport [#57899](https://github.com/frappe/erpnext/issues/57899)) ([#57902](https://github.com/frappe/erpnext/issues/57902)) ([6b45002](6b45002abc))
* **setup:** fetch driver address by supplier link ([62a851b](62a851bebb))
* skip incoming rate calc when serial no qty is zero (backport [#57427](https://github.com/frappe/erpnext/issues/57427)) ([#57957](https://github.com/frappe/erpnext/issues/57957)) ([a4d9411](a4d9411345))
* skip PO items with invoice-created receipts in billing repair patch ([9139994](9139994686))
* skip the pending production check on update-after-submit saves ([491f9fa](491f9fa3fe))
* **stock:** drop call to confirm_if_drafts_exist missing on v16 ([#57833](https://github.com/frappe/erpnext/issues/57833)) ([626e351](626e35135f))
* **stock:** handle multi-item opening balance in Stock Ledger report (backport [#57591](https://github.com/frappe/erpnext/issues/57591)) ([#57796](https://github.com/frappe/erpnext/issues/57796)) ([af3184c](af3184c8b4))
* **stock:** repair duplicated purchase receipt billing ([fa733e6](fa733e691b))
* **stock:** validate new warehouse inventory account after naming ([fce0eb1](fce0eb1577))
* **stock:** validate warehouse accounts when used ([0dfa54f](0dfa54f812))
* stop asking for a manufacturing entry when process loss explains the shortfall ([104c8df](104c8df765))
* **subscription:** don't reactivate a cancelled subscription (backport [#57774](https://github.com/frappe/erpnext/issues/57774))  ([eeb3cd2](eeb3cd238e)), closes [#57761](https://github.com/frappe/erpnext/issues/57761) [#56796](https://github.com/frappe/erpnext/issues/56796) [#56796](https://github.com/frappe/erpnext/issues/56796) [#57780](https://github.com/frappe/erpnext/issues/57780)
* sync open reference forms after Quality Inspection updates them ([33eb619](33eb6199d9))
* sync translations from crowdin ([#57841](https://github.com/frappe/erpnext/issues/57841)) ([a9d139a](a9d139ae6b))
* sync translations from crowdin ([#58069](https://github.com/frappe/erpnext/issues/58069)) ([f697b02](f697b024db))
* tolerate floating-point drift in sales team allocated percentage ([39f15bb](39f15bb3e9))
* use stock settings for warehouse defaults ([d8bbe86](d8bbe865a8))
* validate webform for project ([8d98fe8](8d98fe8187))
* zero-rate repost fallback could still read sibling SLE ([81f81ff](81f81fff32))

### Features

* **job_card:** print quantities with their stock uom ([#57689](https://github.com/frappe/erpnext/issues/57689)) ([eb90186](eb90186d3f))
* stock validations in Period Closing Voucher and snapshot-seeded batch valuation (backport [#57811](https://github.com/frappe/erpnext/issues/57811)) ([#57816](https://github.com/frappe/erpnext/issues/57816)) ([243266f](243266f5ef))
* sync serial no status from stock ledger in Stock Qty vs Serial No Count report (version-16-hotfix) ([#57864](https://github.com/frappe/erpnext/issues/57864)) ([ea5cbb1](ea5cbb116c))
2026-08-11 23:39:40 +00:00
Diptanil Saha
b27bc42d51 Merge pull request #58024 from frappe/version-16-hotfix
chore: release v16
2026-08-12 05:08:00 +05:30
Frappe PR Bot
22247ab7c5 chore(release): Bumped to Version 16.31.1
## [16.31.1](https://github.com/frappe/erpnext/compare/v16.31.0...v16.31.1) (2026-08-06)

### Bug Fixes

* **stock:** drop call to confirm_if_drafts_exist missing on v16 ([#57833](https://github.com/frappe/erpnext/issues/57833)) ([e834910](e834910bfd))
2026-08-06 08:12:42 +00:00
Mihir Kandoi
f8b19f7376 Merge pull request #57834 from frappe/mergify/bp/version-16/pr-57833
fix(stock): drop call to confirm_if_drafts_exist missing on v16 (backport #57833)
2026-08-06 13:40:55 +05:30
Mihir Kandoi
e834910bfd fix(stock): drop call to confirm_if_drafts_exist missing on v16 (#57833)
(cherry picked from commit 626e35135f)
2026-08-06 08:09:41 +00:00
ljain112
b412266e18 chore: resolve conflicts 2026-08-05 17:01:39 +05:30
ljain112
3141387949 chore: resolve conflicts 2026-08-05 12:33:01 +05:30
ljain112
6bee70596b refactor: move functionality in postprocess for mapped doc
(cherry picked from commit 0691c7c7bc)

# Conflicts:
#	erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py
2026-08-05 12:31:22 +05:30
Frappe PR Bot
68ea583a1f chore(release): Bumped to Version 16.31.0
# [16.31.0](https://github.com/frappe/erpnext/compare/v16.30.0...v16.31.0) (2026-08-04)

### Bug Fixes

* **accounts receivable:** made territory field multi select (backport [#57322](https://github.com/frappe/erpnext/issues/57322)) ([#57346](https://github.com/frappe/erpnext/issues/57346)) ([9b452f1](9b452f12b7))
* **accounts:** fetch deferred invoice docs on non-empty `sales_docs` or `purchase_docs` in repost accounting ledger ([#57753](https://github.com/frappe/erpnext/issues/57753)) ([6153202](6153202231))
* **accounts:** skip party dashboard without invoice permission ([ee6955d](ee6955d56c))
* **accounts:** update AU standard chart of accounts (backport [#57145](https://github.com/frappe/erpnext/issues/57145)) ([#57608](https://github.com/frappe/erpnext/issues/57608)) ([1ea1ce1](1ea1ce15d0))
* allow custom remark on reversal journal entry ([b4dfca9](b4dfca9ef1))
* apply the supplier selection to this branch's own mapper module ([e5f8d0c](e5f8d0c84b))
* **assets:** add permission checks on whitelisted methods on `asset_capitalization` ([99f249b](99f249b1a0))
* **banking:** fetch company list from DB instead of boot (backport [#57731](https://github.com/frappe/erpnext/issues/57731)) ([#57739](https://github.com/frappe/erpnext/issues/57739)) ([ca60653](ca6065398c))
* do not fetch a random inventory account when multiple inventory accounts exist (backport [#57626](https://github.com/frappe/erpnext/issues/57626)) ([#57632](https://github.com/frappe/erpnext/issues/57632)) ([34cbd3c](34cbd3c8d5))
* drop row prefix in reserve stock message when row is unknown ([684ae4d](684ae4d762))
* escape data in multiple templates (backport [#57742](https://github.com/frappe/erpnext/issues/57742)) ([#57770](https://github.com/frappe/erpnext/issues/57770)) ([824ae57](824ae57e44))
* escape item code and UOM in the supplier dialog errors ([06a753f](06a753faf3))
* exclude transferred and consumed qty from dashboard reserved stock ([46317b0](46317b063a))
* filter Accounts Receivable by invoice sales partner (backport [#57628](https://github.com/frappe/erpnext/issues/57628)) ([#57648](https://github.com/frappe/erpnext/issues/57648)) ([0e46937](0e46937f60))
* guard against None row in get_stock_balance_for (backport [#57567](https://github.com/frappe/erpnext/issues/57567)) ([283ee6e](283ee6e07b))
* guard scio row lookup in stock entry items_add ([fb6c87d](fb6c87dd0c))
* **italy:** skip e-invoicing for opening invoices ([5e584d1](5e584d1cfb))
* **item_variant:** added permission checks on `enqueue_multiple_variant_creation` ([c7cf9d8](c7cf9d868b))
* keep the bulk supplier field to half the supplier selection dialog ([2c9db13](2c9db13041))
* label the items table in the supplier selection dialog ([187840b](187840b559))
* let Purchase Receipt cancel defer to Frappe's linked-document check (backport [#57592](https://github.com/frappe/erpnext/issues/57592)) ([#57597](https://github.com/frappe/erpnext/issues/57597)) ([16be0f0](16be0f0944))
* link the item and spell out the unit in the supplier dialog errors ([810b9ae](810b9ae28f))
* list the Purchase Orders created per supplier instead of opening one ([d2fe4b6](d2fe4b623c))
* **manufacturing:** reach the whole configurator from tree toolbar actions ([281e92f](281e92fb6e)), closes [#57773](https://github.com/frappe/erpnext/issues/57773)
* open the Purchase Order when the supplier selection creates only one ([e71cef0](e71cef02b3))
* **opportunity:** add validation for positive item quantities ([a9f969e](a9f969e942))
* **payment reconciliation:** correct supplier gain/loss posting ([dc907ad](dc907add40))
* **payment_request:** added permission checks on `resend_payment_email` ([fcbbb25](fcbbb251cf))
* **plant_floor:** add missing perm check on `get_stock_summary` (backport [#57667](https://github.com/frappe/erpnext/issues/57667)) ([#57670](https://github.com/frappe/erpnext/issues/57670)) ([7bc0475](7bc04752fc))
* **pos:** don't double-escape Item Group names in get_item_groups ([#57673](https://github.com/frappe/erpnext/issues/57673)) ([a5544d0](a5544d0bfb))
* preserve UOM conversion factor precision in transactions ([e5999b2](e5999b22c7))
* prevent duplicate shipping charges without cost center ([666b616](666b6167a1))
* **projects:** add project filter ([3ba8313](3ba83134af))
* **projects:** include on hold status in project filters and reports ([d104d8e](d104d8e723))
* **purchase:** reject purchase returns where every item has zero quantity ([032b922](032b922f0c))
* **quotation:** carry forward communications from opportunity at after_insert (backport [#57639](https://github.com/frappe/erpnext/issues/57639)) ([#57643](https://github.com/frappe/erpnext/issues/57643)) ([624a236](624a236f88))
* reject the same Material Request item twice in one supplier selection ([ea770f6](ea770f6a8e))
* resolve version 16 backport conflicts ([e98471d](e98471d9c9))
* resolve version-16 backport conflicts ([697f68d](697f68d1d2))
* respect child warehouse account override in Stock and Account Value Comparison ([#57552](https://github.com/frappe/erpnext/issues/57552)) ([ca657d2](ca657d2629))
* respect quantity precision in material transfer validation ([eb969a5](eb969a5866))
* **sales:** reject sales returns where every item has zero quantity ([aa71cd6](aa71cd695b))
* seed standard Item Groups under the existing tree root ([4ffa950](4ffa950aa3)), closes [#57581](https://github.com/frappe/erpnext/issues/57581)
* **selling:** don't require cancel and delete perms to remove items via Update Items (backport [#57419](https://github.com/frappe/erpnext/issues/57419)) ([#57601](https://github.com/frappe/erpnext/issues/57601)) ([04e1ca8](04e1ca8226))
* set Required By on Purchase Orders created per supplier ([f99d66d](f99d66d578))
* set reservation voucher_qty to voucher demand not reserved qty ([ecccedf](ecccedf0ed))
* source manually created asset value from valuation rate ([b556b01](b556b012f0))
* **stock:** accept every number a reading can be written as ([fccf122](fccf1220f6))
* **stock:** correct secondary item valuation across stock entry purposes ([4ed0374](4ed03748fe)), closes [#57732](https://github.com/frappe/erpnext/issues/57732) [#57735](https://github.com/frappe/erpnext/issues/57735) [#57736](https://github.com/frappe/erpnext/issues/57736) [#57737](https://github.com/frappe/erpnext/issues/57737) [#57738](https://github.com/frappe/erpnext/issues/57738)
* **stock:** keep manufactured item rate at zero when inputs are free  (backport [#57334](https://github.com/frappe/erpnext/issues/57334)) ([#57513](https://github.com/frappe/erpnext/issues/57513)) ([49924dd](49924ddd1d))
* **stock:** read quality inspection readings in the user's number format ([3b7fb68](3b7fb6851a))
* **stock:** resolve backport conflict in quality inspection imports ([656db1c](656db1c2fe))
* **stock:** scope over deliver/receive role check to delivery and receipt overflow ([4713ddd](4713ddd55b))
* **stock:** validate only the variant attributes that changed ([0013908](00139081f6))
* **stock:** validate over delivery/receipt allowance in stock settings ([3f3292c](3f3292ca4a))
* **stock:** value batched packed-item returns from the original bundle  (backport [#57327](https://github.com/frappe/erpnext/issues/57327)) ([#57511](https://github.com/frappe/erpnext/issues/57511)) ([c0ac8aa](c0ac8aaf86))
* sync translations from crowdin ([#57741](https://github.com/frappe/erpnext/issues/57741)) ([285aec3](285aec3164))
* update stock variance account logic which defaults to default expense ([#57656](https://github.com/frappe/erpnext/issues/57656)) ([d09c04a](d09c04a623))
* use payment entry posting date for received amount exchange rate (backport [#57660](https://github.com/frappe/erpnext/issues/57660)) ([#57663](https://github.com/frappe/erpnext/issues/57663)) ([5595d1e](5595d1ed2f))
* validate account frozen date ([17aeb0b](17aeb0b55b))
* warn about existing draft orders before the supplier selection creates more ([93331a1](93331a1cf0))

### Features

* adjust the ordered quantity in the supplier selection dialog ([2c8c375](2c8c375ca6))
* alert when Required By falls back to today ([1adeb66](1adeb66bdc))
* auto-fill subscription accounting dimensions from plan with item fallback (backport [#57615](https://github.com/frappe/erpnext/issues/57615)) ([#57622](https://github.com/frappe/erpnext/issues/57622)) ([ebba4e9](ebba4e9958))
* order only the items ticked in the supplier selection dialog ([d6ee543](d6ee5436b8))
* select a supplier per item when creating Purchase Orders from Material Request ([95f7810](95f7810948))
* set one supplier across every item in the supplier selection dialog ([9b647be](9b647bed5c))
* show the UOM alongside the quantity in the supplier selection dialog ([5aaefec](5aaefec747))
* status based bar colors in Work Order gantt view (backport [#57634](https://github.com/frappe/erpnext/issues/57634)) ([#57636](https://github.com/frappe/erpnext/issues/57636)) ([2d03d80](2d03d80269))
2026-08-04 20:30:34 +00:00
Diptanil Saha
2769a8c69e Merge pull request #57781 from frappe/version-16-hotfix
chore: release v16
2026-08-05 01:58:06 +05:30
Mohd Haris
5764d1d173 fix: prevent TimestampMismatchError resolving Dunning with multiple overdue installments
`get_linked_dunnings_as_per_state` joins Dunning to its Overdue Payment child
table without DISTINCT. When a Sales Invoice has more than one overdue
installment, its Dunning holds one Overdue Payment row per installment, so the
query returns the same Dunning name once per row.

`update_linked_dunnings` then loads that Dunning name into a separate document
object for each duplicate row and saves each one. The first save bumps the
`modified` timestamp, so the second (now stale) save fails with
`TimestampMismatchError` ("Document has been modified after you have opened
it"). The error is raised on the Dunning while the user is submitting a Payment
Entry, making it confusing, and payments for such invoices cannot be posted at
all.

Add DISTINCT so each linked Dunning is returned (and saved) exactly once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 06bfc23436)
2026-08-04 07:34:27 +00:00
Frappe PR Bot
8378b6e203 chore(release): Bumped to Version 16.30.0
# [16.30.0](https://github.com/frappe/erpnext/compare/v16.29.0...v16.30.0) (2026-07-28)

### Bug Fixes

* add permission check for `get_item_details` (backport [#57515](https://github.com/frappe/erpnext/issues/57515)) ([#57551](https://github.com/frappe/erpnext/issues/57551)) ([7a606ab](7a606ab91c))
* bump item doctype modified timestamp so description change syncs on migrate ([2471c5c](2471c5ccbb))
* **crm:** align Opportunity status checks with Quotation statuses (backport [#57489](https://github.com/frappe/erpnext/issues/57489)) ([#57491](https://github.com/frappe/erpnext/issues/57491)) ([ad9870a](ad9870acc1))
* **crm:** clarify the reason why an opportunity cannot be declared as lost (backport [#57495](https://github.com/frappe/erpnext/issues/57495)) ([#57498](https://github.com/frappe/erpnext/issues/57498)) ([3ea19d8](3ea19d8eb1))
* detect the currency column by fieldtype in trends total row ([a30aac8](a30aac87bf))
* enable the 'Include Zero Stock Items' filter by default to show zero-stock items in the Stock Balance report (backport [#57458](https://github.com/frappe/erpnext/issues/57458)) ([#57460](https://github.com/frappe/erpnext/issues/57460)) ([a0af717](a0af717234))
* enhance growth view filtering by validating period keys ([3f87836](3f87836536))
* force-delete repost data file during cleanup (backport [#57245](https://github.com/frappe/erpnext/issues/57245)) ([32baf6a](32baf6a47c))
* get reserved batch qty precision from settings ([95de237](95de2374ed))
* guard against missing is_your_company_address custom field on address ([2e6b4b5](2e6b4b5838))
* Incorrect creation time at the time cancelling an entry causing an issue especially same posting datetime  (backport [#57380](https://github.com/frappe/erpnext/issues/57380)) ([#57397](https://github.com/frappe/erpnext/issues/57397)) ([84813d7](84813d7f46))
* **item:** correct description on deferred revenue/expense ([ee12f8d](ee12f8d2d4))
* **manufacturing:** exclude corrective job cards from semi-FG aggregate ([ebc8482](ebc8482310))
* **manufacturing:** fall back to UOM Conversion Factor in Production Plan (backport [#57553](https://github.com/frappe/erpnext/issues/57553)) ([#57554](https://github.com/frappe/erpnext/issues/57554)) ([8f36753](8f36753705))
* **manufacturing:** scope BOM Creator tree children to the parent row ([339bb0b](339bb0b4ea)), closes [#57311](https://github.com/frappe/erpnext/issues/57311)
* **manufacturing:** sum semi-FG qty across split job cards ([c411b8e](c411b8e471))
* **manufacturing:** update cost of BOMs created via BOM Creator ([f1a0a5e](f1a0a5e1bf))
* map MT940 per-transaction reference from :61: customer_reference ([b9c4f79](b9c4f790bd))
* map pick list customer to delivery note when no sales order ([3f6501b](3f6501b4ff)), closes [#57412](https://github.com/frappe/erpnext/issues/57412)
* migrate stored AR/AP ageing filter to renamed field ([2e5fc38](2e5fc38179))
* pool batch slot values on every run, not only when negative ([5763378](5763378ee1))
* **quotation:** carry forward communications from opportunity (backport [#57507](https://github.com/frappe/erpnext/issues/57507)) ([#57509](https://github.com/frappe/erpnext/issues/57509)) ([f9b3e42](f9b3e42dcd))
* rebalance batch slot values at the pooled rate when driven negative ([8a50572](8a50572786))
* recalculate operating cost on hour rate change in routing ([c182b40](c182b4085b))
* recover failed POS closings (backport [#57203](https://github.com/frappe/erpnext/issues/57203)) ([#57572](https://github.com/frappe/erpnext/issues/57572)) ([9c946eb](9c946eb168))
* rename misleading filter labels in AR/AP reports ([6d856fa](6d856fa632))
* resolve backport conflicts in item doctype ([0ce5040](0ce50407f9))
* respect selected BOM when creating work order for variant item ([#57359](https://github.com/frappe/erpnext/issues/57359)) ([1132eb1](1132eb1a0f))
* respect user permissions in party dashboard company list ([c79f2e4](c79f2e45a8)), closes [frappe/erpnext#57428](https://github.com/frappe/erpnext/issues/57428)
* **setup:** scope manufacturing warehouse filters to company ([f05e8ed](f05e8ed0ce))
* skip stock expense GL entries for non-stock items ([#57518](https://github.com/frappe/erpnext/issues/57518)) ([861c50e](861c50e727))
* **stock:** narrow legacy serial ledger lookup by item (backport [#57499](https://github.com/frappe/erpnext/issues/57499)) ([#57506](https://github.com/frappe/erpnext/issues/57506)) ([59efe72](59efe7299a))
* stop storing raw title template on subcontracting orders ([0863c1e](0863c1e05c))
* **subcontracting:** release raw-material reservation when closing a subcontracting order ([ee75fac](ee75fac9d7))
* **test:** flaky test in exchange rate revaluation ([484ff8e](484ff8e349))
* typeerror in get_batches_by_oldest for mixed batch expiry ([bbe7580](bbe7580c9d))
* update formatting of growth view for FS report ([da3844c](da3844c4df))
* update operating cost when propagating workstation hour rate to routing ([#57504](https://github.com/frappe/erpnext/issues/57504)) ([39d5fd8](39d5fd84db))
* use company currency instead of global default in report (backport [#56561](https://github.com/frappe/erpnext/issues/56561)) ([9e5f77b](9e5f77b57c))

### Features

* block sales invoice submit when customer overdue exceeds threshold (backport [#57230](https://github.com/frappe/erpnext/issues/57230), [#57298](https://github.com/frappe/erpnext/issues/57298)) ([#57438](https://github.com/frappe/erpnext/issues/57438)) ([d3c5e86](d3c5e866f0)), closes [#52960](https://github.com/frappe/erpnext/issues/52960)
* book Expenses Added To Stock GL entries (backport [#57190](https://github.com/frappe/erpnext/issues/57190) + [#57475](https://github.com/frappe/erpnext/issues/57475)) ([#57503](https://github.com/frappe/erpnext/issues/57503)) ([68caa60](68caa60dfa))
* make Shipping Rule Cost Center optional with company default fallback (backport [#57355](https://github.com/frappe/erpnext/issues/57355)) ([#57385](https://github.com/frappe/erpnext/issues/57385)) ([bbd942c](bbd942c600))
2026-07-28 23:31:26 +00:00
Diptanil Saha
eaf95e5c36 Merge pull request #57548 from frappe/version-16-hotfix
chore: release v16
2026-07-29 04:59:39 +05:30
Frappe PR Bot
a5de60c357 chore(release): Bumped to Version 16.29.0
# [16.29.0](https://github.com/frappe/erpnext/compare/v16.28.0...v16.29.0) (2026-07-22)

### Bug Fixes

* add fetch from in production plan material request child table ([2852671](2852671cd5))
* added missing validations for `Dunning Type` (backport [#57224](https://github.com/frappe/erpnext/issues/57224)) ([#57227](https://github.com/frappe/erpnext/issues/57227)) ([3948951](3948951904))
* allow delivery when a batch is reserved across multiple sales orders (backport [#57169](https://github.com/frappe/erpnext/issues/57169)) ([56bbca0](56bbca0203))
* block changing Stock account type when stock ledger entries exist (backport [#57283](https://github.com/frappe/erpnext/issues/57283)) ([#57285](https://github.com/frappe/erpnext/issues/57285)) ([a26296c](a26296ca50))
* consider min order qty in the purchase/transfer flow of production plan (backport [#57204](https://github.com/frappe/erpnext/issues/57204)) ([cbd4f93](cbd4f93f3d))
* correct typo in allow_negative_stock parameter ([e2be05e](e2be05e48b))
* dont overwrite rate with 0 if not found ([aa08f75](aa08f753b4))
* exclude fully-delivered reservations from the conflict message ([555c607](555c607f2f))
* exclude transferred_qty from work order item to pick list item mapping ([c466b49](c466b49d09)), closes [#57253](https://github.com/frappe/erpnext/issues/57253) [#57236](https://github.com/frappe/erpnext/issues/57236) [#56596](https://github.com/frappe/erpnext/issues/56596)
* hide job card field in purchase order item ([20f6ac8](20f6ac81b9))
* keep Standard Cost stock value in step with the standard rate ([fe65882](fe65882e59))
* name every conflicting voucher in the reserved batch error ([839fd5e](839fd5e3b6))
* parallel reposting stalls between scheduler ticks (backport [#57220](https://github.com/frappe/erpnext/issues/57220)) ([#57248](https://github.com/frappe/erpnext/issues/57248)) ([a3bfded](a3bfdede06))
* pass ctx keys get_price_list_rate_for reads, skip rate update on insert ([c3aea9c](c3aea9ca9c))
* **payments:** ensure `payments` app installed on the site in `payment_app_import_guard` (backport [#57342](https://github.com/frappe/erpnext/issues/57342)) ([#57344](https://github.com/frappe/erpnext/issues/57344)) ([2c8c076](2c8c076f6e))
* permission issue (backport [#57112](https://github.com/frappe/erpnext/issues/57112)) (backport [#57142](https://github.com/frappe/erpnext/issues/57142)) ([#57184](https://github.com/frappe/erpnext/issues/57184)) ([0817d10](0817d1064c))
* project % complete field allowing modification when manual method (backport [#57274](https://github.com/frappe/erpnext/issues/57274)) ([#57276](https://github.com/frappe/erpnext/issues/57276)) ([0d53f1a](0d53f1adb7))
* **project:** improved access control for project users (backport [#56675](https://github.com/frappe/erpnext/issues/56675)) ([#57181](https://github.com/frappe/erpnext/issues/57181)) ([b1adec7](b1adec7e9e))
* **report:** handle nonetype error in timesheet billing summary grouping logic ([b7cf3bf](b7cf3bf641))
* rescale batch FIFO slot values on stock reconciliation ([6b3b03f](6b3b03fcd8))
* rescale stock ageing FIFO slot values on stock reconciliation ([c9394c0](c9394c030f))
* resolve float precision before streaming stock ledger entries ([0f25254](0f252542c3))
* restrict jinja globals in process statement of accounts templates (backport [#56458](https://github.com/frappe/erpnext/issues/56458)) ([#57232](https://github.com/frappe/erpnext/issues/57232)) ([5f6952b](5f6952b15c))
* revalue batch reco slots only when the entry covers the full batch ([a4bf506](a4bf50656a))
* round outstanding qty guard consistently with the conflict gate ([f3e1b3f](f3e1b3fca7))
* set correct currency in supplier quotation net rate field ([e24ef84](e24ef847dc))
* show transaction currency symbol in Payment Request schedule dialog and reference table (backport [#57050](https://github.com/frappe/erpnext/issues/57050)) ([#57312](https://github.com/frappe/erpnext/issues/57312)) ([429b58b](429b58b833))
* **stock:** correct stock ageing value for moving average and lifo items ([#56693](https://github.com/frappe/erpnext/issues/56693)) ([02549ef](02549ef8c4))
* strip account number when building account name in COA importer ([e1ebfa7](e1ebfa7163))
* sync process loss percentage when fg qty changes ([beeffee](beeffee8f9))
* sync translations from crowdin ([#57187](https://github.com/frappe/erpnext/issues/57187)) ([827831a](827831a247))
* use system float precision for batch qty comparison ([92217b2](92217b2c45))
* validate buying price list on material request and update item rates on change ([025b2f2](025b2f2922))

### Features

* add on hold status to project ([57a49ce](57a49ce168))
* recalculate valuation rate and stock value from Bin ([a38cbfc](a38cbfc88e))
* **stock:** automatically link portal users to their associated contact profiles for customers and suppliers ([134d63d](134d63de78))
* **stock:** expose all Bin qty fields in Stock Summary and Stock Projected Qty ([0bdf258](0bdf258888))
2026-07-22 00:17:03 +00:00
Diptanil Saha
264bfa188b Merge pull request #57324 from frappe/version-16-hotfix
chore: release v16
2026-07-22 05:45:21 +05:30
Frappe PR Bot
de591661b9 chore(release): Bumped to Version 16.28.0
# [16.28.0](https://github.com/frappe/erpnext/compare/v16.27.0...v16.28.0) (2026-07-15)

### Bug Fixes

* **accounts:** added permission checks on `get_account_balances_coa` (backport [#57107](https://github.com/frappe/erpnext/issues/57107)) ([#57122](https://github.com/frappe/erpnext/issues/57122)) ([e8a5325](e8a532587d))
* allow barcode scan to add and increment items in pick list ([00a646a](00a646ac25))
* apply precision fix inline for v16-hotfix, drop develop-only asset_service refactor ([d3a8e91](d3a8e91cda))
* **asset:** allow asset repair creation for fully depreciated assets ([8658039](8658039e9a))
* **budget-variance:** correct month shift in comparison chart ([703e9a7](703e9a728c))
* guard company logo lookup in default letterheads ([7b54314](7b543142a2))
* **journal-entry:** fetch outstanding on foreign currency ([ec782ee](ec782ee20d))
* **manufacturing:** preserve job card transferred quantity ([d7e9321](d7e9321746))
* match depreciation schedule rows at currency precision to avoid duplicate JEs ([a1fae95](a1fae959ed))
* prevent duplicate material request items in purchase order ([4d951c1](4d951c1cf8))
* set explicit table and logo widths in grey letterhead ([0d80fef](0d80fef3bf))
* **stock:** fix sqlparse token limit in get_bundle_wise_serial_nos ([5a99dd6](5a99dd6016))
* **stock:** set stock_uom on transferred Stock Reservation Entries ([5991ecf](5991ecfa3d))
* **stock:** show qty (company) and qty (warehouse) in sales transactions ([0d9ace7](0d9ace7ab8))
* **tnc:** `get_terms_and_conditions` render_template with `safe_exec` (backport [#56944](https://github.com/frappe/erpnext/issues/56944)) ([#56977](https://github.com/frappe/erpnext/issues/56977)) ([40c85a0](40c85a0087))

### Features

* **balance-sheet:** implement execute_synced_report with full parity to normal report ([636bcbe](636bcbedc0))
* **general-ledger:** implement execute_synced_report with full parity to normal report ([19ec095](19ec095ff8))
* **profit-and-loss:** implement execute_synced_report with full parity to normal report ([21ddb00](21ddb00e20))
* **trial-balance:** implement execute_duckdb with full parity to normal report ([228418b](228418b05f))
* weekly auto-repost of incorrect stock valuation entries (backport [#56637](https://github.com/frappe/erpnext/issues/56637)) ([#56700](https://github.com/frappe/erpnext/issues/56700)) ([bf1b7f2](bf1b7f2bea))
2026-07-15 02:24:51 +00:00
ruthra kumar
9a7e796fd2 Merge pull request #57161 from frappe/version-16-hotfix
chore: release v16
2026-07-15 06:17:14 +05:30
Frappe PR Bot
9d5c7605b8 chore(release): Bumped to Version 16.27.0
# [16.27.0](https://github.com/frappe/erpnext/compare/v16.26.2...v16.27.0) (2026-07-13)

### Bug Fixes

* **`frappe_crm_api`:** handle failure for `after_app_install` and `after_app_uninstall` ([eac3afc](eac3afcd88))
* **accounts:** clear clearance date when amending reconciled voucher ([6f28135](6f281356a6)), closes [#54909](https://github.com/frappe/erpnext/issues/54909)
* **accounts:** retain invoice table on opening invoice creation error (backport [#56353](https://github.com/frappe/erpnext/issues/56353)) ([#57058](https://github.com/frappe/erpnext/issues/57058)) ([4136ca5](4136ca55a7))
* add page numbers to print format footer ([0371e8e](0371e8eaf0))
* added permission checks on various whitelisted functions (backport [#56745](https://github.com/frappe/erpnext/issues/56745)) ([#56946](https://github.com/frappe/erpnext/issues/56946)) ([7ce1289](7ce1289c10))
* address review comments ([056195c](056195ce07))
* auto fetch serial no from previous operation output (backport to v16) ([#56861](https://github.com/frappe/erpnext/issues/56861)) ([17f2de4](17f2de42f3))
* **banking:** allow negative balance in bank statement import (backport [#56959](https://github.com/frappe/erpnext/issues/56959)) ([#57054](https://github.com/frappe/erpnext/issues/57054)) ([835e32c](835e32cec7))
* **banking:** handle blank password protected PDFs and negative amounts in CR/DR columns (backport [#56690](https://github.com/frappe/erpnext/issues/56690)) ([#56694](https://github.com/frappe/erpnext/issues/56694)) ([19c318d](19c318df68))
* **banking:** use custom renderer for translated strings and parser for rules (backport [#56643](https://github.com/frappe/erpnext/issues/56643)) ([#56647](https://github.com/frappe/erpnext/issues/56647)) ([2cb577b](2cb577b912))
* block serialized to non-serialized item change when SABB exists (backport [#56773](https://github.com/frappe/erpnext/issues/56773)) ([#56775](https://github.com/frappe/erpnext/issues/56775)) ([19d03fe](19d03fee46))
* clear stray permission message when item dashboard has no warehouse access ([a39753e](a39753ee08))
* **company:** ignore user permissions for link fields having link to `Account` and `Cost Center` (backport [#56748](https://github.com/frappe/erpnext/issues/56748)) ([#56795](https://github.com/frappe/erpnext/issues/56795)) ([003b655](003b6554c4))
* correct filter handling in Sales Person-wise Transaction Summary + tests (backport [#56783](https://github.com/frappe/erpnext/issues/56783)) ([#56908](https://github.com/frappe/erpnext/issues/56908)) ([24f0989](24f0989ac1))
* **crm_settings:** skip allowed users check when frappe crm is installed locally ([dfe4d5a](dfe4d5ab73))
* display outstanding amount using company default currency (backport [#56785](https://github.com/frappe/erpnext/issues/56785)) ([#57009](https://github.com/frappe/erpnext/issues/57009)) ([bdba4c8](bdba4c8091))
* don't attempt to create SABB for non-serialized / non-batch items (backport [#56627](https://github.com/frappe/erpnext/issues/56627)) ([#56803](https://github.com/frappe/erpnext/issues/56803)) ([8c56a5a](8c56a5ac0c))
* don't treat batch slot at FIFO queue head as qty slot ([20f6dd0](20f6dd0224))
* enforce read permission on ledger preview endpoints ([#57039](https://github.com/frappe/erpnext/issues/57039)) ([5642359](5642359c10))
* fetch payment entry reference amounts from invoice (backport [#56928](https://github.com/frappe/erpnext/issues/56928)) ([#57042](https://github.com/frappe/erpnext/issues/57042)) ([67c85ef](67c85ef0af))
* FIFO queue checks and incorrect entries filter in stock ledger reports ([3571512](3571512101)), closes [#43619](https://github.com/frappe/erpnext/issues/43619)
* **financial_statement:** render columnar financial statements instea… (backport [#56921](https://github.com/frappe/erpnext/issues/56921)) ([#57053](https://github.com/frappe/erpnext/issues/57053)) ([e6a6c13](e6a6c13355))
* for purchases do voucher based reposting (backport [#56601](https://github.com/frappe/erpnext/issues/56601)) ([#56608](https://github.com/frappe/erpnext/issues/56608)) ([e1e6176](e1e6176ddc))
* guard against missing DocType in onboarding steps patch ([#56804](https://github.com/frappe/erpnext/issues/56804)) ([f72289e](f72289e27c))
* **item-attribute:** clear attribute values when marking numeric ([374b340](374b340e73))
* **item:** error on uncommitted input and escape values in variant dialog ([04c834d](04c834d6a9))
* **item:** rework multiple variant dialog for large numeric ranges ([025d0cd](025d0cd7f3))
* make represents company field in purchase invoice ignore user permissions ([4aed77d](4aed77df0c))
* make trend report based-on and group-by column labels translatable ([b8199d8](b8199d88b6))
* map stock_qty in apply_price_list_on_item (backport [#56869](https://github.com/frappe/erpnext/issues/56869)) ([#57052](https://github.com/frappe/erpnext/issues/57052)) ([240fb2c](240fb2c4b8))
* partial delivery note against pick list (backport [#56985](https://github.com/frappe/erpnext/issues/56985)) ([#57006](https://github.com/frappe/erpnext/issues/57006)) ([20255a8](20255a8a7f))
* **patch:** moved create_company_custom_fields from `pre_model_sync` to `post_model_sync` (backport [#56962](https://github.com/frappe/erpnext/issues/56962)) ([#56965](https://github.com/frappe/erpnext/issues/56965)) ([14efd14](14efd14384))
* **payment reconciliation:** honour user permissions on accounting di… ([#56560](https://github.com/frappe/erpnext/issues/56560)) ([8eb92b8](8eb92b8b18))
* precision issue causing reconciliation error ([#54043](https://github.com/frappe/erpnext/issues/54043)) ([20df15b](20df15b3ac))
* prevent repeatable read related concurrency errors ([019b02a](019b02adcd))
* race condition and repeatable read in process pcv ([6d9f5fa](6d9f5fac76))
* recompute transferred qty before deciding work order status ([bc952f4](bc952f40d9))
* remove company default on cost center in stock entry detail ([a1fe6ce](a1fe6cee5d))
* remove incorrect Payable account_type from Customer Deposits in Philippines CoA (backport [#57018](https://github.com/frappe/erpnext/issues/57018)) ([#57061](https://github.com/frappe/erpnext/issues/57061)) ([edfbc71](edfbc71135))
* rename variant item_code/item_name when attribute abbreviation changes ([97cd1e7](97cd1e7144))
* render letter head footer in print formats ([fa4d32d](fa4d32dcdb))
* replay immutable SLE qty for serial/batch bundle valuation (backport [#56814](https://github.com/frappe/erpnext/issues/56814)) ([#56835](https://github.com/frappe/erpnext/issues/56835)) ([2264e25](2264e25cc6))
* resolve conflicts ([3aad7fe](3aad7fee24))
* restore Save button on reverse journal entry (backport [#56770](https://github.com/frappe/erpnext/issues/56770)) ([#56772](https://github.com/frappe/erpnext/issues/56772)) ([4573cd1](4573cd15a9))
* **selling:** update sales order per billed on credit note submission ([224cf19](224cf19f33))
* set conversion_rate on quotation created from customer ([26905bc](26905bc142))
* show only template items in Variant Of filter ([bebe011](bebe011636))
* skip item prices tab render for users without item price read access ([c964811](c964811293))
* skip stock reservation for opted-out production plans ([17598e2](17598e2626)), closes [#56798](https://github.com/frappe/erpnext/issues/56798)
* **stock:** backfill transferred qty for existing pick lists ([903d78c](903d78cc43))
* **stock:** link job card in stock entry created from pick list (backport [#57031](https://github.com/frappe/erpnext/issues/57031)) ([755b9cc](755b9ccbc3))
* **stock:** pick list serial batch posting date ([#57015](https://github.com/frappe/erpnext/issues/57015)) ([7e46be2](7e46be2a33)), closes [#56951](https://github.com/frappe/erpnext/issues/56951)
* **stock:** propagate project from job card to stock entry ([51d2077](51d2077eed))
* **stock:** support quality inspection for stock entry by purpose (backport [#56446](https://github.com/frappe/erpnext/issues/56446)) ([40ca3b5](40ca3b5e5d))
* update BOM operations when routing is changed ([b2e6a39](b2e6a39743))
* update events order by date asc (backport [#56963](https://github.com/frappe/erpnext/issues/56963)) ([#57056](https://github.com/frappe/erpnext/issues/57056)) ([e2fd061](e2fd061b3d))
* update modified timestamp in item.json ([f602ee0](f602ee0e73))
* use live source warehouse valuation for internal transfer purchase receipts (backport [#56431](https://github.com/frappe/erpnext/issues/56431)) ([#56677](https://github.com/frappe/erpnext/issues/56677)) ([1da28f2](1da28f2278))
* validate planned end date is not before planned start date in work order ([745baad](745baad0d1))
* validate reverse GL entries on current date under immutable ledger (backport [#56709](https://github.com/frappe/erpnext/issues/56709)) ([#56752](https://github.com/frappe/erpnext/issues/56752)) ([eea4756](eea4756ce9))
* validate template and its variant in the same Pricing Rule ([159a253](159a2538da))

### Features

* capture user persona during setup (backport [#56705](https://github.com/frappe/erpnext/issues/56705)) ([#56707](https://github.com/frappe/erpnext/issues/56707)) ([d9aa449](d9aa4491f1))
* **crm_settings:** auto-update crm sync settings on frappe crm install and uninstall ([cb2a930](cb2a930a32))
* explain FIFO allocation of fixed Discount Amount on Sales Order ([#56436](https://github.com/frappe/erpnext/issues/56436)) ([62fed1d](62fed1d562))
* **manufacturing:** create material request for raw materials from work order ([#56980](https://github.com/frappe/erpnext/issues/56980)) ([144f92d](144f92d58e))
* **sla:** filter service level agreement link by document type (backport [#56954](https://github.com/frappe/erpnext/issues/56954)) ([#56956](https://github.com/frappe/erpnext/issues/56956)) ([393b5d1](393b5d1f74))
* **stock:** support partial transfer from pick list ([af495ed](af495ed253))

### Performance Improvements

* avoid per-row Warehouse doc fetches in auto reorder job ([39b5e12](39b5e12305))
* batch bin lookups in delivery note stock update ([697e2c0](697e2c0b66))
2026-07-13 07:41:31 +00:00
Diptanil Saha
f94eee3197 Merge pull request #56931 from frappe/version-16-hotfix
chore: release v16
2026-07-13 12:09:52 +05:30
384 changed files with 239760 additions and 84595 deletions

View File

@@ -4,6 +4,36 @@ set -e
cd ~ || exit
# Authenticate git against github.com with the job token: anonymous git-over-HTTPS from the
# runners gets throttled to a 401, which kills whichever clone is in flight — the frappe fetch
# below, or payments under `bench get-app`. See the PR description.
#
# A credential helper rather than a url.insteadOf rewrite, because `git clone` PERSISTS a
# rewritten URL into the new repo's .git/config: an insteadOf would leave the token sitting in
# apps/payments/.git/config on the runner. A helper is consulted only when github.com actually
# challenges, and leaves the stored remote URL untouched. Passing it through GIT_CONFIG_* keeps
# the token out of ~/.gitconfig too, and child processes inherit it (bench shells out to git).
ci_github_token=${CI_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}
if [ -n "$ci_github_token" ]; then
export CI_GITHUB_TOKEN="$ci_github_token"
export GIT_CONFIG_COUNT=3
# Reset first: git runs EVERY configured helper and calls `store` on them after a successful
# auth, so a `credential.helper=store` inherited from the image's gitconfig would write the
# token to ~/.git-credentials. An empty value clears the list before ours is added.
export GIT_CONFIG_KEY_0="credential.helper"
export GIT_CONFIG_VALUE_0=""
export GIT_CONFIG_KEY_1="credential.https://github.com.username"
export GIT_CONFIG_VALUE_1="x-access-token"
export GIT_CONFIG_KEY_2="credential.https://github.com.helper"
# Single-quoted: $CI_GITHUB_TOKEN is expanded by the shell git runs the helper in, so the
# token is read from the environment at call time and never stored anywhere. Answering only
# `get` makes the helper inert for git's `store`/`erase` calls.
export GIT_CONFIG_VALUE_2='!f() { test "$1" = get && echo "password=$CI_GITHUB_TOKEN"; }; f'
fi
# Whatever happens, never sit on a credential prompt: fail fast and legibly instead.
export GIT_TERMINAL_PROMPT=0
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
frappeuser=${FRAPPE_USER:-"frappe"}
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}

View File

@@ -0,0 +1,54 @@
name: Upload main.pot to Crowdin
on:
push:
branches:
- develop
- version-16-hotfix
paths:
- "erpnext/locale/main.pot"
workflow_dispatch:
concurrency:
group: crowdin-upload-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
jobs:
upload-sources:
name: Upload sources from ${{ github.ref_name }}
runs-on: ubuntu-latest
steps:
- name: Checkout ${{ github.ref_name }}
uses: actions/checkout@v6
- name: Restore Crowdin cache
uses: actions/cache/restore@v6
with:
path: .crowdin
key: crowdin-${{ github.ref_name }}
restore-keys: crowdin-${{ github.ref_name }}-
- name: Upload main.pot to Crowdin
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
with:
config: crowdin.yml
upload_sources: true
upload_translations: false
download_translations: false
create_pull_request: false
crowdin_branch_name: "[frappe.erpnext] ${{ github.ref_name }}"
upload_sources_args: "--cache"
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Save Crowdin cache
uses: actions/cache/save@v6
if: always()
with:
path: .crowdin
key: crowdin-${{ github.ref_name }}-${{ github.run_id }}

View File

@@ -105,6 +105,8 @@ jobs:
env:
DB: mariadb
TYPE: server
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
- name: Run Patch Tests
run: |

View File

@@ -22,4 +22,4 @@ jobs:
pull-requests: write
steps:
- uses: alyf-de/po-review-action@v1.1.0
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1

View File

@@ -129,6 +129,8 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
- name: Run Tests
run: |

View File

@@ -13,6 +13,7 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- '**.po'
permissions:
contents: read

View File

@@ -13,6 +13,7 @@ on:
- 'crowdin.yml'
- '.coderabbit.yml'
- '.mergify.yml'
- '**.po'
schedule:
# Run everday at midnight UTC / 5:30 IST
- cron: "0 0 * * *"
@@ -101,6 +102,8 @@ jobs:
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
# Anonymous git to github.com gets throttled to a 401; authenticate the clones.
CI_GITHUB_TOKEN: ${{ github.token }}
DB_HOST: 127.0.0.1
DB_USER_HOST: '%'
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb

View File

@@ -24,10 +24,10 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.17.0",
"frappe-react-sdk": "^1.17.1",
"fuse.js": "^7.3.0",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.2",
"jotai-family": "^1.1.0",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.6.1",
@@ -39,7 +39,7 @@
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^8.1.0",
"react-router": "^8.3.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
@@ -48,18 +48,18 @@
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^3.1.1",
"vite": "^8.0.16"
"vite": "^8.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^9.39.5",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.62.1"
"typescript-eslint": "^8.67.0"
}
}

View File

@@ -9,6 +9,7 @@ import Fuse from "fuse.js"
import { ChevronDownIcon } from "lucide-react"
import { useLayoutEffect, useMemo, useRef, useState } from "react"
import { FormControl } from "../ui/form"
import useResetScrollOnSearch from "@/hooks/useResetScrollOnSearch"
export interface AccountsDropdownProps {
@@ -104,6 +105,10 @@ const AccountsDropdown = ({ root_type, report_type, account_type, value, onChang
const buttonRef = useRef<HTMLButtonElement>(null)
// Searching replaces the grouped list with a short result list, so pin the scroll back to
// the top - otherwise the auto-selected first result can be out of view.
const listRef = useResetScrollOnSearch(search)
const [width, setWidth] = useState(320)
useLayoutEffect(() => {
@@ -153,7 +158,7 @@ const AccountsDropdown = ({ root_type, report_type, account_type, value, onChang
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
<Command shouldFilter={false} className="w-full">
<CommandInput placeholder={_("Search account...")} onValueChange={setSearch} value={search} />
<CommandList>
<CommandList ref={listRef}>
<CommandEmpty>{_("No accounts found.")}</CommandEmpty>
{recommendedAccounts.length > 0 && (

View File

@@ -10,6 +10,7 @@ import { ChevronDownIcon, ExternalLink } from "lucide-react";
import { Button } from "../ui/button";
import { cn } from "@/lib/utils";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command";
import useResetScrollOnSearch from "@/hooks/useResetScrollOnSearch";
import _ from "@/lib/translate";
import ErrorBanner from "../ui/error-banner";
import MarkdownRenderer from "../ui/markdown";
@@ -149,6 +150,10 @@ const LinkFieldCombobox = ({
const buttonRef = useRef<HTMLButtonElement>(null)
// Results change as the search runs, so pin the scroll back to the top to keep the
// auto-selected first result in view.
const listRef = useResetScrollOnSearch(searchInput)
const [width, setWidth] = useState(320)
useLayoutEffect(() => {
@@ -264,7 +269,7 @@ const LinkFieldCombobox = ({
{error && <ErrorBanner error={error} />}
<Command shouldFilter={false} className="w-full">
<CommandInput placeholder={placeholder} onValueChange={setSearchInput} />
<CommandList>
<CommandList ref={listRef}>
<CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty>
<CommandGroup>
{items?.map((result) => (
@@ -272,7 +277,7 @@ const LinkFieldCombobox = ({
<span className="font-medium">
{result.label || result.value}
</span>
{result.description && <span className="text-xs text-ink-gray-5">
{result.description && <span className="text-p-xs text-ink-gray-5">
<MarkdownRenderer content={result.description} />
</span>}
</CommandItem>

View File

@@ -6,13 +6,13 @@ import { Progress } from "@/components/ui/progress"
import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils"
import { flt, formatCurrency } from "@/lib/numbers"
import { Skeleton } from "@/components/ui/skeleton"
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
import { Edit, Info, Trash2 } from "lucide-react"
import { H4, Paragraph } from "@/components/ui/typography"
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
import { getCompanyCurrency } from "@/lib/company"
import _ from "@/lib/translate"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { formatDate } from "@/lib/date"
import { Form } from "@/components/ui/form"
@@ -26,50 +26,109 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { toast } from "sonner"
import ErrorBanner from "@/components/ui/error-banner"
const BankBalance = () => {
const useBankCurrency = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
return bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
}
/**
* One line of the balance summary - label on the left, figure right-aligned.
*
* `items-baseline` keeps the figure on the label's FIRST line, so a row carrying a `subLabel`
* (the statement row's "As of <date>" note) doesn't centre its value against both lines.
*/
const BalanceRow = ({ label, info, subLabel, emphasis, children }: {
label: React.ReactNode
info?: React.ReactNode
subLabel?: React.ReactNode
emphasis?: boolean
children: React.ReactNode
}) => (
<div className="flex items-baseline justify-between gap-3">
<span className="flex min-w-0 flex-col gap-1.5">
<span className={cn("flex items-center gap-1 whitespace-nowrap text-xs text-ink-gray-6",
emphasis && "font-medium text-ink-gray-7")}>
{label}
{info}
</span>
{subLabel}
</span>
<div className="flex flex-col items-end">{children}</div>
</div>
)
/**
* Type styles for a figure. Shared so an interactive figure can put them on the <button>
* ITSELF rather than on a nested span: Tailwind's preflight sets `font: inherit` on buttons,
* which resets line-height too, so a button wrapping a `text-sm` span gets a taller strut than
* the span and the row grows - visible as extra space above a baseline-aligned row.
*/
const BALANCE_VALUE_CLASSES = "font-numeric text-sm tabular-nums text-ink-gray-8"
const BalanceValue = ({ children, emphasis, tone, className }: { children: React.ReactNode, emphasis?: boolean, tone?: 'red', className?: string }) => (
<span className={cn(BALANCE_VALUE_CLASSES,
emphasis && "font-semibold",
tone === 'red' && "text-ink-red-3",
className)}>
{children}
</span>
)
const BalanceSkeleton = () => <Skeleton className="h-4 w-24 rounded-sm" />
/**
* Balances and progress for the selected bank account, laid out like the totals block of an
* invoice. This sits beside the bank picker rather than in a row of its own (saves vertical
* space) and outside the picker's horizontal scroll area, so the figures being reconciled
* against can never scroll out of view.
*/
const BankAccountBalancePanel = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
if (!bankAccount) {
return null
}
return (
<div className="flex justify-between">
<div className="w-[80%] flex flex-wrap justify-between gap-2 pe-8 border-e-border border-e">
<OpeningBalance />
<ClosingBalance />
<ClosingBalanceAsPerStatement />
<Difference />
</div>
<ReconcileProgress />
return (
<div className="flex w-72 shrink-0 flex-col justify-center gap-2.5 border-s border-outline-gray-2 ps-4">
{/* Names the account these figures belong to - the picker scrolls, so the
highlighted card can't be relied on as the referent. */}
<span
className="truncate text-xs font-medium text-ink-gray-7"
title={bankAccount.account_name}>
{bankAccount.account_name}
</span>
<OpeningBalanceRow />
<SystemClosingBalanceRow />
<StatementClosingBalanceRow />
<Separator />
<DifferenceRow />
<ReconciledRow />
</div>
)
}
const OpeningBalance = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const OpeningBalanceRow = () => {
const currency = useBankCurrency()
const { data, isLoading } = useGetAccountOpeningBalance()
return <StatContainer className="min-w-48">
<StatLabel>{_("Opening Balance")}</StatLabel>
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
</StatContainer>
return <BalanceRow label={_("Opening Balance")}>
{isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
</BalanceRow>
}
const ClosingBalance = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const SystemClosingBalanceRow = () => {
const currency = useBankCurrency()
const { data, isLoading } = useGetAccountClosingBalance()
return (
<StatContainer className="min-w-48">
<div className="flex items-start gap-1">
<StatLabel>
{_("Closing Balance as per system")}
</StatLabel>
<BalanceRow
label={_("Closing (system)")}
info={
<HoverCard openDelay={100}>
<HoverCardTrigger>
<Info className="size-3.5 text-ink-gray-6 -mt-px" />
<Info className="size-3.5 text-ink-gray-6" />
</HoverCardTrigger>
<HoverCardContent className="w-96" align="start" side="right">
<H4 className="text-base">{_("Closing balance as per system")}</H4>
@@ -84,15 +143,111 @@ const ClosingBalance = () => {
</Paragraph>
</HoverCardContent>
</HoverCard>
</div>
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
</StatContainer>
}
>
{isLoading ? <BalanceSkeleton /> : <BalanceValue>{formatCurrency(flt(data?.message, 2), currency)}</BalanceValue>}
</BalanceRow>
)
}
const Difference = () => {
const StatementClosingBalanceRow = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const currency = useBankCurrency()
const dates = useAtomValue(bankRecDateAtom)
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
onSuccess: (data) => {
if (data?.message && data?.message?.balance) {
setValue({
value: data?.message?.balance,
stringValue: data?.message?.balance.toString()
})
}
}
})
const isDateSame = data?.message?.date === dates.toDate
// The server uses the returned date to distinguish an unset balance from a saved zero.
const hasBalance = Boolean(data?.message?.date)
const [isOpen, setIsOpen] = useState(false)
const tooltip = hasBalance
? _("Click to change the closing balance as per statement")
: _("Click to set the closing balance as per statement")
return (
<BalanceRow
label={_("Closing (statement)")}
// The pencil sits beside the label, mirroring the info icon on the row above, so
// the figure stays a plain right-aligned number in line with every other row.
info={
<Tooltip>
<TooltipTrigger asChild>
{/* `p-0`: Tailwind's preflight gives buttons `appearance: button` but
doesn't reset padding, so a bare button picks up the UA's ~1px 6px
and knocks this row out of step with its neighbours. */}
<button
type='button'
aria-label={tooltip}
onClick={() => setIsOpen(true)}
className="cursor-pointer p-0 text-ink-gray-5 transition-colors hover:text-ink-gray-7">
<Edit className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
}
subLabel={!isDateSame && data?.message.date
? <span className="whitespace-nowrap text-2xs font-medium text-ink-red-3">
{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}
</span>
: undefined}
>
{/* Deliberately NOT a flex container: a flex box's baseline doesn't resolve to its
text, so the row's `items-baseline` couldn't line this up with the label. As a
plain inline button its baseline is the figure's own, like every other row.
"Set" gets the same treatment as a figure - it stands in for one. */}
{isLoading
? <BalanceSkeleton />
: <Tooltip>
<TooltipTrigger asChild>
{/* The figure styles live on the button itself - see
BALANCE_VALUE_CLASSES. `p-0` because preflight leaves the UA's
button padding in place. */}
<button
type='button'
aria-label={tooltip}
onClick={() => setIsOpen(true)}
className={cn(BALANCE_VALUE_CLASSES,
"cursor-pointer p-0 underline decoration-outline-gray-5 decoration-dashed underline-offset-4",
"transition-colors hover:decoration-ink-gray-8")}>
{hasBalance ? formatCurrency(flt(data?.message?.balance, 2), currency) : _("Set")}
</button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="min-w-xl">
<ClosingBalanceForm
defaultBalance={data?.message?.balance ?? 0}
date={dates.toDate}
bankAccount={bankAccount}
onClose={() => setIsOpen(false)}
/>
</DialogContent>
</Dialog>
</BalanceRow>
)
}
const DifferenceRow = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const currency = useBankCurrency()
const { data, isLoading } = useGetAccountClosingBalance()
@@ -102,16 +257,15 @@ const Difference = () => {
const isError = difference !== 0
return <StatContainer className="w-fit text-end sm:min-w-56">
<StatLabel className="text-end">{_("Difference")}</StatLabel>
{isLoading ? <Skeleton className="w-[150px] h-5 self-end rounded-sm" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}>
{formatCurrency(difference,
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))
}</StatValue>}
</StatContainer>
return <BalanceRow label={_("Difference")} emphasis>
{isLoading
? <BalanceSkeleton />
: <BalanceValue emphasis tone={isError ? 'red' : undefined}>{formatCurrency(difference, currency)}</BalanceValue>}
</BalanceRow>
}
const ReconcileProgress = () => {
/** Reconciliation progress through the selected date range: a count plus a slim bar. */
const ReconciledRow = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -132,75 +286,14 @@ const ReconcileProgress = () => {
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
return <div className="w-[18%] flex flex-col gap-1 items-end">
<div className="w-full">
<Progress
value={progress}
max={100}
size="md"
label="Progress"
hint
hintText={`${reconciledCount} / ${totalCount} ${_("reconciled")}`} />
</div>
return <div className="flex flex-col gap-1.5">
<BalanceRow label={_("Reconciled")}>
<BalanceValue>{reconciledCount} / {totalCount ?? 0}</BalanceValue>
</BalanceRow>
<Progress value={progress} max={100} size="sm" />
</div>
}
const ClosingBalanceAsPerStatement = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const dates = useAtomValue(bankRecDateAtom)
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
onSuccess: (data) => {
if (data?.message && data?.message?.balance) {
setValue({
value: data?.message?.balance,
stringValue: data?.message?.balance.toString()
})
}
}
})
const isDateSame = data?.message?.date === dates.toDate
const [isOpen, setIsOpen] = useState(false)
return <StatContainer className="min-w-48">
<StatLabel>{_("Closing Balance as per statement")}</StatLabel>
<div className="flex flex-col gap-2 items-start">
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button">
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
<Edit className="w-4 h-4" />
</div>
</TooltipTrigger>
<TooltipContent>
{_("Click to set the closing balance as per statement")}
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent className="min-w-xl">
<ClosingBalanceForm
defaultBalance={data?.message?.balance ?? 0}
date={dates.toDate}
bankAccount={bankAccount}
onClose={() => setIsOpen(false)}
/>
</DialogContent>
</Dialog>
{!isDateSame && data?.message.date && <span className="text-xs font-medium text-ink-red-3">{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}</span>}
</div>
</StatContainer>
}
const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => {
const { mutate } = useSWRConfig()
@@ -302,7 +395,7 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
return <div>
<Separator className="my-8" />
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
<p className="text-p-sm text-center pb-2">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
<Table>
<TableHeader>
<TableRow>
@@ -331,4 +424,4 @@ const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank
}
export default BankBalance
export default BankAccountBalancePanel

View File

@@ -205,9 +205,9 @@ const BankClearanceSummaryView = () => {
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
<div>
<div className="shrink-0">
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
@@ -220,8 +220,9 @@ const BankClearanceSummaryView = () => {
data={data.message.result}
columns={clearanceColumns}
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
maxHeight="calc(100vh - 200px)"
scrollAreaClassName="min-h-[calc(100vh-200px)]"
className="min-h-0 flex-1"
maxHeight="none"
scrollAreaClassName="flex-1"
emptyState={_("No rows to display.")}
/>
) : null}

View File

@@ -74,7 +74,10 @@ const BankPicker = ({ className }: { className?: string }) => {
}
return (
<div
className={cn("flex gap-3 items-stretch w-full overflow-x-auto pe-4",
// No trailing padding: it would sit inside the fade region, so the mask would
// spend itself on empty space and the last card would stop short of the balance
// panel instead of fading towards it. The column gap provides the separation.
className={cn("flex gap-3 items-stretch w-full overflow-x-auto scroll-fade-x",
banks?.length > 4 ? 'pb-2' : '', className,
)}
style={{
@@ -108,12 +111,12 @@ const BankPickerItem = ({ bank }: { bank: SelectedBank }) => {
role="button"
title={`Select ${bank.account_name}`}
onClick={onSelect}
className={cn('rounded-md border border-outline-gray-1 max-w-60 min-w-60 p-2 overflow-hidden cursor-pointer',
// `shrink-0`: this is a horizontally scrolling row, so cards keep their own width
// instead of being compressed to fit the container.
className={cn('w-60 shrink-0 rounded-md border border-outline-gray-1 p-2 overflow-hidden cursor-pointer transition-colors',
isSelected ? 'border-outline-gray-5 bg-surface-gray-1' : 'hover:bg-surface-gray-1'
)}
>
<BankLogo bank={bank} className="mb-2" />
<div className="flex flex-col gap-1">

View File

@@ -5,107 +5,179 @@ import { AVAILABLE_TIME_PERIODS, formatDate, getDatesForTimePeriod, TimePeriod }
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react'
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { parse } from "chrono-node"
import { Calendar } from '@/components/ui/calendar'
import useFiscalYear from '@/hooks/useFiscalYear'
import dayjs from 'dayjs'
import _ from '@/lib/translate'
import { useDirection } from '@/components/ui/direction'
import useResetScrollOnSearch from '@/hooks/useResetScrollOnSearch'
const DATE_FORMAT = 'YYYY-MM-DD'
/** Current fiscal year plus this many previous ones, for quarter/year options. */
const PREVIOUS_FISCAL_YEARS = 2
type DateOption = {
/** Stable id - used as the cmdk value and the React key. */
key: string
label: string
translatedLabel: string
fromDate: string
toDate: string
format: string
/** Extra terms to match against, beyond the labels and dates. */
keywords?: string[]
/** Whether to show this option when the search box is empty. */
isDefault?: boolean
}
/**
* Fiscal years keep the same month/day boundaries year on year, so previous years can be
* derived by subtracting whole years instead of fetching them. Works for both Jan-Dec and
* Apr-Mar style fiscal years.
*/
const fiscalYearLabel = (start: dayjs.Dayjs, end: dayjs.Dayjs) =>
start.year() === end.year() ? `${start.year()}` : `${start.year()}-${end.year()}`
const BankRecDateFilter = () => {
const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom)
const { data: fiscalYear } = useFiscalYear()
const { fiscalYear } = useFiscalYear()
const timePeriodOptions = useMemo(() => {
const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => {
const today = useMemo(() => dayjs().format(DATE_FORMAT), [])
const allOptions = useMemo(() => {
const standardOptions: DateOption[] = AVAILABLE_TIME_PERIODS.map((period) => {
const dates = getDatesForTimePeriod(period)
return {
key: period,
label: period,
translatedLabel: dates.translatedLabel ?? _(period),
fromDate: dates.fromDate,
toDate: dates.toDate,
format: dates.format,
translatedLabel: dates.translatedLabel
isDefault: true,
}
})
if (fiscalYear?.message) {
// For a fiscal year, we need to replace "Last Year", "This Year", and add options for quarters
const fiscalYearStart = fiscalYear.message.year_start_date
const fiscalYearEnd = fiscalYear.message.year_end_date
const q1 = {
label: `Q1: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q1")}: ${fiscalYear.message.name}`,
fromDate: fiscalYearStart,
toDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q2 = {
label: `Q2: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q2")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q3 = {
label: `Q3: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q3")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
const q4 = {
label: `Q4: ${fiscalYear.message.name}`,
translatedLabel: `${_("Q4")}: ${fiscalYear.message.name}`,
fromDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
toDate: fiscalYearEnd,
format: 'MMM YYYY'
}
const thisYear = {
label: `This Fiscal Year`,
translatedLabel: `${_("This Fiscal Year")}`,
fromDate: fiscalYearStart,
toDate: fiscalYearEnd,
format: 'MMM YYYY'
}
const lastYear = {
label: `Last Fiscal Year`,
translatedLabel: `${_("Last Fiscal Year")}`,
fromDate: dayjs(fiscalYearStart).subtract(1, 'year').format('YYYY-MM-DD'),
toDate: dayjs(fiscalYearEnd).subtract(1, 'year').format('YYYY-MM-DD'),
format: 'MMM YYYY'
}
// Sort the options so that we get "This Month", "Last Month", quarters, fiscal year, then the rest of the standard options
const topRankedItems = standardOptions.filter((option) => {
return option.label === "This Month" || option.label === "Last Month"
})
const bottomRankedItems = standardOptions.filter((option) => {
return option.label !== "This Month" && option.label !== "Last Month"
})
return [...topRankedItems, q1, q2, q3, q4, thisYear, lastYear, ...bottomRankedItems]
if (!fiscalYear) {
return standardOptions
}
return standardOptions
const currentStart = dayjs(fiscalYear.year_start_date)
const currentEnd = dayjs(fiscalYear.year_end_date)
const quarterOptions: DateOption[] = []
const fiscalYearOptions: DateOption[] = []
// Static literals so the translation extractor can find them.
const quarterLabels = [_("Q1"), _("Q2"), _("Q3"), _("Q4")]
for (let yearsAgo = 0; yearsAgo <= PREVIOUS_FISCAL_YEARS; yearsAgo++) {
const start = currentStart.subtract(yearsAgo, 'year')
const end = currentEnd.subtract(yearsAgo, 'year')
// Keep the real name for the current year; derive it for the earlier ones.
const yearLabel = yearsAgo === 0 ? fiscalYear.name : fiscalYearLabel(start, end)
for (let quarter = 0; quarter < 4; quarter++) {
const quarterStart = start.add(quarter * 3, 'month')
// End the day before the next quarter starts, clamped to the fiscal year end
// so a short fiscal year can't spill over.
const nextQuarterStart = start.add((quarter + 1) * 3, 'month')
const quarterEnd = nextQuarterStart.subtract(1, 'day').isAfter(end)
? end
: nextQuarterStart.subtract(1, 'day')
if (quarterStart.isAfter(end)) continue
quarterOptions.push({
key: `Q${quarter + 1}-${yearLabel}`,
label: `Q${quarter + 1}: ${yearLabel}`,
translatedLabel: `${quarterLabels[quarter]}: ${yearLabel}`,
fromDate: quarterStart.format(DATE_FORMAT),
toDate: quarterEnd.format(DATE_FORMAT),
format: 'MMM YYYY',
keywords: ['quarter', `q${quarter + 1}`, yearLabel],
// Only the current fiscal year's quarters clutter the default list;
// older ones stay searchable.
isDefault: yearsAgo === 0,
})
}
const label = yearsAgo === 0
? 'This Fiscal Year'
: yearsAgo === 1
? 'Last Fiscal Year'
: `FY ${yearLabel}`
fiscalYearOptions.push({
key: `fiscal-year-${yearLabel}`,
label,
translatedLabel: yearsAgo <= 1 ? _(label) : `${_("FY")} ${yearLabel}`,
fromDate: start.format(DATE_FORMAT),
toDate: end.format(DATE_FORMAT),
format: 'MMM YYYY',
keywords: ['fiscal year', yearLabel],
isDefault: yearsAgo <= 1,
})
}
// "This Month"/"Last Month" first, then quarters and fiscal years, then the rest.
const topRanked = standardOptions.filter((o) => o.label === 'This Month' || o.label === 'Last Month')
const bottomRanked = standardOptions.filter((o) => o.label !== 'This Month' && o.label !== 'Last Month')
return [...topRanked, ...quarterOptions, ...fiscalYearOptions, ...bottomRanked]
}, [fiscalYear])
// Reconciliation only looks backwards, so a period that hasn't started is never useful.
const selectableOptions = useMemo(
() => allOptions.filter((option) => option.fromDate <= today),
[allOptions, today],
)
const [open, setOpen] = useState(false)
const [value, setValue] = useState("")
// We filter ourselves (`shouldFilter={false}`) so that the parsed-date suggestion can be a
// real CommandItem alongside the predefined options, and keyboard navigation covers both.
const filteredOptions = useMemo(() => {
const query = value.trim().toLowerCase()
if (!query) {
return selectableOptions.filter((option) => option.isDefault)
}
const tokens = query.split(/\s+/)
return selectableOptions.filter((option) => {
const haystack = [
option.label,
option.translatedLabel,
...(option.keywords ?? []),
option.fromDate,
option.toDate,
].join(' ').toLowerCase()
return tokens.every((token) => haystack.includes(token))
})
}, [selectableOptions, value])
const parsedOption = useMemo(() => parseDateRange(value), [value])
// Filtering shortens the list, so pin the scroll back to the top to keep the
// auto-selected first option in view.
const listRef = useResetScrollOnSearch(value)
// Don't show a parsed suggestion that duplicates an option already in the list.
const showParsedOption = parsedOption
&& !filteredOptions.some((o) => o.fromDate === parsedOption.fromDate && o.toDate === parsedOption.toDate)
const timePeriod: TimePeriod | string = useMemo(() => {
if (bankRecDate.fromDate && bankRecDate.toDate) {
// Check if the from and to dates match any predefined time period
for (const period of timePeriodOptions) {
for (const period of allOptions) {
if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) {
return period.label;
}
@@ -114,10 +186,11 @@ const BankRecDateFilter = () => {
} else {
return "Date Range";
}
}, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]);
}, [bankRecDate.fromDate, bankRecDate.toDate, allOptions]);
const handleTimePeriodChange = (fromDate: string, toDate: string) => {
setBankRecDate({ fromDate, toDate })
setValue("")
setOpen(false)
}
@@ -130,7 +203,9 @@ const BankRecDateFilter = () => {
const direction = useDirection()
const RangeArrow = direction === 'ltr'
? <ChevronRight className='text-[12px] text-ink-gray-5/70' />
: <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />
return <div className='flex items-center'>
<Popover open={open} onOpenChange={setOpen}>
@@ -141,30 +216,57 @@ const BankRecDateFilter = () => {
size='md'
className='rounded-e-none border-e-0'
role="combobox">
{timePeriodOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
{allOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-84 p-1" align='start'>
<Command>
<Command shouldFilter={false}>
<CommandInput placeholder="e.g. Last 3 weeks" onValueChange={setValue} value={value} />
<CommandList className='max-h-fit'>
<CommandEmpty className='text-start p-2 hover:bg-surface-gray-1'>
<EmptyState onSelect={handleTimePeriodChange} value={value} />
</CommandEmpty>
{timePeriodOptions.map((period) => (
<CommandItem key={period.label} className='flex justify-between' onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
<span>
{period.translatedLabel ?? _(period.label)}
</span>
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
{formatDate(period.fromDate, period.format)} {direction === 'ltr' ? <ChevronRight className='text-[12px] text-ink-gray-5/70' /> : <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />} {formatDate(period.toDate, period.format)}
</span>
</CommandItem>
))}
<CommandInput placeholder={_("e.g. Last 3 weeks, Q1, May 2025")} onValueChange={setValue} value={value} />
<CommandList ref={listRef} className='max-h-80'>
{showParsedOption && parsedOption && (
<CommandGroup heading={_("Matched date")}>
<CommandItem
value='parsed-date-range'
className='flex justify-between'
onSelect={() => handleTimePeriodChange(parsedOption.fromDate, parsedOption.toDate)}>
<span className='max-w-[45%] truncate'>{value}</span>
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
{parsedOption.fromDate === parsedOption.toDate
? formatDate(parsedOption.fromDate, 'Do MMM YYYY')
: <>{formatDate(parsedOption.fromDate, 'Do MMM YY')} {RangeArrow} {formatDate(parsedOption.toDate, 'Do MMM YY')}</>}
</span>
</CommandItem>
</CommandGroup>
)}
{filteredOptions.length > 0 && (
<CommandGroup>
{filteredOptions.map((period) => (
<CommandItem
key={period.key}
value={period.key}
className='flex justify-between'
onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
<span>
{period.translatedLabel}
</span>
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
{formatDate(period.fromDate, period.format)} {RangeArrow} {formatDate(period.toDate, period.format)}
</span>
</CommandItem>
))}
</CommandGroup>
)}
{!showParsedOption && filteredOptions.length === 0 && (
<div className='p-2 text-sm text-ink-gray-5'>
{_("No results found")}
</div>
)}
</CommandList>
</Command>
@@ -199,77 +301,97 @@ const BankRecDateFilter = () => {
}
const referentialKeywords = ["last", "this", "next", "previous"]
const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => {
const dates = useMemo(() => {
if (value) {
// Try parsing the value
const parsedDate = parse(value, undefined, { forwardDate: false })
/** chrono exposes `knownValues` on ParsingComponents but doesn't type it publicly. */
const knownValuesOf = (components: unknown): Record<string, number> =>
(components as { knownValues?: Record<string, number> })?.knownValues ?? {}
if (parsedDate && parsedDate.length > 0) {
const startDate = parsedDate[0].start.date()
const endDate = parsedDate[0].end?.date()
/**
* How far back a parsed date must move to land in the past. Reconciliation only ever looks
* backwards, so an ambiguous input that chrono resolves into the future - "December" typed in
* September, or a bare weekday like "Friday" - is pulled to its most recent past occurrence.
* An explicitly stated year is respected; a range that is still future gets discarded later.
*
* This returns a shift rather than a date so that a range can be moved as a single unit -
* shifting its start and end independently would distort or invert it.
*/
const pastShift = (date: Date, knownValues: Record<string, number>) => {
const today = dayjs()
let candidate = dayjs(date)
if (!endDate) {
const today = new Date()
// If today is greater than the start date, use today as the end date
if (startDate.getTime() > today.getTime()) {
return { fromDate: today, toDate: startDate }
} else {
// Check if the user only wants a specific month like "May 2025"
// If the "known values" just has month and year, then we need to get the first day of the month and the last day of the month
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
if (parsedDate[0].start.knownValues?.month && !parsedDate[0].start.knownValues?.day) {
return {
fromDate: startDate,
toDate: dayjs(startDate).endOf('month').toDate()
}
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
} else if (parsedDate[0].start.knownValues?.month && parsedDate[0].start.knownValues?.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
// If month and day is known, then we should not assume that the user wants to get everything until today
return {
fromDate: startDate,
toDate: startDate,
}
}
return {
fromDate: startDate,
toDate: today
}
}
} else {
return { fromDate: startDate, toDate: endDate }
}
}
}
}, [value])
const onClick = (fromDate: Date, toDate: Date) => {
onSelect(formatDate(fromDate, 'YYYY-MM-DD'), formatDate(toDate, 'YYYY-MM-DD'))
if (!candidate.isAfter(today, 'date') || knownValues.year !== undefined) {
return { amount: 0, unit: 'year' as const }
}
const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date')
// A bare weekday repeats weekly, everything else (month/day) repeats yearly.
const unit = knownValues.weekday !== undefined && knownValues.day === undefined
? 'day' as const
: 'year' as const
const step = unit === 'day' ? 7 : 1
let amount = 0
return <div>
{dates ?
<div className='flex gap-2 items-center justify-between cursor-pointer' onClick={() => onClick(dates.fromDate, dates.toDate)}>
<span className='text-sm text-ink-gray-5 max-w-[30%]'>
{value}
</span>
{isEqual ? <span className='text-xs text-ink-gray-5 text-balance flex items-center gap-1'>
{formatDate(dates.fromDate, 'Do MMM YYYY')}
</span> :
<span className='text-xs text-ink-gray-5 flex items-center gap-1'>
{formatDate(dates.fromDate, 'Do MMM YY')} <ChevronRight size='16' className='text-ink-gray-5' /> {formatDate(dates.toDate, 'Do MMM YY')}
</span>}
</div> :
<span className='text-sm text-ink-gray-5'>
No results found
</span>
}
</div>
for (let i = 0; i < 200 && candidate.isAfter(today, 'date'); i++) {
candidate = candidate.subtract(step, unit)
amount += step
}
return { amount, unit }
}
export default BankRecDateFilter
/**
* Parse free text into a past date range, or return undefined when it can't be parsed or
* resolves entirely into the future.
*/
const parseDateRange = (value: string): { fromDate: string, toDate: string } | undefined => {
if (!value.trim()) return undefined
const parsedDate = parse(value, undefined, { forwardDate: false })
if (!parsedDate || parsedDate.length === 0) return undefined
const result = parsedDate[0]
const startKnownValues = knownValuesOf(result.start)
// Anchor the shift on the start and apply it to both ends, so an explicit range like
// "1st Sept to 30th Sept" keeps its shape instead of having only its end rolled back.
const shift = pastShift(result.start.date(), startKnownValues)
const startDate = dayjs(result.start.date()).subtract(shift.amount, shift.unit).toDate()
const endDate = result.end
? dayjs(result.end.date()).subtract(shift.amount, shift.unit).toDate()
: undefined
const today = new Date()
let range: { fromDate: Date, toDate: Date }
if (endDate) {
const endKnownValues = knownValuesOf(result.end)
// chrono ends "Apr 2025 to Jun 2025" on the 1st of June, but the user means all of it.
const rangeEnd = endKnownValues.month && !endKnownValues.day
? dayjs(endDate).endOf('month').toDate()
: endDate
range = { fromDate: startDate, toDate: rangeEnd }
} else if (startKnownValues.month && !startKnownValues.day) {
// The user only wants a specific month like "May 2025" - span the whole month
range = { fromDate: dayjs(startDate).startOf('month').toDate(), toDate: dayjs(startDate).endOf('month').toDate() }
} else if (startKnownValues.month && startKnownValues.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
// If month and day is known, then we should not assume that the user wants to get everything until today
range = { fromDate: startDate, toDate: startDate }
} else {
range = { fromDate: startDate, toDate: today }
}
// A range that hasn't started yet is never useful for reconciliation. A range that merely
// ends in the future is kept as typed, the same way "This Month" spans the whole month.
if (dayjs(range.fromDate).isAfter(today, 'date')) return undefined
if (dayjs(range.toDate).isBefore(range.fromDate, 'date')) {
range = { fromDate: range.toDate, toDate: range.fromDate }
}
return {
fromDate: dayjs(range.fromDate).format(DATE_FORMAT),
toDate: dayjs(range.toDate).format(DATE_FORMAT),
}
}
export default BankRecDateFilter

View File

@@ -191,9 +191,9 @@ const BankReconciliationStatementView = () => {
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
return <div className="space-y-4 py-2">
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
<div>
<div className="shrink-0">
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
@@ -201,16 +201,18 @@ const BankReconciliationStatementView = () => {
{error && <ErrorBanner error={error} />}
{data && <SummarySection data={data} />}
{data && <div className="shrink-0"><SummarySection data={data} /></div>}
{data && data.message.result.length > 0 && (
<div className="space-y-2">
<p className="text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
<div className="flex min-h-0 flex-1 flex-col space-y-2">
<p className="shrink-0 text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
<ListView
data={statementRows}
columns={statementColumns}
getRowId={(row) => row.payment_entry}
maxHeight="min(70vh, 640px)"
className="min-h-0 flex-1"
maxHeight="none"
scrollAreaClassName="flex-1"
emptyState={_("No entries with a payment document in this list.")}
/>
</div>

View File

@@ -245,9 +245,9 @@ const BankTransactionListView = () => {
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-2 py-2">
return <div className="flex min-h-0 flex-1 flex-col space-y-2 py-2">
<div className="flex gap-2 justify-between items-center">
<div className="flex shrink-0 gap-2 justify-between items-center">
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
@@ -278,8 +278,9 @@ const BankTransactionListView = () => {
data={filteredResults}
columns={transactionColumns}
getRowId={(row) => row.name}
maxHeight="calc(100vh - 200px)"
scrollAreaClassName="min-h-[calc(100vh-200px)]"
className="min-h-0 flex-1"
maxHeight="none"
scrollAreaClassName="flex-1"
emptyState={<Empty>
<EmptyMedia>
<ListIcon />

View File

@@ -181,9 +181,9 @@ const IncorrectlyClearedEntriesView = () => {
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
return <div className="flex min-h-0 flex-1 flex-col space-y-4 py-2">
<div>
<div className="shrink-0">
<span className="text-p-sm">
<MarkdownRenderer content={content} />
<br />
@@ -198,13 +198,15 @@ const IncorrectlyClearedEntriesView = () => {
{error && <ErrorBanner error={error} />}
{data && data.message.result.length > 0 && (
<div className="space-y-2">
<p className="text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
<div className="flex min-h-0 flex-1 flex-col space-y-2">
<p className="shrink-0 text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
<ListView
data={data.message.result}
columns={incorrectlyClearedColumns}
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
maxHeight="min(70vh, 640px)"
className="min-h-0 flex-1"
maxHeight="none"
scrollAreaClassName="flex-1"
emptyState={_("No rows to display.")}
/>
</div>

View File

@@ -37,7 +37,7 @@ import { Link } from "react-router"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { InputGroup, InputGroupAddon, InputGroupText } from "@/components/ui/input-group"
const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
const MatchAndReconcile = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
if (!selectedBank) {
@@ -52,15 +52,15 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
}
return <>
<div className={`flex items-start space-x-2`} >
<div className="flex-1">
<H4 className="text-sm font-medium">{_("Unreconciled Transactions")}</H4>
<UnreconciledTransactions contentHeight={contentHeight} />
<div className="flex min-h-0 flex-1 items-stretch space-x-2" >
<div className="flex min-h-0 flex-1 flex-col">
<H4 className="shrink-0 text-sm font-medium">{_("Unreconciled Transactions")}</H4>
<UnreconciledTransactions />
</div>
<Separator orientation="vertical" style={{ minHeight: `${contentHeight}px` }} />
<div className="flex-1 px-1">
<H4 className="text-sm font-medium">{_("Match or Create")}</H4>
<VouchersSection contentHeight={contentHeight} />
<Separator orientation="vertical" className="self-stretch" />
<div className="flex min-h-0 flex-1 flex-col px-1">
<H4 className="shrink-0 text-sm font-medium">{_("Match or Create")}</H4>
<VouchersSection />
</div>
</div>
<TransferModal />
@@ -69,16 +69,19 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
</>
}
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
/**
* TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets
* the real height. The scroll container fills its flex parent rather than taking a pixel
* height - the virtualizer observes its own rect, so it stays correct across resizes and any
* layout change above it.
*/
function VirtualizedListBody<T>({
items,
height,
getItemKey,
children,
estimateSize = 74,
}: {
items: T[]
height: number
getItemKey: (item: T, index: number) => string | number
children: (item: T, index: number) => React.ReactNode
estimateSize?: number
@@ -100,8 +103,7 @@ function VirtualizedListBody<T>({
return (
<div
ref={scrollRef}
className="overflow-auto contain-strict"
style={{ height }}
className="min-h-0 flex-1 overflow-auto contain-strict"
>
<div
className="relative w-full"
@@ -123,7 +125,7 @@ function VirtualizedListBody<T>({
)
}
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
const UnreconciledTransactions = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
@@ -187,14 +189,13 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
}
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
const listHeight = contentHeight - 72
if (isLoading) {
return <UnreconciledTransactionsLoadingState />
}
return <div className="space-y-1">
<div className="flex py-2 w-full gap-2">
return <div className="flex min-h-0 flex-1 flex-col space-y-1">
<div className="flex py-2 w-full gap-2 shrink-0">
<InputGroup variant='outline'>
<label className="sr-only">{_("Search transactions")}</label>
@@ -278,7 +279,6 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
<VirtualizedListBody
items={results}
height={listHeight}
estimateSize={74}
getItemKey={(transaction) => transaction.name}
>
@@ -381,7 +381,7 @@ const UnreconciledTransactionItem = ({ transaction }: { transaction: Unreconcile
}
const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
const VouchersSection = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
const selectedTransactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
@@ -402,8 +402,8 @@ const VouchersSection = ({ contentHeight }: { contentHeight: number }) => {
return <OptionsForMultipleTransactions transactions={selectedTransactions} />
}
return <div style={{ minHeight: contentHeight }} className="mt-2">
<OptionsForSingleTransaction transaction={selectedTransactions[0]} contentHeight={contentHeight} />
return <div className="mt-2 flex min-h-0 flex-1 flex-col">
<OptionsForSingleTransaction transaction={selectedTransactions[0]} />
</div>
}
@@ -535,11 +535,11 @@ const OptionsForMultipleTransactions = ({ transactions }: { transactions: Unreco
}
const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
const OptionsForSingleTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
const { setTransferModalOpen, setRecordPaymentModalOpen, setRecordJournalEntryModalOpen } = useKeyboardShortcuts()
return <div className="flex flex-col gap-3">
return <div className="flex min-h-0 flex-1 flex-col gap-3">
<TooltipProvider>
<div className="flex items-center justify-between pt-2">
<div className="flex gap-4 justify-center">
@@ -602,7 +602,7 @@ const OptionsForSingleTransaction = ({ transaction, contentHeight }: { transacti
</div>
</TooltipProvider>
{transaction.matched_transaction_rule && <RuleAction transaction={transaction} />}
<VouchersForTransaction transaction={transaction} contentHeight={contentHeight} />
<VouchersForTransaction transaction={transaction} />
</div>
}
@@ -774,12 +774,11 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
)
}
const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: UnreconciledTransaction, contentHeight: number }) => {
const VouchersForTransaction = ({ transaction }: { transaction: UnreconciledTransaction }) => {
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
const voucherList = vouchers?.message ?? []
const listHeight = contentHeight - 120
if (error) {
return <ErrorBanner error={error} />
@@ -801,8 +800,8 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
</div>
}
return <div className="relative space-y-2">
<div className="flex items-center gap-2 text-sm text-ink-gray-5">
return <div className="relative flex min-h-0 flex-1 flex-col space-y-2">
<div className="flex shrink-0 items-center gap-2 text-sm text-ink-gray-5">
<Separator className="flex-1" />
<span>or</span>
<Separator className="flex-1" />
@@ -818,7 +817,6 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
</Empty>}
<VirtualizedListBody
items={voucherList}
height={listHeight}
estimateSize={121}
getItemKey={(voucher) => voucher.name}
>

View File

@@ -59,8 +59,8 @@ const SelectedTransactionDetails = ({ transaction, showAccount = false, account
</div>
</div>
<div className='flex flex-col gap-1'>
<span className='text-sm'>{transaction.description}</span>
{transaction.reference_number ? <span className='text-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
<span className='text-p-sm'>{transaction.description}</span>
{transaction.reference_number ? <span className='text-p-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
{showAccount && account ? <span className='text-sm text-ink-gray-5'>{_("GL Account")}: {account}</span> : null}
</div>

View File

@@ -490,7 +490,7 @@ const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transact
<Calendar size='16px' />
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
</div>
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
<span className='text-p-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
</div>
</div>
</div>

View File

@@ -231,7 +231,7 @@ export const BANK_LOGOS: { keywords: string[], logo: string, locale?: string[],
{
keywords: ['Federal Bank'],
logo: 'Federal_Bank.png',
logoDark: 'Federal_Bank-dark.png',
logoDark: 'Federal_Bank-Dark.png',
locale: ['India']
},
{

View File

@@ -83,10 +83,13 @@ const StatementDetails = ({ data }: Props) => {
}
// `progress` is a percentage (drives the bar); `current`/`total` are actual counts.
const [progress, setProgress] = useState(0)
const [imported, setImported] = useState({ current: 0, total: 0 })
useFrappeEventListener("bank-rec-statement-import-progress", (event) => {
setProgress(event.progress)
setImported({ current: event.current ?? 0, total: event.total ?? 0 })
})
const file_name = data.doc.file.split("/").pop() ?? ""
@@ -112,7 +115,9 @@ const StatementDetails = ({ data }: Props) => {
{data.doc.status === 'Completed' ? <Badge theme='green'>{_("Completed")}</Badge> :
<Button onClick={onImport} disabled={loading || data.final_transactions?.length === 0} size='sm' type='button'>
{loading ? <Loader2Icon className='size-4 animate-spin' /> : null}
{loading ? _("Importing...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
{loading ? _("Importing...") : data.final_transactions?.length === 1
? _("Import 1 transaction")
: _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
}
</div>
<div className='flex items-start gap-4'>
@@ -129,7 +134,9 @@ const StatementDetails = ({ data }: Props) => {
</div>
{progress > 0 && <div className='flex flex-col gap-2'><Progress value={progress} max={100} size="lg" />
<span className='text-sm'>{_("Importing {0} transactions", [progress.toString()])}
<span className='text-sm'>{imported.total === 1
? _("Importing 1 transaction")
: _("Importing {0} of {1} transactions", [imported.current.toString(), imported.total.toString()])}
</span>
</div>}

View File

@@ -387,7 +387,7 @@ function ListViewInner<TData>({
)}
role="columnheader"
>
<div className="min-w-0 flex-1 truncate">
<div className="min-w-0 flex-1 truncate leading-snug">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}

View File

@@ -1,13 +1,58 @@
import { useFrappeGetCall } from "frappe-react-sdk"
import { useMemo } from "react"
import dayjs from "dayjs"
import { useCurrentCompany } from "./useCurrentCompany"
const useFiscalYear = () => {
return useFrappeGetCall("erpnext.accounts.utils.get_fiscal_year", undefined, 'fiscal_year', {
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false
})
export type FiscalYear = {
name: string
year_start_date: string
year_end_date: string
}
export default useFiscalYear
/**
* The fiscal year containing today, for the currently selected company.
*
* `company` matters in multi-company setups, where fiscal years can be restricted to
* specific companies. `date` matters because without it `get_fiscal_year` returns the newest
* fiscal year in the system (they're ordered by start date, descending) - which may be one
* created in advance for a year that hasn't started.
*/
const useFiscalYear = () => {
const company = useCurrentCompany()
const { data, ...rest } = useFrappeGetCall<{ message: FiscalYear | [string, string, string] | false }>(
"erpnext.accounts.utils.get_fiscal_year",
{
date: dayjs().format("YYYY-MM-DD"),
company,
as_dict: 1,
// Return nothing instead of throwing/msgprinting when no fiscal year covers today.
raise_on_missing: 0,
verbose: 0,
},
company ? `fiscal_year_${company}` : null,
{
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false
}
)
// get_fiscal_year returns a dict with as_dict, a (name, start, end) tuple without it, and
// false when there's no match - normalise all three.
const fiscalYear = useMemo<FiscalYear | undefined>(() => {
const message = data?.message
if (!message) return undefined
if (Array.isArray(message)) {
const [name, year_start_date, year_end_date] = message
return { name, year_start_date, year_end_date }
}
return message
}, [data])
return { fiscalYear, ...rest }
}
export default useFiscalYear

View File

@@ -0,0 +1,23 @@
import { useLayoutEffect, useRef } from "react"
/**
* Pins a scrollable list back to the top whenever the search term changes.
*
* Dropdowns that do their own filtering (`shouldFilter={false}`) swap a long list for a much
* shorter one while the scroll container keeps its previous offset - which can leave the
* auto-selected first item scrolled out of view.
*
* Returns a ref to attach to the scroll container (e.g. `CommandList`).
*/
const useResetScrollOnSearch = (search: string) => {
const listRef = useRef<HTMLDivElement>(null)
// Layout effect so the reset lands before paint, avoiding a visible jump.
useLayoutEffect(() => {
listRef.current?.scrollTo({ top: 0 })
}, [search])
return listRef
}
export default useResetScrollOnSearch

View File

@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./styles/scroll-fade.css";
@font-face {
font-family: InterVariable;

View File

@@ -1,4 +1,4 @@
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
import BankAccountBalancePanel from "@/components/features/BankReconciliation/BankBalance"
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
@@ -9,10 +9,9 @@ import ActionLog from "@/components/features/ActionLog/ActionLog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { TooltipProvider } from "@/components/ui/tooltip"
import _ from "@/lib/translate"
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
import { lazy, Suspense } from "react"
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Badge } from "@/components/ui/badge"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Button } from "@/components/ui/button"
import { useAtomValue } from "jotai"
@@ -25,23 +24,13 @@ const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankR
const BankReconciliation = () => {
const [headerHeight, setHeaderHeight] = useState(0)
const ref = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (ref.current) {
setHeaderHeight(ref.current.clientHeight)
}
}, [])
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
return (
<div>
<div className="p-4 flex-col gap-4 md:flex hidden">
<div ref={ref} className="flex flex-col gap-4">
<div className="flex justify-between">
{/* The page owns the viewport height and the tabs/lists below fill what's left, so
the virtualizers size themselves from layout instead of a measured pixel value. */}
<div className="px-2 pt-1 flex-col gap-4 md:flex hidden h-dvh">
<div className="flex flex-col gap-4 shrink-0">
<div className="flex justify-between shrink-0">
<div className="flex items-center gap-6">
<Breadcrumb>
<BreadcrumbList>
@@ -54,7 +43,7 @@ const BankReconciliation = () => {
<BreadcrumbItem>
<BreadcrumbPage>
<div className="flex gap-1 items-center">
{_("Banking")} <Badge theme="violet" variant="subtle">{_("Beta")}</Badge>
{_("Banking")}
</div>
</BreadcrumbPage>
@@ -71,10 +60,8 @@ const BankReconciliation = () => {
<BankRecDateFilter />
</div>
</div>
<BankPicker />
<BankBalance />
</div>
<BankRecTabs remainingHeightAfterTabs={remainingHeightAfterTabs} />
<BankRecWorkspace />
<BankTransactionUnreconcileModal />
</div>
<div className="md:hidden flex h-screen items-center justify-between">
@@ -104,42 +91,53 @@ const BankReconciliation = () => {
)
}
const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: number }) => {
const BankRecWorkspace = () => {
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
if (!selectedBankAccount) {
return null
}
return <Tabs defaultValue="Match and Reconcile">
<TabsList>
<TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger>
<TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Bank Reconciliation Statement")}</TabsTrigger>
<TabsTrigger value="Bank Transactions"><ListIcon />{_("Bank Transactions")}</TabsTrigger>
<TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Bank Clearance Summary")}</TabsTrigger>
<TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared Entries")}</TabsTrigger>
</TabsList>
<TabsContent value="Match and Reconcile">
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
</TabsContent>
<Suspense fallback={
<div className="flex items-center justify-center p-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
return <Tabs defaultValue="Match and Reconcile" className="min-h-0 flex-1 gap-4">
{/* Picker + tab strip stack on the left, balance panel beside them - the tab strip
fills height the panel needs anyway, so it costs no row of its own. The picker
scrolls horizontally (`min-w-0` lets it shrink so its overflow-x engages) while
the panel stays put, so the figures never scroll away. */}
{/* No gap here: the panel's own `border-s ps-4` supplies the separation, and a gap
would leave dead space the picker's edge fade can't reach. */}
<div className="flex shrink-0 items-stretch">
<div className="flex min-w-0 flex-1 flex-col justify-between gap-3">
<BankPicker />
{selectedBankAccount && <TabsList>
<TabsTrigger value="Match and Reconcile"><ShuffleIcon /> {_("Match and Reconcile")}</TabsTrigger>
<TabsTrigger value="Bank Reconciliation Statement"><ScrollTextIcon /> {_("Reconciliation Statement")}</TabsTrigger>
<TabsTrigger value="Bank Transactions"><ListIcon />{_("Transactions")}</TabsTrigger>
<TabsTrigger value="Bank Clearance Summary"><CheckCircleIcon />{_("Clearance Summary")}</TabsTrigger>
<TabsTrigger value="Incorrectly Cleared Entries"><AlertTriangleIcon /> {_("Incorrectly Cleared")}</TabsTrigger>
</TabsList>}
</div>
}>
<TabsContent value="Bank Reconciliation Statement">
<BankReconciliationStatement />
{selectedBankAccount && <BankAccountBalancePanel />}
</div>
{selectedBankAccount && <>
<TabsContent value="Match and Reconcile" className="flex min-h-0 flex-col">
<MatchAndReconcile />
</TabsContent>
<TabsContent value="Bank Transactions">
<BankTransactions />
</TabsContent>
<TabsContent value="Bank Clearance Summary">
<BankClearanceSummary />
</TabsContent>
<TabsContent value="Incorrectly Cleared Entries">
<IncorrectlyClearedEntries />
</TabsContent>
</Suspense>
<Suspense fallback={
<div className="flex items-center justify-center p-16">
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
</div>
}>
<TabsContent value="Bank Reconciliation Statement" className="flex min-h-0 flex-col">
<BankReconciliationStatement />
</TabsContent>
<TabsContent value="Bank Transactions" className="flex min-h-0 flex-col">
<BankTransactions />
</TabsContent>
<TabsContent value="Bank Clearance Summary" className="flex min-h-0 flex-col">
<BankClearanceSummary />
</TabsContent>
<TabsContent value="Incorrectly Cleared Entries" className="flex min-h-0 flex-col">
<IncorrectlyClearedEntries />
</TabsContent>
</Suspense>
</>}
</Tabs>
}

View File

@@ -226,7 +226,7 @@ const StatementImportLog = () => {
field: "creation",
order: "desc"
},
limit: 10
limit: 20
}, bankAccount ? undefined : null, {
revalidateOnFocus: false
})

View File

@@ -0,0 +1,94 @@
/* Scroll-edge fade mask for horizontal scroll containers (the bank picker strip).
Ported from Raven's `scroll-fade-x`; imported by index.css, since Tailwind processes
`@utility` in imported files the same as in the entry file.
The scroll-timeline keyframes reveal each edge's fade only when there IS content to scroll
in that direction - no fade on the left edge when scrolled fully left, none on the right at
the end. `@property` makes the fade animate smoothly rather than jumping.
Without scroll-timeline support (Firefox) there is deliberately NO fade at all: the fade
vars stay at their 0px initial value and the gradient stops collapse to the edges. A static
both-edges fallback was tried in Raven and removed - on a container with nothing to scroll
it dimmed the edges anyway, promising content that didn't exist. */
@property --scroll-fade-l {
/* length-percentage, NOT length: the fade size is min(12%, …) - a percentage. A <length>
property rejects that value and reverts to initial-value (0px), zeroing the fade. */
syntax: "<length-percentage>";
inherits: false;
initial-value: 0px;
}
@property --scroll-fade-r {
syntax: "<length-percentage>";
inherits: false;
initial-value: 0px;
}
@keyframes scroll-fade-reveal-l {
from {
--scroll-fade-l: 0px;
}
to {
--scroll-fade-l: var(--_scroll-fade-size-l);
}
}
@keyframes scroll-fade-reveal-r {
from {
--scroll-fade-r: var(--_scroll-fade-size-r);
}
to {
--scroll-fade-r: 0px;
}
}
@utility scroll-fade-x {
--_scroll-fade-size-l: var(--scroll-fade-l-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing, 0.25rem) * 10))));
--_scroll-fade-size-r: var(--scroll-fade-r-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing, 0.25rem) * 10))));
/* Eased (smoothstep) alpha ramp, sampled finely so it reads as a smooth curve, NOT fading
all the way to transparent: the edge floors at 0.25 (content dims, never vanishes), ramping
up to a full 1 for the body. The opaque end MUST be 1 or everything would be permanently
dimmed. Stops collapse to the edge when the size animates to 0, so the true first/last card
is never dimmed at rest. Tune the floor - higher (~0.4) = subtler, lower (~0.1) = stronger. */
--scroll-fade-inline: linear-gradient(to right,
rgba(0, 0, 0, 0.25) 0,
rgba(0, 0, 0, 0.282) calc(var(--scroll-fade-l, 0px) * 0.125),
rgba(0, 0, 0, 0.367) calc(var(--scroll-fade-l, 0px) * 0.25),
rgba(0, 0, 0, 0.487) calc(var(--scroll-fade-l, 0px) * 0.375),
rgba(0, 0, 0, 0.625) calc(var(--scroll-fade-l, 0px) * 0.5),
rgba(0, 0, 0, 0.763) calc(var(--scroll-fade-l, 0px) * 0.625),
rgba(0, 0, 0, 0.883) calc(var(--scroll-fade-l, 0px) * 0.75),
rgba(0, 0, 0, 0.968) calc(var(--scroll-fade-l, 0px) * 0.875),
rgba(0, 0, 0, 1) var(--scroll-fade-l, 0px),
rgba(0, 0, 0, 1) calc(100% - var(--scroll-fade-r, 0px)),
rgba(0, 0, 0, 0.968) calc(100% - var(--scroll-fade-r, 0px) * 0.875),
rgba(0, 0, 0, 0.883) calc(100% - var(--scroll-fade-r, 0px) * 0.75),
rgba(0, 0, 0, 0.763) calc(100% - var(--scroll-fade-r, 0px) * 0.625),
rgba(0, 0, 0, 0.625) calc(100% - var(--scroll-fade-r, 0px) * 0.5),
rgba(0, 0, 0, 0.487) calc(100% - var(--scroll-fade-r, 0px) * 0.375),
rgba(0, 0, 0, 0.367) calc(100% - var(--scroll-fade-r, 0px) * 0.25),
rgba(0, 0, 0, 0.282) calc(100% - var(--scroll-fade-r, 0px) * 0.125),
rgba(0, 0, 0, 0.25) 100%);
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation:
scroll-fade-reveal-l 1ms ease-in-out,
scroll-fade-reveal-r 1ms ease-in-out;
animation-timeline: scroll(self x), scroll(self x);
animation-range:
0 var(--scroll-fade-reveal, calc(var(--spacing, 0.25rem) * 24)),
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing, 0.25rem) * 24))) 100%;
animation-fill-mode: both;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,5 @@
preserve_hierarchy: true
files:
- source: /erpnext/locale/main.pot
translation: /erpnext/locale/%two_letters_code%.po
pull_request_title: "fix: sync translations from crowdin"
pull_request_labels:
- translation
- skip-release-notes
pull_request_reviewers:
- barredterra # change to your GitHub username if you copied this file
commit_message: "fix: %language% translations"
append_commit_message: false
languages_mapping:
two_letters_code:
pt-BR: pt_BR

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.26.2"
__version__ = "16.35.0"
def get_default_company(user=None):
@@ -177,7 +177,12 @@ def normalize_ctx_input(T: type) -> callable:
def decorator(func: callable):
# conserve annotations for frappe.utils.typing_validations
@functools.wraps(func, assigned=(a for a in functools.WRAPPER_ASSIGNMENTS if a != "__annotations__"))
@functools.wraps(
func,
assigned=(
a for a in functools.WRAPPER_ASSIGNMENTS if a not in ("__annotations__", "__annotate__")
),
)
def wrapper(ctx: T | Document | dict | str, *args, **kwargs):
if isinstance(ctx, Document):
ctx = T(**ctx.as_dict())

View File

@@ -9,7 +9,7 @@
"idx": 0,
"is_public": 1,
"is_standard": 1,
"modified": "2025-12-19 12:37:31.673782",
"modified": "2026-09-04 12:37:31.673782",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Profit and Loss",
@@ -17,7 +17,6 @@
"owner": "Administrator",
"report_name": "Profit and Loss Statement",
"roles": [],
"show_values_over_chart": 1,
"timeseries": 0,
"type": "Line",
"use_report_chart": 1,

View File

@@ -16,6 +16,8 @@ frappe.ui.form.on("Accounting Dimension", {
return {
filters: {
name: ["not in", invalid_doctypes],
istable: 0,
issingle: 0,
},
};
});

View File

@@ -60,6 +60,14 @@ class AccountingDimension(Document):
msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type)
frappe.throw(msg)
meta = frappe.get_meta(self.document_type)
if meta.istable or meta.issingle:
frappe.throw(
_(
"{0} cannot be used as an accounting dimension as it is not a standalone document type."
).format(frappe.bold(self.document_type))
)
exists = frappe.db.get_value("Accounting Dimension", {"document_type": self.document_type}, ["name"])
if exists and self.is_new():

View File

@@ -52,6 +52,23 @@ class TestAccountingDimension(ERPNextTestSuite):
self.assertEqual(gle.get("department"), "_Test Department - _TC")
self.assertEqual(gle1.get("department"), "_Test Department - _TC")
def test_child_table_not_allowed_as_dimension(self):
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Sales Team"})
self.assertRaises(frappe.ValidationError, dimension.insert)
def test_single_doctype_not_allowed_as_dimension(self):
dimension = frappe.get_doc({"doctype": "Accounting Dimension", "document_type": "Selling Settings"})
self.assertRaises(frappe.ValidationError, dimension.insert)
def test_non_scalar_dimension_value_skipped_in_gl_dict(self):
si = create_sales_invoice(do_not_save=1)
si.department = "_Test Department - _TC"
self.assertEqual(si.get_gl_dict({}).get("department"), "_Test Department - _TC")
si.department = ["_Test Department - _TC"]
self.assertNotIn("department", si.get_gl_dict({}))
def test_mandatory(self):
location = frappe.get_doc("Accounting Dimension", "Location")
location.dimension_defaults[0].mandatory_for_bs = True

View File

@@ -94,13 +94,14 @@
"column_break_25",
"reports_tab",
"remarks_section",
"general_ledger_remarks_length",
"receivable_payable_remarks_length",
"disable_include_dimensions",
"column_break_lvjk",
"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",
"tab_break_dpet",
@@ -198,10 +199,12 @@
},
{
"default": "1",
"description": "System will use the latest saved Currency Exchange rate on or before the transaction date, however old it is. <br>\nUncheck to ignore rates older than Stale Days and fetch a fresh rate from the exchange rate provider instead.",
"fieldname": "allow_stale",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Allow Stale Exchange Rates"
"label": "Allow Stale Exchange Rates",
"show_description_on_click": 1
},
{
"default": "1",
@@ -221,7 +224,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",
@@ -277,10 +281,10 @@
},
{
"default": "0",
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
"description": "Enabling this option prevents the creation of a new Sales Invoice when the customer has an overdue limit set and their outstanding overdue amount exceeds that limit.",
"fieldname": "enable_overdue_billing_threshold",
"fieldtype": "Check",
"label": "Restrict Customer Over Billing"
"label": "Prevent Sales Invoice when Customer is Overdue"
},
{
"depends_on": "eval:doc.enable_overdue_billing_threshold",
@@ -474,7 +478,7 @@
{
"fieldname": "remarks_section",
"fieldtype": "Section Break",
"label": "Remarks Column Length"
"label": "General Ledger Report"
},
{
"default": "0",
@@ -548,7 +552,7 @@
{
"fieldname": "accounts_receivable_payable_tuning_section",
"fieldtype": "Section Break",
"label": "Accounts Receivable / Payable Tuning"
"label": "Accounts Receivable / Payable Report"
},
{
"fieldname": "legacy_section",
@@ -780,6 +784,12 @@
"fieldname": "book_stock_expense_gl_entries",
"fieldtype": "Check",
"label": "Book Stock Expense GL Entries"
},
{
"default": "0",
"fieldname": "disable_include_dimensions",
"fieldtype": "Check",
"label": "Disable \"Consider Accounting Dimension\" Filter"
}
],
"grid_page_length": 50,
@@ -788,7 +798,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-07-27 12:00:00.000000",
"modified": "2026-09-04 10:08:30.115003",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

@@ -72,6 +72,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_accounting_dimensions: DF.Check
enable_common_party_accounting: DF.Check
enable_discounts_and_margin: DF.Check
@@ -220,6 +221,13 @@ class AccountsSettings(Document):
set_allow_on_submit_for_dimension_fields(doctypes)
@frappe.whitelist(methods=["POST"])
def get_posting_date_confirmation() -> int:
return cint(
frappe.db.get_single_value("Accounts Settings", "confirm_before_resetting_posting_date", cache=False)
)
def toggle_accounting_dimension_sections(hide):
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
for doctype in accounting_dimension_doctypes:

View File

@@ -2,10 +2,16 @@ import unittest
import frappe
from erpnext.accounts.doctype.accounts_settings.accounts_settings import get_posting_date_confirmation
from erpnext.tests.utils import ERPNextTestSuite
class TestAccountsSettings(ERPNextTestSuite):
def test_posting_date_confirmation_uses_current_setting(self):
for enabled in (0, 1, 0):
frappe.db.set_single_value("Accounts Settings", "confirm_before_resetting_posting_date", enabled)
self.assertEqual(get_posting_date_confirmation(), enabled)
def test_stale_days(self):
cur_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
cur_settings.allow_stale = 0

View File

@@ -916,7 +916,7 @@ def search_for_transfer_transaction(transaction_id: str | int):
days = frappe.db.get_single_value("Accounts Settings", "transfer_match_days")
if not days:
if days is None:
days = 3
min_date = frappe.utils.add_days(date, -days)
@@ -1340,9 +1340,11 @@ def get_pe_matching_query(
ref_condition = pe.reference_no == transaction.reference_number
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
amount_equality = pe.paid_amount == transaction.unallocated_amount
amount_field = pe.received_amount_after_tax if account_from_to == "paid_to" else pe.paid_amount_after_tax
amount_equality = amount_field == transaction.unallocated_amount
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
amount_condition = amount_equality if exact_match else amount_field > 0.0
party_condition = (
(pe.party_type == transaction.party_type) & (pe.party == transaction.party) & pe.party.isnotnull()
@@ -1359,7 +1361,7 @@ def get_pe_matching_query(
(ref_rank + amount_rank + party_rank + 1).as_("rank"),
ConstantColumn("Payment Entry").as_("doctype"),
pe.name,
pe.base_paid_amount_after_tax.as_("paid_amount"),
amount_field.as_("paid_amount"),
pe.reference_no,
pe.reference_date,
pe.party,

View File

@@ -8,7 +8,9 @@ from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
get_auto_reconcile_message,
get_bank_transactions,
get_linked_payments,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -97,3 +99,103 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def make_bank_transaction(self, date, deposit=100, withdrawal=0):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": date,
"deposit": deposit,
"withdrawal": withdrawal,
"bank_account": self.bank_account,
"currency": "INR",
}
)
.save()
.submit()
)
def get_matching_payment_entries(self, bank_transaction, exact_match=False):
document_types = ["payment_entry", "exact_match"] if exact_match else ["payment_entry"]
vouchers = get_linked_payments(
bank_transaction,
document_types,
from_date=add_days(today(), -1),
to_date=today(),
)
return [v for v in vouchers if v.get("doctype") == "Payment Entry"]
def test_get_bank_transactions_excludes_dates_after_to_date(self):
self.make_bank_transaction(date=today())
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_deposit_matches_amount_received_in_bank_account(self):
# money leaves another bank account and lands here minus a charge, so the two sides differ
payment = frappe.get_doc(
{
"doctype": "Payment Entry",
"payment_type": "Internal Transfer",
"company": self.company,
"posting_date": today(),
"paid_from": "_Test Bank - _TC",
"paid_to": self.bank,
"paid_amount": 3537.64,
"received_amount": 3460.52,
"reference_no": "TRF-001",
"reference_date": today(),
}
)
payment.set_missing_values()
payment.set_exchange_rate()
payment.set_amounts()
payment.deductions[-1].account = "_Test Exchange Gain/Loss - _TC"
payment.deductions[-1].cost_center = "_Test Cost Center - _TC"
payment = payment.save().submit()
transaction = self.make_bank_transaction(date=today(), deposit=3460.52)
# the received side is what reached this bank account, so that is what is shown
matches = self.get_matching_payment_entries(transaction.name)
self.assertEqual([m["name"] for m in matches], [payment.name])
self.assertEqual(matches[0]["paid_amount"], 3460.52)
# and what the exact match compares against
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
def test_withdrawal_matches_amount_paid_from_bank_account(self):
payment = create_payment_entry(
company=self.company,
payment_type="Pay",
party_type="Supplier",
party="_Test Supplier",
paid_from=self.bank,
paid_to="Creditors - _TC",
paid_amount=1250,
)
payment = payment.save().submit()
transaction = self.make_bank_transaction(date=today(), deposit=0, withdrawal=1250)
exact_matches = self.get_matching_payment_entries(transaction.name, exact_match=True)
self.assertEqual([m["name"] for m in exact_matches], [payment.name])
self.assertEqual(exact_matches[0]["paid_amount"], 1250)
def test_auto_reconcile_message_for_no_matches(self):
message, indicator = get_auto_reconcile_message([], [])
self.assertEqual(indicator, "blue")
self.assertIn("No matches", message)
def test_auto_reconcile_message_counts_and_pluralizes(self):
# reconciled count is reported and the indicator turns green
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
self.assertEqual(indicator, "green")
self.assertIn("2 Transaction(s) Reconciled", message)
# partially-reconciled label is singular for one, plural for many
singular, _ = get_auto_reconcile_message(["p1"], [])
self.assertIn("1 Transaction Partially Reconciled", singular)
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
self.assertIn("2 Transactions Partially Reconciled", plural)

View File

@@ -167,9 +167,10 @@ def get_transaction_reference(txn_data: dict) -> str:
).strip()
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def convert_mt940_to_csv(data_import, mt940_file_path):
doc = frappe.get_doc("Bank Statement Import", data_import)
doc.check_permission("write")
_file_doc, content = get_file(mt940_file_path)
@@ -234,26 +235,30 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
@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):
job_id = frappe.get_doc("Bank Statement Import", data_import).start_import()
return job_id is not None
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 is_mt940_format(content: str) -> bool:
@@ -392,6 +397,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(

View File

@@ -375,8 +375,7 @@ class BankStatementImportLog(Document):
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
final_transactions, table["date_format"], table["amount_format"] = build_table_transactions(table)
# Tables with no detectable transactions (ads, summaries, headers) start excluded.
table["included"] = bool(final_transactions)
table["included"] = should_include_table(table, final_transactions)
self.pdf_tables = json.dumps(tables)
return tables
@@ -542,6 +541,8 @@ class BankStatementImportLog(Document):
"bank-rec-statement-import-progress",
{
"progress": round(progress / total_transactions * 100),
"current": progress,
"total": total_transactions,
},
doctype="Bank Statement Import Log",
docname=self.name,
@@ -551,6 +552,7 @@ class BankStatementImportLog(Document):
"bank-rec-statement-import-progress",
{
"progress": 100,
"current": total_transactions,
"total": total_transactions,
},
doctype="Bank Statement Import Log",
@@ -821,6 +823,15 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
"""Pure version of the final-transaction builder (date normalized, amount split)."""
final_transactions = []
# Which marker does this statement actually write? A statement that only ever says "Cr"
# is marking the credits as its exceptions, so an unmarked row is a withdrawal; one that
# only ever says "Dr" means the opposite. With both markers present an unmarked row is
# genuinely undetermined, so it stays a withdrawal.
unmarked_is_deposit = False
if amount_format == 'Amount column has "CR"/"DR" values':
markers = {get_amount_cr_dr_marker(row.get("amount")) for row in transaction_rows}
unmarked_is_deposit = markers - {None} == {"dr"}
def parse_amount(transaction_row: dict):
if amount_format == "Separate columns for withdrawal and deposit":
return get_float_amount(transaction_row.get("withdrawal")), get_float_amount(
@@ -829,44 +840,43 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
if amount_format == 'Amount column has "CR"/"DR" values':
amount = transaction_row.get("amount")
marker = get_amount_cr_dr_marker(amount)
# The marker carries the direction, so the amount's own sign is ignored.
signed_amount = get_float_amount(amount) or 0
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
float_amount = abs(get_float_amount(amount) or 0)
if "cr" in amount.lower():
return 0, float_amount
else:
return float_amount, 0
if marker:
return (0, abs(signed_amount)) if marker == "cr" else (abs(signed_amount), 0)
# An unmarked row takes the opposite direction to the marker this statement
# uses. A negative amount reverses that again (a refund).
is_deposit = unmarked_is_deposit
if signed_amount < 0:
is_deposit = not is_deposit
return (0, abs(signed_amount)) if is_deposit else (abs(signed_amount), 0)
# `or 0` below: get_float_amount returns None for an unparseable cell, and a blank
# transaction-type cell comes through as None. Both used to raise.
if amount_format == "Amount column has positive/negative values":
amount = get_float_amount(transaction_row.get("amount", "0"))
amount = get_float_amount(transaction_row.get("amount", "0")) or 0
if amount > 0:
return 0, abs(amount)
else:
return abs(amount), 0
transaction_type = str(transaction_row.get("debit_credit") or "").strip().lower()
amount = abs(get_float_amount(transaction_row.get("amount", "0")) or 0)
if amount_format == 'Transaction type column has "CR"/"DR" values':
transaction_type = transaction_row.get("debit_credit")
amount = get_float_amount(transaction_row.get("amount", "0"))
if "cr" in transaction_type.lower():
return 0, abs(amount)
else:
return abs(amount), 0
# "credit" contains "cr". "debit" does not contain "dr", so it correctly falls
# through to the withdrawal side.
return (0, amount) if "cr" in transaction_type else (amount, 0)
if amount_format == 'Transaction type column has "C"/"D" values':
transaction_type = transaction_row.get("debit_credit")
amount = get_float_amount(transaction_row.get("amount", "0"))
if transaction_type.lower().strip() == "c":
return 0, abs(amount)
else:
return abs(amount), 0
return (0, amount) if transaction_type == "c" else (amount, 0)
if amount_format == 'Transaction type column has "Deposit"/"Withdrawal" values':
transaction_type = transaction_row.get("debit_credit")
amount = get_float_amount(transaction_row.get("amount", "0"))
if "deposit" in transaction_type.lower():
return 0, abs(amount)
else:
return abs(amount), 0
return (0, amount) if "deposit" in transaction_type else (amount, 0)
return 0, 0
@@ -910,6 +920,26 @@ def build_table_transactions(table: dict):
return final_transactions, date_format, amount_format
def should_include_table(table: dict, final_transactions: list) -> bool:
"""
Whether a freshly extracted PDF table should START as included - only the default state
of the checkbox, which the user can change afterwards.
It must have yielded transactions, and it must have a Description column mapped. A
transaction table always carries a narration; the summary boxes printed around it -
payment due, credit limit, reward points - are dates and figures only. Otherwise the
HDFC credit-card "Payment Due Date / Total Dues / Minimum Amount Due" box parses as one
transaction and imports a phantom row.
A description is NOT needed to import (it is not mandatory on Bank Transaction), so a
bank that omits narration still works - its table just starts unticked.
"""
if not final_transactions:
return False
return any(column.get("maps_to") == "Description" for column in table.get("column_mapping", []))
def _clean_cell(cell) -> str:
"""Normalize a pdfplumber cell: None -> '', collapse wrapped newlines, strip."""
if cell is None:
@@ -1055,6 +1085,43 @@ def get_float_amount(amount):
return amount
# A "CR"/"DR" marker on the amount itself, at either end: "2,378.00Cr", "Cr 100",
# "INR 50.90 Cr.", "DR 1,234.50".
# `(?![a-zA-Z])` rather than `\b` on the leading form: there is no word boundary between
# the "r" of "Cr100" and the digit, but there IS one inside "CREDIT" and "DRAFT".
AMOUNT_CR_DR_PATTERN = re.compile(r"^\s*(cr|dr)(?![a-zA-Z])\.?|(?:^|[\s\d.)])(cr|dr)\b\.?\s*$", re.IGNORECASE)
def get_amount_cr_dr_marker(amount) -> str | None:
"""
Return "cr" or "dr" if the amount cell carries a direction marker of its own, else None.
What is left after removing the marker has to look like an amount - it must hold a digit
and at most a short currency token - so that text which merely starts or ends with the
letters is not read as a marker. That guard is what separates "Cr 100" from a
description that bled into the amount column, like "Dr Smith Clinic 500".
"""
if not isinstance(amount, str):
return None
match = AMOUNT_CR_DR_PATTERN.search(amount)
if not match:
return None
# Only the marker itself is removed - the surrounding character the pattern needed to
# anchor on (a digit, say) stays part of the remainder.
group = 1 if match.group(1) else 2
start, end = match.span(group)
remainder = amount[:start] + amount[end:]
if not any(char.isdigit() for char in remainder):
return None
if sum(char.isalpha() for char in remainder) > 3:
return None
return match.group(group).lower()
def get_file_properties(transactions: list):
"""
From the transaction rows, try to figure out the following:
@@ -1075,6 +1142,8 @@ def get_file_properties(transactions: list):
'Transaction type column has "C"/"D" values': 0,
}
amount_column_has_cr_dr = False
for transaction in transactions:
date_format = transaction.get("date_format")
@@ -1092,33 +1161,40 @@ def get_file_properties(transactions: list):
if not amount:
continue
if isinstance(amount, str) and ("cr" in amount.lower() or "dr" in amount.lower()):
debit_credit = str(transaction.get("debit_credit") or "").strip().lower()
# One vote per row, most specific signal first. Order matters: "withdrawal" contains
# "dr", so it must be matched before the loose cr/dr check or a Deposit/Withdrawal
# column reads as CR/DR. "debit" needs listing because, unlike "credit", it does not
# contain "dr". The final else means every row votes, even an unrecognised type.
if get_amount_cr_dr_marker(amount):
amount_column_has_cr_dr = True
amount_format_frequency['Amount column has "CR"/"DR" values'] += 1
# Check if there's a debit_credit column containing "cr"/"dr"
if transaction.get("debit_credit", None):
if (
"cr" in transaction.get("debit_credit", "").lower()
or "dr" in transaction.get("debit_credit", "").lower()
):
amount_format_frequency['Transaction type column has "CR"/"DR" values'] += 1
elif (
"deposit" in transaction.get("debit_credit", "").lower()
or "withdrawal" in transaction.get("debit_credit", "").lower()
):
amount_format_frequency['Transaction type column has "Deposit"/"Withdrawal" values'] += 1
elif (transaction.get("debit_credit", "").lower().strip() == "c") or (
transaction.get("debit_credit", "").lower().strip() == "d"
):
amount_format_frequency['Transaction type column has "C"/"D" values'] += 1
# Else assume that the amount is expressed as positive/negative value
elif "deposit" in debit_credit or "withdrawal" in debit_credit:
amount_format_frequency['Transaction type column has "Deposit"/"Withdrawal" values'] += 1
elif debit_credit in ("c", "d"):
amount_format_frequency['Transaction type column has "C"/"D" values'] += 1
elif any(token in debit_credit for token in ("cr", "dr", "debit")):
amount_format_frequency['Transaction type column has "CR"/"DR" values'] += 1
else:
# Nothing said which direction this is, so assume the amount carries the sign.
amount_format_frequency["Amount column has positive/negative values"] += 1
most_common_date_format = max(date_format_frequency, key=date_format_frequency.get)
most_common_amount_format = max(amount_format_frequency, key=amount_format_frequency.get)
# With no votes at all (no rows, or every amount blank) max() would return whichever key
# happens to be first in the dict. Say what we mean instead.
if not amount_format_frequency[most_common_amount_format]:
most_common_amount_format = "Amount column has positive/negative values"
# A CR/DR amount column is proved by a single marker, not by a majority: both formats
# describe the same column, and an unmarked row is only the default direction, not
# evidence against the notation. Statements mark just the exceptions - one HDFC
# credit-card page has 18 rows and a single "50.90Cr".
if amount_column_has_cr_dr and most_common_amount_format == "Amount column has positive/negative values":
most_common_amount_format = 'Amount column has "CR"/"DR" values'
return most_common_date_format, most_common_amount_format

View File

@@ -11,12 +11,14 @@ from erpnext.accounts.doctype.bank_statement_import_log.bank_statement_import_lo
detect_column_mapping,
detect_header_row,
extract_pdf_tables,
get_amount_cr_dr_marker,
get_float_amount,
get_statement_details,
guess_column_mapping_by_content,
reextract_pdf_table,
set_header_index,
set_pdf_table_header,
should_include_table,
update_column_mapping,
update_pdf_tables,
)
@@ -124,6 +126,184 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
self.assertIsNone(get_float_amount("ABCD"))
self.assertIsNone(get_float_amount("****"))
# ------------------------------------------------------------------ #
# Amount format detection
# ------------------------------------------------------------------ #
def test_amount_cr_dr_marker(self):
"""The marker is read at either end of the cell, but only next to the amount."""
for amount in ("2,378.00Cr", "50.90 CR", "INR 50.90 Cr.", "1000cr", "5cr", "(100) Cr"):
self.assertEqual(get_amount_cr_dr_marker(amount), "cr", amount)
for amount in ("2,378.00Dr", "50.90 DR", "1000dr", "-100 Dr"):
self.assertEqual(get_amount_cr_dr_marker(amount), "dr", amount)
# Some banks put the marker in front of the digits instead.
for amount in ("Cr 100", "Cr100", "CR INR 100", "cr 0.00"):
self.assertEqual(get_amount_cr_dr_marker(amount), "cr", amount)
for amount in ("Dr 100", "Dr100", "Dr. 1,234.50"):
self.assertEqual(get_amount_cr_dr_marker(amount), "dr", amount)
for amount in ("100.00", "-2,000.00", "INR 25,236.00", "", None, 100.0):
self.assertIsNone(get_amount_cr_dr_marker(amount), amount)
# Text that merely starts or ends with the letters must not be read as a marker, or
# a description that bled into the amount column would reclassify the statement.
for amount in (
"CREDIT CARD PAYMENT 500",
"DRAFT 100",
"Dr Smith Clinic 500",
"DR AMBEDKAR ROAD BRANCH 500",
"500 CRC",
"Cheque Dr",
"Cr",
):
self.assertIsNone(get_amount_cr_dr_marker(amount), amount)
def test_sparsely_marked_cr_dr_amount_column(self):
"""One marker is enough to prove a CR/DR amount column - it is not a majority vote.
A real HDFC credit-card page carries 18 rows and a single "50.90Cr": the unmarked
rows are ordinary purchases, and only the exceptions are marked. A frequency vote
therefore picked "positive/negative" 17-1 and imported that lone credit as a debit.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Transaction Description", "Amount (in Rs.)"],
["21/07/2026", "ITC MAURYA NEW DELHI", "2,495.00"],
["22/07/2026", "ZOMATO LIMITED Gurugram", "1,288.68"],
["23/07/2026", "SWIGGY Bangalore", "532.00"],
["26/07/2026", "SWIGGY Bangalore", "1,043.00"],
["27/07/2026", "PETRO SURCHARGE WAIVER", "50.90Cr"],
]
)
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
# Only "Cr" appears, so it is the marked exception and unmarked rows are debits.
self.assertEqual(doc.total_credits, 50.90)
self.assertEqual(doc.total_credit_transactions, 1)
self.assertEqual(doc.total_debits, 5358.68)
self.assertEqual(doc.total_debit_transactions, 4)
def test_dr_only_statement_treats_unmarked_rows_as_deposits(self):
"""The mirror image of a Cr-only statement: only withdrawals are marked.
The unmarked default cannot be hardcoded to the debit, because which side gets
marked varies by bank. It is derived from the markers the statement actually uses -
here only "Dr" appears, so "Dr" is the exception and everything unmarked is a
deposit.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Amount"],
["01/04/2026", "ATM WITHDRAWAL", "2,000.00Dr"],
["03/04/2026", "SALARY", "20,000.00"],
["05/04/2026", "INTEREST", "150.00"],
]
)
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
self.assertEqual(doc.total_debits, 2000.0)
self.assertEqual(doc.total_debit_transactions, 1)
self.assertEqual(doc.total_credits, 20150.0)
self.assertEqual(doc.total_credit_transactions, 2)
def test_leading_cr_dr_markers(self):
"""Some banks print the marker in front of the amount."""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Amount"],
["01/04/2026", "ATM WITHDRAWAL", "Dr 2,000.00"],
["03/04/2026", "SALARY", "Cr 20,000.00"],
]
)
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
self.assertEqual(doc.total_debits, 2000.0)
self.assertEqual(doc.total_credits, 20000.0)
def test_partially_marked_cr_dr_amount_column(self):
"""A CR/DR amount column stays CR/DR even when some rows carry no marker.
Every unmarked row used to also vote for "positive/negative", so an ordinary
statement with a few unmarked rows was detected as positive/negative and a
"2000.00Dr" was then imported as a deposit.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Amount", "Balance"],
["01/04/2026", "OPENING FEE", "100.00", "9,900.00"],
["03/04/2026", "SALARY", "20000.00Cr", "29,900.00"],
["05/04/2026", "ATM WDL", "2000.00Dr", "27,900.00"],
]
)
self.assertEqual(doc.detected_amount_format, 'Amount column has "CR"/"DR" values')
# Both markers appear, so an unmarked row is undetermined and stays a debit.
self.assertEqual(doc.total_debits, 2100.0)
self.assertEqual(doc.total_debit_transactions, 2)
self.assertEqual(doc.total_credits, 20000.0)
self.assertEqual(doc.total_credit_transactions, 1)
def test_deposit_withdrawal_type_column(self):
"""The word Withdrawal contains "dr", so a loose CR/DR check claims this column first.
It then reads "Deposit" (which has no "cr" in it) as a withdrawal, flipping the
direction of every credit in the statement.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Transaction Type", "Amount"],
["01/04/2026", "ATM WDL", "Withdrawal", "2,000.00"],
["03/04/2026", "SALARY", "Deposit", "20,000.00"],
["05/04/2026", "ATM WDL", "Withdrawal", "500.00"],
]
)
self.assertEqual(
doc.detected_amount_format, 'Transaction type column has "Deposit"/"Withdrawal" values'
)
self.assertEqual(doc.total_debits, 2500.0)
self.assertEqual(doc.total_debit_transactions, 2)
self.assertEqual(doc.total_credits, 20000.0)
self.assertEqual(doc.total_credit_transactions, 1)
def test_unrecognised_type_column_falls_back_to_signed_amount(self):
"""An unrecognised transaction type must not stop the amount being read.
No tally was incremented for these rows, so max() returned the first key -
"Separate columns for withdrawal and deposit" - and, with no such columns in the
file, every amount came through as None.
"""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Transaction Type", "Amount"],
["01/04/2026", "ATM WDL", "NEFT", "-2,000.00"],
["03/04/2026", "SALARY", "IMPS", "20,000.00"],
]
)
self.assertEqual(doc.detected_amount_format, "Amount column has positive/negative values")
self.assertEqual(doc.total_debits, 2000.0)
self.assertEqual(doc.total_credits, 20000.0)
def test_blank_transaction_type_cell(self):
"""A blank type cell used to raise - `None.lower()` - instead of parsing the row."""
doc = self._create_bank_statement_import_log(
[
["Date", "Narration", "Transaction Type", "Amount"],
["01/04/2026", "ATM WDL", "Dr", "2,000.00"],
["03/04/2026", "SALARY", "Cr", "20,000.00"],
["05/04/2026", "UNKNOWN", None, "500.00"],
]
)
self.assertEqual(doc.detected_amount_format, 'Transaction type column has "CR"/"DR" values')
# The unmarked row has no direction of its own, so it counts as a withdrawal.
self.assertEqual(doc.total_debits, 2500.0)
self.assertEqual(doc.total_credits, 20000.0)
# ------------------------------------------------------------------ #
# PDF statement import
# ------------------------------------------------------------------ #
@@ -159,7 +339,8 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
else:
table["header_index"] = None
table["column_mapping"] = guess_column_mapping_by_content(table["rows"])
table["included"] = True
final_transactions, _df, _af = build_table_transactions(table)
table["included"] = should_include_table(table, final_transactions)
return table
def test_pdf_multi_page_kept_separate_and_unioned(self):
@@ -197,6 +378,74 @@ class TestBankStatementImportLog(ERPNextTestSuite, AccountsTestMixin):
final, _df, _af = build_table_transactions(ad_table)
self.assertEqual(final, [])
def test_pdf_summary_box_not_auto_included(self):
"""A summary box that happens to parse as one transaction must not start included.
The "Payment Due Date / Total Dues / Minimum Amount Due" block on an HDFC
credit-card statement has a date column and a figures column, so it yields a single
transaction - the due date and the minimum amount - and used to import as a phantom
row. What it does not have, and a real transaction table always does, is a narration.
"""
summary_box = {
"header_index": 1,
"rows": [
["Statement Date:17/08/2025", "Card No: 4341 55XX XXXX 2754", ""],
["Payment Due Date", "Total Dues", "Minimum Amount Due"],
["06/09/2025", "73,200.00", "3,660.00"],
["Credit Limit", "Available Credit Limit", "Available Cash Limit"],
["", "32,800", ""],
],
"column_mapping": [
{"index": 0, "header_text": "Payment Due Date", "variable": "a", "maps_to": "Date"},
{"index": 1, "header_text": "Total Dues", "variable": "b", "maps_to": "Do not import"},
{"index": 2, "header_text": "Minimum Amount Due", "variable": "c", "maps_to": "Amount"},
],
}
final, _df, _af = build_table_transactions(summary_box)
# It really does parse as a transaction - that is why the previous check missed it.
self.assertEqual(len(final), 1)
self.assertFalse(should_include_table(summary_box, final))
# The transaction table beside it, which does carry a narration, still starts included.
transactions = self._auto_map(
{
"rows": [
["Date", "Transaction Description", "Amount (in Rs.)"],
["21/07/2025", "ITC MAURYA NEW DELHI", "2,495.00"],
["27/07/2025", "PETRO SURCHARGE WAIVER", "50.90Cr"],
]
}
)
self.assertTrue(transactions["included"])
def test_pdf_table_without_description_still_importable(self):
"""No narration column means "starts unticked", NOT "cannot be imported".
`description` is not mandatory on Bank Transaction, so a bank that omits narration
must still import once the user ticks the table.
"""
table = {
"header_index": 0,
"rows": [
["Date", "Amount", "Balance"],
["01/04/2025", "500.00", "9,500.00"],
["03/04/2025", "20000.00", "29,500.00"],
],
"column_mapping": [
{"index": 0, "header_text": "Date", "variable": "a", "maps_to": "Date"},
{"index": 1, "header_text": "Amount", "variable": "b", "maps_to": "Amount"},
{"index": 2, "header_text": "Balance", "variable": "c", "maps_to": "Balance"},
],
}
final, _df, _af = build_table_transactions(table)
self.assertFalse(should_include_table(table, final))
# The transactions themselves are intact and importable.
self.assertEqual(len(final), 2)
self.assertEqual([t["date"] for t in final], ["2025-04-01", "2025-04-03"])
def test_headerless_content_mapping(self):
"""Without a header row, columns are guessed from their contents."""
rows = [

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
@@ -147,6 +148,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 = [
@@ -161,6 +187,7 @@ class Dunning(AccountsController):
"Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
"Payment Entry",
]
@frappe.whitelist()
@@ -259,11 +286,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):
@@ -275,6 +364,7 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
.join(overdue_payment)
.on(overdue_payment.parent == dunning.name)
.select(dunning.name)
.distinct()
.where(
(dunning.status == state)
& (dunning.docstatus != 2)

View File

@@ -12,6 +12,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,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -54,6 +55,123 @@ class TestDunning(ERPNextTestSuite):
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.
@@ -122,6 +240,41 @@ class TestDunning(ERPNextTestSuite):
self.assertEqual(sales_invoice.status, "Overdue")
self.assertEqual(dunning.status, "Unresolved")
def test_payment_against_invoice_with_multiple_overdue_installments_in_dunning(self):
"""
When an invoice has more than one overdue installment, its Dunning holds one
Overdue Payment row per installment. Submitting a Payment Entry for the invoice
must resolve the Dunning without raising a TimestampMismatchError caused by the
same Dunning being loaded and saved more than once.
"""
create_payment_terms_template_for_dunning()
# Post far enough in the past that BOTH installments (5 and 10 credit days) are overdue.
sales_invoice = create_sales_invoice_against_cost_center(
posting_date=add_days(today(), -15),
qty=1,
rate=100,
do_not_submit=True,
)
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
sales_invoice.submit()
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
# Two overdue installments -> two overdue payment rows for the same invoice.
self.assertEqual(len(dunning.overdue_payments), 2)
dunning.submit()
self.assertEqual(dunning.status, "Unresolved")
# Pay the invoice in full. This previously raised TimestampMismatchError on the Dunning.
pe = get_payment_entry("Sales Invoice", sales_invoice.name)
pe.reference_no, pe.reference_date = "3", nowdate()
pe.insert()
pe.submit()
sales_invoice.reload()
dunning.reload()
self.assertEqual(sales_invoice.outstanding_amount, 0)
self.assertEqual(dunning.status, "Resolved")
def test_dunning_resolution_from_credit_note(self):
"""
Test that dunning is resolved when a credit note is issued against the original invoice.

View File

@@ -31,6 +31,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_validat
AccountFilterValidator,
CalculationFormulaValidator,
DependencyValidator,
get_valid_api_method,
)
from erpnext.accounts.report.financial_statements import (
get_columns,
@@ -478,7 +479,10 @@ class DataCollector:
if company:
query = query.where(account.company == company)
if conditions := filter_parser.build_conditions(account_rows, account):
# filters are optional: no filter means all (enabled, non-group) accounts of the company.
# invalid filters can't reach here — build_conditions raises on them (raise_on_invalid).
conditions = filter_parser.build_conditions(account_rows, account, raise_on_invalid=True)
if conditions is not None:
query = query.where(conditions)
return query.run(pluck=True)
@@ -790,17 +794,20 @@ class FilterExpressionParser:
def __init__(self):
self.validator = AccountFilterValidator()
def build_conditions(self, report_rows, table):
def build_conditions(self, report_rows, table, raise_on_invalid=False):
conditions = []
for row in report_rows or []:
condition = self.build_condition(row, table)
condition = self.build_condition(row, table, raise_on_invalid=raise_on_invalid)
if condition is not None:
conditions.append(condition)
if not conditions:
return None
# ensure brackets in or condition
return reduce(lambda a, b: (a) | (b), conditions)
def build_condition(self, report_row, table):
def build_condition(self, report_row, table, raise_on_invalid=False):
"""
Build SQL condition directly from filter formula.
@@ -830,9 +837,11 @@ class FilterExpressionParser:
if not filter_formula:
return None
errors = self.validator.validate(report_row)
errors = self.validator.validate_filter(report_row)
if not errors.is_valid:
error_messages = [str(issue) for issue in errors.issues]
if raise_on_invalid:
frappe.throw("<br><br>".join(error_messages), title=_("Invalid Filter"))
frappe.log_error(f"Filter validation errors found:\n{'<br><br>'.join(error_messages)}")
return None
@@ -1022,7 +1031,11 @@ class FormulaFieldUpdater:
@frappe.whitelist()
def get_filtered_accounts(company: str, account_rows: str | list):
if not company:
frappe.throw(_("Company is required"), title=_("Missing Company"))
frappe.has_permission("Financial Report Template", ptype="read", throw=True)
frappe.has_permission("Company", doc=company, throw=True)
if isinstance(account_rows, str):
account_rows = json.loads(account_rows, object_hook=frappe._dict)
@@ -1164,10 +1177,12 @@ class RowProcessor:
def _process_api_row(self, row) -> RowData:
api_path = row.calculation_formula
# TODO
method = get_valid_api_method(api_path)
try:
values = frappe.call(api_path, filters=self.context.filters, periods=self.period_list, row=row)
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-codeinjection-eval
values = frappe.call(method, filters=self.context.filters, periods=self.period_list, row=row)
if row.reverse_sign:
values = [-1 * v for v in values]

View File

@@ -163,7 +163,7 @@ function show_accounts_tree(template_rows, has_selection) {
fieldname: "company",
fieldtype: "Link",
options: "Company",
label: "Company",
label: __("Company"),
reqd: 1,
default: frappe.defaults.get_user_default("Company"),
onchange: () => {
@@ -176,7 +176,7 @@ function show_accounts_tree(template_rows, has_selection) {
fieldname: "view_type",
fieldtype: "Select",
options: ["Missing Accounts", "Filtered Accounts"],
label: "View",
label: __("View"),
default: has_selection ? "Filtered Accounts" : "Missing Accounts",
reqd: 1,
onchange: () => {
@@ -192,10 +192,10 @@ function show_accounts_tree(template_rows, has_selection) {
{
fieldname: "tip",
fieldtype: "HTML",
label: "Tip",
label: __("Tip"),
options: `
<div class="alert alert-success" role="alert">
Tip: Select report lines to view their accounts
${__("Tip: Select report lines to view their accounts")}
</div>
`,
depends_on: has_selection ? "eval: false" : "eval: true",
@@ -203,7 +203,7 @@ function show_accounts_tree(template_rows, has_selection) {
{
fieldname: "tree_area",
fieldtype: "HTML",
label: "Chart of Accounts",
label: __("Chart of Accounts"),
read_only: 1,
depends_on: "eval: doc.company",
},
@@ -288,14 +288,14 @@ function update_formula_label(frm, data_source) {
if (!field) return;
const labels = {
"Account Data": "Account Filter",
"Custom API": "API Method Path",
"Account Data": __("Account Filter"),
"Custom API": __("API Method Path"),
};
grid.update_docfield_property(
"calculation_formula",
"label",
labels[data_source] || "Calculation Formula"
labels[data_source] || __("Calculation Formula")
);
}
@@ -370,7 +370,7 @@ function update_formula_description(frm, data_source) {
description_html = `
<div ${container_style}>
<h5 ${title_style}>Custom API Setup</h5>
<p ${text_style}>Path to your custom method that returns financial data.</p>
<p ${text_style}>Path to your custom whitelisted method that returns financial data. It must permit GET requests.</p>
<h6 ${subtitle_style}>Format:</h6>
<ul ${list_style}>
@@ -380,7 +380,8 @@ function update_formula_description(frm, data_source) {
<h6 ${subtitle_style}>Method Signature:</h6>
<div ${code_style}>
<pre ${pre_style}>def get_custom_data(filters, periods, row): <br>&nbsp; # filters: dict — report filters (company, period, etc.) <br>&nbsp; # periods: list[dict] — period definitions <br>&nbsp; # row: dict — the current report row <br><br>&nbsp; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
<!-- &#10; is used for line breaks since frappe.render replaces newlines with spaces -->
<pre ${pre_style} class="language-python">@frappe.whitelist(methods=["GET"])&#10;def get_custom_data(filters, periods, row):&#10; # filters: dict — report filters (company, period, etc.)&#10; # periods: list[dict] — period definitions&#10; # row: dict — the current report row&#10;&#10; return [1000.0, 1200.0, 1150.0] # one value per period</pre>
</div>
<h6 ${subtitle_style}>Return Format:</h6>

View File

@@ -10,18 +10,41 @@ from enum import Enum
from typing import Any, ClassVar
import frappe
from frappe import _
from frappe import _, is_whitelisted
from frappe.database.operator_map import OPERATOR_MAP
from frappe.database.query import SQLFunctionParser
def get_valid_api_method(api_path: str):
"""Resolve `api_path`, ensuring it is whitelisted and permits GET (i.e. read-only)."""
method = frappe.get_attr(api_path)
is_whitelisted(method)
if "GET" not in frappe.allowed_http_methods_for_whitelisted_func.get(method, ()):
frappe.throw(
_("Method {0} must permit GET requests").format(frappe.bold(api_path)),
frappe.PermissionError,
title=_("Method Not Allowed"),
)
return method
def get_formula_field_label(data_source: str) -> str:
# Must mirror the `labels` map in financial_report_template.js (update_formula_label),
labels = {
"Account Data": _("Account Filter"),
"Custom API": _("API Method Path"),
}
return labels.get(data_source, _("Calculation Formula"))
@dataclass
class ValidationIssue:
"""Represents a single validation issue"""
message: str
row_idx: int | None = None
field: str | None = None
details: dict[str, Any] = None
def __post_init__(self):
@@ -29,10 +52,9 @@ class ValidationIssue:
self.details = {}
def __str__(self) -> str:
prefix = f"Row {self.row_idx}: " if self.row_idx else ""
field_info = f"[{self.field}] " if self.field else ""
message = f"{prefix}{field_info}{self.message}"
return _(message)
if self.row_idx:
return _("Row {0}: {1}", context="Financial Report Template").format(self.row_idx, self.message)
return self.message
@dataclass
@@ -134,7 +156,9 @@ class TemplateStructureValidator(Validator):
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
result.add_error(
ValidationIssue(
message=f"Invalid line reference format: '{ref_code}'. Must start with letter and contain only letters, numbers, underscores, and hyphens",
message=_(
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
).format(ref_code),
row_idx=row.idx,
)
)
@@ -143,7 +167,7 @@ class TemplateStructureValidator(Validator):
if ref_code in used_codes:
result.add_error(
ValidationIssue(
message=f"Duplicate line reference: '{ref_code}'",
message=_("Duplicate line reference: '{0}'").format(ref_code),
row_idx=row.idx,
)
)
@@ -159,7 +183,7 @@ class TemplateStructureValidator(Validator):
if row.data_source == "Account Data" and not row.balance_type:
result.add_error(
ValidationIssue(
message="Balance Type is required for Account Data",
message=_("Balance Type is required for Account Data"),
row_idx=row.idx,
)
)
@@ -169,7 +193,11 @@ class TemplateStructureValidator(Validator):
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message=f"Formula is required for {row.data_source}",
message=_("{0} is required when {1} is {2}").format(
get_formula_field_label(row.data_source),
row.meta.get_translated_label("data_source"),
_(row.data_source),
),
row_idx=row.idx,
)
)
@@ -196,7 +224,14 @@ class DependencyValidator(Validator):
for row in self.template.rows:
if row.reference_code and row.data_source == "Calculated Amount" and row.calculation_formula:
deps = extract_reference_codes_from_formula(row.calculation_formula, list(available_codes))
# skip self-reference, `CalculationFormulaValidator` already reports it
deps = [
code
for code in extract_reference_codes_from_formula(
row.calculation_formula, list(available_codes)
)
if code != row.reference_code
]
if deps:
graph[row.reference_code] = deps
@@ -226,7 +261,7 @@ class DependencyValidator(Validator):
cycle = [*path[cycle_start:], node]
result.add_error(
ValidationIssue(
message=f"Circular dependency detected: {''.join(cycle)}",
message=_("Circular dependency detected: {0}").format("".join(cycle)),
)
)
return
@@ -258,7 +293,9 @@ class DependencyValidator(Validator):
row_idx = self._get_row_idx(ref_code)
result.add_error(
ValidationIssue(
message=f"Line References undefined in Formula: {', '.join(undefined)}",
message=_("Line references undefined in {0}: {1}").format(
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
),
row_idx=row_idx,
)
)
@@ -285,16 +322,6 @@ class CalculationFormulaValidator(Validator):
if row.data_source != "Calculated Amount":
return result
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message="Formula is required for Calculated Amount",
row_idx=row.idx,
field="Formula",
)
)
return result
formula = self._preprocess_formula(row.calculation_formula)
row.calculation_formula = formula
@@ -302,7 +329,7 @@ class CalculationFormulaValidator(Validator):
if not self._are_parentheses_balanced(formula):
result.add_error(
ValidationIssue(
message="Formula has unbalanced parentheses",
message=_("Formula has unbalanced parentheses"),
row_idx=row.idx,
)
)
@@ -314,17 +341,7 @@ class CalculationFormulaValidator(Validator):
if row.reference_code and row.reference_code in refs:
result.add_error(
ValidationIssue(
message=f"Formula references itself ('{row.reference_code}')",
row_idx=row.idx,
)
)
# Check undefined references
undefined = set(refs) - set(available_codes)
if undefined:
result.add_error(
ValidationIssue(
message=f"Formula references undefined codes: {', '.join(undefined)}",
message=_("Formula references itself ('{0}')").format(row.reference_code),
row_idx=row.idx,
)
)
@@ -334,7 +351,7 @@ class CalculationFormulaValidator(Validator):
if eval_error:
result.add_error(
ValidationIssue(
message=f"Formula evaluation error: {eval_error}",
message=_("Formula evaluation error: {0}").format(eval_error),
row_idx=row.idx,
)
)
@@ -371,7 +388,7 @@ class CalculationFormulaValidator(Validator):
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
if not isinstance(result, (int, float)): # noqa: UP038
return f"Formula must return a numeric value, got {type(result).__name__}"
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
return None
except Exception as e:
@@ -386,20 +403,19 @@ class AccountFilterValidator(Validator):
self.account_fields = account_fields or set(self.account_meta._valid_columns)
def validate(self, row) -> ValidationResult:
result = ValidationResult()
# dispatch-path guard: only account-data rows are validated here
if row.data_source != "Account Data":
return result
return ValidationResult()
if not row.calculation_formula:
result.add_error(
ValidationIssue(
message="Account filter is required for Account Data",
row_idx=row.idx,
field="Formula",
)
)
return result
return self.validate_filter(row)
def validate_filter(self, row) -> ValidationResult:
"""Validate calculation_formula as an Account filter, regardless of data_source.
The caller has already decided this row is an account filter, so unlike
`validate()` this does not opt out based on `data_source`.
"""
result = ValidationResult()
try:
filter_config = json.loads(row.calculation_formula)
@@ -412,18 +428,21 @@ class AccountFilterValidator(Validator):
if error:
result.add_error(
ValidationIssue(
message=error,
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label("Account Data"), error
),
row_idx=row.idx,
field="Account Filter",
)
)
except json.JSONDecodeError as e:
result.add_error(
ValidationIssue(
message=f"Invalid JSON format: {e!s}",
message=_("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label("Account Data"),
_("Invalid JSON format: {0}").format(str(e)),
),
row_idx=row.idx,
field="Account Filter",
)
)
@@ -438,36 +457,35 @@ class AccountFilterValidator(Validator):
# simple condition: [field, operator, value]
if isinstance(filter_config, list):
if len(filter_config) != 3:
return "Filter must be [field, operator, value]"
return _("Filter must be [field, operator, value]")
field, operator, value = filter_config
if not isinstance(field, str) or not isinstance(operator, str):
return "Field and operator must be strings"
display = (field if advanced_filtering else self.account_meta.get_label(field)) or field
return _("Field and operator must be strings")
if field not in account_fields:
return f"Field '{display}' is not a valid Account field"
# escape: `field` is caller-supplied and this message renders as HTML
return _("Field '{0}' is not a valid Account field").format(frappe.utils.escape_html(field))
if operator.casefold() not in OPERATOR_MAP:
return f"Invalid operator '{operator}'"
return _("Invalid operator '{0}'").format(operator)
if operator in ["in", "not in"] and not isinstance(value, list):
return f"Operator '{operator}' requires a list value"
return _("Operator '{0}' requires a list value").format(operator)
# logical condition: {"and": [condition1, condition2]}
elif isinstance(filter_config, dict):
if len(filter_config) != 1:
return "Logical condition must have exactly one operator"
return _("Logical condition must have exactly one operator")
op = next(iter(filter_config.keys())).lower()
if op not in ["and", "or"]:
return "Logical operators must be 'and' or 'or'"
return _("Logical operators must be 'and' or 'or'")
conditions = filter_config[next(iter(filter_config.keys()))]
if not isinstance(conditions, list) or len(conditions) < 1:
return "Logical conditions need at least 1 sub-condition"
return _("Logical conditions need at least 1 sub-condition")
# recursive
for condition in conditions:
@@ -475,7 +493,7 @@ class AccountFilterValidator(Validator):
if error:
return error
else:
return "Filter must be a list or dict"
return _("Filter must be a list or dict")
return None
@@ -511,34 +529,32 @@ class FormulaValidator(Validator):
if "." not in api_path:
result.add_error(
ValidationIssue(
message="Custom API path should be in format: app.module.method",
message=_("{0} should be in format: app.module.method").format(
get_formula_field_label(row.data_source)
),
row_idx=row.idx,
field="Formula",
)
)
return result
# Method exists?
try:
module_path, method_name = api_path.rsplit(".", 1)
module = frappe.get_module(module_path)
if not hasattr(module, method_name):
result.add_error(
ValidationIssue(
message=f"Method '{method_name}' not found in module '{module_path}' (might be environment-specific)",
row_idx=row.idx,
field="Formula",
)
)
get_valid_api_method(api_path)
except Exception as e:
result.add_error(
ValidationIssue(
message=f"Could not validate API path: {e!s}",
row_idx=row.idx,
field="Formula",
if isinstance(e, frappe.PermissionError | frappe.ValidationError):
# frappe.throw inside get_valid_api_method logs a message that would pop up in UI
frappe.clear_last_message()
if isinstance(e, frappe.PermissionError):
message = _("[{0}] {1}", context="Financial Report Template").format(
get_formula_field_label(row.data_source),
_("Method '{0}' must be whitelisted and permit GET requests").format(api_path),
)
)
else:
message = _("Could not validate {0}: {1}").format(
get_formula_field_label(row.data_source), str(e)
)
result.add_error(ValidationIssue(message=message, row_idx=row.idx))
return result

View File

@@ -2,7 +2,13 @@
# For license information, please see license.txt
import frappe
from frappe.tests.utils import whitelist_for_tests
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
AccountFilterValidator,
FormulaValidator,
get_valid_api_method,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -72,3 +78,173 @@ class FinancialReportTemplateTestCase(ERPNextTestSuite):
{"doctype": "Financial Report Template", "template_name": template_name, "rows": rows_data}
)
return template
def not_whitelisted_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["POST"])
def whitelisted_post_only_method(**kwargs):
return [42.0]
@whitelist_for_tests(methods=["GET"])
def whitelisted_get_method(**kwargs):
return [42.0]
class TestCustomAPIValidation(FinancialReportTemplateTestCase):
"""Custom API rows must point to whitelisted methods that permit GET"""
TEST_MODULE = "erpnext.accounts.doctype.financial_report_template.test_financial_report_template"
NOT_WHITELISTED = f"{TEST_MODULE}.not_whitelisted_method"
WHITELISTED_POST_ONLY = f"{TEST_MODULE}.whitelisted_post_only_method"
WHITELISTED_GET = f"{TEST_MODULE}.whitelisted_get_method"
def create_api_template(self, api_path):
template = self.create_test_template_with_rows(
[
{
"reference_code": "API001",
"display_name": "API Row",
"data_source": "Custom API",
"calculation_formula": api_path,
}
]
)
template.report_type = "Profit and Loss Statement"
return template
def test_get_valid_api_method(self):
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.NOT_WHITELISTED)
self.assertRaises(frappe.PermissionError, get_valid_api_method, self.WHITELISTED_POST_ONLY)
self.assertEqual(get_valid_api_method(self.WHITELISTED_GET), frappe.get_attr(self.WHITELISTED_GET))
def test_save_rejects_invalid_api_methods(self):
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
self.assertRaises(frappe.ValidationError, template.insert)
def test_save_allows_get_whitelisted_method(self):
template = self.create_api_template(self.WHITELISTED_GET)
template.insert()
template.delete()
def test_engine_rejects_invalid_api_methods(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY):
template = self.create_api_template(api_path)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
self.assertRaises(frappe.PermissionError, processor._process_api_row, template.rows[0])
def test_engine_calls_valid_api_method(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
ReportContext,
RowProcessor,
)
template = self.create_api_template(self.WHITELISTED_GET)
context = ReportContext(template=template, filters={}, period_list=[{"key": "p1"}])
processor = RowProcessor(context)
row_data = processor._process_api_row(template.rows[0])
self.assertEqual(row_data.values, [42.0])
def test_validation_keeps_message_log_clean(self):
validator = FormulaValidator(frappe._dict(rows=[]))
message_count = len(frappe.local.message_log)
# last path raises AppNotInstalledError, which also logs a message via frappe.throw
for api_path in (self.NOT_WHITELISTED, self.WHITELISTED_POST_ONLY, "missing_app.api.method"):
row = frappe._dict(data_source="Custom API", calculation_formula=api_path, idx=1)
result = validator.validate(row)
self.assertFalse(result.is_valid)
self.assertEqual(len(frappe.local.message_log), message_count)
class TestAccountFilter(FinancialReportTemplateTestCase):
"""Filter fields must be validated on the account-filter parser path."""
@staticmethod
def _row(formula, **extra):
return frappe._dict(calculation_formula=formula, idx=1, **extra)
def test_validate_filter_enforces_allow_list_without_data_source(self):
# the parser path has no `data_source`; the field allow-list must still apply
validator = AccountFilterValidator()
self.assertFalse(validator.validate_filter(self._row('["bad_field", "=", "x"]')).is_valid)
self.assertTrue(validator.validate_filter(self._row('["root_type", "=", "Income"]')).is_valid)
def test_validate_gate_still_opts_out_for_non_account_data(self):
# validate() is the dispatch gate: it must not validate non "Account Data" rows
validator = AccountFilterValidator()
row = self._row('["bad_field", "=", "x"]', data_source="Custom API")
self.assertTrue(validator.validate(row).is_valid)
def test_error_message_labels_and_escapes_field(self):
validator = AccountFilterValidator()
result = validator.validate_filter(self._row('["<script>", "=", "x"]'))
message = str(result.issues[0])
self.assertIn("[Account Filter]", message)
self.assertIn("&lt;script&gt;", message)
self.assertNotIn("<script>", message)
def test_build_conditions_raises_on_invalid_field_when_opted_in(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FilterExpressionParser,
)
account = frappe.qb.DocType("Account")
rows = [self._row('["bad_field", "=", "x"]')]
parser = FilterExpressionParser()
# default: invalid rows are skipped, not raised
self.assertIsNone(parser.build_conditions(rows, account))
# opted in (the get_filtered_accounts path): invalid rows raise
self.assertRaises(
frappe.ValidationError, parser.build_conditions, rows, account, raise_on_invalid=True
)
def test_build_conditions_empty_returns_none(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FilterExpressionParser,
)
account = frappe.qb.DocType("Account")
self.assertIsNone(FilterExpressionParser().build_conditions([], account))
def test_endpoint_requires_company(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
get_filtered_accounts,
)
self.assertRaises(frappe.ValidationError, get_filtered_accounts, "", "[]")
def test_endpoint_rejects_invalid_field(self):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
get_filtered_accounts,
)
company = frappe.get_all("Company", limit=1, pluck="name")[0]
rows = frappe.as_json([{"calculation_formula": '["bad_field", "=", "x"]'}])
self.assertRaises(frappe.ValidationError, get_filtered_accounts, company, rows)
def test_endpoint_empty_rows_returns_all_company_accounts(self):
# filters are optional: no filter returns every enabled, non-group account of the company
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
get_filtered_accounts,
)
company = frappe.get_all("Company", limit=1, pluck="name")[0]
expected = frappe.get_all(
"Account",
filters={"company": company, "disabled": 0, "is_group": 0},
pluck="name",
)
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))

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

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_import": 1,
"autoname": "ACC-INV-DISC-.YYYY.-.#####",
"creation": "2019-03-07 12:01:56.296952",
@@ -170,7 +171,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:09:52.746196",
"modified": "2026-09-09 17:04:59.512294",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Invoice Discounting",
@@ -187,14 +188,15 @@
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"role": "Accounts Manager",
"share": 1,
"submit": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -2,8 +2,6 @@
# For license information, please see license.txt
import json
import frappe
from frappe import _
from frappe.utils import add_days, flt, getdate, nowdate
@@ -317,8 +315,15 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
def get_invoices(filters):
filters = frappe._dict(json.loads(filters))
def get_invoices(filters: str | dict):
filters = frappe._dict(frappe.parse_json(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

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

@@ -95,7 +95,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 () {
@@ -472,8 +472,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"]);
};
@@ -623,7 +623,7 @@ $.extend(erpnext.journal_entry, {
} else {
erpnext.journal_entry.set_debit_credit_in_company_currency(frm, cdt, cdn);
}
refresh_field("exchange_rate", cdn, "accounts");
frm.get_field("accounts").grid.refresh_row(cdn);
},
quick_entry: function (frm) {

View File

@@ -960,12 +960,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 = []
@@ -1402,6 +1404,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))
@@ -1447,6 +1450,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
@@ -1482,6 +1487,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"
)
@@ -1777,6 +1784,20 @@ def make_inter_company_journal_entry(name, voucher_type, company):
@frappe.whitelist()
def make_reverse_journal_entry(source_name, target_doc=None):
# `get_mapped_doc` checks this as well, but the guards below disclose which entry
# reverses which, so read access has to be settled before they run
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),
)
)
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
if existing_reverse:
frappe.throw(

View File

@@ -248,6 +248,27 @@ class TestJournalEntry(ERPNextTestSuite):
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)
@@ -388,6 +409,59 @@ class TestJournalEntry(ERPNextTestSuite):
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

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

@@ -297,6 +297,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()
if not frappe.in_test:

View File

@@ -4,9 +4,11 @@
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.account.test_account import create_account
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
from erpnext.tests.utils import ERPNextTestSuite
@@ -126,6 +128,55 @@ class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
for invoice in invoices:
self.assertEqual(frappe.db.get_value("Sales Invoice", invoice, "department"), "Sales - _TOIC")
@ERPNextTestSuite.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

@@ -46,23 +46,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,
};
});
@@ -106,21 +110,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
@@ -175,6 +176,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()
@@ -207,9 +209,15 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule()
self.make_gl_entries()
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.set_status()
self.trigger_invoice_update_for_subscriptions()
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], [])
@@ -313,6 +321,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()
self.trigger_invoice_update_for_subscriptions()
@@ -623,6 +632,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))
@@ -2883,17 +2896,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,
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.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)))
@@ -2988,7 +3005,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",
{
@@ -2996,21 +3013,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",
@@ -3300,8 +3319,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

@@ -726,6 +726,23 @@ class TestPaymentEntry(ERPNextTestSuite):
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

@@ -83,6 +83,7 @@ def get_supplier_query(doctype, txt, searchfield, start, page_len, filters):
@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

@@ -92,6 +92,7 @@ frappe.ui.form.on("Payment Request", "is_a_subscription", function (frm) {
freeze: true,
callback: function (data) {
if (!data.exc) {
frm.clear_table("subscription_plans");
$.each(data.message || [], function (i, v) {
var d = frappe.model.add_child(
frm.doc,

View File

@@ -711,6 +711,7 @@ def make_payment_request(**args):
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
party_account_currency = get_account_currency(party_account)
subscription_plans = get_subscription_details(ref_doc.doctype, ref_doc.name)
pr.update(
{
"payment_gateway_account": gateway_account.get("name"),
@@ -742,12 +743,24 @@ def make_payment_request(**args):
or gateway_account.get("payment_channel", "Email") != "Email"
),
"phone_number": args.get("phone_number") if args.get("phone_number") else None,
"is_a_subscription": 1 if subscription_plans else 0,
}
)
if selected_payment_schedules:
apply_payment_references(pr, payment_reference)
if subscription_plans:
pr.set(
"subscription_plans",
[
{
"plan": row.plan,
"qty": row.qty,
}
for row in subscription_plans
],
)
# Dimensions
pr.update(
{
@@ -1061,19 +1074,25 @@ def get_dummy_message(doc):
@frappe.whitelist()
def get_subscription_details(reference_doctype, reference_name):
if reference_doctype == "Sales Invoice":
subscriptions = frappe.db.sql(
"""SELECT parent as sub_name FROM `tabSubscription Invoice` WHERE invoice=%s""",
reference_name,
as_dict=1,
)
subscription_plans = []
for subscription in subscriptions:
plans = frappe.get_doc("Subscription", subscription.sub_name).plans
for plan in plans:
subscription_plans.append(plan)
return subscription_plans
def get_subscription_details(reference_doctype: str, reference_name: str) -> list[dict]:
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
if not frappe.get_meta(reference_doctype).has_field("subscription"):
return []
subscription = frappe.db.get_value(reference_doctype, reference_name, "subscription")
if not subscription:
return []
return frappe.get_all(
"Subscription Plan Detail",
filters={"parent": subscription, "parenttype": "Subscription", "parentfield": "plans"},
fields=[
"plan",
"qty",
],
)
@frappe.whitelist()
@@ -1169,6 +1188,7 @@ def get_irequests_of_payment_request(doc: str | None = None) -> list:
@frappe.whitelist()
def get_available_payment_schedules(reference_doctype, reference_name):
ref_doc = frappe.get_doc(reference_doctype, reference_name)
ref_doc.check_permission()
if not hasattr(ref_doc, "payment_schedule") or not ref_doc.payment_schedule:
return []

View File

@@ -261,12 +261,15 @@ def get_cashiers(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
def get_invoices(start, end, pos_profile, user):
invoice_doctype = frappe.db.get_single_value("POS Settings", "invoice_type")
frappe.has_permission("POS Profile", doc=pos_profile, throw=True)
frappe.has_permission("Sales Invoice", throw=True)
sales_inv_query = build_invoice_query("Sales Invoice", user, pos_profile, start, end)
query = sales_inv_query
if invoice_doctype == "POS Invoice":
frappe.has_permission("POS Invoice", throw=True)
pos_inv_query = build_invoice_query("POS Invoice", user, pos_profile, start, end)
query = query + pos_inv_query

View File

@@ -497,6 +497,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",
@@ -1636,7 +1637,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2026-05-28 12:22:50.253090",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",

View File

@@ -280,40 +280,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,8 +12,9 @@
{
"fieldname": "fieldname",
"fieldtype": "Data",
"hidden": 1,
"label": "Fieldname"
"in_list_view": 1,
"label": "Fieldname",
"read_only": 1
},
{
"fieldname": "field",
@@ -26,7 +27,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-07-29 18:08:40.323579",
"modified": "2026-08-31 20:41:12.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Search Fields",

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) {
@@ -42,74 +11,97 @@ frappe.ui.form.on("POS Settings", {
frm.trigger("add_search_options");
},
invoice_type: function (frm) {
frm.trigger("get_invoice_fields");
},
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 invoice_type = frm.doc.invoice_type;
if (!invoice_type) return;
frappe.model.with_doctype(invoice_type, () => {
// the invoice type can change again while the meta loads
if (frm.doc.invoice_type !== invoice_type) return;
const fields = frappe.get_doc("DocType", invoice_type).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 };
})
)
);
frm.trigger("validate_invoice_fields");
});
},
validate_invoice_fields: function (frm) {
const valid_fieldnames = frappe
.get_doc("DocType", frm.doc.invoice_type)
.fields.filter(is_valid_invoice_field)
.map((df) => df.fieldname);
const invalid_fields = (frm.doc.invoice_fields || [])
.filter((row) => row.fieldname && !valid_fieldnames.includes(row.fieldname))
.map((row) => `#${row.idx} ${row.fieldname}`);
if (!invalid_fields.length) return;
frappe.msgprint({
title: __("Invalid POS Fields"),
indicator: "orange",
message: __("The following rows are not valid fields of {0} and have to be removed: {1}", [
frm.doc.invoice_type.bold(),
invalid_fields.join(", "),
]),
});
},
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");
},
});
frappe.ui.form.on("POS Field", {
fieldname: function (frm, doctype, name) {
var doc = frappe.get_doc(doctype, name);
var df = $.map(frappe.get_doc("DocType", "POS Invoice").fields, function (d) {
return doc.fieldname == d.fieldname ? d : null;
})[0];
const doc = frappe.get_doc(doctype, name);
const invoice_meta = frappe.get_doc("DocType", frm.doc.invoice_type);
const df = invoice_meta?.fields.find((d) => d.fieldname === doc.fieldname);
if (!df) return;
doc.label = df.label;
doc.reqd = df.reqd;
doc.options = df.options;
doc.fieldtype = df.fieldtype;
doc.default_value = df.default;
frm.refresh_field("fields");
frm.refresh_field("invoice_fields");
},
});

View File

@@ -5,8 +5,46 @@ 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
@@ -32,17 +70,10 @@ class POSSettings(Document):
if old_doc.invoice_type != self.invoice_type:
self.validate_invoice_type()
self.validate_duplicate_invoice_fields()
self.validate_invoice_fields()
def validate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
self.validate_duplicate_pos_search_fields()
self.validate_pos_search_fields()
def validate_invoice_type(self):
pos_opening_entries_count = frappe.db.count(
@@ -55,3 +86,94 @@ class POSSettings(Document):
),
title=_("Invoice Document Type Selection Error"),
)
def validate_duplicate_invoice_fields(self):
invoice_fields = [field.fieldname for field in self.invoice_fields]
duplicate_invoice_fields = {key for key, value in Counter(invoice_fields).items() if value > 1}
if len(duplicate_invoice_fields):
for field in duplicate_invoice_fields:
frappe.throw(
title=_("Duplicate POS Fields"), msg=_("'{0}' has been already added.").format(field)
)
def validate_invoice_fields(self):
if not self.invoice_type:
return
meta = frappe.get_meta(self.invoice_type)
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(_(self.invoice_type))
),
)
# 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]
duplicate_fieldnames = {key for key, value in Counter(fieldnames).items() if value > 1}
for fieldname in duplicate_fieldnames:
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

@@ -2,8 +2,135 @@
# See license.txt
import unittest
import frappe
from erpnext.patches.v16_0.append_fieldname_to_pos_search_fields import execute as append_fieldname
from erpnext.tests.utils import ERPNextTestSuite
class TestPOSSettings(ERPNextTestSuite):
pass
def setUp(self):
self.settings = frappe.get_single("POS Settings")
self.settings.invoice_fields = []
self.settings.pos_search_fields = []
def assertInvalid(self, message):
with self.assertRaises(frappe.ValidationError) as context:
self.settings.save()
self.assertIn(message, str(context.exception))
def test_invoice_field_is_validated_against_invoice_type(self):
# consolidated_invoice exists on POS Invoice only
self.settings.invoice_type = "POS Invoice"
self.settings.append("invoice_fields", {"fieldname": "consolidated_invoice"})
self.settings.save()
self.settings.invoice_type = "Sales Invoice"
self.assertInvalid("is not a valid field of")
def test_field_common_to_both_invoice_types_is_allowed(self):
for invoice_type in ("POS Invoice", "Sales Invoice"):
self.settings.invoice_type = invoice_type
self.settings.invoice_fields = []
self.settings.append("invoice_fields", {"fieldname": "po_no"})
self.settings.save()
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)")
def test_invoice_fields_are_skipped_when_no_invoice_type_is_selected(self):
self.settings.invoice_type = None
self.settings.append("invoice_fields", {"fieldname": "customer"})
self.settings.save()
self.assertEqual(self.settings.invoice_fields[0].fieldname, "customer")

View File

@@ -142,6 +142,8 @@ def start_pcv_processing(docname: str):
@frappe.whitelist()
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()
@@ -157,6 +159,8 @@ def pause_pcv_processing(docname: str):
@frappe.whitelist()
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()
@@ -171,6 +175,8 @@ def cancel_pcv_processing(docname: str):
@frappe.whitelist()
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()

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);
@@ -418,7 +418,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", "");
@@ -482,28 +482,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) {
@@ -716,7 +714,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

@@ -504,6 +504,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",
@@ -1703,7 +1704,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-08-05 15:40:16.519774",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -42,6 +42,7 @@ from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
update_billed_amount_based_on_po,
)
@@ -286,9 +287,7 @@ class PurchaseInvoice(BuyingController):
self.check_conversion_rate()
self.validate_credit_to_acc()
self.clear_unallocated_advances("Purchase Invoice Advance", "advances")
self.check_for_on_hold_or_closed_status(
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
)
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
self.validate_with_previous_doc()
self.validate_uom_is_integer("uom", "qty")
self.validate_uom_is_integer("stock_uom", "stock_qty")
@@ -300,6 +299,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
)
@@ -323,6 +323,47 @@ 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."))
@@ -983,6 +1024,10 @@ class PurchaseInvoice(BuyingController):
gl_entries.append(self.get_gl_dict(gl, self.party_account_currency, item=self))
def make_item_gl_entries(self, gl_entries):
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
get_custom_dimension_overrides,
)
# item gl entries
stock_items = self.get_stock_items()
if self.update_stock and self.auto_accounting_for_stock:
@@ -1124,25 +1169,34 @@ class PurchaseInvoice(BuyingController):
# Amount added through landed-cost-voucher
if landed_cost_entries:
if (item.item_code, item.name) in landed_cost_entries:
for account, base_amount in landed_cost_entries[
(item.item_code, item.name)
].items():
gl_entries.append(
self.get_gl_dict(
{
"account": account,
"against": item.expense_account,
"cost_center": item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(base_amount["base_amount"]),
"credit_in_account_currency": flt(base_amount["amount"]),
"credit_in_transaction_currency": item.net_amount,
"project": item.project or self.project,
},
item=item,
)
for entry in landed_cost_entries.get((item.item_code, item.name), []):
if not (entry.amount or entry.base_amount):
continue
lcv_account_currency = get_account_currency(entry.expense_account)
credit_in_transaction_currency = (
flt(entry.amount)
if lcv_account_currency == self.currency
else flt(
entry.base_amount / self.conversion_rate, item.precision("net_amount")
)
)
gl_dict = self.get_gl_dict(
{
"account": entry.expense_account,
"against": item.expense_account,
"cost_center": entry.dimensions.cost_center or item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(entry.base_amount),
"credit_in_account_currency": flt(entry.amount),
"credit_in_transaction_currency": credit_in_transaction_currency,
"project": entry.dimensions.project or item.project or self.project,
},
item=item,
)
gl_dict.update(get_custom_dimension_overrides(entry))
gl_entries.append(gl_dict)
# sub-contracting warehouse
if flt(item.rm_supp_cost):
@@ -1752,9 +1806,7 @@ class PurchaseInvoice(BuyingController):
super().on_cancel()
PurchaseTaxWithholding(self).on_cancel()
self.check_for_on_hold_or_closed_status(
"Purchase Order", "purchase_order", exclude_if_field="purchase_receipt"
)
self.check_purchase_order_on_hold_or_close("purchase_order", exclude_if_field="purchase_receipt")
if self.is_return and not self.update_billed_amount_in_purchase_order:
# NOTE status updating bypassed for is_return
@@ -2081,6 +2133,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)
@@ -2104,15 +2161,13 @@ def make_purchase_receipt(source_name, target_doc=None, args=None):
or {}
)
target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
target.received_qty = flt(obj.qty) - flt(obj.received_qty)
target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
obj.conversion_factor
)
target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
target.base_amount = (
(flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
)
pending_qty = flt(obj.qty) - received_and_mapped_qty(obj)
target.qty = pending_qty - flt(returned_qty_map.get("qty"))
target.received_qty = pending_qty
target.stock_qty = (pending_qty - flt(returned_qty_map.get("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", [])
@@ -2142,7 +2197,9 @@ 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

@@ -507,6 +507,12 @@ class TestPurchaseInvoice(ERPNextTestSuite, 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",
@@ -518,25 +524,15 @@ class TestPurchaseInvoice(ERPNextTestSuite, 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):
@@ -544,7 +540,17 @@ class TestPurchaseInvoice(ERPNextTestSuite, 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"
@@ -558,34 +564,20 @@ class TestPurchaseInvoice(ERPNextTestSuite, 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.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}, "credit"
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(flt(discrepancy_caused_by_exchange_rate_diff, 2), amount)
def test_purchase_invoice_change_naming_series(self):
pi = frappe.copy_doc(self.globalTestRecords["Purchase Invoice"][1])
pi.insert()
@@ -3060,6 +3052,23 @@ class TestPurchaseInvoice(ERPNextTestSuite, 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

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

@@ -368,7 +368,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
let filters = {
docstatus: 1,
status: ["not in", ["Closed", "On Hold"]],
per_billed: ["<", 99.99],
company: me.frm.doc.company,
};
@@ -387,6 +386,8 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
customer: me.frm.doc.customer || undefined,
},
get_query_filters: filters,
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

@@ -612,6 +612,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",
@@ -2364,7 +2365,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-05-28 12:15:12.486443",
"modified": "2026-08-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -1877,7 +1877,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:

View File

@@ -1484,6 +1484,33 @@ class TestSalesInvoice(ERPNextTestSuite):
frappe.db.set_single_value("POS Settings", "post_change_gl_entries", 1)
def test_pos_change_amount_multi_currency_gl_entry(self):
frappe.db.set_single_value("POS 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("POS 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

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"]},
],
}

View File

@@ -25,6 +25,7 @@ import erpnext
from erpnext import get_company_currency
from erpnext.accounts.utils import get_fiscal_year
from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen
from erpnext.stock.doctype.price_list.price_list import is_price_list_enabled
from erpnext.utilities.regional import temporary_flag
try:
@@ -370,12 +371,17 @@ def set_other_values(party_details, party, party_type):
def get_default_price_list(party):
"""Return default price list for party (Document object)"""
if party.get("default_price_list"):
return party.default_price_list
"""Return the first enabled default price list for party (Document object)"""
price_list = party.get("default_price_list")
if is_price_list_enabled(price_list):
return price_list
if party.doctype == "Customer":
return frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
if party.doctype != "Customer":
return
price_list = frappe.get_cached_value("Customer Group", party.customer_group, "default_price_list")
if is_price_list_enabled(price_list):
return price_list
def set_price_list(party_details, party, party_type, given_price_list, pos=None):
@@ -388,7 +394,7 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
elif pos and party_type == "Customer":
customer_price_list = frappe.get_value("Customer", party.name, "default_price_list")
if customer_price_list:
if is_price_list_enabled(customer_price_list):
price_list = customer_price_list
else:
pos_price_list = frappe.get_value("POS Profile", pos, "selling_price_list")
@@ -396,6 +402,9 @@ def set_price_list(party_details, party, party_type, given_price_list, pos=None)
else:
price_list = get_default_price_list(party) or given_price_list
if price_list and not is_price_list_enabled(price_list):
price_list = None
if price_list:
party_details.price_list_currency = frappe.db.get_value(
"Price List", price_list, "currency", cache=True

View File

@@ -94,10 +94,15 @@ frappe.query_reports["Accounts Payable"] = {
options: get_party_type_options(),
on_change: function () {
frappe.query_report.set_filter_value("party", "");
frappe.query_report.toggle_filter_display(
"supplier_group",
frappe.query_report.get_filter_value("party_type") !== "Supplier"
);
let is_supplier = frappe.query_report.get_filter_value("party_type") === "Supplier";
let supplier_group_filter = frappe.query_report.get_filter("supplier_group");
if (supplier_group_filter) {
supplier_group_filter.df.hidden = !is_supplier;
}
frappe.query_report.toggle_filter_display("supplier_group", !is_supplier);
if (!is_supplier) {
frappe.query_report.set_filter_value("supplier_group", []);
}
},
},
{

View File

@@ -7,6 +7,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, query_builder, scrub
from frappe.database.schema import get_definition
from frappe.permissions import get_allowed_docs_for_doctype
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Substring, Sum
from frappe.utils import cint, cstr, flt, getdate, nowdate
@@ -52,6 +53,7 @@ class ReceivablePayableReport:
self.filters = frappe._dict(filters or {})
self.qb_selection_filter = []
self.ple = qb.DocType("Payment Ledger Entry")
self.sales_person_records = None
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
@@ -92,6 +94,7 @@ class ReceivablePayableReport:
self.party_type = get_party_types_from_account_type(self.account_type)
self.party_details = {}
self.invoices = set()
self.sales_person_records = None
self.skip_total_row = 0
self.advance_payment_doctypes = get_advance_payment_doctypes()
@@ -169,6 +172,7 @@ class ReceivablePayableReport:
party_account=ple.account,
posting_date=ple.posting_date,
account_currency=ple.account_currency,
cost_center=ple.cost_center,
remarks=ple.remarks,
invoiced=0.0,
paid=0.0,
@@ -206,7 +210,7 @@ class ReceivablePayableReport:
def get_invoices(self, ple):
if ple.voucher_type in ("Sales Invoice", "Purchase Invoice"):
if self.filters.get("sales_person"):
if self.sales_person_records is not None:
if ple.voucher_no in self.sales_person_records.get(
"Sales Invoice", []
) or ple.party in self.sales_person_records.get("Customer", []):
@@ -237,7 +241,7 @@ class ReceivablePayableReport:
]
def get_voucher_balance(self, ple):
if self.filters.get("sales_person"):
if self.sales_person_records is not None:
if not (
ple.party in self.sales_person_records.get("Customer", [])
or ple.against_voucher_no in self.sales_person_records.get("Sales Invoice", [])
@@ -896,25 +900,54 @@ class ReceivablePayableReport:
self.ple_query = query
def get_permitted_sales_persons(self, parenttype):
if self.account_type != "Receivable":
return None
permissions = frappe.permissions.get_user_permissions(frappe.session.user).get("Sales Person", [])
if not permissions:
return None
return get_allowed_docs_for_doctype(permissions, parenttype)
def get_sales_invoices_or_customers_based_on_sales_person(self):
parenttypes = ["Customer", "Sales Invoice"]
permitted = {p: self.get_permitted_sales_persons(p) for p in parenttypes}
if not (self.filters.get("sales_person") or any(p is not None for p in permitted.values())):
return
steam = frappe.qb.DocType("Sales Team")
scope = []
for parenttype in parenttypes:
criterion = steam.parenttype == parenttype
if (allowed := permitted[parenttype]) is not None:
criterion &= steam.sales_person.isin(allowed or [""])
scope.append(criterion)
conditions = [Criterion.any(scope)]
if self.filters.get("sales_person"):
lft, rgt = frappe.db.get_value("Sales Person", self.filters.get("sales_person"), ["lft", "rgt"])
# nosemgrep
records = frappe.db.sql(
"""
select distinct parent, parenttype
from `tabSales Team` steam
where parenttype in ('Customer', 'Sales Invoice')
and exists(select name from `tabSales Person` where lft >= %s and rgt <= %s and name = steam.sales_person)
""",
(lft, rgt),
as_dict=1,
sp = frappe.qb.DocType("Sales Person")
conditions.append(
steam.sales_person.isin(
frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt))
)
)
self.sales_person_records = frappe._dict()
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
records = (
frappe.qb.from_(steam)
.select(steam.parent, steam.parenttype)
.distinct()
.where(Criterion.all(conditions))
.run(as_dict=1)
)
self.sales_person_records = frappe._dict()
for d in records:
self.sales_person_records.setdefault(d.parenttype, set()).add(d.parent)
def get_invoices_based_on_sales_partner(self):
if not self.filters.get("sales_partner"):

View File

@@ -1173,6 +1173,28 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
row = report[1][0]
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
def test_cost_center_on_payment_before_invoice(self):
filters = {
"company": self.company,
"party_type": "Customer",
"party": [self.customer],
"report_date": today(),
"range": "30, 60, 90, 120",
}
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.posting_date = add_days(today(), 1)
si.due_date = si.posting_date
si.payment_schedule[0].due_date = si.posting_date
si.save().submit()
pe = self.create_payment_entry(si.name, do_not_submit=True)
pe.cost_center = self.cost_center
pe.save().submit()
row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name)
self.assertEqual(row.cost_center, pe.cost_center)
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms

View File

@@ -28,16 +28,16 @@
<br>{%= __("Clearance Date") %}: {%= frappe.datetime.str_to_user(data[i]["clearance_date"]) %}
{% } %}
</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
</tr>
{% } else { %}
<tr>
<td></td>
<td></td>
<td>{%= data[i]["payment_entry"] %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
</tr>
{% } %}
{% } %}

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