Compare commits

...

287 Commits

Author SHA1 Message Date
Nikhil Kothari
574c86729b fix: type hints for get_children and enqueue opening stock after commit (semgrep) 2026-09-25 16:10:41 +05:30
Nishka Gosalia
ead172c4e5 Merge pull request #59228 from nishkagosalia/work-order-ux-cleanup
fix(UX): Work order UX cleanup

(cherry picked from commit f45b22b79c)
2026-09-25 15:47:02 +05:30
Nishka Gosalia
281eb112b9 Merge pull request #59329 from nishkagosalia/job-card-ux-cleanup
fix(UX): Job card cleanup

(cherry picked from commit 9a79253ee1)
2026-09-25 15:45:38 +05:30
Nikhil Kothari
62ccd0cefb feat: update icons and documentation links for DocTypes and reports (#59235)
* feat: update icons and documentation links for DocTypes

* feat: add documentation links in ERPNext

* fix: migrate to newer Lucide icons

(cherry picked from commit 3c251254b8)
2026-09-25 15:40:47 +05:30
Mihir Kandoi
63e419b4c6 chore(desk): hide CRM and Support on the dock (#59208)
(cherry picked from commit dd72c2688b)
2026-09-25 15:37:53 +05:30
Khushi Rawat
7d7eb9b151 Merge pull request #59085 from khushi8112/print-format-classic-item-description
fix: show item description in Classic print formats
(cherry picked from commit 4974372073)
2026-09-25 15:37:53 +05:30
Mihir Kandoi
fc8994f746 fix(ui): do not decorate self-referential links with the row's own title (#59023)
(cherry picked from commit 41ad83dfb1)
2026-09-25 15:35:59 +05:30
Soham Kulkarni
e4692a4acb Merge pull request #58729 from sokumon/sidebar-changes
fix: more sidebar improvements
(cherry picked from commit f8951034be)
2026-09-25 15:35:59 +05:30
Sumit Jain
90e6831887 Merge pull request #58726 from frappe/fix-account-tree
feat(accounts): disabled marker and cost center parity in tree views

(cherry picked from commit 3b9535ed91)
2026-09-25 15:34:03 +05:30
Sumit Jain
7bdb0ef4bb fix: enhance tree view functionality for accounts and cost centers (#58520)
(cherry picked from commit 31761699ae)
2026-09-25 15:33:34 +05:30
Soham Kulkarni
2c0eaa4b6b Merge pull request #58476 from sokumon/module-sidebars
feat: erpnext sidebars and workspaces
(cherry picked from commit 64186f229f)
2026-09-25 15:33:34 +05:30
Khushi Rawat
7c7f69948d Merge pull request #57838 from khushi8112/hide/redundant-primary-address-contact-fields
refactor(ui): hide the primary address and contact preview fields

(cherry picked from commit 0b01b9bcd3)
2026-09-25 15:32:02 +05:30
Khushi Rawat
0fd8d380ce Merge pull request #58096 from khushi8112/refactor/item-prices-embedded-list
refactor: use EmbeddedList for item prices
(cherry picked from commit 7212e6d989)
2026-09-25 15:30:30 +05:30
Nishka Gosalia
82176cf7c8 Merge pull request #57845 from nishkagosalia/settings-mapping-cleanup
chore: restructure exported files of doctype settings
(cherry picked from commit d9e048ece7)
2026-09-25 15:29:19 +05:30
Soham Kulkarni
bfd1a6f022 Merge pull request #57614 from sokumon/item-default
fix: unchecking default workspace
(cherry picked from commit ec02b5fa64)
2026-09-25 15:29:19 +05:30
Khushi Rawat
1ff926da30 Merge pull request #57520 from khushi8112/feature/default-modern-print-formats
feat: default new sites to the Modern with Images print formats
(cherry picked from commit 4f1adb8a94)
2026-09-25 15:29:19 +05:30
Khushi Rawat
e83908173e Merge pull request #57451 from khushi8112/feature/request-for-quotation-print-formats
feat: add four Request for Quotation print formats built with the print format builder
(cherry picked from commit 56a9ca334b)
2026-09-25 15:29:19 +05:30
Khushi Rawat
de6d2e125e Merge pull request #57450 from khushi8112/feature/quotation-print-formats
feat: add four Quotation print formats built with the print format builder
(cherry picked from commit aacccfb958)
2026-09-25 15:29:19 +05:30
Khushi Rawat
989579a900 Merge pull request #57449 from khushi8112/feature/pos-invoice-print-formats
feat: add four POS Invoice print formats built with the print format builder
(cherry picked from commit bd5d6fb9d8)
2026-09-25 15:29:19 +05:30
Khushi Rawat
8649c908b8 Merge pull request #57447 from khushi8112/feature/purchase-invoice-print-formats
feat: add four Purchase Invoice print formats built with the print format builder
(cherry picked from commit 22334def56)
2026-09-25 15:29:18 +05:30
Khushi Rawat
3125ac3d47 Merge pull request #57446 from khushi8112/feature/purchase-order-print-formats
feat: add four Purchase Order print formats built with the print format builder
(cherry picked from commit 5631acc39b)
2026-09-25 15:29:18 +05:30
Khushi Rawat
412ae07aba Merge pull request #57445 from khushi8112/feature/delivery-note-print-formats
feat: add four Delivery Note print formats built with the print format builder
(cherry picked from commit dd0f763613)
2026-09-25 15:29:18 +05:30
Khushi Rawat
126a04bbbb Merge pull request #57437 from khushi8112/feature/sales-order-print-formats
feat: add four Sales Order print formats built with the print format builder
(cherry picked from commit b71a9b8273)
2026-09-25 15:29:18 +05:30
Khushi Rawat
280d43bf91 Merge pull request #57430 from khushi8112/feature/sales-invoice-print-formats
feat: add four Sales Invoice print formats
(cherry picked from commit 7a76c5f268)
2026-09-25 15:29:18 +05:30
Nishka Gosalia
89ad39c9af Merge pull request #57333 from nishkagosalia/fix-settings-map
fix: settings map cleanup
(cherry picked from commit eafd43769b)
2026-09-25 15:29:18 +05:30
Mihir Kandoi
2ee542a7ef Merge pull request #57225 from mihir-kandoi/fix-duplicate-workspace-links
fix: remove duplicate links from home and projects workspaces
(cherry picked from commit 0cba26c608)
2026-09-25 15:29:18 +05:30
Nishka Gosalia
418870812a feat: map settings for DocTypes to show on settings dialog (#57025)
fix: mapping settings for DocType settings
(cherry picked from commit 5fc03a116a)
2026-09-25 15:29:18 +05:30
Soham Kulkarni
bb84f54941 Merge pull request #57134 from sokumon/merge-workspaces
fix: merge erpnext workspaces
(cherry picked from commit 4d2b603ba6)
2026-09-25 15:29:18 +05:30
rohitwaghchaure
0c60ff7838 fix: workspace for stock and manufacturing (#56906)
(cherry picked from commit 607f0e943f)
2026-09-25 15:29:18 +05:30
Nikhil Kothari
02f43d2b5f fix: replace all old icons (#56864)
(cherry picked from commit dc09362454)
2026-09-25 15:26:33 +05:30
Soham Kulkarni
889b6900dd Merge pull request #56656 from sokumon/revamp-workspaces
chore: exporting workspaces with sidebars
(cherry picked from commit 3eaea74a51)
2026-09-25 15:23:41 +05:30
Khushi Rawat
8ec1a8dd7b Merge pull request #54570 from khushi8112/item-opening-stock-dialog
feat: add opening stock dialog for stock items
(cherry picked from commit bda7a8ced2)
2026-09-25 15:23:40 +05:30
rohitwaghchaure
5b1e9b5ef1 fix: opening stock not working for serial / batch (#51781)
(cherry picked from commit 589a393b5c)
2026-09-25 15:23:40 +05:30
Nabin Hait
7812ca1f9f chore: remove leaderboard dead code (#55030)
(cherry picked from commit 6114293b92)
2026-09-25 15:20:03 +05:30
Deepesh Garg
869ed9dfce Merge pull request #52453 from deepeshgarg007/erpnext_form_cleanups_v1
fix: Currency fields label (Sales Invoice)
(cherry picked from commit 4584893542)
2026-09-25 15:18:06 +05:30
Sudharsanan Ashok
f3c74f4639 Merge pull request #59439 from frappe/mergify/bp/version-16-hotfix/pr-59430
fix(accounts): filter purchase register by supplier group from supplier master (backport #59430)
2026-09-25 15:02:00 +05:30
Sudharsanan Ashok
6686da2cce fix(accounts): filter purchase register by supplier group from supplier master (#59430)
(cherry picked from commit 75efa2d1cf)
2026-09-25 09:14:53 +00:00
Mihir Kandoi
4426228532 fix(manufacturing): scope secondary items to job card (backport #58260) (#59435) 2026-09-25 09:08:45 +00:00
Mihir Kandoi
223adf655f fix: count only matched batches on work order reservations (backport #59424) (#59427) 2026-09-25 08:39:05 +00:00
Mihir Kandoi
544ca623bd fix: release the work order reservation before previewing a stock entry (backport #59425) (#59428) 2026-09-25 08:18:58 +00:00
Deepesh Garg
30baa235e4 Merge pull request #59313 from frappe/mergify/bp/version-16-hotfix/pr-59276
feat: make bank reconciliation dialog voucher types extensible (#59276)
2026-09-25 10:59:33 +05:30
Deepesh Garg
3a7a7dbd58 test: cover the bank reconciliation voucher type registry
Node unit tests evaluate the dialog script with stubbed frappe globals and
cover preserving pre-registered types, filtering them per transaction,
injecting their dialog fields, the submit and full-page return contracts
and rejecting unregistered types. yarn test:js runs them and a js unit
tests job runs them on every PR.

Also restores the track_voucher call on the full-page path that the merge
with develop dropped, and registers the after-submit reconciliation hooks
for every registered voucher type.
2026-09-25 10:22:09 +05:30
Deepesh Garg
4fe41c925d feat: make bank reconciliation dialog voucher types extensible
Other apps can register additional "Create Voucher" document types in
erpnext.accounts.bank_reconciliation.voucher_types with their own dialog
fields, applicability check and creation call. Payment Entry and Journal
Entry now go through the same registry, which also removes the duplicated
create and edit-in-full-page call blocks.
2026-09-25 10:20:23 +05:30
mergify[bot]
032fcad61b fix: set correct transaction type on args copy while evaulating item wise pricing rule (backport #59406) (#59409)
fix: set correct transaction type on args copy while evaulating item wise pricing rule (#59406)

(cherry picked from commit 9cc2c2272a)

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-25 06:32:57 +05:30
Shllokkk
80b26b2f28 Merge pull request #59389 from frappe/mergify/bp/version-16-hotfix/pr-59386
fix: skip activities fetch on unsaved CRM doc (backport #59386)
2026-09-24 18:18:07 +05:30
Shllokkk
ede9f11500 fix: skip activities fetch on unsaved CRM doc
(cherry picked from commit 87052020fb)
2026-09-24 12:41:44 +00:00
Sudharsanan Ashok
5306aaf53d fix(stock): restrict delivery to reserved serial nos when ensure delivery by serial no is set (#59381)
* fix(stock): restrict delivery to reserved serial nos when ensure delivery by serial no is set

delivery note and sales invoice with update stock now throw on submit if a sales order item with ensure delivery based on produced serial no has no serial reservation, or if a delivered serial no is not one of the serial nos reserved for it. the check runs after the stock ledger is posted, so serial nos auto picked on submit are validated too.

* test(stock): add tests for delivery restricted to reserved produced serial nos

* fix(stock): reserve stock in work order for ensure delivery by serial no items

a new work order for a sales order item with ensure delivery based on produced serial no now reserves stock by default. the checkbox is hidden and cleared on the sales order when stock reservation is disabled in stock settings, and the delivery check is skipped in that case. the missing reservation error now asks to reserve the produced serial nos from the sales order, since reserve stock cannot be enabled on a work order after production.

* test(stock): add tests for work order reserve stock and ensure delivery checkbox clearing
2026-09-24 15:57:13 +05:30
Sudharsanan Ashok
3e851108f1 Merge pull request #59366 from frappe/mergify/bp/version-16-hotfix/pr-59168
fix(regional): report uae vat 201 sales vat in company currency  (backport #59168)
2026-09-24 10:58:35 +05:30
Sudharsanan11
e55efadd0d fix: resolve conflicts 2026-09-24 10:34:58 +05:30
Sudharsanan Ashok
2387c67952 fix(regional): report uae vat 201 sales vat in company currency (#59168)
(cherry picked from commit 6e0ec5af31)

# Conflicts:
#	erpnext/regional/report/uae_vat_201/uae_vat_201.py
2026-09-24 04:39:28 +00:00
Mihir Kandoi
0c02cbaf1e perf(stock): serve Item link search from an FTS5 trigram index (backport #59166) (#59350) 2026-09-24 08:39:16 +05:30
Mihir Kandoi
ad8f945cce fix: UOM whole number check truncated instead of rounding (backport #57861) (#59364) 2026-09-24 03:06:25 +00:00
Shllokkk
d0290ea98e Merge pull request #59353 from Shllokkk/mrp-po-missing-supplier-backport
fix: handle missing and item group default supplier in MRP make_order
2026-09-24 04:01:31 +05:30
Shllokkk
61f66ed579 test: cover missing and item-group-fallback supplier in MRP make_order 2026-09-24 03:47:36 +05:30
mergify[bot]
1e16c827ef fix(pos): reset stale selected mode of payment on new invoice (backport #59308) (#59355)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-09-23 19:26:48 +00:00
Shllokkk
be283b70f2 fix: resolve MRP purchase supplier with item group fallback, else throw 2026-09-24 00:27:24 +05:30
Shllokkk
79858d5af6 fix(manufacturing): classify MRP rows by purchase item, not missing BOM (#58510) 2026-09-24 00:27:18 +05:30
Diptanil Saha
48635ee6b5 fix(permissions)!: add permission checks to whitelisted methods and the DocPerm rows they need (#59284) 2026-09-23 23:01:45 +05:30
Sudharsanan Ashok
8732c4eee3 Merge pull request #59339 from frappe/mergify/bp/version-16-hotfix/pr-59336
fix(selling): use preferred addresses in customer quotations (backport #59336)
2026-09-23 19:02:26 +05:30
Sudharsanan Ashok
0e1657a35a Merge pull request #59281 from frappe/mergify/bp/version-16-hotfix/pr-59277
fix(crm): keep opportunity type default untranslated (backport #59277)
2026-09-23 18:54:55 +05:30
pandiyan
c019bb5c28 fix(selling): use preferred addresses in customer quotations 2026-09-23 18:48:11 +05:30
Mihir Kandoi
1ead4cebcf fix(quality): block Quality Inspection submit without a sample size (backport #59330) (#59331) 2026-09-23 10:56:56 +00:00
mergify[bot]
242f4219cb fix(pos): quote the reversed row's rate on a consolidated credit note (backport #59320) (#59323)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-23 16:02:13 +05:30
Mihir Kandoi
5d495c2763 fix(manufacturing): finish a work order after partial material consumption (#59312) 2026-09-23 07:38:13 +00:00
Deepesh Garg
f1cdeab601 test: cover the bank reconciliation voucher type registry
Node unit tests evaluate the dialog script with stubbed frappe globals and
cover preserving pre-registered types, filtering them per transaction,
injecting their dialog fields, the submit and full-page return contracts
and rejecting unregistered types. yarn test:js runs them and a js unit
tests job runs them on every PR.

Also restores the track_voucher call on the full-page path that the merge
with develop dropped, and registers the after-submit reconciliation hooks
for every registered voucher type.

(cherry picked from commit 60f0a736ab)

# Conflicts:
#	erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
2026-09-23 07:19:23 +00:00
Deepesh Garg
a657adba37 feat: make bank reconciliation dialog voucher types extensible
Other apps can register additional "Create Voucher" document types in
erpnext.accounts.bank_reconciliation.voucher_types with their own dialog
fields, applicability check and creation call. Payment Entry and Journal
Entry now go through the same registry, which also removes the duplicated
create and edit-in-full-page call blocks.

(cherry picked from commit 96db1071da)

# Conflicts:
#	erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
2026-09-23 07:19:23 +00:00
Khushi Rawat
868de6e513 Merge pull request #59289 from frappe/mergify/bp/version-16-hotfix/pr-59262
fix(journal-entry): carry reference fields into reverse journal entry (backport #59262)
2026-09-23 12:36:37 +05:30
Khushi Rawat
54c094ed46 Merge pull request #59305 from frappe/mergify/bp/version-16-hotfix/pr-59304
fix: count complete opening depreciation periods accurately (backport #59304)
2026-09-23 12:20:40 +05:30
khushi8112
d4105c3aaf test(assets): cover complete opening depreciation periods
(cherry picked from commit 3691ca5b5b)
2026-09-23 06:38:24 +00:00
khushi8112
900eb1e466 fix(assets): count complete opening depreciation periods accurately
(cherry picked from commit 56f24a6adf)
2026-09-23 06:38:24 +00:00
Sudharsanan Ashok
cfa5c25215 Merge pull request #59297 from frappe/mergify/bp/version-16-hotfix/pr-59231
fix(accounts): keep price list within user permissions (backport #59231)
2026-09-22 23:52:42 +05:30
Sudharsanan Ashok
04be8b1218 Merge pull request #59295 from frappe/mergify/bp/version-16-hotfix/pr-59286
fix(stock): keep mapped cost center in stock entry items (backport #59286)
2026-09-22 23:46:11 +05:30
Sudharsanan11
98fa0d4cb4 fix: resolve conflicts 2026-09-22 21:35:38 +05:30
Sudharsanan Ashok
385343a389 fix(accounts): keep price list within user permissions (#59231)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit e814d13126)
2026-09-22 21:35:38 +05:30
Mihir Kandoi
4186d6c994 fix(stock): deduct rejected qty from the in-transit warehouse (#59299) 2026-09-22 14:24:03 +00:00
Sudharsanan Ashok
06d55a089a Merge pull request #59293 from frappe/mergify/bp/version-16-hotfix/pr-59250
fix(stock): seed bin values when cancelling a stock voucher (backport #59250)
2026-09-22 18:40:42 +05:30
Sudharsanan Ashok
cdbdcc6e33 fix(stock): keep mapped cost center in stock entry items (#59286)
(cherry picked from commit 7f608f31eb)
2026-09-22 12:35:50 +00:00
Sudharsanan Ashok
3ab13e12d4 fix(stock): seed bin values when cancelling a stock voucher (#59250)
* fix(stock): seed bin values when cancelling a stock voucher

cancellation flags every sle of the voucher before update_entries_after
runs, so get_sle_against_current_voucher returns nothing and the seeding
added in #57380 never fires. prev_sle_dict stays empty, update_bin()
writes nothing, and the bin keeps the stock value and valuation rate it
had before the cancellation while its quantity is restored.

seed from the args when the query comes back empty, leaving the existing
anchor in place whenever a live entry shares the posting datetime.

* test(stock): cover bin stock value after cancelling a transfer

a transfer between two warehouses that both hold stock, then cancelled:
both bins must return to their previous quantity, valuation rate and
stock value. fails on develop with 500.0 != 1000.

(cherry picked from commit 62cb38d36b)
2026-09-22 12:23:29 +00:00
khushi8112
0db0cc55d4 fix: apply journal entry reversal backport to version 16 controller 2026-09-22 17:29:06 +05:30
Mihir Kandoi
6af589208b fix(stock): use net purchase values in landed cost vouchers (backport #59274) (#59278)
Co-authored-by: pandiyan <pandiyanpalani37@gmail.com>
2026-09-22 11:50:24 +00:00
ervishnucs
06cc2914ba fix(journal-entry): carry reference fields into reverse journal entry
(cherry picked from commit 9e01992217)

# Conflicts:
#	erpnext/accounts/doctype/journal_entry/mapper.py
2026-09-22 11:47:35 +00:00
Khushi Rawat
8e9c0f6e41 Merge pull request #59273 from frappe/mergify/bp/version-16-hotfix/pr-59180
fix: refresh price list exchange rate when currency changes (backport #59180)
2026-09-22 16:59:51 +05:30
Pandiyan P
8eb906366d fix(crm): keep opportunity type default untranslated (#59277)
(cherry picked from commit 79fd06a1ba)
2026-09-22 11:07:03 +00:00
rohitwaghchaure
06a8faa4ed fix: do not zero out backdated stock at a stock reco adjustment entry (#59270)
* fix: do not zero out backdated stock at a stock reco adjustment entry

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

* fix: read stock reco adjustment rows once and value them from the ledger
2026-09-22 16:30:01 +05:30
khushi8112
ff0841e0cb test: cover price list exchange rate currency changes
(cherry picked from commit 221baabf9e)
2026-09-22 10:00:52 +00:00
khushi8112
0fe41ce92e fix: refresh price list exchange rate when currency changes
(cherry picked from commit 8b59d66f94)
2026-09-22 10:00:52 +00:00
Mihir Kandoi
aaaba7cd82 fix: skip serial nos with no stock when reconciling specific serial nos (backport #59266) (#59267) 2026-09-22 09:53:16 +00:00
mergify[bot]
814d7f0c4e feat(selling): group Sales Order Analysis by item (backport #59236) (#59246)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 08:35:00 +00:00
mergify[bot]
0f1cefa918 fix(stock): compare transit quantities in stock UOM (backport #59202) (#59244)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 08:34:11 +00:00
Sudharsanan Ashok
6d89487518 Merge pull request #59255 from frappe/mergify/bp/version-16-hotfix/pr-59192
fix(stock): apply transit warehouse filter to target warehouse in items table (backport #59192)
2026-09-22 13:39:00 +05:30
Shllokkk
fe8199bece Merge pull request #59259 from frappe/mergify/bp/version-16-hotfix/pr-59240
fix: asset field unfilterable on manual Asset Depreciation Schedule (backport #59240)
2026-09-22 13:16:24 +05:30
Shllokkk
a436707efc fix: remove company link filter on asset field in Asset Depreciation Schedule
(cherry picked from commit a931c7d2f4)
2026-09-22 07:07:59 +00:00
Afsal Syed
ae96dad151 fix(stock): apply transit warehouse filter to target warehouse in items table (#59192)
(cherry picked from commit 893fc75df4)
2026-09-22 12:34:57 +05:30
Sudharsanan Ashok
d4b1be1154 Merge pull request #59194 from frappe/mergify/bp/version-16-hotfix/pr-59191
fix(stock): validate warehouse account belongs to selected company (backport #59191)
2026-09-22 12:04:15 +05:30
Afsal Syed
38bdb4f9c0 fix(stock): validate warehouse account belongs to selected company (#59191)
(cherry picked from commit db6e089109)
2026-09-22 11:42:31 +05:30
Shllokkk
33e86edc91 Merge pull request #59234 from frappe/mergify/bp/version-16-hotfix/pr-59144
fix: use correct exchange rate for multi-currency bank reconciliation payments (backport #59144)
2026-09-21 17:58:53 +05:30
Shllokkk
427a20c508 fix: use correct exchange rate for multi-currency bank reconciliation payments (#59144)
* fix: use correct exchange rate for multi-currency bank reconciliation payments

* test: exchange rate for multi-currency bank reconciliation payment

(cherry picked from commit 04c949a662)
2026-09-21 12:00:43 +00:00
Diptanil Saha
854b0e4734 fix(permissions): grant select on link targets to roles with write access (#59123) 2026-09-21 16:56:53 +05:30
Sudharsanan Ashok
9b8c35f431 Merge pull request #59227 from frappe/mergify/bp/version-16-hotfix/pr-59220
fix(stock): honour pick manually when mapping a pick list (backport #59220)
2026-09-21 15:33:18 +05:30
Sudharsanan11
7ff2a33a5b fix: resolve conflicts 2026-09-21 14:59:27 +05:30
Sudharsanan Ashok
3ee9353240 fix(stock): honour pick manually when mapping a pick list (#59220)
(cherry picked from commit 6e87694bbe)

# Conflicts:
#	erpnext/manufacturing/doctype/work_order/mapper.py
#	erpnext/stock/doctype/material_request/mapper.py
2026-09-21 14:59:27 +05:30
Shllokkk
4455ffcae1 Merge pull request #59204 from Shllokkk/fix-gp-returned-invoices-company-filter
perf: filter by company in get_returned_invoice_items
2026-09-21 14:32:02 +05:30
Shllokkk
ff29e14353 Merge branch 'version-16-hotfix' into fix-gp-returned-invoices-company-filter 2026-09-21 13:44:22 +05:30
mergify[bot]
4af1b0db4e perf: skip legacy batch ledger lookups when no legacy entry exists (backport #59110) (#59111)
* perf: skip legacy batch ledger lookups when no legacy entry exists (#59110)

* perf: skip legacy batch ledger lookups when no legacy entry exists

(cherry picked from commit 0130d287f6)

* chore: fix conflicts

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-21 13:37:46 +05:30
Khushi Rawat
75c1a7fd69 Merge pull request #59222 from frappe/mergify/bp/version-16-hotfix/pr-59128
fix: validate coupon pricing rules and validity dates (backport #59128)
2026-09-21 13:23:11 +05:30
MochaMind
d217283229 fix: sync translations from crowdin (version-16-hotfix) (#59210)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-21 09:52:07 +02:00
rohitwaghchaure
8bbb73d2e2 fix: write off stranded stock value only when the warehouse is empty (#59216) 2026-09-21 13:16:38 +05:30
pandiyan
fa6b3380b9 test: cover coupon pricing rule and date validation
(cherry picked from commit 3384c1939b)
2026-09-21 07:17:20 +00:00
pandiyan
2db2244852 fix: validate coupon pricing rules and validity dates
(cherry picked from commit 14275e4c01)
2026-09-21 07:17:19 +00:00
Sudharsanan Ashok
f5eb2d4a0b Merge pull request #59173 from frappe/mergify/bp/version-16-hotfix/pr-59170
fix(stock): respect manual picking when mapping sales orders (backport #59170)
2026-09-21 11:12:05 +05:30
MochaMind
6661c30db4 chore: update POT file (#59200) 2026-09-20 21:43:11 +02:00
Shllokkk
fa998703bf Merge pull request #59203 from Shllokkk/migrate-gp-returned-invoices-to-qb
refactor: migrate raw SQL queries in gross profit report to query builder
2026-09-20 19:53:28 +05:30
Shllokkk
423aa09f59 perf: filter by company in get_returned_invoice_items 2026-09-20 19:38:06 +05:30
Shllokkk
fd369a5a0f refactor: migrate raw SQL queries in gross profit report to query builder 2026-09-20 19:38:02 +05:30
rohitwaghchaure
af334dabcc fix: valuation of serialized items after disabling serial no wise valuation (#59197)
* fix: recalculate outgoing rate on repost after disabling serial no wise valuation

* fix: keep valuation method when serial no wise valuation was already disabled
2026-09-19 22:51:18 +05:30
Sudharsanan Ashok
0fbea6021b Merge pull request #59165 from frappe/mergify/bp/version-16-hotfix/pr-59160
fix(stock): forward company to outward batch lookup (backport #59160)
2026-09-18 18:27:09 +05:30
Khushi Rawat
236464e6cc Merge pull request #59184 from frappe/mergify/bp/version-16-hotfix/pr-59181
fix(accounts): clarify revaluation journal filters (backport #59181)
2026-09-18 17:57:53 +05:30
pandiyan
58847de59a fix(stock): respect manual picking when mapping sales orders 2026-09-18 17:54:10 +05:30
pandiyan
eebd0bc533 fix(accounts): clarify revaluation journal filters
(cherry picked from commit dc4b390a9b)
2026-09-18 12:23:25 +00:00
rohitwaghchaure
c055faabc1 feat(stock): GL-only reposting from Stock and Account Value Comparison report (#59127)
* feat(stock): repost only GL entries from the value comparison report

Adds a "Create GL Reposting Entries" button to the Stock and Account Value
Comparison report, next to the existing "Create Reposting Entries" which is
unchanged. It queues Repost Item Valuation entries with
`repost_only_accounting_ledgers` set, so the General and Payment Ledger are
rebuilt for the selected vouchers while the stock ledgers and valuation rates
are left untouched. This is for the case where stock valuation is already
correct and only the accounting ledger has drifted, which avoids paying for a
full revaluation.

Rows of ledger type "GL Entry" are rejected: they have accounting entries but
no stock ledger entries to rebuild them from, so a GL-only repost would just
wipe their GL. The same restriction is enforced on Repost Item Valuation for
callers outside the report.

Repeated selections are deduplicated, and vouchers that already have a queued
or in-progress GL-only repost are skipped.

* feat(stock): bound GL reposting by a From Date, skip non-stock rows

* fix: batch the pending GL repost lookup and index it on existing sites
2026-09-18 17:02:15 +05:30
Sudharsanan Ashok
62f560a91d Merge pull request #59159 from frappe/mergify/bp/version-16-hotfix/pr-59155
fix(stock): continue putaway allocation after undersized whole-uom rules (backport #59155)
2026-09-18 15:16:23 +05:30
Pandiyan P
5e1cfa4f52 fix(stock): forward company to outward batch lookup (#59160)
(cherry picked from commit 622b7e0caa)
2026-09-18 09:07:13 +00:00
Khushi Rawat
a28767599f Merge pull request #59163 from frappe/mergify/bp/version-16-hotfix/pr-59142
fix: validate shipping rule account company (backport #59142)
2026-09-18 14:33:05 +05:30
pandiyan
f61caa5211 test: cover shipping rule account company validation
(cherry picked from commit 022ee73d78)
2026-09-18 14:18:08 +05:30
pandiyan
dbb8981fa1 fix(stock): continue putaway allocation after undersized whole-uom rules (#59155)
(cherry picked from commit 6cc500e28d)
2026-09-18 14:16:00 +05:30
Khushi Rawat
816880b010 Merge pull request #59157 from frappe/mergify/bp/version-16-hotfix/pr-59081
fix(accounts): validate bank reconciliation date ranges (backport #59081)
2026-09-18 14:13:24 +05:30
pandiyan
590fff6a45 fix: validate shipping rule account company
(cherry picked from commit 340feb94a1)
2026-09-18 08:37:20 +00:00
pandiyan
0634cf31c7 test: cover bank reconciliation date validation
(cherry picked from commit 62d0e2c82e)

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

# Conflicts:
#	erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
2026-09-18 13:51:57 +05:30
Sudharsanan Ashok
56db17919b Merge pull request #59126 from frappe/mergify/bp/version-16-hotfix/pr-59116
fix: retain bom components without warehouse stock (backport #59116)
2026-09-18 11:55:49 +05:30
Sudharsanan Ashok
11cbcbe02a Merge pull request #59119 from frappe/mergify/bp/version-16-hotfix/pr-59104
fix(stock): allow zero completed quantity and handle process loss in job cards (backport #59104)
2026-09-18 11:55:10 +05:30
Sudharsanan Ashok
5d4271b1c7 Merge pull request #59145 from frappe/mergify/bp/version-16-hotfix/pr-59143
fix(manufacturing): include the mrp bucket that ends on to_date (backport #59143)
2026-09-18 11:40:01 +05:30
Sudharsanan Ashok
cbd932e20d Merge pull request #59093 from frappe/mergify/bp/version-16-hotfix/pr-59007
fix(manufacturing): apply mrp manufacturing buffer as a duration (backport #59007)
2026-09-18 11:12:56 +05:30
Sudharsanan Ashok
4ad08db1d6 Merge pull request #59090 from frappe/mergify/bp/version-16-hotfix/pr-58923
fix(stock): filter item group defaults by company and non-group records (backport #58923)
2026-09-18 11:12:02 +05:30
Sudharsanan11
131343de28 fix(manufacturing): include the mrp bucket that ends on to_date (#59143)
(cherry picked from commit 5d5eeb5e02)
2026-09-18 11:00:06 +05:30
rohitwaghchaure
16b1821f35 perf: reduce memory consumption during reposting (backport #59117) (#59149)
* perf: reduce memory consumption during reposting

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

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

* fix: keep recent repost messages and log skipped entries

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

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

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

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

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

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

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

* test: repost covers every entry once across batches
2026-09-18 09:03:48 +05:30
mergify[bot]
d2042bc1bb feat: mark update_serial_batch_entries patch as never skippable (backport #59147) (#59148)
feat: mark update_serial_batch_entries patch as never skippable (#59147)

(cherry picked from commit aebb160e76)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-17 17:10:48 +00:00
Sudharsanan Ashok
652fc96e85 Merge pull request #59088 from frappe/mergify/bp/version-16-hotfix/pr-58998
fix(stock): use stored posting_datetime when reposting stock ledger entries (backport #58998)
2026-09-17 18:44:36 +05:30
Sudharsanan11
6fab4e0203 fix: resolve conflicts 2026-09-17 18:28:20 +05:30
Sudharsanan Ashok
39d6f2a236 fix(stock): use stored posting_datetime when reposting stock ledger entries (#58998)
* fix(stock): use stored posting_datetime for repost boundary

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

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

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

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

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

* test(stock): cover repost with diverged posting_datetime

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

(cherry picked from commit 6cee9c330c)

# Conflicts:
#	erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
2026-09-17 18:28:20 +05:30
Afsal Syed
4f984aeecf fix(stock): allow zero completed quantity and handle process loss in job cards (#59104)
(cherry picked from commit 1d8ce1ee8c)
2026-09-17 13:47:17 +05:30
pandiyan
18ee3b9cc0 fix: retain bom components without warehouse stock (#59116)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
(cherry picked from commit 60913b722a)
2026-09-17 11:16:48 +05:30
Sudharsanan Ashok
3eca07169e Merge pull request #59098 from frappe/mergify/bp/version-16-hotfix/pr-59097
fix: avoid pricing rule help template errors (backport #59097)
2026-09-17 11:09:26 +05:30
Sudharsanan Ashok
3acf4e7606 Merge pull request #59096 from frappe/mergify/bp/version-16-hotfix/pr-58899
fix: clear stock value for zero-quantity balances (backport #58899)
2026-09-17 11:08:34 +05:30
pandiyan
007a1f5528 fix: clear stock value for zero-quantity balances (#58899)
* fix: clear stock value for zero-quantity balances

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

(cherry picked from commit b439bdda68)
2026-09-17 10:52:57 +05:30
Shllokkk
c42df7ef3e Merge pull request #59108 from frappe/mergify/bp/version-16-hotfix/pr-59078
fix: render letter head jinja in statement of accounts pdf (backport #59078)
2026-09-16 16:44:24 +05:30
Shllokkk
31111a4a30 fix: render letter head jinja in statement of accounts pdf
(cherry picked from commit da377683d6)
2026-09-16 10:10:24 +00:00
mergify[bot]
877747c133 fix: update modified for last purchase rate (backport #59044) (#59106)
fix: update modified for last purchase rate (#59044)

(cherry picked from commit f6b907ef9d)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-16 15:37:19 +05:30
rohitwaghchaure
86fe0c1f4b perf(stock): chunk the serial and batch entry backfill patch (#59076)
* perf(stock): chunk the serial and batch entry backfill patch

* fix(stock): support postgres in the serial and batch entry backfill patch
2026-09-16 15:23:08 +05:30
rohitwaghchaure
262fdf3e69 fix: negative stock value for moving average item with mixed batchwise valuation (#59099)
* fix: negative stock value for moving average item with mixed batchwise valuation

* chore: remove redundant docstring

* test: restore frappe flags in a finally block
2026-09-16 14:49:01 +05:30
Pandiyan P
e3ef3be490 fix: avoid pricing rule help template errors (#59097)
(cherry picked from commit 2273448f1b)
2026-09-16 07:40:46 +00:00
rohitwaghchaure
f09ce0583f feat: use serial no wise valuation switch on item (#59082)
* perf: speed up legacy serial no valuation lookup

* feat: use serial no wise valuation switch on item

* fix: clear stale valuation method cache on item update

* fix: force moving average only when stock transactions exist

* fix: correct serial no wise valuation field description

* fix: add type hints to whitelisted get_incoming_rate args
2026-09-16 12:24:18 +05:30
pandiyan
fa732ca3f4 fix(manufacturing): apply mrp manufacturing buffer as a duration (#59007)
(cherry picked from commit 2ad4a8c4a4)

Include the material-type helper required by the duration calculation on version 16.
2026-09-16 12:05:39 +05:30
pandiyan
741ae5d33c fix(stock): filter item group defaults by company and non-group records (#58923)
(cherry picked from commit 00ee694444)
2026-09-16 12:05:13 +05:30
Sudharsanan Ashok
378c756d52 Merge pull request #59016 from frappe/mergify/bp/version-16-hotfix/pr-58994
fix(stock): calculate batch bundle valuation per unit (backport #58994)
2026-09-16 11:03:43 +05:30
mergify[bot]
3c9d9d62aa fix(asset): skip missing checkbox columns in asset type patch (backport #58416) (#58417)
Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-09-16 01:39:55 +02:00
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
Pandiyan P
110192d9de fix(stock): calculate batch bundle valuation per unit (#58994)
(cherry picked from commit 825d24f406)
2026-09-11 12:51:30 +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
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
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
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
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
847 changed files with 155603 additions and 111028 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

@@ -23,6 +23,19 @@ jobs:
- name: Install and Run Pre-commit
uses: pre-commit/action@v3.0.1
js-unit-tests:
name: js unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Run JS unit tests
run: yarn test:js
semgrep:
name: semgrep
runs-on: ubuntu-latest

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

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

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

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

View File

@@ -55,6 +55,9 @@ class ERPNextAddress(Address):
@frappe.whitelist()
def get_shipping_address(company, address=None):
# `select`, not `read`: Delivery, Maintenance, Purchase and Stock Manager fill in forms that ask
# for this while holding no Company `read` row. doc= so User Permissions apply to the named company.
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
filters = [
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],

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

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

View File

@@ -7,6 +7,7 @@
"description": "Heads (or groups) against which Accounting Entries are made and balances are maintained.",
"doctype": "DocType",
"document_type": "Setup",
"documentation": "https://docs.frappe.io/erpnext/chart-of-accounts",
"engine": "InnoDB",
"field_order": [
"properties",
@@ -122,6 +123,7 @@
"description": "Setting Account Type helps in selecting this Account in transactions.",
"fieldname": "account_type",
"fieldtype": "Select",
"in_preview": 1,
"in_standard_filter": 1,
"label": "Account Type",
"oldfieldname": "account_type",
@@ -199,11 +201,11 @@
"options": "Account Category"
}
],
"icon": "fa fa-money",
"icon": "vault",
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2026-04-14 18:14:42.202065",
"modified": "2026-09-25 15:33:56.058228",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",
@@ -264,6 +266,46 @@
{
"role": "HR Manager",
"select": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

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

View File

@@ -52,6 +52,42 @@ frappe.treeview_settings["Account"] = {
],
root_label: "Accounts",
get_tree_nodes: "erpnext.accounts.utils.get_children",
get_label: function (node) {
// clean display name — the account number renders as a badge (see
// onrender) instead of being glued into the name
return frappe.utils.escape_html(node.data.account_name || node.title || node.label);
},
onrender: function (node) {
if (node.is_root || !node.data) return;
const flags = [];
if (node.data.account_number) {
flags.push(frappe.ui.badge({ label: node.data.account_number }));
}
const company = frappe.treeview_settings["Account"].treeview?.page?.fields_dict?.company?.get_value();
const company_currency = company && erpnext.get_currency(company);
if (
node.data.account_currency &&
company_currency &&
node.data.account_currency !== company_currency
) {
flags.push(frappe.ui.badge({ label: node.data.account_currency, theme: "blue" }));
}
if (node.data.freeze_account === "Yes") {
flags.push(
frappe.ui.badge({
label: __("Frozen"),
icon: "lock",
title: __("Frozen - entries restricted"),
theme: "orange",
})
);
}
erpnext.utils.render_tree_node_flags(node, flags);
},
on_node_render: function (node, deep) {
const render_balances = () => {
for (let account of cur_tree.account_balance_data) {
@@ -232,7 +268,7 @@ frappe.treeview_settings["Account"] = {
frappe.treeview_settings["Account"].treeview["tree"] = treeview.tree;
if (treeview.can_create) {
treeview.page.set_primary_action(
__("New"),
{ label: __("Add Account"), short_label: __("Add") },
function () {
let root_company = treeview.page.fields_dict.root_company.get_value();
if (root_company) {
@@ -243,13 +279,14 @@ frappe.treeview_settings["Account"] = {
treeview.new_node();
}
},
"add"
"plus"
);
}
},
toolbar: [
{
label: __("Add Child"),
icon: "plus",
condition: function (node) {
return (
frappe.boot.user.can_create.indexOf("Account") !== -1 &&
@@ -272,6 +309,7 @@ frappe.treeview_settings["Account"] = {
return !node.root && frappe.boot.user.can_read.indexOf("GL Entry") !== -1;
},
label: __("View Ledger"),
icon: "book-open",
click: function (node, btn) {
frappe.route_options = {
from_date: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
@@ -286,6 +324,106 @@ frappe.treeview_settings["Account"] = {
},
btnClass: "hidden-xs",
},
{
// same label and mechanism as the Account form's Actions button:
// NOT frappe's generic rename (Allow Rename stays off) — this is
// ERPNext's controlled update that rebuilds the derived
// "number - name - abbr" document name
label: __("Update Account Name / Number"),
icon: "text-cursor-input",
condition: function (node) {
return !node.is_root && frappe.model.can_write("Account");
},
click: function (node) {
const dialog = new frappe.ui.Dialog({
title: __("Update Account Number / Name"),
fields: [
{
fieldtype: "Data",
fieldname: "account_name",
label: __("Account Name"),
reqd: 1,
default: node.data.account_name,
},
{
fieldtype: "Data",
fieldname: "account_number",
label: __("Account Number"),
default: node.data.account_number,
},
],
primary_action_label: __("Update"),
primary_action(values) {
dialog.hide();
frappe.dom.freeze(__("Updating {0}", [node.label]));
frappe.call({
method: "erpnext.accounts.doctype.account.account.update_account_number",
args: {
name: node.label,
account_name: values.account_name,
account_number: values.account_number,
},
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees["Account"];
node.parent_node && treeview.tree.load_children(node.parent_node);
},
always: function () {
frappe.dom.unfreeze();
},
});
},
});
dialog.show();
},
},
{
label: __("Convert to Group"),
icon: "folder-tree",
condition: function (node) {
return !node.is_root && !node.expandable && frappe.model.can_write("Account");
},
click: function (node) {
erpnext.accounts.convert_tree_node("Account", node, "convert_ledger_to_group");
},
},
{
label: __("Convert to Non-Group"),
icon: "file-text",
condition: function (node) {
// only on groups the user has opened and found empty — a
// group with children can't convert, so don't offer it
return (
!node.is_root &&
node.expandable &&
node.loaded &&
!node.$ul.children().length &&
frappe.model.can_write("Account")
);
},
click: function (node) {
erpnext.accounts.convert_tree_node("Account", node, "convert_group_to_ledger");
},
},
],
extend_toolbar: true,
};
frappe.provide("erpnext.accounts");
// shared by the Account and Cost Center tree views (defined in both files,
// whichever loads first wins): run the doctype's whitelisted convert method,
// then re-render the branch so the node's group/leaf state updates
erpnext.accounts.convert_tree_node =
erpnext.accounts.convert_tree_node ||
function (doctype, node, method) {
frappe.call({
method: "run_doc_method",
args: { dt: doctype, dn: node.label, method: method },
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees[doctype];
node.parent_node && treeview.tree.load_children(node.parent_node);
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
},
});
};

View File

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

View File

@@ -40,6 +40,7 @@
}
],
"grid_page_length": 50,
"icon": "folder-tree",
"index_web_pages_for_search": 1,
"links": [
{

View File

@@ -147,7 +147,7 @@
"precision": "9"
}
],
"icon": "fa fa-list",
"icon": "scale",
"in_create": 1,
"links": [],
"modified": "2025-08-22 19:13:50.400404",

View File

@@ -3,6 +3,7 @@
"autoname": "field:label",
"creation": "2019-05-04 18:13:37.002352",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-dimensions",
"engine": "InnoDB",
"field_order": [
"document_type",
@@ -49,6 +50,7 @@
"options": "Accounting Dimension Detail"
}
],
"icon": "layers",
"links": [],
"modified": "2024-03-27 13:05:56.890002",
"modified_by": "Administrator",

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
"autoname": "format:{accounting_dimension}-{#####}",
"creation": "2020-11-08 18:28:11.906146",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-dimension-filter",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -99,6 +100,7 @@
"label": "Fieldname"
}
],
"icon": "funnel",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-08-08 14:13:22.203011",

View File

@@ -3,6 +3,7 @@
"autoname": "field:period_name",
"creation": "2018-04-13 18:50:14.672323",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounting-period",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -77,6 +78,7 @@
"options": "Role"
}
],
"icon": "calendar-range",
"links": [],
"modified": "2026-03-09 17:15:33.577217",
"modified_by": "Administrator",

View File

@@ -3,6 +3,7 @@
"creation": "2013-06-24 15:49:57",
"doctype": "DocType",
"document_type": "Other",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/accounts-settings",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -224,7 +225,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",
@@ -792,12 +794,12 @@
}
],
"grid_page_length": 50,
"icon": "icon-cog",
"icon": "settings",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-08-14 15:26:49.070889",
"modified": "2026-09-04 10:08:30.115003",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",

View File

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

@@ -4,6 +4,7 @@
"creation": "2024-10-16 16:57:12.085072",
"doctype": "DocType",
"engine": "InnoDB",
"icon": "book-open",
"field_order": [
"company",
"voucher_type",

View File

@@ -6,6 +6,7 @@
"creation": "2018-04-07 16:59:59.496668",
"doctype": "DocType",
"document_type": "Setup",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -100,8 +101,9 @@
"read_only": 1
}
],
"icon": "landmark",
"links": [],
"modified": "2024-03-27 13:06:36.896195",
"modified": "2026-09-16 11:42:07.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank",
@@ -118,6 +120,14 @@
"role": "System Manager",
"share": 1,
"write": 1
},
{
"role": "Accounts Manager",
"select": 1
},
{
"role": "Accounts User",
"select": 1
}
],
"quick_entry": 1,

View File

@@ -5,6 +5,7 @@
"creation": "2017-05-29 21:35:13.136357",
"doctype": "DocType",
"document_type": "Setup",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-account",
"engine": "InnoDB",
"field_order": [
"account_name",
@@ -227,6 +228,7 @@
}
],
"grid_page_length": 50,
"icon": "credit-card",
"links": [
{
"group": "Transactions",
@@ -269,7 +271,7 @@
"link_fieldname": "default_bank_account"
}
],
"modified": "2026-04-11 19:46:27.609994",
"modified": "2026-09-16 11:42:07.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Bank Account",
@@ -299,6 +301,22 @@
"role": "Accounts User",
"share": 1,
"write": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -44,6 +44,7 @@
}
],
"grid_page_length": 50,
"icon": "wallet",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-06-16 22:17:48.007982",

View File

@@ -18,6 +18,7 @@
"unique": 1
}
],
"icon": "credit-card",
"links": [],
"modified": "2024-03-27 13:06:37.221876",
"modified_by": "Administrator",

View File

@@ -18,6 +18,7 @@
"unique": 1
}
],
"icon": "credit-card",
"links": [],
"modified": "2024-03-27 13:06:37.347035",
"modified_by": "Administrator",

View File

@@ -87,7 +87,7 @@
}
],
"hide_toolbar": 1,
"icon": "fa fa-check",
"icon": "badge-check",
"idx": 1,
"issingle": 1,
"links": [],

View File

@@ -5,6 +5,7 @@
"creation": "2016-12-17 10:43:35.731631",
"doctype": "DocType",
"document_type": "Document",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-guarantee",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -216,6 +217,7 @@
}
],
"grid_page_length": 50,
"icon": "shield-check",
"is_submittable": 1,
"links": [],
"modified": "2026-05-25 18:12:10.768835",

View File

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

View File

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

View File

@@ -116,6 +116,7 @@
}
],
"hide_toolbar": 1,
"icon": "arrow-left-right",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],

View File

@@ -10,7 +10,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Sum
from frappe.utils import cint, create_batch, flt
from frappe.utils import cint, create_batch, flt, getdate
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
@@ -55,6 +55,8 @@ def get_bank_transactions(
all_transactions: bool = False,
):
# returns bank transactions for a bank account
validate_date_range(from_date, to_date)
filters = []
filters.append(["bank_account", "=", bank_account])
filters.append(["docstatus", "=", 1])
@@ -794,6 +796,7 @@ def create_bulk_payment_entry_and_reconcile(
"deposit",
"withdrawal",
"bank_account",
"company",
"currency",
"unallocated_amount",
"date",
@@ -828,11 +831,7 @@ def create_bulk_payment_entry_and_reconcile(
"paid_from": paid_from,
"paid_to": paid_to,
"paid_amount": bank_transaction.unallocated_amount,
"base_paid_amount": bank_transaction.unallocated_amount,
"received_amount": bank_transaction.unallocated_amount,
"base_received_amount": bank_transaction.unallocated_amount,
"target_exchange_rate": 1,
"source_exchange_rate": 1,
"reference_date": bank_transaction.date,
"posting_date": bank_transaction.date,
"reference_no": (bank_transaction.reference_number or bank_transaction.description or "")[
@@ -841,6 +840,8 @@ def create_bulk_payment_entry_and_reconcile(
}
)
set_multi_currency_amounts(payment_entry_doc)
payment_entry_doc.insert()
payment_entry_doc.submit()
@@ -879,6 +880,7 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
"doctype": "Payment Entry",
}
)
set_multi_currency_amounts(payment_entry)
payment_entry.insert()
payment_entry.submit()
transaction = reconcile_vouchers(
@@ -901,6 +903,33 @@ def create_payment_entry_and_reconcile(bank_transaction_name: str | int, payment
}
def set_multi_currency_amounts(pe):
"""Set real exchange rates when the bank and party accounts differ in currency."""
company_currency = frappe.get_cached_value("Company", pe.company, "default_currency")
pe.paid_from_account_currency = frappe.get_cached_value("Account", pe.paid_from, "account_currency")
pe.paid_to_account_currency = frappe.get_cached_value("Account", pe.paid_to, "account_currency")
pe.source_exchange_rate = (
1.0
if pe.paid_from_account_currency == company_currency
else get_exchange_rate(pe.paid_from_account_currency, company_currency, pe.posting_date)
)
pe.target_exchange_rate = (
1.0
if pe.paid_to_account_currency == company_currency
else get_exchange_rate(pe.paid_to_account_currency, company_currency, pe.posting_date)
)
# derive the party-side amount from the authoritative bank-side amount; Payment Entry books any
# rounding residual to Exchange Gain/Loss during validation (set_exchange_gain_loss)
if pe.payment_type == "Receive" and pe.source_exchange_rate:
base_amount = flt(pe.received_amount) * pe.target_exchange_rate
pe.paid_amount = flt(base_amount / pe.source_exchange_rate, pe.precision("paid_amount"))
elif pe.payment_type == "Pay" and pe.target_exchange_rate:
base_amount = flt(pe.paid_amount) * pe.source_exchange_rate
pe.received_amount = flt(base_amount / pe.target_exchange_rate, pe.precision("received_amount"))
@frappe.whitelist(methods=["GET"])
def search_for_transfer_transaction(transaction_id: str | int):
"""
@@ -916,7 +945,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)
@@ -969,6 +998,7 @@ def auto_reconcile_vouchers(
from_reference_date: str | date | None = None,
to_reference_date: str | date | None = None,
):
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
bank_transactions = get_bank_transactions(bank_account)
if len(bank_transactions) > 10:
@@ -1087,6 +1117,7 @@ def get_linked_payments(
to_reference_date: str | date | None = None,
):
# get all matching payments for a bank transaction
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_account = frappe.db.get_values(
"Bank Account", transaction.bank_account, ["account", "company"], as_dict=True
@@ -1106,6 +1137,23 @@ def get_linked_payments(
return subtract_allocations(gl_account, matching)
def validate_date_range(
from_date,
to_date,
filter_by_reference_date=False,
from_reference_date=None,
to_reference_date=None,
):
if cint(filter_by_reference_date):
from_date, to_date = from_reference_date, to_reference_date
message = _("From Reference Date cannot be greater than To Reference Date")
else:
message = _("From Date cannot be greater than To Date")
if from_date and to_date and getdate(from_date) > getdate(to_date):
frappe.throw(message)
def subtract_allocations(gl_account, vouchers):
"Look up & subtract any existing Bank Transaction allocations"
copied = []
@@ -1340,9 +1388,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 +1409,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

@@ -2,18 +2,26 @@
# See license.txt
from unittest.mock import patch
import frappe
from frappe import qb
from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
create_bulk_payment_entry_and_reconcile,
create_payment_entry_and_reconcile,
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
from erpnext.tests.utils import ERPNextTestSuite
RATE_METHOD = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_exchange_rate"
class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
def setUp(self):
@@ -97,3 +105,244 @@ 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_rejects_reversed_date_ranges(self):
from_date, to_date = today(), add_days(today(), -1)
with self.assertRaisesRegex(frappe.ValidationError, "From Date cannot be greater than To Date"):
get_bank_transactions(self.bank_account, from_date, to_date)
with self.assertRaisesRegex(
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
):
auto_reconcile_vouchers(
self.bank_account,
filter_by_reference_date=True,
from_reference_date=from_date,
to_reference_date=to_date,
)
transaction = self.make_bank_transaction(date=today())
with self.assertRaisesRegex(
frappe.ValidationError, "From Reference Date cannot be greater than To Reference Date"
):
get_linked_payments(
transaction.name,
["payment_entry"],
filter_by_reference_date=True,
from_reference_date=from_date,
to_reference_date=to_date,
)
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)
def test_multi_currency_pay_converts_and_balances(self):
# withdrawal from an INR bank paying a USD supplier; rate 3.0 makes 100/3 non-exact
self.enable_multi_currency_setup()
pe = self.reconcile_new_payment(
self.make_multi_currency_txn(withdrawal=100),
payment_type="Pay",
party_type="Supplier",
party=self.supplier,
party_account=self.creditors_usd,
paid_from=self.bank,
paid_to=self.creditors_usd,
rate=3.0,
)
self.assertEqual(pe.docstatus, 1) # submits despite the rounding residual
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (1.0, 3.0))
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33)) # bank side kept, 100/3
self.assertEqual(pe.difference_amount, 0)
# Payment Entry auto-books the rounding residual to Exchange Gain/Loss
self.assertTrue(pe.deductions[0].is_exchange_gain_loss)
self.assertEqual(pe.deductions[0].amount, 0.01) # 100 - 33.33 * 3
def test_multi_currency_receive_converts_and_balances(self):
# deposit into an INR bank from a USD customer; the party side must convert
self.enable_multi_currency_setup()
pe = self.reconcile_new_payment(
self.make_multi_currency_txn(deposit=100),
payment_type="Receive",
party_type="Customer",
party=self.customer,
party_account=self.debtors_usd,
paid_from=self.debtors_usd,
paid_to=self.bank,
rate=3.0,
)
self.assertEqual(pe.docstatus, 1)
self.assertEqual((pe.source_exchange_rate, pe.target_exchange_rate), (3.0, 1.0))
self.assertEqual((pe.received_amount, pe.paid_amount), (100, 33.33)) # bank side kept, 100/3
self.assertEqual(pe.difference_amount, 0)
def test_multi_currency_bulk_pay_converts_and_balances(self):
# the bulk path builds the Payment Entry itself, so it must convert too
self.enable_multi_currency_setup()
txn = self.make_multi_currency_txn(withdrawal=100)
with patch(RATE_METHOD, return_value=3.0):
result = create_bulk_payment_entry_and_reconcile(
[txn.name], "Supplier", self.supplier, self.creditors_usd
)
pe = frappe.get_doc("Payment Entry", result[0]["payment_entry"].name)
self.assertEqual(pe.docstatus, 1)
self.assertEqual(pe.target_exchange_rate, 3.0)
self.assertEqual((pe.paid_amount, pe.received_amount), (100, 33.33))
self.assertEqual(pe.difference_amount, 0)
def enable_multi_currency_setup(self):
# USD party/accounts + a company gain/loss account to absorb rounding residuals
self.company_abbr = "_TC"
self.create_supplier(supplier_name="_Test Supplier USD", currency="USD")
self.create_customer(customer_name="_Test Customer USD", currency="USD")
self.create_usd_payable_account()
self.create_usd_receivable_account()
self.set_party_account("Supplier", self.supplier, self.creditors_usd)
if not frappe.db.get_value("Company", self.company, "exchange_gain_loss_account"):
frappe.db.set_value(
"Company", self.company, "exchange_gain_loss_account", "Exchange Gain/Loss - _TC"
)
def set_party_account(self, party_type, party, account):
doc = frappe.get_doc(party_type, party)
if not any(row.company == self.company for row in doc.accounts):
doc.append("accounts", {"company": self.company, "account": account})
doc.save()
def make_multi_currency_txn(self, withdrawal=0, deposit=0):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": today(),
"withdrawal": withdrawal,
"deposit": deposit,
"bank_account": self.bank_account,
"currency": "INR",
"reference_number": "TEST-FX-REF",
}
)
.save()
.submit()
)
def reconcile_new_payment(
self, txn, *, payment_type, party_type, party, party_account, paid_from, paid_to, rate
):
# mimics the /banking frontend, which sends a hardcoded 1:1 rate
payment_entry_doc = {
"payment_type": payment_type,
"company": self.company,
"party_type": party_type,
"party": party,
"party_account": party_account,
"paid_from": paid_from,
"paid_to": paid_to,
"paid_amount": txn.unallocated_amount,
"received_amount": txn.unallocated_amount,
"source_exchange_rate": 1,
"target_exchange_rate": 1,
"posting_date": today(),
"reference_no": f"TEST-FX-{payment_type}",
"reference_date": today(),
}
with patch(RATE_METHOD, return_value=rate):
result = create_payment_entry_and_reconcile(txn.name, payment_entry_doc)
return frappe.get_doc("Payment Entry", result["payment_entry"].name)

View File

@@ -224,6 +224,7 @@
}
],
"hide_toolbar": 1,
"icon": "file-down",
"links": [],
"modified": "2026-05-30 20:51:10.353723",
"modified_by": "Administrator",

View File

@@ -436,6 +436,9 @@ def get_import_logs(docname: str):
@frappe.whitelist()
def upload_bank_statement(**args):
# The only caller routes into a new Bank Statement Import form, so `create` is the right to require.
frappe.has_permission("Bank Statement Import", "create", throw=True)
args = frappe._dict(args)
bsi = frappe.new_doc("Bank Statement Import")

View File

@@ -188,6 +188,7 @@
}
],
"grid_page_length": 50,
"icon": "file-clock",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-07-09 17:55:25.615942",

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

@@ -4,6 +4,7 @@
"autoname": "naming_series:",
"creation": "2018-10-22 18:19:02.784533",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/bank-transaction",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -277,6 +278,7 @@
}
],
"grid_page_length": 50,
"icon": "arrow-left-right",
"is_submittable": 1,
"links": [],
"modified": "2026-04-11 20:41:15.124085",

View File

@@ -11,6 +11,10 @@ from frappe.utils.dateutils import parse_date
@frappe.whitelist()
def upload_bank_statement():
# Parsing a statement is the first step of creating Bank Transactions from it. Both functions here
# are reached only over HTTP, so there is no in-process caller to break.
frappe.has_permission("Bank Transaction", "create", throw=True)
if getattr(frappe, "uploaded_file", None):
with open(frappe.uploaded_file, "rb") as upfile:
fcontent = upfile.read()
@@ -34,8 +38,13 @@ def upload_bank_statement():
return {"columns": columns, "data": data}
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def create_bank_entries(columns, data, bank_account):
# insert()/submit() enforce this per document, but only after the loop has read each Bank Account
# and written an Error Log per rejected row. Check once up front.
frappe.has_permission("Bank Transaction", "create", throw=True)
frappe.has_permission("Bank Account", doc=bank_account, throw=True)
header_map = get_header_mapping(columns, bank_account)
success = 0

View File

@@ -140,6 +140,7 @@
}
],
"grid_page_length": 50,
"icon": "zap",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-04-11 21:36:26.754667",

View File

@@ -167,6 +167,7 @@
}
],
"hide_toolbar": 1,
"icon": "split",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],

View File

@@ -68,6 +68,7 @@
"label": "Generated"
}
],
"icon": "git-branch",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-03-27 13:06:39.766063",

View File

@@ -4,6 +4,7 @@
"autoname": "naming_series:",
"creation": "2016-05-16 11:42:29.632528",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/budgeting",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -330,6 +331,7 @@
}
],
"grid_page_length": 50,
"icon": "piggy-bank",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],

View File

@@ -122,6 +122,7 @@
"read_only": 1
}
],
"icon": "lock",
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:06:44.260440",

View File

@@ -16,6 +16,8 @@ frappe.ui.form.on("Chart of Accounts Importer", {
() => generate_tree_preview(frm),
() => create_import_button(frm),
() => frm.set_df_property("chart_preview", "hidden", 0),
// the preview is the point of this page — open it right away
() => frm.fields_dict.chart_preview.collapse(false),
]);
}
@@ -128,7 +130,6 @@ var create_import_button = function (frm) {
freeze_message: __("Creating Accounts..."),
callback: function (r) {
if (!r.exc) {
clearInterval(frm.page["interval"]);
frm.page.set_indicator(__("Import Successful"), "blue");
create_reset_button(frm);
}
@@ -142,42 +143,95 @@ var create_reset_button = function (frm) {
frm.page
.set_primary_action(__("Reset"), function () {
frm.page.clear_primary_action();
delete frm.page["show_import_button"];
frm.reload_doc();
})
.addClass("btn btn-primary");
};
var validate_coa = function (frm) {
if (frm.doc.import_file) {
let parent = __("All Accounts");
return frappe.call({
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
args: {
file_name: frm.doc.import_file,
parent: parent,
doctype: "Chart of Accounts Importer",
file_type: frm.doc.file_type,
for_validate: 1,
},
callback: function (r) {
if (r.message["show_import_button"]) {
frm.page["show_import_button"] = Boolean(r.message["show_import_button"]);
}
},
});
}
};
var generate_tree_preview = function (frm) {
let parent = __("All Accounts");
$(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
const wrapper = $(frm.fields_dict["chart_tree"].wrapper).empty(); // empty wrapper to load new data
// search + expand/collapse-all lean on frappe.ui.Tree helpers added with
// row mode; when running against an older frappe that predates them, skip
// this toolbar so the preview still renders (just without the extras)
const has_row_helpers =
typeof frappe.ui.Tree.prototype.get_expansion_state === "function" &&
typeof frappe.ui.Tree.prototype.filter_nodes === "function";
let tree;
let deep_loaded = false;
let search_text = "";
let update_buttons = () => {};
if (has_row_helpers) {
// same toolbar anatomy as the tree view: search on the left,
// expand/collapse-all on the right (three-state: fully collapsed ->
// Expand All, fully expanded -> Collapse All, partially expanded -> both)
const $toolbar = $('<div class="flex items-center gap-2 mb-2"></div>').appendTo(wrapper);
const search_control = frappe.ui.form.make_control({
df: { fieldtype: "Data", fieldname: "preview_search", placeholder: __("Search") },
parent: $toolbar,
only_input: true,
});
search_control.refresh();
$(search_control.wrapper).addClass("m-0").css("width", "220px");
search_control.$input.addClass("input-xs");
search_control.$input.on(
"input",
frappe.utils.debounce(() => {
search_text = search_control.$input.val();
const run = () => {
// a newer keystroke superseded this one while the deep load ran
if (search_text !== search_control.$input.val()) return;
tree.filter_nodes(search_text);
};
if (!search_text || deep_loaded) {
run();
return;
}
tree.load_children(tree.root_node, true).then(() => {
deep_loaded = true;
run();
});
}, 300)
);
const $actions = $('<div class="ms-auto flex items-center gap-1"></div>').appendTo($toolbar);
update_buttons = () => {
const state = tree.get_expansion_state();
$expand_all.prop("disabled", !(state === "collapsed" || state === "partial"));
$collapse_all.prop("disabled", !(state === "expanded" || state === "partial"));
};
// tooltip on a wrapper: a disabled es-button has pointer-events:none,
// so hover falls through to the wrapper and the tooltip still shows
const make_action = (icon, label, onclick) => {
const $btn = $(
frappe.ui.button({ icon, disabled: true, onclick, attrs: { "aria-label": label } })
);
const $wrapper = $('<span class="inline-flex"></span>').append($btn).appendTo($actions);
frappe.ui.tooltip($wrapper, { text: label });
return $btn;
};
var $expand_all = make_action("chevrons-up-down", __("Expand All"), () => {
tree.load_children(tree.root_node, true).then(() => {
deep_loaded = true;
});
});
var $collapse_all = make_action("chevrons-down-up", __("Collapse All"), () => {
tree.load_children(tree.root_node, false);
});
}
// generate tree structure based on the csv data
return new frappe.ui.Tree({
parent: $(frm.fields_dict["chart_tree"].wrapper),
tree = new frappe.ui.Tree({
parent: wrapper,
label: parent,
expandable: true,
// read-only preview: row-mode visuals without actions or hover cards
// (ignored by an older frappe, which renders the legacy tree)
row_style: true,
method: "erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer.get_coa",
args: {
file_name: frm.doc.import_file,
@@ -185,8 +239,9 @@ var generate_tree_preview = function (frm) {
doctype: "Chart of Accounts Importer",
file_type: frm.doc.file_type,
},
onclick: function (node) {
parent = node.value;
},
on_node_render: () => update_buttons(),
// expanded flips right after this callback — check on the next tick
on_click: () => setTimeout(update_buttons, 0),
});
return tree;
};

View File

@@ -5,6 +5,7 @@
"description": "Import Chart of Accounts from a csv file",
"doctype": "DocType",
"document_type": "Other",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/chart-of-accounts-importer",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -47,6 +48,7 @@
}
],
"hide_toolbar": 1,
"icon": "file-input",
"in_create": 1,
"issingle": 1,
"links": [],

View File

@@ -8,6 +8,7 @@ from functools import reduce
import frappe
from frappe import _
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.desk.form.linked_with import get_linked_fields
from frappe.model.document import Document
from frappe.utils import cint, cstr
@@ -58,6 +59,8 @@ def validate_columns(data):
@frappe.whitelist()
def validate_company(company):
frappe.has_permission("Chart of Accounts Importer", throw=True)
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
)
@@ -110,7 +113,10 @@ def import_coa(file_name, company):
def get_file(file_name):
file_doc = frappe.get_doc("File", {"file_url": file_name})
file_doc = find_file_by_url(file_name)
if not file_doc:
raise frappe.PermissionError
parts = file_doc.get_extension()
extension = parts[1]
extension = extension.lstrip(".")
@@ -177,6 +183,8 @@ def generate_data_from_excel(file_doc, extension, as_dict=False):
def get_coa(doctype, parent, is_root=False, file_name=None, for_validate=0):
"""called by tree view (to fetch node's children)"""
frappe.has_permission("Chart of Accounts Importer", throw=True)
file_doc, extension = get_file(file_name)
parent = None if parent == _("All Accounts") else parent
@@ -324,6 +332,8 @@ def build_response_as_excel(writer):
@frappe.whitelist()
def download_template(file_type, template_type, company):
frappe.has_permission("Chart of Accounts Importer", throw=True)
writer = get_template(template_type, company)
if file_type == "CSV":
@@ -376,7 +386,6 @@ def get_sample_template(writer, company):
return writer
@frappe.whitelist()
def validate_accounts(file_doc, extension):
if extension == "csv":
accounts = generate_data_from_csv(file_doc, as_dict=True)

View File

@@ -4,6 +4,7 @@
"autoname": "field:bank_name",
"creation": "2016-05-04 14:35:00.402544",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cheque-print-template",
"engine": "InnoDB",
"field_order": [
"settings",
@@ -293,6 +294,7 @@
"fieldtype": "HTML"
}
],
"icon": "printer",
"links": [],
"max_attachments": 1,
"modified": "2026-06-08 12:10:35.829531",

View File

@@ -7,6 +7,7 @@
"description": "Track separate Income and Expense for product verticals or divisions.",
"doctype": "DocType",
"document_type": "Setup",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cost-center",
"engine": "InnoDB",
"field_order": [
"sb0",
@@ -122,11 +123,11 @@
"label": "Disabled"
}
],
"icon": "fa fa-money",
"icon": "chart-pie",
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2026-04-14 18:15:27.367298",
"modified": "2026-09-16 11:42:07.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cost Center",
@@ -181,6 +182,54 @@
{
"role": "HR Manager",
"select": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Manufacturing User",
"select": 1
},
{
"role": "Projects Manager",
"select": 1
},
{
"role": "Projects User",
"select": 1
},
{
"role": "Purchase Master Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
},
{
"role": "Sales Master Manager",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
}
],
"row_format": "Dynamic",

View File

@@ -12,6 +12,19 @@ frappe.treeview_settings["Cost Center"] = {
],
root_label: "Cost Centers",
get_tree_nodes: "erpnext.accounts.utils.get_children",
get_label: function (node) {
// clean display name — the number renders as a badge (see onrender)
return frappe.utils.escape_html(node.data.cost_center_name || node.title || node.label);
},
onrender: function (node) {
if (node.is_root || !node.data) return;
const flags = [];
if (node.data.cost_center_number) {
flags.push(frappe.ui.badge({ label: node.data.cost_center_number }));
}
erpnext.utils.render_tree_node_flags(node, flags);
},
add_tree_node: "erpnext.accounts.utils.add_cc",
menu_items: [
{
@@ -42,6 +55,37 @@ frappe.treeview_settings["Cost Center"] = {
},
],
ignore_fields: ["parent_cost_center"],
toolbar: [
{
label: __("Convert to Group"),
icon: "folder-tree",
condition: function (node) {
return !node.is_root && !node.expandable && frappe.model.can_write("Cost Center");
},
click: function (node) {
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_ledger_to_group");
},
},
{
label: __("Convert to Non-Group"),
icon: "file-text",
condition: function (node) {
// only on groups the user has opened and found empty — a
// group with children can't convert, so don't offer it
return (
!node.is_root &&
node.expandable &&
node.loaded &&
!node.$ul.children().length &&
frappe.model.can_write("Cost Center")
);
},
click: function (node) {
erpnext.accounts.convert_tree_node("Cost Center", node, "convert_group_to_ledger");
},
},
],
extend_toolbar: true,
onload: function (treeview) {
function get_company() {
return treeview.page.fields_dict.company.get_value();
@@ -82,3 +126,22 @@ frappe.treeview_settings["Cost Center"] = {
);
},
};
frappe.provide("erpnext.accounts");
// shared by the Account and Cost Center tree views (defined in both files,
// whichever loads first wins): run the doctype's whitelisted convert method,
// then re-render the branch so the node's group/leaf state updates
erpnext.accounts.convert_tree_node =
erpnext.accounts.convert_tree_node ||
function (doctype, node, method) {
frappe.call({
method: "run_doc_method",
args: { dt: doctype, dn: node.label, method: method },
callback: function (r) {
if (r.exc) return;
const treeview = frappe.views.trees[doctype];
node.parent_node && treeview.tree.load_children(node.parent_node);
frappe.show_alert({ message: __("{0} converted", [node.label]), indicator: "green" });
},
});
};

View File

@@ -4,6 +4,7 @@
"autoname": "CC-ALLOC-.#####",
"creation": "2022-01-13 20:07:29.871109",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/cost_center_allocation",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -65,6 +66,7 @@
"read_only": 1
}
],
"icon": "chart-pie",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],

View File

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

View File

@@ -5,6 +5,7 @@
"creation": "2018-01-22 14:34:39.701832",
"doctype": "DocType",
"document_type": "Other",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/coupon-code",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -124,8 +125,9 @@
"label": "From External Ecomm Platform"
}
],
"icon": "ticket-percent",
"links": [],
"modified": "2024-11-19 16:35:11.836441",
"modified": "2026-09-16 11:42:07.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Coupon Code",
@@ -179,6 +181,14 @@
"role": "Website Manager",
"share": 1,
"write": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
}
],
"sort_field": "creation",

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
"actions": [],
"creation": "2022-01-10 13:03:26.237081",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/currency-exchange-settings",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -101,6 +102,7 @@
"label": "Use HTTP Protocol"
}
],
"icon": "refresh-cw",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],

View File

@@ -4,6 +4,7 @@
"autoname": "naming_series:",
"creation": "2019-07-05 16:34:31.013238",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/dunning",
"engine": "InnoDB",
"field_order": [
"naming_series",
@@ -397,6 +398,7 @@
"fieldtype": "Column Break"
}
],
"icon": "bell-ring",
"is_submittable": 1,
"links": [],
"modified": "2026-05-30 23:18:04.712528",

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):

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.

View File

@@ -100,6 +100,7 @@
"fieldtype": "Column Break"
}
],
"icon": "bell",
"links": [
{
"link_doctype": "Dunning",

View File

@@ -4,6 +4,7 @@
"autoname": "ACC-ERR-.YYYY.-.#####",
"creation": "2018-04-13 18:25:55.943587",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/exchange-rate-revaluation",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -107,6 +108,7 @@
"precision": "9"
}
],
"icon": "arrow-right-left",
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:09:42.951164",

View File

@@ -5,6 +5,7 @@
"creation": "2018-04-13 17:42:43.252224",
"doctype": "DocType",
"document_type": "Document",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/finance-book",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -18,9 +19,9 @@
"unique": 1
}
],
"icon": "fa fa-book",
"icon": "book",
"links": [],
"modified": "2024-03-27 13:09:44.514241",
"modified": "2026-09-16 11:42:07.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Finance Book",
@@ -55,6 +56,18 @@
"report": 1,
"role": "Auditor",
"share": 1
},
{
"role": "HR Manager",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Quality Manager",
"select": 1
}
],
"quick_entry": 1,

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",
},
@@ -236,6 +236,8 @@ async function refresh_tree_view(dialog, account_rows) {
parent: wrapper,
label: company,
root_value: company,
// read-only preview: row-mode visuals without actions
row_style: true,
method: "erpnext.accounts.doctype.financial_report_template.financial_report_engine.get_children_accounts",
args: { doctype: "Account", company: company, filtered_accounts: filtered_accounts, missed: missed },
toolbar: [],
@@ -288,14 +290,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 +372,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 +382,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

@@ -3,6 +3,7 @@
"autoname": "field:template_name",
"creation": "2025-08-02 04:44:15.184541",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/financial-report-template",
"engine": "InnoDB",
"field_order": [
"template_name",
@@ -64,6 +65,7 @@
}
],
"grid_page_length": 50,
"icon": "file-spreadsheet",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-02-23 01:04:05.797161",

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

@@ -6,6 +6,7 @@
"description": "Represents a Financial Year. All accounting entries and other major transactions are tracked against the Fiscal Year.",
"doctype": "DocType",
"document_type": "Setup",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/fiscal-year",
"engine": "InnoDB",
"field_order": [
"year",
@@ -79,10 +80,10 @@
"set_only_once": 1
}
],
"icon": "fa fa-calendar",
"icon": "calendar",
"idx": 1,
"links": [],
"modified": "2024-05-27 17:29:55.560840",
"modified": "2026-09-16 11:42:07.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Fiscal Year",
@@ -131,6 +132,10 @@
{
"read": 1,
"role": "Auditor"
},
{
"role": "Sales Master Manager",
"select": 1
}
],
"show_name_in_global_search": 1,

View File

@@ -376,7 +376,7 @@
"precision": "9"
}
],
"icon": "fa fa-list",
"icon": "book-open",
"idx": 1,
"in_create": 1,
"links": [],

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,9 +1,11 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_import": 1,
"autoname": "ACC-INV-DISC-.YYYY.-.#####",
"creation": "2019-03-07 12:01:56.296952",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/invoice_discounting",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -168,9 +170,10 @@
"read_only": 1
}
],
"icon": "ticket-percent",
"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 +190,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

@@ -5,6 +5,7 @@
"creation": "2022-01-19 01:09:13.297137",
"doctype": "DocType",
"document_type": "Setup",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/item-tax-template",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -56,8 +57,9 @@
"fieldtype": "Section Break"
}
],
"icon": "circle-percent",
"links": [],
"modified": "2024-03-27 13:09:55.573483",
"modified": "2026-09-16 11:42:07.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Item Tax Template",
@@ -95,6 +97,54 @@
"report": 1,
"role": "Accounts User",
"share": 1
},
{
"role": "Delivery Manager",
"select": 1
},
{
"role": "Delivery User",
"select": 1
},
{
"role": "Item Manager",
"select": 1
},
{
"role": "Maintenance Manager",
"select": 1
},
{
"role": "Maintenance User",
"select": 1
},
{
"role": "Manufacturing Manager",
"select": 1
},
{
"role": "Purchase Manager",
"select": 1
},
{
"role": "Purchase User",
"select": 1
},
{
"role": "Sales Manager",
"select": 1
},
{
"role": "Sales User",
"select": 1
},
{
"role": "Stock Manager",
"select": 1
},
{
"role": "Stock User",
"select": 1
}
],
"show_name_in_global_search": 1,

View File

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

@@ -6,6 +6,7 @@
"creation": "2022-01-25 10:29:58.717206",
"doctype": "DocType",
"document_type": "Document",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/journal-entry",
"engine": "InnoDB",
"field_order": [
"entry_type_and_date",
@@ -661,7 +662,7 @@
"label": "Custom Remark"
}
],
"icon": "fa fa-file-text",
"icon": "notebook-pen",
"idx": 176,
"is_submittable": 1,
"links": [

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 = []
@@ -1354,6 +1356,10 @@ def get_default_bank_cash_account(
):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
# `select`, not `read`: this also runs server-side from get_payment_entry, and Auditor/HR User/Desk
# User hold only the select row on Company. doc= brings User Permissions to bear.
frappe.has_permission("Company", ptype="select", doc=company, throw=True)
if mode_of_payment:
account = get_bank_cash_account(mode_of_payment, company).get("account")
@@ -1382,6 +1388,10 @@ def get_default_bank_cash_account(
account = account_list[0].name
if account:
# `fetch_balance` is caller supplied, so authorise the account here rather than relying on
# get_balance_on(), which only checks on the branch that reads a balance.
frappe.has_permission("Account", doc=account, throw=True)
account_details = frappe.get_cached_value(
"Account", account, ["account_currency", "account_type"], as_dict=1
)
@@ -1566,30 +1576,39 @@ def get_against_jv(doctype, txt, searchfield, start, page_len, filters):
if not frappe.db.has_column("Journal Entry", searchfield):
return []
JournalEntry = frappe.qb.DocType("Journal Entry")
JournalEntryAccount = frappe.qb.DocType("Journal Entry Account")
account = filters.get("account")
party = filters.get("party")
query = (
frappe.qb.from_(JournalEntry)
.join(JournalEntryAccount)
.on(JournalEntryAccount.parent == JournalEntry.name)
.select(JournalEntry.name, JournalEntry.posting_date, JournalEntry.remark)
.where(JournalEntryAccount.account == filters.get("account"))
.where(JournalEntryAccount.reference_type.isnull() | (JournalEntryAccount.reference_type == ""))
.where(JournalEntry.docstatus == 1)
.where(JournalEntry[searchfield].like(f"%{txt}%"))
.orderby(JournalEntry.name, order=frappe.qb.desc)
.limit(page_len)
.offset(start)
# each names one value: a list would be read as a filter operator and widen the search.
for value in (account, party):
if value and not isinstance(value, str):
frappe.throw(_("Invalid filter"), frappe.PermissionError)
# get_list applies the permission query conditions; the child-table filter resolves the check to `read`
je_filters = [
["docstatus", "=", 1],
[searchfield, "like", f"%{txt}%"],
["Journal Entry Account", "account", "=", account],
["Journal Entry Account", "reference_type", "is", "not set"],
]
je_filters.append(
["Journal Entry Account", "party", "=", party]
if party
else ["Journal Entry Account", "party", "is", "not set"]
)
party = filters.get("party")
if party:
query = query.where(JournalEntryAccount.party == party)
else:
query = query.where(JournalEntryAccount.party.isnull() | (JournalEntryAccount.party == ""))
return query.run()
return frappe.get_list(
"Journal Entry",
filters=je_filters,
fields=["name", "posting_date", "remark"],
order_by="name desc",
limit_start=start,
limit_page_length=page_len,
as_list=True,
# one row per entry, not per matching account row. group_by rather than distinct: frappe
# drops ORDER BY from a distinct query on postgres, which would lose the ordering above.
group_by="name",
)
@frappe.whitelist()
@@ -1808,6 +1827,10 @@ def make_reverse_journal_entry(source_name, target_doc=None):
def post_process(source, target):
target.reversal_of = source.name
target.naming_series = source.naming_series
if source.voucher_type == "Bank Entry":
target.cheque_no = source.cheque_no
target.cheque_date = source.cheque_date
doclist = get_mapped_doc(
"Journal Entry",

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