Compare commits

...

225 Commits

Author SHA1 Message Date
Mihir Kandoi
defff46d8f test(selling): Update Items keeps a row with an issued proforma 2026-09-24 21:57:29 +05:30
Mihir Kandoi
7dd3598156 fix(selling): block deleting a proformed Sales Order row via Update Items
Deleting the row left the issued proforma pointing at a Sales Order
line that no longer exists.
2026-09-24 21:57:13 +05:30
Mihir Kandoi
6bf5fa51d5 test(selling): edited and default Proforma Invoice line description 2026-09-24 21:56:26 +05:30
Mihir Kandoi
d29ccfb569 feat(selling): editable item description on Proforma Invoice
The create dialog now shows each line's Sales Order description for
editing. The proforma line stores it, falling back to the Sales Order
description, and the print shows it under the item name.
2026-09-24 21:55:43 +05:30
Mihir Kandoi
c1808c9124 fix(selling): tidy the Proforma Invoice print
Print the customer address under the customer name, and drop the
Against Sales Order row and the not-a-demand-for-payment footer.
2026-09-24 21:52:26 +05:30
Mihir Kandoi
fa9d7a75e1 fix(selling): keep the Proforma tab on a cancelled Sales Order
Cancelling a Sales Order first needs its proformas cancelled, and the
tab then vanished, hiding the cancelled proformas that the list keeps
for audit. Show the list read-only instead.
2026-09-24 21:51:26 +05:30
Mihir Kandoi
02854a48ee test(selling): reject an amended Proforma Invoice 2026-09-24 21:50:45 +05:30
Mihir Kandoi
b03e48f453 fix(selling): block amending a cancelled Proforma Invoice
A proforma is only created from its Sales Order, but a cancelled one
showed Amend to Administrator, and the amended copy could be saved
outside that path.
2026-09-24 21:50:30 +05:30
Mihir Kandoi
b78c2bc509 fix(stock): drop unused serial balance from Stock Ledger (#59392) 2026-09-24 14:50:53 +00:00
Shllokkk
57f266b092 Merge pull request #59386 from Shllokkk/crm-activities-new-doc-guard
fix: skip activities fetch on unsaved CRM doc
2026-09-24 18:11:16 +05:30
Shllokkk
87052020fb fix: skip activities fetch on unsaved CRM doc 2026-09-24 17:38:41 +05:30
Gajendra Nishad
5aeacbbf5d Merge pull request #59233 from vishwajeet-13/feat/payment-reconciliation-totals-v2
feat(payment-reconciliation): show invoice/payment/difference totals
2026-09-24 15:55:26 +05:30
Nishka Gosalia
fbc9a7960a Merge pull request #59178 from nishkagosalia/bom-ux-cleanup
fix(UX): Bom UX cleanup
2026-09-24 13:10:55 +05:30
nishkagosalia
ec88db0253 fix: Moving the setting to set rate based on sub assembly to item level 2026-09-24 12:29:47 +05:30
nishkagosalia
73e5eed986 fix: bom operation and item cleanup 2026-09-24 12:27:58 +05:30
nishkagosalia
c19be65fc5 fix(UX): Bom UX cleanup 2026-09-24 12:27:58 +05:30
Mihir Kandoi
87113d7c2c feat(subcontracting): use template BOM for variant finished goods (#59373) 2026-09-24 06:46:18 +00:00
Pandiyan P
707f028780 fix: respect permissions on sales and purchase invoice actions (#59367) 2026-09-24 12:14:08 +05:30
Mihir Kandoi
549e1774a1 feat(stock): serial and batch wise stock balance report (#59321) 2026-09-24 11:52:52 +05:30
Sudharsanan Ashok
ce3b63ae25 fix(subcontracting): convert service cost to company currency (#59334)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-24 10:54:06 +05:30
Mihir Kandoi
80cd187b2a test(stock): let the superset check accept a declined index (#59362) 2026-09-24 08:30:43 +05:30
Shllokkk
7cc81a5b25 Merge pull request #59361 from Shllokkk/mrp-test-default-warehouse
test: set valid default warehouse in MRP item group defaults
2026-09-24 03:55:12 +05:30
Shllokkk
90d59aeb54 test: set valid default warehouse in MRP item group defaults 2026-09-24 03:41:28 +05:30
Sudharsanan Ashok
6e0ec5af31 fix(regional): report uae vat 201 sales vat in company currency (#59168) 2026-09-24 01:05:37 +05:30
Vishnu Priya Baskaran
f7d16fbc16 fix: round running allocation balance in payment reconciliation (#58393) 2026-09-24 00:56:06 +05:30
Vishnu Priya Baskaran
2a7c7f5508 fix(pos): reset stale selected mode of payment on new invoice (#59308) 2026-09-24 00:54:51 +05:30
Shllokkk
0e322e457e Merge pull request #59349 from Shllokkk/mrp-po-missing-supplier
fix: throw clear message when MRP purchase item has no default supplier
2026-09-23 22:30:35 +05:30
Mihir Kandoi
775b019c30 feat(manufacturing): close individual blanket order items (#59343)
* feat(manufacturing): show stock UOM on blanket order items

A Blanket Order row's qty is in the item's stock UOM (orders made from
it use the stock UOM), but the row never said which unit that was.
Fetch the stock UOM onto the row and backfill existing rows.

* feat(manufacturing): close individual blanket order items

Use the row-level Close Items and Reopen Items actions from #57596 on
Blanket Order. A row can be closed while part of its qty is still
unordered. Closing every row closes the Blanket Order, and reopening a
row reopens it.

on_item_close_status_change becomes optional, like validate_item_close,
since a Blanket Order has no progress fields to recalculate.

* test(manufacturing): closing blanket order items

* feat(manufacturing): skip closed blanket order items when ordering

A closed row is left out when creating an order from the Blanket Order,
and out of the row picker and the item details lookup for that item.
Saving or submitting an order against it fails, and so does raising a
linked row's qty with Update Items.

* test(manufacturing): closed blanket order items are not ordered

* feat(manufacturing): show row state on blanket order items

Mark each Blanket Order row the way Purchase Order marks its rows: gray
when closed, green when fully ordered, orange while qty is still
pending.
2026-09-23 22:24:36 +05:30
Mihir Kandoi
df4a9f8d9f feat(manufacturing): close and re-open blanket orders (#59341)
* feat(manufacturing): close and re-open blanket orders

Blanket Order had no status field, so a finished or cancelled agreement
looked the same as an active one and could still be ordered against.

Add a status (Draft, Submitted, Closed, Cancelled) driven by the status
map, with Close and Re-open buttons under Status. A closed Blanket Order
hides its Create buttons. The patch backfills status from docstatus.

* test(manufacturing): blanket order status through close, re-open and cancel

* feat(manufacturing): stop ordering against a closed blanket order

Hiding the Create button is not enough: a Sales or Purchase Order can
still pick the Blanket Order on its rows. A closed Blanket Order is now
left out of the row picker and the item details lookup, and making,
saving or submitting an order against it fails. Update Items fails
when it raises the qty of a row linked to one.

* test(manufacturing): closed blanket order blocks new orders and qty increases

* perf(manufacturing): check closed blanket order on the loaded document

validate_against_blanket_order already loads each Blanket Order, so read
its status from there instead of querying it again per Blanket Order.
make_order and Update Items use the same method.

* fix(manufacturing): check every order row linked to a blanket order

A Sales or Purchase Order row could keep its Blanket Order link with
Against Blanket Order unticked, for example through the API or an
import. Validation skipped that row, so it could exceed the allowance or
order against a closed Blanket Order, yet its qty still counted as
ordered. Check every linked row, the same rows that ordered qty counts.

* test(manufacturing): linked order row is checked without against blanket order

* fix(manufacturing): lock the blanket order while an order is checked against it

Saving an order read the Blanket Order without a lock, so another
transaction could close it, or close one of its rows, between the check
and the commit. Load it FOR UPDATE, which locks the Blanket Order and
its rows until the order is saved; Close and Close Items wait for it.
Blanket Orders are locked in name order so two orders cannot deadlock.
The lock also serialises the existing allowance check. Update Items
takes the same lock.

* fix(manufacturing): check blanket order expiry in update items

Update Items checked only whether the Blanket Order was closed. Its To
Date can be edited after submit, so a submitted order dated after the
new To Date could still grow. Keep the order-level checks in one method,
validate_can_be_ordered, used by make_order, order validation and
Update Items.

* test(manufacturing): update items cannot raise qty after blanket order expires
2026-09-23 22:24:35 +05:30
Shllokkk
6367ff9f8d test: cover missing and item-group-fallback supplier in MRP make_order 2026-09-23 22:12:47 +05:30
Shllokkk
43913d5c2a fix: resolve MRP purchase supplier with item group fallback, else throw 2026-09-23 22:12:44 +05:30
Mihir Kandoi
328ec92662 feat: email templates for customer-facing emails (#59332) 2026-09-23 22:07:10 +05:30
Mihir Kandoi
a434defbb7 fix(manufacturing): stop ordering against an expired blanket order (#59346) 2026-09-23 21:15:47 +05:30
Mihir Kandoi
62b69d6dcf fix: translate doctype name values in report columns (#59342) 2026-09-23 21:14:44 +05:30
Mihir Kandoi
33d29274ba perf(stock): serve Item link search from an FTS5 trigram index (#59166) 2026-09-23 21:14:12 +05:30
Mihir Kandoi
5426a228c5 fix: keep against voucher link in general ledger for non-english users (#59345) 2026-09-23 15:11:03 +00:00
Mihir Kandoi
df428d6cc7 refactor: use frm instead of cur_frm in client scripts (#59315) 2026-09-23 19:33:03 +05:30
Pandiyan P
574f345e91 fix(selling): use preferred addresses in customer quotations (#59336) 2026-09-23 18:45:02 +05:30
Nishka Gosalia
9a79253ee1 Merge pull request #59329 from nishkagosalia/job-card-ux-cleanup
fix(UX): Job card cleanup
2026-09-23 18:17:12 +05:30
nishkagosalia
a05229b988 fix(UX): Job card cleanup 2026-09-23 16:45:40 +05:30
Mihir Kandoi
a0b7db19d9 fix(quality): block Quality Inspection submit without a sample size (#59330) 2026-09-23 10:59:37 +00:00
rohitwaghchaure
bc6e5e2060 fix: permission checks for serial / batch creation using scanning (#59325) 2026-09-23 15:57:00 +05:30
Diptanil Saha
5de2ac1f26 fix(pos): quote the reversed row's rate on a consolidated credit note (#59320) 2026-09-23 14:57:21 +05:30
Mihir Kandoi
a46930e10a fix(manufacturing): subtract consumption entries from available materials (#59310) 2026-09-23 13:37:26 +05:30
rohitwaghchaure
18093079d9 feat(stock): GL-only reposting from Stock and Account Value Comparison report (backport #59127) (#59307)
* feat(stock): GL-only reposting from Stock and Account Value Comparison report (backport #59127)

* fix: take GL repost posting date from the voucher's stock ledger and skip GL-only rows
2026-09-23 07:53:57 +00:00
rohitwaghchaure
f4979e6650 fix: production plan layout (#59185) 2026-09-23 13:21:07 +05:30
rohitwaghchaure
63413f8bfe perf: reduce memory consumption during reposting (backport #59117) (#59306) 2026-09-23 13:12:11 +05:30
Deepesh Garg
02c04ae167 Merge pull request #59292 from deepeshgarg007/erpnext_persona_skip
fix: Skip ERPNext persona for HRMS signups
2026-09-23 13:11:21 +05:30
Deepesh Garg
2dcf82a2ab Merge pull request #59276 from deepeshgarg007/bank-reco-voucher-registry
feat: make bank reconciliation dialog voucher types extensible
2026-09-23 12:48:37 +05:30
Deepesh Garg
ca1a88b53b Merge branch 'develop' into erpnext_persona_skip 2026-09-23 12:37:11 +05:30
Mihir Kandoi
d75b957ce0 feat(selling): put a customer on hold (#59303) 2026-09-23 12:13:48 +05:30
Khushi Rawat
e9480bd0e1 Merge pull request #59304 from khushi8112/fix/opening-depreciation-periods
fix: count complete opening depreciation periods accurately
2026-09-23 12:08:13 +05:30
rohitwaghchaure
8793ad8264 fix: negative stock value for moving average items with mixed batchwise valuation (backport #59102, #59111) (#59291)
* 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

(cherry picked from commit 262fdf3e69)

* 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>
(cherry picked from commit 4af1b0db4e)

* perf: probe legacy batch ledgers on the batch_no index

The batch_no, item_code, warehouse index was dropped in v15, so the item and
warehouse probe fell back to the item ledger and read every row to look at
batch_no. Probe the batches the aggregates already filter on instead, which
the batch_no index covers.

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2026-09-23 06:37:59 +00:00
khushi8112
3691ca5b5b test(assets): cover complete opening depreciation periods 2026-09-23 11:49:02 +05:30
khushi8112
56f24a6adf fix(assets): count complete opening depreciation periods accurately 2026-09-23 11:48:32 +05:30
Deepesh Garg
60f0a736ab 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-23 09:48:23 +05:30
Deepesh Garg
1ba071d657 Merge branch 'develop' into erpnext_persona_skip 2026-09-23 09:10:04 +05:30
Mihir Kandoi
b3d55db893 fix(stock): return material that a document rejected in full (#59280)
* fix(stock): return material that a document rejected in full

A receipt may leave the accepted quantity at zero when the whole row is
rejected, and so may an invoice that updates stock. Neither could be sent back:
the return mapper kept only rows with an accepted quantity, so it produced a
document with no rows at all, and the checks that a return carries something
looked at the accepted quantity alone.

Carry a row that has a rejected quantity, and count that quantity as material
going back.

* test(stock): cover the return of a receipt that rejected every unit

* fix(stock): send rejected material back at the rate it came in at

Material a transfer rejected in full went back to the in-transit warehouse with
no value at all, and the invoice, having no accepted quantity to price, fell
back to a single unit's rate.

Three things stood in the way of simply reversing the rate the material came in
at. A return of a transfer never read that rate. The value going back counted
the accepted quantity alone. And the rate was looked up against the accepted
warehouse, which had received nothing.

Ten units received at 100 into the rejected warehouse now go back as 1000, and
the entries say the same as the stock.

* test(accounts): cover the return of a transfer that rejected every unit
2026-09-22 20:32:13 +05:30
Mihir Kandoi
876bf0b7f7 fix(stock): move rejected material of an internal transfer on an invoice too (#59260)
* feat(stock): move rejected material of an internal transfer on an invoice too

An invoice that updates stock moves the same material as a receipt, so it was
left with the fault the layers below fixed for the receipt: the rejected
material stayed in the in-transit warehouse and was counted in the rejected
warehouse as well.

Its entries had no accounting for rejected material at all, which is why those
layers stopped at the receipt. The entry that books the rejected warehouse
serves both cases now: an internal transfer credits the in-transit warehouse for
the accepted and the rejected material together, and an invoice that bills the
rejected qty keeps its cost on the supplier entry, as before.

`is_internal_receipt` covers an invoice that updates stock, so the qty, the
packages, cancelling and returning behave as they do on a receipt.

* test(accounts): cover an internal transfer invoice that rejects material

* fix(stock): validate a rejected package against the rejected quantity

On an invoice row the quantity field was forced to `stock_qty` for every
package, so the package of rejected material was compared with the accepted
quantity and a partly rejected internal transfer could not be saved.

Keep the override for the accepted package only, and let an explicitly passed
field through untouched.

* test(accounts): cover rejected batch material on an internal transfer invoice

* fix(accounts): let a stock updating invoice reject every unit of a row

A receipt may leave the accepted quantity at zero when the whole row is
rejected. An invoice that updates stock moves the same material, but the row was
refused with "Quantity for Item cannot be zero".

Share the receipt test with `is_internal_receipt()` so both exemptions follow
one rule.

* test(accounts): cover a stock updating invoice that rejects a whole row

* fix(stock): count a rejected package in the units a package counts in

A package holds stock units, but the package of rejected material was compared
with the rejected quantity as the row states it. A row of one box of twelve was
refused for holding twelve units, and resizing such a package cut it to the
number of boxes.

Read the row the way the package was built, so a rejected quantity is carried
through the conversion factor like every other quantity.

* test(stock): cover rejected material of a transfer bought in another unit
2026-09-22 20:32:13 +05:30
Mihir Kandoi
16b1be814c fix(accounts): bill the rejected quantity on a stock updating invoice (#59258)
* feat(accounts): bill the rejected quantity on a stock updating invoice

Both rejected material settings are written for the receipt flow. The receipt
books rejected material against Stock Received But Not Billed, and the invoice
mapped from it carries the received qty with nothing rejected, so the supplier
pays for every unit received and that account clears.

An invoice that moves stock itself has no receipt to do that. It bills the
accepted qty alone, so the setting that asks for rejected material to be valued
had nothing to back the value it asked for, and the layer below zeroes it.

Such an invoice bills the received qty now when the setting is on, spreads the
valuation over the same qty, and debits the rejected warehouse from its own
entries, so the supplier entry carries the cost. An internal transfer bills
nothing of the sort: its material is paid for by the warehouse it came out of.

* test(accounts): cover the rejected quantity billed on a stock updating invoice

* fix(accounts): tell the form whether the rejected quantity is billed

The form read the two settings off the document, where they never appear: a
doctype settings map drives the settings panel of a form, it does not put those
fields on the document. The amount of a row stayed at the accepted qty until the
document was saved and the server worked it out again.

Both settings go into the boot now, and the form reads them from there. An
internal transfer is left alone, as it is on the server.

* test(accounts): give the project purchase cost test its own payable account

The test bills in USD but let the payable account be chosen for it, so it passed
only when an earlier test had left a payable account in that currency behind.

* test(accounts): read the invoices back before cancelling them

Submitting an invoice against a project writes to it again, so the copy the test
holds is already behind and cancelling it fails on the timestamp.

* test(accounts): restore the buying settings the tests change

Both tests put the settings back by hand, one of them to a value the site never
had, so every test that ran afterwards saw the rejected quantity billed. Let the
suite save and restore them.

* fix(accounts): spread a discount over the quantity the invoice bills

An invoice that bills the rejected quantity carries an amount for every unit
received, but the net rate was still divided by the accepted quantity alone. Six
accepted and four rejected at 100 with a tenth off gave a net rate of 150 and
wrote that to the item as its last purchase rate.

Divide by the quantity the amount was built from. Nothing changes for a document
that does not bill the rejected quantity.

* test(accounts): cover a discount on an invoice that bills the rejected quantity

* fix(accounts): book the rejected warehouse from the stock it received

The entry was written only while the setting that bills the rejected quantity
was on, so an invoice reposted after that setting changed lost the entry while
keeping the supplier credit that paid for the material, and reposting failed on
the difference. The stock the invoice moved is what the entry records, so read
that instead. Material that nothing paid for carries no value and still books
nothing.

A return that stands on its own compared what the supplier was credited with the
stock of the accepted warehouse alone, and booked the rejected material a second
time as a variance. Count both warehouses, since both come back.

* test(accounts): cover the rejected warehouse after a repost and on a return

* docs(buying): say where the rejected quantity is billed

An invoice that updates stock bills the rejected quantity too, with no receipt
in front of it.

* docs(buying): drop the list of documents from the billing description

The purchase cycle says it.
2026-09-22 20:32:12 +05:30
Mihir Kandoi
ecc643fde0 fix(stock): stop valuing rejected material on a stock updating invoice (#59257)
* fix(stock): stop valuing rejected material on a stock updating invoice

`set_valuation_rate_for_rejected_materials` is written for the receipt flow. A
receipt books rejected material against Stock Received But Not Billed, and the
invoice mapped from it carries the received qty with nothing rejected, so the
supplier pays for all of it and that account clears.

An invoice that moves stock itself has no receipt to do that. It bills the
accepted qty alone, while the setting still gave its rejected material the
invoice rate, so the rejected warehouse received stock value that nothing paid
for and no entry backed.

One predicate now answers whether rejected material carries value, for plain
rows and for rows tracked by a package alike. Material of an internal transfer
always does, since its value was credited out of the in-transit warehouse. A
receipt follows the setting. A stock updating invoice does not, until it bills
that material.

* test(accounts): cover rejected material value on a stock updating invoice

* fix(stock): read the transit test from where this layer keeps it

The layer below asks the package itself whether the material came from an
in-transit warehouse. Here that question is answered by the buying settings.

* docs(buying): say where rejected material is valued

The setting reaches every document of the purchase cycle that receives material,
not the receipt alone.
2026-09-22 20:32:12 +05:30
Mihir Kandoi
df3f952fac fix(stock): empty the in-transit warehouse when every unit is rejected (#59256)
* fix(stock): deduct rejected qty from the in-transit warehouse

On an internal transfer the receipt took only the accepted qty out of the
in-transit warehouse, while the rejected qty was booked into the rejected
warehouse, so the rejected material was counted in both.

It also carried stock value, because an internal transfer anchors every inward
entry to the rate of the delivery note, but the rejected warehouse got no
accounting entry unless Buying Settings asked for one. The stock value and the
account value then disagreed.

The entry for the in-transit warehouse covers the accepted and the rejected qty
now, and the rejected warehouse is booked whatever that setting says, since the
value came out of the in-transit warehouse either way.

* test(stock): cover rejected qty on an internal transfer receipt

* fix(stock): let rejected serial and batch material leave the in-transit warehouse

A serial or batch item rejected on an internal transfer could not be received at
all. The package for the in-transit warehouse is copied from the delivery note,
and the copy was never resized, because the check compared a positive qty against
the negative total of an outgoing package.

The package of a row follows the split now. A row that rejects material carries
the package of its accepted warehouse, holding the accepted material alone,
which is the entry it belongs to and the total the desk sets its accepted qty
from. A row that rejects nothing keeps the package of the in-transit warehouse it
came out of. Editing the split moves the package from one to the other, and a row
that accepts nothing carries no package at all.

The entry for the in-transit warehouse gets a package of its own, holding the
accepted and the rejected material together. A landed cost voucher or a repost
reuses it rather than building a second one, which would make the batch qty count
the material twice.

Rejected material of an internal transfer keeps its rate, since its value was
credited out of the in-transit warehouse; refusing it a rate left the difference
to be written off. Cancelling reverses that warehouse with the package its entry
posted, after the rejected warehouse, so serial numbers are not put back and
taken out again. A return builds an inward package covering both.

The resize also fixes an ordinary partial receipt of a tracked item bought in
another UOM: the package is sized in stock UOM, which is what the row is
validated against.

* test(stock): cover rejected serial and batch material on an internal transfer

* fix(stock): build the package of rejected material on an internal transfer

A receipt of an internal transfer builds one package and stops there, so a
tracked row that rejects material had nothing to say where that material came
from. The desk offers no field for it either, and the receipt could not be
submitted: the entry for the in-transit warehouse was handed the package of the
accepted warehouse.

The row takes a package of its own for the rejected material now, built from what
the delivery note put in the in-transit warehouse. Moving the package of a row
between the two warehouses also reads that delivery note package, instead of the
package it happens to hold, which no longer covers the qty once the split changes.

* test(stock): cover the package built for rejected batch material

* fix(stock): empty the in-transit warehouse when every unit is rejected

A receipt that rejects the whole qty left the material in the in-transit
warehouse and added it to the rejected warehouse as well, because the entries of
a row were made only when there was an accepted qty. They are made from the qty
that leaves the source warehouse now, so a row with no accepted qty is posted
like any other. One gate replaces two nested ones, which moves the body of the
loop out by a level; read the diff with whitespace ignored.

Such a row also carried no valuation rate, since the rate of an internal transfer
is taken from the accepted qty alone, and the rejected warehouse was then debited
without a matching credit. The rate falls back to the rejected qty.

Returning that material from the rejected warehouse left the in-transit warehouse
holding the qty at no value and wrote the value off: the return has no delivery
note reference, so its entry for that warehouse got no rate, and the entry
against it was suppressed because rejected material normally carries none. It
takes the rate of the return now, and the value of the source warehouse is signed
rather than absolute, so a return debits the warehouse the material returns to.

* test(stock): cover an internal transfer with every unit rejected

* fix(stock): keep resizing the package of a row whose qty changed

The package of rejected material was built between the two branches that build
and resize the package of a row, which left the resize attached to it. A row
whose qty changed after its package was built stopped being resized, and the
receipt was refused for the qty it no longer had.

* test(stock): state the rejected valuation setting the transfer test relies on

* fix(stock): keep a charge off the rejected material of a transfer

A landed cost voucher rebuilds the receipt with the charge spread over the
material it accepted, and the package of rejected material was then valued at
that same rate. Three units rejected out of a transfer worth 100 each came to
351.43 after a charge of 120, and the difference was credited to Cost of Goods
Sold to make the entries balance.

Rejected material of a transfer keeps the value it arrived in transit with. The
share of the charge that would have sat on it is expensed instead.

* test(stock): cover a charge on a transfer that rejected material
2026-09-22 20:32:12 +05:30
Mihir Kandoi
726c92f8ae fix(stock): let rejected serial and batch material leave the in-transit warehouse (#59252)
* fix(stock): let rejected serial and batch material leave the in-transit warehouse

A serial or batch item rejected on an internal transfer could not be received at
all. The package for the in-transit warehouse is copied from the delivery note,
and the copy was never resized, because the check compared a positive qty against
the negative total of an outgoing package.

The package of a row follows the split now. A row that rejects material carries
the package of its accepted warehouse, holding the accepted material alone,
which is the entry it belongs to and the total the desk sets its accepted qty
from. A row that rejects nothing keeps the package of the in-transit warehouse it
came out of. Editing the split moves the package from one to the other, and a row
that accepts nothing carries no package at all.

The entry for the in-transit warehouse gets a package of its own, holding the
accepted and the rejected material together. A landed cost voucher or a repost
reuses it rather than building a second one, which would make the batch qty count
the material twice.

Rejected material of an internal transfer keeps its rate, since its value was
credited out of the in-transit warehouse; refusing it a rate left the difference
to be written off. Cancelling reverses that warehouse with the package its entry
posted, after the rejected warehouse, so serial numbers are not put back and
taken out again. A return builds an inward package covering both.

The resize also fixes an ordinary partial receipt of a tracked item bought in
another UOM: the package is sized in stock UOM, which is what the row is
validated against.

* test(stock): cover rejected serial and batch material on an internal transfer

* fix(stock): build the package of rejected material on an internal transfer

A receipt of an internal transfer builds one package and stops there, so a
tracked row that rejects material had nothing to say where that material came
from. The desk offers no field for it either, and the receipt could not be
submitted: the entry for the in-transit warehouse was handed the package of the
accepted warehouse.

The row takes a package of its own for the rejected material now, built from what
the delivery note put in the in-transit warehouse. Moving the package of a row
between the two warehouses also reads that delivery note package, instead of the
package it happens to hold, which no longer covers the qty once the split changes.

* test(stock): cover the package built for rejected batch material

* fix(stock): keep resizing the package of a row whose qty changed

The package of rejected material was built between the two branches that build
and resize the package of a row, which left the resize attached to it. A row
whose qty changed after its package was built stopped being resized, and the
receipt was refused for the qty it no longer had.

* fix(stock): keep a charge off the rejected material of a transfer

A landed cost voucher rebuilds the receipt with the charge spread over the
material it accepted, and the package of rejected material was then valued at
that same rate. Three units rejected out of a transfer worth 100 each came to
351.43 after a charge of 120, and the difference was credited to Cost of Goods
Sold to make the entries balance.

Rejected material of a transfer keeps the value it arrived in transit with. The
share of the charge that would have sat on it is expensed instead.

* test(stock): cover a charge on a transfer that rejected material
2026-09-22 20:32:11 +05:30
Mihir Kandoi
c0f13b01de fix(stock): deduct rejected qty from the in-transit warehouse (#59251)
* fix(stock): deduct rejected qty from the in-transit warehouse

On an internal transfer the receipt took only the accepted qty out of the
in-transit warehouse, while the rejected qty was booked into the rejected
warehouse, so the rejected material was counted in both.

It also carried stock value, because an internal transfer anchors every inward
entry to the rate of the delivery note, but the rejected warehouse got no
accounting entry unless Buying Settings asked for one. The stock value and the
account value then disagreed.

The entry for the in-transit warehouse covers the accepted and the rejected qty
now, and the rejected warehouse is booked whatever that setting says, since the
value came out of the in-transit warehouse either way.

* test(stock): cover rejected qty on an internal transfer receipt

* test(stock): state the rejected valuation setting the transfer test relies on
2026-09-22 20:32:11 +05:30
rohitwaghchaure
23afc786bb feat: use serial no wise valuation switch on item (backport #59082) (#59182)
feat: use serial no wise valuation switch on item (#59082)
2026-09-22 14:00:42 +00:00
Mihir Kandoi
3bf3bc709f fix(stock): carry the batch of a delivery into the in-transit warehouse (#59264) 2026-09-22 18:17:14 +05:30
Pandiyan P
102842253b fix(stock): use net purchase values in landed cost vouchers (#59274)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 18:05:16 +05:30
Sudharsanan Ashok
e814d13126 fix(accounts): keep price list within user permissions (#59231)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-22 18:04:26 +05:30
Sudharsanan Ashok
7f608f31eb fix(stock): keep mapped cost center in stock entry items (#59286) 2026-09-22 18:02:53 +05:30
Sudharsanan Ashok
f990df9deb refactor(selling): remove ensure delivery based on produced serial no (#59217)
* refactor(selling): remove ensure delivery based on produced serial no

the ensure_delivery_based_on_produced_serial_no checkbox on sales order
item only ever validated itself at sales order save: serialized item,
active bom, and the same setting on every row of an item. nothing
downstream read the flag, so a delivery note or stock updating sales
invoice could ship any serial no and submit silently. the field promised
a guarantee it never enforced.

remove the field, its typing entry and validate_serial_no_based_delivery.
stock reservation on the sales order and work order is the supported way
to hold produced stock for an order. the database column is left in place
by migrate, so no data is dropped.

* chore(stock): remove dead reserved serial and batch no validators

validate_reserved_serial_nos and validate_reserved_batch_nos lost their
only callers in a20951e1cd ("fix: reserved serial nos validation"), which
moved serial level reservation checks into serial and batch bundle but
left both functions in stock ledger. drop them along with the three
imports that only they used.
2026-09-22 17:54:54 +05:30
Sudharsanan Ashok
62cb38d36b 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.
2026-09-22 17:53:07 +05:30
Deepesh Garg
eca99d3df4 fix: Skip ERPNext persona for HRMS signups 2026-09-22 17:42:33 +05:30
Mihir Kandoi
8d6ee86ba3 fix: don't bump Customer timestamp when caching primary address (#59285)
Saving a Customer runs create_primary_address, which calls frappe.set_value on the
linked Address to ensure is_primary_address. That is a full document save, so
ERPNextAddress.on_update fires and writes the address display back to
Customer.primary_address with update_modified=True. The Customer row's modified
moves after the document has already been written, so the form keeps the older
timestamp and the next save from the same form fails check_if_latest with
TimestampMismatchError.

primary_address is a denormalized display cache, not a user edit, so writing it
must not move the optimistic lock timestamp.
2026-09-22 12:10:21 +00:00
Khushi Rawat
e3fc1790d4 Merge pull request #59262 from aerele/fix-reverse-je-mandatory-fields
fix(journal-entry): carry reference fields into reverse journal entry
2026-09-22 17:15:30 +05:30
Deepesh Garg
db6dad7ddd Merge branch 'develop' of https://github.com/frappe/erpnext into bank-reco-voucher-registry 2026-09-22 16:49:40 +05:30
rohitwaghchaure
719b53f2ad fix: do not zero out backdated stock at a stock reco adjustment entry (#59269)
* 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:38:51 +05:30
Pandiyan P
79fd06a1ba fix(crm): keep opportunity type default untranslated (#59277) 2026-09-22 11:06:45 +00:00
Khushi Rawat
5ebee655b7 Merge pull request #59180 from khushi8112/pos-price-list-exchange-rate
fix: refresh price list exchange rate when currency changes
2026-09-22 15:29:36 +05:30
Deepesh Garg
96db1071da 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-22 15:29:18 +05:30
Mihir Kandoi
9f2b05c103 fix: skip serial nos with no stock when reconciling specific serial nos (#59266) 2026-09-22 14:31:14 +05:30
ervishnucs
9e01992217 fix(journal-entry): carry reference fields into reverse journal entry 2026-09-22 13:25:11 +05:30
Shllokkk
5ebc52c95c Merge pull request #59240 from Shllokkk/asset-depr-schedule-company-filter
fix: asset field unfilterable on manual Asset Depreciation Schedule
2026-09-22 12:36:50 +05:30
ruthra kumar
86eb9c6204 Merge pull request #59230 from ruthra-kumar/more_changes_to_pcv_using_mapreduce
refactor(pcv): status on list view, progress update on UI
2026-09-22 12:19:05 +05:30
Afsal Syed
893fc75df4 fix(stock): apply transit warehouse filter to target warehouse in items table (#59192) 2026-09-22 06:48:07 +00:00
ruthra kumar
f2279a139f refactor(pcv): status on list view, progress update on UI 2026-09-22 11:50:28 +05:30
Mihir Kandoi
30e0382aa9 feat(selling): group Sales Order Analysis by item (#59236) 2026-09-22 11:09:20 +05:30
Mihir Kandoi
d2b1965cc8 fix(stock): compare transit quantities in stock UOM (#59202) 2026-09-22 11:08:09 +05:30
Shllokkk
a931c7d2f4 fix: remove company link filter on asset field in Asset Depreciation Schedule 2026-09-22 03:36:40 +05:30
Nikhil Kothari
3c251254b8 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
2026-09-21 12:32:05 +00:00
Shllokkk
04c949a662 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
2026-09-21 17:30:25 +05:30
Nishka Gosalia
5d00fec1c2 Merge pull request #59177 from nishkagosalia/stock-unreserve-issue
fix: unreserving stock failing in work order
2026-09-21 14:20:18 +05:30
Raffael Meyer
bb16dca4f7 ci: bump po-review-action to v1.2.0 (#59224) 2026-09-21 08:19:34 +00:00
Shllokkk
79fdc8add3 Merge pull request #59198 from Shllokkk/gp-returned-invoices-company-filter
perf: filter by company in get_returned_invoice_items
2026-09-21 13:43:30 +05:30
Sudharsanan Ashok
6e87694bbe fix(stock): honour pick manually when mapping a pick list (#59220) 2026-09-21 13:32:58 +05:30
rohitwaghchaure
6eda8c8c62 fix: write off stranded stock value only when the warehouse is empty (#59218) 2026-09-21 13:29:57 +05:30
Khushi Rawat
fb69724cda Merge pull request #59128 from aerele/fix/coupon-code-validation
fix: validate coupon pricing rules and validity dates
2026-09-21 12:46:57 +05:30
Mihir Kandoi
dd72c2688b chore(desk): hide CRM and Support on the dock (#59208) 2026-09-21 04:03:46 +00:00
MochaMind
8fd0175b06 chore: update POT file (#59201) 2026-09-20 21:44:23 +02:00
Shllokkk
1e35af3abe perf: filter by company in get_returned_invoice_items 2026-09-20 01:33:39 +05:30
Afsal Syed
db6e089109 fix(stock): validate warehouse account belongs to selected company (#59191) 2026-09-19 13:06:06 +05:30
Khushi Rawat
a2481e9390 Merge pull request #59181 from aerele/fix/accounts-reports-revaluation-filter-labels
fix(accounts): clarify revaluation journal filters
2026-09-18 17:53:02 +05:30
khushi8112
221baabf9e test: cover price list exchange rate currency changes 2026-09-18 17:47:34 +05:30
khushi8112
8b59d66f94 fix: refresh price list exchange rate when currency changes 2026-09-18 17:34:55 +05:30
pandiyan
dc4b390a9b fix(accounts): clarify revaluation journal filters 2026-09-18 17:26:10 +05:30
nishkagosalia
ff70aa6a33 fix: unreserving stock failing in work order 2026-09-18 17:12:01 +05:30
Pandiyan P
4bbd07944d fix(stock): respect manual picking when mapping sales orders (#59170) 2026-09-18 16:31:56 +05:30
Pandiyan P
622b7e0caa fix(stock): forward company to outward batch lookup (#59160) 2026-09-18 14:36:49 +05:30
Khushi Rawat
3d1ec937cf Merge pull request #59142 from aerele/fix/shipping-rule-account-company
fix: validate shipping rule account company
2026-09-18 14:06:59 +05:30
Pandiyan P
6cc500e28d fix(stock): continue putaway allocation after undersized whole-uom rules (#59155) 2026-09-18 07:44:21 +00:00
Khushi Rawat
40ab41278e Merge pull request #59081 from aerele/fix/bank-reconciliation-date-validation
fix(accounts): validate bank reconciliation date ranges
2026-09-18 13:07:24 +05:30
mergify[bot]
4197932f60 perf(stock): chunk the serial and batch entry backfill patch (backport #59076) (#59151)
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

(cherry picked from commit 86fe0c1f4b)

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-09-18 11:47:42 +05:30
rohitwaghchaure
aebb160e76 feat: mark update_serial_batch_entries patch as never skippable (#59147) 2026-09-17 16:52:16 +00:00
Sudharsanan Ashok
5d5eeb5e02 fix(manufacturing): include the mrp bucket that ends on to_date (#59143) 2026-09-17 13:21:10 +00:00
pandiyan
022ee73d78 test: cover shipping rule account company validation 2026-09-17 18:31:38 +05:30
pandiyan
340feb94a1 fix: validate shipping rule account company 2026-09-17 18:31:38 +05:30
Mihir Kandoi
4f312fcba4 docs(postgres): a representative row must not be picked by Min(name) (#59135) 2026-09-17 08:40:46 +00:00
Mihir Kandoi
f3c2a836eb fix(stock): break the disassembly representative tie on a date, not a name (#59131) 2026-09-17 08:39:01 +00:00
Mihir Kandoi
f97660d10e fix(manufacturing): take the job card secondary UOM off the representative line (#59129) 2026-09-17 08:35:36 +00:00
Mihir Kandoi
00b7e6e9eb fix(stock): reserve product bundle components from a Pick List (#59134) 2026-09-17 13:58:02 +05:30
Mihir Kandoi
0f66c41819 fix(buying): take the requested-item UOM pair off one line (#59132) 2026-09-17 08:26:34 +00:00
Mihir Kandoi
2aab7f4f72 fix(accounts): take the POS summary labels off one invoice, not a text sort (#59130) 2026-09-17 08:23:12 +00:00
Mihir Kandoi
228bbe845f fix(controllers): report a Prospect quotation's territory (#59133) 2026-09-17 08:21:59 +00:00
pandiyan
3384c1939b test: cover coupon pricing rule and date validation 2026-09-17 11:54:06 +05:30
pandiyan
14275e4c01 fix: validate coupon pricing rules and validity dates 2026-09-17 11:31:59 +05:30
Pandiyan P
60913b722a fix: retain bom components without warehouse stock (#59116)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-17 11:08:07 +05:30
Mihir Kandoi
0e31182dca perf(stock): skip the negative batch scan when nothing is negative (#59061) 2026-09-17 11:07:27 +05:30
Mihir Kandoi
ded6df3614 fix(selling): reset ordered_qty when a cancelled Sales Order is unlinked from its Purchase Orders (#59120) 2026-09-17 11:05:11 +05:30
Mihir Kandoi
cb409d710a refactor(manufacturing): share the bin subquery in BOM Stock Analysis (#59121) 2026-09-17 10:59:02 +05:30
Abdeali Chharchhodawala
4e3e301c90 fix: formula evaluation and line reference validation for FRT (#59084)
* fix: use one formula environment in validator and engine

* test: cover shared formula environment

* fix: use distinct dummy values when test-evaluating formulas

* fix: reject line references that can't be used in a formula

* fix: drop the undefined-reference check

* fix: normalise line references before validating

* fix: normalise formulas on save instead of during validation

* fix: escape validation messages where they are rendered

* fix: strip the formula in the engine instead of relying on the validator

* fix: ignore division by zero when test-evaluating formulas
2026-09-17 10:47:37 +05:30
Diptanil Saha
863aa45e51 fix: add permission checks and record-level scoping to whitelisted methods (#59100) 2026-09-17 10:30:47 +05:30
Afsal Syed
1d8ce1ee8c fix(stock): allow zero completed quantity and handle process loss in job cards (#59104) 2026-09-16 20:14:32 +05:30
pandiyan
62d0e2c82e test: cover bank reconciliation date validation 2026-09-16 15:57:50 +05:30
pandiyan
970f32a342 fix: validate bank reconciliation date ranges 2026-09-16 15:57:43 +05:30
Shllokkk
4b858ec0af Merge pull request #59078 from Shllokkk/soa-render-letter-head-template
fix: render letter head jinja in statement of accounts pdf
2026-09-16 15:39:55 +05:30
rohitwaghchaure
f6b907ef9d fix: update modified for last purchase rate (#59044) 2026-09-16 15:23:47 +05:30
Pandiyan P
2273448f1b fix: avoid pricing rule help template errors (#59097) 2026-09-16 07:40:22 +00:00
Pandiyan P
b439bdda68 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
2026-09-16 13:00:27 +05:30
Khushi Rawat
4974372073 Merge pull request #59085 from khushi8112/print-format-classic-item-description
fix: show item description in Classic print formats
2026-09-16 12:18:20 +05:30
Pandiyan P
2ad4a8c4a4 fix(manufacturing): apply mrp manufacturing buffer as a duration (#59007)
* fix(manufacturing): apply mrp manufacturing buffer as a duration

* test(manufacturing): cover mrp buffer durations
2026-09-16 06:22:43 +00:00
Pandiyan P
00ee694444 fix(stock): filter item group defaults by company and non-group records (#58923) 2026-09-16 11:47:51 +05:30
Sudharsanan Ashok
6cee9c330c 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.
2026-09-16 11:45:32 +05:30
khushi8112
f73d818ff4 feat: show item description in Classic print formats
Merge the item description as a secondary line under the item name in the
Item column of all Classic transaction print formats.
2026-09-16 11:36:15 +05:30
Raffael Meyer
29335f56b2 feat(manufacturing): ask for a BOM before creating MRP work orders (#58511)
* feat(manufacturing): ask for a BOM before creating MRP work orders

* test(manufacturing): cover MRP work orders created with a supplied BOM
2026-09-16 01:59:21 +02:00
Raffael Meyer
965124ba7c fix(manufacturing): classify MRP rows by purchase item, not missing BOM (#58510) 2026-09-16 01:43:50 +02:00
Khushi Rawat
2fff457e34 Merge pull request #58688 from krishna-254/fix/issue-55219-bank-reconciliation-document-types
fix: handle missing bank reconciliation document types
2026-09-16 00:01:58 +05:30
Khushi Rawat
8e14cd2394 Merge pull request #58941 from Henil666/fix/asset-depreciation-checkbox-order-clean
fix: asset depreciation table stays empty if you check the box before entering the amount
2026-09-15 23:57:28 +05:30
Khushi Rawat
514c3b45d0 chore: remove unnecessary comments 2026-09-15 23:56:20 +05:30
Shllokkk
da377683d6 fix: render letter head jinja in statement of accounts pdf 2026-09-15 21:52:30 +05:30
Mihir Kandoi
9be19e69b5 fix(buying): show linked record count on Supplier Scorecard connections (#59079) 2026-09-15 13:58:42 +00:00
Mihir Kandoi
41ad83dfb1 fix(ui): do not decorate self-referential links with the row's own title (#59023) 2026-09-15 19:03:03 +05:30
Mihir Kandoi
09dea8b1b5 fix: item selection writes to the child table that fired it (#59068) 2026-09-15 12:13:43 +05:30
Mihir Kandoi
f3dcc6180f fix(stock): value stock entry rows as of the posting date (#59042) 2026-09-15 12:11:21 +05:30
MochaMind
198d40446f fix: sync translations from crowdin (develop) (#59051)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-09-14 19:06:24 +02:00
Mihir Kandoi
5a63b36c3b fix(stock): consume batch slots newest first for LIFO items (#59062)
* fix(stock): consume batch slots newest first for LIFO items

The valuation method decided which end of the queue an issue consumed
from, but only for stock carrying no batch or serial number. Batch slots
were always consumed from the head, so a LIFO item reported its oldest
stock as still on hand when it had been issued.

Slots of one batch valued batchwise share a date, so the direction of
the walk cannot change what they report. Slots pooled across batches
carry the date of the batch that filled them, and there the wrong stock
aged.

Pass the valuation method through to the batch walk and read the queue
from the tail for a LIFO item, as the untagged walk already does.

* test(stock): cover LIFO consumption of pooled batch slots

Issue against the newer of two pooled batches on a LIFO item and assert
the September slot is consumed rather than the January one.
2026-09-14 17:51:15 +05:30
Mihir Kandoi
990a43ed16 fix(stock): scope stock ageing batch and serial age to the warehouse (#59058)
* fix(stock): scope stock ageing batch and serial age to the warehouse

The first inward posting date of a batch or serial number was cached
under the identity alone, so the age of a row depended on which stock
ledger entries the filters let the report scan.

A batch received into WH A and transferred to WH B aged from the WH A
receipt in an unfiltered run, but from the transfer date once a
warehouse filter was applied. Same stock, same warehouse, same to date,
two different ages.

Key the cache on the warehouse as well. Repeated receipts of one batch
into one warehouse still age from the first of them, and a transfer now
restarts the clock in the destination warehouse, as it already does for
stock that carries no batch or serial number.

* test(stock): cover warehouse scoped batch age in stock ageing

A batch received into one warehouse and transferred to another aged
from the first receipt in an unfiltered run and from the transfer once
the warehouse filter narrowed the scan. Assert both runs report the
transfer date.

* test(stock): cover warehouse scoped serial age in stock ageing

The cached date is keyed on the warehouse for serial numbers as well as
batches, and only the batch half was covered. Assert a serial
transferred between warehouses ages from the transfer in both a full
and a warehouse filtered scan.

Insert the batch fixture with ignore_if_duplicate instead of checking
for it first.
2026-09-14 17:51:14 +05:30
Mihir Kandoi
9d3675405a perf(stock): look up stock accounts once per company in the warehouse account map (#59056) 2026-09-14 13:15:12 +05:30
Sudharsanan Ashok
fe25746feb fix(subcontracting): validate project across the subcontracting flow (#58965) 2026-09-13 11:58:56 +05:30
Mihir Kandoi
f5f956c4dd test: stop six tests from passing without running (#59025)
* test: stop four tests from passing without running

Three advisory-lock tests return early on MariaDB:

    if frappe.db.db_type != "postgres":
        return

A bare return reports the test as passed, so the MariaDB CI job shows
green for a test it never ran. skipTest reports it as skipped.

test_stock_reco_with_opening_stock_with_diff_inventory returned early
when the custom "Plant" DocType already existed. DocType creation is
DDL and survives the test transaction, so the test ran once on a fresh
site and silently did nothing on every run after that. Create the
DocType only when it is missing and let the test run either way.

Its closing loop also asserted inside an if/elif over the ledger rows,
which verified nothing if the dimension came back unset. Compare the
whole {plant: qty} mapping instead.

* test: give the job card validator tests a real job card

Both tests looked for a submitted Job Card left behind by another test
and returned when they did not find one:

    jc_name = frappe.db.get_value("Job Card", {"docstatus": 1})
    if not jc_name:
        return  # skip if no job cards in test data

Run in isolation they asserted nothing and still reported a pass, and
they were the only coverage for validate_job_card_fg_item and
validate_job_card_item.

Move them to test_job_card.py, where the Work Order and BOM fixtures
that produce Job Cards already live, and build the Job Card in the test.
The finished-good case needs a card that carries one, so it goes through
a track_semi_finished_goods BOM. Both now assert on the message text, and
both fail if the validator body is removed.
2026-09-12 14:23:11 +05:30
Mihir Kandoi
fe9d6e5a57 test: remove seven tests that cannot fail (#59024)
Four tests in test_stock_entry.py each cover only an early-return guard:

    def test_validate_job_card_item_skips_when_no_job_card(self):
        se = frappe.new_doc("Stock Entry")
        se.job_card = None
        se.validate_job_card_item()  # must not raise

That exercises `if not self.job_card: return` and nothing else. The
mismatch tests next to them already cover the behaviour these validators
actually implement.

Three tests in test_payment_request.py assert against their own mock.
_is_v2_gateway delegates to payments.utils.is_v2_gateway; all three mock
that delegate to return False and then assert the result is False, for
inputs (None, "", "NonExistentGateway12345") that take an identical code
path. The mock decides the outcome, so the assertion holds regardless of
what ERPNext does. The three tests covering the real branches --
delegation, a False delegate, and the exception fallback -- are kept.
2026-09-12 14:23:11 +05:30
Mihir Kandoi
bec627c3eb refactor(manufacturing): rename secondary item valuation type to "% of Component Cost" (#59021) 2026-09-12 06:55:42 +05:30
Mihir Kandoi
bee358ea25 fix(support): stop the SLA link filter patches from aborting migrate on PostgreSQL (#59018) 2026-09-12 06:20:50 +05:30
Pandiyan P
000dcfc23d feat(stock): show reserved stock in batch balance history (#59008) 2026-09-11 18:21:16 +05:30
Pandiyan P
825d24f406 fix(stock): calculate batch bundle valuation per unit (#58994) 2026-09-11 12:50:49 +00:00
Pandiyan P
e6f431a8d6 fix: prevent disabled items from being used in BOM (#58997)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-11 12:20:30 +00:00
Mihir Kandoi
5dfd21cce6 fix(selling): list billable sales orders oldest first (#59010) 2026-09-11 12:05:02 +00:00
Krishna Pramod Shirsath
b85da1628d Merge branch 'develop' into fix/issue-55219-bank-reconciliation-document-types 2026-09-11 16:25:39 +05:30
Shllokkk
6f5f2cfce1 Merge pull request #58979 from Shllokkk/fix-bom-cost-allocation-per-type
fix: coerce cost_allocation_per to float in BOM cost allocation
2026-09-11 14:16:57 +05:30
Mihir Kandoi
142976a829 test(accounts): expect a naming rule counter per resolved prefix (#59000) 2026-09-11 08:12:52 +00:00
Henil
5a8126412a 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.
2026-09-11 13:02:04 +05:30
Diptanil Saha
1dd0b7dc9e fix(regional)!: remove irs_1099_print whitelisted method (#58976) 2026-09-11 09:40:22 +05:30
Diptanil Saha
b481083ff0 fix(accounts): add permission checks on invoice_discounting.get_invoices (#58975) 2026-09-11 09:39:38 +05:30
Raffael Meyer
be8208e7cb fix(stock): recalculate billing status of returned delivery notes (#58953) 2026-09-10 19:56:28 +02:00
Mihir Kandoi
4671d1a665 fix(stock): apply company restriction to Item Price (#58948) 2026-09-10 20:51:48 +05:30
Mihir Kandoi
5f216c5d55 fix(selling): exclude fully billed orders from the invoice picker (#58966) 2026-09-10 20:50:11 +05:30
Shllokkk
4d95a240bb chore: resolve merge conflicts 2026-09-10 19:07:25 +05:30
Diptanil Saha
86821ac6ee fix(accounts): add permission checks on execute_repost_payment_ledger (#58980) 2026-09-10 13:33:38 +00:00
Shllokkk
bc2fa03730 fix: coerce cost_allocation_per to float in BOM cost allocation 2026-09-10 18:53:39 +05:30
Mihir Kandoi
e6b8e90ad9 fix(stock): do not rebuild the Warehouse tree on an account map miss (#58956) 2026-09-10 18:15:28 +05:30
Pandiyan P
d82c35aae9 fix: avoid TypeError on new Work Order when company is not set (#58969)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-10 12:33:06 +00:00
Sudharsanan Ashok
b1e99a70ac fix(mrp): align monthly bucket labels with demand dates (#58963) 2026-09-10 12:13:44 +00:00
Mihir Kandoi
d5e63b8a9e fix(subcontracting): cannot select Against Finished Good on customer receipt (#58949) 2026-09-09 18:42:31 +05:30
Pandiyan P
3be0c7801a fix(manufacturing): handle empty raw materials in workstation (#58927) 2026-09-09 18:05:03 +05:30
Pandiyan P
33a066d568 fix(manufacturing): handle empty bom cost allocation (#58939) 2026-09-09 18:04:10 +05:30
ruthra kumar
cf6aeddc29 Merge pull request #58732 from ruthra-kumar/rewriting_process_pcv_on_map_reduce
refactor: rewriting process pcv on mapreduce
2026-09-09 17:28:04 +05:30
ruthra kumar
3f422f8e0d refactor: use qb for estimation and include correction factor 2026-09-09 17:08:40 +05:30
Afsal Syed
f130c64530 fix(stock): set item_name to fetch if empty in packing slip item (#58925) 2026-09-09 11:36:56 +00:00
Henil
5d1ffa7fca 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.
2026-09-09 16:34:36 +05:30
ruthra kumar
681bd2734f refactor: post ledger entries once mapreduce is complete 2026-09-09 16:23:32 +05:30
ruthra kumar
31205c4114 refactor: dashboard for pcv 2026-09-09 16:23:32 +05:30
ruthra kumar
503a80f2c9 refactor: dynamic link to mapreduce and clean on cancel and trash 2026-09-09 16:23:32 +05:30
ruthra kumar
3a8bd852d5 refactor: rebuild pcv on mapreduce (parallelization) 2026-09-09 16:23:31 +05:30
Diptanil Saha
a8ec43bcf8 fix(selling): add email permission check on send_emails (#58935) 2026-09-09 15:37:55 +05:30
Diptanil Saha
e2b2940452 fix(accounts): add permission checks on send_proforma_email (#58933) 2026-09-09 09:53:29 +00:00
Shllokkk
3761eb8cbe fix: % Amount Billed exceeds 100% on fully rejected Purchase Receipts (#58885)
* fix: include rejected qty in Purchase Receipt billing base

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 14:58:18 +05:30
Mihir Kandoi
fd492100b0 fix(stock): ignore disabled price lists on transactions (#58926) 2026-09-09 09:15:44 +00:00
Mihir Kandoi
28bd1332de fix(accounts): stop counting received qty on non-stock invoice returns (#58924)
* fix(accounts): skip received/rejected qty checks on non-stock return

A Purchase Invoice without Update Stock writes no Stock Ledger Entry, so
received_qty and rejected_qty on its rows move no stock and bill no
amount. The server never derives or validates them either:
validate_accepted_rejected_qty only runs when update_stock is set.

validate_quantity still counted both columns against the source invoice.
Whatever value the form last wrote to the read-only received_qty was
tallied as returned, so a partial return that lowered qty locked out the
rest of the invoice with StockOverReturnError.

Restrict the two columns to documents that actually carry an
accepted/rejected split: Purchase Receipt, Subcontracting Receipt, and a
Purchase Invoice with Update Stock. qty stays validated in every case, so
the billed quantity is still capped at what the source invoice billed.

* test(accounts): cover partial returns of a non-stock invoice

Fails before the previous commit with StockOverReturnError on the second
return, because the stale received_qty carried by the first return is
tallied as a full return of the invoice.

* refactor(accounts): pass frm into the Purchase Invoice hide_fields

hide_fields took a doc but reached for cur_frm to get the grid and to
refresh, so it only worked on whichever form happened to be current. Take
frm instead: all three callers already have one.

frm.toggle_display replaces the hide_field / unhide_field globals, which
resolve the docfield through cur_frm the same way. var becomes let/const.

No change in behaviour.

* fix(accounts): hide stock columns without Update Stock

Received Qty, Rejected Qty and the warehouse section were shown on any
return, including one that updates no stock. received_qty is read-only
there and rejected_qty moves neither stock nor billed amount, so the grid
offered values the user could not correct and the form could not keep in
step with qty.

Show the group only when the invoice updates stock, matching an ordinary
Purchase Invoice. Nothing on these rows needs a warehouse either:
validate_warehouse only checks the warehouses that are set.
2026-09-09 08:55:35 +00:00
Pandiyan P
4b23cee2ea fix(stock): remove empty delivery stops after mapping (#58896)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:24:10 +00:00
Khushi Rawat
bdf124ab06 Merge pull request #58909 from khushi8112/fix/posting-date-confirmation
fix: read posting date confirmation setting from boot data
2026-09-09 12:50:41 +05:30
Pandiyan P
b2bdeaa672 fix: validate shelf life for automatically created expiring batches (#58911)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-09 07:07:03 +00:00
khushi8112
bcade8f0ba fix: refresh posting date confirmation setting 2026-09-09 12:36:28 +05:30
Pandiyan P
4c32acf300 fix: reset price list fallback when changing parties (#58893) 2026-09-09 12:26:05 +05:30
khushi8112
db9e93306a fix: read posting date confirmation setting from boot data 2026-09-09 11:52:42 +05:30
Khushi Rawat
e93ca84398 Merge pull request #58227 from aerele/fix/dunning-interest-lost-after-payment
fix: keep a dunning claimable until its interest is paid too
2026-09-09 10:47:07 +05:30
Pandiyan P
afd93cf867 fix(stock): guard inventory dimension lookup without reference document (#58889)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:42:40 +05:30
Vishnu Priya Baskaran
51fb261b6b fix: calculate holiday list totals for half days (#58792)
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:38:23 +05:30
Pandiyan P
f8c2f3440b fix(accounts): reject disabled price lists in buying and selling (#58891) 2026-09-09 06:57:48 +05:30
Diptanil Saha
991cea5ae2 fix(accounts): add permission checks to Chart of Accounts Importer methods (#58892) 2026-09-09 01:14:27 +05:30
Diptanil Saha
11d2847d3e fix(support): bind issue split to its own timeline (#58890) 2026-09-09 00:26:39 +05:30
Krishna Pramod Shirsath
e0d6d797d7 fix(stock): preserve rates for unsaved mapped rows (#58852)
* fix(stock): preserve rates for unsaved mapped rows

* test(stock): cover mapped delivery note item rates
2026-09-08 18:45:02 +05:30
pandiyan
d5a9d158f9 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.
2026-09-08 17:49:36 +05:30
pandiyan
fa89552d10 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.
2026-09-08 17:48:15 +05:30
Krishna Pramod Shirsath
cee9f4949a fix: show cost center for unmatched payments in receivables (#58453) 2026-09-08 16:52:53 +05:30
Vishnu Priya Baskaran
218e7927ff fix: fetch accounts by company (#58523) 2026-09-08 16:47:46 +05:30
Pandiyan P
36a4dfe797 fix(accounts): reject same-account internal transfers (#58529) 2026-09-08 16:29:20 +05:30
Sudharsanan Ashok
e825bb2f74 fix(accounts): round journal entry totals to field precision (#58629) 2026-09-08 16:26:04 +05:30
Pandiyan P
f864333afa fix(stock): recalculate delivery note billing after return (#58869) 2026-09-08 10:51:55 +00:00
Krishna Pramod Shirsath
c412310eb5 fix: use account currency in Bank Reconciliation Statement print (#58692) 2026-09-08 16:14:09 +05:30
Sudharsanan Ashok
1728d1b0f5 fix(stock): distribute additional costs when incoming items have no value (#58842)
* fix(stock): distribute additional costs when incoming items have no value

when every incoming row has zero basic amount, for example a raw material
purchased at zero rate, distribute_additional_costs returned early and left
additional_cost at 0 on the finished item. the charges were never capitalised
into valuation and the balancing debit stayed in stock adjustment instead of
reaching stock in hand.

the gl composer had its own quantity fallback for the same case, but it divided
by the qty of every row rather than the incoming ones, so a manufacture entry
booked only a fraction of the cost to the expense account, and it apportioned
by qty while valuation apportions by stock qty, which split rows of differing
conversion factors two different ways.

both sides now take the rows, the basis and its total from a single
get_additional_cost_allocation, falling back to transfer_qty so valuation and gl
cannot disagree. the basis is unchanged whenever the incoming rows carry value.

* test(stock): cover additional cost distribution for zero valued items

adds qty based distribution cases for manufacture and material receipt, a
manufacture entry asserting the whole cost reaches its expense account, and a
conversion factor case asserting the gl split matches the valuation split.

updates test_total_basic_amount_zero, which asserted the cost landing in stock
adjustment rather than being capitalised.
2026-09-08 16:13:12 +05:30
Lakshit Jain
79fc039092 Merge pull request #58682 from ljain112/fix-tds-subcontracting
fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping
2026-09-08 14:53:58 +05:30
Vishnu Priya Baskaran
467f54162f fix(gross-profit): include item_name in export (#58631) 2026-09-08 14:43:42 +05:30
Diptanil Saha
f60c349794 fix(buying): restrict linked material requests to permitted documents (#58855) 2026-09-08 14:23:03 +05:30
Pandiyan P
cadc0ca86d fix: persist rendered terms on transaction save (#58858) 2026-09-08 13:28:30 +05:30
Sudharsanan Ashok
2f572b1624 fix(stock): distribute additional costs when incoming items have no value (#58841) 2026-09-08 13:27:41 +05:30
ljain112
13031d6d5d fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping 2026-09-02 13:25:23 +05:30
Krishna Shirsath
d39e677d80 fix: handle missing bank reconciliation document types 2026-09-02 13:21:24 +05:30
768 changed files with 20232 additions and 5284 deletions

View File

@@ -180,6 +180,13 @@ audit of these fixes found four recurring mistakes:
the arbitrary-pick preservation the wrap is usually justified as. Confirmed on CI; see #56241.
Note a local macOS PostgreSQL gives a **false all-clear** — its collation happens to agree with
MariaDB on case. Fix: take a representative row rather than sorting text.
**Picking that row is the hard part.** `Min(name)` is still a text sort: `autoname="hash"` is
not reliably lower case, because `_get_timestamp_prefix()` prepends `get_trace_id()[-1:]`
un-lowered and a client-supplied `X-Frappe-Request-Id` can put an upper case `A-F` there. A
non-text key (`Min(idx)`) works only where it is **unique within the group** and the join-back
carries the **full group key** — a date is usually neither, and joining on a duplicated value
turns one group into several rows (§3). Otherwise select the row in Python, sorting with
`key=str.casefold` so the order matches MariaDB's collation without depending on the database's.
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
average for a rate. A blind `Max` can understate urgency or overstate a figure.

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

@@ -22,6 +22,6 @@ jobs:
pull-requests: write
steps:
- uses: alyf-de/po-review-action@5928f84d6bc9094f9ad6e2c5780f01c0044b800e # v1.1.1
- uses: alyf-de/po-review-action@57fff275f4a0518a2ca55869ec6776fa3813b3d5 # v1.2.0
with:
hidden-po-files: eo.po

View File

@@ -47,14 +47,29 @@ class ERPNextAddress(Address):
super().on_update()
address_display = get_address_display(self.as_dict())
filters = {"customer_primary_address": self.name}
customers = frappe.db.get_all("Customer", filters=filters, as_list=True)
for customer_name in customers:
frappe.db.set_value("Customer", customer_name[0], "primary_address", address_display)
customers = frappe.db.get_all(
"Customer", filters={"customer_primary_address": self.name}, pluck="name"
)
for customer in customers:
frappe.db.set_value(
"Customer", customer, "primary_address", address_display, update_modified=False
)
@frappe.whitelist()
def get_shipping_address(company: str, address: str | None = None):
# `company` is caller supplied and this returns that company's own registered address with every
# field. `select` rather than `read` on Company: Delivery, Maintenance, Purchase Manager and
# Stock Manager all fill in transactions that ask for this while holding no Company `read` row.
frappe.has_permission("Company", ptype="select", throw=True)
# and scope it to the caller's own Company restrictions, which costs nobody who has none
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "Address")
if allowed_companies and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
filters = [
["Dynamic Link", "link_doctype", "=", "Company"],
["Dynamic Link", "link_name", "=", company],

View File

@@ -24,7 +24,7 @@ def get(
heatmap_year: str | None = 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
@@ -46,6 +46,9 @@ def get(
if not account:
frappe.throw(_("Account filter not set!"))
# 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",
@@ -200,7 +201,7 @@
"options": "Account Category"
}
],
"icon": "fa fa-money",
"icon": "vault",
"idx": 1,
"is_tree": 1,
"links": [],

View File

@@ -503,24 +503,21 @@ class Account(NestedSet):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
Account = frappe.qb.DocType("Account")
search_field_obj = getattr(Account, searchfield)
query = (
frappe.qb.from_(Account)
.select(Account.name)
.where(Account.is_group == 1)
.where(Account.docstatus != 2)
.where(Account.company == filters["company"])
.where(search_field_obj.like(f"%{txt}%"))
.order_by(Account.name)
.limit(page_len)
.offset(start)
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,
)
return query.run(as_list=1)
def get_account_currency(account):
"""Helper function to get account currency"""

View File

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

View File

@@ -148,7 +148,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

@@ -223,8 +223,11 @@ def delete_accounting_dimension(doc):
frappe.clear_cache(doctype=doctype)
@frappe.whitelist()
@frappe.whitelist(methods=["POST"])
def disable_dimension(doc: str):
# 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

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

@@ -12,7 +12,7 @@ frappe.ui.form.on("Accounting Period", {
doc: frm.doc,
callback: function (r) {
if (r.message) {
cur_frm.clear_table("closed_documents");
frm.clear_table("closed_documents");
r.message.forEach(function (element) {
var c = frm.add_child("closed_documents");
c.document_type = element.document_type;

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": [
@@ -801,7 +802,7 @@
}
],
"grid_page_length": 50,
"icon": "icon-cog",
"icon": "settings",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,

View File

@@ -222,6 +222,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

@@ -1,9 +1,15 @@
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",
"is_submittable": 1,
"field_order": [
"company",

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,6 +101,7 @@
"read_only": 1
}
],
"icon": "landmark",
"links": [],
"modified": "2026-08-21 23:11:39.423431",
"modified_by": "Administrator",

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

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

@@ -1,14 +1,14 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
cur_frm.add_fetch("bank_account", "account", "account");
cur_frm.add_fetch("bank_account", "bank_account_no", "bank_account_no");
cur_frm.add_fetch("bank_account", "iban", "iban");
cur_frm.add_fetch("bank_account", "branch_code", "branch_code");
cur_frm.add_fetch("bank", "swift_number", "swift_number");
frappe.ui.form.on("Bank Guarantee", {
setup: function (frm) {
frm.add_fetch("bank_account", "account", "account");
frm.add_fetch("bank_account", "bank_account_no", "bank_account_no");
frm.add_fetch("bank_account", "iban", "iban");
frm.add_fetch("bank_account", "branch_code", "branch_code");
frm.add_fetch("bank", "swift_number", "swift_number");
frm.set_query("reference_doctype", function () {
return {
filters: {
@@ -63,11 +63,15 @@ frappe.ui.form.on("Bank Guarantee", {
},
start_date: function (frm) {
var end_date = frappe.datetime.add_days(cur_frm.doc.start_date, cur_frm.doc.validity - 1);
cur_frm.set_value("end_date", end_date);
frm.events.set_end_date(frm);
},
validity: function (frm) {
var end_date = frappe.datetime.add_days(cur_frm.doc.start_date, cur_frm.doc.validity - 1);
cur_frm.set_value("end_date", end_date);
frm.events.set_end_date(frm);
},
set_end_date: function (frm) {
let end_date = frappe.datetime.add_days(frm.doc.start_date, frm.doc.validity - 1);
frm.set_value("end_date", end_date);
},
});

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":
@@ -70,4 +73,14 @@ def get_voucher_details(bank_guarantee_type: str, reference_name: str):
doctype = "Purchase Order"
fields_to_fetch.append("supplier")
# and scope the referenced order to the caller's own Company restrictions, which costs nobody
# who has none
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "Bank Guarantee")
if allowed_companies:
company = frappe.db.get_value(doctype, reference_name, "company")
if company and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
return frappe.db.get_value(doctype, reference_name, fields_to_fetch, as_dict=True)

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

@@ -9,7 +9,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Max, 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
@@ -54,6 +54,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])
@@ -790,6 +792,7 @@ def create_bulk_payment_entry_and_reconcile(
"deposit",
"withdrawal",
"bank_account",
"company",
"currency",
"unallocated_amount",
"date",
@@ -824,11 +827,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 "")[
@@ -837,6 +836,8 @@ def create_bulk_payment_entry_and_reconcile(
}
)
set_multi_currency_amounts(payment_entry_doc)
payment_entry_doc.insert()
payment_entry_doc.submit()
@@ -875,6 +876,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(
@@ -897,6 +899,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):
"""
@@ -962,9 +991,10 @@ def auto_reconcile_vouchers(
from_date: str | date | None = None,
to_date: str | date | None = None,
filter_by_reference_date: bool | None = None,
from_reference_date: bool | None = None,
to_reference_date: str | None = None,
from_reference_date: str | date | None = None,
to_reference_date: str | date | None = None,
):
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
bank_transactions = get_bank_transactions(bank_account)
if len(bank_transactions) > 10:
@@ -1079,10 +1109,11 @@ def get_linked_payments(
from_date: str | date | None = None,
to_date: str | date | None = None,
filter_by_reference_date: bool | None = None,
from_reference_date: bool | None = None,
to_reference_date: str | None = None,
from_reference_date: str | date | None = None,
to_reference_date: str | date | None = None,
):
# get all matching payments for a bank transaction
validate_date_range(from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
bank_account = frappe.db.get_values(
"Bank Account", transaction.bank_account, ["account", "company"], as_dict=True
@@ -1102,6 +1133,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 = []
@@ -1138,6 +1186,7 @@ def check_matching(
from_reference_date=None,
to_reference_date=None,
):
document_types = document_types or []
exact_match = True if "exact_match" in document_types else False
common_filters = frappe._dict(

View File

@@ -2,12 +2,16 @@
# 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,
@@ -16,6 +20,8 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay
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):
@@ -131,6 +137,37 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_get_linked_payments_without_document_types(self):
bank_transaction = self.make_bank_transaction(date=today())
self.assertEqual(get_linked_payments(bank_transaction.name), [])
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(
@@ -199,3 +236,117 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
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

@@ -225,6 +225,7 @@
}
],
"hide_toolbar": 1,
"icon": "file-down",
"links": [],
"modified": "2026-06-19 14:18:00.000000",
"modified_by": "Administrator",

View File

@@ -437,6 +437,11 @@ def get_import_logs(docname: str):
@frappe.whitelist()
def upload_bank_statement(**args):
# The only caller is the Bank Reconciliation Tool's "Upload Bank Statement" button, whose
# callback routes straight into a new Bank Statement Import form — so `create` is exactly the
# right to require, and both doctypes are System Manager only, which makes it loser-free.
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

@@ -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,11 @@ 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, so that is the
# right to require. Both functions in this file are reached only over HTTP — nothing in the tree
# calls either — so there is no 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()
@@ -36,6 +41,12 @@ def upload_bank_statement():
@frappe.whitelist(methods=["POST"])
def create_bank_entries(columns: str, data: str | list, bank_account: str):
# insert()/submit() below already enforce this per document, but only after the per-row loop has
# read the Bank Account and its Bank mapping and written an Error Log for every rejected row —
# so check once up front rather than failing row by row.
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

@@ -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: str):
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: str, company: str):
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(".")
@@ -179,6 +185,8 @@ def get_coa(
):
"""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
@@ -326,6 +334,8 @@ def build_response_as_excel(writer):
@frappe.whitelist()
def download_template(file_type: str, template_type: str, company: str):
frappe.has_permission("Chart of Accounts Importer", throw=True)
writer = get_template(template_type, company)
if file_type == "CSV":
@@ -378,7 +388,6 @@ def get_sample_template(writer, company):
return writer
@frappe.whitelist()
def validate_accounts(file_doc: Document, extension: str):
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,7 +123,7 @@
"label": "Disabled"
}
],
"icon": "fa fa-money",
"icon": "chart-pie",
"idx": 1,
"is_tree": 1,
"links": [],

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,6 +125,7 @@
"label": "From External Ecomm Platform"
}
],
"icon": "ticket-percent",
"links": [],
"modified": "2026-08-21 23:11:41.010871",
"modified_by": "Administrator",

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

@@ -112,6 +112,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

@@ -5,6 +5,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",
@@ -398,6 +399,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

@@ -55,6 +55,125 @@ 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)
@ERPNextTestSuite.change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
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),
currency="USD",
conversion_rate=50,
rate=100,
debit_to="Debtors - _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

@@ -101,6 +101,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,7 +19,7 @@
"unique": 1
}
],
"icon": "fa fa-book",
"icon": "book",
"links": [],
"modified": "2026-08-21 23:11:42.386104",
"modified_by": "Administrator",

View File

@@ -3,7 +3,6 @@
import ast
import json
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from functools import cache, reduce
@@ -29,6 +28,7 @@ from erpnext.accounts.doctype.financial_report_template.financial_report_templat
FinancialReportTemplate,
)
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
FORMULA_FUNCTIONS,
AccountFilterValidator,
CalculationFormulaValidator,
DependencyValidator,
@@ -1328,26 +1328,14 @@ class FormulaCalculator:
self.precision = get_currency_precision()
self.validator = CalculationFormulaValidator(set(row_data.keys()))
self.math_functions = {
"abs": abs,
"round": round,
"min": min,
"max": max,
"sum": sum,
"sqrt": math.sqrt,
"pow": math.pow,
"ceil": math.ceil,
"floor": math.floor,
}
def evaluate_formula(self, report_row: dict[str, Any]) -> list[float]:
validation_result = self.validator.validate(report_row)
formula = report_row.calculation_formula
formula = (report_row.calculation_formula or "").strip()
negation_factor = -1 if report_row.reverse_sign else 1
if validation_result.issues:
# TODO: Throw?
messages = "<br><br>".join(issue.message for issue in validation_result.issues)
messages = "<br><br>".join(str(issue) for issue in validation_result.issues)
frappe.log_error(f"Formula validation errors found:\n{messages}")
return [0.0] * len(self.period_list)
@@ -1362,7 +1350,7 @@ class FormulaCalculator:
# TODO: consistent error handling
try:
context = self._build_context(period_index)
result = frappe.safe_eval(formula, context)
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
return flt(result * negation_factor, self.precision)
except ZeroDivisionError:
@@ -1383,7 +1371,7 @@ class FormulaCalculator:
context[code] = 0.0
# math functions
context.update(self.math_functions)
context.update(FORMULA_FUNCTIONS)
return context

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

@@ -34,6 +34,13 @@ class FinancialReportTemplate(Document):
def before_validate(self):
self.clear_hidden_fields()
for row in self.rows:
if row.reference_code:
row.reference_code = row.reference_code.strip()
if row.calculation_formula:
row.calculation_formula = row.calculation_formula.strip()
def clear_hidden_fields(self):
style_data_sources = {"Blank Line", "Column Break", "Section Break"}

View File

@@ -2,6 +2,8 @@
# For license information, please see license.txt
import json
import keyword
import math
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@@ -10,6 +12,19 @@ from typing import Any
import frappe
from frappe import _, is_whitelisted
from frappe.database.operator_map import OPERATOR_MAP
from frappe.utils import escape_html
FORMULA_FUNCTIONS = {
"abs": abs,
"round": round,
"min": min,
"max": max,
"sum": sum,
"sqrt": math.sqrt,
"pow": math.pow,
"ceil": math.ceil,
"floor": math.floor,
}
def get_valid_api_method(api_path: str):
@@ -89,8 +104,9 @@ class ValidationResult:
self.warnings.append(issue)
def notify_user(self) -> None:
warnings = "<br><br>".join(str(w) for w in self.warnings if w)
errors = "<br><br>".join(str(e) for e in self.issues if e)
# messages quote user input back, and both are rendered as HTML
warnings = "<br><br>".join(escape_html(str(w)) for w in self.warnings if w)
errors = "<br><br>".join(escape_html(str(e)) for e in self.issues if e)
if warnings:
frappe.msgprint(warnings, title=_("Warnings"), indicator="orange")
@@ -147,18 +163,27 @@ class TemplateStructureValidator(Validator):
if not row.reference_code:
continue
ref_code = row.reference_code.strip()
ref_code = row.reference_code
# Check format
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]*$", ref_code):
# a line reference is used as a name in formulas, so it must be a usable one
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", ref_code):
result.add_error(
ValidationIssue(
message=_(
"Invalid line reference format: '{0}'. Must start with letter and contain only letters, numbers, underscores, and hyphens"
"Invalid line reference format: '{0}'. Must start with a letter and contain only letters, numbers and underscores"
).format(ref_code),
row_idx=row.idx,
)
)
elif keyword.iskeyword(ref_code) or ref_code in FORMULA_FUNCTIONS:
result.add_error(
ValidationIssue(
message=_("'{0}' is a reserved name and cannot be used as a line reference").format(
ref_code
),
row_idx=row.idx,
)
)
# Check uniqueness
if ref_code in used_codes:
@@ -208,12 +233,7 @@ class DependencyValidator(Validator):
self.dependencies = self._build_dependency_graph()
def validate(self, context=None) -> ValidationResult:
result = ValidationResult()
result.merge(self._validate_circular_dependencies())
result.merge(self._validate_missing_dependencies())
return result
return self._validate_circular_dependencies()
def _build_dependency_graph(self) -> dict[str, list[str]]:
graph = {}
@@ -280,31 +300,6 @@ class DependencyValidator(Validator):
return result
def _validate_missing_dependencies(self) -> ValidationResult:
available = {row.reference_code for row in self.template.rows if row.reference_code}
result = ValidationResult()
for ref_code, deps in self.dependencies.items():
undefined = [d for d in deps if d not in available]
if undefined:
row_idx = self._get_row_idx(ref_code)
result.add_error(
ValidationIssue(
message=_("Line references undefined in {0}: {1}").format(
get_formula_field_label("Calculated Amount"), ", ".join(undefined)
),
row_idx=row_idx,
)
)
return result
def _get_row_idx(self, reference_code: str) -> int | None:
for row in self.template.rows:
if row.reference_code == reference_code:
return row.idx
return None
class CalculationFormulaValidator(Validator):
"""Validates calculation formulas used in Calculated Amount rows"""
@@ -320,7 +315,6 @@ class CalculationFormulaValidator(Validator):
return result
formula = self._preprocess_formula(row.calculation_formula)
row.calculation_formula = formula
# Check parentheses
if not self._are_parentheses_balanced(formula):
@@ -368,25 +362,15 @@ class CalculationFormulaValidator(Validator):
def _test_formula_evaluation(self, formula: str, available_codes: list[str]) -> str | None:
try:
context = {code: 1.0 for code in available_codes}
context.update(
{
"abs": abs,
"round": round,
"min": min,
"max": max,
"sum": sum,
"sqrt": lambda x: x**0.5,
"pow": pow,
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
"floor": int,
}
)
context.update(FORMULA_FUNCTIONS)
result = frappe.safe_eval(formula, eval_globals=None, eval_locals=context)
if not isinstance(result, (int, float)): # noqa: UP038
if not isinstance(result, (int | float)):
return _("Formula must return a numeric value, got {0}").format(type(result).__name__)
return None
except ZeroDivisionError:
return None
except Exception as e:
return str(e)
@@ -462,13 +446,14 @@ class AccountFilterValidator(Validator):
return _("Field and operator must be strings")
if field not in account_fields:
# 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))
return _("Field '{0}' is not a valid Account field").format(field)
if operator.casefold() not in OPERATOR_MAP:
normalized_operator = operator.casefold()
if normalized_operator not in OPERATOR_MAP:
return _("Invalid operator '{0}'").format(operator)
if operator in ["in", "not in"] and not isinstance(value, list):
if normalized_operator in ["in", "not in"] and not isinstance(value, list):
return _("Operator '{0}' requires a list value").format(operator)
# logical condition: {"and": [condition1, condition2]}

View File

@@ -5,8 +5,11 @@ import frappe
from frappe.tests.utils import whitelist_for_tests
from erpnext.accounts.doctype.financial_report_template.financial_report_validation import (
FORMULA_FUNCTIONS,
AccountFilterValidator,
CalculationFormulaValidator,
FormulaValidator,
TemplateStructureValidator,
get_valid_api_method,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -189,8 +192,13 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
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("[Account Filter]", str(result.issues[0]))
# escaping happens where the message is rendered, not where it is built
frappe.clear_messages()
with self.assertRaises(frappe.ValidationError):
result.notify_user()
message = frappe.get_message_log()[-1]["message"]
self.assertIn("&lt;script&gt;", message)
self.assertNotIn("<script>", message)
@@ -248,3 +256,149 @@ class TestAccountFilter(FinancialReportTemplateTestCase):
pluck="name",
)
self.assertEqual(sorted(get_filtered_accounts(company, "[]")), sorted(expected))
class TestFormulaEnvironment(FinancialReportTemplateTestCase):
"""Validator and engine must evaluate a formula in the same environment."""
@staticmethod
def _calc(row_data):
from erpnext.accounts.doctype.financial_report_template.financial_report_engine import (
FormulaCalculator,
)
return FormulaCalculator(row_data, [{"key": "p1"}])
@staticmethod
def _row(formula):
return frappe._dict(
calculation_formula=formula,
idx=1,
reverse_sign=0,
data_source="Calculated Amount",
reference_code="X",
)
def test_engine_keeps_reference_codes_named_like_builtins(self):
# "int" and "long" are whitelisted safe_eval globals; the row values must win
calc = self._calc({"int": [500.0], "long": [2000.0]})
self.assertEqual(calc.evaluate_formula(self._row("int + long"))[0], 2500.0)
def test_validator_keeps_reference_codes_named_like_builtins(self):
validator = CalculationFormulaValidator({"int", "long"})
self.assertTrue(validator.validate(self._row("int + long")).is_valid)
def test_engine_uses_the_shared_function_list(self):
context = self._calc({"A": [1.0]})._build_context(0)
for name, function in FORMULA_FUNCTIONS.items():
self.assertIs(context[name], function)
def test_rounding_matches_math_module(self):
calc = self._calc({"A": [1.0]})
self.assertEqual(calc.evaluate_formula(self._row("floor(-2.5)"))[0], -3.0)
self.assertEqual(calc.evaluate_formula(self._row("ceil(-2.5)"))[0], -2.0)
class TestCalculationFormula(FinancialReportTemplateTestCase):
"""Formulas are test-evaluated with dummy values before a template can be saved."""
@staticmethod
def _validate(formula, codes=("A", "B", "C")):
row = frappe._dict(
calculation_formula=formula, idx=1, data_source="Calculated Amount", reference_code="X"
)
return CalculationFormulaValidator(set(codes)).validate(row)
def test_division_by_zero_is_not_a_validation_error(self):
# the dummy values are all 1.0, so a denominator can only be zero by accident;
# the engine tolerates real division by zero at run time
self.assertTrue(self._validate("A / (B - C)").is_valid)
self.assertTrue(self._validate("(A - B) / (A - C)").is_valid)
self.assertTrue(self._validate("ROM / (CAS + FDE - ROM)", ("ROM", "CAS", "FDE")).is_valid)
self.assertTrue(self._validate("A / 0").is_valid)
def test_broken_formulas_are_rejected(self):
self.assertFalse(self._validate("A +").is_valid)
self.assertFalse(self._validate("NOPE * 2").is_valid)
self.assertFalse(self._validate("'text'").is_valid)
class TestFilterOperatorCase(FinancialReportTemplateTestCase):
"""Operators are matched case-insensitively, so their value checks must be too."""
@staticmethod
def _row(formula):
return frappe._dict(calculation_formula=formula, idx=1)
def test_uppercase_in_requires_a_list_value(self):
validator = AccountFilterValidator()
self.assertFalse(validator.validate_filter(self._row('["root_type", "IN", "Income"]')).is_valid)
self.assertFalse(validator.validate_filter(self._row('["root_type", "NOT IN", "Income"]')).is_valid)
def test_uppercase_in_accepts_a_list_value(self):
validator = AccountFilterValidator()
self.assertTrue(validator.validate_filter(self._row('["root_type", "IN", ["Income"]]')).is_valid)
class TestLineReferenceNames(FinancialReportTemplateTestCase):
"""A line reference becomes a name in formulas, so it must be usable as one."""
@staticmethod
def _validate(code):
template = frappe._dict(rows=[frappe._dict(reference_code=code, idx=1, data_source="Blank Line")])
return TemplateStructureValidator()._validate_reference_codes(template)
def test_plain_codes_are_accepted(self):
for code in ("REV", "CA100", "cash_flow_2"):
self.assertTrue(self._validate(code).is_valid, code)
def test_hyphen_is_rejected(self):
# "-" reads as subtraction in a formula and is not a valid Python name
self.assertFalse(self._validate("REV-COGS").is_valid)
def test_python_keyword_is_rejected(self):
for code in ("if", "None", "class"):
self.assertFalse(self._validate(code).is_valid, code)
def test_formula_function_name_is_rejected(self):
# these would be overwritten by the function of the same name
for code in ("sum", "round", "abs"):
self.assertFalse(self._validate(code).is_valid, code)
def test_surrounding_spaces_are_normalised_before_validation(self):
template = frappe.new_doc("Financial Report Template")
template.template_name = "Spaces"
template.append("rows", {"reference_code": " REV ", "data_source": "Blank Line"})
template.append(
"rows",
{
"reference_code": "X",
"data_source": "Calculated Amount",
"calculation_formula": " REV * 2 ",
},
)
template.before_validate()
self.assertEqual(template.rows[0].reference_code, "REV")
self.assertEqual(template.rows[1].calculation_formula, "REV * 2")
def test_validation_does_not_modify_the_row(self):
row = frappe._dict(
calculation_formula=" REV * 2 ",
idx=1,
data_source="Calculated Amount",
reference_code="X",
)
CalculationFormulaValidator({"REV", "X"}).validate(row)
self.assertEqual(row.calculation_formula, " REV * 2 ")
def test_invalid_reference_code_is_escaped(self):
# this message fires when the code fails the format check, so it can hold anything
template = frappe._dict(rows=[frappe._dict(reference_code="<img src=x onerror=alert(1)>", idx=1)])
result = TemplateStructureValidator()._validate_reference_codes(template)
frappe.clear_messages()
with self.assertRaises(frappe.ValidationError):
result.notify_user()
message = frappe.get_message_log()[-1]["message"]
self.assertIn("&lt;img", message)
self.assertNotIn("<img", message)

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,7 +80,7 @@
"set_only_once": 1
}
],
"icon": "fa fa-calendar",
"icon": "calendar",
"idx": 1,
"links": [],
"modified": "2026-08-21 23:11:42.509102",

View File

@@ -377,7 +377,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

@@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController):
@frappe.whitelist()
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)
si = frappe.qb.DocType("Sales Invoice")
di = frappe.qb.DocType("Discounted Invoice")

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,6 +57,7 @@
"fieldtype": "Section Break"
}
],
"icon": "circle-percent",
"links": [],
"modified": "2026-08-21 23:11:43.571355",
"modified_by": "Administrator",

View File

@@ -32,7 +32,7 @@ frappe.ui.form.on("Journal Entry", {
erpnext.journal_entry.lock_reversal_entry(frm);
}
erpnext.toggle_naming_series();
erpnext.toggle_naming_series(frm);
erpnext.journal_entry.add_custom_buttons(frm);
erpnext.journal_entry.toggle_fields_based_on_currency(frm);
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
@@ -624,8 +624,8 @@ Object.assign(erpnext.journal_entry, {
total_credit += flt(row.credit, precision("credit", row));
});
frm.doc.total_debit = total_debit;
frm.doc.total_credit = total_credit;
frm.doc.total_debit = flt(total_debit, precision("total_debit"));
frm.doc.total_credit = flt(total_credit, precision("total_credit"));
frm.doc.difference = flt(total_debit - total_credit, precision("difference"));
["total_debit", "total_credit", "difference"].forEach((field) => frm.refresh_field(field));
},

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

@@ -674,12 +674,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 = []
@@ -1021,6 +1023,11 @@ def get_default_bank_cash_account(
) -> dict:
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
# the company is the scope being authorised, and doc= brings User Permissions to bear. `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
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")
@@ -1049,6 +1056,11 @@ def get_default_bank_cash_account(
account = account_list[0].name
if account:
# `account` may be named by the caller outright, so authorise the account actually being
# described. get_balance_on() checks this too, but only on the branch that reads a balance,
# and `fetch_balance` is a caller-supplied argument.
frappe.has_permission("Account", doc=account, throw=True)
account_details = frappe.get_cached_value(
"Account", account, ["account_currency", "account_type"], as_dict=1
)
@@ -1078,30 +1090,40 @@ def get_against_jv(
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 below and widen the search
# past what the caller named.
for value in (account, party):
if value and not isinstance(value, str):
frappe.throw(_("Invalid filter"), frappe.PermissionError)
# get_list applies the permission query conditions; the child-table filter resolves the check to `read`
je_filters = [
["docstatus", "=", 1],
[searchfield, "like", f"%{txt}%"],
["Journal Entry Account", "account", "=", account],
["Journal Entry Account", "reference_type", "is", "not set"],
]
je_filters.append(
["Journal Entry Account", "party", "=", party]
if party
else ["Journal Entry Account", "party", "is", "not set"]
)
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()

View File

@@ -253,6 +253,10 @@ def make_reverse_journal_entry(source_name: str, target_doc: str | dict | Docume
def post_process(source, target) -> None:
target.reversal_of = source.name
target.naming_series = source.naming_series
if source.voucher_type == "Bank Entry":
target.cheque_no = source.cheque_no
target.cheque_date = source.cheque_date
doclist = get_mapped_doc(
"Journal Entry",

View File

@@ -461,6 +461,59 @@ class TestJournalEntry(ERPNextTestSuite):
self.check_gl_entries()
def make_jv_with_fractional_totals(self):
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
jv = frappe.new_doc("Journal Entry")
jv.posting_date = nowdate()
jv.company = "_Test Company"
jv.voucher_type = "Journal Entry"
jv.remark = "test"
for amount in (0.10, 0.20):
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": amount,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 0.30,
},
)
jv.insert()
return jv
def test_totals_are_rounded_to_precision(self):
jv = self.make_jv_with_fractional_totals()
jv.submit()
stored = frappe.db.get_value(
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
)
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
self.assertEqual(jv.total_debit, stored.total_debit)
self.assertEqual(jv.total_credit, stored.total_credit)
self.assertEqual(jv.difference, stored.difference)
def test_update_after_submit_with_fractional_totals(self):
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
jv = self.make_jv_with_fractional_totals()
jv.submit()
jv.pay_to_recd_from = "_Test Supplier"
jv.save()
self.assertEqual(jv.docstatus, 1)
self.assertEqual(
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
)
def test_jv_account_and_party_balance_with_cost_centre(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.utils import get_balance_on

View File

@@ -4,6 +4,7 @@
"creation": "2020-04-09 01:32:51.332301",
"doctype": "DocType",
"document_type": "Document",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/journal-entry-template",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -85,6 +86,7 @@
"label": "Multi Currency"
}
],
"icon": "notebook-text",
"links": [],
"modified": "2024-03-27 13:09:58.814734",
"modified_by": "Administrator",

View File

@@ -40,6 +40,7 @@
"label": "General and Payment Ledger mismatch"
}
],
"icon": "heart-pulse",
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],

View File

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

View File

@@ -92,6 +92,7 @@
}
],
"hide_toolbar": 1,
"icon": "merge",
"links": [],
"modified": "2024-03-27 13:10:02.198009",
"modified_by": "Administrator",

View File

@@ -97,6 +97,7 @@
"label": "Discretionary Reason"
}
],
"icon": "star",
"in_create": 1,
"links": [],
"modified": "2024-07-01 08:51:13.927009",

View File

@@ -3,6 +3,7 @@
"autoname": "field:loyalty_program_name",
"creation": "2018-01-23 06:23:05.731431",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/loyalty-program",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -153,6 +154,7 @@
"options": "Project"
}
],
"icon": "gift",
"links": [],
"modified": "2026-08-21 23:11:44.144864",
"modified_by": "Administrator",

View File

@@ -129,6 +129,11 @@ def get_loyalty_program_details(
silent: bool = False,
include_expired_entry: bool = False,
):
# Same guard as get_loyalty_program_details_with_points above: the customer is what the caller
# is entitled to, not the programme. A check on Loyalty Program itself would be read-only to
# System Manager and would deny every role that actually fills in the two calling forms.
frappe.has_permission("Customer", doc=customer, throw=True)
lp_details = frappe._dict()
if not loyalty_program:
@@ -150,6 +155,13 @@ def get_loyalty_program_details(
@frappe.whitelist()
def get_redeemption_factor(loyalty_program: str | None = None, customer: str | None = None):
# both call sites send only `loyalty_program`, so the calling form is the boundary; the customer branch stays guarded
if not (frappe.has_permission("Sales Invoice") or frappe.has_permission("POS Invoice")):
frappe.throw(_("Not permitted"), frappe.PermissionError)
if customer:
frappe.has_permission("Customer", doc=customer, throw=True)
customer_loyalty_program = None
if not loyalty_program:
customer_loyalty_program = frappe.db.get_value("Customer", customer, "loyalty_program")

View File

@@ -6,6 +6,7 @@
"creation": "2012-12-04 17:49:20",
"doctype": "DocType",
"document_type": "Setup",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/mode-of-payment",
"engine": "InnoDB",
"field_order": [
"mode_of_payment",
@@ -44,7 +45,7 @@
"label": "Enabled"
}
],
"icon": "fa fa-credit-card",
"icon": "wallet",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],

View File

@@ -43,7 +43,7 @@
"options": "Monthly Distribution Percentage"
}
],
"icon": "fa fa-bar-chart",
"icon": "chart-bar",
"idx": 1,
"links": [],
"modified": "2026-08-21 23:11:44.908490",

View File

@@ -4,6 +4,7 @@
"allow_copy": 1,
"creation": "2017-08-29 02:22:54.947711",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/opening-invoice-creation-tool",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -88,6 +89,7 @@
}
],
"hide_toolbar": 1,
"icon": "file-plus",
"issingle": 1,
"links": [],
"modified": "2026-05-30 23:18:48.691227",

View File

@@ -50,6 +50,7 @@
"options": "secondary_role"
}
],
"icon": "link",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-03-27 13:10:08.607170",

View File

@@ -2,8 +2,6 @@
// For license information, please see license.txt
frappe.provide("erpnext.accounts.dimensions");
cur_frm.cscript.tax_table = "Advance Taxes and Charges";
erpnext.accounts.taxes.setup_tax_validations("Payment Entry");
erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
@@ -46,23 +44,29 @@ frappe.ui.form.on("Payment Entry", {
},
setup: function (frm) {
frm.set_query("paid_from", function () {
frm.cscript.tax_table = "Advance Taxes and Charges";
frm.set_query("paid_from", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
filters.name = ["!=", doc.paid_to];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});
@@ -106,21 +110,25 @@ frappe.ui.form.on("Payment Entry", {
}
});
frm.set_query("paid_to", function () {
frm.set_query("paid_to", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
filters.name = ["!=", doc.paid_from];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});

View File

@@ -5,6 +5,7 @@
"autoname": "naming_series:",
"creation": "2016-06-01 14:38:51.012597",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-entry",
"engine": "InnoDB",
"field_order": [
"type_of_payment",
@@ -784,6 +785,7 @@
}
],
"grid_page_length": 50,
"icon": "banknote",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [

View File

@@ -176,6 +176,7 @@ class PaymentEntry(AccountsController):
self.set_liability_account()
self.set_missing_ref_details(force=True)
self.validate_payment_type()
self.validate_internal_transfer_accounts()
self.validate_party_details()
self.set_exchange_rate()
self.validate_mandatory()
@@ -208,9 +209,15 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule()
self.make_gl_entries()
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.set_status()
self.trigger_invoice_update_for_subscriptions()
def update_linked_dunnings(self):
from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment
update_dunnings_linked_to_payment(self)
def validate_for_repost(self):
validate_docs_for_voucher_types(["Payment Entry"])
validate_docs_for_deferred_accounting([self.name], [])
@@ -315,6 +322,7 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule(cancel=1)
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.delink_advance_entry_references()
self.set_status()
self.trigger_invoice_update_for_subscriptions()
@@ -627,6 +635,10 @@ class PaymentEntry(AccountsController):
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
def validate_internal_transfer_accounts(self):
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
def validate_party_details(self):
if self.party and not frappe.db.exists(self.party_type, self.party):
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
@@ -2725,7 +2737,7 @@ def get_payment_entry(
pe.append("references", reference)
else:
if dt == "Dunning":
for overdue_payment in doc.overdue_payments:
for overdue_payment, outstanding in doc.get_unpaid_overdue_payments():
pe.append(
"references",
{
@@ -2733,21 +2745,23 @@ def get_payment_entry(
"reference_name": overdue_payment.sales_invoice,
"payment_term": overdue_payment.payment_term,
"due_date": overdue_payment.due_date,
"total_amount": overdue_payment.outstanding,
"outstanding_amount": overdue_payment.outstanding,
"allocated_amount": overdue_payment.outstanding,
"total_amount": outstanding,
"outstanding_amount": outstanding,
"allocated_amount": outstanding,
},
)
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * doc.dunning_amount,
"description": _("Interest and/or dunning fee"),
},
)
if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0:
pe.append(
"deductions",
{
"account": doc.income_account,
"cost_center": doc.cost_center,
"amount": -1 * unpaid_dunning_amount,
"description": _("Interest and/or dunning fee"),
"dunning": doc.name,
},
)
else:
pe.append(
"references",
@@ -3040,8 +3054,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre
grand_total = doc.rounded_total or doc.grand_total
outstanding_amount = doc.outstanding_amount
elif dt == "Dunning":
grand_total = doc.grand_total
outstanding_amount = doc.grand_total
# only what is left to collect, the totals on the dunning are the ones it was raised with
grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments())
grand_total += doc.get_unpaid_dunning_amount()
outstanding_amount = grand_total
else:
if party_account_currency == doc.company_currency:
grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total"))

View File

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

View File

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

View File

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

View File

@@ -84,6 +84,7 @@
"reqd": 1
}
],
"icon": "credit-card",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-14 16:49:55.210352",

View File

@@ -5,6 +5,7 @@
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"icon": "book-open",
"is_submittable": 1,
"field_order": [
"posting_date",

View File

@@ -3,6 +3,7 @@
"autoname": "naming_series:",
"creation": "2018-07-20 16:43:08.505978",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-order",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -107,6 +108,7 @@
"label": "Account"
}
],
"icon": "send",
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:10:10.186727",

View File

@@ -57,9 +57,27 @@ class PaymentOrder(Document):
frappe.db.set_value(self.payment_order_type, d.get(ref_doc_field), ref_field, status)
def _readable_payment_order(filters: dict) -> str | None:
"""Authorise the parent before reading its rows.
A child table carries no permissions of its own, so a read of it has to be authorised on the
Payment Order the rows belong to.
"""
parent = filters.get("parent")
if not parent or not frappe.db.exists("Payment Order", parent):
return None
ptype = "select" if frappe.only_has_select_perm("Payment Order") else "read"
frappe.has_permission("Payment Order", ptype, doc=parent, throw=True)
return parent
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
if not _readable_payment_order(filters):
return []
return frappe.get_all(
"Payment Order Reference",
filters={"parent": filters.get("parent"), "mode_of_payment": ["like", f"%{txt}%"]},
@@ -74,6 +92,9 @@ def get_mop_query(doctype: str, txt: str, searchfield: str, start: int, page_len
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier_query(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
if not _readable_payment_order(filters):
return []
return frappe.get_all(
"Payment Order Reference",
filters={

View File

@@ -109,12 +109,13 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
}
this.frm.trigger("set_query_for_dimension_filters");
this.update_totals();
this.bind_totals_on_row_select();
// check for any running reconciliation jobs
if (this.frm.doc.receivable_payable_account) {
this.frm.call({
doc: this.frm.doc,
method: "is_auto_process_enabled",
frappe.call({
method: "erpnext.accounts.doctype.payment_reconciliation.payment_reconciliation.is_auto_process_enabled",
callback: (r) => {
if (r.message) {
this.frm
@@ -223,6 +224,31 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
this.frm.clear_table("payments");
this.frm.clear_table("allocation");
this.frm.refresh_fields();
this.update_totals();
}
update_totals() {
const sum_outstanding = (rows) => rows.reduce((total, row) => total + flt(row.outstanding_amount), 0);
const sum_amount = (rows) => rows.reduce((total, row) => total + flt(row.amount), 0);
const selected_invoices = this.frm.fields_dict.invoices.grid.get_selected_children();
const selected_payments = this.frm.fields_dict.payments.grid.get_selected_children();
const total_invoice_amount = sum_outstanding(selected_invoices);
const total_payment_amount = sum_amount(selected_payments);
this.frm.set_value({
total_invoice_amount,
total_payment_amount,
difference_amount: total_invoice_amount - total_payment_amount,
});
}
bind_totals_on_row_select() {
["invoices", "payments"].forEach((fieldname) => {
this.frm.fields_dict[fieldname].grid.wrapper
.off("click.pr_totals")
.on("click.pr_totals", ".grid-row-check", () => this.update_totals());
});
}
get_unreconciled_entries() {
@@ -231,6 +257,7 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
doc: this.frm.doc,
method: "get_unreconciled_entries",
callback: () => {
this.update_totals();
if (!(this.frm.doc.payments.length || this.frm.doc.invoices.length)) {
frappe.throw({
message: __("No Unreconciled Invoices and Payments found for this party and account"),
@@ -431,4 +458,4 @@ frappe.ui.form.on("Payment Reconciliation Allocation", {
},
});
extend_cscript(cur_frm.cscript, new erpnext.accounts.PaymentReconciliationController({ frm: cur_frm }));
frappe.ui.form.set_controller("Payment Reconciliation", erpnext.accounts.PaymentReconciliationController);

View File

@@ -3,6 +3,7 @@
"allow_copy": 1,
"creation": "2014-07-09 12:04:51.681583",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-reconciliation",
"engine": "InnoDB",
"field_order": [
"company",
@@ -10,6 +11,7 @@
"column_break_4",
"party",
"receivable_payable_account",
"currency",
"default_advance_account",
"col_break1",
"from_invoice_date",
@@ -35,6 +37,12 @@
"column_break_15",
"payment_name",
"payments",
"totals_section",
"total_invoice_amount",
"column_break_totals_1",
"total_payment_amount",
"column_break_totals_2",
"difference_amount",
"sec_break2",
"allocation"
],
@@ -70,6 +78,15 @@
"options": "Account",
"reqd": 1
},
{
"fetch_from": "receivable_payable_account.account_currency",
"fieldname": "currency",
"fieldtype": "Link",
"hidden": 1,
"label": "Currency",
"options": "Currency",
"read_only": 1
},
{
"description": "This filter will be applied to Journal Entry.",
"fieldname": "bank_cash_account",
@@ -99,6 +116,41 @@
"label": "Payments",
"options": "Payment Reconciliation Payment"
},
{
"depends_on": "eval:(doc.payments).length || (doc.invoices).length",
"fieldname": "totals_section",
"fieldtype": "Section Break",
"label": "Totals"
},
{
"fieldname": "total_invoice_amount",
"fieldtype": "Currency",
"label": "Total Invoice Amount",
"options": "currency",
"read_only": 1
},
{
"fieldname": "column_break_totals_1",
"fieldtype": "Column Break"
},
{
"fieldname": "total_payment_amount",
"fieldtype": "Currency",
"label": "Total Payment Amount",
"options": "currency",
"read_only": 1
},
{
"fieldname": "column_break_totals_2",
"fieldtype": "Column Break"
},
{
"fieldname": "difference_amount",
"fieldtype": "Currency",
"label": "Difference Amount",
"options": "currency",
"read_only": 1
},
{
"depends_on": "allocation",
"fieldname": "sec_break2",
@@ -235,7 +287,7 @@
}
],
"hide_toolbar": 1,
"icon": "icon-resize-horizontal",
"icon": "arrow-left-right",
"is_virtual": 1,
"issingle": 1,
"links": [],

View File

@@ -460,10 +460,6 @@ class PaymentReconciliation(Document):
return difference_amount
@frappe.whitelist()
def is_auto_process_enabled(self):
return frappe.get_single_value("Accounts Settings", "auto_reconcile_payments")
@frappe.whitelist()
def calculate_difference_on_allocation_change(
self, payment_entry: list, invoice: list, allocated_amount: float
@@ -486,6 +482,13 @@ class PaymentReconciliation(Document):
"Accounts Settings", "exchange_gain_loss_posting_date", cache=True
)
invoice_exchange_map = self.get_invoice_exchange_map(args.get("invoices"), args.get("payments"))
account_currency = frappe.get_cached_value(
"Account", self.receivable_payable_account, "account_currency"
)
allocated_amount_precision = get_field_precision(
frappe.get_meta("Payment Reconciliation Allocation").get_field("allocated_amount"),
currency=account_currency,
)
entries = []
for pay in args.get("payments"):
@@ -493,11 +496,17 @@ class PaymentReconciliation(Document):
for inv in args.get("invoices"):
if pay.get("amount") >= inv.get("outstanding_amount"):
res = self.get_allocated_entry(pay, inv, inv["outstanding_amount"])
pay["amount"] = flt(pay.get("amount")) - flt(inv.get("outstanding_amount"))
pay["amount"] = flt(
flt(pay.get("amount")) - flt(inv.get("outstanding_amount")),
allocated_amount_precision,
)
inv["outstanding_amount"] = 0
else:
res = self.get_allocated_entry(pay, inv, pay["amount"])
inv["outstanding_amount"] = flt(inv.get("outstanding_amount")) - flt(pay.get("amount"))
inv["outstanding_amount"] = flt(
flt(inv.get("outstanding_amount")) - flt(pay.get("amount")),
allocated_amount_precision,
)
pay["amount"] = 0
inv["exchange_rate"] = invoice_exchange_map.get(inv.get("invoice_number"))
@@ -967,3 +976,8 @@ def get_queries_for_dimension_filters(company: str | None = None):
dimensions_with_filters.append({"fieldname": d.fieldname, "filters": filters})
return dimensions_with_filters
@frappe.whitelist()
def is_auto_process_enabled():
return frappe.get_single_value("Accounts Settings", "auto_reconcile_payments")

View File

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

View File

@@ -1,9 +1,7 @@
cur_frm.add_fetch("payment_gateway_account", "payment_account", "payment_account");
cur_frm.add_fetch("payment_gateway_account", "payment_gateway", "payment_gateway");
cur_frm.add_fetch("payment_gateway_account", "message", "message");
frappe.ui.form.on("Payment Request", {
setup: function (frm) {
frm.add_fetch("payment_gateway_account", "message", "message");
frm.set_query("party_type", function () {
return {
query: "erpnext.setup.doctype.party_type.party_type.get_party_type",

View File

@@ -3,6 +3,7 @@
"autoname": "naming_series:",
"creation": "2015-12-15 22:23:24.745065",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-request",
"engine": "InnoDB",
"field_order": [
"payment_request_type",
@@ -474,6 +475,7 @@
}
],
"grid_page_length": 50,
"icon": "hand-coins",
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,

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