Compare commits

...

70 Commits

Author SHA1 Message Date
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
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
Krishna Pramod Shirsath
b85da1628d Merge branch 'develop' into fix/issue-55219-bank-reconciliation-document-types 2026-09-11 16:25:39 +05:30
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
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
Krishna Shirsath
d39e677d80 fix: handle missing bank reconciliation document types 2026-09-02 13:21:24 +05:30
508 changed files with 6948 additions and 2831 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

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

@@ -55,6 +55,18 @@ class ERPNextAddress(Address):
@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

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

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

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

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

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

@@ -5,6 +5,7 @@
"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": [
@@ -169,6 +170,7 @@
"read_only": 1
}
],
"icon": "ticket-percent",
"is_submittable": 1,
"links": [],
"modified": "2026-09-09 17:04:59.512294",

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

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

@@ -1023,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")
@@ -1051,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
)
@@ -1080,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

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

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

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

@@ -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",
@@ -235,7 +236,7 @@
}
],
"hide_toolbar": 1,
"icon": "icon-resize-horizontal",
"icon": "arrow-left-right",
"is_virtual": 1,
"issingle": 1,
"links": [],

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,

View File

@@ -5,6 +5,7 @@
"autoname": "field:payment_term_name",
"creation": "2017-08-10 15:24:54.876365",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-terms",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -115,6 +116,7 @@
"fieldtype": "Column Break"
}
],
"icon": "calendar-clock",
"links": [],
"modified": "2026-08-21 23:11:45.693762",
"modified_by": "Administrator",

View File

@@ -5,6 +5,7 @@
"autoname": "field:template_name",
"creation": "2017-08-10 15:34:28.058054",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/payment-terms-template",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -34,6 +35,7 @@
"label": "Allocate Payment Based On Payment Terms"
}
],
"icon": "calendar-clock",
"links": [],
"modified": "2024-04-07 11:26:42.021585",
"modified_by": "Administrator",

View File

@@ -20,6 +20,7 @@
}
],
"grid_page_length": 50,
"icon": "anchor",
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],

View File

@@ -3,6 +3,7 @@
"autoname": "ACC-PCV-.YYYY.-.#####",
"creation": "2013-01-10 16:34:07",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/period-closing-voucher",
"engine": "InnoDB",
"field_order": [
"transaction_date",
@@ -110,7 +111,7 @@
"reqd": 1
}
],
"icon": "fa fa-file-text",
"icon": "folder-lock",
"idx": 1,
"is_submittable": 1,
"links": [],

View File

@@ -254,6 +254,7 @@
}
],
"grid_page_length": 50,
"icon": "book-lock",
"is_submittable": 1,
"links": [
{

View File

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

View File

@@ -1640,7 +1640,7 @@
"print_hide": 1
}
],
"icon": "fa fa-file-text",
"icon": "receipt-text",
"is_submittable": 1,
"links": [],
"modified": "2026-08-21 23:11:45.029925",

View File

@@ -909,6 +909,30 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist()
def get_stock_availability(item_code: str | None, warehouse: str):
# The POS Profile is what entitles a caller to POS stock figures, and it is the only boundary
# that fits: `Item` read and `Bin` read both exclude Accounts Manager, `Item` select is granted
# to every desk user by `Desk User`, and `POS Invoice` read is granted to `All`.
frappe.has_permission("POS Profile", throw=True)
# and keep a company-restricted caller inside their own companies, which costs nobody who has
# no Company User Permission
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, "POS Profile")
if allowed_companies:
company = frappe.db.get_value("Warehouse", warehouse, "company")
if company and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
# the caller picks the warehouse when allow_warehouse_change is set, and the company check above
# does not narrow within a company; costs nobody who has no Warehouse User Permission
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
if warehouse_permissions := get_user_permissions(frappe.session.user).get("Warehouse"):
allowed_warehouses = get_allowed_docs_for_doctype(warehouse_permissions, "POS Invoice")
if allowed_warehouses and warehouse not in allowed_warehouses:
frappe.throw(_("Not permitted for {0}").format(warehouse), frappe.PermissionError)
if frappe.db.get_value("Item", item_code, "is_stock_item"):
is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse)

View File

@@ -126,6 +126,7 @@
"reqd": 1
}
],
"icon": "merge",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],

View File

@@ -129,6 +129,7 @@
"reqd": 1
}
],
"icon": "folder-open",
"is_submittable": 1,
"links": [],
"modified": "2024-03-27 13:10:16.034296",

View File

@@ -4,6 +4,7 @@
"autoname": "Prompt",
"creation": "2013-05-24 12:15:51",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/pos-profile",
"engine": "InnoDB",
"field_order": [
"company",
@@ -557,7 +558,7 @@
}
],
"grid_page_length": 50,
"icon": "icon-cog",
"icon": "monitor",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [

View File

@@ -51,6 +51,7 @@
}
],
"hide_toolbar": 0,
"icon": "settings",
"issingle": 1,
"links": [],
"modified": "2026-03-16 13:28:19.677217",

View File

