mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-16 19:32:38 +00:00
Compare commits
155 Commits
auth_did
...
fix-je-par
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3f262b415 | ||
|
|
7e602d5389 | ||
|
|
529f8dc7cd | ||
|
|
52b406f5f1 | ||
|
|
3dda2005d8 | ||
|
|
322d4dff25 | ||
|
|
01a10fb5b0 | ||
|
|
4c084f7eff | ||
|
|
627f2058b5 | ||
|
|
8db4d2705a | ||
|
|
4ca7bc8ccf | ||
|
|
ca5cc4afdc | ||
|
|
380b005659 | ||
|
|
df0ad93262 | ||
|
|
f503614cc0 | ||
|
|
6d9beea56b | ||
|
|
560d8bb674 | ||
|
|
a3e3e1b32c | ||
|
|
2492dfa558 | ||
|
|
3b5a203d61 | ||
|
|
934abe5c6d | ||
|
|
867ee484b9 | ||
|
|
2652082475 | ||
|
|
abb579e2db | ||
|
|
0c2d5488a6 | ||
|
|
138f683a68 | ||
|
|
479f9f63c9 | ||
|
|
56bfe6b6a6 | ||
|
|
acae34c8e1 | ||
|
|
dcbe4a6d55 | ||
|
|
87d26a2d67 | ||
|
|
e1d8d06966 | ||
|
|
8c88cecc1f | ||
|
|
9aeafb8140 | ||
|
|
c24e9796ae | ||
|
|
c7d42e161b | ||
|
|
701896692a | ||
|
|
93d6be2ed7 | ||
|
|
b0e9ad198f | ||
|
|
a9029f83c7 | ||
|
|
31e4da562d | ||
|
|
e6fdb3702a | ||
|
|
bd60a9be90 | ||
|
|
a64466561f | ||
|
|
f7ff25d9a8 | ||
|
|
c933e34914 | ||
|
|
87092961e7 | ||
|
|
3f436985ed | ||
|
|
cf127e8900 | ||
|
|
9ea766fc10 | ||
|
|
53180fde93 | ||
|
|
224dff32df | ||
|
|
292bfa2a34 | ||
|
|
e90896ced7 | ||
|
|
c360487cd1 | ||
|
|
a0177fdbe8 | ||
|
|
64175bdb3e | ||
|
|
4fed04c6c7 | ||
|
|
35fe9c60c7 | ||
|
|
878c22fa3f | ||
|
|
12ada21639 | ||
|
|
daf3f2e142 | ||
|
|
ea3ec325e2 | ||
|
|
73d1852773 | ||
|
|
9c5f9218b5 | ||
|
|
a8a78a2163 | ||
|
|
0b6121422d | ||
|
|
9249fa89aa | ||
|
|
5a816d19cb | ||
|
|
a7d41f24a3 | ||
|
|
81a1c2c8ce | ||
|
|
0c6f7fed55 | ||
|
|
bfee9df9aa | ||
|
|
bddd1d0ebc | ||
|
|
aa9f225c41 | ||
|
|
9c799f31ff | ||
|
|
a60afaf91a | ||
|
|
a4cff805f1 | ||
|
|
4f55071eda | ||
|
|
43bb6c5a42 | ||
|
|
34955380ee | ||
|
|
1714e13b39 | ||
|
|
263c3e9dd4 | ||
|
|
c97c2d1e02 | ||
|
|
cf37478870 | ||
|
|
060a5c4eeb | ||
|
|
3ad32f4030 | ||
|
|
dfc824ded6 | ||
|
|
f099dbad35 | ||
|
|
cc8ce03232 | ||
|
|
bcc1e73962 | ||
|
|
32d7250946 | ||
|
|
4c1cabb53e | ||
|
|
1105cb8ddf | ||
|
|
8bb4ffc6b1 | ||
|
|
dfd7cd0bae | ||
|
|
e083aa4c86 | ||
|
|
c4fbc745db | ||
|
|
2b6234f7af | ||
|
|
88b9911136 | ||
|
|
360f52e636 | ||
|
|
6201fefdfb | ||
|
|
08129ff71c | ||
|
|
5357634b70 | ||
|
|
20ba97aa7d | ||
|
|
d90d4c29e1 | ||
|
|
ddbd61b2a2 | ||
|
|
6a7c9f616e | ||
|
|
a3194720b4 | ||
|
|
7825ddf989 | ||
|
|
e9b67ff682 | ||
|
|
4c3aa9b4f3 | ||
|
|
ca77145522 | ||
|
|
5753c23ccf | ||
|
|
a397e82278 | ||
|
|
9c23229cbf | ||
|
|
08f6af867a | ||
|
|
6988781f81 | ||
|
|
49093b326e | ||
|
|
9503dd0c7f | ||
|
|
bd0acf4413 | ||
|
|
969cdf1b26 | ||
|
|
8db1eb0d27 | ||
|
|
d146dc5435 | ||
|
|
0ca38517f3 | ||
|
|
5d1af7fc93 | ||
|
|
1fab935434 | ||
|
|
d6ba0f0eca | ||
|
|
49164f41b1 | ||
|
|
e36426e235 | ||
|
|
ba936eefab | ||
|
|
5eb9461cfd | ||
|
|
e1e588e416 | ||
|
|
00880eb657 | ||
|
|
ae6aef91bd | ||
|
|
faf92b1368 | ||
|
|
a52c8fdaea | ||
|
|
030e1a77e6 | ||
|
|
d2306b1b29 | ||
|
|
601f39dda7 | ||
|
|
047e4faa90 | ||
|
|
8d7edafc99 | ||
|
|
8f15dd4d5d | ||
|
|
bf769a52c0 | ||
|
|
58582cfa09 | ||
|
|
1ef4978a86 | ||
|
|
37d2adc74b | ||
|
|
5dbf3fdde0 | ||
|
|
4b0b7adeee | ||
|
|
8de259a669 | ||
|
|
2ecf8b0466 | ||
|
|
e460e83516 | ||
|
|
9084570d18 | ||
|
|
d57786caa2 | ||
|
|
a2f877cee6 |
51
.github/helper/install.sh
vendored
51
.github/helper/install.sh
vendored
@@ -4,24 +4,46 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
sudo apt update
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
|
||||
|
||||
pip install frappe-bench
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — parallelise the three slow, independent setup steps:
|
||||
# a) system packages b) frappe-bench pip install c) frappe git fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
sudo apt update
|
||||
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
|
||||
mkdir frappe
|
||||
(
|
||||
cd frappe
|
||||
git init
|
||||
git remote add origin "https://github.com/${frappeuser}/frappe"
|
||||
git fetch origin "${frappecommitish}" --depth 1
|
||||
) &
|
||||
clone_pid=$!
|
||||
|
||||
wait $apt_pid
|
||||
wait $pip_pid
|
||||
wait $clone_pid
|
||||
|
||||
pushd frappe
|
||||
git init
|
||||
git remote add origin "https://github.com/${frappeuser}/frappe"
|
||||
git fetch origin "${frappecommitish}" --depth 1
|
||||
git checkout FETCH_HEAD
|
||||
popd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — bench init and site setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
@@ -37,6 +59,11 @@ if [ "$DB" == "mariadb" ];then
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
# Belt-and-suspenders: also set performance variables at runtime in case
|
||||
# MARIADB_EXTRA_FLAGS was not honoured by the container image.
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot \
|
||||
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
||||
@@ -51,9 +78,11 @@ fi
|
||||
|
||||
|
||||
install_whktml() {
|
||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f /tmp/wkhtmltox.deb ]; then
|
||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
sudo apt install /tmp/wkhtmltox.deb
|
||||
|
||||
}
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
|
||||
21
.github/workflows/server-tests-mariadb.yml
vendored
21
.github/workflows/server-tests-mariadb.yml
vendored
@@ -59,6 +59,10 @@ jobs:
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
# Disable durability guarantees that are unnecessary in a throwaway CI container.
|
||||
# innodb_flush_log_at_trx_commit=0 avoids an fsync on every commit (biggest win).
|
||||
# sync_binlog=0 skips binary-log syncs; innodb_doublewrite=0 skips the doublewrite buffer.
|
||||
MARIADB_EXTRA_FLAGS: --innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --innodb-doublewrite=0
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
@@ -122,6 +126,12 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Cache wkhtmltopdf
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/wkhtmltox.deb
|
||||
key: wkhtmltox-0.12.6.1-2-jammy-amd64
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
@@ -131,7 +141,14 @@ jobs:
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
|
||||
- name: Run Tests
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --lightmode --app erpnext --total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }} --with-coverage'
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
coverage_flag=""
|
||||
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||
--total-builds ${{ strategy.job-total }} \
|
||||
--build-number ${{ matrix.container }} \
|
||||
$coverage_flag
|
||||
env:
|
||||
TYPE: server
|
||||
|
||||
@@ -141,6 +158,7 @@ jobs:
|
||||
run: cat ~/frappe-bench/bench_start.log || true
|
||||
|
||||
- name: Upload coverage data
|
||||
if: ${{ env.WITH_COVERAGE == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-${{ matrix.container }}
|
||||
@@ -149,6 +167,7 @@ jobs:
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: test
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
|
||||
10
.greptile/config.json
Normal file
10
.greptile/config.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"disabledLabels": [
|
||||
"conflicts"
|
||||
],
|
||||
"context": {
|
||||
"repos": [
|
||||
"frappe/frappe"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -592,10 +592,12 @@ def update_account_number(
|
||||
@frappe.whitelist()
|
||||
def merge_account(old: str, new: str):
|
||||
_ensure_idle_system()
|
||||
# Validate properties before merging
|
||||
new_account = frappe.get_cached_doc("Account", new)
|
||||
old_account = frappe.get_cached_doc("Account", old)
|
||||
|
||||
new_account.check_permission("write")
|
||||
old_account.check_permission("write")
|
||||
|
||||
if not new_account:
|
||||
throw(_("Account {0} does not exist").format(new))
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ class BankClearance(Document):
|
||||
invalid_document = []
|
||||
invalid_cheque_date = []
|
||||
entries_to_update = []
|
||||
self.check_permission("write")
|
||||
|
||||
def validate_entry(d):
|
||||
is_valid = True
|
||||
|
||||
@@ -518,6 +518,7 @@ def create_internal_transfer(
|
||||
"""
|
||||
|
||||
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
bank_transaction.check_permission("write")
|
||||
|
||||
bank_account = frappe.get_cached_value("Bank Account", bank_transaction.bank_account, "account")
|
||||
company = frappe.get_cached_value("Account", bank_account, "company")
|
||||
@@ -778,7 +779,6 @@ def create_bulk_payment_entry_and_reconcile(
|
||||
"""
|
||||
Create a payment entry and reconcile it with the bank transaction
|
||||
"""
|
||||
|
||||
output = []
|
||||
|
||||
for bank_transaction_name in bank_transaction_names:
|
||||
|
||||
@@ -374,6 +374,7 @@ def unreconcile_transaction(transaction_name: str | int):
|
||||
Else, cancel the individual entries
|
||||
"""
|
||||
transaction = frappe.get_doc("Bank Transaction", transaction_name)
|
||||
transaction.check_permission("write")
|
||||
|
||||
vouchers_to_cancel = []
|
||||
|
||||
@@ -401,6 +402,7 @@ def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type:
|
||||
"""
|
||||
|
||||
bank_transaction = frappe.get_doc("Bank Transaction", bank_transaction_id)
|
||||
bank_transaction.check_permission("write")
|
||||
|
||||
# Find the voucher in the bank transaction and depending on the action, either remove it or cancel the voucher
|
||||
for entry in bank_transaction.payment_entries:
|
||||
|
||||
@@ -121,7 +121,7 @@ class BisectAccountingStatements(Document):
|
||||
|
||||
cur_node.save()
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def build_tree(self):
|
||||
frappe.db.delete("Bisect Nodes")
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ frappe.ui.form.on("Budget", {
|
||||
filters: {
|
||||
is_group: 0,
|
||||
company: frm.doc.company,
|
||||
root_type: ["in", ["Income", "Expense"]],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,12 +6,14 @@ frappe.provide("erpnext.cheque_print");
|
||||
frappe.ui.form.on("Cheque Print Template", {
|
||||
refresh: function (frm) {
|
||||
if (!frm.doc.__islocal) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
if (frappe.user.has_role("System Manager")) {
|
||||
frm.add_custom_button(
|
||||
frm.doc.has_print_format ? __("Update Print Format") : __("Create Print Format"),
|
||||
function () {
|
||||
erpnext.cheque_print.view_cheque_print(frm);
|
||||
}
|
||||
).addClass("btn-primary");
|
||||
}
|
||||
|
||||
$(frm.fields_dict.cheque_print_preview.wrapper).empty();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "field:bank_name",
|
||||
"creation": "2016-05-04 14:35:00.402544",
|
||||
"doctype": "DocType",
|
||||
@@ -294,7 +295,7 @@
|
||||
],
|
||||
"links": [],
|
||||
"max_attachments": 1,
|
||||
"modified": "2024-03-27 13:06:44.654989",
|
||||
"modified": "2026-06-08 12:10:35.829531",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Cheque Print Template",
|
||||
@@ -325,19 +326,17 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ class ChequePrintTemplate(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
if not frappe.db.exists("Print Format", template_name):
|
||||
cheque_print = frappe.new_doc("Print Format")
|
||||
cheque_print.update(
|
||||
|
||||
@@ -11,22 +11,28 @@ frappe.ui.form.on("Currency Exchange Settings", {
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r && r.message) {
|
||||
let result = [],
|
||||
params = {};
|
||||
if (frm.doc.service_provider == "exchangerate.host") {
|
||||
let result = ["result"];
|
||||
let params = {
|
||||
result = ["result"];
|
||||
params = {
|
||||
date: "{transaction_date}",
|
||||
from: "{from_currency}",
|
||||
to: "{to_currency}",
|
||||
};
|
||||
add_param(frm, r.message, params, result);
|
||||
} else if (["frankfurter.app", "frankfurter.dev"].includes(frm.doc.service_provider)) {
|
||||
let result = ["rates", "{to_currency}"];
|
||||
let params = {
|
||||
result = ["rates", "{to_currency}"];
|
||||
params = {
|
||||
base: "{from_currency}",
|
||||
symbols: "{to_currency}",
|
||||
};
|
||||
add_param(frm, r.message, params, result);
|
||||
} else if (frm.doc.service_provider == "frankfurter.dev - v2") {
|
||||
result = ["rate"];
|
||||
params = {
|
||||
date: "{transaction_date}",
|
||||
};
|
||||
}
|
||||
add_param(frm, r.message, params, result);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"fieldname": "service_provider",
|
||||
"fieldtype": "Select",
|
||||
"label": "Service Provider",
|
||||
"options": "frankfurter.dev\nexchangerate.host\nCustom",
|
||||
"options": "frankfurter.dev\nexchangerate.host\nfrankfurter.dev - v2\nCustom",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -101,11 +101,10 @@
|
||||
"label": "Use HTTP Protocol"
|
||||
}
|
||||
],
|
||||
"hide_toolbar": 0,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-16 13:28:21.075743",
|
||||
"modified": "2026-06-15 11:25:55.873110",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Currency Exchange Settings",
|
||||
@@ -122,24 +121,11 @@
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "Accounts User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
|
||||
@@ -29,7 +29,7 @@ class CurrencyExchangeSettings(Document):
|
||||
disabled: DF.Check
|
||||
req_params: DF.Table[CurrencyExchangeSettingsDetails]
|
||||
result_key: DF.Table[CurrencyExchangeSettingsResult]
|
||||
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "Custom"]
|
||||
service_provider: DF.Literal["frankfurter.dev", "exchangerate.host", "frankfurter.dev - v2", "Custom"]
|
||||
url: DF.Data | None
|
||||
use_http: DF.Check
|
||||
# end: auto-generated types
|
||||
@@ -70,6 +70,14 @@ class CurrencyExchangeSettings(Document):
|
||||
self.append("req_params", {"key": "base", "value": "{from_currency}"})
|
||||
self.append("req_params", {"key": "symbols", "value": "{to_currency}"})
|
||||
|
||||
elif self.service_provider == "frankfurter.dev - v2":
|
||||
self.set("result_key", [])
|
||||
self.set("req_params", [])
|
||||
|
||||
self.api_endpoint = get_api_endpoint(self.service_provider, self.use_http)
|
||||
self.append("result_key", {"key": "rate"})
|
||||
self.append("req_params", {"key": "date", "value": "{transaction_date}"})
|
||||
|
||||
def validate_parameters(self):
|
||||
params = {}
|
||||
for row in self.req_params:
|
||||
@@ -105,13 +113,20 @@ class CurrencyExchangeSettings(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_api_endpoint(service_provider: str | None = None, use_http: bool = False):
|
||||
if service_provider and service_provider in ["exchangerate.host", "frankfurter.dev", "frankfurter.app"]:
|
||||
if service_provider and service_provider in [
|
||||
"exchangerate.host",
|
||||
"frankfurter.dev",
|
||||
"frankfurter.app",
|
||||
"frankfurter.dev - v2",
|
||||
]:
|
||||
if service_provider == "exchangerate.host":
|
||||
api = "api.exchangerate.host/convert"
|
||||
elif service_provider == "frankfurter.app":
|
||||
api = "api.frankfurter.app/{transaction_date}"
|
||||
elif service_provider == "frankfurter.dev":
|
||||
api = "api.frankfurter.dev/v1/{transaction_date}"
|
||||
elif service_provider == "frankfurter.dev - v2":
|
||||
api = "api.frankfurter.dev/v2/rate/{from_currency}/{to_currency}"
|
||||
|
||||
protocol = "https://"
|
||||
if use_http:
|
||||
|
||||
@@ -5,7 +5,7 @@ import frappe
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry_against_invoice
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -178,7 +178,7 @@ frappe.ui.form.on("Journal Entry", {
|
||||
voucher_type: frm.doc.voucher_type,
|
||||
company: args.company,
|
||||
},
|
||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_inter_company_journal_entry",
|
||||
method: "erpnext.accounts.doctype.journal_entry.mapper.make_inter_company_journal_entry",
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
var doc = frappe.model.sync(r.message)[0];
|
||||
@@ -409,18 +409,16 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
||||
}
|
||||
|
||||
get_outstanding(doctype, docname, company, child) {
|
||||
var args = {
|
||||
doctype: doctype,
|
||||
docname: docname,
|
||||
party: child.party,
|
||||
account: child.account,
|
||||
account_currency: child.account_currency,
|
||||
company: company,
|
||||
};
|
||||
|
||||
return frappe.call({
|
||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.get_outstanding",
|
||||
args: { args: args },
|
||||
args: {
|
||||
doctype: doctype,
|
||||
docname: docname,
|
||||
company: company,
|
||||
account: child.account,
|
||||
party: child.party,
|
||||
account_currency: child.account_currency,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
$.each(r.message, function (field, value) {
|
||||
@@ -731,7 +729,7 @@ $.extend(erpnext.journal_entry, {
|
||||
|
||||
reverse_journal_entry: function (frm) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.journal_entry.journal_entry.make_reverse_journal_entry",
|
||||
method: "erpnext.accounts.doctype.journal_entry.mapper.make_reverse_journal_entry",
|
||||
frm: frm,
|
||||
});
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
261
erpnext/accounts/doctype/journal_entry/mapper.py
Normal file
261
erpnext/accounts/doctype/journal_entry/mapper.py
Normal file
@@ -0,0 +1,261 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""Document builders that map a source document to a Journal Entry or to a
|
||||
Payment Entry raised against it."""
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import flt, get_link_to_form, nowdate
|
||||
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_order(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | float | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
) -> dict | Document:
|
||||
"""Build an advance-payment Journal Entry against an unbilled Sales/Purchase Order."""
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
|
||||
if flt(ref_doc.per_billed, 2) > 0:
|
||||
frappe.throw(_("Can only make payment against unbilled {0}").format(dt))
|
||||
|
||||
if dt == "Sales Order":
|
||||
party_type = "Customer"
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
|
||||
party_account_currency = get_account_currency(party_account)
|
||||
|
||||
if not amount:
|
||||
if party_account_currency == ref_doc.company_currency:
|
||||
amount = flt(ref_doc.base_grand_total) - flt(ref_doc.advance_paid)
|
||||
else:
|
||||
amount = flt(ref_doc.grand_total) - flt(ref_doc.advance_paid)
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount,
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Advance Payment received against {dt} {dn}",
|
||||
"is_advance": "Yes",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_payment_entry_against_invoice(
|
||||
dt: str,
|
||||
dn: str,
|
||||
amount: float | None = None,
|
||||
debit_in_account_currency: str | None = None,
|
||||
journal_entry: bool = False,
|
||||
bank_account: str | None = None,
|
||||
) -> dict | Document:
|
||||
"""Build a payment Journal Entry against a Sales/Purchase Invoice's outstanding amount."""
|
||||
ref_doc = frappe.get_doc(dt, dn)
|
||||
if dt == "Sales Invoice":
|
||||
party_type = "Customer"
|
||||
party_account = get_party_account_based_on_invoice_discounting(dn) or ref_doc.debit_to
|
||||
else:
|
||||
party_type = "Supplier"
|
||||
party_account = ref_doc.credit_to
|
||||
|
||||
if (dt == "Sales Invoice" and ref_doc.outstanding_amount > 0) or (
|
||||
dt == "Purchase Invoice" and ref_doc.outstanding_amount < 0
|
||||
):
|
||||
amount_field_party = "credit_in_account_currency"
|
||||
amount_field_bank = "debit_in_account_currency"
|
||||
else:
|
||||
amount_field_party = "debit_in_account_currency"
|
||||
amount_field_bank = "credit_in_account_currency"
|
||||
|
||||
return get_payment_entry(
|
||||
ref_doc,
|
||||
{
|
||||
"party_type": party_type,
|
||||
"party_account": party_account,
|
||||
"party_account_currency": ref_doc.party_account_currency,
|
||||
"amount_field_party": amount_field_party,
|
||||
"amount_field_bank": amount_field_bank,
|
||||
"amount": amount if amount else abs(ref_doc.outstanding_amount),
|
||||
"debit_in_account_currency": debit_in_account_currency,
|
||||
"remarks": f"Payment received against {dt} {dn}. {ref_doc.remarks}",
|
||||
"is_advance": "No",
|
||||
"bank_account": bank_account,
|
||||
"journal_entry": journal_entry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_payment_entry(ref_doc, args: dict) -> dict | Document:
|
||||
"""Build a Bank Entry Journal Entry paying `ref_doc`, with a party row and a bank row.
|
||||
|
||||
Returns the Journal Entry document when `args["journal_entry"]` is truthy, otherwise its
|
||||
dict (for client calls).
|
||||
"""
|
||||
je = frappe.new_doc("Journal Entry")
|
||||
je.update({"voucher_type": "Bank Entry", "company": ref_doc.company, "remark": args.get("remarks")})
|
||||
|
||||
cost_center = ref_doc.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", ref_doc.company, "cost_center"
|
||||
)
|
||||
exchange_rate = _reference_exchange_rate(ref_doc, args)
|
||||
|
||||
party_row = _append_party_row(je, ref_doc, args, cost_center, exchange_rate)
|
||||
bank_row = _append_bank_row(je, ref_doc, args, cost_center, exchange_rate)
|
||||
|
||||
if party_row.account_currency != ref_doc.company_currency or (
|
||||
bank_row.account_currency and bank_row.account_currency != ref_doc.company_currency
|
||||
):
|
||||
je.multi_currency = 1
|
||||
|
||||
je.set_amounts_in_company_currency()
|
||||
je.set_total_debit_credit()
|
||||
|
||||
return je if args.get("journal_entry") else je.as_dict()
|
||||
|
||||
|
||||
def _reference_exchange_rate(ref_doc, args: dict) -> float:
|
||||
"""Exchange rate of the party account on the reference document's posting date."""
|
||||
if not args.get("party_account"):
|
||||
return 1
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_exchange_rate
|
||||
|
||||
return get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
args.get("party_account"),
|
||||
args.get("party_account_currency"),
|
||||
ref_doc.company,
|
||||
ref_doc.doctype,
|
||||
ref_doc.name,
|
||||
)
|
||||
|
||||
|
||||
def _append_party_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
|
||||
"""Append the party (debtor/creditor) row that records the advance/payment."""
|
||||
return je.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": args.get("party_account"),
|
||||
"party_type": args.get("party_type"),
|
||||
"party": ref_doc.get(args.get("party_type").lower()),
|
||||
"cost_center": cost_center,
|
||||
"account_type": frappe.get_cached_value("Account", args.get("party_account"), "account_type"),
|
||||
"account_currency": args.get("party_account_currency")
|
||||
or get_account_currency(args.get("party_account")),
|
||||
"exchange_rate": exchange_rate,
|
||||
args.get("amount_field_party"): args.get("amount"),
|
||||
"is_advance": args.get("is_advance"),
|
||||
"reference_type": ref_doc.doctype,
|
||||
"reference_name": ref_doc.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _append_bank_row(je, ref_doc, args: dict, cost_center, exchange_rate: float):
|
||||
"""Append the bank/cash row, defaulting the account and converting the amount to it."""
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import (
|
||||
get_default_bank_cash_account,
|
||||
get_exchange_rate,
|
||||
)
|
||||
|
||||
bank_row = je.append("accounts")
|
||||
bank_account = get_default_bank_cash_account(ref_doc.company, "Bank", account=args.get("bank_account"))
|
||||
if bank_account:
|
||||
bank_row.update(bank_account)
|
||||
# posting date assumed to be the reference document's posting/transaction date
|
||||
bank_row.exchange_rate = get_exchange_rate(
|
||||
ref_doc.get("posting_date") or ref_doc.get("transaction_date"),
|
||||
bank_account["account"],
|
||||
bank_account["account_currency"],
|
||||
ref_doc.company,
|
||||
)
|
||||
|
||||
bank_row.cost_center = cost_center
|
||||
|
||||
amount = args.get("debit_in_account_currency") or args.get("amount")
|
||||
if bank_row.account_currency == args.get("party_account_currency"):
|
||||
bank_row.set(args.get("amount_field_bank"), amount)
|
||||
else:
|
||||
bank_row.set(args.get("amount_field_bank"), amount * exchange_rate)
|
||||
|
||||
return bank_row
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_inter_company_journal_entry(name: str, voucher_type: str, company: str) -> dict:
|
||||
"""Build the counterpart Journal Entry in another company, linked back to `name`."""
|
||||
journal_entry = frappe.new_doc("Journal Entry")
|
||||
journal_entry.voucher_type = voucher_type
|
||||
journal_entry.company = company
|
||||
journal_entry.posting_date = nowdate()
|
||||
journal_entry.inter_company_journal_entry_reference = name
|
||||
return journal_entry.as_dict()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal_entry(source_name: str, target_doc: str | Document | None = None) -> Document:
|
||||
"""Map a submitted Journal Entry to a reversing one (debits and credits swapped)."""
|
||||
existing_reverse = frappe.db.exists("Journal Entry", {"reversal_of": source_name, "docstatus": 1})
|
||||
if existing_reverse:
|
||||
frappe.throw(
|
||||
_("A Reverse Journal Entry {0} already exists for this Journal Entry.").format(
|
||||
get_link_to_form("Journal Entry", existing_reverse)
|
||||
)
|
||||
)
|
||||
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def post_process(source, target) -> None:
|
||||
target.reversal_of = source.name
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Journal Entry",
|
||||
source_name,
|
||||
{
|
||||
"Journal Entry": {"doctype": "Journal Entry", "validation": {"docstatus": ["=", 1]}},
|
||||
"Journal Entry Account": {
|
||||
"doctype": "Journal Entry Account",
|
||||
"field_map": {
|
||||
"account_currency": "account_currency",
|
||||
"exchange_rate": "exchange_rate",
|
||||
"debit_in_account_currency": "credit_in_account_currency",
|
||||
"debit": "credit",
|
||||
"credit_in_account_currency": "debit_in_account_currency",
|
||||
"credit": "debit",
|
||||
"reference_type": "reference_type",
|
||||
"reference_name": "reference_name",
|
||||
},
|
||||
},
|
||||
},
|
||||
target_doc,
|
||||
post_process,
|
||||
)
|
||||
|
||||
return doclist
|
||||
200
erpnext/accounts/doctype/journal_entry/services/asset_service.py
Normal file
200
erpnext/accounts/doctype/journal_entry/services/asset_service.py
Normal file
@@ -0,0 +1,200 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_depr_schedule,
|
||||
)
|
||||
|
||||
|
||||
class AssetService:
|
||||
"""Keeps Assets in sync with the Journal Entries that depreciate, dispose or
|
||||
adjust them.
|
||||
|
||||
On submit of a Depreciation Entry it reduces the asset value and links the
|
||||
depreciation schedule; on submit of an Asset Disposal it marks the asset
|
||||
disposed. On cancel it reverses those links. It also guards cancellation of
|
||||
Journal Entries tied to asset scrapping or value adjustments.
|
||||
"""
|
||||
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def validate_depr_account_and_depr_entry_voucher_type(self) -> None:
|
||||
"""A depreciation account requires voucher type Depreciation Entry and an Expense account."""
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.account_type == "Depreciation":
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
frappe.throw(
|
||||
_("Journal Entry type should be set as Depreciation Entry for asset depreciation")
|
||||
)
|
||||
|
||||
if frappe.get_cached_value("Account", d.account, "root_type") != "Expense":
|
||||
frappe.throw(_("Account {0} should be of type Expense").format(d.account))
|
||||
|
||||
def has_asset_adjustment_entry(self) -> None:
|
||||
"""Block cancellation while a submitted Asset Value Adjustment links to this entry."""
|
||||
if self.doc.flags.get("via_asset_value_adjustment"):
|
||||
return
|
||||
|
||||
asset_value_adjustment = frappe.db.get_value(
|
||||
"Asset Value Adjustment", {"docstatus": 1, "journal_entry": self.doc.name}, "name"
|
||||
)
|
||||
if asset_value_adjustment:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot cancel this document as it is linked with the submitted Asset Value Adjustment <b>{0}</b>. Please cancel the Asset Value Adjustment to continue."
|
||||
).format(frappe.utils.get_link_to_form("Asset Value Adjustment", asset_value_adjustment))
|
||||
)
|
||||
|
||||
def update_asset_value(self) -> None:
|
||||
"""Apply the entry's effect to its linked assets on submit (depreciation or disposal)."""
|
||||
self.update_asset_on_depreciation()
|
||||
self.update_asset_on_disposal()
|
||||
|
||||
def update_asset_on_depreciation(self) -> None:
|
||||
"""Reduce each depreciated asset's value and link the depreciation schedule row."""
|
||||
if self.doc.voucher_type != "Depreciation Entry":
|
||||
return
|
||||
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
):
|
||||
asset = frappe.get_cached_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation:
|
||||
self.update_journal_entry_link_on_depr_schedule(asset, d)
|
||||
self.update_value_after_depreciation(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation - d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def update_value_after_depreciation(self, asset, depr_amount: float) -> None:
|
||||
"""Subtract the depreciation amount from the asset's relevant finance book."""
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation -= depr_amount
|
||||
frappe.db.set_value(
|
||||
"Asset Finance Book", fb_row.name, "value_after_depreciation", fb_row.value_after_depreciation
|
||||
)
|
||||
|
||||
def update_journal_entry_link_on_depr_schedule(self, asset, je_row) -> None:
|
||||
"""Stamp this entry onto the matching (date + amount) depreciation schedule row."""
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", self.doc.finance_book)
|
||||
for d in depr_schedule or []:
|
||||
if (
|
||||
d.schedule_date == self.doc.posting_date
|
||||
and not d.journal_entry
|
||||
and d.depreciation_amount == flt(je_row.debit)
|
||||
):
|
||||
frappe.db.set_value("Depreciation Schedule", d.name, "journal_entry", self.doc.name)
|
||||
|
||||
def update_asset_on_disposal(self) -> None:
|
||||
"""Mark each referenced asset disposed (date + scrap entry) on an Asset Disposal."""
|
||||
if self.doc.voucher_type == "Asset Disposal":
|
||||
disposed_assets = []
|
||||
for d in self.doc.get("accounts"):
|
||||
if (
|
||||
d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and d.reference_name not in disposed_assets
|
||||
):
|
||||
frappe.db.set_value(
|
||||
"Asset",
|
||||
d.reference_name,
|
||||
{
|
||||
"disposal_date": self.doc.posting_date,
|
||||
"journal_entry_for_scrap": self.doc.name,
|
||||
},
|
||||
)
|
||||
asset_doc = frappe.get_doc("Asset", d.reference_name)
|
||||
asset_doc.set_status()
|
||||
disposed_assets.append(d.reference_name)
|
||||
|
||||
def unlink_asset_reference(self) -> None:
|
||||
"""On cancel, reverse depreciation links and block cancelling an asset-scrap entry."""
|
||||
for d in self.doc.get("accounts"):
|
||||
if self._is_depreciation_asset_row(d):
|
||||
self._reverse_asset_depreciation(d)
|
||||
elif (
|
||||
self.doc.voucher_type == "Journal Entry" and d.reference_type == "Asset" and d.reference_name
|
||||
):
|
||||
self._block_scrap_journal_cancel(d)
|
||||
|
||||
def _is_depreciation_asset_row(self, d) -> bool:
|
||||
return bool(
|
||||
self.doc.voucher_type == "Depreciation Entry"
|
||||
and d.reference_type == "Asset"
|
||||
and d.reference_name
|
||||
and frappe.get_cached_value("Account", d.account, "root_type") == "Expense"
|
||||
and d.debit
|
||||
)
|
||||
|
||||
def _reverse_asset_depreciation(self, d) -> None:
|
||||
"""Add the depreciation amount back to the asset and unlink its schedule row."""
|
||||
asset = frappe.get_doc("Asset", d.reference_name)
|
||||
|
||||
if asset.calculate_depreciation and not self._restore_scheduled_depreciation(asset, d.debit):
|
||||
self._restore_finance_book_value(asset, d.debit)
|
||||
|
||||
asset.db_set("value_after_depreciation", asset.value_after_depreciation + d.debit)
|
||||
asset.set_status()
|
||||
asset.set_total_booked_depreciations()
|
||||
|
||||
def _restore_scheduled_depreciation(self, asset, debit: float) -> bool:
|
||||
"""Unlink this entry from the depreciation schedule and credit back its finance book.
|
||||
|
||||
Returns True if a matching scheduled depreciation was found.
|
||||
"""
|
||||
for fb_row in asset.get("finance_books"):
|
||||
depr_schedule = get_depr_schedule(asset.name, "Active", fb_row.finance_book)
|
||||
for s in depr_schedule or []:
|
||||
if s.journal_entry == self.doc.name:
|
||||
s.db_set("journal_entry", None)
|
||||
fb_row.value_after_depreciation += debit
|
||||
fb_row.db_update()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _restore_finance_book_value(self, asset, debit: float) -> None:
|
||||
"""Credit the depreciation amount back to the relevant finance book when no schedule matched."""
|
||||
fb_idx = 1
|
||||
if self.doc.finance_book:
|
||||
for fb_row in asset.get("finance_books"):
|
||||
if fb_row.finance_book == self.doc.finance_book:
|
||||
fb_idx = fb_row.idx
|
||||
break
|
||||
|
||||
fb_row = asset.get("finance_books")[fb_idx - 1]
|
||||
fb_row.value_after_depreciation += debit
|
||||
fb_row.db_update()
|
||||
|
||||
def _block_scrap_journal_cancel(self, d) -> None:
|
||||
"""Prevent cancelling a plain Journal Entry that is an asset's scrap voucher."""
|
||||
journal_entry_for_scrap = frappe.db.get_value("Asset", d.reference_name, "journal_entry_for_scrap")
|
||||
if journal_entry_for_scrap == self.doc.name:
|
||||
frappe.throw(
|
||||
_("Journal Entry for Asset scrapping cannot be cancelled. Please restore the Asset.")
|
||||
)
|
||||
|
||||
def unlink_asset_adjustment_entry(self) -> None:
|
||||
"""Detach this entry from any Asset Value Adjustment that referenced it."""
|
||||
AssetValueAdjustment = frappe.qb.DocType("Asset Value Adjustment")
|
||||
(
|
||||
frappe.qb.update(AssetValueAdjustment)
|
||||
.set(AssetValueAdjustment.journal_entry, None)
|
||||
.where(AssetValueAdjustment.journal_entry == self.doc.name)
|
||||
).run()
|
||||
@@ -18,86 +18,88 @@ class JournalEntryGLComposer(BaseGLComposer):
|
||||
from the first foreign-currency row (mirroring the former build_gl_map).
|
||||
"""
|
||||
|
||||
def compose(self):
|
||||
doc = self.doc
|
||||
gl_map = []
|
||||
|
||||
company_currency = erpnext.get_company_currency(doc.company)
|
||||
doc.transaction_currency = company_currency
|
||||
doc.transaction_exchange_rate = 1
|
||||
if doc.multi_currency:
|
||||
for row in doc.get("accounts"):
|
||||
if row.account_currency != company_currency:
|
||||
# Journal assumes the first foreign currency as transaction currency
|
||||
doc.transaction_currency = row.account_currency
|
||||
doc.transaction_exchange_rate = row.exchange_rate
|
||||
break
|
||||
|
||||
def compose(self) -> list:
|
||||
"""Project the Journal Entry's non-zero account rows into GL dicts."""
|
||||
self._set_transaction_currency()
|
||||
advance_doctypes = get_advance_payment_doctypes()
|
||||
|
||||
for d in doc.get("accounts"):
|
||||
if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
|
||||
r = [d.user_remark, doc.remark]
|
||||
r = [x for x in r if x]
|
||||
remarks = "\n".join(r)
|
||||
|
||||
row = {
|
||||
"account": d.account,
|
||||
"party_type": d.party_type,
|
||||
"due_date": doc.due_date,
|
||||
"party": d.party,
|
||||
"against": d.against_account,
|
||||
"debit": flt(d.debit, d.precision("debit")),
|
||||
"credit": flt(d.credit, d.precision("credit")),
|
||||
"account_currency": d.account_currency,
|
||||
"debit_in_account_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
),
|
||||
"credit_in_account_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
),
|
||||
"transaction_currency": doc.transaction_currency,
|
||||
"transaction_exchange_rate": doc.transaction_exchange_rate,
|
||||
"debit_in_transaction_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
|
||||
"credit_in_transaction_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
|
||||
"against_voucher_type": d.reference_type,
|
||||
"against_voucher": d.reference_name,
|
||||
"remarks": remarks,
|
||||
"voucher_detail_no": d.reference_detail_no,
|
||||
"cost_center": d.cost_center,
|
||||
"project": d.project,
|
||||
"finance_book": doc.finance_book,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
}
|
||||
|
||||
if d.reference_type in advance_doctypes:
|
||||
row.update(
|
||||
{
|
||||
"against_voucher_type": doc.doctype,
|
||||
"against_voucher": doc.name,
|
||||
"advance_voucher_type": d.reference_type,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
# set flag to skip party validation
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
|
||||
frappe.flags.party_not_required = True
|
||||
|
||||
gl_map.append(
|
||||
self.get_gl_dict(
|
||||
row,
|
||||
item=d,
|
||||
)
|
||||
)
|
||||
gl_map = []
|
||||
for d in self.doc.get("accounts"):
|
||||
if d.debit or d.credit or self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
gl_map.append(self.get_gl_dict(self._gl_row(d, advance_doctypes), item=d))
|
||||
return gl_map
|
||||
|
||||
def _set_transaction_currency(self) -> None:
|
||||
"""Company currency, or the first foreign-currency row, becomes the transaction currency."""
|
||||
doc = self.doc
|
||||
doc.transaction_currency = erpnext.get_company_currency(doc.company)
|
||||
doc.transaction_exchange_rate = 1
|
||||
if not doc.multi_currency:
|
||||
return
|
||||
|
||||
for row in doc.get("accounts"):
|
||||
if row.account_currency != doc.transaction_currency:
|
||||
# Journal assumes the first foreign currency as transaction currency
|
||||
doc.transaction_currency = row.account_currency
|
||||
doc.transaction_exchange_rate = row.exchange_rate
|
||||
break
|
||||
|
||||
def _gl_row(self, d, advance_doctypes: list) -> dict:
|
||||
"""Build the GL dict for a single account row."""
|
||||
doc = self.doc
|
||||
remarks = "\n".join(x for x in [d.user_remark, doc.remark] if x)
|
||||
|
||||
row = {
|
||||
"account": d.account,
|
||||
"party_type": d.party_type,
|
||||
"due_date": doc.due_date,
|
||||
"party": d.party,
|
||||
"against": d.against_account,
|
||||
"debit": flt(d.debit, d.precision("debit")),
|
||||
"credit": flt(d.credit, d.precision("credit")),
|
||||
"account_currency": d.account_currency,
|
||||
"debit_in_account_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
),
|
||||
"credit_in_account_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
),
|
||||
"transaction_currency": doc.transaction_currency,
|
||||
"transaction_exchange_rate": doc.transaction_exchange_rate,
|
||||
"debit_in_transaction_currency": flt(
|
||||
d.debit_in_account_currency, d.precision("debit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
|
||||
"credit_in_transaction_currency": flt(
|
||||
d.credit_in_account_currency, d.precision("credit_in_account_currency")
|
||||
)
|
||||
if doc.transaction_currency == d.account_currency
|
||||
else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
|
||||
"against_voucher_type": d.reference_type,
|
||||
"against_voucher": d.reference_name,
|
||||
"remarks": remarks,
|
||||
"voucher_detail_no": d.reference_detail_no,
|
||||
"cost_center": d.cost_center,
|
||||
"project": d.project,
|
||||
"finance_book": doc.finance_book,
|
||||
"advance_voucher_type": d.advance_voucher_type,
|
||||
"advance_voucher_no": d.advance_voucher_no,
|
||||
}
|
||||
|
||||
if d.reference_type in advance_doctypes:
|
||||
row.update(
|
||||
{
|
||||
"against_voucher_type": doc.doctype,
|
||||
"against_voucher": doc.name,
|
||||
"advance_voucher_type": d.reference_type,
|
||||
"advance_voucher_no": d.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
# set flag to skip party validation
|
||||
account_type = frappe.get_cached_value("Account", d.account, "account_type")
|
||||
if account_type in ["Receivable", "Payable"] and doc.party_not_required:
|
||||
frappe.flags.party_not_required = True
|
||||
|
||||
return row
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _, scrub
|
||||
from frappe.utils import cstr, flt, fmt_money
|
||||
|
||||
from erpnext.accounts.deferred_revenue import get_deferred_booking_accounts
|
||||
from erpnext.accounts.doctype.invoice_discounting.invoice_discounting import (
|
||||
get_party_account_based_on_invoice_discounting,
|
||||
)
|
||||
from erpnext.accounts.utils import get_account_currency
|
||||
|
||||
REFERENCE_PARTY_ACCOUNT_FIELDS = {
|
||||
"Sales Invoice": ["Customer", "Debit To"],
|
||||
"Purchase Invoice": ["Supplier", "Credit To"],
|
||||
"Sales Order": ["Customer"],
|
||||
"Purchase Order": ["Supplier"],
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryReferenceValidator:
|
||||
"""Validates Journal Entry account rows against their referenced documents.
|
||||
|
||||
For each row that links a Sales/Purchase Invoice or Order, this checks the
|
||||
debit/credit direction, party and account match, and aggregates per-reference
|
||||
totals (held on the document as ``reference_totals``/``reference_types``/
|
||||
``reference_accounts``) which are then validated against the referenced
|
||||
orders and invoices.
|
||||
"""
|
||||
|
||||
def __init__(self, doc) -> None:
|
||||
self.doc = doc
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate every reference-bearing row, then the referenced orders and invoices."""
|
||||
self.doc.reference_totals = {}
|
||||
self.doc.reference_types = {}
|
||||
self.doc.reference_accounts = {}
|
||||
for row in self.doc.get("accounts"):
|
||||
self._normalize_reference_fields(row)
|
||||
if not self._has_party_reference(row):
|
||||
continue
|
||||
self._validate_order_direction(row)
|
||||
self._register_reference(row)
|
||||
self._validate_reference_party_and_account(row)
|
||||
|
||||
self._validate_orders()
|
||||
self._validate_invoices()
|
||||
|
||||
def _normalize_reference_fields(self, row) -> None:
|
||||
if not row.reference_type:
|
||||
row.reference_name = None
|
||||
if not row.reference_name:
|
||||
row.reference_type = None
|
||||
|
||||
def _has_party_reference(self, row) -> bool:
|
||||
return bool(
|
||||
row.reference_type and row.reference_name and row.reference_type in REFERENCE_PARTY_ACCOUNT_FIELDS
|
||||
)
|
||||
|
||||
def _reference_amount_field(self, row) -> str:
|
||||
if row.reference_type in ("Sales Order", "Sales Invoice"):
|
||||
return "credit_in_account_currency"
|
||||
return "debit_in_account_currency"
|
||||
|
||||
def _validate_order_direction(self, row) -> None:
|
||||
"""An order can only be linked on the side that records an advance."""
|
||||
if row.reference_type == "Sales Order" and flt(row.debit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Debit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
if row.reference_type == "Purchase Order" and flt(row.credit) > 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Credit entry can not be linked with a {1}").format(row.idx, row.reference_type)
|
||||
)
|
||||
|
||||
def _register_reference(self, row) -> None:
|
||||
"""Aggregate the row's amount, type and account onto the per-reference lookups."""
|
||||
if row.reference_name not in self.doc.reference_totals:
|
||||
self.doc.reference_totals[row.reference_name] = 0.0
|
||||
if self.doc.voucher_type not in ("Deferred Revenue", "Deferred Expense"):
|
||||
self.doc.reference_totals[row.reference_name] += flt(row.get(self._reference_amount_field(row)))
|
||||
self.doc.reference_types[row.reference_name] = row.reference_type
|
||||
self.doc.reference_accounts[row.reference_name] = row.account
|
||||
|
||||
def _validate_reference_party_and_account(self, row) -> None:
|
||||
"""Reject a missing reference, then check party/account against the linked document."""
|
||||
party_fields = REFERENCE_PARTY_ACCOUNT_FIELDS[row.reference_type]
|
||||
against_voucher = frappe.db.get_value(
|
||||
row.reference_type, row.reference_name, [scrub(f) for f in party_fields]
|
||||
)
|
||||
if not against_voucher:
|
||||
frappe.throw(_("Row {0}: Invalid reference {1}").format(row.idx, row.reference_name))
|
||||
|
||||
if row.reference_type in ("Sales Invoice", "Purchase Invoice"):
|
||||
self._validate_invoice_party_and_account(row, against_voucher, party_fields)
|
||||
elif row.reference_type in ("Sales Order", "Purchase Order"):
|
||||
self._validate_order_party(row, against_voucher)
|
||||
|
||||
def _validate_invoice_party_and_account(self, row, against_voucher, party_fields) -> None:
|
||||
party_account, against_party = self._resolve_invoice_party_account(row, against_voucher)
|
||||
if self.doc.voucher_type == "Exchange Gain Or Loss":
|
||||
return
|
||||
if against_party != cstr(row.party) or party_account != row.account:
|
||||
frappe.throw(
|
||||
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
|
||||
row.idx, party_fields[0], party_fields[1], row.reference_type, row.reference_name
|
||||
)
|
||||
)
|
||||
|
||||
def _resolve_invoice_party_account(self, row, against_voucher) -> tuple:
|
||||
"""Expected (party_account, party) for an invoice row, honouring deferred booking
|
||||
and invoice-discounting accounts."""
|
||||
if self.doc.voucher_type in ("Deferred Revenue", "Deferred Expense") and row.reference_detail_no:
|
||||
debit_or_credit = "Debit" if row.debit else "Credit"
|
||||
party_account = get_deferred_booking_accounts(
|
||||
row.reference_type, row.reference_detail_no, debit_or_credit
|
||||
)
|
||||
return party_account, ""
|
||||
if row.reference_type == "Sales Invoice":
|
||||
party_account = (
|
||||
get_party_account_based_on_invoice_discounting(row.reference_name) or against_voucher[1]
|
||||
)
|
||||
else:
|
||||
party_account = against_voucher[1]
|
||||
return party_account, against_voucher[0]
|
||||
|
||||
def _validate_order_party(self, row, against_voucher) -> None:
|
||||
if against_voucher != row.party:
|
||||
frappe.throw(
|
||||
_("Row {0}: {1} {2} does not match with {3}").format(
|
||||
row.idx, row.party_type, row.party, row.reference_type
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_orders(self) -> None:
|
||||
"""Validate totals, closed and docstatus for referenced orders."""
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
account = self.doc.reference_accounts[reference_name]
|
||||
if reference_type not in ("Sales Order", "Purchase Order"):
|
||||
continue
|
||||
|
||||
order = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_order_status(order, reference_type, reference_name)
|
||||
self._validate_order_advance_total(order, account, total, reference_type, reference_name)
|
||||
|
||||
def _validate_order_status(self, order, reference_type, reference_name) -> None:
|
||||
if order.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
if flt(order.per_billed) >= 100:
|
||||
frappe.throw(_("{0} {1} is fully billed").format(reference_type, reference_name))
|
||||
if cstr(order.status) == "Closed":
|
||||
frappe.throw(_("{0} {1} is closed").format(reference_type, reference_name))
|
||||
|
||||
def _validate_order_advance_total(self, order, account, total, reference_type, reference_name) -> None:
|
||||
"""The advance paid against an order cannot exceed its grand total."""
|
||||
account_currency = get_account_currency(account)
|
||||
if account_currency == self.doc.company_currency:
|
||||
voucher_total = order.base_grand_total
|
||||
field = "base_grand_total"
|
||||
else:
|
||||
voucher_total = order.grand_total
|
||||
field = "grand_total"
|
||||
|
||||
if flt(voucher_total) < (flt(order.advance_paid) + total):
|
||||
formatted_voucher_total = fmt_money(
|
||||
voucher_total, order.precision(field), currency=account_currency
|
||||
)
|
||||
frappe.throw(
|
||||
_("Advance paid against {0} {1} cannot be greater than Grand Total {2}").format(
|
||||
reference_type, reference_name, formatted_voucher_total
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_invoices(self) -> None:
|
||||
"""Validate totals and docstatus for referenced invoices."""
|
||||
if self.doc.voucher_type in ("Debit Note", "Credit Note"):
|
||||
return
|
||||
for reference_name, total in self.doc.reference_totals.items():
|
||||
reference_type = self.doc.reference_types[reference_name]
|
||||
if reference_type not in ("Sales Invoice", "Purchase Invoice"):
|
||||
continue
|
||||
invoice = frappe.get_doc(reference_type, reference_name)
|
||||
self._validate_invoice_outstanding(invoice, total, reference_type, reference_name)
|
||||
|
||||
def _validate_invoice_outstanding(self, invoice, total, reference_type, reference_name) -> None:
|
||||
"""Payment booked against an invoice cannot exceed its outstanding amount."""
|
||||
if invoice.docstatus != 1:
|
||||
frappe.throw(_("{0} {1} is not submitted").format(reference_type, reference_name))
|
||||
|
||||
precision = invoice.precision("outstanding_amount")
|
||||
if total and flt(invoice.outstanding_amount, precision) < flt(total, precision):
|
||||
frappe.throw(
|
||||
_("Payment against {0} {1} cannot be greater than Outstanding Amount {2}").format(
|
||||
reference_type, reference_name, invoice.outstanding_amount
|
||||
)
|
||||
)
|
||||
@@ -169,8 +169,11 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency",
|
||||
"credit",
|
||||
"credit_in_account_currency",
|
||||
"debit_in_transaction_currency",
|
||||
"credit_in_transaction_currency",
|
||||
]
|
||||
|
||||
# Transaction currency is USD (first foreign row); the INR row is converted at 1/50.
|
||||
self.expected_gle = [
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
@@ -179,6 +182,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency": 0,
|
||||
"credit": 5000,
|
||||
"credit_in_account_currency": 5000,
|
||||
"debit_in_transaction_currency": 0,
|
||||
"credit_in_transaction_currency": 100,
|
||||
},
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
@@ -187,6 +192,8 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
"debit_in_account_currency": 100,
|
||||
"credit": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"debit_in_transaction_currency": 100,
|
||||
"credit_in_transaction_currency": 0,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -203,8 +210,54 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
|
||||
self.assertFalse(gle)
|
||||
|
||||
def test_multi_currency_transaction_currency_on_foreign_debit(self):
|
||||
"""Pin debit_in_transaction_currency for a foreign-currency debit row.
|
||||
|
||||
Transaction currency is USD (the first foreign row); the INR debit row must be
|
||||
converted at 1/exchange_rate, so 5000 INR -> 100 USD. Guards the / vs * direction.
|
||||
"""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.multi_currency = 1
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"credit_in_account_currency": 100,
|
||||
"exchange_rate": 50,
|
||||
},
|
||||
)
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"cost_center": "_Test Cost Center - _TC",
|
||||
"debit_in_account_currency": 5000,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
jv.submit()
|
||||
|
||||
self.voucher_no = jv.name
|
||||
self.fields = ["account", "debit_in_transaction_currency", "credit_in_transaction_currency"]
|
||||
self.expected_gle = [
|
||||
{
|
||||
"account": "_Test Bank - _TC",
|
||||
"debit_in_transaction_currency": 100,
|
||||
"credit_in_transaction_currency": 0,
|
||||
},
|
||||
{
|
||||
"account": "_Test Bank USD - _TC",
|
||||
"debit_in_transaction_currency": 0,
|
||||
"credit_in_transaction_currency": 100,
|
||||
},
|
||||
]
|
||||
self.check_gl_entries()
|
||||
|
||||
def test_reverse_journal_entry(self):
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||
|
||||
jv = make_journal_entry("_Test Bank USD - _TC", "Sales - _TC", 100, exchange_rate=50, save=False)
|
||||
|
||||
@@ -609,6 +662,181 @@ class TestJournalEntry(ERPNextTestSuite):
|
||||
jv.save()
|
||||
self.assertRaises(frappe.ValidationError, jv.submit)
|
||||
|
||||
def test_party_not_allowed_for_non_receivable_payable_account(self):
|
||||
customer = make_customer("_Test New Customer")
|
||||
jv = make_journal_entry(account1="_Test Cash - _TC", account2="_Test Bank - _TC", amount=100, save=False)
|
||||
jv.accounts[0].party_type = "Customer"
|
||||
jv.accounts[0].party = customer
|
||||
self.assertRaises(frappe.ValidationError, jv.save)
|
||||
|
||||
def test_validate_reference_doc_debit_against_sales_order_throws(self):
|
||||
"""Characterize: a debit entry linked to a Sales Order is rejected."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
jv = make_journal_entry("Debtors - _TC", "_Test Cash - _TC", 100, save=False)
|
||||
jv.accounts[0].party_type = "Customer"
|
||||
jv.accounts[0].party = "_Test Customer"
|
||||
jv.accounts[0].reference_type = "Sales Order"
|
||||
jv.accounts[0].reference_name = sales_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Debit entry can not be linked", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_credit_against_purchase_order_throws(self):
|
||||
"""Characterize: a credit entry linked to a Purchase Order is rejected."""
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
|
||||
purchase_order = create_purchase_order()
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Creditors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Supplier"
|
||||
jv.accounts[1].party = "_Test Supplier"
|
||||
jv.accounts[1].reference_type = "Purchase Order"
|
||||
jv.accounts[1].reference_name = purchase_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Credit entry can not be linked", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_nonexistent_reference_rejected(self):
|
||||
"""Characterize: a JE referencing a non-existent invoice is rejected by link validation.
|
||||
|
||||
Note: the controller's own "Invalid reference" branch is unreachable in normal flow
|
||||
because Frappe link validation rejects the missing reference before validate_reference_doc.
|
||||
"""
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = "NON-EXISTENT-SI"
|
||||
self.assertRaises(frappe.LinkValidationError, jv.insert)
|
||||
|
||||
def test_validate_reference_doc_invoice_party_mismatch_throws(self):
|
||||
"""Characterize: an invoice reference whose party differs from the row party is rejected."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = other_customer
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Party / Account does not match", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_order_party_mismatch_throws(self):
|
||||
"""Characterize: a Sales Order reference whose party differs from the row party is rejected."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
other_customer = make_customer("_Test JE Mismatch Customer")
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = other_customer
|
||||
jv.accounts[1].is_advance = "Yes"
|
||||
jv.accounts[1].reference_type = "Sales Order"
|
||||
jv.accounts[1].reference_name = sales_order.name
|
||||
self.assertRaisesRegex(frappe.ValidationError, "does not match", jv.insert)
|
||||
|
||||
def test_validate_reference_doc_populates_reference_side_effects(self):
|
||||
"""Characterize: a valid invoice reference populates reference_totals/types/accounts."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=500)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
jv.accounts[1].party_type = "Customer"
|
||||
jv.accounts[1].party = "_Test Customer"
|
||||
jv.accounts[1].reference_type = "Sales Invoice"
|
||||
jv.accounts[1].reference_name = invoice.name
|
||||
jv.insert()
|
||||
self.assertEqual(jv.reference_totals[invoice.name], 100.0)
|
||||
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
|
||||
self.assertEqual(jv.reference_accounts[invoice.name], "Debtors - _TC")
|
||||
|
||||
def test_get_balance_places_difference_on_blank_row(self):
|
||||
"""Characterize: get_balance puts the unbalanced difference on an amountless row."""
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.append(
|
||||
"accounts",
|
||||
{
|
||||
"account": "_Test Cash - _TC",
|
||||
"debit_in_account_currency": 100,
|
||||
"debit": 100,
|
||||
"exchange_rate": 1,
|
||||
},
|
||||
)
|
||||
jv.append("accounts", {"account": "_Test Bank - _TC", "exchange_rate": 1}) # amountless row
|
||||
jv.set_total_debit_credit()
|
||||
self.assertEqual(jv.difference, 100)
|
||||
|
||||
jv.get_balance()
|
||||
blank_row = jv.accounts[1]
|
||||
self.assertEqual(blank_row.credit_in_account_currency, 100)
|
||||
self.assertEqual(jv.total_debit, jv.total_credit)
|
||||
|
||||
def test_get_outstanding_invoices_builds_write_off_rows(self):
|
||||
"""Characterize: get_outstanding_invoices adds a party row for each outstanding invoice."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=700)
|
||||
jv = frappe.new_doc("Journal Entry")
|
||||
jv.company = "_Test Company"
|
||||
jv.posting_date = nowdate()
|
||||
jv.voucher_type = "Write Off Entry"
|
||||
jv.write_off_based_on = "Accounts Receivable"
|
||||
jv.write_off_amount = 1000
|
||||
jv.get_outstanding_invoices()
|
||||
|
||||
invoice_rows = [row for row in jv.accounts if row.reference_name == invoice.name]
|
||||
self.assertTrue(invoice_rows)
|
||||
self.assertEqual(invoice_rows[0].party_type, "Customer")
|
||||
self.assertEqual(invoice_rows[0].reference_type, "Sales Invoice")
|
||||
self.assertEqual(flt(invoice_rows[0].credit_in_account_currency), 700)
|
||||
|
||||
def test_unlink_advance_entry_reference_on_cancel(self):
|
||||
"""Characterize: cancelling an advance JE against an invoice clears the row's reference."""
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
invoice = create_sales_invoice(rate=700)
|
||||
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
|
||||
advance_row = jv.accounts[1]
|
||||
advance_row.party_type = "Customer"
|
||||
advance_row.party = "_Test Customer"
|
||||
advance_row.is_advance = "Yes"
|
||||
advance_row.reference_type = "Sales Invoice"
|
||||
advance_row.reference_name = invoice.name
|
||||
jv.submit()
|
||||
|
||||
jv.cancel()
|
||||
jv.reload()
|
||||
self.assertFalse(jv.accounts[1].reference_type)
|
||||
self.assertFalse(jv.accounts[1].reference_name)
|
||||
|
||||
def test_get_payment_entry_against_order_builds_advance_je(self):
|
||||
"""Characterize the mapper: an advance Bank Entry JE is built against an unbilled order."""
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import get_payment_entry_against_order
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order()
|
||||
je = get_payment_entry_against_order("Sales Order", sales_order.name, journal_entry=True)
|
||||
|
||||
self.assertEqual(je.voucher_type, "Bank Entry")
|
||||
party_rows = [row for row in je.accounts if row.party_type == "Customer"]
|
||||
self.assertTrue(party_rows)
|
||||
self.assertEqual(party_rows[0].reference_type, "Sales Order")
|
||||
self.assertEqual(party_rows[0].reference_name, sales_order.name)
|
||||
self.assertEqual(party_rows[0].is_advance, "Yes")
|
||||
|
||||
def test_make_inter_company_journal_entry_builds_linked_draft(self):
|
||||
"""Characterize the mapper: the counterpart JE carries the company and back-reference."""
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_inter_company_journal_entry
|
||||
|
||||
source = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
|
||||
result = make_inter_company_journal_entry(
|
||||
source.name, "Inter Company Journal Entry", "_Test Company 1"
|
||||
)
|
||||
|
||||
self.assertEqual(result.get("voucher_type"), "Inter Company Journal Entry")
|
||||
self.assertEqual(result.get("company"), "_Test Company 1")
|
||||
self.assertEqual(result.get("inter_company_journal_entry_reference"), source.name)
|
||||
|
||||
|
||||
def make_journal_entry(
|
||||
account1,
|
||||
|
||||
@@ -2036,6 +2036,9 @@ def get_outstanding_reference_documents(args: str | dict, validate: bool = False
|
||||
if args.get("party_type") == "Member":
|
||||
return
|
||||
|
||||
if args.get("party_type") and args.get("party"):
|
||||
frappe.has_permission(args["party_type"], "read", args["party"], throw=True)
|
||||
|
||||
if not args.get("get_outstanding_invoices") and not args.get("get_orders_to_be_billed"):
|
||||
args["get_outstanding_invoices"] = True
|
||||
|
||||
@@ -2531,6 +2534,7 @@ def get_reference_details(
|
||||
):
|
||||
total_amount = outstanding_amount = exchange_rate = account = None
|
||||
|
||||
frappe.has_permission(reference_doctype, "read", reference_name, throw=True)
|
||||
ref_doc = frappe.get_lazy_doc(reference_doctype, reference_name)
|
||||
company_currency = ref_doc.get("company_currency") or erpnext.get_company_currency(ref_doc.company)
|
||||
|
||||
@@ -2776,7 +2780,7 @@ def get_payment_entry(
|
||||
pe, doc, discount_amount, base_total_discount_loss, party_account_currency
|
||||
)
|
||||
|
||||
pe.set_exchange_rate(ref_doc=doc)
|
||||
pe.set_exchange_rate()
|
||||
pe.set_amounts()
|
||||
|
||||
# If PE is created from PR directly, then no need to find open PRs for the references
|
||||
|
||||
@@ -532,6 +532,8 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
si.submit()
|
||||
|
||||
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC", bank_amount=4700)
|
||||
pe.source_exchange_rate = 50
|
||||
pe.set_amounts()
|
||||
pe.reference_no = si.name
|
||||
pe.reference_date = nowdate()
|
||||
|
||||
@@ -607,6 +609,8 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
pe = get_payment_entry(
|
||||
"Sales Invoice", si.name, party_amount=20, bank_account="_Test Bank - _TC", bank_amount=900
|
||||
)
|
||||
pe.source_exchange_rate = 50
|
||||
pe.set_amounts()
|
||||
pe.reference_no = "1"
|
||||
pe.reference_date = "2016-01-01"
|
||||
|
||||
|
||||
@@ -11,11 +11,12 @@ from erpnext import get_company_currency
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.doctype.bank_account.bank_account import get_party_bank_account
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import (
|
||||
get_payment_entry,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.accounts.party import get_party_account, get_party_bank_account
|
||||
from erpnext.accounts.party import get_party_account
|
||||
from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes, get_currency_precision
|
||||
from erpnext.utilities import payment_app_import_guard
|
||||
|
||||
|
||||
@@ -332,7 +332,12 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
return_doc=1,
|
||||
)
|
||||
|
||||
pe = pr.set_as_paid()
|
||||
pe = pr.create_payment_entry(submit=False)
|
||||
pe.source_exchange_rate = 50
|
||||
pe.target_exchange_rate = 50
|
||||
pe.set_amounts()
|
||||
pe.insert(ignore_permissions=True)
|
||||
pe.submit()
|
||||
|
||||
expected_gle = dict(
|
||||
(d[0], d)
|
||||
@@ -418,7 +423,12 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com")
|
||||
pr = frappe.get_doc(pr).save().submit()
|
||||
|
||||
pe = pr.create_payment_entry()
|
||||
pe = pr.create_payment_entry(submit=False)
|
||||
pe.target_exchange_rate = 80
|
||||
pe.paid_amount = 800
|
||||
pe.set_amounts()
|
||||
pe.insert(ignore_permissions=True)
|
||||
pe.submit()
|
||||
self.assertEqual(pe.base_paid_amount, 800)
|
||||
self.assertEqual(pe.paid_amount, 800)
|
||||
self.assertEqual(pe.base_received_amount, 800)
|
||||
|
||||
@@ -21,6 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyServi
|
||||
from erpnext.accounts.party import get_due_date, get_party_account
|
||||
from erpnext.controllers.queries import item_query as _item_query
|
||||
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
|
||||
from erpnext.selling.doctype.product_bundle.product_bundle import get_active_product_bundle
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.stock_ledger import is_negative_stock_allowed
|
||||
|
||||
@@ -403,7 +404,7 @@ class POSInvoice(SalesInvoice):
|
||||
|
||||
for d in self.get("items"):
|
||||
if not d.serial_and_batch_bundle:
|
||||
if frappe.db.exists("Product Bundle", d.item_code):
|
||||
if get_active_product_bundle(d.item_code):
|
||||
(
|
||||
availability,
|
||||
is_stock_item,
|
||||
@@ -916,7 +917,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
return bin_qty - pos_sales_qty, is_stock_item, is_negative_stock_allowed(item_code=item_code)
|
||||
else:
|
||||
is_stock_item = True
|
||||
if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}):
|
||||
if get_active_product_bundle(item_code):
|
||||
return get_bundle_availability(item_code, warehouse), is_stock_item, False
|
||||
else:
|
||||
is_stock_item = False
|
||||
@@ -926,7 +927,7 @@ def get_stock_availability(item_code: str | None, warehouse: str):
|
||||
|
||||
def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
is_stock_item = True
|
||||
bundle = frappe.get_doc("Product Bundle", item_code)
|
||||
bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(item_code))
|
||||
availabilities = []
|
||||
for bundle_item in bundle.items:
|
||||
if frappe.get_value("Item", bundle_item.item_code, "is_stock_item"):
|
||||
@@ -945,7 +946,7 @@ def get_product_bundle_stock_availability(item_code, warehouse, item_qty):
|
||||
|
||||
|
||||
def get_bundle_availability(bundle_item_code, warehouse):
|
||||
product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)
|
||||
product_bundle = frappe.get_doc("Product Bundle", get_active_product_bundle(bundle_item_code))
|
||||
|
||||
bundle_bin_qty = 1000000
|
||||
for item in product_bundle.items:
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -125,6 +127,23 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -858,7 +877,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-20 16:16:12.322024",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice Item",
|
||||
|
||||
@@ -315,32 +315,3 @@ def pos_profile_query(doctype: str, txt: str, searchfield: str, start: int, page
|
||||
)
|
||||
|
||||
return pos_profile
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_default_profile(pos_profile: str, company: str):
|
||||
modified = now()
|
||||
user = frappe.session.user
|
||||
|
||||
if pos_profile and company:
|
||||
frappe.db.sql(
|
||||
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
set
|
||||
pfu.default = 0, pf.modified = %s, pf.modified_by = %s
|
||||
where
|
||||
pfu.user = %s and pf.name = pfu.parent and pf.company = %s
|
||||
and pfu.default = 1""",
|
||||
(modified, user, user, company),
|
||||
auto_commit=1,
|
||||
)
|
||||
|
||||
frappe.db.sql(
|
||||
""" update `tabPOS Profile User` pfu, `tabPOS Profile` pf
|
||||
set
|
||||
pfu.default = 1, pf.modified = %s, pf.modified_by = %s
|
||||
where
|
||||
pfu.user = %s and pf.name = pfu.parent and pf.company = %s and pf.name = %s
|
||||
""",
|
||||
(modified, user, user, company, pos_profile),
|
||||
auto_commit=1,
|
||||
)
|
||||
|
||||
@@ -131,6 +131,7 @@ def is_job_running(job_name: str) -> bool:
|
||||
@frappe.whitelist()
|
||||
def pause_job_for_doc(docname: str | None = None):
|
||||
if docname:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Paused")
|
||||
log = frappe.db.get_value("Process Payment Reconciliation Log", filters={"process_pr": docname})
|
||||
if log:
|
||||
@@ -145,6 +146,8 @@ def trigger_job_for_doc(docname: str | None = None):
|
||||
if not docname:
|
||||
return
|
||||
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "auto_reconcile_payments"):
|
||||
frappe.throw(
|
||||
_("Auto Reconciliation of Payments has been disabled. Enable it through {0}").format(
|
||||
|
||||
@@ -92,6 +92,7 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Payment Reconciliation", "write", doc=docname, throw=True)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
@@ -101,6 +101,7 @@ class ProcessStatementOfAccounts(Document):
|
||||
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -521,6 +522,7 @@ def download_statements(document_name: str):
|
||||
@frappe.whitelist()
|
||||
def send_emails(document_name: str, from_scheduler: bool = False, posting_date: str | None = None):
|
||||
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
|
||||
doc.check_permission()
|
||||
report = get_report_pdf(doc, consolidated=False)
|
||||
|
||||
if report:
|
||||
@@ -577,6 +579,7 @@ def send_emails(document_name: str, from_scheduler: bool = False, posting_date:
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_auto_email():
|
||||
frappe.has_permission("Process Statement Of Accounts", throw=True)
|
||||
selected = frappe.get_list(
|
||||
"Process Statement Of Accounts",
|
||||
filters={"enable_auto_email": 1},
|
||||
|
||||
@@ -614,10 +614,12 @@
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.items.every((item) => !item.pr_detail)",
|
||||
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Purchase Receipt is created separately.",
|
||||
"fieldname": "update_stock",
|
||||
"fieldtype": "Check",
|
||||
"label": "Update Stock",
|
||||
"print_hide": 1
|
||||
"print_hide": 1,
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "scan_barcode",
|
||||
@@ -1690,7 +1692,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:36:55.215363",
|
||||
"modified": "2026-06-13 18:36:46.704623",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -653,6 +653,9 @@ class PurchaseInvoice(BuyingController):
|
||||
|
||||
self.process_common_party_accounting()
|
||||
|
||||
if self.is_return:
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def on_update_after_submit(self):
|
||||
fields_to_check = [
|
||||
"cash_bank_account",
|
||||
@@ -772,6 +775,8 @@ class PurchaseInvoice(BuyingController):
|
||||
"Tax Withholding Entry",
|
||||
)
|
||||
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def update_project(self):
|
||||
projects = frappe._dict()
|
||||
for d in self.items:
|
||||
@@ -934,9 +939,9 @@ def make_regional_gl_entries(gl_entries, doc):
|
||||
|
||||
@frappe.whitelist()
|
||||
def change_release_date(name: str, release_date: str | None = None):
|
||||
if frappe.db.exists("Purchase Invoice", name):
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.db_set("release_date", release_date)
|
||||
pi = frappe.get_lazy_doc("Purchase Invoice", name)
|
||||
pi.check_permission()
|
||||
pi.db_set("release_date", release_date)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -51,16 +51,6 @@ class ExpenseAccountService:
|
||||
if doc.update_stock and item.warehouse and (not item.from_warehouse):
|
||||
_inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
|
||||
|
||||
if for_validate and item.expense_account and item.expense_account != _inv_dict["account"]:
|
||||
msg = _(
|
||||
"Row {0}: Expense Head changed to {1} because account {2} is not linked to warehouse {3} or it is not the default inventory account"
|
||||
).format(
|
||||
item.idx,
|
||||
frappe.bold(_inv_dict["account"]),
|
||||
frappe.bold(item.expense_account),
|
||||
frappe.bold(item.warehouse),
|
||||
)
|
||||
frappe.msgprint(msg, title=_("Expense Head Changed"))
|
||||
item.expense_account = _inv_dict["account"]
|
||||
else:
|
||||
# check if 'Stock Received But Not Billed' account is credited in Purchase receipt or not
|
||||
|
||||
@@ -886,8 +886,10 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
@@ -1008,7 +1010,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-06 08:08:40.782395",
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice Item",
|
||||
|
||||
@@ -158,6 +158,7 @@ def start_repost(account_repost_doc: str | None = None) -> None:
|
||||
frappe.flags.through_repost_accounting_ledger = True
|
||||
if account_repost_doc:
|
||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
|
||||
repost_doc.check_permission("write")
|
||||
|
||||
if repost_doc.docstatus == 1:
|
||||
# Prevent repost on invoices with deferred accounting
|
||||
|
||||
@@ -327,7 +327,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"rate": "rate",
|
||||
},
|
||||
"postprocess": update_item,
|
||||
"condition": lambda doc: doc.qty > 0,
|
||||
"condition": lambda doc: doc.qty - received_items.get(doc.name, 0.0) > 0,
|
||||
}
|
||||
|
||||
if doctype in ["Sales Invoice", "Sales Order"]:
|
||||
@@ -367,11 +367,19 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
target_doc,
|
||||
set_missing_values,
|
||||
)
|
||||
if not doclist.get("items"):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot create Intercompany {0}. All items in the source {1} have already been fully invoiced. "
|
||||
"Please check the existing linked {2}s."
|
||||
).format(target_doctype, doctype, target_doctype)
|
||||
)
|
||||
|
||||
return doclist
|
||||
|
||||
|
||||
def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
@frappe.whitelist()
|
||||
def get_received_items(reference_name: str, doctype: str, reference_fieldname: str):
|
||||
reference_field = "inter_company_invoice_reference"
|
||||
if doctype == "Purchase Order":
|
||||
reference_field = "inter_company_order_reference"
|
||||
@@ -384,20 +392,19 @@ def get_received_items(reference_name, doctype, reference_fieldname):
|
||||
target_doctypes = frappe.get_all(
|
||||
doctype,
|
||||
filters=filters,
|
||||
as_list=True,
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
received_items_map = {}
|
||||
if target_doctypes:
|
||||
target_doctypes = list(target_doctypes[0])
|
||||
|
||||
received_items_map = frappe._dict(
|
||||
frappe.get_all(
|
||||
received_items_data = frappe.get_all(
|
||||
doctype + " Item",
|
||||
filters={"parent": ("in", target_doctypes)},
|
||||
fields=[reference_fieldname, "qty"],
|
||||
as_list=1,
|
||||
)
|
||||
)
|
||||
for item in received_items_data:
|
||||
key = item.get(reference_fieldname)
|
||||
if key:
|
||||
received_items_map[key] = received_items_map.get(key, 0.0) + flt(item.qty)
|
||||
|
||||
return received_items_map
|
||||
|
||||
|
||||
@@ -179,12 +179,31 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
: "Inter Company Purchase Invoice";
|
||||
|
||||
me.frm.add_custom_button(
|
||||
button_label,
|
||||
__(button_label),
|
||||
function () {
|
||||
me.make_inter_company_invoice();
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.mapper.get_received_items",
|
||||
args: {
|
||||
reference_name: me.frm.doc.name,
|
||||
doctype: "Purchase Invoice",
|
||||
reference_fieldname: "sales_invoice_item",
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.exc) return;
|
||||
const received_items = r.message || {};
|
||||
const has_pending_qty = me.frm.doc.items.some(
|
||||
(item) => flt(item.qty) - flt(received_items[item.name] || 0) > 0
|
||||
);
|
||||
if (!has_pending_qty) {
|
||||
me.frm.remove_custom_button(__(button_label), __("Create"));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,6 +586,8 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
|
||||
set_dynamic_labels() {
|
||||
super.set_dynamic_labels();
|
||||
this.frm.events.hide_fields(this.frm);
|
||||
const hide_update_stock = cint(this.frm.doc.is_debit_note) || cint(this.frm.doc.has_subcontracted);
|
||||
this.frm.set_df_property("update_stock", "hidden", hide_update_stock);
|
||||
}
|
||||
|
||||
items_on_form_rendered() {
|
||||
@@ -1155,13 +1176,20 @@ frappe.ui.form.on("Sales Invoice", {
|
||||
);
|
||||
},
|
||||
|
||||
is_debit_note: function (frm) {
|
||||
if (frm.doc.is_debit_note) {
|
||||
frm.set_value("update_stock", 0);
|
||||
}
|
||||
// visibility handled by set_dynamic_labels()
|
||||
frm.cscript.set_dynamic_labels();
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.is_debit_note) {
|
||||
frm.set_df_property("return_against", "label", __("Adjustment Against"));
|
||||
}
|
||||
|
||||
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
|
||||
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -715,6 +715,7 @@
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.items.every((item) => !item.dn_detail)",
|
||||
"description": "If checked, updates inventory; stock and accounting entries are created together. Leave unchecked if a Delivery Note is created separately.",
|
||||
"fieldname": "update_stock",
|
||||
"fieldtype": "Check",
|
||||
"hide_days": 1,
|
||||
@@ -722,7 +723,8 @@
|
||||
"label": "Update Stock",
|
||||
"oldfieldname": "update_stock",
|
||||
"oldfieldtype": "Check",
|
||||
"print_hide": 1
|
||||
"print_hide": 1,
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "scan_barcode",
|
||||
|
||||
@@ -304,6 +304,7 @@ class SalesInvoice(SellingController):
|
||||
self.validate_uom_is_integer("uom", "qty")
|
||||
self.check_sales_order_on_hold_or_close("sales_order")
|
||||
self.validate_debit_to_acc()
|
||||
self.validate_debit_note_with_update_stock()
|
||||
self.clear_unallocated_advances("Sales Invoice Advance", "advances")
|
||||
FixedAssetService(self).validate_fixed_asset()
|
||||
FixedAssetService(self).set_income_account_for_fixed_assets()
|
||||
@@ -411,8 +412,8 @@ class SalesInvoice(SellingController):
|
||||
validate_account_head(item.idx, item.income_account, self.company, _("Income"))
|
||||
|
||||
def before_save(self):
|
||||
POSService(self).update_paid_amount()
|
||||
POSService(self).set_account_for_mode_of_payment()
|
||||
POSService(self).set_paid_amount()
|
||||
|
||||
def before_submit(self):
|
||||
self.add_remarks()
|
||||
@@ -497,6 +498,9 @@ class SalesInvoice(SellingController):
|
||||
self.process_common_party_accounting()
|
||||
self.update_billed_qty_in_scio()
|
||||
|
||||
if self.is_return:
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def before_cancel(self):
|
||||
POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated()
|
||||
POSService(self).check_if_consolidated_invoice()
|
||||
@@ -584,6 +588,7 @@ class SalesInvoice(SellingController):
|
||||
POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
|
||||
|
||||
self.update_billed_qty_in_scio()
|
||||
self.refresh_subscription_status()
|
||||
|
||||
def update_status_updater_args(self):
|
||||
if not cint(self.update_stock):
|
||||
@@ -956,6 +961,17 @@ class SalesInvoice(SellingController):
|
||||
if flt(self.change_amount) and not self.account_for_change_amount:
|
||||
msgprint(_("Please enter Account for Change Amount"), raise_exception=1)
|
||||
|
||||
def validate_debit_note_with_update_stock(self):
|
||||
"""Prevent stock update when Sales Invoice is marked as Debit Note."""
|
||||
if self.is_debit_note and cint(self.update_stock):
|
||||
frappe.throw(
|
||||
_(
|
||||
"You cannot update stock for a Debit Note. A Debit Note is a financial "
|
||||
"document that should not affect inventory. Please disable 'Update Stock'."
|
||||
),
|
||||
title=_("Invalid Configuration"),
|
||||
)
|
||||
|
||||
def validate_dropship_item(self):
|
||||
"""If items are drop shipped, stock cannot be updated."""
|
||||
if not cint(self.update_stock):
|
||||
|
||||
@@ -114,10 +114,17 @@ class POSService:
|
||||
|
||||
return pos
|
||||
|
||||
def set_paid_amount(self) -> None:
|
||||
def update_paid_amount(self) -> None:
|
||||
doc = self.doc
|
||||
paid_amount = 0.0
|
||||
base_paid_amount = 0.0
|
||||
|
||||
if not cint(doc.is_pos) and doc.is_return:
|
||||
doc.set("payments", [])
|
||||
doc.paid_amount = paid_amount
|
||||
doc.base_paid_amount = base_paid_amount
|
||||
return
|
||||
|
||||
for data in doc.payments:
|
||||
data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount"))
|
||||
paid_amount += data.amount
|
||||
|
||||
@@ -2918,6 +2918,67 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
self.assertEqual(target_doc.company, "_Test Company 1")
|
||||
self.assertEqual(target_doc.supplier, "_Test Internal Supplier")
|
||||
|
||||
def test_restrict_inter_company_pi_when_sales_invoice_qty_fully_consumed(self):
|
||||
item_code_1 = "_Test IC Item 1"
|
||||
item_code_2 = "_Test IC Item 2"
|
||||
|
||||
create_item(item_code_1, is_stock_item=1)
|
||||
create_item(item_code_2, is_stock_item=1)
|
||||
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
item_code=item_code_1,
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
qty=3,
|
||||
do_not_save=1,
|
||||
)
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code_2,
|
||||
"item_name": item_code_2,
|
||||
"description": item_code_2,
|
||||
"warehouse": "Stores - WP",
|
||||
"qty": 2,
|
||||
"uom": "Nos",
|
||||
"stock_uom": "Nos",
|
||||
"rate": 100,
|
||||
"price_list_rate": 100,
|
||||
"income_account": "Sales - WP",
|
||||
"expense_account": "Cost of Goods Sold - WP",
|
||||
"cost_center": "Main - WP",
|
||||
"conversion_factor": 1,
|
||||
},
|
||||
)
|
||||
|
||||
si.submit()
|
||||
|
||||
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
for item in target_doc.items:
|
||||
item.update(
|
||||
{
|
||||
"expense_account": "Cost of Goods Sold - _TC1",
|
||||
"cost_center": "Main - _TC1",
|
||||
}
|
||||
)
|
||||
|
||||
target_doc.submit()
|
||||
self.assertEqual(len(target_doc.items), 2)
|
||||
self.assertEqual([item.qty for item in target_doc.items], [3, 2])
|
||||
with self.assertRaisesRegex(
|
||||
frappe.ValidationError,
|
||||
"already been fully invoiced",
|
||||
):
|
||||
make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
def test_inter_company_transaction_does_not_inherit_party_fields(self):
|
||||
"""
|
||||
Party-derived fields on SI (from Customer) must not leak into the mapped PI.
|
||||
@@ -5152,6 +5213,13 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
|
||||
|
||||
def test_debit_note_with_update_stock_validation(self):
|
||||
"""Test that saving a Debit Note with Update Stock enabled raises ValidationError."""
|
||||
si = create_sales_invoice(do_not_save=True)
|
||||
si.is_debit_note = 1
|
||||
si.update_stock = 1
|
||||
self.assertRaises(frappe.ValidationError, si.save)
|
||||
|
||||
|
||||
def make_item_for_si(item_code, properties=None):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"barcode",
|
||||
"has_item_scanned",
|
||||
"item_code",
|
||||
"is_product_bundle",
|
||||
"product_bundle",
|
||||
"col_break1",
|
||||
"item_name",
|
||||
"customer_item_code",
|
||||
@@ -144,6 +146,23 @@
|
||||
"options": "Item",
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "is_product_bundle",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Is Product Bundle",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.is_product_bundle",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only_depends_on": "eval:doc.so_detail || doc.dn_detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "col_break1",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -1036,7 +1055,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-03 13:17:36.145788",
|
||||
"modified": "2026-06-08 20:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -29,7 +29,13 @@ frappe.ui.form.on("Subscription", {
|
||||
},
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.is_new()) return;
|
||||
if (frm.is_new()) {
|
||||
// The field wrapper is reused across docs; clear any stale heatmap.
|
||||
frm.get_field("billing_heatmap").$wrapper.empty();
|
||||
return;
|
||||
}
|
||||
|
||||
frm.trigger("render_billing_heatmap");
|
||||
|
||||
if (frm.doc.status !== "Cancelled") {
|
||||
frm.add_custom_button(
|
||||
@@ -95,4 +101,88 @@ frappe.ui.form.on("Subscription", {
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
render_billing_heatmap: function (frm) {
|
||||
frm.call("get_billing_heatmap").then((r) => {
|
||||
if (!r.message || !r.message.length) return;
|
||||
render_heatmap(frm.get_field("billing_heatmap").$wrapper, r.message, frm.doc);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Status -> colour and label for the calendar heatmap. Keys are Title-case to
|
||||
// match the value frappe-charts shows in its hover tooltip.
|
||||
const HEATMAP_COLORS = {
|
||||
Paid: "#39d353",
|
||||
Unpaid: "#388bfd",
|
||||
Overdue: "#f0883e",
|
||||
Cancelled: "#f85149",
|
||||
Refunded: "#a371f7",
|
||||
Planned: "#87ceeb",
|
||||
};
|
||||
|
||||
// Days inside the window but outside the subscription's active span stay faded.
|
||||
const EMPTY_COLOR = "#ebedf0";
|
||||
|
||||
function title_case(status) {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
}
|
||||
|
||||
function render_heatmap($wrapper, days, doc) {
|
||||
const data_points = {};
|
||||
days.forEach((day) => {
|
||||
data_points[day.date] = title_case(day.status);
|
||||
});
|
||||
|
||||
$wrapper.empty();
|
||||
const chart_el = $('<div class="subscription-billing-heatmap"></div>').appendTo($wrapper)[0];
|
||||
|
||||
new frappe.Chart(chart_el, {
|
||||
type: "heatmap",
|
||||
data: {
|
||||
dataPoints: data_points,
|
||||
start: new Date(days[0].date),
|
||||
end: new Date(days[days.length - 1].date),
|
||||
},
|
||||
discreteDomains: 1,
|
||||
showLegend: 0,
|
||||
// frappe-charts only does an intensity scale; we recolour each square by
|
||||
// its own status below, so the scale colours are placeholders.
|
||||
colors: ["#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0", "#ebedf0"],
|
||||
});
|
||||
|
||||
// Paint every day square with its status colour (data-value holds the status).
|
||||
// The chart re-renders once for its entry animation, so repaint on each redraw.
|
||||
const within_subscription = (date) =>
|
||||
(!doc.start_date || date >= doc.start_date) && (!doc.end_date || date <= doc.end_date);
|
||||
|
||||
const paint = () =>
|
||||
chart_el.querySelectorAll("[data-date]").forEach((square) => {
|
||||
const status = square.getAttribute("data-value");
|
||||
if (status === "Planned" && !within_subscription(square.getAttribute("data-date"))) {
|
||||
// Outside the subscription's span: render blank and drop the status so the
|
||||
// hover tooltip shows only the date, not "Planned".
|
||||
square.setAttribute("fill", EMPTY_COLOR);
|
||||
square.setAttribute("data-value", "");
|
||||
return;
|
||||
}
|
||||
square.setAttribute("fill", HEATMAP_COLORS[status] || EMPTY_COLOR);
|
||||
});
|
||||
|
||||
paint();
|
||||
new MutationObserver(paint).observe(chart_el, { childList: true, subtree: true });
|
||||
|
||||
const legend = Object.keys(HEATMAP_COLORS)
|
||||
.map(
|
||||
(status) =>
|
||||
`<span style="display:inline-flex;align-items:center;gap:4px;margin-right:12px;">
|
||||
<span style="width:11px;height:11px;border-radius:2px;background:${HEATMAP_COLORS[status]};"></span>
|
||||
${__(status)}
|
||||
</span>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
$(`<div style="margin-top:8px;font-size:11px;color:var(--text-muted);">${legend}</div>`).appendTo(
|
||||
$wrapper
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"billing_history_section",
|
||||
"billing_heatmap",
|
||||
"section_break_jznv",
|
||||
"party_type",
|
||||
"party",
|
||||
"cb_1",
|
||||
@@ -21,12 +24,16 @@
|
||||
"generate_new_invoices_past_due_date",
|
||||
"submit_invoice",
|
||||
"column_break_11",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"days_until_due",
|
||||
"generate_invoice_at",
|
||||
"number_of_days",
|
||||
"cancel_at_period_end",
|
||||
"billing_period_section",
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"billing_period_cb",
|
||||
"next_billing_period_start",
|
||||
"next_billing_period_end",
|
||||
"sb_4",
|
||||
"plans",
|
||||
"sb_1",
|
||||
@@ -51,7 +58,7 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted",
|
||||
"options": "\nTrialing\nActive\nGrace Period\nCancelled\nUnpaid\nCompleted\nRefunded",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -83,17 +90,40 @@
|
||||
"fieldname": "column_break_11",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_period_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing Period"
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice Start Date",
|
||||
"label": "Current Invoice Start",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice End Date",
|
||||
"label": "Current Invoice End",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_period_cb",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "next_billing_period_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Next Billing Period Start",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "next_billing_period_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Next Billing Period End",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -108,7 +138,18 @@
|
||||
"default": "0",
|
||||
"fieldname": "cancel_at_period_end",
|
||||
"fieldtype": "Check",
|
||||
"label": "Cancel At End Of Period"
|
||||
"label": "Cancel When Period Ends"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.__islocal",
|
||||
"fieldname": "billing_history_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Billing History"
|
||||
},
|
||||
{
|
||||
"fieldname": "billing_heatmap",
|
||||
"fieldtype": "HTML",
|
||||
"label": "Billing Heatmap"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
@@ -206,7 +247,7 @@
|
||||
"description": "New invoices will be generated as per schedule even if current invoices are unpaid or past due date",
|
||||
"fieldname": "generate_new_invoices_past_due_date",
|
||||
"fieldtype": "Check",
|
||||
"label": "Generate New Invoices Past Due Date"
|
||||
"label": "Bill Even If Previous Invoice Unpaid"
|
||||
},
|
||||
{
|
||||
"fieldname": "end_date",
|
||||
@@ -239,19 +280,23 @@
|
||||
"label": "Submit Generated Invoices"
|
||||
},
|
||||
{
|
||||
"default": "End of the current subscription period",
|
||||
"default": "Postpaid (bill at period end)",
|
||||
"fieldname": "generate_invoice_at",
|
||||
"fieldtype": "Select",
|
||||
"label": "Generate Invoice At",
|
||||
"options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period",
|
||||
"options": "Postpaid (bill at period end)\nPrepaid (bill at period start)\nBill N days before period start",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\"",
|
||||
"fieldname": "number_of_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Number of Days",
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Bill N days before period start\""
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jznv",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@@ -267,11 +312,11 @@
|
||||
"link_fieldname": "subscription"
|
||||
}
|
||||
],
|
||||
"modified": "2025-12-23 19:42:52.036034",
|
||||
"modified": "2026-06-04 07:21:15.938170",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscription",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ from frappe.utils.data import (
|
||||
cint,
|
||||
date_diff,
|
||||
flt,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
get_link_to_form,
|
||||
getdate,
|
||||
@@ -35,6 +36,24 @@ class InvoiceNotCancelled(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
GENERATE_AT_END = "Postpaid (bill at period end)"
|
||||
GENERATE_AT_BEGINNING = "Prepaid (bill at period start)"
|
||||
GENERATE_AT_DAYS_BEFORE = "Bill N days before period start"
|
||||
|
||||
STATUS_TRIALING = "Trialing"
|
||||
STATUS_ACTIVE = "Active"
|
||||
STATUS_GRACE_PERIOD = "Grace Period"
|
||||
STATUS_CANCELLED = "Cancelled"
|
||||
STATUS_UNPAID = "Unpaid"
|
||||
STATUS_COMPLETED = "Completed"
|
||||
STATUS_REFUNDED = "Refunded"
|
||||
|
||||
PARTY_CUSTOMER = "Customer"
|
||||
PARTY_SUPPLIER = "Supplier"
|
||||
|
||||
INVOICE_PAID = "Paid"
|
||||
|
||||
|
||||
DateTimeLikeObject = str | date
|
||||
|
||||
|
||||
@@ -64,11 +83,13 @@ class Subscription(Document):
|
||||
end_date: DF.Date | None
|
||||
follow_calendar_months: DF.Check
|
||||
generate_invoice_at: DF.Literal[
|
||||
"End of the current subscription period",
|
||||
"Beginning of the current subscription period",
|
||||
"Days before the current subscription period",
|
||||
"Postpaid (bill at period end)",
|
||||
"Prepaid (bill at period start)",
|
||||
"Bill N days before period start",
|
||||
]
|
||||
generate_new_invoices_past_due_date: DF.Check
|
||||
next_billing_period_end: DF.Date | None
|
||||
next_billing_period_start: DF.Date | None
|
||||
number_of_days: DF.Int
|
||||
party: DF.DynamicLink
|
||||
party_type: DF.Link
|
||||
@@ -76,7 +97,9 @@ class Subscription(Document):
|
||||
purchase_tax_template: DF.Link | None
|
||||
sales_tax_template: DF.Link | None
|
||||
start_date: DF.Date | None
|
||||
status: DF.Literal["", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed"]
|
||||
status: DF.Literal[
|
||||
"", "Trialing", "Active", "Grace Period", "Cancelled", "Unpaid", "Completed", "Refunded"
|
||||
]
|
||||
submit_invoice: DF.Check
|
||||
trial_period_end: DF.Date | None
|
||||
trial_period_start: DF.Date | None
|
||||
@@ -103,38 +126,39 @@ class Subscription(Document):
|
||||
or an outstanding invoice blocks billing (per `generate_new_invoices_past_due_date`).
|
||||
"""
|
||||
while getdate(self._next_invoice_trigger_date()) <= getdate(nowdate()):
|
||||
period_start = self.current_invoice_start
|
||||
period_start = self.next_billing_period_start
|
||||
self.process(posting_date=self._next_invoice_trigger_date())
|
||||
|
||||
if self.status == "Cancelled" or getdate(self.current_invoice_start) == getdate(period_start):
|
||||
if self.status == STATUS_CANCELLED or getdate(self.next_billing_period_start) == getdate(
|
||||
period_start
|
||||
):
|
||||
break
|
||||
|
||||
if not self.generate_new_invoices_past_due_date:
|
||||
break
|
||||
|
||||
def _next_invoice_trigger_date(self) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
return self.current_invoice_start
|
||||
if self.generate_invoice_at == "Days before the current subscription period":
|
||||
return add_days(self.current_invoice_start, -self.number_of_days)
|
||||
return self.current_invoice_end
|
||||
return self._invoice_date_for_period(self.next_billing_period_start, self.next_billing_period_end)
|
||||
|
||||
def _invoice_date_for_period(
|
||||
self, period_start: DateTimeLikeObject, period_end: DateTimeLikeObject
|
||||
) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
return period_start
|
||||
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
return add_days(period_start, -self.number_of_days)
|
||||
return period_end
|
||||
|
||||
def update_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
"""
|
||||
Subscription period is the period to be billed. This method updates the
|
||||
beginning of the billing period and end of the billing period.
|
||||
The beginning of the billing period is represented in the doctype as
|
||||
`current_invoice_start` and the end of the billing period is represented
|
||||
as `current_invoice_end`.
|
||||
`next_billing_period_start` and the end of the billing period is represented
|
||||
as `next_billing_period_end`.
|
||||
"""
|
||||
self.current_invoice_start = self.get_current_invoice_start(date)
|
||||
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
|
||||
|
||||
def _get_subscription_period(self, date: DateTimeLikeObject | None = None):
|
||||
_current_invoice_start = self.get_current_invoice_start(date)
|
||||
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
|
||||
|
||||
return _current_invoice_start, _current_invoice_end
|
||||
self.next_billing_period_start = self.get_current_invoice_start(date)
|
||||
self.next_billing_period_end = self.get_current_invoice_end(self.next_billing_period_start)
|
||||
|
||||
def get_current_invoice_start(self, date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||
"""
|
||||
@@ -175,7 +199,7 @@ class Subscription(Document):
|
||||
_current_invoice_end = add_to_date(self.start_date, **billing_cycle_info)
|
||||
|
||||
# For cases where trial period is for an entire billing interval
|
||||
if getdate(self.current_invoice_end) < getdate(date):
|
||||
if getdate(self.next_billing_period_end) < getdate(date):
|
||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||
else:
|
||||
_current_invoice_end = add_to_date(date, **billing_cycle_info)
|
||||
@@ -253,21 +277,35 @@ class Subscription(Document):
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
self._set_current_invoice_dates()
|
||||
if self.is_trialling():
|
||||
self.status = "Trialing"
|
||||
self.status = STATUS_TRIALING
|
||||
elif self.is_fully_refunded() and self.has_outstanding_invoice():
|
||||
self.status = STATUS_REFUNDED
|
||||
elif (
|
||||
not self.has_outstanding_invoice()
|
||||
and self.end_date
|
||||
and getdate(posting_date) > getdate(self.end_date)
|
||||
):
|
||||
self.status = "Completed"
|
||||
self.status = STATUS_COMPLETED
|
||||
elif self.is_past_grace_period():
|
||||
self.status = self.get_status_for_past_grace_period()
|
||||
self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
|
||||
self.cancelation_date = getdate(posting_date) if self.status == STATUS_CANCELLED else None
|
||||
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
|
||||
self.status = "Grace Period"
|
||||
self.status = STATUS_GRACE_PERIOD
|
||||
elif not self.has_outstanding_invoice():
|
||||
self.status = "Active"
|
||||
self.status = STATUS_ACTIVE
|
||||
|
||||
def _set_current_invoice_dates(self) -> None:
|
||||
invoice = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||
fields=["from_date", "to_date"],
|
||||
order_by="to_date desc",
|
||||
limit=1,
|
||||
)
|
||||
self.current_invoice_start = invoice[0].from_date if invoice else None
|
||||
self.current_invoice_end = invoice[0].to_date if invoice else None
|
||||
|
||||
def is_trialling(self) -> bool:
|
||||
"""
|
||||
@@ -282,7 +320,6 @@ class Subscription(Document):
|
||||
"""
|
||||
Returns true if the given `end_date` has passed
|
||||
"""
|
||||
# todo: test for illegal time
|
||||
if not end_date:
|
||||
return True
|
||||
|
||||
@@ -290,10 +327,10 @@ class Subscription(Document):
|
||||
|
||||
def get_status_for_past_grace_period(self) -> str:
|
||||
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
|
||||
status = "Unpaid"
|
||||
status = STATUS_UNPAID
|
||||
|
||||
if cancel_after_grace:
|
||||
status = "Cancelled"
|
||||
status = STATUS_CANCELLED
|
||||
|
||||
return status
|
||||
|
||||
@@ -321,7 +358,7 @@ class Subscription(Document):
|
||||
|
||||
@property
|
||||
def invoice_document_type(self) -> str:
|
||||
return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
|
||||
return "Sales Invoice" if self.party_type == PARTY_CUSTOMER else "Purchase Invoice"
|
||||
|
||||
def validate(self) -> None:
|
||||
self.validate_trial_period()
|
||||
@@ -413,11 +450,7 @@ class Subscription(Document):
|
||||
to_date: DateTimeLikeObject | None = None,
|
||||
posting_date: DateTimeLikeObject | None = None,
|
||||
) -> Document:
|
||||
"""
|
||||
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
|
||||
saves the `Subscription`.
|
||||
Backwards compatibility
|
||||
"""
|
||||
"""Public alias for `create_invoice`; kept for external integrations."""
|
||||
return self.create_invoice(from_date=from_date, to_date=to_date, posting_date=posting_date)
|
||||
|
||||
def create_invoice(
|
||||
@@ -429,8 +462,19 @@ class Subscription(Document):
|
||||
"""
|
||||
Creates a `Invoice`, submits it and returns it
|
||||
"""
|
||||
# For backward compatibility
|
||||
# Earlier subscription didn't had any company field
|
||||
company = self._resolve_company()
|
||||
invoice = self._init_invoice_doc(company, posting_date)
|
||||
self._set_invoice_party(invoice)
|
||||
self._set_invoice_currency(invoice)
|
||||
self._apply_accounting_dimensions(invoice)
|
||||
self._append_invoice_items(invoice)
|
||||
self._apply_taxes(invoice)
|
||||
self._apply_payment_schedule(invoice)
|
||||
self._apply_discounts(invoice)
|
||||
return self._finalize_invoice(invoice, from_date, to_date)
|
||||
|
||||
def _resolve_company(self) -> str:
|
||||
# Earlier subscriptions didn't have a company field
|
||||
company = self.get("company") or get_default_company()
|
||||
if not company:
|
||||
frappe.throw(
|
||||
@@ -438,48 +482,49 @@ class Subscription(Document):
|
||||
"Company is mandatory for generating an invoice. Please set a default company in Global Defaults."
|
||||
)
|
||||
)
|
||||
return company
|
||||
|
||||
def _init_invoice_doc(self, company: str, posting_date: DateTimeLikeObject | None = None) -> Document:
|
||||
invoice = frappe.new_doc(self.invoice_document_type)
|
||||
invoice.company = company
|
||||
invoice.set_posting_time = 1
|
||||
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
invoice.posting_date = self.current_invoice_start
|
||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
||||
invoice.posting_date = posting_date or self.current_invoice_start
|
||||
else:
|
||||
invoice.posting_date = self.current_invoice_end
|
||||
|
||||
invoice.posting_date = self._invoice_posting_date(posting_date)
|
||||
invoice.cost_center = self.cost_center
|
||||
return invoice
|
||||
|
||||
def _invoice_posting_date(self, posting_date: DateTimeLikeObject | None = None) -> DateTimeLikeObject:
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
return self.next_billing_period_start
|
||||
if self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
return posting_date or self.next_billing_period_start
|
||||
return self.next_billing_period_end
|
||||
|
||||
def _set_invoice_party(self, invoice: Document) -> None:
|
||||
if self.invoice_document_type == "Sales Invoice":
|
||||
invoice.customer = self.party
|
||||
else:
|
||||
invoice.supplier = self.party
|
||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||
)
|
||||
if tax_withholding_category or tax_withholding_group:
|
||||
invoice.apply_tds = 1
|
||||
return
|
||||
|
||||
# Add currency to invoice
|
||||
invoice.supplier = self.party
|
||||
tax_withholding_category, tax_withholding_group = frappe.get_cached_value(
|
||||
"Supplier", self.party, ["tax_withholding_category", "tax_withholding_group"]
|
||||
)
|
||||
if tax_withholding_category or tax_withholding_group:
|
||||
invoice.apply_tds = 1
|
||||
|
||||
def _set_invoice_currency(self, invoice: Document) -> None:
|
||||
invoice.currency = frappe.db.get_value("Subscription Plan", {"name": self.plans[0].plan}, "currency")
|
||||
|
||||
# Add dimensions in invoice for subscription:
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
|
||||
for dimension in accounting_dimensions:
|
||||
def _apply_accounting_dimensions(self, invoice: Document) -> None:
|
||||
for dimension in get_accounting_dimensions():
|
||||
if self.get(dimension):
|
||||
invoice.update({dimension: self.get(dimension)})
|
||||
|
||||
# Subscription is better suited for service items. I won't update `update_stock`
|
||||
# for that reason
|
||||
items_list = self.get_items_from_plans(self.plans, is_prorate())
|
||||
|
||||
for item in items_list:
|
||||
def _append_invoice_items(self, invoice: Document) -> None:
|
||||
# Subscription is better suited for service items, so `update_stock` is left untouched
|
||||
for item in self.get_items_from_plans(self.plans, is_prorate()):
|
||||
invoice.append("items", item)
|
||||
|
||||
# Taxes
|
||||
def _apply_taxes(self, invoice: Document) -> None:
|
||||
tax_template = ""
|
||||
|
||||
if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
|
||||
@@ -493,37 +538,43 @@ class Subscription(Document):
|
||||
invoice.taxes_and_charges = tax_template
|
||||
TaxService(invoice).set_taxes()
|
||||
|
||||
# Due date
|
||||
if self.days_until_due:
|
||||
invoice.append(
|
||||
"payment_schedule",
|
||||
{
|
||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||
"invoice_portion": 100,
|
||||
},
|
||||
)
|
||||
def _apply_payment_schedule(self, invoice: Document) -> None:
|
||||
if not self.days_until_due:
|
||||
return
|
||||
|
||||
# Discounts
|
||||
invoice.append(
|
||||
"payment_schedule",
|
||||
{
|
||||
"due_date": add_days(invoice.posting_date, cint(self.days_until_due)),
|
||||
"invoice_portion": 100,
|
||||
},
|
||||
)
|
||||
|
||||
def _apply_discounts(self, invoice: Document) -> None:
|
||||
if self.is_trialling():
|
||||
invoice.additional_discount_percentage = 100
|
||||
else:
|
||||
if self.additional_discount_percentage:
|
||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||
return
|
||||
|
||||
if self.additional_discount_amount:
|
||||
invoice.discount_amount = self.additional_discount_amount
|
||||
if self.additional_discount_percentage:
|
||||
invoice.additional_discount_percentage = self.additional_discount_percentage
|
||||
|
||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||
discount_on = self.apply_additional_discount
|
||||
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
|
||||
if self.additional_discount_amount:
|
||||
invoice.discount_amount = self.additional_discount_amount
|
||||
|
||||
# Subscription period
|
||||
if self.additional_discount_percentage or self.additional_discount_amount:
|
||||
invoice.apply_discount_on = self.apply_additional_discount or "Grand Total"
|
||||
|
||||
def _finalize_invoice(
|
||||
self,
|
||||
invoice: Document,
|
||||
from_date: DateTimeLikeObject | None = None,
|
||||
to_date: DateTimeLikeObject | None = None,
|
||||
) -> Document:
|
||||
invoice.subscription = self.name
|
||||
invoice.from_date = from_date or self.current_invoice_start
|
||||
invoice.to_date = to_date or self.current_invoice_end
|
||||
invoice.from_date = from_date or self.next_billing_period_start
|
||||
invoice.to_date = to_date or self.next_billing_period_end
|
||||
|
||||
invoice.flags.ignore_mandatory = True
|
||||
|
||||
invoice.set_missing_values()
|
||||
invoice.save()
|
||||
|
||||
@@ -540,15 +591,9 @@ class Subscription(Document):
|
||||
prorate_factor = 1
|
||||
if prorate:
|
||||
prorate_factor = get_prorata_factor(
|
||||
self.current_invoice_end,
|
||||
self.current_invoice_start,
|
||||
cint(
|
||||
self.generate_invoice_at
|
||||
in [
|
||||
"Beginning of the current subscription period",
|
||||
"Days before the current subscription period",
|
||||
]
|
||||
),
|
||||
self.next_billing_period_end,
|
||||
self.next_billing_period_start,
|
||||
cint(self.generate_invoice_at in [GENERATE_AT_BEGINNING, GENERATE_AT_DAYS_BEFORE]),
|
||||
)
|
||||
|
||||
items = []
|
||||
@@ -558,7 +603,7 @@ class Subscription(Document):
|
||||
|
||||
item_code = plan_doc.item
|
||||
|
||||
if self.party_type == "Customer":
|
||||
if self.party_type == PARTY_CUSTOMER:
|
||||
deferred_field = "enable_deferred_revenue"
|
||||
else:
|
||||
deferred_field = "enable_deferred_expense"
|
||||
@@ -572,8 +617,8 @@ class Subscription(Document):
|
||||
plan.plan,
|
||||
plan.qty,
|
||||
party,
|
||||
self.current_invoice_start,
|
||||
self.current_invoice_end,
|
||||
self.next_billing_period_start,
|
||||
self.next_billing_period_end,
|
||||
prorate_factor,
|
||||
),
|
||||
"cost_center": plan_doc.cost_center,
|
||||
@@ -583,8 +628,8 @@ class Subscription(Document):
|
||||
item.update(
|
||||
{
|
||||
deferred_field: deferred,
|
||||
"service_start_date": self.current_invoice_start,
|
||||
"service_end_date": self.current_invoice_end,
|
||||
"service_start_date": self.next_billing_period_start,
|
||||
"service_end_date": self.next_billing_period_end,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -607,11 +652,11 @@ class Subscription(Document):
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
if not self.is_current_invoice_generated(
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
self.next_billing_period_start, self.next_billing_period_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
self.generate_invoice(posting_date=posting_date)
|
||||
if self.end_date:
|
||||
next_start = add_days(self.current_invoice_end, 1)
|
||||
next_start = add_days(self.next_billing_period_end, 1)
|
||||
|
||||
if getdate(next_start) > getdate(self.end_date):
|
||||
if self.cancel_at_period_end:
|
||||
@@ -621,12 +666,12 @@ class Subscription(Document):
|
||||
|
||||
self.save()
|
||||
return
|
||||
self.update_subscription_period(add_days(self.current_invoice_end, 1))
|
||||
elif posting_date and getdate(posting_date) > getdate(self.current_invoice_end):
|
||||
self.update_subscription_period(add_days(self.next_billing_period_end, 1))
|
||||
elif posting_date and getdate(posting_date) > getdate(self.next_billing_period_end):
|
||||
self.update_subscription_period()
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
getdate(posting_date) >= getdate(self.next_billing_period_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
):
|
||||
self.cancel_subscription()
|
||||
@@ -652,9 +697,9 @@ class Subscription(Document):
|
||||
# multi-year gap doesn't retroactively bill cycle after cycle in one call.
|
||||
billing_cycle_info = self.get_billing_cycle_data()
|
||||
if billing_cycle_info:
|
||||
upper = getdate(add_to_date(self.current_invoice_end, **billing_cycle_info))
|
||||
upper = getdate(add_to_date(self.next_billing_period_end, **billing_cycle_info))
|
||||
else:
|
||||
upper = getdate(self.current_invoice_end)
|
||||
upper = getdate(self.next_billing_period_end)
|
||||
|
||||
return posting <= upper
|
||||
|
||||
@@ -664,9 +709,8 @@ class Subscription(Document):
|
||||
_current_end_date: DateTimeLikeObject | None = None,
|
||||
) -> bool:
|
||||
if not (_current_start_date and _current_end_date):
|
||||
_current_start_date, _current_end_date = self._get_subscription_period(
|
||||
date=add_days(self.current_invoice_end, 1)
|
||||
)
|
||||
_current_start_date = self.get_current_invoice_start(add_days(self.next_billing_period_end, 1))
|
||||
_current_end_date = self.get_current_invoice_end(_current_start_date)
|
||||
|
||||
if self.current_invoice and getdate(_current_start_date) <= getdate(
|
||||
self.current_invoice.posting_date
|
||||
@@ -688,7 +732,7 @@ class Subscription(Document):
|
||||
"""
|
||||
invoice = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
{"subscription": self.name, "docstatus": ("<", 2)},
|
||||
{"subscription": self.name, "docstatus": ("<", 2), "is_return": 0},
|
||||
limit=1,
|
||||
order_by="to_date desc",
|
||||
pluck="name",
|
||||
@@ -710,41 +754,70 @@ class Subscription(Document):
|
||||
"""
|
||||
Return `True` if the given invoice is paid
|
||||
"""
|
||||
return invoice.status == "Paid"
|
||||
return invoice.status == INVOICE_PAID
|
||||
|
||||
def has_outstanding_invoice(self) -> int:
|
||||
"""
|
||||
Returns `True` if the most recent invoice for the `Subscription` is not paid
|
||||
Returns the count of submitted, non-return invoices that are not yet paid.
|
||||
"""
|
||||
return frappe.db.count(
|
||||
self.invoice_document_type,
|
||||
{
|
||||
"subscription": self.name,
|
||||
"docstatus": 1,
|
||||
"status": ["!=", "Paid"],
|
||||
"is_return": 0,
|
||||
"status": ["!=", INVOICE_PAID],
|
||||
},
|
||||
)
|
||||
|
||||
def is_fully_refunded(self) -> bool:
|
||||
"""
|
||||
`True` only when every submitted, not-`Paid` invoice on the subscription has
|
||||
credit notes whose absolute total covers its outstanding amount.
|
||||
"""
|
||||
unpaid_invoices = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={
|
||||
"subscription": self.name,
|
||||
"docstatus": 1,
|
||||
"is_return": 0,
|
||||
"status": ["!=", INVOICE_PAID],
|
||||
},
|
||||
fields=["name", "outstanding_amount"],
|
||||
)
|
||||
if not unpaid_invoices:
|
||||
return False
|
||||
|
||||
return all(self._is_invoice_fully_credited(invoice) for invoice in unpaid_invoices)
|
||||
|
||||
def _is_invoice_fully_credited(self, invoice: dict) -> bool:
|
||||
credit_notes = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"return_against": invoice.name, "docstatus": 1},
|
||||
pluck="grand_total",
|
||||
)
|
||||
credited = sum(flt(amount) for amount in credit_notes)
|
||||
return abs(credited) >= flt(invoice.outstanding_amount)
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_subscription(self) -> None:
|
||||
"""
|
||||
This sets the subscription as cancelled. It will stop invoices from being generated
|
||||
but it will not affect already created invoices.
|
||||
"""
|
||||
if self.status == "Cancelled":
|
||||
if self.status == STATUS_CANCELLED:
|
||||
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
|
||||
|
||||
to_generate_invoice = (
|
||||
True
|
||||
if self.status == "Active"
|
||||
and self.generate_invoice_at != "Beginning of the current subscription period"
|
||||
if self.status == STATUS_ACTIVE and self.generate_invoice_at != GENERATE_AT_BEGINNING
|
||||
else False
|
||||
)
|
||||
self.status = "Cancelled"
|
||||
self.status = STATUS_CANCELLED
|
||||
self.cancelation_date = nowdate()
|
||||
|
||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.current_invoice_start):
|
||||
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
|
||||
if to_generate_invoice and getdate(self.cancelation_date) >= getdate(self.next_billing_period_start):
|
||||
self.generate_invoice(self.next_billing_period_start, self.cancelation_date)
|
||||
|
||||
self.save()
|
||||
|
||||
@@ -755,10 +828,10 @@ class Subscription(Document):
|
||||
subscription and the `Subscription` will lose all the history of generated invoices
|
||||
it has.
|
||||
"""
|
||||
if self.status != "Cancelled":
|
||||
if self.status != STATUS_CANCELLED:
|
||||
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
|
||||
|
||||
self.status = "Active"
|
||||
self.status = STATUS_ACTIVE
|
||||
self.cancelation_date = None
|
||||
self.update_subscription_period(posting_date or nowdate())
|
||||
self.save()
|
||||
@@ -766,25 +839,130 @@ class Subscription(Document):
|
||||
@frappe.whitelist()
|
||||
def force_fetch_subscription_updates(self):
|
||||
"""
|
||||
Process Subscription and create Invoices even if current date doesn't lie between current_invoice_start and currenct_invoice_end
|
||||
Process Subscription and create Invoices even if current date doesn't lie between next_billing_period_start and next_billing_period_end
|
||||
It makes use of 'Proces Subscription' to force processing in a specific 'posting_date'
|
||||
"""
|
||||
|
||||
# Don't process future subscriptions
|
||||
if getdate(nowdate()) < getdate(self.current_invoice_start):
|
||||
if getdate(nowdate()) < getdate(self.next_billing_period_start):
|
||||
frappe.msgprint(_("Subscription for Future dates cannot be processed."))
|
||||
return
|
||||
|
||||
processing_date = None
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period":
|
||||
processing_date = self.current_invoice_start
|
||||
elif self.generate_invoice_at == "End of the current subscription period":
|
||||
processing_date = self.current_invoice_end
|
||||
elif self.generate_invoice_at == "Days before the current subscription period":
|
||||
processing_date = add_days(self.current_invoice_start, -self.number_of_days)
|
||||
if self.generate_invoice_at == GENERATE_AT_BEGINNING:
|
||||
processing_date = self.next_billing_period_start
|
||||
elif self.generate_invoice_at == GENERATE_AT_END:
|
||||
processing_date = self.next_billing_period_end
|
||||
elif self.generate_invoice_at == GENERATE_AT_DAYS_BEFORE:
|
||||
processing_date = add_days(self.next_billing_period_start, -self.number_of_days)
|
||||
|
||||
self.process(posting_date=processing_date)
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_billing_heatmap(self) -> list[dict]:
|
||||
"""
|
||||
One cell per calendar day for a fixed 12-month window starting at the first day of
|
||||
the subscription's first month. Each day is coloured by the status of the billing
|
||||
period it falls into; days with no invoice yet are `planned`.
|
||||
"""
|
||||
periods = self._billing_periods()
|
||||
window_start = get_first_day(self.start_date) if self.start_date else get_first_day(nowdate())
|
||||
window_end = get_last_day(add_months(window_start, 11))
|
||||
|
||||
cells = []
|
||||
day = window_start
|
||||
while day <= window_end:
|
||||
cells.append(self._heatmap_cell(day, periods))
|
||||
day = add_days(day, 1)
|
||||
|
||||
return cells
|
||||
|
||||
def _billing_periods(self) -> list[dict]:
|
||||
invoices = frappe.get_all(
|
||||
self.invoice_document_type,
|
||||
filters={"subscription": self.name},
|
||||
fields=[
|
||||
"name",
|
||||
"from_date",
|
||||
"to_date",
|
||||
"status",
|
||||
"due_date",
|
||||
"grand_total",
|
||||
"docstatus",
|
||||
"is_return",
|
||||
"return_against",
|
||||
],
|
||||
order_by="from_date asc",
|
||||
)
|
||||
|
||||
credited = {
|
||||
invoice.return_against
|
||||
for invoice in invoices
|
||||
if invoice.is_return and invoice.docstatus == 1 and invoice.return_against
|
||||
}
|
||||
|
||||
periods = [
|
||||
{
|
||||
"period_start": str(invoice.from_date),
|
||||
"period_end": str(invoice.to_date),
|
||||
"invoice": invoice.name,
|
||||
"amount": flt(invoice.grand_total),
|
||||
"status": self._heatmap_status(invoice, invoice.name in credited),
|
||||
}
|
||||
for invoice in invoices
|
||||
if not invoice.is_return and invoice.from_date and invoice.to_date
|
||||
]
|
||||
|
||||
return [*periods, *self._planned_periods(periods)]
|
||||
|
||||
def _heatmap_status(self, invoice: dict, is_credited: bool) -> str:
|
||||
if invoice.docstatus == 2:
|
||||
return "cancelled"
|
||||
if is_credited:
|
||||
return "refunded"
|
||||
if invoice.status == INVOICE_PAID:
|
||||
return "paid"
|
||||
if invoice.due_date and getdate(invoice.due_date) < getdate(nowdate()):
|
||||
return "overdue"
|
||||
return "unpaid"
|
||||
|
||||
def _planned_periods(self, invoiced_periods: list[dict]) -> list[dict]:
|
||||
invoiced = {(period["period_start"], period["period_end"]) for period in invoiced_periods}
|
||||
planned = []
|
||||
for start, end in self._upcoming_periods():
|
||||
if start and end and (str(start), str(end)) not in invoiced:
|
||||
planned.append(
|
||||
{
|
||||
"period_start": str(start),
|
||||
"period_end": str(end),
|
||||
"invoice": None,
|
||||
"amount": 0.0,
|
||||
"status": "planned",
|
||||
}
|
||||
)
|
||||
return planned
|
||||
|
||||
def _upcoming_periods(self) -> list[tuple]:
|
||||
"""The open billing period and the one immediately after it."""
|
||||
open_period = (self.next_billing_period_start, self.next_billing_period_end)
|
||||
after_start = add_days(self.next_billing_period_end, 1) if self.next_billing_period_end else None
|
||||
after_end = self.get_current_invoice_end(after_start) if after_start else None
|
||||
return [open_period, (after_start, after_end)]
|
||||
|
||||
def _heatmap_cell(self, day: date, periods: list[dict]) -> dict:
|
||||
for period in periods:
|
||||
if getdate(period["period_start"]) <= day <= getdate(period["period_end"]):
|
||||
return {"date": str(day), **period}
|
||||
|
||||
return {
|
||||
"date": str(day),
|
||||
"status": "planned",
|
||||
"invoice": None,
|
||||
"amount": 0.0,
|
||||
"period_start": None,
|
||||
"period_end": None,
|
||||
}
|
||||
|
||||
|
||||
def is_prorate() -> int:
|
||||
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
|
||||
|
||||
@@ -11,6 +11,8 @@ from frappe.utils.data import (
|
||||
date_diff,
|
||||
flt,
|
||||
get_date_str,
|
||||
get_first_day,
|
||||
get_last_day,
|
||||
getdate,
|
||||
nowdate,
|
||||
)
|
||||
@@ -35,11 +37,11 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(subscription.trial_period_start, nowdate())
|
||||
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
|
||||
self.assertEqual(
|
||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.current_invoice_start)
|
||||
add_days(subscription.trial_period_end, 1), get_date_str(subscription.next_billing_period_start)
|
||||
)
|
||||
self.assertEqual(
|
||||
add_to_date(subscription.current_invoice_start, months=1, days=-1),
|
||||
get_date_str(subscription.current_invoice_end),
|
||||
add_to_date(subscription.next_billing_period_start, months=1, days=-1),
|
||||
get_date_str(subscription.next_billing_period_end),
|
||||
)
|
||||
self.assertEqual(subscription.invoices, [])
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
@@ -48,8 +50,8 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription()
|
||||
self.assertEqual(subscription.trial_period_start, None)
|
||||
self.assertEqual(subscription.trial_period_end, None)
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
# No invoice is created
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
@@ -66,12 +68,12 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate("2018-02-01"))
|
||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate("2018-02-28"))
|
||||
self.assertEqual(getdate(subscription.next_billing_period_start), getdate("2018-02-01"))
|
||||
self.assertEqual(getdate(subscription.next_billing_period_end), getdate("2018-02-28"))
|
||||
|
||||
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01") # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
@@ -89,7 +91,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.process()
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
|
||||
self.assertEqual(subscription.next_billing_period_start, add_months(subscription.start_date, 1))
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_subscription_cancel_after_grace_period(self):
|
||||
@@ -122,7 +124,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
_date = add_months(nowdate(), -1)
|
||||
subscription = create_subscription(start_date=_date, days_until_due=10)
|
||||
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
@@ -134,7 +136,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), -1000))
|
||||
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
subscription.process(posting_date=subscription.next_billing_period_end) # generate first invoice
|
||||
self.assertEqual(subscription.status, "Grace Period")
|
||||
|
||||
subscription.process()
|
||||
@@ -154,20 +156,20 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription() # no changes expected
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
subscription.process() # no changes expected still
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
subscription.process() # no changes expected yet still
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(subscription.next_billing_period_start, nowdate())
|
||||
self.assertEqual(subscription.next_billing_period_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_subscription_cancellation(self):
|
||||
@@ -191,16 +193,18 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
||||
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||
plan_days = flt(
|
||||
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||
)
|
||||
prorate_factor = flt(diff / plan_days)
|
||||
|
||||
self.assertEqual(
|
||||
flt(
|
||||
get_prorata_factor(
|
||||
subscription.current_invoice_end,
|
||||
subscription.current_invoice_start,
|
||||
cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
|
||||
subscription.next_billing_period_end,
|
||||
subscription.next_billing_period_start,
|
||||
cint(subscription.generate_invoice_at == "Prepaid (bill at period start)"),
|
||||
),
|
||||
2,
|
||||
),
|
||||
@@ -237,8 +241,10 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.cancel_subscription()
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
diff = flt(date_diff(nowdate(), subscription.current_invoice_start) + 1)
|
||||
plan_days = flt(date_diff(subscription.current_invoice_end, subscription.current_invoice_start) + 1)
|
||||
diff = flt(date_diff(nowdate(), subscription.next_billing_period_start) + 1)
|
||||
plan_days = flt(
|
||||
date_diff(subscription.next_billing_period_end, subscription.next_billing_period_start) + 1
|
||||
)
|
||||
prorate_factor = flt(diff / plan_days)
|
||||
|
||||
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
|
||||
@@ -303,9 +309,9 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.save()
|
||||
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
start_date="2018-01-01", generate_invoice_at="Prepaid (bill at period start)"
|
||||
)
|
||||
subscription.process(subscription.current_invoice_start) # generate first invoice
|
||||
subscription.process(subscription.next_billing_period_start) # generate first invoice
|
||||
# This should change status to Unpaid since grace period is 0
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
@@ -317,7 +323,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
# A new invoice is generated
|
||||
subscription.process(posting_date=subscription.current_invoice_start)
|
||||
subscription.process(posting_date=subscription.next_billing_period_start)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
@@ -354,7 +360,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
|
||||
# Change the subscription type to prebilled and process it.
|
||||
# Prepaid invoice should be generated
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||
subscription.save()
|
||||
subscription.process()
|
||||
|
||||
@@ -366,7 +372,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
settings.prorate = 1
|
||||
settings.save()
|
||||
|
||||
subscription = create_subscription(generate_invoice_at="Beginning of the current subscription period")
|
||||
subscription = create_subscription(generate_invoice_at="Prepaid (bill at period start)")
|
||||
subscription.process()
|
||||
subscription.cancel_subscription()
|
||||
|
||||
@@ -387,7 +393,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription.company = "_Test Company"
|
||||
subscription.party_type = "Supplier"
|
||||
subscription.party = "_Test Supplier"
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.generate_invoice_at = "Prepaid (bill at period start)"
|
||||
subscription.follow_calendar_months = 1
|
||||
|
||||
# select subscription start date as "2018-01-15"
|
||||
@@ -413,7 +419,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date="2018-12-31",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
@@ -424,7 +430,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_subscription_without_generate_invoice_past_due(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
@@ -442,7 +448,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||
party=party,
|
||||
)
|
||||
@@ -464,7 +470,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
frappe.db.set_value("Customer", party, "default_currency", "USD")
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1, "currency": "USD"}],
|
||||
party=party,
|
||||
)
|
||||
@@ -517,7 +523,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
subscription = create_subscription(
|
||||
start_date="2023-01-01",
|
||||
end_date="2023-02-28",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
generate_invoice_at="Bill N days before period start",
|
||||
number_of_days=10,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
@@ -555,7 +561,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
start_date=start_date,
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
generate_invoice_at="Bill N days before period start",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
number_of_days=2,
|
||||
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
|
||||
@@ -577,7 +583,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=add_days(start_date, 8),
|
||||
cancel_at_period_end=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
# Catch-up billing on creation generates every elapsed period and cancels at end
|
||||
@@ -598,7 +604,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=add_days(start_date, 6),
|
||||
cancel_at_period_end=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
plans=[{"plan": "_Test plan name 10", "qty": 1}],
|
||||
)
|
||||
|
||||
@@ -684,7 +690,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
end_date=end_date,
|
||||
party_type="Customer",
|
||||
party="_Test Customer",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan 3 Day", "qty": 1}],
|
||||
)
|
||||
@@ -713,7 +719,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_status_updates_immediately_when_invoice_paid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -729,7 +735,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_invoice_update_hook_refreshes_subscription_status(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -748,7 +754,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
# Test that payment entry → invoice → subscription status update chain works
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
@@ -771,16 +777,33 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_first_invoice_generated_on_create_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_current_invoice_dates_reflect_latest_invoice(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01")
|
||||
invoice = subscription.get_current_invoice()
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(getdate(subscription.current_invoice_start), getdate(invoice.from_date))
|
||||
self.assertEqual(getdate(subscription.current_invoice_end), getdate(invoice.to_date))
|
||||
# `next_billing_period_start` tracks the next (unbilled) period.
|
||||
self.assertEqual(
|
||||
getdate(subscription.next_billing_period_start), getdate(add_days(invoice.to_date, 1))
|
||||
)
|
||||
|
||||
def test_first_invoice_not_generated_on_create_during_trial(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
trial_period_start=nowdate(),
|
||||
trial_period_end=add_days(nowdate(), 30),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
self.assertEqual(subscription.status, "Trialing")
|
||||
@@ -790,7 +813,7 @@ class TestSubscription(ERPNextTestSuite):
|
||||
try:
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
finally:
|
||||
@@ -799,10 +822,144 @@ class TestSubscription(ERPNextTestSuite):
|
||||
def test_first_invoice_not_generated_for_future_dated_subscription(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_generate_invoice_at_migration_patch(self):
|
||||
from erpnext.patches.v16_0.migrate_subscription_generate_invoice_at import VALUE_MAP, execute
|
||||
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), 10))
|
||||
for old_value, new_value in VALUE_MAP.items():
|
||||
frappe.db.set_value("Subscription", subscription.name, "generate_invoice_at", old_value)
|
||||
execute()
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Subscription", subscription.name, "generate_invoice_at"), new_value
|
||||
)
|
||||
|
||||
def test_next_billing_period_populated_for_prepaid(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_days(nowdate(), 10),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
self.assertEqual(getdate(subscription.next_billing_period_start), getdate(add_days(nowdate(), 10)))
|
||||
self.assertGreater(
|
||||
getdate(subscription.next_billing_period_end), getdate(subscription.next_billing_period_start)
|
||||
)
|
||||
|
||||
def test_status_becomes_refunded_when_only_invoice_credited(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
make_full_credit_note(subscription.get_current_invoice().name)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Refunded")
|
||||
|
||||
def test_status_stays_unpaid_when_one_of_two_invoices_credited(self):
|
||||
subscription = create_subscription(
|
||||
start_date=add_months(nowdate(), -2),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
invoices = frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={"subscription": subscription.name, "docstatus": 1, "is_return": 0},
|
||||
pluck="name",
|
||||
order_by="from_date asc",
|
||||
)
|
||||
self.assertGreaterEqual(len(invoices), 2)
|
||||
|
||||
make_full_credit_note(invoices[0])
|
||||
|
||||
subscription.reload()
|
||||
self.assertNotEqual(subscription.status, "Refunded")
|
||||
|
||||
def test_refunded_reverts_to_active_after_full_settlement(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
make_full_credit_note(invoice.name)
|
||||
|
||||
subscription.reload()
|
||||
self.assertEqual(subscription.status, "Refunded")
|
||||
|
||||
invoice.db_set("status", "Paid")
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
subscription.process()
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_heatmap_spans_twelve_months_from_start_month(self):
|
||||
start_date = getdate("2024-03-14")
|
||||
subscription = create_subscription(start_date=start_date)
|
||||
heatmap = subscription.get_billing_heatmap()
|
||||
self.assertEqual(getdate(heatmap[0]["date"]), get_first_day(start_date))
|
||||
self.assertEqual(
|
||||
getdate(heatmap[-1]["date"]), get_last_day(add_months(get_first_day(start_date), 11))
|
||||
)
|
||||
self.assertIn("status", heatmap[0])
|
||||
|
||||
def test_heatmap_marks_paid_days_green(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
invoice.db_set("status", "Paid")
|
||||
invoice.db_set("outstanding_amount", 0)
|
||||
|
||||
subscription.reload()
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "paid")
|
||||
|
||||
def test_heatmap_marks_future_planned_days(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
)
|
||||
today = getdate(nowdate())
|
||||
planned = [
|
||||
cell
|
||||
for cell in subscription.get_billing_heatmap()
|
||||
if cell["status"] == "planned" and getdate(cell["date"]) > today
|
||||
]
|
||||
self.assertTrue(planned)
|
||||
|
||||
def test_heatmap_marks_refunded_days_for_credited_periods(self):
|
||||
subscription = create_subscription(
|
||||
start_date=nowdate(),
|
||||
generate_invoice_at="Prepaid (bill at period start)",
|
||||
submit_invoice=1,
|
||||
)
|
||||
subscription.process(posting_date=nowdate())
|
||||
invoice = subscription.get_current_invoice()
|
||||
make_full_credit_note(invoice.name)
|
||||
|
||||
subscription.reload()
|
||||
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
|
||||
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded")
|
||||
|
||||
|
||||
def make_full_credit_note(invoice_name):
|
||||
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
|
||||
|
||||
credit_note = make_sales_return(invoice_name)
|
||||
credit_note.insert()
|
||||
credit_note.submit()
|
||||
return credit_note
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -509,11 +509,6 @@ def get_party_advance_account(party_type, party, company):
|
||||
return account
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_party_bank_account(party_type: str, party: str):
|
||||
return frappe.db.get_value("Bank Account", {"party_type": party_type, "party": party, "is_default": 1})
|
||||
|
||||
|
||||
def get_party_account_currency(party_type, party, company):
|
||||
def generator():
|
||||
party_account = get_party_account(party_type, party, company)
|
||||
|
||||
@@ -927,8 +927,28 @@ class ReceivablePayableReport:
|
||||
if self.filters.project:
|
||||
self.qb_selection_filter.append(self.ple.project.isin(self.filters.project))
|
||||
|
||||
self.add_user_permission_filters()
|
||||
|
||||
self.add_accounting_dimensions_filters()
|
||||
|
||||
def add_user_permission_filters(self):
|
||||
# Party is a dynamic link, so match conditions cannot auto-apply Customer/Supplier user permissions
|
||||
from frappe.core.doctype.user_permission.user_permission import get_user_permissions
|
||||
from frappe.permissions import get_allowed_docs_for_doctype
|
||||
|
||||
user_permissions = get_user_permissions()
|
||||
if not user_permissions:
|
||||
return
|
||||
|
||||
for party_type in self.party_type:
|
||||
if party_type not in user_permissions:
|
||||
continue
|
||||
|
||||
allowed_parties = get_allowed_docs_for_doctype(user_permissions[party_type], party_type)
|
||||
self.qb_selection_filter.append(
|
||||
(self.ple.party_type != party_type) | self.ple.party.isin(allowed_parties or [""])
|
||||
)
|
||||
|
||||
def get_cost_center_conditions(self):
|
||||
cost_center_list = get_cost_centers_with_children(self.filters.cost_center)
|
||||
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))
|
||||
|
||||
@@ -1243,3 +1243,44 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.assertEqual(len(report[1]), 1)
|
||||
row = report[1][0]
|
||||
self.assertEqual([si.name, project.name, 60], [row.voucher_no, row.project, row.outstanding])
|
||||
|
||||
def test_accounts_receivable_respects_user_permissions(self):
|
||||
# Party is a dynamic link on Payment Ledger Entry, so user permissions on Customer
|
||||
# must be applied explicitly. The report should only show permitted customers.
|
||||
original_customer = self.customer
|
||||
second_customer = "_Test AR Perm Customer"
|
||||
|
||||
# create_customer overrides self.customer, so build the restricted invoice first
|
||||
self.create_customer(customer_name=second_customer)
|
||||
self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
self.customer = original_customer
|
||||
allowed_invoice = self.create_sales_invoice(no_payment_schedule=True)
|
||||
|
||||
test_user = "test_ar_user_permission@example.com"
|
||||
if not frappe.db.exists("User", test_user):
|
||||
user = frappe.new_doc("User")
|
||||
user.email = test_user
|
||||
user.first_name = "AR Perm"
|
||||
user.append("roles", {"role": "Accounts User"})
|
||||
user.save()
|
||||
|
||||
frappe.permissions.add_user_permission("Customer", original_customer, test_user)
|
||||
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
}
|
||||
|
||||
frappe.set_user(test_user)
|
||||
try:
|
||||
report = execute(filters)
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
parties = {row.party for row in report[1]}
|
||||
self.assertIn(original_customer, parties)
|
||||
self.assertNotIn(second_customer, parties)
|
||||
self.assertEqual(allowed_invoice.customer, original_customer)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder import CustomFunction
|
||||
from frappe.utils import cint
|
||||
|
||||
|
||||
@@ -97,19 +98,32 @@ def get_sales_details(filters):
|
||||
if filters["based_on"] not in ("Sales Order", "Sales Invoice"):
|
||||
frappe.throw(_("Invalid value {0} for 'Based On'").format(filters["based_on"]))
|
||||
|
||||
date_field = "s.transaction_date" if filters["based_on"] == "Sales Order" else "s.posting_date"
|
||||
parent = frappe.qb.DocType(filters["based_on"])
|
||||
child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item"
|
||||
child = frappe.qb.DocType(child_doctype)
|
||||
|
||||
sales_data = frappe.db.sql(
|
||||
"""
|
||||
select s.territory, s.customer, si.item_group, si.item_code, si.qty, {date_field} as last_order_date,
|
||||
DATEDIFF(CURRENT_DATE, {date_field}) as days_since_last_order
|
||||
from `tab{doctype}` s, `tab{doctype} Item` si
|
||||
where s.name = si.parent and s.docstatus = 1
|
||||
order by days_since_last_order """.format( # nosec
|
||||
date_field=date_field, doctype=filters["based_on"]
|
||||
),
|
||||
as_dict=1,
|
||||
)
|
||||
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
|
||||
current_date = CustomFunction("CURRENT_DATE", [])
|
||||
|
||||
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
|
||||
days_since_last_order = date_diff(current_date(), date_col)
|
||||
|
||||
sales_data = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
parent.territory,
|
||||
parent.customer,
|
||||
child.item_group,
|
||||
child.item_code,
|
||||
child.qty,
|
||||
date_col.as_("last_order_date"),
|
||||
days_since_last_order.as_("days_since_last_order"),
|
||||
)
|
||||
.where(parent.docstatus == 1)
|
||||
.orderby(days_since_last_order)
|
||||
).run(as_dict=True)
|
||||
|
||||
for d in sales_data:
|
||||
item_details_map.setdefault((d.territory, d.item_code), d)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
|
||||
from frappe.model.workflow import get_workflow_name
|
||||
from frappe.utils import flt, get_link_to_form, getdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
|
||||
@@ -222,15 +222,12 @@ class ChildItemUpdater:
|
||||
current_state = self.parent.get(workflow_doc.workflow_state_field)
|
||||
roles = frappe.get_roles()
|
||||
|
||||
transitions = [
|
||||
t.as_dict()
|
||||
for t in workflow_doc.transitions
|
||||
if t.next_state == current_state
|
||||
and t.allowed in roles
|
||||
and is_transition_condition_satisfied(t, self.parent)
|
||||
]
|
||||
allowed = any(
|
||||
state.state == current_state and (not state.allow_edit or state.allow_edit in roles)
|
||||
for state in workflow_doc.states
|
||||
)
|
||||
|
||||
if not transitions:
|
||||
if not allowed:
|
||||
frappe.throw(
|
||||
_("You are not allowed to update as per the conditions set in {} Workflow.").format(
|
||||
get_link_to_form("Workflow", workflow)
|
||||
|
||||
@@ -80,6 +80,8 @@ class TestUtils(ERPNextTestSuite):
|
||||
purchase_invoice.submit()
|
||||
|
||||
payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name)
|
||||
payment_entry.target_exchange_rate = 82.32
|
||||
payment_entry.set_amounts()
|
||||
payment_entry.paid_amount = 15725
|
||||
payment_entry.deductions = []
|
||||
payment_entry.save()
|
||||
|
||||
@@ -304,6 +304,7 @@ def get_balance_on(
|
||||
)
|
||||
|
||||
if party_type and party:
|
||||
frappe.has_permission(party_type, "read", party, throw=True)
|
||||
cond.append(
|
||||
f"""gle.party_type = {frappe.db.escape(party_type)} and gle.party = {frappe.db.escape(party)} """
|
||||
)
|
||||
@@ -446,15 +447,13 @@ def add_ac(args: frappe._dict | None = None):
|
||||
if not args:
|
||||
args = frappe.local.form_dict
|
||||
|
||||
args.pop("ignore_permissions", None)
|
||||
frappe.has_permission("Account", "create", throw=True)
|
||||
|
||||
args.doctype = "Account"
|
||||
args = make_tree_args(**args)
|
||||
|
||||
ac = frappe.new_doc("Account")
|
||||
|
||||
if args.get("ignore_permissions"):
|
||||
ac.flags.ignore_permissions = True
|
||||
args.pop("ignore_permissions")
|
||||
|
||||
ac.update(args)
|
||||
|
||||
if not ac.parent_account:
|
||||
@@ -1554,6 +1553,7 @@ def update_cost_center(
|
||||
Renames the document by adding the number as a prefix to the current name and updates
|
||||
all transaction where it was present.
|
||||
"""
|
||||
frappe.has_permission("Cost Center", "write", doc=docname, throw=True)
|
||||
validate_field_number("Cost Center", docname, cost_center_number, company, "cost_center_number")
|
||||
|
||||
if cost_center_number:
|
||||
|
||||
@@ -24,7 +24,7 @@ import erpnext
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_checks_for_pl_and_bs_accounts,
|
||||
)
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
|
||||
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
|
||||
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
|
||||
get_asset_depr_schedule_doc,
|
||||
|
||||
@@ -86,7 +86,7 @@ class SubcontractingService:
|
||||
|
||||
def update_subcontracting_order_status(self) -> None:
|
||||
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
|
||||
update_subcontracting_order_status as update_sco_status,
|
||||
set_subcontracting_order_status as update_sco_status,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
|
||||
@@ -845,8 +845,10 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Product Bundle version this row was packed from",
|
||||
"fieldname": "product_bundle",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Product Bundle",
|
||||
"options": "Product Bundle",
|
||||
"read_only": 1
|
||||
@@ -939,7 +941,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-20 00:50:16.192936",
|
||||
"modified": "2026-06-08 21:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -180,6 +180,7 @@ def refresh_scorecards():
|
||||
def make_all_scorecards(docname: str):
|
||||
sc = frappe.get_doc("Supplier Scorecard", docname)
|
||||
supplier = frappe.get_doc("Supplier", sc.supplier)
|
||||
supplier.check_permission("write")
|
||||
|
||||
start_date = getdate(supplier.creation)
|
||||
end_date = get_scorecard_date(sc.period, start_date)
|
||||
|
||||
@@ -296,6 +296,7 @@ def get_message():
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_default_supplier(item_code: str, supplier: str, company: str):
|
||||
frappe.has_permission("Item", "write", doc=item_code, throw=True)
|
||||
frappe.db.set_value(
|
||||
"Item Default",
|
||||
{"parent": item_code, "company": company},
|
||||
|
||||
@@ -38,7 +38,7 @@ from erpnext.accounts.party import (
|
||||
from erpnext.accounts.utils import (
|
||||
get_advance_payment_doctypes as _get_advance_payment_doctypes,
|
||||
)
|
||||
from erpnext.accounts.utils import validate_fiscal_year
|
||||
from erpnext.accounts.utils import get_fiscal_year, validate_fiscal_year
|
||||
from erpnext.controllers.print_settings import (
|
||||
set_print_templates_for_item_table,
|
||||
set_print_templates_for_taxes,
|
||||
@@ -640,21 +640,29 @@ class AccountsController(TransactionBase):
|
||||
self.calculate_contribution()
|
||||
|
||||
def validate_date_with_fiscal_year(self):
|
||||
if self.meta.get_field("fiscal_year"):
|
||||
date_field = None
|
||||
if self.meta.get_field("posting_date"):
|
||||
date_field = "posting_date"
|
||||
elif self.meta.get_field("transaction_date"):
|
||||
date_field = "transaction_date"
|
||||
date_field = None
|
||||
if self.meta.get_field("posting_date"):
|
||||
date_field = "posting_date"
|
||||
elif self.meta.get_field("transaction_date"):
|
||||
date_field = "transaction_date"
|
||||
|
||||
if date_field and self.get(date_field):
|
||||
validate_fiscal_year(
|
||||
self.get(date_field),
|
||||
self.fiscal_year,
|
||||
self.company,
|
||||
self.meta.get_label(date_field),
|
||||
self,
|
||||
)
|
||||
if not date_field or not self.get(date_field):
|
||||
return
|
||||
|
||||
if self.meta.get_field("fiscal_year"):
|
||||
validate_fiscal_year(
|
||||
self.get(date_field),
|
||||
self.fiscal_year,
|
||||
self.company,
|
||||
self.meta.get_label(date_field),
|
||||
self,
|
||||
)
|
||||
else:
|
||||
get_fiscal_year(
|
||||
self.get(date_field),
|
||||
company=self.company,
|
||||
label=self.meta.get_label(date_field),
|
||||
)
|
||||
|
||||
def validate_due_date(self):
|
||||
if self.get("is_pos") or self.doctype not in ["Sales Invoice", "Purchase Invoice"]:
|
||||
|
||||
@@ -598,6 +598,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
target_doc.so_detail = source_doc.so_detail
|
||||
target_doc.expense_account = source_doc.expense_account
|
||||
target_doc.dn_detail = source_doc.name
|
||||
target_doc.cost_center = source_doc.cost_center
|
||||
if default_warehouse_for_sales_return:
|
||||
target_doc.warehouse = default_warehouse_for_sales_return
|
||||
elif doctype == "Sales Invoice" or doctype == "POS Invoice":
|
||||
|
||||
@@ -425,7 +425,12 @@ class SellingController(StockController):
|
||||
row.new_item_code
|
||||
for row in frappe.get_all(
|
||||
"Product Bundle",
|
||||
filters={"new_item_code": ("in", items_to_fetch), "disabled": 0},
|
||||
filters={
|
||||
"new_item_code": ("in", items_to_fetch),
|
||||
"is_active": 1,
|
||||
"docstatus": 1,
|
||||
"disabled": 0,
|
||||
},
|
||||
fields="new_item_code",
|
||||
)
|
||||
}
|
||||
@@ -979,9 +984,14 @@ class SellingController(StockController):
|
||||
|
||||
qty_can_be_deliver = 0
|
||||
if sre_doc.reservation_based_on == "Serial and Batch":
|
||||
sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
|
||||
# Delivered serial/batch may live in a Serial and Batch Bundle or directly in the
|
||||
# row's serial_no/batch_no fields (use_serial_batch_fields). Read from whichever is
|
||||
# present so this never crashes on a missing bundle.
|
||||
(
|
||||
delivered_serial_nos,
|
||||
delivered_batch_qty,
|
||||
) = get_delivered_serial_batch_for_reservation(item)
|
||||
if sre_doc.has_serial_no:
|
||||
delivered_serial_nos = [d.serial_no for d in sbb.entries]
|
||||
for entry in sre_doc.sb_entries:
|
||||
if entry.serial_no in delivered_serial_nos:
|
||||
entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No.
|
||||
@@ -989,16 +999,16 @@ class SellingController(StockController):
|
||||
qty_can_be_deliver += 1
|
||||
delivered_serial_nos.remove(entry.serial_no)
|
||||
else:
|
||||
delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries}
|
||||
for entry in sre_doc.sb_entries:
|
||||
if entry.batch_no in delivered_batch_qty:
|
||||
available_batch_qty = delivered_batch_qty.get(entry.batch_no, 0)
|
||||
if available_batch_qty > 0:
|
||||
delivered_qty = min(
|
||||
(entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no]
|
||||
(entry.qty - entry.delivered_qty), available_batch_qty
|
||||
)
|
||||
entry.delivered_qty += delivered_qty
|
||||
entry.db_update()
|
||||
qty_can_be_deliver += delivered_qty
|
||||
delivered_batch_qty[entry.batch_no] -= delivered_qty
|
||||
delivered_batch_qty[entry.batch_no] = available_batch_qty - delivered_qty
|
||||
else:
|
||||
# `Delivered Qty` should be less than or equal to `Reserved Qty`.
|
||||
qty_can_be_deliver = min(
|
||||
@@ -1174,3 +1184,31 @@ def get_serial_and_batch_bundle(child, parent, delivery_note_child=None):
|
||||
child.db_set("serial_and_batch_bundle", doc.name)
|
||||
|
||||
return doc.name
|
||||
|
||||
|
||||
def get_delivered_serial_batch_for_reservation(item):
|
||||
"""Serial nos and per-batch qty delivered by a stock row.
|
||||
|
||||
The detail may be stored in a Serial and Batch Bundle or directly in the row's
|
||||
``serial_no``/``batch_no`` fields (``use_serial_batch_fields``). Reading from whichever is
|
||||
present keeps the Stock Reservation Entry delivered-qty update independent of a bundle being
|
||||
created -- delivering reserved serial/batch stock used to crash when the row had no bundle.
|
||||
"""
|
||||
serial_nos, batch_qty = [], {}
|
||||
|
||||
if item.get("serial_and_batch_bundle"):
|
||||
bundle = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
|
||||
for row in bundle.entries:
|
||||
if row.serial_no:
|
||||
serial_nos.append(row.serial_no)
|
||||
if row.batch_no:
|
||||
batch_qty[row.batch_no] = batch_qty.get(row.batch_no, 0) + abs(flt(row.qty))
|
||||
else:
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
if item.get("serial_no"):
|
||||
serial_nos = get_serial_nos(item.serial_no)
|
||||
if item.get("batch_no"):
|
||||
batch_qty[item.batch_no] = abs(flt(item.get("stock_qty") or item.get("qty")))
|
||||
|
||||
return serial_nos, batch_qty
|
||||
|
||||
@@ -1124,10 +1124,10 @@ class SubcontractingInwardController:
|
||||
def update_inward_order_status(self):
|
||||
if self.subcontracting_inward_order:
|
||||
from erpnext.subcontracting.doctype.subcontracting_inward_order.subcontracting_inward_order import (
|
||||
update_subcontracting_inward_order_status,
|
||||
set_subcontracting_inward_order_status,
|
||||
)
|
||||
|
||||
update_subcontracting_inward_order_status(self.subcontracting_inward_order)
|
||||
set_subcontracting_inward_order_status(self.subcontracting_inward_order)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -32,7 +32,7 @@ class calculate_taxes_and_totals:
|
||||
def __init__(self, doc: Document):
|
||||
self.doc = doc
|
||||
frappe.flags.round_off_applicable_accounts = (
|
||||
get_round_off_applicable_accounts(self.doc.company, []) or []
|
||||
get_round_off_applicable_accounts(self.doc.company, [], self.doc) or []
|
||||
)
|
||||
frappe.flags.round_row_wise_tax = frappe.get_single_value("Accounts Settings", "round_row_wise_tax")
|
||||
|
||||
@@ -1240,14 +1240,16 @@ def get_itemised_tax_breakup_html(doc):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_round_off_applicable_accounts(company: str, account_list: list | str):
|
||||
def get_round_off_applicable_accounts(
|
||||
company: str, account_list: list | str, doc: str | dict | Document | None = None
|
||||
):
|
||||
# required to set correct region
|
||||
with temporary_flag("company", company):
|
||||
return get_regional_round_off_accounts(company, account_list)
|
||||
return get_regional_round_off_accounts(company, account_list, doc)
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
def get_regional_round_off_accounts(company, account_list):
|
||||
def get_regional_round_off_accounts(company, account_list, doc=None):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -988,6 +988,34 @@ class TestAccountsController(ERPNextTestSuite):
|
||||
self.assertEqual(sinv.taxes[0].account_head, "_Test Account Excise Duty - _TC")
|
||||
self.assertEqual(sinv.total_taxes_and_charges, 5)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"add_taxes_from_item_tax_template": 1, "add_taxes_from_taxes_and_charges_template": 0},
|
||||
)
|
||||
def test_19b_fetch_taxes_from_item_tax_template_purchase_invoice(self):
|
||||
pinv = frappe.new_doc("Purchase Invoice")
|
||||
pinv.supplier = "_Test Supplier"
|
||||
pinv.company = self.company
|
||||
pinv.currency = "INR"
|
||||
item = pinv.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "_Test Item",
|
||||
"qty": 1,
|
||||
"rate": 50,
|
||||
"item_tax_template": "_Test Account Excise Duty @ 10 - _TC",
|
||||
},
|
||||
)
|
||||
|
||||
item_details = pinv.fetch_item_details(item)
|
||||
pinv.add_taxes_from_item_template(item, item_details)
|
||||
|
||||
self.assertEqual(len(pinv.taxes), 1)
|
||||
tax_row = pinv.taxes[0]
|
||||
self.assertEqual(tax_row.account_head, "_Test Account Excise Duty - _TC")
|
||||
self.assertEqual(tax_row.category, "Total")
|
||||
self.assertEqual(tax_row.add_deduct_tax, "Add")
|
||||
|
||||
def test_20_journal_against_sales_invoice(self):
|
||||
# Invoice in Foreign Currency
|
||||
si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)
|
||||
|
||||
@@ -16,12 +16,13 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
class TestItemWiseInventoryAccount(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = make_company()
|
||||
self.company_abbr = frappe.db.get_value("Company", self.company, "abbr")
|
||||
self.company = "_Test Company with perpetual inventory"
|
||||
self.company_abbr = "TCP1"
|
||||
self.default_warehouse = frappe.db.get_value(
|
||||
"Warehouse",
|
||||
{"company": self.company, "is_group": 0, "warehouse_name": ("like", "%Stores%")},
|
||||
)
|
||||
frappe.db.set_value("Company", self.company, "enable_item_wise_inventory_account", 1)
|
||||
|
||||
def test_item_account_for_purchase_receipt_entry(self):
|
||||
items = {
|
||||
@@ -577,23 +578,3 @@ class TestItemWiseInventoryAccount(ERPNextTestSuite):
|
||||
gl_value = gl_value * -1
|
||||
|
||||
self.assertEqual(sle_value, gl_value, f"GL Entry not created for {item_code} correctly")
|
||||
|
||||
|
||||
def make_company():
|
||||
company = "_Test Company for Item Wise Inventory Account"
|
||||
if frappe.db.exists("Company", company):
|
||||
return company
|
||||
|
||||
company = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Company",
|
||||
"company_name": "_Test Company for Item Wise Inventory Account",
|
||||
"abbr": "_TCIWIA",
|
||||
"default_currency": "INR",
|
||||
"country": "India",
|
||||
"enable_perpetual_inventory": 1,
|
||||
"enable_item_wise_inventory_account": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
return company.name
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestTaxesAndTotals(ERPNextTestSuite):
|
||||
"""
|
||||
test_account = "_Test Round Off Account"
|
||||
|
||||
def mock_regional(company, account_list: list) -> list:
|
||||
def mock_regional(company, account_list: list, doc=None) -> list:
|
||||
# Simulates a regional override
|
||||
account_list.extend([test_account])
|
||||
return account_list
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"opportunity_section",
|
||||
"close_opportunity_after_days",
|
||||
"column_break_9",
|
||||
"enable_opportunity_creation_from_contact_us",
|
||||
"quotation_section",
|
||||
"default_valid_till",
|
||||
"section_break_13",
|
||||
@@ -98,15 +99,20 @@
|
||||
"fieldname": "update_timestamp_on_new_communication",
|
||||
"fieldtype": "Check",
|
||||
"label": "Update timestamp on new communication"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_opportunity_creation_from_contact_us",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Opportunity Creation from Contact Us"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"hide_toolbar": 0,
|
||||
"icon": "fa fa-cog",
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-16 13:28:19.573964",
|
||||
"modified": "2026-06-11 23:09:49.750381",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM Settings",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -20,8 +21,20 @@ class CRMSettings(Document):
|
||||
carry_forward_communication_and_comments: DF.Check
|
||||
close_opportunity_after_days: DF.Int
|
||||
default_valid_till: DF.Data | None
|
||||
enable_opportunity_creation_from_contact_us: DF.Check
|
||||
update_timestamp_on_new_communication: DF.Check
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))
|
||||
self.validate_enable_opportunity_creation_from_contact_us()
|
||||
|
||||
def validate_enable_opportunity_creation_from_contact_us(self):
|
||||
contact_disabled = frappe.get_single_value("Contact Us Settings", "is_disabled")
|
||||
|
||||
if self.enable_opportunity_creation_from_contact_us and contact_disabled:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cannot enable Opportunity creation from Contact Us because the Contact Us form is disabled."
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.contacts.address_and_contact import (
|
||||
load_address_and_contact,
|
||||
)
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
|
||||
from frappe.utils import comma_and, get_link_to_form, validate_email_address
|
||||
from frappe.utils.data import DateTimeLikeObject
|
||||
|
||||
from erpnext.accounts.party import set_taxes
|
||||
@@ -173,9 +173,6 @@ class Lead(SellingController, CRMNote):
|
||||
if self.email_id == self.lead_owner:
|
||||
frappe.throw(_("Lead Owner cannot be same as the Lead Email Address"))
|
||||
|
||||
if self.is_new() or not self.image:
|
||||
self.image = has_gravatar(self.email_id)
|
||||
|
||||
def link_to_contact(self):
|
||||
# update contact links
|
||||
if self.contact_doc:
|
||||
@@ -386,7 +383,7 @@ def get_lead_with_phone_number(number):
|
||||
def add_lead_to_prospect(lead: str, prospect: str):
|
||||
prospect = frappe.get_doc("Prospect", prospect)
|
||||
prospect.append("leads", {"lead": lead})
|
||||
prospect.save(ignore_permissions=True)
|
||||
prospect.save()
|
||||
|
||||
carry_forward_communication_and_comments = frappe.db.get_single_value(
|
||||
"CRM Settings", "carry_forward_communication_and_comments"
|
||||
|
||||
@@ -130,7 +130,6 @@ def make_lead_from_communication(communication: str, ignore_communication_links:
|
||||
}
|
||||
)
|
||||
lead.flags.ignore_mandatory = True
|
||||
lead.flags.ignore_permissions = True
|
||||
lead.insert()
|
||||
|
||||
lead_name = lead.name
|
||||
|
||||
@@ -145,7 +145,7 @@ def make_opportunity_from_communication(
|
||||
"opportunity_from": opportunity_from,
|
||||
"party_name": lead,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
).insert()
|
||||
|
||||
link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)
|
||||
|
||||
|
||||
@@ -31,20 +31,16 @@ def create_custom_fields_for_frappe_crm():
|
||||
@frappe.whitelist()
|
||||
def create_prospect_against_crm_deal():
|
||||
doc = frappe.form_dict
|
||||
prospect = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Prospect",
|
||||
"company_name": doc.organization or doc.lead_name,
|
||||
"no_of_employees": doc.no_of_employees,
|
||||
"prospect_owner": doc.deal_owner,
|
||||
"company": doc.erpnext_company,
|
||||
"crm_deal": doc.crm_deal,
|
||||
"territory": doc.territory,
|
||||
"industry": doc.industry,
|
||||
"website": doc.website,
|
||||
"annual_revenue": doc.annual_revenue,
|
||||
}
|
||||
)
|
||||
prospect = frappe.new_doc("Prospect")
|
||||
prospect.company_name = doc.organization or doc.lead_name
|
||||
prospect.no_of_employees = doc.no_of_employees
|
||||
prospect.prospect_owner = doc.deal_owner
|
||||
prospect.company = doc.erpnext_company
|
||||
prospect.crm_deal = doc.crm_deal
|
||||
prospect.territory = doc.territory
|
||||
prospect.industry = doc.industry
|
||||
prospect.website = doc.website
|
||||
prospect.annual_revenue = doc.annual_revenue
|
||||
|
||||
try:
|
||||
prospect_name = frappe.db.get_value("Prospect", {"company_name": prospect.company_name})
|
||||
@@ -150,6 +146,18 @@ def contact_exists(email, mobile_no):
|
||||
return False
|
||||
|
||||
|
||||
CUSTOMER_ALLOWED_FIELDS = {
|
||||
"customer_name",
|
||||
"customer_group",
|
||||
"customer_type",
|
||||
"territory",
|
||||
"default_currency",
|
||||
"industry",
|
||||
"website",
|
||||
"crm_deal",
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_customer(customer_data: dict | None = None):
|
||||
if not customer_data:
|
||||
@@ -158,9 +166,11 @@ def create_customer(customer_data: dict | None = None):
|
||||
try:
|
||||
customer_name = frappe.db.exists("Customer", {"customer_name": customer_data.get("customer_name")})
|
||||
if not customer_name:
|
||||
customer = frappe.get_doc({"doctype": "Customer", **customer_data}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
customer = frappe.new_doc("Customer")
|
||||
for field in CUSTOMER_ALLOWED_FIELDS:
|
||||
if customer_data.get(field) is not None:
|
||||
customer.set(field, customer_data.get(field))
|
||||
customer.insert(ignore_permissions=True)
|
||||
customer_name = customer.name
|
||||
|
||||
contacts = json.loads(customer_data.get("contacts"))
|
||||
|
||||
@@ -5,6 +5,11 @@ from frappe.utils import cstr, now, today
|
||||
from pypika import functions
|
||||
|
||||
|
||||
def disable_opportunity_creation_on_contact_us_disabled(doc, method):
|
||||
if doc.is_disabled:
|
||||
frappe.db.set_single_value("CRM Settings", "enable_opportunity_creation_from_contact_us", 0)
|
||||
|
||||
|
||||
def update_lead_phone_numbers(contact, method):
|
||||
if contact.phone_nos:
|
||||
contact_lead = contact.get_link_for("Lead")
|
||||
|
||||
@@ -383,6 +383,9 @@ doc_events = {
|
||||
"Event": {
|
||||
"after_insert": "erpnext.crm.utils.link_events_with_prospect",
|
||||
},
|
||||
"Contact Us Settings": {
|
||||
"on_update": "erpnext.crm.utils.disable_opportunity_creation_on_contact_us_disabled",
|
||||
},
|
||||
"Sales Invoice": {
|
||||
"on_submit": [
|
||||
"erpnext.regional.italy.utils.sales_invoice_on_submit",
|
||||
|
||||
6335
erpnext/locale/ar.po
6335
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
6313
erpnext/locale/bs.po
6313
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
6265
erpnext/locale/cs.po
6265
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
6259
erpnext/locale/da.po
6259
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
6353
erpnext/locale/de.po
6353
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
6363
erpnext/locale/eo.po
6363
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
6341
erpnext/locale/es.po
6341
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
6353
erpnext/locale/fa.po
6353
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
6315
erpnext/locale/fr.po
6315
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
62782
erpnext/locale/hi.po
Normal file
62782
erpnext/locale/hi.po
Normal file
File diff suppressed because it is too large
Load Diff
6327
erpnext/locale/hr.po
6327
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
6295
erpnext/locale/hu.po
6295
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
6265
erpnext/locale/id.po
6265
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
6271
erpnext/locale/it.po
6271
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
6315
erpnext/locale/ko.po
6315
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
6259
erpnext/locale/my.po
6259
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
6265
erpnext/locale/nb.po
6265
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
6349
erpnext/locale/nl.po
6349
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
6295
erpnext/locale/pl.po
6295
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
6269
erpnext/locale/pt.po
6269
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
6345
erpnext/locale/ru.po
6345
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user