@@ -108,7 +108,7 @@ frappe.ui.form.on("Pricing Rule", {
</td></tr>
</table>`;
frm.set_df_property("pricing_rule_help", "options", help_content);
frm.get_field("pricing_rule_help").html(help_content);
frm.events.set_options_for_applicable_for(frm);
frm.trigger("toggle_reqd_apply_on");
},

View File

@@ -5,6 +5,7 @@
"autoname": "naming_series:",
"creation": "2014-02-21 15:02:51",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/pricing-rule",
"engine": "InnoDB",
"field_order": [
"applicability_section",
@@ -654,7 +655,7 @@
"label": "Don't Enforce Free Item Qty"
}
],
"icon": "fa fa-gift",
"icon": "badge-percent",
"idx": 1,
"links": [],
"modified": "2026-02-17 12:24:07.553505",

View File

@@ -12,6 +12,29 @@ from frappe import _, throw
from frappe.model.document import Document
from frappe.utils import cint, flt
# the transactions the pricing engine is called for, from transaction.js and the POS
PRICING_TRANSACTION_DOCTYPES = frozenset(
{
"Quotation",
"Sales Order",
"Delivery Note",
"Sales Invoice",
"POS Invoice",
"Supplier Quotation",
"Purchase Order",
"Purchase Receipt",
"Purchase Invoice",
"Material Request",
# these three also extend a controller that calls the pricing engine: BOM and BOM Creator
# through TransactionController, Request for Quotation through BuyingController
"BOM",
"BOM Creator",
"Request for Quotation",
# no client sends this one, but set_transaction_type below still branches on it
"Opportunity",
}
)
apply_on_dict = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}
other_fields = ["other_item_code", "other_item_group", "other_brand"]
@@ -366,6 +389,30 @@ def apply_pricing_rule(args: str | dict, doc: str | dict | Document | None = Non
args = frappe._dict(args)
# `args` is caller supplied, and what comes back is pricing: matched Pricing Rules, discounts
# and rates. The transaction being priced is what decides who may price it, so authorise that
# — and the document itself where the caller named an existing one, so User Permissions apply.
# an allow-list, not just a type check: `doctype` is caller-chosen, and any doctype the caller can
# read would otherwise satisfy has_permission below while the pricing engine still ran
transaction_doctype = args.get("doctype")
if transaction_doctype not in PRICING_TRANSACTION_DOCTYPES:
frappe.throw(_("Invalid doctype"), frappe.PermissionError)
transaction_name = args.get("name")
if not isinstance(transaction_name, str) or not frappe.db.exists(transaction_doctype, transaction_name):
transaction_name = None
frappe.has_permission(transaction_doctype, doc=transaction_name, throw=True)
# scope by the caller's own Company restrictions, not a Company read: several roles that fill these forms hold none
company = args.get("company")
if company:
from erpnext.stock.doctype.company_restriction.company_restriction import get_allowed_companies
allowed_companies = get_allowed_companies(frappe.session.user, transaction_doctype)
if allowed_companies and company not in allowed_companies:
frappe.throw(_("Not permitted for {0}").format(company), frappe.PermissionError)
set_transaction_type(args)
# list of dictionaries
@@ -725,14 +772,18 @@ def set_transaction_type(pricing_ctx: frappe._dict) -> None:
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_item_uoms(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
items = [filters.get("value")]
if filters.get("apply_on") != "Item Code":
field = frappe.scrub(filters.get("apply_on"))
items = [d.name for d in frappe.db.get_all("Item", filters={field: filters.get("value")})]
if filters.get("apply_on") == "Item Code":
item_filters = [["name", "=", filters.get("value")]]
else:
item_filters = [[frappe.scrub(filters.get("apply_on")), "=", filters.get("value")]]
items = frappe.get_list("Item", filters=item_filters, pluck="name")
if not items:
return []
return frappe.get_all(
"UOM Conversion Detail",
filters={"parent": ("in", items), "uom": ("like", f"{txt}%")},
filters={"parent": ("in", items), "parenttype": "Item", "uom": ("like", f"{txt}%")},
fields=["uom"],
as_list=1,
distinct=True,

View File

@@ -3,6 +3,7 @@
"autoname": "ACC-PDA-.#####",
"creation": "2019-11-04 18:01:23.454775",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/process-deferred-accounting",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -74,6 +75,7 @@
"reqd": 1
}
],
"icon": "list-clock",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],

View File

@@ -154,6 +154,7 @@
"reqd": 0
}
],
"icon": "scale",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],

View File

@@ -106,6 +106,7 @@
"read_only": 1
}
],
"icon": "file-clock",
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],

View File

@@ -80,6 +80,7 @@
}
],
"grid_page_length": 50,
"icon": "lock",
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],

View File

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

View File

@@ -4,6 +4,7 @@
"autoname": "Prompt",
"creation": "2020-05-22 16:46:18.712954",
"doctype": "DocType",
"documentation": "https://docs.frappe.io/erpnext/user/manual/en/process-statement-of-accounts",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
@@ -424,6 +425,7 @@
"label": "Show Opening Entries"
}
],
"icon": "mail",
"links": [],
"modified": "2026-06-01 15:37:07.660442",
"modified_by": "Administrator",

View File

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

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