Merge branch 'frappe:develop' into rounded-row-wise-tax

This commit is contained in:
Dany Robert 2023-10-11 12:31:13 +05:30 committed by GitHub
commit 6a27cbd61d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
425 changed files with 16998 additions and 16021 deletions

View File

@ -4,7 +4,9 @@ set -e
cd ~ || exit cd ~ || exit
sudo apt update && sudo apt install redis-server libcups2-dev sudo apt update
sudo apt remove mysql-server mysql-client
sudo apt install libcups2-dev redis-server mariadb-client-10.6
pip install frappe-bench pip install frappe-bench
@ -25,14 +27,14 @@ fi
if [ "$DB" == "mariadb" ];then if [ "$DB" == "mariadb" ];then
mysql --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 character_set_server = 'utf8mb4'"
mysql --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
mysql --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 USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
mysql --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 "CREATE DATABASE test_frappe"
mysql --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
mysql --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES" mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
fi fi
if [ "$DB" == "postgres" ];then if [ "$DB" == "postgres" ];then
@ -68,6 +70,6 @@ if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
wait $wkpid wait $wkpid
bench start &> bench_run_logs.txt & bench start &>> ~/frappe-bench/bench_start.log &
CI=Yes bench build --app frappe & CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes bench --site test_site reinstall --yes

View File

@ -30,23 +30,3 @@ jobs:
head: version-${{ matrix.version }}-hotfix head: version-${{ matrix.version }}-hotfix
env: env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
beta-release:
name: Release
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: octokit/request-action@v2.x
with:
route: POST /repos/{owner}/{repo}/pulls
owner: frappe
repo: erpnext
title: |-
"chore: release v15 beta"
body: "Automated beta release."
base: version-15-beta
head: develop
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@ -23,12 +23,12 @@ jobs:
services: services:
mysql: mysql:
image: mariadb:10.3 image: mariadb:10.6
env: env:
MARIADB_ROOT_PASSWORD: 'root' MARIADB_ROOT_PASSWORD: 'root'
ports: ports:
- 3306:3306 - 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps: steps:
- name: Clone - name: Clone
@ -45,9 +45,7 @@ jobs:
- name: Setup Python - name: Setup Python
uses: "actions/setup-python@v4" uses: "actions/setup-python@v4"
with: with:
python-version: | python-version: '3.10'
3.7
3.10
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
@ -102,40 +100,60 @@ jobs:
- name: Run Patch Tests - name: Run Patch Tests
run: | run: |
cd ~/frappe-bench/ cd ~/frappe-bench/
wget https://erpnext.com/files/v10-erpnext.sql.gz bench remove-app payments --force
bench --site test_site --force restore ~/frappe-bench/v10-erpnext.sql.gz jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
wget https://erpnext.com/files/v13-erpnext.sql.gz
bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
for version in $(seq 12 13)
do
echo "Updating to v$version"
branch_name="version-$version-hotfix"
git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name function update_to_version() {
git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name version=$1
git -C "apps/frappe" checkout -q -f $branch_name branch_name="version-$version-hotfix"
git -C "apps/erpnext" checkout -q -f $branch_name echo "Updating to v$version"
rm -rf ~/frappe-bench/env # Fetch and checkout branches
bench setup env --python python3.7 git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
bench pip install -e ./apps/payments git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
bench pip install -e ./apps/erpnext git -C "apps/frappe" checkout -q -f $branch_name
git -C "apps/erpnext" checkout -q -f $branch_name
bench --site test_site migrate # Resetup env and install apps
done pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env
bench -v setup env
bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
}
update_to_version 14
echo "Updating to latest version" echo "Updating to latest version"
git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA" git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env rm -rf ~/frappe-bench/env
bench -v setup env --python python3.10 bench -v setup env
bench pip install -e ./apps/payments
bench pip install -e ./apps/erpnext bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate bench --site test_site migrate
bench --site test_site install-app payments
- name: Show bench output
if: ${{ always() }}
run: |
cd ~/frappe-bench
cat bench_start.log || true
cd logs
for f in ./*.log*; do
echo "Printing log: $f";
cat $f
done

View File

@ -47,7 +47,7 @@ jobs:
MARIADB_ROOT_PASSWORD: 'root' MARIADB_ROOT_PASSWORD: 'root'
ports: ports:
- 3306:3306 - 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3 options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps: steps:
- name: Clone - name: Clone
@ -123,6 +123,10 @@ jobs:
CI_BUILD_ID: ${{ github.run_id }} CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
- name: Show bench output
if: ${{ always() }}
run: cat ~/frappe-bench/bench_start.log || true
- name: Upload coverage data - name: Upload coverage data
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:

View File

@ -40,6 +40,7 @@ repos:
- id: flake8 - id: flake8
additional_dependencies: [ additional_dependencies: [
'flake8-bugbear', 'flake8-bugbear',
'flake8-tuple',
] ]
args: ['--config', '.github/helper/.flake8_strict'] args: ['--config', '.github/helper/.flake8_strict']
exclude: ".*setup.py$" exclude: ".*setup.py$"

View File

@ -137,9 +137,6 @@ frappe.ui.form.on("Account", {
args: { args: {
old: frm.doc.name, old: frm.doc.name,
new: data.name, new: data.name,
is_group: frm.doc.is_group,
root_type: frm.doc.root_type,
company: frm.doc.company,
}, },
callback: function (r) { callback: function (r) {
if (!r.exc) { if (!r.exc) {

View File

@ -18,6 +18,10 @@ class BalanceMismatchError(frappe.ValidationError):
pass pass
class InvalidAccountMergeError(frappe.ValidationError):
pass
class Account(NestedSet): class Account(NestedSet):
nsm_parent_field = "parent_account" nsm_parent_field = "parent_account"
@ -460,25 +464,34 @@ def update_account_number(name, account_name, account_number=None, from_descenda
@frappe.whitelist() @frappe.whitelist()
def merge_account(old, new, is_group, root_type, company): def merge_account(old, new):
# Validate properties before merging # Validate properties before merging
new_account = frappe.get_cached_doc("Account", new) new_account = frappe.get_cached_doc("Account", new)
old_account = frappe.get_cached_doc("Account", old)
if not new_account: if not new_account:
throw(_("Account {0} does not exist").format(new)) throw(_("Account {0} does not exist").format(new))
if (new_account.is_group, new_account.root_type, new_account.company) != ( if (
cint(is_group), cint(new_account.is_group),
root_type, new_account.root_type,
company, new_account.company,
cstr(new_account.account_currency),
) != (
cint(old_account.is_group),
old_account.root_type,
old_account.company,
cstr(old_account.account_currency),
): ):
throw( throw(
_( msg=_(
"""Merging is only possible if following properties are same in both records. Is Group, Root Type, Company""" """Merging is only possible if following properties are same in both records. Is Group, Root Type, Company and Account Currency"""
) ),
title=("Invalid Accounts"),
exc=InvalidAccountMergeError,
) )
if is_group and new_account.parent_account == old: if old_account.is_group and new_account.parent_account == old:
new_account.db_set("parent_account", frappe.get_cached_value("Account", old, "parent_account")) new_account.db_set("parent_account", frappe.get_cached_value("Account", old, "parent_account"))
frappe.rename_doc("Account", old, new, merge=1, force=1) frappe.rename_doc("Account", old, new, merge=1, force=1)

View File

@ -1,289 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
"""
Import chart of accounts from OpenERP sources
"""
import ast
import json
import os
from xml.etree import ElementTree as ET
import frappe
from frappe.utils.csvutils import read_csv_content
path = "/Users/nabinhait/projects/odoo/addons"
accounts = {}
charts = {}
all_account_types = []
all_roots = {}
def go():
global accounts, charts
default_account_types = get_default_account_types()
country_dirs = []
for basepath, folders, files in os.walk(path):
basename = os.path.basename(basepath)
if basename.startswith("l10n_"):
country_dirs.append(basename)
for country_dir in country_dirs:
accounts, charts = {}, {}
country_path = os.path.join(path, country_dir)
manifest = ast.literal_eval(open(os.path.join(country_path, "__openerp__.py")).read())
data_files = (
manifest.get("data", []) + manifest.get("init_xml", []) + manifest.get("update_xml", [])
)
files_path = [os.path.join(country_path, d) for d in data_files]
xml_roots = get_xml_roots(files_path)
csv_content = get_csv_contents(files_path)
prefix = country_dir if csv_content else None
account_types = get_account_types(
xml_roots.get("account.account.type", []), csv_content.get("account.account.type", []), prefix
)
account_types.update(default_account_types)
if xml_roots:
make_maps_for_xml(xml_roots, account_types, country_dir)
if csv_content:
make_maps_for_csv(csv_content, account_types, country_dir)
make_account_trees()
make_charts()
create_all_roots_file()
def get_default_account_types():
default_types_root = []
default_types_root.append(
ET.parse(os.path.join(path, "account", "data", "data_account_type.xml")).getroot()
)
return get_account_types(default_types_root, None, prefix="account")
def get_xml_roots(files_path):
xml_roots = frappe._dict()
for filepath in files_path:
fname = os.path.basename(filepath)
if fname.endswith(".xml"):
tree = ET.parse(filepath)
root = tree.getroot()
for node in root[0].findall("record"):
if node.get("model") in [
"account.account.template",
"account.chart.template",
"account.account.type",
]:
xml_roots.setdefault(node.get("model"), []).append(root)
break
return xml_roots
def get_csv_contents(files_path):
csv_content = {}
for filepath in files_path:
fname = os.path.basename(filepath)
for file_type in ["account.account.template", "account.account.type", "account.chart.template"]:
if fname.startswith(file_type) and fname.endswith(".csv"):
with open(filepath, "r") as csvfile:
try:
csv_content.setdefault(file_type, []).append(read_csv_content(csvfile.read()))
except Exception as e:
continue
return csv_content
def get_account_types(root_list, csv_content, prefix=None):
types = {}
account_type_map = {
"cash": "Cash",
"bank": "Bank",
"tr_cash": "Cash",
"tr_bank": "Bank",
"receivable": "Receivable",
"tr_receivable": "Receivable",
"account rec": "Receivable",
"payable": "Payable",
"tr_payable": "Payable",
"equity": "Equity",
"stocks": "Stock",
"stock": "Stock",
"tax": "Tax",
"tr_tax": "Tax",
"tax-out": "Tax",
"tax-in": "Tax",
"charges_personnel": "Chargeable",
"fixed asset": "Fixed Asset",
"cogs": "Cost of Goods Sold",
}
for root in root_list:
for node in root[0].findall("record"):
if node.get("model") == "account.account.type":
data = {}
for field in node.findall("field"):
if (
field.get("name") == "code"
and field.text.lower() != "none"
and account_type_map.get(field.text)
):
data["account_type"] = account_type_map[field.text]
node_id = prefix + "." + node.get("id") if prefix else node.get("id")
types[node_id] = data
if csv_content and csv_content[0][0] == "id":
for row in csv_content[1:]:
row_dict = dict(zip(csv_content[0], row))
data = {}
if row_dict.get("code") and account_type_map.get(row_dict["code"]):
data["account_type"] = account_type_map[row_dict["code"]]
if data and data.get("id"):
node_id = prefix + "." + data.get("id") if prefix else data.get("id")
types[node_id] = data
return types
def make_maps_for_xml(xml_roots, account_types, country_dir):
"""make maps for `charts` and `accounts`"""
for model, root_list in xml_roots.items():
for root in root_list:
for node in root[0].findall("record"):
if node.get("model") == "account.account.template":
data = {}
for field in node.findall("field"):
if field.get("name") == "name":
data["name"] = field.text
if field.get("name") == "parent_id":
parent_id = field.get("ref") or field.get("eval")
data["parent_id"] = parent_id
if field.get("name") == "user_type":
value = field.get("ref")
if account_types.get(value, {}).get("account_type"):
data["account_type"] = account_types[value]["account_type"]
if data["account_type"] not in all_account_types:
all_account_types.append(data["account_type"])
data["children"] = []
accounts[node.get("id")] = data
if node.get("model") == "account.chart.template":
data = {}
for field in node.findall("field"):
if field.get("name") == "name":
data["name"] = field.text
if field.get("name") == "account_root_id":
data["account_root_id"] = field.get("ref")
data["id"] = country_dir
charts.setdefault(node.get("id"), {}).update(data)
def make_maps_for_csv(csv_content, account_types, country_dir):
for content in csv_content.get("account.account.template", []):
for row in content[1:]:
data = dict(zip(content[0], row))
account = {
"name": data.get("name"),
"parent_id": data.get("parent_id:id") or data.get("parent_id/id"),
"children": [],
}
user_type = data.get("user_type/id") or data.get("user_type:id")
if account_types.get(user_type, {}).get("account_type"):
account["account_type"] = account_types[user_type]["account_type"]
if account["account_type"] not in all_account_types:
all_account_types.append(account["account_type"])
accounts[data.get("id")] = account
if not account.get("parent_id") and data.get("chart_template_id:id"):
chart_id = data.get("chart_template_id:id")
charts.setdefault(chart_id, {}).update({"account_root_id": data.get("id")})
for content in csv_content.get("account.chart.template", []):
for row in content[1:]:
if row:
data = dict(zip(content[0], row))
charts.setdefault(data.get("id"), {}).update(
{
"account_root_id": data.get("account_root_id:id") or data.get("account_root_id/id"),
"name": data.get("name"),
"id": country_dir,
}
)
def make_account_trees():
"""build tree hierarchy"""
for id in accounts.keys():
account = accounts[id]
if account.get("parent_id"):
if accounts.get(account["parent_id"]):
# accounts[account["parent_id"]]["children"].append(account)
accounts[account["parent_id"]][account["name"]] = account
del account["parent_id"]
del account["name"]
# remove empty children
for id in accounts.keys():
if "children" in accounts[id] and not accounts[id].get("children"):
del accounts[id]["children"]
def make_charts():
"""write chart files in app/setup/doctype/company/charts"""
for chart_id in charts:
src = charts[chart_id]
if not src.get("name") or not src.get("account_root_id"):
continue
if not src["account_root_id"] in accounts:
continue
filename = src["id"][5:] + "_" + chart_id
print("building " + filename)
chart = {}
chart["name"] = src["name"]
chart["country_code"] = src["id"][5:]
chart["tree"] = accounts[src["account_root_id"]]
for key, val in chart["tree"].items():
if key in ["name", "parent_id"]:
chart["tree"].pop(key)
if type(val) == dict:
val["root_type"] = ""
if chart:
fpath = os.path.join(
"erpnext", "erpnext", "accounts", "doctype", "account", "chart_of_accounts", filename + ".json"
)
with open(fpath, "r") as chartfile:
old_content = chartfile.read()
if not old_content or (
json.loads(old_content).get("is_active", "No") == "No"
and json.loads(old_content).get("disabled", "No") == "No"
):
with open(fpath, "w") as chartfile:
chartfile.write(json.dumps(chart, indent=4, sort_keys=True))
all_roots.setdefault(filename, chart["tree"].keys())
def create_all_roots_file():
with open("all_roots.txt", "w") as f:
for filename, roots in sorted(all_roots.items()):
f.write(filename)
f.write("\n----------------------\n")
for r in sorted(roots):
f.write(r.encode("utf-8"))
f.write("\n")
f.write("\n\n\n")
if __name__ == "__main__":
go()

View File

@ -437,12 +437,20 @@
}, },
"Sales": { "Sales": {
"Sales from Other Regions": { "Sales from Other Regions": {
"Sales from Other Region": {} "Sales from Other Region": {
"account_type": "Income Account"
}
}, },
"Sales of same region": { "Sales of same region": {
"Management Consultancy Fees 1": {}, "Management Consultancy Fees 1": {
"Sales Account": {}, "account_type": "Income Account"
"Sales of I/C": {} },
"Sales Account": {
"account_type": "Income Account"
},
"Sales of I/C": {
"account_type": "Income Account"
}
} }
}, },
"root_type": "Income" "root_type": "Income"

View File

@ -69,8 +69,7 @@
"Persediaan Barang": { "Persediaan Barang": {
"Persediaan Barang": { "Persediaan Barang": {
"account_number": "1141.000", "account_number": "1141.000",
"account_type": "Stock", "account_type": "Stock"
"is_group": 1
}, },
"Uang Muka Pembelian": { "Uang Muka Pembelian": {
"Uang Muka Pembelian": { "Uang Muka Pembelian": {
@ -670,7 +669,8 @@
}, },
"Penjualan Barang Dagangan": { "Penjualan Barang Dagangan": {
"Penjualan": { "Penjualan": {
"account_number": "4110.000" "account_number": "4110.000",
"account_type": "Income Account"
}, },
"Potongan Penjualan": { "Potongan Penjualan": {
"account_number": "4130.000" "account_number": "4130.000"

View File

@ -109,8 +109,7 @@
} }
}, },
"INVENTARIOS": { "INVENTARIOS": {
"account_type": "Stock", "account_type": "Stock"
"is_group": 1
} }
}, },
"ACTIVO LARGO PLAZO": { "ACTIVO LARGO PLAZO": {
@ -398,10 +397,18 @@
"INGRESOS POR SERVICIOS 1": {} "INGRESOS POR SERVICIOS 1": {}
}, },
"VENTAS": { "VENTAS": {
"VENTAS EXPORTACION": {}, "VENTAS EXPORTACION": {
"VENTAS INMUEBLES": {}, "account_type": "Income Account"
"VENTAS NACIONALES": {}, },
"VENTAS NACIONALES AL DETAL": {} "VENTAS INMUEBLES": {
"account_type": "Income Account"
},
"VENTAS NACIONALES": {
"account_type": "Income Account"
},
"VENTAS NACIONALES AL DETAL": {
"account_type": "Income Account"
}
} }
} }
}, },

View File

@ -7,7 +7,11 @@ import unittest
import frappe import frappe
from frappe.test_runner import make_test_records from frappe.test_runner import make_test_records
from erpnext.accounts.doctype.account.account import merge_account, update_account_number from erpnext.accounts.doctype.account.account import (
InvalidAccountMergeError,
merge_account,
update_account_number,
)
from erpnext.stock import get_company_default_inventory_account, get_warehouse_account from erpnext.stock import get_company_default_inventory_account, get_warehouse_account
test_dependencies = ["Company"] test_dependencies = ["Company"]
@ -47,49 +51,53 @@ class TestAccount(unittest.TestCase):
frappe.delete_doc("Account", "1211-11-4 - 6 - Debtors 1 - Test - - _TC") frappe.delete_doc("Account", "1211-11-4 - 6 - Debtors 1 - Test - - _TC")
def test_merge_account(self): def test_merge_account(self):
if not frappe.db.exists("Account", "Current Assets - _TC"): create_account(
acc = frappe.new_doc("Account") account_name="Current Assets",
acc.account_name = "Current Assets" is_group=1,
acc.is_group = 1 parent_account="Application of Funds (Assets) - _TC",
acc.parent_account = "Application of Funds (Assets) - _TC" company="_Test Company",
acc.company = "_Test Company" )
acc.insert()
if not frappe.db.exists("Account", "Securities and Deposits - _TC"): create_account(
acc = frappe.new_doc("Account") account_name="Securities and Deposits",
acc.account_name = "Securities and Deposits" is_group=1,
acc.parent_account = "Current Assets - _TC" parent_account="Current Assets - _TC",
acc.is_group = 1 company="_Test Company",
acc.company = "_Test Company" )
acc.insert()
if not frappe.db.exists("Account", "Earnest Money - _TC"): create_account(
acc = frappe.new_doc("Account") account_name="Earnest Money",
acc.account_name = "Earnest Money" parent_account="Securities and Deposits - _TC",
acc.parent_account = "Securities and Deposits - _TC" company="_Test Company",
acc.company = "_Test Company" )
acc.insert()
if not frappe.db.exists("Account", "Cash In Hand - _TC"): create_account(
acc = frappe.new_doc("Account") account_name="Cash In Hand",
acc.account_name = "Cash In Hand" is_group=1,
acc.is_group = 1 parent_account="Current Assets - _TC",
acc.parent_account = "Current Assets - _TC" company="_Test Company",
acc.company = "_Test Company" )
acc.insert()
if not frappe.db.exists("Account", "Accumulated Depreciation - _TC"): create_account(
acc = frappe.new_doc("Account") account_name="Receivable INR",
acc.account_name = "Accumulated Depreciation" parent_account="Current Assets - _TC",
acc.parent_account = "Fixed Assets - _TC" company="_Test Company",
acc.company = "_Test Company" account_currency="INR",
acc.account_type = "Accumulated Depreciation" )
acc.insert()
create_account(
account_name="Receivable USD",
parent_account="Current Assets - _TC",
company="_Test Company",
account_currency="USD",
)
doc = frappe.get_doc("Account", "Securities and Deposits - _TC")
parent = frappe.db.get_value("Account", "Earnest Money - _TC", "parent_account") parent = frappe.db.get_value("Account", "Earnest Money - _TC", "parent_account")
self.assertEqual(parent, "Securities and Deposits - _TC") self.assertEqual(parent, "Securities and Deposits - _TC")
merge_account( merge_account("Securities and Deposits - _TC", "Cash In Hand - _TC")
"Securities and Deposits - _TC", "Cash In Hand - _TC", doc.is_group, doc.root_type, doc.company
)
parent = frappe.db.get_value("Account", "Earnest Money - _TC", "parent_account") parent = frappe.db.get_value("Account", "Earnest Money - _TC", "parent_account")
# Parent account of the child account changes after merging # Parent account of the child account changes after merging
@ -98,30 +106,28 @@ class TestAccount(unittest.TestCase):
# Old account doesn't exist after merging # Old account doesn't exist after merging
self.assertFalse(frappe.db.exists("Account", "Securities and Deposits - _TC")) self.assertFalse(frappe.db.exists("Account", "Securities and Deposits - _TC"))
doc = frappe.get_doc("Account", "Current Assets - _TC")
# Raise error as is_group property doesn't match # Raise error as is_group property doesn't match
self.assertRaises( self.assertRaises(
frappe.ValidationError, InvalidAccountMergeError,
merge_account, merge_account,
"Current Assets - _TC", "Current Assets - _TC",
"Accumulated Depreciation - _TC", "Accumulated Depreciation - _TC",
doc.is_group,
doc.root_type,
doc.company,
) )
doc = frappe.get_doc("Account", "Capital Stock - _TC")
# Raise error as root_type property doesn't match # Raise error as root_type property doesn't match
self.assertRaises( self.assertRaises(
frappe.ValidationError, InvalidAccountMergeError,
merge_account, merge_account,
"Capital Stock - _TC", "Capital Stock - _TC",
"Softwares - _TC", "Softwares - _TC",
doc.is_group, )
doc.root_type,
doc.company, # Raise error as currency doesn't match
self.assertRaises(
InvalidAccountMergeError,
merge_account,
"Receivable INR - _TC",
"Receivable USD - _TC",
) )
def test_account_sync(self): def test_account_sync(self):
@ -400,11 +406,20 @@ def create_account(**kwargs):
"Account", filters={"account_name": kwargs.get("account_name"), "company": kwargs.get("company")} "Account", filters={"account_name": kwargs.get("account_name"), "company": kwargs.get("company")}
) )
if account: if account:
return account account = frappe.get_doc("Account", account)
account.update(
dict(
is_group=kwargs.get("is_group", 0),
parent_account=kwargs.get("parent_account"),
)
)
account.save()
return account.name
else: else:
account = frappe.get_doc( account = frappe.get_doc(
dict( dict(
doctype="Account", doctype="Account",
is_group=kwargs.get("is_group", 0),
account_name=kwargs.get("account_name"), account_name=kwargs.get("account_name"),
account_type=kwargs.get("account_type"), account_type=kwargs.get("account_type"),
parent_account=kwargs.get("parent_account"), parent_account=kwargs.get("parent_account"),

View File

@ -265,20 +265,21 @@ def get_dimension_with_children(doctype, dimensions):
@frappe.whitelist() @frappe.whitelist()
def get_dimensions(with_cost_center_and_project=False): def get_dimensions(with_cost_center_and_project=False):
dimension_filters = frappe.db.sql(
"""
SELECT label, fieldname, document_type
FROM `tabAccounting Dimension`
WHERE disabled = 0
""",
as_dict=1,
)
default_dimensions = frappe.db.sql( c = frappe.qb.DocType("Accounting Dimension Detail")
"""SELECT p.fieldname, c.company, c.default_dimension p = frappe.qb.DocType("Accounting Dimension")
FROM `tabAccounting Dimension Detail` c, `tabAccounting Dimension` p dimension_filters = (
WHERE c.parent = p.name""", frappe.qb.from_(p)
as_dict=1, .select(p.label, p.fieldname, p.document_type)
.where(p.disabled == 0)
.run(as_dict=1)
)
default_dimensions = (
frappe.qb.from_(c)
.inner_join(p)
.on(c.parent == p.name)
.select(p.fieldname, c.company, c.default_dimension)
.run(as_dict=1)
) )
if isinstance(with_cost_center_and_project, str): if isinstance(with_cost_center_and_project, str):

View File

@ -84,12 +84,22 @@ def create_dimension():
frappe.set_user("Administrator") frappe.set_user("Administrator")
if not frappe.db.exists("Accounting Dimension", {"document_type": "Department"}): if not frappe.db.exists("Accounting Dimension", {"document_type": "Department"}):
frappe.get_doc( dimension = frappe.get_doc(
{ {
"doctype": "Accounting Dimension", "doctype": "Accounting Dimension",
"document_type": "Department", "document_type": "Department",
} }
).insert() )
dimension.append(
"dimension_defaults",
{
"company": "_Test Company",
"reference_document": "Department",
"default_dimension": "_Test Department - _TC",
},
)
dimension.insert()
dimension.save()
else: else:
dimension = frappe.get_doc("Accounting Dimension", "Department") dimension = frappe.get_doc("Accounting Dimension", "Department")
dimension.disabled = 0 dimension.disabled = 0

View File

@ -13,6 +13,7 @@
"account_type", "account_type",
"account_subtype", "account_subtype",
"column_break_7", "column_break_7",
"disabled",
"is_default", "is_default",
"is_company_account", "is_company_account",
"company", "company",
@ -199,10 +200,16 @@
"fieldtype": "Data", "fieldtype": "Data",
"in_global_search": 1, "in_global_search": 1,
"label": "Branch Code" "label": "Branch Code"
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
} }
], ],
"links": [], "links": [],
"modified": "2022-05-04 15:49:42.620630", "modified": "2023-09-22 21:31:34.763977",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Bank Account", "name": "Bank Account",

View File

@ -35,13 +35,14 @@ class TestBankClearance(unittest.TestCase):
from lending.loan_management.doctype.loan.test_loan import ( from lending.loan_management.doctype.loan.test_loan import (
create_loan, create_loan,
create_loan_accounts, create_loan_accounts,
create_loan_type, create_loan_product,
create_repayment_entry, create_repayment_entry,
make_loan_disbursement_entry, make_loan_disbursement_entry,
) )
def create_loan_masters(): def create_loan_masters():
create_loan_type( create_loan_product(
"Clearance Loan",
"Clearance Loan", "Clearance Loan",
2000000, 2000000,
13.5, 13.5,

View File

@ -7,7 +7,9 @@ import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import cint, flt from frappe.utils import cint, flt
from pypika.terms import Parameter
from erpnext import get_default_cost_center from erpnext import get_default_cost_center
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
@ -15,7 +17,7 @@ from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_s
get_amounts_not_reflected_in_system, get_amounts_not_reflected_in_system,
get_entries, get_entries,
) )
from erpnext.accounts.utils import get_balance_on from erpnext.accounts.utils import get_account_currency, get_balance_on
class BankReconciliationTool(Document): class BankReconciliationTool(Document):
@ -283,68 +285,68 @@ def auto_reconcile_vouchers(
to_reference_date=None, to_reference_date=None,
): ):
frappe.flags.auto_reconcile_vouchers = True frappe.flags.auto_reconcile_vouchers = True
document_types = ["payment_entry", "journal_entry"] reconciled, partially_reconciled = set(), set()
bank_transactions = get_bank_transactions(bank_account) bank_transactions = get_bank_transactions(bank_account)
matched_transaction = []
for transaction in bank_transactions: for transaction in bank_transactions:
linked_payments = get_linked_payments( linked_payments = get_linked_payments(
transaction.name, transaction.name,
document_types, ["payment_entry", "journal_entry"],
from_date, from_date,
to_date, to_date,
filter_by_reference_date, filter_by_reference_date,
from_reference_date, from_reference_date,
to_reference_date, to_reference_date,
) )
vouchers = []
for r in linked_payments: if not linked_payments:
vouchers.append( continue
{
"payment_doctype": r[1], vouchers = list(
"payment_name": r[2], map(
"amount": r[4], lambda entry: {
} "payment_doctype": entry.get("doctype"),
) "payment_name": entry.get("name"),
transaction = frappe.get_doc("Bank Transaction", transaction.name) "amount": entry.get("paid_amount"),
account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
matched_trans = 0
for voucher in vouchers:
gl_entry = frappe.db.get_value(
"GL Entry",
dict(
account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"]
),
["credit", "debit"],
as_dict=1,
)
gl_amount, transaction_amount = (
(gl_entry.credit, transaction.deposit)
if gl_entry.credit > 0
else (gl_entry.debit, transaction.withdrawal)
)
allocated_amount = gl_amount if gl_amount >= transaction_amount else transaction_amount
transaction.append(
"payment_entries",
{
"payment_document": voucher["payment_doctype"],
"payment_entry": voucher["payment_name"],
"allocated_amount": allocated_amount,
}, },
linked_payments,
) )
matched_transaction.append(str(transaction.name)) )
transaction.save()
transaction.update_allocations() updated_transaction = reconcile_vouchers(transaction.name, json.dumps(vouchers))
matched_transaction_len = len(set(matched_transaction))
if matched_transaction_len == 0: if updated_transaction.status == "Reconciled":
frappe.msgprint(_("No matching references found for auto reconciliation")) reconciled.add(updated_transaction.name)
elif matched_transaction_len == 1: elif flt(transaction.unallocated_amount) != flt(updated_transaction.unallocated_amount):
frappe.msgprint(_("{0} transaction is reconcilied").format(matched_transaction_len)) # Partially reconciled (status = Unreconciled & unallocated amount changed)
else: partially_reconciled.add(updated_transaction.name)
frappe.msgprint(_("{0} transactions are reconcilied").format(matched_transaction_len))
alert_message, indicator = get_auto_reconcile_message(partially_reconciled, reconciled)
frappe.msgprint(title=_("Auto Reconciliation"), msg=alert_message, indicator=indicator)
frappe.flags.auto_reconcile_vouchers = False frappe.flags.auto_reconcile_vouchers = False
return reconciled, partially_reconciled
return frappe.get_doc("Bank Transaction", transaction.name)
def get_auto_reconcile_message(partially_reconciled, reconciled):
"""Returns alert message and indicator for auto reconciliation depending on result state."""
alert_message, indicator = "", "blue"
if not partially_reconciled and not reconciled:
alert_message = _("No matches occurred via auto reconciliation")
return alert_message, indicator
indicator = "green"
if reconciled:
alert_message += _("{0} Transaction(s) Reconciled").format(len(reconciled))
alert_message += "<br>"
if partially_reconciled:
alert_message += _("{0} {1} Partially Reconciled").format(
len(partially_reconciled),
_("Transactions") if len(partially_reconciled) > 1 else _("Transaction"),
)
return alert_message, indicator
@frappe.whitelist() @frappe.whitelist()
@ -390,19 +392,13 @@ def subtract_allocations(gl_account, vouchers):
"Look up & subtract any existing Bank Transaction allocations" "Look up & subtract any existing Bank Transaction allocations"
copied = [] copied = []
for voucher in vouchers: for voucher in vouchers:
rows = get_total_allocated_amount(voucher[1], voucher[2]) rows = get_total_allocated_amount(voucher.get("doctype"), voucher.get("name"))
amount = None filtered_row = list(filter(lambda row: row.get("gl_account") == gl_account, rows))
for row in rows:
if row["gl_account"] == gl_account:
amount = row["total"]
break
if amount: if amount := None if not filtered_row else filtered_row[0]["total"]:
l = list(voucher) voucher["paid_amount"] -= amount
l[3] -= amount
copied.append(tuple(l)) copied.append(voucher)
else:
copied.append(voucher)
return copied return copied
@ -418,6 +414,18 @@ def check_matching(
to_reference_date, to_reference_date,
): ):
exact_match = True if "exact_match" in document_types else False exact_match = True if "exact_match" in document_types else False
queries = get_queries(
bank_account,
company,
transaction,
document_types,
from_date,
to_date,
filter_by_reference_date,
from_reference_date,
to_reference_date,
exact_match,
)
filters = { filters = {
"amount": transaction.unallocated_amount, "amount": transaction.unallocated_amount,
@ -429,30 +437,15 @@ def check_matching(
} }
matching_vouchers = [] matching_vouchers = []
for query in queries:
matching_vouchers.extend(frappe.db.sql(query, filters, as_dict=True))
# get matching vouchers from all the apps return (
for method_name in frappe.get_hooks("get_matching_vouchers_for_bank_reconciliation"): sorted(matching_vouchers, key=lambda x: x["rank"], reverse=True) if matching_vouchers else []
matching_vouchers.extend( )
frappe.get_attr(method_name)(
bank_account,
company,
transaction,
document_types,
from_date,
to_date,
filter_by_reference_date,
from_reference_date,
to_reference_date,
exact_match,
filters,
)
or []
)
return sorted(matching_vouchers, key=lambda x: x[0], reverse=True) if matching_vouchers else []
def get_matching_vouchers_for_bank_reconciliation( def get_queries(
bank_account, bank_account,
company, company,
transaction, transaction,
@ -463,7 +456,6 @@ def get_matching_vouchers_for_bank_reconciliation(
from_reference_date, from_reference_date,
to_reference_date, to_reference_date,
exact_match, exact_match,
filters,
): ):
# get queries to get matching vouchers # get queries to get matching vouchers
account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from" account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from"
@ -488,17 +480,7 @@ def get_matching_vouchers_for_bank_reconciliation(
or [] or []
) )
vouchers = [] return queries
for query in queries:
vouchers.extend(
frappe.db.sql(
query,
filters,
)
)
return vouchers
def get_matching_queries( def get_matching_queries(
@ -515,6 +497,8 @@ def get_matching_queries(
to_reference_date, to_reference_date,
): ):
queries = [] queries = []
currency = get_account_currency(bank_account)
if "payment_entry" in document_types: if "payment_entry" in document_types:
query = get_pe_matching_query( query = get_pe_matching_query(
exact_match, exact_match,
@ -541,12 +525,12 @@ def get_matching_queries(
queries.append(query) queries.append(query)
if transaction.deposit > 0.0 and "sales_invoice" in document_types: if transaction.deposit > 0.0 and "sales_invoice" in document_types:
query = get_si_matching_query(exact_match) query = get_si_matching_query(exact_match, currency)
queries.append(query) queries.append(query)
if transaction.withdrawal > 0.0: if transaction.withdrawal > 0.0:
if "purchase_invoice" in document_types: if "purchase_invoice" in document_types:
query = get_pi_matching_query(exact_match) query = get_pi_matching_query(exact_match, currency)
queries.append(query) queries.append(query)
if "bank_transaction" in document_types: if "bank_transaction" in document_types:
@ -560,33 +544,48 @@ def get_bt_matching_query(exact_match, transaction):
# get matching bank transaction query # get matching bank transaction query
# find bank transactions in the same bank account with opposite sign # find bank transactions in the same bank account with opposite sign
# same bank account must have same company and currency # same bank account must have same company and currency
bt = frappe.qb.DocType("Bank Transaction")
field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal" field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal"
amount_equality = getattr(bt, field) == transaction.unallocated_amount
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
amount_condition = amount_equality if exact_match else getattr(bt, field) > 0.0
return f""" ref_rank = (
frappe.qb.terms.Case().when(bt.reference_number == transaction.reference_number, 1).else_(0)
)
unallocated_rank = (
frappe.qb.terms.Case().when(bt.unallocated_amount == transaction.unallocated_amount, 1).else_(0)
)
SELECT party_condition = (
(CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END (bt.party_type == transaction.party_type)
+ CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END & (bt.party == transaction.party)
+ CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END & bt.party.isnotnull()
+ CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END )
+ 1) AS rank, party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
'Bank Transaction' AS doctype,
name, query = (
unallocated_amount AS paid_amount, frappe.qb.from_(bt)
reference_number AS reference_no, .select(
date AS reference_date, (ref_rank + amount_rank + party_rank + unallocated_rank + 1).as_("rank"),
party, ConstantColumn("Bank Transaction").as_("doctype"),
party_type, bt.name,
date AS posting_date, bt.unallocated_amount.as_("paid_amount"),
currency bt.reference_number.as_("reference_no"),
FROM bt.date.as_("reference_date"),
`tabBank Transaction` bt.party,
WHERE bt.party_type,
status != 'Reconciled' bt.date.as_("posting_date"),
AND name != '{transaction.name}' bt.currency,
AND bank_account = '{transaction.bank_account}' )
AND {field} {'= %(amount)s' if exact_match else '> 0.0'} .where(bt.status != "Reconciled")
""" .where(bt.name != transaction.name)
.where(bt.bank_account == transaction.bank_account)
.where(amount_condition)
.where(bt.docstatus == 1)
)
return str(query)
def get_pe_matching_query( def get_pe_matching_query(
@ -600,45 +599,56 @@ def get_pe_matching_query(
to_reference_date, to_reference_date,
): ):
# get matching payment entries query # get matching payment entries query
if transaction.deposit > 0.0: to_from = "to" if transaction.deposit > 0.0 else "from"
currency_field = "paid_to_account_currency as currency" currency_field = f"paid_{to_from}_account_currency"
else: payment_type = "Receive" if transaction.deposit > 0.0 else "Pay"
currency_field = "paid_from_account_currency as currency" pe = frappe.qb.DocType("Payment Entry")
filter_by_date = f"AND posting_date between '{from_date}' and '{to_date}'"
order_by = " posting_date" ref_condition = pe.reference_no == transaction.reference_number
filter_by_reference_no = "" ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
amount_equality = pe.paid_amount == transaction.unallocated_amount
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
party_condition = (
(pe.party_type == transaction.party_type)
& (pe.party == transaction.party)
& pe.party.isnotnull()
)
party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
filter_by_date = pe.posting_date.between(from_date, to_date)
if cint(filter_by_reference_date): if cint(filter_by_reference_date):
filter_by_date = f"AND reference_date between '{from_reference_date}' and '{to_reference_date}'" filter_by_date = pe.reference_date.between(from_reference_date, to_reference_date)
order_by = " reference_date"
query = (
frappe.qb.from_(pe)
.select(
(ref_rank + amount_rank + party_rank + 1).as_("rank"),
ConstantColumn("Payment Entry").as_("doctype"),
pe.name,
pe.paid_amount,
pe.reference_no,
pe.reference_date,
pe.party,
pe.party_type,
pe.posting_date,
getattr(pe, currency_field).as_("currency"),
)
.where(pe.docstatus == 1)
.where(pe.payment_type.isin([payment_type, "Internal Transfer"]))
.where(pe.clearance_date.isnull())
.where(getattr(pe, account_from_to) == Parameter("%(bank_account)s"))
.where(amount_condition)
.where(filter_by_date)
.orderby(pe.reference_date if cint(filter_by_reference_date) else pe.posting_date)
)
if frappe.flags.auto_reconcile_vouchers == True: if frappe.flags.auto_reconcile_vouchers == True:
filter_by_reference_no = f"AND reference_no = '{transaction.reference_number}'" query = query.where(ref_condition)
return f"""
SELECT return str(query)
(CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END
+ CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END
+ CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END
+ 1 ) AS rank,
'Payment Entry' as doctype,
name,
paid_amount,
reference_no,
reference_date,
party,
party_type,
posting_date,
{currency_field}
FROM
`tabPayment Entry`
WHERE
docstatus = 1
AND payment_type IN (%(payment_type)s, 'Internal Transfer')
AND ifnull(clearance_date, '') = ""
AND {account_from_to} = %(bank_account)s
AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'}
{filter_by_date}
{filter_by_reference_no}
order by{order_by}
"""
def get_je_matching_query( def get_je_matching_query(
@ -655,100 +665,121 @@ def get_je_matching_query(
# So one bank could have both types of bank accounts like asset and liability # So one bank could have both types of bank accounts like asset and liability
# So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type # So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type
cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit" cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit"
filter_by_date = f"AND je.posting_date between '{from_date}' and '{to_date}'" je = frappe.qb.DocType("Journal Entry")
order_by = " je.posting_date" jea = frappe.qb.DocType("Journal Entry Account")
filter_by_reference_no = ""
ref_condition = je.cheque_no == transaction.reference_number
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
amount_field = f"{cr_or_dr}_in_account_currency"
amount_equality = getattr(jea, amount_field) == transaction.unallocated_amount
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
filter_by_date = je.posting_date.between(from_date, to_date)
if cint(filter_by_reference_date): if cint(filter_by_reference_date):
filter_by_date = f"AND je.cheque_date between '{from_reference_date}' and '{to_reference_date}'" filter_by_date = je.cheque_date.between(from_reference_date, to_reference_date)
order_by = " je.cheque_date"
if frappe.flags.auto_reconcile_vouchers == True: query = (
filter_by_reference_no = f"AND je.cheque_no = '{transaction.reference_number}'" frappe.qb.from_(jea)
return f""" .join(je)
SELECT .on(jea.parent == je.name)
(CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END .select(
+ CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END (ref_rank + amount_rank + 1).as_("rank"),
+ 1) AS rank , ConstantColumn("Journal Entry").as_("doctype"),
'Journal Entry' AS doctype,
je.name, je.name,
jea.{cr_or_dr}_in_account_currency AS paid_amount, getattr(jea, amount_field).as_("paid_amount"),
je.cheque_no AS reference_no, je.cheque_no.as_("reference_no"),
je.cheque_date AS reference_date, je.cheque_date.as_("reference_date"),
je.pay_to_recd_from AS party, je.pay_to_recd_from.as_("party"),
jea.party_type, jea.party_type,
je.posting_date, je.posting_date,
jea.account_currency AS currency jea.account_currency.as_("currency"),
FROM )
`tabJournal Entry Account` AS jea .where(je.docstatus == 1)
JOIN .where(je.voucher_type != "Opening Entry")
`tabJournal Entry` AS je .where(je.clearance_date.isnull())
ON .where(jea.account == Parameter("%(bank_account)s"))
jea.parent = je.name .where(amount_equality if exact_match else getattr(jea, amount_field) > 0.0)
WHERE .where(je.docstatus == 1)
je.docstatus = 1 .where(filter_by_date)
AND je.voucher_type NOT IN ('Opening Entry') .orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
AND (je.clearance_date IS NULL OR je.clearance_date='0000-00-00') )
AND jea.account = %(bank_account)s
AND jea.{cr_or_dr}_in_account_currency {'= %(amount)s' if exact_match else '> 0.0'} if frappe.flags.auto_reconcile_vouchers == True:
AND je.docstatus = 1 query = query.where(ref_condition)
{filter_by_date}
{filter_by_reference_no} return str(query)
order by {order_by}
"""
def get_si_matching_query(exact_match): def get_si_matching_query(exact_match, currency):
# get matching sales invoice query # get matching sales invoice query
return f""" si = frappe.qb.DocType("Sales Invoice")
SELECT sip = frappe.qb.DocType("Sales Invoice Payment")
( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END
+ CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END amount_equality = sip.amount == Parameter("%(amount)s")
+ 1 ) AS rank, amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
'Sales Invoice' as doctype, amount_condition = amount_equality if exact_match else sip.amount > 0.0
party_condition = si.customer == Parameter("%(party)s")
party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
query = (
frappe.qb.from_(sip)
.join(si)
.on(sip.parent == si.name)
.select(
(party_rank + amount_rank + 1).as_("rank"),
ConstantColumn("Sales Invoice").as_("doctype"),
si.name, si.name,
sip.amount as paid_amount, sip.amount.as_("paid_amount"),
'' as reference_no, ConstantColumn("").as_("reference_no"),
'' as reference_date, ConstantColumn("").as_("reference_date"),
si.customer as party, si.customer.as_("party"),
'Customer' as party_type, ConstantColumn("Customer").as_("party_type"),
si.posting_date, si.posting_date,
si.currency si.currency,
)
.where(si.docstatus == 1)
.where(sip.clearance_date.isnull())
.where(sip.account == Parameter("%(bank_account)s"))
.where(amount_condition)
.where(si.currency == currency)
)
FROM return str(query)
`tabSales Invoice Payment` as sip
JOIN
`tabSales Invoice` as si
ON
sip.parent = si.name
WHERE
si.docstatus = 1
AND (sip.clearance_date is null or sip.clearance_date='0000-00-00')
AND sip.account = %(bank_account)s
AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'}
"""
def get_pi_matching_query(exact_match): def get_pi_matching_query(exact_match, currency):
# get matching purchase invoice query when they are also used as payment entries (is_paid) # get matching purchase invoice query when they are also used as payment entries (is_paid)
return f""" purchase_invoice = frappe.qb.DocType("Purchase Invoice")
SELECT
( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END amount_equality = purchase_invoice.paid_amount == Parameter("%(amount)s")
+ CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
+ 1 ) AS rank, amount_condition = amount_equality if exact_match else purchase_invoice.paid_amount > 0.0
'Purchase Invoice' as doctype,
name, party_condition = purchase_invoice.supplier == Parameter("%(party)s")
paid_amount, party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
'' as reference_no,
'' as reference_date, query = (
supplier as party, frappe.qb.from_(purchase_invoice)
'Supplier' as party_type, .select(
posting_date, (party_rank + amount_rank + 1).as_("rank"),
currency ConstantColumn("Purchase Invoice").as_("doctype"),
FROM purchase_invoice.name,
`tabPurchase Invoice` purchase_invoice.paid_amount,
WHERE ConstantColumn("").as_("reference_no"),
docstatus = 1 ConstantColumn("").as_("reference_date"),
AND is_paid = 1 purchase_invoice.supplier.as_("party"),
AND ifnull(clearance_date, '') = "" ConstantColumn("Supplier").as_("party_type"),
AND cash_bank_account = %(bank_account)s purchase_invoice.posting_date,
AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'} purchase_invoice.currency,
""" )
.where(purchase_invoice.docstatus == 1)
.where(purchase_invoice.is_paid == 1)
.where(purchase_invoice.clearance_date.isnull())
.where(purchase_invoice.cash_bank_account == Parameter("%(bank_account)s"))
.where(amount_condition)
.where(purchase_invoice.currency == currency)
)
return str(query)

View File

@ -1,9 +1,100 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
# import frappe
import unittest import unittest
import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, getdate, today
class TestBankReconciliationTool(unittest.TestCase): from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
pass auto_reconcile_vouchers,
get_bank_transactions,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
class TestBankReconciliationTool(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.create_company()
self.create_customer()
self.clear_old_entries()
bank_dt = qb.DocType("Bank")
q = qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
self.create_bank_account()
def tearDown(self):
frappe.db.rollback()
def create_bank_account(self):
bank = frappe.get_doc(
{
"doctype": "Bank",
"bank_name": "HDFC",
}
).save()
self.bank_account = (
frappe.get_doc(
{
"doctype": "Bank Account",
"account_name": "HDFC _current_",
"bank": bank,
"is_company_account": True,
"account": self.bank, # account from Chart of Accounts
}
)
.insert()
.name
)
def test_auto_reconcile(self):
# make payment
from_date = add_days(today(), -1)
to_date = today()
payment = create_payment_entry(
company=self.company,
posting_date=from_date,
payment_type="Receive",
party_type="Customer",
party=self.customer,
paid_from=self.debit_to,
paid_to=self.bank,
paid_amount=100,
).save()
payment.reference_no = "123"
payment = payment.save().submit()
# make bank transaction
bank_transaction = (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": to_date,
"deposit": 100,
"bank_account": self.bank_account,
"reference_number": "123",
}
)
.save()
.submit()
)
# assert API output pre reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 1)
self.assertEqual(transactions[0].name, bank_transaction.name)
# auto reconcile
auto_reconcile_vouchers(
bank_account=self.bank_account,
from_date=from_date,
to_date=to_date,
filter_by_reference_date=False,
)
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)

View File

@ -352,10 +352,11 @@ frappe.ui.form.on("Bank Statement Import", {
export_errored_rows(frm) { export_errored_rows(frm) {
open_url_post( open_url_post(
"/api/method/frappe.core.doctype.data_import.data_import.download_errored_template", "/api/method/erpnext.accounts.doctype.bank_statement_import.bank_statement_import.download_errored_template",
{ {
data_import_name: frm.doc.name, data_import_name: frm.doc.name,
} },
true
); );
}, },

View File

@ -13,10 +13,11 @@ frappe.ui.form.on("Bank Transaction", {
}); });
}, },
refresh(frm) { refresh(frm) {
frm.add_custom_button(__('Unreconcile Transaction'), () => { if (!frm.is_dirty() && frm.doc.payment_entries.length > 0) {
frm.call('remove_payment_entries') frm.add_custom_button(__("Unreconcile Transaction"), () => {
.then( () => frm.refresh() ); frm.call("remove_payment_entries").then(() => frm.refresh());
}); });
}
}, },
bank_account: function (frm) { bank_account: function (frm) {
set_bank_statement_filter(frm); set_bank_statement_filter(frm);

View File

@ -47,7 +47,7 @@ class TestBankTransaction(FrappeTestCase):
from_date=bank_transaction.date, from_date=bank_transaction.date,
to_date=utils.today(), to_date=utils.today(),
) )
self.assertTrue(linked_payments[0][6] == "Conrad Electronic") self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment # This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
def test_reconcile(self): def test_reconcile(self):
@ -93,7 +93,7 @@ class TestBankTransaction(FrappeTestCase):
from_date=bank_transaction.date, from_date=bank_transaction.date,
to_date=utils.today(), to_date=utils.today(),
) )
self.assertTrue(linked_payments[0][3]) self.assertTrue(linked_payments[0]["paid_amount"])
# Check error if already reconciled # Check error if already reconciled
def test_already_reconciled(self): def test_already_reconciled(self):
@ -188,7 +188,7 @@ class TestBankTransaction(FrappeTestCase):
repayment_entry = create_loan_and_repayment() repayment_entry = create_loan_and_repayment()
linked_payments = get_linked_payments(bank_transaction.name, ["loan_repayment", "exact_match"]) linked_payments = get_linked_payments(bank_transaction.name, ["loan_repayment", "exact_match"])
self.assertEqual(linked_payments[0][2], repayment_entry.name) self.assertEqual(linked_payments[0]["name"], repayment_entry.name)
@if_lending_app_installed @if_lending_app_installed
@ -410,7 +410,7 @@ def add_vouchers():
def create_loan_and_repayment(): def create_loan_and_repayment():
from lending.loan_management.doctype.loan.test_loan import ( from lending.loan_management.doctype.loan.test_loan import (
create_loan, create_loan,
create_loan_type, create_loan_product,
create_repayment_entry, create_repayment_entry,
make_loan_disbursement_entry, make_loan_disbursement_entry,
) )
@ -420,7 +420,8 @@ def create_loan_and_repayment():
from erpnext.setup.doctype.employee.test_employee import make_employee from erpnext.setup.doctype.employee.test_employee import make_employee
create_loan_type( create_loan_product(
"Personal Loan",
"Personal Loan", "Personal Loan",
500000, 500000,
8.4, 8.4,
@ -441,7 +442,7 @@ def create_loan_and_repayment():
"applicant_type": "Employee", "applicant_type": "Employee",
"company": "_Test Company", "company": "_Test Company",
"applicant": applicant, "applicant": applicant,
"loan_type": "Personal Loan", "loan_product": "Personal Loan",
"loan_amount": 5000, "loan_amount": 5000,
"repayment_method": "Repay Fixed Amount per Period", "repayment_method": "Repay Fixed Amount per Period",
"monthly_repayment_amount": 500, "monthly_repayment_amount": 500,

View File

@ -9,6 +9,7 @@
"disabled", "disabled",
"service_provider", "service_provider",
"api_endpoint", "api_endpoint",
"access_key",
"url", "url",
"column_break_3", "column_break_3",
"help", "help",
@ -84,12 +85,18 @@
"fieldname": "disabled", "fieldname": "disabled",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Disabled" "label": "Disabled"
},
{
"depends_on": "eval:doc.service_provider == 'exchangerate.host';",
"fieldname": "access_key",
"fieldtype": "Data",
"label": "Access Key"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2023-01-09 12:19:03.955906", "modified": "2023-10-04 15:30:25.333860",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Currency Exchange Settings", "name": "Currency Exchange Settings",

View File

@ -18,11 +18,21 @@ class CurrencyExchangeSettings(Document):
def set_parameters_and_result(self): def set_parameters_and_result(self):
if self.service_provider == "exchangerate.host": if self.service_provider == "exchangerate.host":
if not self.access_key:
frappe.throw(
_("Access Key is required for Service Provider: {0}").format(
frappe.bold(self.service_provider)
)
)
self.set("result_key", []) self.set("result_key", [])
self.set("req_params", []) self.set("req_params", [])
self.api_endpoint = "https://api.exchangerate.host/convert" self.api_endpoint = "https://api.exchangerate.host/convert"
self.append("result_key", {"key": "result"}) self.append("result_key", {"key": "result"})
self.append("req_params", {"key": "access_key", "value": self.access_key})
self.append("req_params", {"key": "amount", "value": "1"})
self.append("req_params", {"key": "date", "value": "{transaction_date}"}) self.append("req_params", {"key": "date", "value": "{transaction_date}"})
self.append("req_params", {"key": "from", "value": "{from_currency}"}) self.append("req_params", {"key": "from", "value": "{from_currency}"})
self.append("req_params", {"key": "to", "value": "{to_currency}"}) self.append("req_params", {"key": "to", "value": "{to_currency}"})

View File

@ -3,6 +3,296 @@
import unittest import unittest
import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, today
class TestExchangeRateRevaluation(unittest.TestCase): from erpnext import get_default_cost_center
pass from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.party import get_party_account
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.stock.doctype.item.test_item import create_item
class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.create_company()
self.create_usd_receivable_account()
self.create_item()
self.create_customer()
self.clear_old_entries()
self.set_system_and_company_settings()
def tearDown(self):
frappe.db.rollback()
def set_system_and_company_settings(self):
# set number and currency precision
system_settings = frappe.get_doc("System Settings")
system_settings.float_precision = 2
system_settings.currency_precision = 2
system_settings.save()
# Using Exchange Gain/Loss account for unrealized as well.
company_doc = frappe.get_doc("Company", self.company)
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
company_doc.save()
@change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_01_revaluation_of_forex_balance(self):
"""
Test Forex account balance and Journal creation post Revaluation
"""
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.posting_date = today()
accounts = err.get_accounts_data()
err.extend("accounts", accounts)
row = err.accounts[0]
row.new_exchange_rate = 85
row.new_balance_in_base_currency = flt(
row.new_exchange_rate * flt(row.balance_in_account_currency)
)
row.gain_loss = row.new_balance_in_base_currency - flt(row.balance_in_base_currency)
err.set_total_gain_loss()
err = err.save().submit()
# Create JV for ERR
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
je = je.submit()
je.reload()
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
self.assertEqual(je.total_debit, 8500.0)
self.assertEqual(je.total_credit, 8500.0)
acc_balance = frappe.db.get_all(
"GL Entry",
filters={"account": self.debtors_usd, "is_cancelled": 0},
fields=["sum(debit)-sum(credit) as balance"],
)[0]
self.assertEqual(acc_balance.balance, 8500.0)
@change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_02_accounts_only_with_base_currency_balance(self):
"""
Test Revaluation on Forex account with balance only in base currency
"""
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
pe = get_payment_entry(si.doctype, si.name)
pe.source_exchange_rate = 85
pe.received_amount = 8500
pe.save().submit()
# Cancel the auto created gain/loss JE to simulate balance only in base currency
je = frappe.db.get_all(
"Journal Entry Account", filters={"reference_name": si.name}, pluck="parent"
)[0]
frappe.get_doc("Journal Entry", je).cancel()
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.posting_date = today()
err.fetch_and_calculate_accounts_data()
err = err.save().submit()
# Create JV for ERR
self.assertTrue(err.check_journal_entry_condition())
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
je = je.submit()
je.reload()
self.assertEqual(je.voucher_type, "Exchange Gain Or Loss")
self.assertEqual(len(je.accounts), 2)
# Only base currency fields will be posted to
for acc in je.accounts:
self.assertEqual(acc.debit_in_account_currency, 0)
self.assertEqual(acc.credit_in_account_currency, 0)
self.assertEqual(je.total_debit, 500.0)
self.assertEqual(je.total_credit, 500.0)
acc_balance = frappe.db.get_all(
"GL Entry",
filters={"account": self.debtors_usd, "is_cancelled": 0},
fields=[
"sum(debit)-sum(credit) as balance",
"sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency",
],
)[0]
# account shouldn't have balance in base and account currency
self.assertEqual(acc_balance.balance, 0.0)
self.assertEqual(acc_balance.balance_in_account_currency, 0.0)
@change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_03_accounts_only_with_account_currency_balance(self):
"""
Test Revaluation on Forex account with balance only in account currency
"""
precision = frappe.db.get_single_value("System Settings", "currency_precision")
# posting on previous date to make sure that ERR picks up the Payment entry's exchange
# rate while calculating gain/loss for account currency balance
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debtors_usd,
posting_date=add_days(today(), -1),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = 95
pe.source_exchange_rate = 84.211
pe.received_amount = 8000
pe.references = []
pe.save().submit()
acc_balance = frappe.db.get_all(
"GL Entry",
filters={"account": self.debtors_usd, "is_cancelled": 0},
fields=[
"sum(debit)-sum(credit) as balance",
"sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency",
],
)[0]
# account should have balance only in account currency
self.assertEqual(flt(acc_balance.balance, precision), 0.0)
self.assertEqual(flt(acc_balance.balance_in_account_currency, precision), 5.0) # in USD
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.posting_date = today()
err.fetch_and_calculate_accounts_data()
err.set_total_gain_loss()
err = err.save().submit()
# Create JV for ERR
self.assertTrue(err.check_journal_entry_condition())
err_journals = err.make_jv_entries()
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
je = je.submit()
je.reload()
self.assertEqual(je.voucher_type, "Exchange Gain Or Loss")
self.assertEqual(len(je.accounts), 2)
# Only account currency fields will be posted to
for acc in je.accounts:
self.assertEqual(flt(acc.debit, precision), 0.0)
self.assertEqual(flt(acc.credit, precision), 0.0)
row = [x for x in je.accounts if x.account == self.debtors_usd][0]
self.assertEqual(flt(row.credit_in_account_currency, precision), 5.0) # in USD
row = [x for x in je.accounts if x.account != self.debtors_usd][0]
self.assertEqual(flt(row.debit_in_account_currency, precision), 421.06) # in INR
# total_debit and total_credit will be 0.0, as JV is posting only to account currency fields
self.assertEqual(flt(je.total_debit, precision), 0.0)
self.assertEqual(flt(je.total_credit, precision), 0.0)
acc_balance = frappe.db.get_all(
"GL Entry",
filters={"account": self.debtors_usd, "is_cancelled": 0},
fields=[
"sum(debit)-sum(credit) as balance",
"sum(debit_in_account_currency)-sum(credit_in_account_currency) as balance_in_account_currency",
],
)[0]
# account shouldn't have balance in base and account currency post revaluation
self.assertEqual(flt(acc_balance.balance, precision), 0.0)
self.assertEqual(flt(acc_balance.balance_in_account_currency, precision), 0.0)
@change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
)
def test_04_get_account_details_function(self):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=1,
)
si.currency = "USD"
si.conversion_rate = 80
si.save().submit()
from erpnext.accounts.doctype.exchange_rate_revaluation.exchange_rate_revaluation import (
get_account_details,
)
account_details = get_account_details(
self.company, si.posting_date, self.debtors_usd, "Customer", self.customer, 0.05
)
# not checking for new exchange rate and balances as it is dependent on live exchange rates
expected_data = {
"account_currency": "USD",
"balance_in_base_currency": 8000.0,
"balance_in_account_currency": 100.0,
"current_exchange_rate": 80.0,
"zero_balance": False,
"new_balance_in_account_currency": 100.0,
}
for key, val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))

View File

@ -50,8 +50,18 @@ frappe.ui.form.on("Journal Entry", {
frm.trigger("make_inter_company_journal_entry"); frm.trigger("make_inter_company_journal_entry");
}, __('Make')); }, __('Make'));
} }
},
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
},
before_save: function(frm) {
if ((frm.doc.docstatus == 0) && (!frm.doc.is_system_generated)) {
let payment_entry_references = frm.doc.accounts.filter(elem => (elem.reference_type == "Payment Entry"));
if (payment_entry_references.length > 0) {
let rows = payment_entry_references.map(x => "#"+x.idx);
frappe.throw(__("Rows: {0} have 'Payment Entry' as reference_type. This should not be set manually.", [frappe.utils.comma_and(rows)]));
}
}
},
make_inter_company_journal_entry: function(frm) { make_inter_company_journal_entry: function(frm) {
var d = new frappe.ui.Dialog({ var d = new frappe.ui.Dialog({
title: __("Select Company"), title: __("Select Company"),

View File

@ -48,9 +48,6 @@ def start_merge(docname):
merge_account( merge_account(
row.account, row.account,
ledger_merge.account, ledger_merge.account,
ledger_merge.is_group,
ledger_merge.root_type,
ledger_merge.company,
) )
row.db_set("merged", 1) row.db_set("merged", 1)
frappe.db.commit() frappe.db.commit()

View File

@ -218,6 +218,7 @@ def make_customer(customer=None):
"territory": "All Territories", "territory": "All Territories",
} }
) )
if not frappe.db.exists("Customer", customer_name): if not frappe.db.exists("Customer", customer_name):
customer.insert(ignore_permissions=True) customer.insert(ignore_permissions=True)
return customer.name return customer.name

View File

@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
frappe.ui.form.on('Payment Entry', { frappe.ui.form.on('Payment Entry', {
onload: function(frm) { onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger']; frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payments', 'Unreconcile Payment Entries'];
if(frm.doc.__islocal) { if(frm.doc.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
@ -154,6 +154,13 @@ frappe.ui.form.on('Payment Entry', {
frm.events.set_dynamic_labels(frm); frm.events.set_dynamic_labels(frm);
frm.events.show_general_ledger(frm); frm.events.show_general_ledger(frm);
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(frm); erpnext.accounts.ledger_preview.show_accounting_ledger_preview(frm);
if(frm.doc.references.find((elem) => {return elem.exchange_gain_loss != 0})) {
frm.add_custom_button(__("View Exchange Gain/Loss Journals"), function() {
frappe.set_route("List", "Journal Entry", {"voucher_type": "Exchange Gain Or Loss", "reference_name": frm.doc.name});
}, __('Actions'));
}
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
}, },
validate_company: (frm) => { validate_company: (frm) => {
@ -535,15 +542,21 @@ frappe.ui.form.on('Payment Entry', {
}, },
source_exchange_rate: function(frm) { source_exchange_rate: function(frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.paid_amount) { if (frm.doc.paid_amount) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
// target exchange rate should always be same as source if both account currencies is same // target exchange rate should always be same as source if both account currencies is same
if(frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { if(frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate); frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
frm.set_value("base_received_amount", frm.doc.base_paid_amount); frm.set_value("base_received_amount", frm.doc.base_paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.base_paid_amount);
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
} }
frm.events.set_unallocated_amount(frm); // set_unallocated_amount is called by below method,
// no need trigger separately
frm.events.set_total_allocated_amount(frm);
} }
// Make read only if Accounts Settings doesn't allow stale rates // Make read only if Accounts Settings doesn't allow stale rates
@ -552,6 +565,7 @@ frappe.ui.form.on('Payment Entry', {
target_exchange_rate: function(frm) { target_exchange_rate: function(frm) {
frm.set_paid_amount_based_on_received_amount = true; frm.set_paid_amount_based_on_received_amount = true;
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.received_amount) { if (frm.doc.received_amount) {
frm.set_value("base_received_amount", frm.set_value("base_received_amount",
@ -561,9 +575,14 @@ frappe.ui.form.on('Payment Entry', {
(frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency)) { (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency)) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate); frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
frm.set_value("base_paid_amount", frm.doc.base_received_amount); frm.set_value("base_paid_amount", frm.doc.base_received_amount);
} else if (company_currency == frm.doc.paid_from_account_currency) {
frm.set_value("paid_amount", frm.doc.base_received_amount);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
} }
frm.events.set_unallocated_amount(frm); // set_unallocated_amount is called by below method,
// no need trigger separately
frm.events.set_total_allocated_amount(frm);
} }
frm.set_paid_amount_based_on_received_amount = false; frm.set_paid_amount_based_on_received_amount = false;
@ -879,12 +898,18 @@ frappe.ui.form.on('Payment Entry', {
}, },
set_total_allocated_amount: function(frm) { set_total_allocated_amount: function(frm) {
let exchange_rate = 1;
if (frm.doc.payment_type == "Receive") {
exchange_rate = frm.doc.source_exchange_rate;
} else if (frm.doc.payment_type == "Pay") {
exchange_rate = frm.doc.target_exchange_rate;
}
var total_allocated_amount = 0.0; var total_allocated_amount = 0.0;
var base_total_allocated_amount = 0.0; var base_total_allocated_amount = 0.0;
$.each(frm.doc.references || [], function(i, row) { $.each(frm.doc.references || [], function(i, row) {
if (row.allocated_amount) { if (row.allocated_amount) {
total_allocated_amount += flt(row.allocated_amount); total_allocated_amount += flt(row.allocated_amount);
base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(row.exchange_rate), base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(exchange_rate),
precision("base_paid_amount")); precision("base_paid_amount"));
} }
}); });

View File

@ -98,7 +98,6 @@ class PaymentEntry(AccountsController):
if self.difference_amount: if self.difference_amount:
frappe.throw(_("Difference Amount must be zero")) frappe.throw(_("Difference Amount must be zero"))
self.make_gl_entries() self.make_gl_entries()
self.make_advance_gl_entries()
self.update_outstanding_amounts() self.update_outstanding_amounts()
self.update_advance_paid() self.update_advance_paid()
self.update_payment_schedule() self.update_payment_schedule()
@ -149,10 +148,11 @@ class PaymentEntry(AccountsController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger", "Repost Accounting Ledger",
"Repost Accounting Ledger Items", "Repost Accounting Ledger Items",
"Unreconcile Payments",
"Unreconcile Payment Entries",
) )
super(PaymentEntry, self).on_cancel() super(PaymentEntry, self).on_cancel()
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
self.make_advance_gl_entries(cancel=1)
self.update_outstanding_amounts() self.update_outstanding_amounts()
self.update_advance_paid() self.update_advance_paid()
self.delink_advance_entry_references() self.delink_advance_entry_references()
@ -271,16 +271,18 @@ class PaymentEntry(AccountsController):
# if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key # if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key
latest = latest.get(d.payment_term) or latest.get(None) latest = latest.get(d.payment_term) or latest.get(None)
# The reference has already been fully paid # The reference has already been fully paid
if not latest: if not latest:
frappe.throw( frappe.throw(
_("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name) _("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name)
) )
# The reference has already been partly paid # The reference has already been partly paid
elif latest.outstanding_amount < latest.invoice_amount and flt( elif (
d.outstanding_amount, d.precision("outstanding_amount") latest.outstanding_amount < latest.invoice_amount
) != flt(latest.outstanding_amount, d.precision("outstanding_amount")): and flt(d.outstanding_amount, d.precision("outstanding_amount"))
!= flt(latest.outstanding_amount, d.precision("outstanding_amount"))
and d.payment_term == ""
):
frappe.throw( frappe.throw(
_( _(
"{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts." "{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts."
@ -856,6 +858,11 @@ class PaymentEntry(AccountsController):
flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount") flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
) )
# on rare case, when `exchange_rate` is unset, gain/loss amount is incorrectly calculated
# for base currency transactions
if d.exchange_rate is None:
d.exchange_rate = 1
allocated_amount_in_pe_exchange_rate = flt( allocated_amount_in_pe_exchange_rate = flt(
flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount") flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount")
) )
@ -999,14 +1006,14 @@ class PaymentEntry(AccountsController):
if self.payment_type == "Internal Transfer": if self.payment_type == "Internal Transfer":
remarks = [ remarks = [
_("Amount {0} {1} transferred from {2} to {3}").format( _("Amount {0} {1} transferred from {2} to {3}").format(
self.paid_from_account_currency, self.paid_amount, self.paid_from, self.paid_to _(self.paid_from_account_currency), self.paid_amount, self.paid_from, self.paid_to
) )
] ]
else: else:
remarks = [ remarks = [
_("Amount {0} {1} {2} {3}").format( _("Amount {0} {1} {2} {3}").format(
self.party_account_currency, _(self.party_account_currency),
self.paid_amount if self.payment_type == "Receive" else self.received_amount, self.paid_amount if self.payment_type == "Receive" else self.received_amount,
_("received from") if self.payment_type == "Receive" else _("to"), _("received from") if self.payment_type == "Receive" else _("to"),
self.party, self.party,
@ -1023,14 +1030,14 @@ class PaymentEntry(AccountsController):
if d.allocated_amount: if d.allocated_amount:
remarks.append( remarks.append(
_("Amount {0} {1} against {2} {3}").format( _("Amount {0} {1} against {2} {3}").format(
self.party_account_currency, d.allocated_amount, d.reference_doctype, d.reference_name _(self.party_account_currency), d.allocated_amount, d.reference_doctype, d.reference_name
) )
) )
for d in self.get("deductions"): for d in self.get("deductions"):
if d.amount: if d.amount:
remarks.append( remarks.append(
_("Amount {0} {1} deducted against {2}").format(self.company_currency, d.amount, d.account) _("Amount {0} {1} deducted against {2}").format(_(self.company_currency), d.amount, d.account)
) )
self.set("remarks", "\n".join(remarks)) self.set("remarks", "\n".join(remarks))
@ -1055,6 +1062,8 @@ class PaymentEntry(AccountsController):
else: else:
self.make_exchange_gain_loss_journal() self.make_exchange_gain_loss_journal()
self.make_advance_gl_entries(cancel=cancel)
def add_party_gl_entries(self, gl_entries): def add_party_gl_entries(self, gl_entries):
if self.party_account: if self.party_account:
if self.payment_type == "Receive": if self.payment_type == "Receive":
@ -1123,7 +1132,7 @@ class PaymentEntry(AccountsController):
if self.book_advance_payments_in_separate_party_account: if self.book_advance_payments_in_separate_party_account:
gl_entries = [] gl_entries = []
for d in self.get("references"): for d in self.get("references"):
if d.reference_doctype in ("Sales Invoice", "Purchase Invoice"): if d.reference_doctype in ("Sales Invoice", "Purchase Invoice", "Journal Entry"):
if not (against_voucher_type and against_voucher) or ( if not (against_voucher_type and against_voucher) or (
d.reference_doctype == against_voucher_type and d.reference_name == against_voucher d.reference_doctype == against_voucher_type and d.reference_name == against_voucher
): ):
@ -1145,8 +1154,25 @@ class PaymentEntry(AccountsController):
) )
make_reverse_gl_entries(gl_entries=gl_entries, partial_cancel=True) make_reverse_gl_entries(gl_entries=gl_entries, partial_cancel=True)
else: return
make_gl_entries(gl_entries)
# same reference added to payment entry
for gl_entry in gl_entries.copy():
if frappe.db.exists(
"GL Entry",
{
"account": gl_entry.account,
"voucher_type": gl_entry.voucher_type,
"voucher_no": gl_entry.voucher_no,
"voucher_detail_no": gl_entry.voucher_detail_no,
"debit": gl_entry.debit,
"credit": gl_entry.credit,
"is_cancelled": 0,
},
):
gl_entries.remove(gl_entry)
make_gl_entries(gl_entries)
def make_invoice_liability_entry(self, gl_entries, invoice): def make_invoice_liability_entry(self, gl_entries, invoice):
args_dict = { args_dict = {
@ -1159,6 +1185,13 @@ class PaymentEntry(AccountsController):
"voucher_detail_no": invoice.name, "voucher_detail_no": invoice.name,
} }
posting_date = frappe.db.get_value(
invoice.reference_doctype, invoice.reference_name, "posting_date"
)
if getdate(posting_date) < getdate(self.posting_date):
posting_date = self.posting_date
dr_or_cr = "credit" if invoice.reference_doctype == "Sales Invoice" else "debit" dr_or_cr = "credit" if invoice.reference_doctype == "Sales Invoice" else "debit"
args_dict["account"] = invoice.account args_dict["account"] = invoice.account
args_dict[dr_or_cr] = invoice.allocated_amount args_dict[dr_or_cr] = invoice.allocated_amount
@ -1167,6 +1200,7 @@ class PaymentEntry(AccountsController):
{ {
"against_voucher_type": invoice.reference_doctype, "against_voucher_type": invoice.reference_doctype,
"against_voucher": invoice.reference_name, "against_voucher": invoice.reference_name,
"posting_date": posting_date,
} }
) )
gle = self.get_gl_dict( gle = self.get_gl_dict(
@ -1573,6 +1607,14 @@ def get_outstanding_reference_documents(args, validate=False):
fieldname, args.get(date_fields[0]), args.get(date_fields[1]) fieldname, args.get(date_fields[0]), args.get(date_fields[1])
) )
posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])]) posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
elif args.get(date_fields[0]):
# if only from date is supplied
condition += " and {0} >= '{1}'".format(fieldname, args.get(date_fields[0]))
posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0])))
elif args.get(date_fields[1]):
# if only to date is supplied
condition += " and {0} <= '{1}'".format(fieldname, args.get(date_fields[1]))
posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1])))
if args.get("company"): if args.get("company"):
condition += " and company = {0}".format(frappe.db.escape(args.get("company"))) condition += " and company = {0}".format(frappe.db.escape(args.get("company")))
@ -1711,11 +1753,10 @@ def split_invoices_based_on_payment_terms(outstanding_invoices, company):
"voucher_type": d.voucher_type, "voucher_type": d.voucher_type,
"posting_date": d.posting_date, "posting_date": d.posting_date,
"invoice_amount": flt(d.invoice_amount), "invoice_amount": flt(d.invoice_amount),
"outstanding_amount": flt(d.outstanding_amount), "outstanding_amount": payment_term_outstanding
"payment_term_outstanding": payment_term_outstanding,
"allocated_amount": payment_term_outstanding
if payment_term_outstanding if payment_term_outstanding
else d.outstanding_amount, else d.outstanding_amount,
"payment_term_outstanding": payment_term_outstanding,
"payment_amount": payment_term.payment_amount, "payment_amount": payment_term.payment_amount,
"payment_term": payment_term.payment_term, "payment_term": payment_term.payment_term,
"account": d.account, "account": d.account,
@ -1993,10 +2034,15 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre
if not total_amount: if not total_amount:
if party_account_currency == company_currency: if party_account_currency == company_currency:
# for handling cases that don't have multi-currency (base field) # for handling cases that don't have multi-currency (base field)
total_amount = ref_doc.get("base_grand_total") or ref_doc.get("grand_total") total_amount = (
ref_doc.get("base_rounded_total")
or ref_doc.get("rounded_total")
or ref_doc.get("base_grand_total")
or ref_doc.get("grand_total")
)
exchange_rate = 1 exchange_rate = 1
else: else:
total_amount = ref_doc.get("grand_total") total_amount = ref_doc.get("rounded_total") or ref_doc.get("grand_total")
if not exchange_rate: if not exchange_rate:
# Get the exchange rate from the original ref doc # Get the exchange rate from the original ref doc
# or get it based on the posting date of the ref doc. # or get it based on the posting date of the ref doc.
@ -2295,7 +2341,7 @@ def set_paid_amount_and_received_amount(
if bank_amount: if bank_amount:
received_amount = bank_amount received_amount = bank_amount
else: else:
if company_currency != bank.account_currency: if bank and company_currency != bank.account_currency:
received_amount = paid_amount / doc.get("conversion_rate", 1) received_amount = paid_amount / doc.get("conversion_rate", 1)
else: else:
received_amount = paid_amount * doc.get("conversion_rate", 1) received_amount = paid_amount * doc.get("conversion_rate", 1)
@ -2304,7 +2350,7 @@ def set_paid_amount_and_received_amount(
if bank_amount: if bank_amount:
paid_amount = bank_amount paid_amount = bank_amount
else: else:
if company_currency != bank.account_currency: if bank and company_currency != bank.account_currency:
paid_amount = received_amount / doc.get("conversion_rate", 1) paid_amount = received_amount / doc.get("conversion_rate", 1)
else: else:
# if party account currency and bank currency is different then populate paid amount as well # if party account currency and bank currency is different then populate paid amount as well

View File

@ -702,7 +702,50 @@ class TestPaymentEntry(FrappeTestCase):
pe2.submit() pe2.submit()
# create return entry against si1 # create return entry against si1
create_sales_invoice(is_return=1, return_against=si1.name, qty=-1) cr_note = create_sales_invoice(is_return=1, return_against=si1.name, qty=-1)
si1_outstanding = frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount")
# create JE(credit note) manually against si1 and cr_note
je = frappe.get_doc(
{
"doctype": "Journal Entry",
"company": si1.company,
"voucher_type": "Credit Note",
"posting_date": nowdate(),
}
)
je.append(
"accounts",
{
"account": si1.debit_to,
"party_type": "Customer",
"party": si1.customer,
"debit": 0,
"credit": 100,
"debit_in_account_currency": 0,
"credit_in_account_currency": 100,
"reference_type": si1.doctype,
"reference_name": si1.name,
"cost_center": si1.items[0].cost_center,
},
)
je.append(
"accounts",
{
"account": cr_note.debit_to,
"party_type": "Customer",
"party": cr_note.customer,
"debit": 100,
"credit": 0,
"debit_in_account_currency": 100,
"credit_in_account_currency": 0,
"reference_type": cr_note.doctype,
"reference_name": cr_note.name,
"cost_center": cr_note.items[0].cost_center,
},
)
je.save().submit()
si1_outstanding = frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount") si1_outstanding = frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount")
self.assertEqual(si1_outstanding, -100) self.assertEqual(si1_outstanding, -100)
@ -1201,6 +1244,24 @@ class TestPaymentEntry(FrappeTestCase):
template.allocate_payment_based_on_payment_terms = 1 template.allocate_payment_based_on_payment_terms = 1
template.save() template.save()
def test_allocation_validation_for_sales_order(self):
so = make_sales_order(do_not_save=True)
so.items[0].rate = 99.55
so.save().submit()
self.assertGreater(so.rounded_total, 0.0)
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
pe.paid_from = "Debtors - _TC"
pe.paid_amount = 45.55
pe.references[0].allocated_amount = 45.55
pe.save().submit()
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
pe.paid_from = "Debtors - _TC"
# No validation error should be thrown here.
pe.save().submit()
so.reload()
self.assertEqual(so.advance_paid, so.rounded_total)
def create_payment_entry(**args): def create_payment_entry(**args):
payment_entry = frappe.new_doc("Payment Entry") payment_entry = frappe.new_doc("Payment Entry")

View File

@ -294,7 +294,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
cr_note1.return_against = si3.name cr_note1.return_against = si3.name
cr_note1 = cr_note1.save().submit() cr_note1 = cr_note1.save().submit()
pl_entries = ( pl_entries_si3 = (
qb.from_(ple) qb.from_(ple)
.select( .select(
ple.voucher_type, ple.voucher_type,
@ -309,7 +309,24 @@ class TestPaymentLedgerEntry(FrappeTestCase):
.run(as_dict=True) .run(as_dict=True)
) )
expected_values = [ pl_entries_cr_note1 = (
qb.from_(ple)
.select(
ple.voucher_type,
ple.voucher_no,
ple.against_voucher_type,
ple.against_voucher_no,
ple.amount,
ple.delinked,
)
.where(
(ple.against_voucher_type == cr_note1.doctype) & (ple.against_voucher_no == cr_note1.name)
)
.orderby(ple.creation)
.run(as_dict=True)
)
expected_values_for_si3 = [
{ {
"voucher_type": si3.doctype, "voucher_type": si3.doctype,
"voucher_no": si3.name, "voucher_no": si3.name,
@ -317,18 +334,21 @@ class TestPaymentLedgerEntry(FrappeTestCase):
"against_voucher_no": si3.name, "against_voucher_no": si3.name,
"amount": amount, "amount": amount,
"delinked": 0, "delinked": 0,
}, }
]
# credit/debit notes post ledger entries against itself
expected_values_for_cr_note1 = [
{ {
"voucher_type": cr_note1.doctype, "voucher_type": cr_note1.doctype,
"voucher_no": cr_note1.name, "voucher_no": cr_note1.name,
"against_voucher_type": si3.doctype, "against_voucher_type": cr_note1.doctype,
"against_voucher_no": si3.name, "against_voucher_no": cr_note1.name,
"amount": -amount, "amount": -amount,
"delinked": 0, "delinked": 0,
}, },
] ]
self.assertEqual(pl_entries[0], expected_values[0]) self.assertEqual(pl_entries_si3, expected_values_for_si3)
self.assertEqual(pl_entries[1], expected_values[1]) self.assertEqual(pl_entries_cr_note1, expected_values_for_cr_note1)
def test_je_against_inv_and_note(self): def test_je_against_inv_and_note(self):
ple = self.ple ple = self.ple

View File

@ -24,7 +24,8 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
filters: { filters: {
"company": this.frm.doc.company, "company": this.frm.doc.company,
"is_group": 0, "is_group": 0,
"account_type": frappe.boot.party_account_types[this.frm.doc.party_type] "account_type": frappe.boot.party_account_types[this.frm.doc.party_type],
"root_type": this.frm.doc.party_type == 'Customer' ? "Asset" : "Liability"
} }
}; };
}); });
@ -163,6 +164,15 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo
this.frm.refresh(); this.frm.refresh();
} }
invoice_name() {
this.frm.trigger("get_unreconciled_entries");
}
payment_name() {
this.frm.trigger("get_unreconciled_entries");
}
clear_child_tables() { clear_child_tables() {
this.frm.clear_table("invoices"); this.frm.clear_table("invoices");
this.frm.clear_table("payments"); this.frm.clear_table("payments");

View File

@ -27,8 +27,10 @@
"bank_cash_account", "bank_cash_account",
"cost_center", "cost_center",
"sec_break1", "sec_break1",
"invoice_name",
"invoices", "invoices",
"column_break_15", "column_break_15",
"payment_name",
"payments", "payments",
"sec_break2", "sec_break2",
"allocation" "allocation"
@ -137,6 +139,7 @@
"label": "Minimum Invoice Amount" "label": "Minimum Invoice Amount"
}, },
{ {
"default": "50",
"description": "System will fetch all the entries if limit value is zero.", "description": "System will fetch all the entries if limit value is zero.",
"fieldname": "invoice_limit", "fieldname": "invoice_limit",
"fieldtype": "Int", "fieldtype": "Int",
@ -167,6 +170,7 @@
"label": "Maximum Payment Amount" "label": "Maximum Payment Amount"
}, },
{ {
"default": "50",
"description": "System will fetch all the entries if limit value is zero.", "description": "System will fetch all the entries if limit value is zero.",
"fieldname": "payment_limit", "fieldname": "payment_limit",
"fieldtype": "Int", "fieldtype": "Int",
@ -194,13 +198,23 @@
"label": "Default Advance Account", "label": "Default Advance Account",
"mandatory_depends_on": "doc.party_type", "mandatory_depends_on": "doc.party_type",
"options": "Account" "options": "Account"
},
{
"fieldname": "invoice_name",
"fieldtype": "Data",
"label": "Filter on Invoice"
},
{
"fieldname": "payment_name",
"fieldtype": "Data",
"label": "Filter on Payment"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
"icon": "icon-resize-horizontal", "icon": "icon-resize-horizontal",
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2023-06-09 13:02:48.718362", "modified": "2023-08-15 05:35:50.109290",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Reconciliation", "name": "Payment Reconciliation",

View File

@ -5,6 +5,7 @@
import frappe import frappe
from frappe import _, msgprint, qb from frappe import _, msgprint, qb
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import Criterion
from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
@ -18,7 +19,7 @@ from erpnext.accounts.utils import (
get_outstanding_invoices, get_outstanding_invoices,
reconcile_against_document, reconcile_against_document,
) )
from erpnext.controllers.accounts_controller import get_advance_payment_entries from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional
class PaymentReconciliation(Document): class PaymentReconciliation(Document):
@ -74,7 +75,10 @@ class PaymentReconciliation(Document):
} }
) )
payment_entries = get_advance_payment_entries( if self.payment_name:
condition.update({"name": self.payment_name})
payment_entries = get_advance_payment_entries_for_regional(
self.party_type, self.party_type,
self.party, self.party,
party_account, party_account,
@ -89,6 +93,9 @@ class PaymentReconciliation(Document):
def get_jv_entries(self): def get_jv_entries(self):
condition = self.get_conditions() condition = self.get_conditions()
if self.payment_name:
condition += f" and t1.name like '%%{self.payment_name}%%'"
if self.get("cost_center"): if self.get("cost_center"):
condition += f" and t2.cost_center = '{self.cost_center}' " condition += f" and t2.cost_center = '{self.cost_center}' "
@ -109,7 +116,7 @@ class PaymentReconciliation(Document):
"Journal Entry" as reference_type, t1.name as reference_name, "Journal Entry" as reference_type, t1.name as reference_name,
t1.posting_date, t1.remark as remarks, t2.name as reference_row, t1.posting_date, t1.remark as remarks, t2.name as reference_row,
{dr_or_cr} as amount, t2.is_advance, t2.exchange_rate, {dr_or_cr} as amount, t2.is_advance, t2.exchange_rate,
t2.account_currency as currency t2.account_currency as currency, t2.cost_center as cost_center
from from
`tabJournal Entry` t1, `tabJournal Entry Account` t2 `tabJournal Entry` t1, `tabJournal Entry Account` t2
where where
@ -146,6 +153,15 @@ class PaymentReconciliation(Document):
def get_return_invoices(self): def get_return_invoices(self):
voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
doc = qb.DocType(voucher_type) doc = qb.DocType(voucher_type)
conditions = []
conditions.append(doc.docstatus == 1)
conditions.append(doc[frappe.scrub(self.party_type)] == self.party)
conditions.append(doc.is_return == 1)
if self.payment_name:
conditions.append(doc.name.like(f"%{self.payment_name}%"))
self.return_invoices = ( self.return_invoices = (
qb.from_(doc) qb.from_(doc)
.select( .select(
@ -153,11 +169,7 @@ class PaymentReconciliation(Document):
doc.name.as_("voucher_no"), doc.name.as_("voucher_no"),
doc.return_against, doc.return_against,
) )
.where( .where(Criterion.all(conditions))
(doc.docstatus == 1)
& (doc[frappe.scrub(self.party_type)] == self.party)
& (doc.is_return == 1)
)
.run(as_dict=True) .run(as_dict=True)
) )
@ -174,15 +186,12 @@ class PaymentReconciliation(Document):
self.common_filter_conditions.append(ple.account == self.receivable_payable_account) self.common_filter_conditions.append(ple.account == self.receivable_payable_account)
self.get_return_invoices() self.get_return_invoices()
return_invoices = [
x for x in self.return_invoices if x.return_against == None or x.return_against == ""
]
outstanding_dr_or_cr = [] outstanding_dr_or_cr = []
if return_invoices: if self.return_invoices:
ple_query = QueryPaymentLedger() ple_query = QueryPaymentLedger()
return_outstanding = ple_query.get_voucher_outstandings( return_outstanding = ple_query.get_voucher_outstandings(
vouchers=return_invoices, vouchers=self.return_invoices,
common_filter=self.common_filter_conditions, common_filter=self.common_filter_conditions,
posting_date=self.ple_posting_date_filter, posting_date=self.ple_posting_date_filter,
min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None, min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None,
@ -200,6 +209,7 @@ class PaymentReconciliation(Document):
"amount": -(inv.outstanding_in_account_currency), "amount": -(inv.outstanding_in_account_currency),
"posting_date": inv.posting_date, "posting_date": inv.posting_date,
"currency": inv.currency, "currency": inv.currency,
"cost_center": inv.cost_center,
} }
) )
) )
@ -226,6 +236,8 @@ class PaymentReconciliation(Document):
min_outstanding=self.minimum_invoice_amount if self.minimum_invoice_amount else None, min_outstanding=self.minimum_invoice_amount if self.minimum_invoice_amount else None,
max_outstanding=self.maximum_invoice_amount if self.maximum_invoice_amount else None, max_outstanding=self.maximum_invoice_amount if self.maximum_invoice_amount else None,
accounting_dimensions=self.accounting_dimension_filter_conditions, accounting_dimensions=self.accounting_dimension_filter_conditions,
limit=self.invoice_limit,
voucher_no=self.invoice_name,
) )
cr_dr_notes = ( cr_dr_notes = (
@ -346,10 +358,12 @@ class PaymentReconciliation(Document):
"allocated_amount": allocated_amount, "allocated_amount": allocated_amount,
"difference_amount": pay.get("difference_amount"), "difference_amount": pay.get("difference_amount"),
"currency": inv.get("currency"), "currency": inv.get("currency"),
"cost_center": pay.get("cost_center"),
} }
) )
def reconcile_allocations(self, skip_ref_details_update_for_pe=False): def reconcile_allocations(self, skip_ref_details_update_for_pe=False):
adjust_allocations_for_taxes(self)
dr_or_cr = ( dr_or_cr = (
"credit_in_account_currency" "credit_in_account_currency"
if erpnext.get_party_account_type(self.party_type) == "Receivable" if erpnext.get_party_account_type(self.party_type) == "Receivable"
@ -420,6 +434,7 @@ class PaymentReconciliation(Document):
"allocated_amount": flt(row.get("allocated_amount")), "allocated_amount": flt(row.get("allocated_amount")),
"difference_amount": flt(row.get("difference_amount")), "difference_amount": flt(row.get("difference_amount")),
"difference_account": row.get("difference_account"), "difference_account": row.get("difference_account"),
"cost_center": row.get("cost_center"),
} }
) )
@ -592,7 +607,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
inv.dr_or_cr: abs(inv.allocated_amount), inv.dr_or_cr: abs(inv.allocated_amount),
"reference_type": inv.against_voucher_type, "reference_type": inv.against_voucher_type,
"reference_name": inv.against_voucher, "reference_name": inv.against_voucher,
"cost_center": erpnext.get_default_cost_center(company), "cost_center": inv.cost_center or erpnext.get_default_cost_center(company),
"exchange_rate": inv.exchange_rate, "exchange_rate": inv.exchange_rate,
"user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} against {inv.against_voucher}", "user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} against {inv.against_voucher}",
}, },
@ -607,7 +622,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
), ),
"reference_type": inv.voucher_type, "reference_type": inv.voucher_type,
"reference_name": inv.voucher_no, "reference_name": inv.voucher_no,
"cost_center": erpnext.get_default_cost_center(company), "cost_center": inv.cost_center or erpnext.get_default_cost_center(company),
"exchange_rate": inv.exchange_rate, "exchange_rate": inv.exchange_rate,
"user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} from {inv.voucher_no}", "user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} from {inv.voucher_no}",
}, },
@ -633,6 +648,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
create_gain_loss_journal( create_gain_loss_journal(
company, company,
today(),
inv.party_type, inv.party_type,
inv.party, inv.party,
inv.account, inv.account,
@ -646,4 +662,10 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
inv.against_voucher_type, inv.against_voucher_type,
inv.against_voucher, inv.against_voucher,
None, None,
inv.cost_center,
) )
@erpnext.allow_regional
def adjust_allocations_for_taxes(doc):
pass

View File

@ -22,7 +22,8 @@
"column_break_7", "column_break_7",
"difference_account", "difference_account",
"exchange_rate", "exchange_rate",
"currency" "currency",
"cost_center"
], ],
"fields": [ "fields": [
{ {
@ -144,11 +145,17 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Exchange Rate", "label": "Exchange Rate",
"read_only": 1 "read_only": 1
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-12-24 21:01:14.882747", "modified": "2023-09-03 07:52:33.684217",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Reconciliation Allocation", "name": "Payment Reconciliation Allocation",

View File

@ -16,7 +16,8 @@
"sec_break1", "sec_break1",
"remark", "remark",
"currency", "currency",
"exchange_rate" "exchange_rate",
"cost_center"
], ],
"fields": [ "fields": [
{ {
@ -98,11 +99,17 @@
"fieldtype": "Float", "fieldtype": "Float",
"hidden": 1, "hidden": 1,
"label": "Exchange Rate" "label": "Exchange Rate"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-11-08 18:18:36.268760", "modified": "2023-09-03 07:43:29.965353",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Reconciliation Payment", "name": "Payment Reconciliation Payment",

View File

@ -230,6 +230,28 @@
"fieldtype": "Read Only", "fieldtype": "Read Only",
"label": "SWIFT Number" "label": "SWIFT Number"
}, },
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{ {
"depends_on": "eval: doc.payment_request_type == 'Inward'", "depends_on": "eval: doc.payment_request_type == 'Inward'",
"fieldname": "recipient_and_message", "fieldname": "recipient_and_message",
@ -317,9 +339,10 @@
}, },
{ {
"fieldname": "payment_url", "fieldname": "payment_url",
"fieldtype": "Small Text", "fieldtype": "Data",
"hidden": 1, "hidden": 1,
"label": "payment_url", "length": 500,
"options": "URL",
"read_only": 1 "read_only": 1
}, },
{ {
@ -343,6 +366,14 @@
"label": "Payment Account", "label": "Payment Account",
"read_only": 1 "read_only": 1
}, },
{
"fetch_from": "payment_gateway_account.payment_channel",
"fieldname": "payment_channel",
"fieldtype": "Select",
"label": "Payment Channel",
"options": "\nEmail\nPhone",
"read_only": 1
},
{ {
"fieldname": "payment_order", "fieldname": "payment_order",
"fieldtype": "Link", "fieldtype": "Link",
@ -358,43 +389,13 @@
"options": "Payment Request", "options": "Payment Request",
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
},
{
"fetch_from": "payment_gateway_account.payment_channel",
"fieldname": "payment_channel",
"fieldtype": "Select",
"label": "Payment Channel",
"options": "\nEmail\nPhone",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
} }
], ],
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-12-21 16:56:40.115737", "modified": "2023-09-27 09:51:42.277638",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Request", "name": "Payment Request",

View File

@ -249,7 +249,7 @@ class PaymentRequest(Document):
if ( if (
party_account_currency == ref_doc.company_currency and party_account_currency != self.currency party_account_currency == ref_doc.company_currency and party_account_currency != self.currency
): ):
party_amount = ref_doc.base_grand_total party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total")
else: else:
party_amount = self.grand_total party_amount = self.grand_total

View File

@ -8,6 +8,7 @@
"transaction_date", "transaction_date",
"posting_date", "posting_date",
"fiscal_year", "fiscal_year",
"year_start_date",
"amended_from", "amended_from",
"company", "company",
"column_break1", "column_break1",
@ -100,16 +101,22 @@
"fieldtype": "Text", "fieldtype": "Text",
"label": "Error Message", "label": "Error Message",
"read_only": 1 "read_only": 1
},
{
"fieldname": "year_start_date",
"fieldtype": "Date",
"label": "Year Start Date"
} }
], ],
"icon": "fa fa-file-text", "icon": "fa fa-file-text",
"idx": 1, "idx": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-07-20 14:51:04.714154", "modified": "2023-09-11 20:19:11.810533",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Period Closing Voucher", "name": "Period Closing Voucher",
"naming_rule": "Expression (old style)",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@ -144,5 +151,6 @@
"search_fields": "posting_date, fiscal_year", "search_fields": "posting_date, fiscal_year",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"title_field": "closing_account_head" "title_field": "closing_account_head"
} }

View File

@ -33,7 +33,7 @@ class PeriodClosingVoucher(AccountsController):
def on_cancel(self): def on_cancel(self):
self.validate_future_closing_vouchers() self.validate_future_closing_vouchers()
self.db_set("gle_processing_status", "In Progress") self.db_set("gle_processing_status", "In Progress")
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
gle_count = frappe.db.count( gle_count = frappe.db.count(
"GL Entry", "GL Entry",
{"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0}, {"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
@ -95,15 +95,23 @@ class PeriodClosingVoucher(AccountsController):
self.check_if_previous_year_closed() self.check_if_previous_year_closed()
pce = frappe.db.sql( pcv = frappe.qb.DocType("Period Closing Voucher")
"""select name from `tabPeriod Closing Voucher` existing_entry = (
where posting_date > %s and fiscal_year = %s and docstatus = 1 and company = %s""", frappe.qb.from_(pcv)
(self.posting_date, self.fiscal_year, self.company), .select(pcv.name)
.where(
(pcv.posting_date >= self.posting_date)
& (pcv.fiscal_year == self.fiscal_year)
& (pcv.docstatus == 1)
& (pcv.company == self.company)
)
.run()
) )
if pce and pce[0][0]:
if existing_entry and existing_entry[0][0]:
frappe.throw( frappe.throw(
_("Another Period Closing Entry {0} has been made after {1}").format( _("Another Period Closing Entry {0} has been made after {1}").format(
pce[0][0], self.posting_date existing_entry[0][0], self.posting_date
) )
) )
@ -126,22 +134,31 @@ class PeriodClosingVoucher(AccountsController):
def make_gl_entries(self, get_opening_entries=False): def make_gl_entries(self, get_opening_entries=False):
gl_entries = self.get_gl_entries() gl_entries = self.get_gl_entries()
closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries) closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries)
if len(gl_entries) > 5000: if len(gl_entries + closing_entries) > 3000:
frappe.enqueue( frappe.enqueue(
process_gl_entries, process_gl_entries,
gl_entries=gl_entries, gl_entries=gl_entries,
voucher_name=self.name,
timeout=3000,
)
frappe.enqueue(
process_closing_entries,
gl_entries=gl_entries,
closing_entries=closing_entries, closing_entries=closing_entries,
voucher_name=self.name, voucher_name=self.name,
company=self.company, company=self.company,
closing_date=self.posting_date, closing_date=self.posting_date,
queue="long", timeout=3000,
) )
frappe.msgprint( frappe.msgprint(
_("The GL Entries will be processed in the background, it can take a few minutes."), _("The GL Entries will be processed in the background, it can take a few minutes."),
alert=True, alert=True,
) )
else: else:
process_gl_entries(gl_entries, closing_entries, self.name, self.company, self.posting_date) process_gl_entries(gl_entries, self.name)
process_closing_entries(gl_entries, closing_entries, self.name, self.company, self.posting_date)
def get_grouped_gl_entries(self, get_opening_entries=False): def get_grouped_gl_entries(self, get_opening_entries=False):
closing_entries = [] closing_entries = []
@ -322,17 +339,12 @@ class PeriodClosingVoucher(AccountsController):
return query.run(as_dict=1) return query.run(as_dict=1)
def process_gl_entries(gl_entries, closing_entries, voucher_name, company, closing_date): def process_gl_entries(gl_entries, voucher_name):
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
make_closing_entries,
)
from erpnext.accounts.general_ledger import make_gl_entries from erpnext.accounts.general_ledger import make_gl_entries
try: try:
if gl_entries: if gl_entries:
make_gl_entries(gl_entries, merge_entries=False) make_gl_entries(gl_entries, merge_entries=False)
make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date)
frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Completed") frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Completed")
except Exception as e: except Exception as e:
frappe.db.rollback() frappe.db.rollback()
@ -340,6 +352,19 @@ def process_gl_entries(gl_entries, closing_entries, voucher_name, company, closi
frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Failed") frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Failed")
def process_closing_entries(gl_entries, closing_entries, voucher_name, company, closing_date):
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
make_closing_entries,
)
try:
if gl_entries + closing_entries:
make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date)
except Exception as e:
frappe.db.rollback()
frappe.log_error(e)
def make_reverse_gl_entries(voucher_type, voucher_no): def make_reverse_gl_entries(voucher_type, voucher_no):
from erpnext.accounts.general_ledger import make_reverse_gl_entries from erpnext.accounts.general_ledger import make_reverse_gl_entries

View File

@ -10,7 +10,7 @@ from frappe.utils import today
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.utils import get_fiscal_year, now from erpnext.accounts.utils import get_fiscal_year
class TestPeriodClosingVoucher(unittest.TestCase): class TestPeriodClosingVoucher(unittest.TestCase):

View File

@ -5,6 +5,10 @@ import unittest
import frappe import frappe
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
)
from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import ( from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import (
make_closing_entry_from_opening, make_closing_entry_from_opening,
) )
@ -140,6 +144,43 @@ class TestPOSClosingEntry(unittest.TestCase):
pos_inv1.load_from_db() pos_inv1.load_from_db()
self.assertEqual(pos_inv1.status, "Paid") self.assertEqual(pos_inv1.status, "Paid")
def test_pos_closing_for_required_accounting_dimension_in_pos_profile(self):
"""
test case to check whether we can create POS Closing Entry without mandatory accounting dimension
"""
create_dimension()
pos_profile = make_pos_profile(do_not_insert=1, do_not_set_accounting_dimension=1)
self.assertRaises(frappe.ValidationError, pos_profile.insert)
pos_profile.location = "Block 1"
pos_profile.insert()
self.assertTrue(frappe.db.exists("POS Profile", pos_profile.name))
test_user = init_user_and_profile(do_not_create_pos_profile=1)
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_inv1 = create_pos_invoice(rate=350, do_not_submit=1, pos_profile=pos_profile.name)
pos_inv1.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
pos_inv1.submit()
# if in between a mandatory accounting dimension is added to the POS Profile then
accounting_dimension_department = frappe.get_doc("Accounting Dimension", {"name": "Department"})
accounting_dimension_department.dimension_defaults[0].mandatory_for_bs = 1
accounting_dimension_department.save()
pcv_doc = make_closing_entry_from_opening(opening_entry)
# will assert coz the new mandatory accounting dimension bank is not set in POS Profile
self.assertRaises(frappe.ValidationError, pcv_doc.submit)
accounting_dimension_department = frappe.get_doc(
"Accounting Dimension Detail", {"parent": "Department"}
)
accounting_dimension_department.mandatory_for_bs = 0
accounting_dimension_department.save()
disable_dimension()
def init_user_and_profile(**args): def init_user_and_profile(**args):
user = "test@example.com" user = "test@example.com"
@ -149,6 +190,9 @@ def init_user_and_profile(**args):
test_user.add_roles(*roles) test_user.add_roles(*roles)
frappe.set_user(user) frappe.set_user(user)
if args.get("do_not_create_pos_profile"):
return test_user
pos_profile = make_pos_profile(**args) pos_profile = make_pos_profile(**args)
pos_profile.append("applicable_for_users", {"default": 1, "user": user}) pos_profile.append("applicable_for_users", {"default": 1, "user": user})

View File

@ -414,7 +414,7 @@ class POSInvoice(SalesInvoice):
selling_price_list = ( selling_price_list = (
customer_price_list or customer_group_price_list or profile.get("selling_price_list") customer_price_list or customer_group_price_list or profile.get("selling_price_list")
) )
if customer_currency != profile.get("currency"): if customer_currency and customer_currency != profile.get("currency"):
self.set("currency", customer_currency) self.set("currency", customer_currency)
else: else:

View File

@ -12,6 +12,8 @@ from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
from frappe.utils.background_jobs import enqueue, is_job_enqueued from frappe.utils.background_jobs import enqueue, is_job_enqueued
from frappe.utils.scheduler import is_scheduler_inactive from frappe.utils.scheduler import is_scheduler_inactive
from erpnext.accounts.doctype.pos_profile.pos_profile import required_accounting_dimensions
class POSInvoiceMergeLog(Document): class POSInvoiceMergeLog(Document):
def validate(self): def validate(self):
@ -163,7 +165,8 @@ class POSInvoiceMergeLog(Document):
for i in items: for i in items:
if ( if (
i.item_code == item.item_code i.item_code == item.item_code
and not i.serial_and_batch_bundle and not i.serial_no
and not i.batch_no
and i.uom == item.uom and i.uom == item.uom
and i.net_rate == item.net_rate and i.net_rate == item.net_rate
and i.warehouse == item.warehouse and i.warehouse == item.warehouse
@ -238,6 +241,22 @@ class POSInvoiceMergeLog(Document):
invoice.disable_rounded_total = cint( invoice.disable_rounded_total = cint(
frappe.db.get_value("POS Profile", invoice.pos_profile, "disable_rounded_total") frappe.db.get_value("POS Profile", invoice.pos_profile, "disable_rounded_total")
) )
accounting_dimensions = required_accounting_dimensions()
dimension_values = frappe.db.get_value(
"POS Profile", {"name": invoice.pos_profile}, accounting_dimensions, as_dict=1
)
for dimension in accounting_dimensions:
dimension_value = dimension_values.get(dimension)
if not dimension_value:
frappe.throw(
_("Please set Accounting Dimension {} in {}").format(
frappe.bold(frappe.unscrub(dimension)),
frappe.get_desk_link("POS Profile", invoice.pos_profile),
)
)
invoice.set(dimension, dimension_value)
if self.merge_invoices_based_on == "Customer Group": if self.merge_invoices_based_on == "Customer Group":
invoice.flags.ignore_pos_profile = True invoice.flags.ignore_pos_profile = True
@ -424,11 +443,9 @@ def create_merge_logs(invoice_by_customer, closing_entry=None):
) )
merge_log.customer = customer merge_log.customer = customer
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
merge_log.set("pos_invoices", _invoices) merge_log.set("pos_invoices", _invoices)
merge_log.save(ignore_permissions=True) merge_log.save(ignore_permissions=True)
merge_log.submit() merge_log.submit()
if closing_entry: if closing_entry:
closing_entry.set_status(update=True, status="Submitted") closing_entry.set_status(update=True, status="Submitted")
closing_entry.db_set("error_message", "") closing_entry.db_set("error_message", "")

View File

@ -1,6 +1,5 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
frappe.ui.form.on('POS Profile', { frappe.ui.form.on('POS Profile', {
setup: function(frm) { setup: function(frm) {
frm.set_query("selling_price_list", function() { frm.set_query("selling_price_list", function() {
@ -140,6 +139,7 @@ frappe.ui.form.on('POS Profile', {
company: function(frm) { company: function(frm) {
frm.trigger("toggle_display_account_head"); frm.trigger("toggle_display_account_head");
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
}, },
toggle_display_account_head: function(frm) { toggle_display_account_head: function(frm) {

View File

@ -3,7 +3,7 @@
import frappe import frappe
from frappe import _, msgprint from frappe import _, msgprint, scrub, unscrub
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import get_link_to_form, now from frappe.utils import get_link_to_form, now
@ -14,6 +14,21 @@ class POSProfile(Document):
self.validate_all_link_fields() self.validate_all_link_fields()
self.validate_duplicate_groups() self.validate_duplicate_groups()
self.validate_payment_methods() self.validate_payment_methods()
self.validate_accounting_dimensions()
def validate_accounting_dimensions(self):
acc_dim_names = required_accounting_dimensions()
for acc_dim in acc_dim_names:
if not self.get(acc_dim):
frappe.throw(
_(
"{0} is a mandatory Accounting Dimension. <br>"
"Please set a value for {0} in Accounting Dimensions section."
).format(
unscrub(frappe.bold(acc_dim)),
),
title=_("Mandatory Accounting Dimension"),
)
def validate_default_profile(self): def validate_default_profile(self):
for row in self.applicable_for_users: for row in self.applicable_for_users:
@ -152,6 +167,24 @@ def get_child_nodes(group_type, root):
) )
def required_accounting_dimensions():
p = frappe.qb.DocType("Accounting Dimension")
c = frappe.qb.DocType("Accounting Dimension Detail")
acc_dim_doc = (
frappe.qb.from_(p)
.inner_join(c)
.on(p.name == c.parent)
.select(c.parent)
.where((c.mandatory_for_bs == 1) | (c.mandatory_for_pl == 1))
.where(p.disabled == 0)
).run(as_dict=1)
acc_dim_names = [scrub(d.parent) for d in acc_dim_doc]
return acc_dim_names
@frappe.whitelist() @frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def pos_profile_query(doctype, txt, searchfield, start, page_len, filters): def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):

View File

@ -5,7 +5,10 @@ import unittest
import frappe import frappe
from erpnext.accounts.doctype.pos_profile.pos_profile import get_child_nodes from erpnext.accounts.doctype.pos_profile.pos_profile import (
get_child_nodes,
required_accounting_dimensions,
)
from erpnext.stock.get_item_details import get_pos_profile from erpnext.stock.get_item_details import get_pos_profile
test_dependencies = ["Item"] test_dependencies = ["Item"]
@ -118,6 +121,7 @@ def make_pos_profile(**args):
"warehouse": args.warehouse or "_Test Warehouse - _TC", "warehouse": args.warehouse or "_Test Warehouse - _TC",
"write_off_account": args.write_off_account or "_Test Write Off - _TC", "write_off_account": args.write_off_account or "_Test Write Off - _TC",
"write_off_cost_center": args.write_off_cost_center or "_Test Write Off Cost Center - _TC", "write_off_cost_center": args.write_off_cost_center or "_Test Write Off Cost Center - _TC",
"location": "Block 1" if not args.do_not_set_accounting_dimension else None,
} }
) )
@ -132,6 +136,7 @@ def make_pos_profile(**args):
pos_profile.append("payments", {"mode_of_payment": "Cash", "default": 1}) pos_profile.append("payments", {"mode_of_payment": "Cash", "default": 1})
if not frappe.db.exists("POS Profile", args.name or "_Test POS Profile"): if not frappe.db.exists("POS Profile", args.name or "_Test POS Profile"):
pos_profile.insert() if not args.get("do_not_insert"):
pos_profile.insert()
return pos_profile return pos_profile

View File

@ -129,7 +129,7 @@ def trigger_job_for_doc(docname: str | None = None):
frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Running") frappe.db.set_value("Process Payment Reconciliation", docname, "status", "Running")
job_name = f"start_processing_{docname}" job_name = f"start_processing_{docname}"
if not is_job_running(job_name): if not is_job_running(job_name):
job = frappe.enqueue( frappe.enqueue(
method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile_based_on_filters", method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile_based_on_filters",
queue="long", queue="long",
is_async=True, is_async=True,
@ -147,7 +147,7 @@ def trigger_job_for_doc(docname: str | None = None):
# Resume tasks for running doc # Resume tasks for running doc
job_name = f"start_processing_{docname}" job_name = f"start_processing_{docname}"
if not is_job_running(job_name): if not is_job_running(job_name):
job = frappe.enqueue( frappe.enqueue(
method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile_based_on_filters", method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile_based_on_filters",
queue="long", queue="long",
is_async=True, is_async=True,
@ -224,7 +224,7 @@ def reconcile_based_on_filters(doc: None | str = None) -> None:
job_name = f"process_{doc}_fetch_and_allocate" job_name = f"process_{doc}_fetch_and_allocate"
if not is_job_running(job_name): if not is_job_running(job_name):
job = frappe.enqueue( frappe.enqueue(
method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.fetch_and_allocate", method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.fetch_and_allocate",
queue="long", queue="long",
timeout="3600", timeout="3600",
@ -245,7 +245,7 @@ def reconcile_based_on_filters(doc: None | str = None) -> None:
if not allocated: if not allocated:
job_name = f"process__{doc}_fetch_and_allocate" job_name = f"process__{doc}_fetch_and_allocate"
if not is_job_running(job_name): if not is_job_running(job_name):
job = frappe.enqueue( frappe.enqueue(
method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.fetch_and_allocate", method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.fetch_and_allocate",
queue="long", queue="long",
timeout="3600", timeout="3600",
@ -263,7 +263,7 @@ def reconcile_based_on_filters(doc: None | str = None) -> None:
else: else:
reconcile_job_name = f"process_{doc}_reconcile" reconcile_job_name = f"process_{doc}_reconcile"
if not is_job_running(reconcile_job_name): if not is_job_running(reconcile_job_name):
job = frappe.enqueue( frappe.enqueue(
method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile", method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile",
queue="long", queue="long",
timeout="3600", timeout="3600",
@ -350,7 +350,7 @@ def fetch_and_allocate(doc: str) -> None:
reconcile_job_name = f"process_{doc}_reconcile" reconcile_job_name = f"process_{doc}_reconcile"
if not is_job_running(reconcile_job_name): if not is_job_running(reconcile_job_name):
job = frappe.enqueue( frappe.enqueue(
method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile", method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile",
queue="long", queue="long",
timeout="3600", timeout="3600",
@ -462,7 +462,7 @@ def reconcile(doc: None | str = None) -> None:
reconcile_job_name = f"process_{doc}_reconcile" reconcile_job_name = f"process_{doc}_reconcile"
if not is_job_running(reconcile_job_name): if not is_job_running(reconcile_job_name):
job = frappe.enqueue( frappe.enqueue(
method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile", method="erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation.reconcile",
queue="long", queue="long",
timeout="3600", timeout="3600",

View File

@ -51,6 +51,7 @@
"column_break_21", "column_break_21",
"start_date", "start_date",
"section_break_33", "section_break_33",
"pdf_name",
"subject", "subject",
"column_break_28", "column_break_28",
"cc_to", "cc_to",
@ -275,7 +276,7 @@
"fieldname": "help_text", "fieldname": "help_text",
"fieldtype": "HTML", "fieldtype": "HTML",
"label": "Help Text", "label": "Help Text",
"options": "<br>\n<h4>Note</h4>\n<ul>\n<li>\nYou can use <a href=\"https://jinja.palletsprojects.com/en/2.11.x/\" target=\"_blank\">Jinja tags</a> in <b>Subject</b> and <b>Body</b> fields for dynamic values.\n</li><li>\n All fields in this doctype are available under the <b>doc</b> object and all fields for the customer to whom the mail will go to is available under the <b>customer</b> object.\n</li></ul>\n<h4> Examples</h4>\n<!-- {% raw %} -->\n<ul>\n <li><b>Subject</b>:<br><br><pre><code>Statement Of Accounts for {{ customer.name }}</code></pre><br></li>\n <li><b>Body</b>: <br><br>\n<pre><code>Hello {{ customer.name }},<br>PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}.</code> </pre></li>\n</ul>\n<!-- {% endraw %} -->" "options": "<br>\n<h4>Note</h4>\n<ul>\n<li>\nYou can use <a href=\"https://jinja.palletsprojects.com/en/2.11.x/\" target=\"_blank\">Jinja tags</a> in <b>Subject</b> and <b>Body</b> fields for dynamic values.\n</li><li>\n All fields in this doctype are available under the <b>doc</b> object and all fields for the customer to whom the mail will go to is available under the <b>customer</b> object.\n</li></ul>\n<h4> Examples</h4>\n<!-- {% raw %} -->\n<ul>\n <li><b>Subject</b>:<br><br><pre><code>Statement Of Accounts for {{ customer.customer_name }}</code></pre><br></li>\n <li><b>Body</b>: <br><br>\n<pre><code>Hello {{ customer.customer_name }},<br>PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}.</code> </pre></li>\n</ul>\n<!-- {% endraw %} -->"
}, },
{ {
"fieldname": "subject", "fieldname": "subject",
@ -370,10 +371,15 @@
"fieldname": "based_on_payment_terms", "fieldname": "based_on_payment_terms",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Based On Payment Terms" "label": "Based On Payment Terms"
},
{
"fieldname": "pdf_name",
"fieldtype": "Data",
"label": "PDF Name"
} }
], ],
"links": [], "links": [],
"modified": "2023-06-23 10:13:15.051950", "modified": "2023-08-28 12:59:53.071334",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Process Statement Of Accounts", "name": "Process Statement Of Accounts",

View File

@ -27,7 +27,13 @@ class ProcessStatementOfAccounts(Document):
if not self.subject: if not self.subject:
self.subject = "Statement Of Accounts for {{ customer.customer_name }}" self.subject = "Statement Of Accounts for {{ customer.customer_name }}"
if not self.body: if not self.body:
self.body = "Hello {{ customer.name }},<br>PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}." if self.report == "General Ledger":
body_str = " from {{ doc.from_date }} to {{ doc.to_date }}."
else:
body_str = " until {{ doc.posting_date }}."
self.body = "Hello {{ customer.customer_name }},<br>PFA your Statement Of Accounts" + body_str
if not self.pdf_name:
self.pdf_name = "{{ customer.customer_name }}"
validate_template(self.subject) validate_template(self.subject)
validate_template(self.body) validate_template(self.body)
@ -42,6 +48,20 @@ class ProcessStatementOfAccounts(Document):
def get_report_pdf(doc, consolidated=True): def get_report_pdf(doc, consolidated=True):
statement_dict = get_statement_dict(doc)
if not bool(statement_dict):
return False
elif consolidated:
delimiter = '<div style="page-break-before: always;"></div>' if doc.include_break else ""
result = delimiter.join(list(statement_dict.values()))
return get_pdf(result, {"orientation": doc.orientation})
else:
for customer, statement_html in statement_dict.items():
statement_dict[customer] = get_pdf(statement_html, {"orientation": doc.orientation})
return statement_dict
def get_statement_dict(doc, get_statement_dict=False):
statement_dict = {} statement_dict = {}
ageing = "" ageing = ""
@ -60,31 +80,23 @@ def get_report_pdf(doc, consolidated=True):
if doc.report == "General Ledger": if doc.report == "General Ledger":
filters.update(get_gl_filters(doc, entry, tax_id, presentation_currency)) filters.update(get_gl_filters(doc, entry, tax_id, presentation_currency))
else:
filters.update(get_ar_filters(doc, entry))
if doc.report == "General Ledger":
col, res = get_soa(filters) col, res = get_soa(filters)
for x in [0, -2, -1]: for x in [0, -2, -1]:
res[x]["account"] = res[x]["account"].replace("'", "") res[x]["account"] = res[x]["account"].replace("'", "")
if len(res) == 3: if len(res) == 3:
continue continue
else: else:
filters.update(get_ar_filters(doc, entry))
ar_res = get_ar_soa(filters) ar_res = get_ar_soa(filters)
col, res = ar_res[0], ar_res[1] col, res = ar_res[0], ar_res[1]
if not res:
continue
statement_dict[entry.customer] = get_html(doc, filters, entry, col, res, ageing) statement_dict[entry.customer] = (
[res, ageing] if get_statement_dict else get_html(doc, filters, entry, col, res, ageing)
)
if not bool(statement_dict): return statement_dict
return False
elif consolidated:
delimiter = '<div style="page-break-before: always;"></div>' if doc.include_break else ""
result = delimiter.join(list(statement_dict.values()))
return get_pdf(result, {"orientation": doc.orientation})
else:
for customer, statement_html in statement_dict.items():
statement_dict[customer] = get_pdf(statement_html, {"orientation": doc.orientation})
return statement_dict
def set_ageing(doc, entry): def set_ageing(doc, entry):
@ -97,7 +109,8 @@ def set_ageing(doc, entry):
"range2": 60, "range2": 60,
"range3": 90, "range3": 90,
"range4": 120, "range4": 120,
"customer": entry.customer, "party_type": "Customer",
"party": [entry.customer],
} }
) )
col1, ageing = get_ageing(ageing_filters) col1, ageing = get_ageing(ageing_filters)
@ -140,7 +153,9 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
def get_ar_filters(doc, entry): def get_ar_filters(doc, entry):
return { return {
"report_date": doc.posting_date if doc.posting_date else None, "report_date": doc.posting_date if doc.posting_date else None,
"customer": entry.customer, "party_type": "Customer",
"party": [entry.customer],
"customer_name": entry.customer_name if entry.customer_name else None,
"payment_terms_template": doc.payment_terms_template if doc.payment_terms_template else None, "payment_terms_template": doc.payment_terms_template if doc.payment_terms_template else None,
"sales_partner": doc.sales_partner if doc.sales_partner else None, "sales_partner": doc.sales_partner if doc.sales_partner else None,
"sales_person": doc.sales_person if doc.sales_person else None, "sales_person": doc.sales_person if doc.sales_person else None,
@ -366,18 +381,20 @@ def download_statements(document_name):
@frappe.whitelist() @frappe.whitelist()
def send_emails(document_name, from_scheduler=False): def send_emails(document_name, from_scheduler=False, posting_date=None):
doc = frappe.get_doc("Process Statement Of Accounts", document_name) doc = frappe.get_doc("Process Statement Of Accounts", document_name)
report = get_report_pdf(doc, consolidated=False) report = get_report_pdf(doc, consolidated=False)
if report: if report:
for customer, report_pdf in report.items(): for customer, report_pdf in report.items():
attachments = [{"fname": customer + ".pdf", "fcontent": report_pdf}] context = get_context(customer, doc)
filename = frappe.render_template(doc.pdf_name, context)
attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}]
recipients, cc = get_recipients_and_cc(customer, doc) recipients, cc = get_recipients_and_cc(customer, doc)
if not recipients: if not recipients:
continue continue
context = get_context(customer, doc)
subject = frappe.render_template(doc.subject, context) subject = frappe.render_template(doc.subject, context)
message = frappe.render_template(doc.body, context) message = frappe.render_template(doc.body, context)
@ -396,7 +413,7 @@ def send_emails(document_name, from_scheduler=False):
) )
if doc.enable_auto_email and from_scheduler: if doc.enable_auto_email and from_scheduler:
new_to_date = getdate(today()) new_to_date = getdate(posting_date or today())
if doc.frequency == "Weekly": if doc.frequency == "Weekly":
new_to_date = add_days(new_to_date, 7) new_to_date = add_days(new_to_date, 7)
else: else:
@ -405,8 +422,11 @@ def send_emails(document_name, from_scheduler=False):
doc.add_comment( doc.add_comment(
"Comment", "Emails sent on: " + frappe.utils.format_datetime(frappe.utils.now()) "Comment", "Emails sent on: " + frappe.utils.format_datetime(frappe.utils.now())
) )
doc.db_set("to_date", new_to_date, commit=True) if doc.report == "General Ledger":
doc.db_set("from_date", new_from_date, commit=True) doc.db_set("to_date", new_to_date, commit=True)
doc.db_set("from_date", new_from_date, commit=True)
else:
doc.db_set("posting_date", new_to_date, commit=True)
return True return True
else: else:
return False return False
@ -416,7 +436,8 @@ def send_emails(document_name, from_scheduler=False):
def send_auto_email(): def send_auto_email():
selected = frappe.get_list( selected = frappe.get_list(
"Process Statement Of Accounts", "Process Statement Of Accounts",
filters={"to_date": format_date(today()), "enable_auto_email": 1}, filters={"enable_auto_email": 1},
or_filters={"to_date": format_date(today()), "posting_date": format_date(today())},
) )
for entry in selected: for entry in selected:
send_emails(entry.name, from_scheduler=True) send_emails(entry.name, from_scheduler=True)

View File

@ -8,9 +8,24 @@
} }
</style> </style>
<div id="header-html" class="hidden-pdf">
{% if letter_head.content %}
<div class="letter-head text-center">{{ letter_head.content }}</div>
<hr style="height:2px;border-width:0;color:black;background-color:black;">
{% endif %}
</div>
<div id="footer-html" class="visible-pdf">
{% if letter_head.footer %}
<div class="letter-head-footer">
<hr style="border-width:0;color:black;background-color:black;padding-bottom:2px;">
{{ letter_head.footer }}
</div>
{% endif %}
</div>
<h2 class="text-center" style="margin-top:0">{{ _(report.report_name) }}</h2> <h2 class="text-center" style="margin-top:0">{{ _(report.report_name) }}</h2>
<h4 class="text-center"> <h4 class="text-center">
{{ filters.customer }} {{ filters.customer_name }}
</h4> </h4>
<h6 class="text-center"> <h6 class="text-center">
{% if (filters.tax_id) %} {% if (filters.tax_id) %}
@ -341,4 +356,9 @@
</tbody> </tbody>
</table> </table>
{% endif %} {% endif %}
{% if terms_and_conditions %}
<div>
{{ terms_and_conditions }}
</div>
{% endif %}
<p class="text-right text-muted">{{ _("Printed On ") }}{{ frappe.utils.now() }}</p> <p class="text-right text-muted">{{ _("Printed On ") }}{{ frappe.utils.now() }}</p>

View File

@ -1,9 +1,110 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
# import frappe
import unittest import unittest
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, getdate, today
class TestProcessStatementOfAccounts(unittest.TestCase): from erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts import (
pass get_statement_dict,
send_emails,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
class TestProcessStatementOfAccounts(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.create_company()
self.create_customer()
self.create_customer(customer_name="Other Customer")
self.clear_old_entries()
self.si = create_sales_invoice()
create_sales_invoice(customer="Other Customer")
def test_process_soa_for_gl(self):
"""Tests the utils for Statement of Accounts(General Ledger)"""
process_soa = create_process_soa(
name="_Test Process SOA for GL",
customers=[{"customer": "_Test Customer"}, {"customer": "Other Customer"}],
)
statement_dict = get_statement_dict(process_soa, get_statement_dict=True)
# Checks if the statements are filtered based on the Customer
self.assertIn("Other Customer", statement_dict)
self.assertIn("_Test Customer", statement_dict)
# Checks if the correct number of receivable entries exist
# 3 rows for opening and closing and 1 row for SI
receivable_entries = statement_dict["_Test Customer"][0]
self.assertEqual(len(receivable_entries), 4)
# Checks the amount for the receivable entry
self.assertEqual(receivable_entries[1].voucher_no, self.si.name)
self.assertEqual(receivable_entries[1].balance, 100)
def test_process_soa_for_ar(self):
"""Tests the utils for Statement of Accounts(Accounts Receivable)"""
process_soa = create_process_soa(name="_Test Process SOA for AR", report="Accounts Receivable")
statement_dict = get_statement_dict(process_soa, get_statement_dict=True)
# Checks if the statements are filtered based on the Customer
self.assertNotIn("Other Customer", statement_dict)
self.assertIn("_Test Customer", statement_dict)
# Checks if the correct number of receivable entries exist
receivable_entries = statement_dict["_Test Customer"][0]
self.assertEqual(len(receivable_entries), 1)
# Checks the amount for the receivable entry
self.assertEqual(receivable_entries[0].voucher_no, self.si.name)
self.assertEqual(receivable_entries[0].total_due, 100)
# Checks the ageing summary for AR
ageing_summary = statement_dict["_Test Customer"][1][0]
expected_summary = frappe._dict(
range1=100,
range2=0,
range3=0,
range4=0,
range5=0,
)
self.check_ageing_summary(ageing_summary, expected_summary)
def test_auto_email_for_process_soa_ar(self):
process_soa = create_process_soa(
name="_Test Process SOA", enable_auto_email=1, report="Accounts Receivable"
)
send_emails(process_soa.name, from_scheduler=True)
process_soa.load_from_db()
self.assertEqual(process_soa.posting_date, getdate(add_days(today(), 7)))
def check_ageing_summary(self, ageing, expected_ageing):
for age_range in expected_ageing:
self.assertEqual(expected_ageing[age_range], ageing.get(age_range))
def tearDown(self):
frappe.db.rollback()
def create_process_soa(**args):
args = frappe._dict(args)
frappe.delete_doc_if_exists("Process Statement Of Accounts", args.name)
process_soa = frappe.new_doc("Process Statement Of Accounts")
soa_dict = frappe._dict(
name=args.name,
company=args.company or "_Test Company",
customers=args.customers or [{"customer": "_Test Customer"}],
enable_auto_email=1 if args.enable_auto_email else 0,
frequency=args.frequency or "Weekly",
report=args.report or "General Ledger",
from_date=args.from_date or getdate(today()),
to_date=args.to_date or getdate(today()),
posting_date=args.posting_date or getdate(today()),
include_ageing=1,
)
process_soa.update(soa_dict)
process_soa.save()
return process_soa

View File

@ -0,0 +1,8 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Process Subscription", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,90 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-09-17 15:40:59.724177",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"posting_date",
"subscription",
"amended_from"
],
"fields": [
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Process Subscription",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "posting_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Posting Date",
"reqd": 1
},
{
"fieldname": "subscription",
"fieldtype": "Link",
"label": "Subscription",
"options": "Subscription"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-09-17 17:33:37.974166",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Subscription",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"submit": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,27 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from datetime import datetime
from typing import Union
import frappe
from frappe.model.document import Document
from frappe.utils import getdate
from erpnext.accounts.doctype.subscription.subscription import process_all
class ProcessSubscription(Document):
def on_submit(self):
process_all(subscription=self.subscription, posting_date=self.posting_date)
def create_subscription_process(
subscription: str | None, posting_date: Union[str, datetime.date] | None
):
"""Create a new Process Subscription document"""
doc = frappe.new_doc("Process Subscription")
doc.subscription = subscription
doc.posting_date = getdate(posting_date)
doc.insert(ignore_permissions=True)
doc.submit()

View File

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestProcessSubscription(FrappeTestCase):
pass

View File

@ -65,6 +65,25 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
erpnext.accounts.ledger_preview.show_stock_ledger_preview(this.frm); erpnext.accounts.ledger_preview.show_stock_ledger_preview(this.frm);
} }
if (this.frm.doc.repost_required && this.frm.doc.docstatus===1) {
this.frm.set_intro(__("Accounting entries for this invoice need to be reposted. Please click on 'Repost' button to update."));
this.frm.add_custom_button(__('Repost Accounting Entries'),
() => {
this.frm.call({
doc: this.frm.doc,
method: 'repost_accounting_entries',
freeze: true,
freeze_message: __('Reposting...'),
callback: (r) => {
if (!r.exc) {
frappe.msgprint(__('Accounting Entries are reposted.'));
me.frm.refresh();
}
}
});
}).removeClass('btn-default').addClass('btn-warning');
}
if(!doc.is_return && doc.docstatus == 1 && doc.outstanding_amount != 0){ if(!doc.is_return && doc.docstatus == 1 && doc.outstanding_amount != 0){
if(doc.on_hold) { if(doc.on_hold) {
this.frm.add_custom_button( this.frm.add_custom_button(
@ -86,8 +105,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
} }
} }
if(doc.docstatus == 1 && doc.outstanding_amount != 0 if(doc.docstatus == 1 && doc.outstanding_amount != 0 && !doc.on_hold) {
&& !(doc.is_return && doc.return_against) && !doc.on_hold) {
this.frm.add_custom_button( this.frm.add_custom_button(
__('Payment'), __('Payment'),
() => this.make_payment_entry(), () => this.make_payment_entry(),
@ -162,6 +180,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
} }
this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1); this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1);
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm);
} }
unblock_invoice() { unblock_invoice() {
@ -460,6 +479,12 @@ cur_frm.set_query("expense_account", "items", function(doc) {
} }
}); });
cur_frm.set_query("wip_composite_asset", "items", function() {
return {
filters: {'is_composite_asset': 1, 'docstatus': 0 }
}
});
cur_frm.cscript.expense_account = function(doc, cdt, cdn){ cur_frm.cscript.expense_account = function(doc, cdt, cdn){
var d = locals[cdt][cdn]; var d = locals[cdt][cdn];
if(d.idx == 1 && d.expense_account){ if(d.idx == 1 && d.expense_account){

View File

@ -166,6 +166,7 @@
"against_expense_account", "against_expense_account",
"column_break_63", "column_break_63",
"unrealized_profit_loss_account", "unrealized_profit_loss_account",
"repost_required",
"subscription_section", "subscription_section",
"subscription", "subscription",
"auto_repeat", "auto_repeat",
@ -191,8 +192,7 @@
"inter_company_invoice_reference", "inter_company_invoice_reference",
"is_old_subcontracting_flow", "is_old_subcontracting_flow",
"remarks", "remarks",
"connections_tab", "connections_tab"
"column_break_38"
], ],
"fields": [ "fields": [
{ {
@ -990,6 +990,7 @@
"print_hide": 1 "print_hide": 1
}, },
{ {
"allow_on_submit": 1,
"fieldname": "cash_bank_account", "fieldname": "cash_bank_account",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Cash/Bank Account", "label": "Cash/Bank Account",
@ -1053,6 +1054,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"allow_on_submit": 1,
"depends_on": "eval:flt(doc.write_off_amount)!=0", "depends_on": "eval:flt(doc.write_off_amount)!=0",
"fieldname": "write_off_account", "fieldname": "write_off_account",
"fieldtype": "Link", "fieldtype": "Link",
@ -1217,6 +1219,7 @@
"read_only": 1 "read_only": 1
}, },
{ {
"allow_on_submit": 1,
"default": "No", "default": "No",
"fieldname": "is_opening", "fieldname": "is_opening",
"fieldtype": "Select", "fieldtype": "Select",
@ -1349,6 +1352,7 @@
"options": "Project" "options": "Project"
}, },
{ {
"allow_on_submit": 1,
"depends_on": "eval:doc.is_internal_supplier", "depends_on": "eval:doc.is_internal_supplier",
"description": "Unrealized Profit/Loss account for intra-company transfers", "description": "Unrealized Profit/Loss account for intra-company transfers",
"fieldname": "unrealized_profit_loss_account", "fieldname": "unrealized_profit_loss_account",
@ -1381,6 +1385,7 @@
"depends_on": "eval:doc.is_subcontracted", "depends_on": "eval:doc.is_subcontracted",
"fieldname": "supplier_warehouse", "fieldname": "supplier_warehouse",
"fieldtype": "Link", "fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Supplier Warehouse", "label": "Supplier Warehouse",
"no_copy": 1, "no_copy": 1,
"options": "Warehouse", "options": "Warehouse",
@ -1504,10 +1509,6 @@
"fieldname": "column_break_6", "fieldname": "column_break_6",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "column_break_38",
"fieldtype": "Column Break"
},
{ {
"fieldname": "column_break_50", "fieldname": "column_break_50",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@ -1578,13 +1579,22 @@
"fieldname": "use_company_roundoff_cost_center", "fieldname": "use_company_roundoff_cost_center",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Use Company Default Round Off Cost Center" "label": "Use Company Default Round Off Cost Center"
},
{
"default": "0",
"fieldname": "repost_required",
"fieldtype": "Check",
"hidden": 1,
"label": "Repost Required",
"options": "Account",
"read_only": 1
} }
], ],
"icon": "fa fa-file-text", "icon": "fa fa-file-text",
"idx": 204, "idx": 204,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-07-25 17:22:59.145031", "modified": "2023-10-01 21:01:47.282533",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice", "name": "Purchase Invoice",

View File

@ -11,6 +11,9 @@ from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate,
import erpnext import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date from erpnext.accounts.deferred_revenue import validate_service_stop_date
from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt from erpnext.accounts.doctype.gl_entry.gl_entry import update_outstanding_amt
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
validate_docs_for_deferred_accounting,
)
from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
check_if_return_invoice_linked_with_payment_entry, check_if_return_invoice_linked_with_payment_entry,
get_total_in_party_account_currency, get_total_in_party_account_currency,
@ -266,9 +269,7 @@ class PurchaseInvoice(BuyingController):
stock_not_billed_account = self.get_company_default("stock_received_but_not_billed") stock_not_billed_account = self.get_company_default("stock_received_but_not_billed")
stock_items = self.get_stock_items() stock_items = self.get_stock_items()
asset_items = [d.is_fixed_asset for d in self.items if d.is_fixed_asset] asset_received_but_not_billed = None
if len(asset_items) > 0:
asset_received_but_not_billed = self.get_company_default("asset_received_but_not_billed")
if self.update_stock: if self.update_stock:
self.validate_item_code() self.validate_item_code()
@ -362,6 +363,8 @@ class PurchaseInvoice(BuyingController):
) )
item.expense_account = asset_category_account item.expense_account = asset_category_account
elif item.is_fixed_asset and item.pr_detail: elif item.is_fixed_asset and item.pr_detail:
if not asset_received_but_not_billed:
asset_received_but_not_billed = self.get_company_default("asset_received_but_not_billed")
item.expense_account = asset_received_but_not_billed item.expense_account = asset_received_but_not_billed
elif not item.expense_account and for_validate: elif not item.expense_account and for_validate:
throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name)) throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name))
@ -484,6 +487,11 @@ class PurchaseInvoice(BuyingController):
_("Stock cannot be updated against Purchase Receipt {0}").format(item.purchase_receipt) _("Stock cannot be updated against Purchase Receipt {0}").format(item.purchase_receipt)
) )
def validate_for_repost(self):
self.validate_write_off_account()
self.validate_expense_account()
validate_docs_for_deferred_accounting([], [self.name])
def on_submit(self): def on_submit(self):
super(PurchaseInvoice, self).on_submit() super(PurchaseInvoice, self).on_submit()
@ -522,6 +530,18 @@ class PurchaseInvoice(BuyingController):
self.process_common_party_accounting() self.process_common_party_accounting()
def on_update_after_submit(self):
if hasattr(self, "repost_required"):
fields_to_check = [
"cash_bank_account",
"write_off_account",
"unrealized_profit_loss_account",
]
child_tables = {"items": ("expense_account",), "taxes": ("account_head",)}
self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
self.validate_for_repost()
self.db_set("repost_required", self.needs_repost)
def make_gl_entries(self, gl_entries=None, from_repost=False): def make_gl_entries(self, gl_entries=None, from_repost=False):
if not gl_entries: if not gl_entries:
gl_entries = self.get_gl_entries() gl_entries = self.get_gl_entries()
@ -628,9 +648,7 @@ class PurchaseInvoice(BuyingController):
"credit_in_account_currency": base_grand_total "credit_in_account_currency": base_grand_total
if self.party_account_currency == self.company_currency if self.party_account_currency == self.company_currency
else grand_total, else grand_total,
"against_voucher": self.return_against "against_voucher": self.name,
if cint(self.is_return) and self.return_against
else self.name,
"against_voucher_type": self.doctype, "against_voucher_type": self.doctype,
"project": self.project, "project": self.project,
"cost_center": self.cost_center, "cost_center": self.cost_center,
@ -761,21 +779,22 @@ class PurchaseInvoice(BuyingController):
# Amount added through landed-cost-voucher # Amount added through landed-cost-voucher
if landed_cost_entries: if landed_cost_entries:
for account, amount in landed_cost_entries[(item.item_code, item.name)].items(): if (item.item_code, item.name) in landed_cost_entries:
gl_entries.append( for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
self.get_gl_dict( gl_entries.append(
{ self.get_gl_dict(
"account": account, {
"against": item.expense_account, "account": account,
"cost_center": item.cost_center, "against": item.expense_account,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"), "cost_center": item.cost_center,
"credit": flt(amount["base_amount"]), "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"credit_in_account_currency": flt(amount["amount"]), "credit": flt(amount["base_amount"]),
"project": item.project or self.project, "credit_in_account_currency": flt(amount["amount"]),
}, "project": item.project or self.project,
item=item, },
item=item,
)
) )
)
# sub-contracting warehouse # sub-contracting warehouse
if flt(item.rm_supp_cost): if flt(item.rm_supp_cost):
@ -970,8 +989,9 @@ class PurchaseInvoice(BuyingController):
) )
def get_asset_gl_entry(self, gl_entries): def get_asset_gl_entry(self, gl_entries):
arbnb_account = self.get_company_default("asset_received_but_not_billed") arbnb_account = None
eiiav_account = self.get_company_default("expenses_included_in_asset_valuation") eiiav_account = None
asset_eiiav_currency = None
for item in self.get("items"): for item in self.get("items"):
if item.is_fixed_asset: if item.is_fixed_asset:
@ -983,6 +1003,8 @@ class PurchaseInvoice(BuyingController):
"Asset Received But Not Billed", "Asset Received But Not Billed",
"Fixed Asset", "Fixed Asset",
]: ]:
if not arbnb_account:
arbnb_account = self.get_company_default("asset_received_but_not_billed")
item.expense_account = arbnb_account item.expense_account = arbnb_account
if not self.update_stock: if not self.update_stock:
@ -1005,7 +1027,10 @@ class PurchaseInvoice(BuyingController):
) )
if item.item_tax_amount: if item.item_tax_amount:
asset_eiiav_currency = get_account_currency(eiiav_account) if not eiiav_account or not asset_eiiav_currency:
eiiav_account = self.get_company_default("expenses_included_in_asset_valuation")
asset_eiiav_currency = get_account_currency(eiiav_account)
gl_entries.append( gl_entries.append(
self.get_gl_dict( self.get_gl_dict(
{ {
@ -1048,7 +1073,10 @@ class PurchaseInvoice(BuyingController):
) )
if item.item_tax_amount and not cint(erpnext.is_perpetual_inventory_enabled(self.company)): if item.item_tax_amount and not cint(erpnext.is_perpetual_inventory_enabled(self.company)):
asset_eiiav_currency = get_account_currency(eiiav_account) if not eiiav_account or not asset_eiiav_currency:
eiiav_account = self.get_company_default("expenses_included_in_asset_valuation")
asset_eiiav_currency = get_account_currency(eiiav_account)
gl_entries.append( gl_entries.append(
self.get_gl_dict( self.get_gl_dict(
{ {
@ -1068,47 +1096,46 @@ class PurchaseInvoice(BuyingController):
) )
) )
# When update stock is checked
# Assets are bought through this document then it will be linked to this document # Assets are bought through this document then it will be linked to this document
if self.update_stock: if flt(item.landed_cost_voucher_amount):
if flt(item.landed_cost_voucher_amount): if not eiiav_account:
gl_entries.append( eiiav_account = self.get_company_default("expenses_included_in_asset_valuation")
self.get_gl_dict(
{
"account": eiiav_account,
"against": cwip_account,
"cost_center": item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(item.landed_cost_voucher_amount),
"project": item.project or self.project,
},
item=item,
)
)
gl_entries.append( gl_entries.append(
self.get_gl_dict( self.get_gl_dict(
{ {
"account": cwip_account, "account": eiiav_account,
"against": eiiav_account, "against": cwip_account,
"cost_center": item.cost_center, "cost_center": item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"), "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"debit": flt(item.landed_cost_voucher_amount), "credit": flt(item.landed_cost_voucher_amount),
"project": item.project or self.project, "project": item.project or self.project,
}, },
item=item, item=item,
)
) )
# update gross amount of assets bought through this document
assets = frappe.db.get_all(
"Asset", filters={"purchase_invoice": self.name, "item_code": item.item_code}
) )
for asset in assets:
frappe.db.set_value("Asset", asset.name, "gross_purchase_amount", flt(item.valuation_rate)) gl_entries.append(
frappe.db.set_value( self.get_gl_dict(
"Asset", asset.name, "purchase_receipt_amount", flt(item.valuation_rate) {
"account": cwip_account,
"against": eiiav_account,
"cost_center": item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"debit": flt(item.landed_cost_voucher_amount),
"project": item.project or self.project,
},
item=item,
) )
)
# update gross amount of assets bought through this document
assets = frappe.db.get_all(
"Asset", filters={"purchase_invoice": self.name, "item_code": item.item_code}
)
for asset in assets:
frappe.db.set_value("Asset", asset.name, "gross_purchase_amount", flt(item.valuation_rate))
frappe.db.set_value("Asset", asset.name, "purchase_receipt_amount", flt(item.valuation_rate))
return gl_entries return gl_entries
@ -1644,12 +1671,8 @@ class PurchaseInvoice(BuyingController):
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate(): elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid" self.status = "Unpaid"
# Check if outstanding amount is 0 due to debit note issued against invoice # Check if outstanding amount is 0 due to debit note issued against invoice
elif ( elif self.is_return == 0 and frappe.db.get_value(
outstanding_amount <= 0 "Purchase Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
and self.is_return == 0
and frappe.db.get_value(
"Purchase Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
)
): ):
self.status = "Debit Note Issued" self.status = "Debit Note Issued"
elif self.is_return == 1: elif self.is_return == 1:

View File

@ -1164,7 +1164,7 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
item = create_item("_Test Item for Deferred Accounting", is_purchase_item=True) item = create_item("_Test Item for Deferred Accounting", is_purchase_item=True)
item.enable_deferred_expense = 1 item.enable_deferred_expense = 1
item.deferred_expense_account = deferred_account item.item_defaults[0].deferred_expense_account = deferred_account
item.save() item.save()
pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True) pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True)
@ -1744,7 +1744,6 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
pi = make_purchase_invoice( pi = make_purchase_invoice(
company="_Test Company", company="_Test Company",
customer="_Test Supplier",
do_not_save=True, do_not_save=True,
do_not_submit=True, do_not_submit=True,
rate=1000, rate=1000,
@ -1862,7 +1861,6 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
pi = make_purchase_invoice( pi = make_purchase_invoice(
company="_Test Company", company="_Test Company",
customer="_Test Supplier",
do_not_save=True, do_not_save=True,
do_not_submit=True, do_not_submit=True,
rate=1000, rate=1000,
@ -1892,6 +1890,58 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
clear_dimension_defaults("Branch") clear_dimension_defaults("Branch")
disable_dimension() disable_dimension()
def test_repost_accounting_entries(self):
pi = make_purchase_invoice(
rate=1000,
price_list_rate=1000,
qty=1,
)
expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 1000, 0.0, nowdate()],
["Creditors - _TC", 0.0, 1000, nowdate()],
]
check_gl_entries(self, pi.name, expected_gle, nowdate())
pi.items[0].expense_account = "Service - _TC"
pi.save()
pi.load_from_db()
self.assertTrue(pi.repost_required)
pi.repost_accounting_entries()
expected_gle = [
["Creditors - _TC", 0.0, 1000, nowdate()],
["Service - _TC", 1000, 0.0, nowdate()],
]
check_gl_entries(self, pi.name, expected_gle, nowdate())
pi.load_from_db()
self.assertFalse(pi.repost_required)
@change_settings("Buying Settings", {"supplier_group": None})
def test_purchase_invoice_without_supplier_group(self):
# Create a Supplier
test_supplier_name = "_Test Supplier Without Supplier Group"
if not frappe.db.exists("Supplier", test_supplier_name):
supplier = frappe.get_doc(
{
"doctype": "Supplier",
"supplier_name": test_supplier_name,
}
).insert(ignore_permissions=True)
self.assertEqual(supplier.supplier_group, None)
po = create_purchase_order(
supplier=test_supplier_name,
rate=3000,
item="_Test Non Stock Item",
posting_date="2021-09-15",
)
pi = make_purchase_invoice(supplier=test_supplier_name)
self.assertEqual(po.docstatus, 1)
self.assertEqual(pi.docstatus, 1)
def set_advance_flag(company, flag, default_account): def set_advance_flag(company, flag, default_account):
frappe.db.set_value( frappe.db.set_value(

View File

@ -77,6 +77,7 @@
"manufacturer_part_no", "manufacturer_part_no",
"accounting", "accounting",
"expense_account", "expense_account",
"wip_composite_asset",
"col_break5", "col_break5",
"is_fixed_asset", "is_fixed_asset",
"asset_location", "asset_location",
@ -473,6 +474,7 @@
"label": "Accounting" "label": "Accounting"
}, },
{ {
"allow_on_submit": 1,
"fieldname": "expense_account", "fieldname": "expense_account",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Expense Head", "label": "Expense Head",
@ -902,12 +904,18 @@
"no_copy": 1, "no_copy": 1,
"options": "Serial and Batch Bundle", "options": "Serial and Batch Bundle",
"print_hide": 1 "print_hide": 1
},
{
"fieldname": "wip_composite_asset",
"fieldtype": "Link",
"label": "WIP Composite Asset",
"options": "Asset"
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-07-26 12:54:53.178156", "modified": "2023-10-03 21:01:01.824892",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice Item", "name": "Purchase Invoice Item",

View File

@ -86,6 +86,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"allow_on_submit": 1,
"columns": 2, "columns": 2,
"fieldname": "account_head", "fieldname": "account_head",
"fieldtype": "Link", "fieldtype": "Link",
@ -97,6 +98,7 @@
"reqd": 1 "reqd": 1
}, },
{ {
"allow_on_submit": 1,
"default": ":Company", "default": ":Company",
"fieldname": "cost_center", "fieldname": "cost_center",
"fieldtype": "Link", "fieldtype": "Link",

View File

@ -55,7 +55,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-07-27 15:47:58.975034", "modified": "2023-09-26 14:21:27.362567",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Repost Accounting Ledger", "name": "Repost Accounting Ledger",
@ -77,5 +77,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"track_changes": 1
} }

View File

@ -21,29 +21,8 @@ class RepostAccountingLedger(Document):
def validate_for_deferred_accounting(self): def validate_for_deferred_accounting(self):
sales_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Sales Invoice"] sales_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Sales Invoice"]
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
purchase_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Purchase Invoice"] purchase_docs = [x.voucher_no for x in self.vouchers if x.voucher_type == "Purchase Invoice"]
docs_with_deferred_expense = frappe.db.get_all( validate_docs_for_deferred_accounting(sales_docs, purchase_docs)
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(
_("Documents: {0} have deferred revenue/expense enabled for them. Cannot repost.").format(
frappe.bold(
comma_and([x[0] for x in docs_with_deferred_expense + docs_with_deferred_revenue])
)
)
)
def validate_for_closed_fiscal_year(self): def validate_for_closed_fiscal_year(self):
if self.vouchers: if self.vouchers:
@ -139,14 +118,17 @@ class RepostAccountingLedger(Document):
return rendered_page return rendered_page
def on_submit(self): def on_submit(self):
job_name = "repost_accounting_ledger_" + self.name if len(self.vouchers) > 1:
frappe.enqueue( job_name = "repost_accounting_ledger_" + self.name
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost", frappe.enqueue(
account_repost_doc=self.name, method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
is_async=True, account_repost_doc=self.name,
job_name=job_name, is_async=True,
) job_name=job_name,
frappe.msgprint(_("Repost has started in the background")) )
frappe.msgprint(_("Repost has started in the background"))
else:
start_repost(self.name)
@frappe.whitelist() @frappe.whitelist()
@ -181,3 +163,26 @@ def start_repost(account_repost_doc=str) -> None:
doc.make_gl_entries() doc.make_gl_entries()
frappe.db.commit() frappe.db.commit()
def validate_docs_for_deferred_accounting(sales_docs, purchase_docs):
docs_with_deferred_revenue = frappe.db.get_all(
"Sales Invoice Item",
filters={"parent": ["in", sales_docs], "docstatus": 1, "enable_deferred_revenue": True},
fields=["parent"],
as_list=1,
)
docs_with_deferred_expense = frappe.db.get_all(
"Purchase Invoice Item",
filters={"parent": ["in", purchase_docs], "docstatus": 1, "enable_deferred_expense": 1},
fields=["parent"],
as_list=1,
)
if docs_with_deferred_revenue or docs_with_deferred_expense:
frappe.throw(
_("Documents: {0} have deferred revenue/expense enabled for them. Cannot repost.").format(
frappe.bold(comma_and([x[0] for x in docs_with_deferred_expense + docs_with_deferred_revenue]))
)
)

View File

@ -99,7 +99,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-11-08 07:38:40.079038", "modified": "2023-09-26 14:21:35.719727",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Repost Payment Ledger", "name": "Repost Payment Ledger",
@ -155,5 +155,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": [],
"track_changes": 1
} }

View File

@ -37,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload(); super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger"]; 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payments", "Unreconcile Payment Entries"];
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format // show debit_to in print format
@ -98,8 +98,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
erpnext.accounts.ledger_preview.show_stock_ledger_preview(this.frm); erpnext.accounts.ledger_preview.show_stock_ledger_preview(this.frm);
} }
if (doc.docstatus == 1 && doc.outstanding_amount!=0 if (doc.docstatus == 1 && doc.outstanding_amount!=0) {
&& !(cint(doc.is_return) && doc.return_against)) {
this.frm.add_custom_button( this.frm.add_custom_button(
__('Payment'), __('Payment'),
() => this.make_payment_entry(), () => this.make_payment_entry(),
@ -184,8 +183,11 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
}, __('Create')); }, __('Create'));
} }
} }
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm);
} }
make_maintenance_schedule() { make_maintenance_schedule() {
frappe.model.open_mapped_doc({ frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule", method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule",

View File

@ -11,13 +11,13 @@ from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form
import erpnext import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date from erpnext.accounts.deferred_revenue import validate_service_stop_date
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points, get_loyalty_program_details_with_points,
validate_loyalty_points, validate_loyalty_points,
) )
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
validate_docs_for_deferred_accounting,
)
from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import ( from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category import (
get_party_tax_withholding_details, get_party_tax_withholding_details,
) )
@ -168,6 +168,12 @@ class SalesInvoice(SellingController):
self.validate_account_for_change_amount() self.validate_account_for_change_amount()
self.validate_income_account() self.validate_income_account()
def validate_for_repost(self):
self.validate_write_off_account()
self.validate_account_for_change_amount()
self.validate_income_account()
validate_docs_for_deferred_accounting([self.name], [])
def validate_fixed_asset(self): def validate_fixed_asset(self):
for d in self.get("items"): for d in self.get("items"):
if d.is_fixed_asset and d.meta.get_field("asset") and d.asset: if d.is_fixed_asset and d.meta.get_field("asset") and d.asset:
@ -388,6 +394,8 @@ class SalesInvoice(SellingController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger", "Repost Accounting Ledger",
"Repost Accounting Ledger Items", "Repost Accounting Ledger Items",
"Unreconcile Payments",
"Unreconcile Payment Entries",
"Payment Ledger Entry", "Payment Ledger Entry",
"Serial and Batch Bundle", "Serial and Batch Bundle",
) )
@ -515,90 +523,21 @@ class SalesInvoice(SellingController):
def on_update_after_submit(self): def on_update_after_submit(self):
if hasattr(self, "repost_required"): if hasattr(self, "repost_required"):
needs_repost = 0 fields_to_check = [
"additional_discount_account",
# Check if any field affecting accounting entry is altered "cash_bank_account",
doc_before_update = self.get_doc_before_save() "account_for_change_amount",
accounting_dimensions = get_accounting_dimensions() + ["cost_center", "project"] "write_off_account",
"loyalty_redemption_account",
# Check if opening entry check updated "unrealized_profit_loss_account",
if doc_before_update.get("is_opening") != self.is_opening: ]
needs_repost = 1 child_tables = {
"items": ("income_account", "expense_account", "discount_account"),
if not needs_repost: "taxes": ("account_head",),
# Parent Level Accounts excluding party account }
for field in ( self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
"additional_discount_account", self.validate_for_repost()
"cash_bank_account", self.db_set("repost_required", self.needs_repost)
"account_for_change_amount",
"write_off_account",
"loyalty_redemption_account",
"unrealized_profit_loss_account",
):
if doc_before_update.get(field) != self.get(field):
needs_repost = 1
break
# Check for parent accounting dimensions
for dimension in accounting_dimensions:
if doc_before_update.get(dimension) != self.get(dimension):
needs_repost = 1
break
# Check for child tables
if self.check_if_child_table_updated(
"items",
doc_before_update,
("income_account", "expense_account", "discount_account"),
accounting_dimensions,
):
needs_repost = 1
if self.check_if_child_table_updated(
"taxes", doc_before_update, ("account_head",), accounting_dimensions
):
needs_repost = 1
self.validate_accounts()
# validate if deferred revenue is enabled for any item
# Don't allow to update the invoice if deferred revenue is enabled
if needs_repost:
for item in self.get("items"):
if item.enable_deferred_revenue:
frappe.throw(
_(
"Deferred Revenue is enabled for item {0}. You cannot update the invoice after submission."
).format(item.item_code)
)
self.db_set("repost_required", needs_repost)
def check_if_child_table_updated(
self, child_table, doc_before_update, fields_to_check, accounting_dimensions
):
# Check if any field affecting accounting entry is altered
for index, item in enumerate(self.get(child_table)):
for field in fields_to_check:
if doc_before_update.get(child_table)[index].get(field) != item.get(field):
return True
for dimension in accounting_dimensions:
if doc_before_update.get(child_table)[index].get(dimension) != item.get(dimension):
return True
return False
@frappe.whitelist()
def repost_accounting_entries(self):
if self.repost_required:
self.docstatus = 2
self.make_gl_entries_on_cancel()
self.docstatus = 1
self.make_gl_entries()
self.db_set("repost_required", 0)
else:
frappe.throw(_("No updates pending for reposting"))
def set_paid_amount(self): def set_paid_amount(self):
paid_amount = 0.0 paid_amount = 0.0
@ -1104,9 +1043,7 @@ class SalesInvoice(SellingController):
"debit_in_account_currency": base_grand_total "debit_in_account_currency": base_grand_total
if self.party_account_currency == self.company_currency if self.party_account_currency == self.company_currency
else grand_total, else grand_total,
"against_voucher": self.return_against "against_voucher": self.name,
if cint(self.is_return) and self.return_against
else self.name,
"against_voucher_type": self.doctype, "against_voucher_type": self.doctype,
"cost_center": self.cost_center, "cost_center": self.cost_center,
"project": self.project, "project": self.project,
@ -1732,12 +1669,8 @@ class SalesInvoice(SellingController):
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate(): elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid" self.status = "Unpaid"
# Check if outstanding amount is 0 due to credit note issued against invoice # Check if outstanding amount is 0 due to credit note issued against invoice
elif ( elif self.is_return == 0 and frappe.db.get_value(
outstanding_amount <= 0 "Sales Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
and self.is_return == 0
and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
)
): ):
self.status = "Credit Note Issued" self.status = "Credit Note Issued"
elif self.is_return == 1: elif self.is_return == 1:

View File

@ -15,9 +15,11 @@ def get_data():
}, },
"internal_links": { "internal_links": {
"Sales Order": ["items", "sales_order"], "Sales Order": ["items", "sales_order"],
"Delivery Note": ["items", "delivery_note"],
"Timesheet": ["timesheets", "time_sheet"], "Timesheet": ["timesheets", "time_sheet"],
}, },
"internal_and_external_links": {
"Delivery Note": ["items", "delivery_note"],
},
"transactions": [ "transactions": [
{ {
"label": _("Payment"), "label": _("Payment"),

View File

@ -26,6 +26,7 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched
from erpnext.controllers.accounts_controller import update_invoice_status from erpnext.controllers.accounts_controller import update_invoice_status
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data
from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency
from erpnext.selling.doctype.customer.test_customer import get_customer_dict
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
@ -1500,8 +1501,8 @@ class TestSalesInvoice(unittest.TestCase):
self.assertEqual(party_credited, 1000) self.assertEqual(party_credited, 1000)
# Check outstanding amount # Check outstanding amount
self.assertFalse(si1.outstanding_amount) self.assertEqual(frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount"), -1000)
self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 1500) self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 2500)
def test_gle_made_when_asset_is_returned(self): def test_gle_made_when_asset_is_returned(self):
create_asset_data() create_asset_data()
@ -1801,6 +1802,10 @@ class TestSalesInvoice(unittest.TestCase):
) )
def test_outstanding_amount_after_advance_payment_entry_cancellation(self): def test_outstanding_amount_after_advance_payment_entry_cancellation(self):
"""Test impact of advance PE submission/cancellation on SI and SO."""
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sales_order = make_sales_order(item_code="138-CMS Shoe", qty=1, price_list_rate=500)
pe = frappe.get_doc( pe = frappe.get_doc(
{ {
"doctype": "Payment Entry", "doctype": "Payment Entry",
@ -1820,10 +1825,25 @@ class TestSalesInvoice(unittest.TestCase):
"paid_to": "_Test Cash - _TC", "paid_to": "_Test Cash - _TC",
} }
) )
pe.append(
"references",
{
"reference_doctype": "Sales Order",
"reference_name": sales_order.name,
"total_amount": sales_order.grand_total,
"outstanding_amount": sales_order.grand_total,
"allocated_amount": 300,
},
)
pe.insert() pe.insert()
pe.submit() pe.submit()
sales_order.reload()
self.assertEqual(sales_order.advance_paid, 300)
si = frappe.copy_doc(test_records[0]) si = frappe.copy_doc(test_records[0])
si.items[0].sales_order = sales_order.name
si.items[0].so_detail = sales_order.get("items")[0].name
si.is_pos = 0 si.is_pos = 0
si.append( si.append(
"advances", "advances",
@ -1831,6 +1851,7 @@ class TestSalesInvoice(unittest.TestCase):
"doctype": "Sales Invoice Advance", "doctype": "Sales Invoice Advance",
"reference_type": "Payment Entry", "reference_type": "Payment Entry",
"reference_name": pe.name, "reference_name": pe.name,
"reference_row": pe.references[0].name,
"advance_amount": 300, "advance_amount": 300,
"allocated_amount": 300, "allocated_amount": 300,
"remarks": pe.remarks, "remarks": pe.remarks,
@ -1839,7 +1860,13 @@ class TestSalesInvoice(unittest.TestCase):
si.insert() si.insert()
si.submit() si.submit()
si.load_from_db() si.reload()
pe.reload()
sales_order.reload()
# Check if SO is unlinked/replaced by SI in PE & if SO advance paid is 0
self.assertEqual(pe.references[0].reference_name, si.name)
self.assertEqual(sales_order.advance_paid, 0.0)
# check outstanding after advance allocation # check outstanding after advance allocation
self.assertEqual( self.assertEqual(
@ -1847,11 +1874,9 @@ class TestSalesInvoice(unittest.TestCase):
flt(si.rounded_total - si.total_advance, si.precision("outstanding_amount")), flt(si.rounded_total - si.total_advance, si.precision("outstanding_amount")),
) )
# added to avoid Document has been modified exception
pe = frappe.get_doc("Payment Entry", pe.name)
pe.cancel() pe.cancel()
si.reload()
si.load_from_db()
# check outstanding after advance cancellation # check outstanding after advance cancellation
self.assertEqual( self.assertEqual(
flt(si.outstanding_amount), flt(si.outstanding_amount),
@ -2322,7 +2347,7 @@ class TestSalesInvoice(unittest.TestCase):
item = create_item("_Test Item for Deferred Accounting") item = create_item("_Test Item for Deferred Accounting")
item.enable_deferred_revenue = 1 item.enable_deferred_revenue = 1
item.deferred_revenue_account = deferred_account item.item_defaults[0].deferred_revenue_account = deferred_account
item.no_of_months = 12 item.no_of_months = 12
item.save() item.save()
@ -3102,7 +3127,7 @@ class TestSalesInvoice(unittest.TestCase):
item = create_item("_Test Item for Deferred Accounting") item = create_item("_Test Item for Deferred Accounting")
item.enable_deferred_expense = 1 item.enable_deferred_expense = 1
item.deferred_revenue_account = deferred_account item.item_defaults[0].deferred_revenue_account = deferred_account
item.save() item.save()
si = create_sales_invoice( si = create_sales_invoice(
@ -3376,6 +3401,24 @@ class TestSalesInvoice(unittest.TestCase):
set_advance_flag(company="_Test Company", flag=0, default_account="") set_advance_flag(company="_Test Company", flag=0, default_account="")
@change_settings("Selling Settings", {"customer_group": None, "territory": None})
def test_sales_invoice_without_customer_group_and_territory(self):
# create a customer
if not frappe.db.exists("Customer", "_Test Simple Customer"):
customer_dict = get_customer_dict("_Test Simple Customer")
customer_dict.pop("customer_group")
customer_dict.pop("territory")
customer = frappe.get_doc(customer_dict).insert(ignore_permissions=True)
self.assertEqual(customer.customer_group, None)
self.assertEqual(customer.territory, None)
# create a sales invoice
si = create_sales_invoice(customer="_Test Simple Customer")
self.assertEqual(si.docstatus, 1)
self.assertEqual(si.customer_group, None)
self.assertEqual(si.territory, None)
@change_settings("Selling Settings", {"allow_negative_rates_for_items": 0}) @change_settings("Selling Settings", {"allow_negative_rates_for_items": 0})
def test_sales_return_negative_rate(self): def test_sales_return_negative_rate(self):
si = create_sales_invoice(is_return=1, qty=-2, rate=-10, do_not_save=True) si = create_sales_invoice(is_return=1, qty=-2, rate=-10, do_not_save=True)

View File

@ -157,7 +157,6 @@
"oldfieldname": "description", "oldfieldname": "description",
"oldfieldtype": "Text", "oldfieldtype": "Text",
"print_width": "200px", "print_width": "200px",
"reqd": 1,
"width": "200px" "width": "200px"
}, },
{ {

View File

@ -24,8 +24,9 @@
"current_invoice_start", "current_invoice_start",
"current_invoice_end", "current_invoice_end",
"days_until_due", "days_until_due",
"generate_invoice_at",
"number_of_days",
"cancel_at_period_end", "cancel_at_period_end",
"generate_invoice_at_period_start",
"sb_4", "sb_4",
"plans", "plans",
"sb_1", "sb_1",
@ -86,12 +87,14 @@
"fieldname": "current_invoice_start", "fieldname": "current_invoice_start",
"fieldtype": "Date", "fieldtype": "Date",
"label": "Current Invoice Start Date", "label": "Current Invoice Start Date",
"no_copy": 1,
"read_only": 1 "read_only": 1
}, },
{ {
"fieldname": "current_invoice_end", "fieldname": "current_invoice_end",
"fieldtype": "Date", "fieldtype": "Date",
"label": "Current Invoice End Date", "label": "Current Invoice End Date",
"no_copy": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@ -107,12 +110,6 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Cancel At End Of Period" "label": "Cancel At End Of Period"
}, },
{
"default": "0",
"fieldname": "generate_invoice_at_period_start",
"fieldtype": "Check",
"label": "Generate Invoice At Beginning Of Period"
},
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fieldname": "sb_4", "fieldname": "sb_4",
@ -240,6 +237,21 @@
"fieldname": "submit_invoice", "fieldname": "submit_invoice",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Submit Generated Invoices" "label": "Submit Generated Invoices"
},
{
"default": "End of the current subscription period",
"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",
"reqd": 1
},
{
"depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
"fieldname": "number_of_days",
"fieldtype": "Int",
"label": "Number of Days",
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
@ -255,7 +267,7 @@
"link_fieldname": "subscription" "link_fieldname": "subscription"
} }
], ],
"modified": "2022-02-18 23:24:57.185054", "modified": "2023-09-18 17:48:21.900252",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Subscription", "name": "Subscription",

View File

@ -36,12 +36,15 @@ class InvoiceNotCancelled(frappe.ValidationError):
pass pass
DateTimeLikeObject = Union[str, datetime.date]
class Subscription(Document): class Subscription(Document):
def before_insert(self): def before_insert(self):
# update start just before the subscription doc is created # update start just before the subscription doc is created
self.update_subscription_period(self.start_date) self.update_subscription_period(self.start_date)
def update_subscription_period(self, date: Optional[Union[datetime.date, str]] = None): def update_subscription_period(self, date: Optional["DateTimeLikeObject"] = None):
""" """
Subscription period is the period to be billed. This method updates the Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period. beginning of the billing period and end of the billing period.
@ -52,14 +55,14 @@ class Subscription(Document):
self.current_invoice_start = self.get_current_invoice_start(date) self.current_invoice_start = self.get_current_invoice_start(date)
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start) self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
def _get_subscription_period(self, date: Optional[Union[datetime.date, str]] = None): def _get_subscription_period(self, date: Optional["DateTimeLikeObject"] = None):
_current_invoice_start = self.get_current_invoice_start(date) _current_invoice_start = self.get_current_invoice_start(date)
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start) _current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
return _current_invoice_start, _current_invoice_end return _current_invoice_start, _current_invoice_end
def get_current_invoice_start( def get_current_invoice_start(
self, date: Optional[Union[datetime.date, str]] = None self, date: Optional["DateTimeLikeObject"] = None
) -> Union[datetime.date, str]: ) -> Union[datetime.date, str]:
""" """
This returns the date of the beginning of the current billing period. This returns the date of the beginning of the current billing period.
@ -84,7 +87,7 @@ class Subscription(Document):
return _current_invoice_start return _current_invoice_start
def get_current_invoice_end( def get_current_invoice_end(
self, date: Optional[Union[datetime.date, str]] = None self, date: Optional["DateTimeLikeObject"] = None
) -> Union[datetime.date, str]: ) -> Union[datetime.date, str]:
""" """
This returns the date of the end of the current billing period. This returns the date of the end of the current billing period.
@ -179,30 +182,24 @@ class Subscription(Document):
return data return data
def set_subscription_status(self) -> None: def set_subscription_status(self, posting_date: Optional["DateTimeLikeObject"] = None) -> None:
""" """
Sets the status of the `Subscription` Sets the status of the `Subscription`
""" """
if self.is_trialling(): if self.is_trialling():
self.status = "Trialling" self.status = "Trialling"
elif ( elif (
self.status == "Active" self.status == "Active" and self.end_date and getdate(posting_date) > getdate(self.end_date)
and self.end_date
and getdate(frappe.flags.current_date) > getdate(self.end_date)
): ):
self.status = "Completed" self.status = "Completed"
elif self.is_past_grace_period(): elif self.is_past_grace_period():
self.status = self.get_status_for_past_grace_period() self.status = self.get_status_for_past_grace_period()
self.cancelation_date = ( self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
getdate(frappe.flags.current_date) if self.status == "Cancelled" else None
)
elif self.current_invoice_is_past_due() and not self.is_past_grace_period(): elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
self.status = "Past Due Date" self.status = "Past Due Date"
elif not self.has_outstanding_invoice() or self.is_new_subscription(): elif not self.has_outstanding_invoice() or self.is_new_subscription():
self.status = "Active" self.status = "Active"
self.save()
def is_trialling(self) -> bool: def is_trialling(self) -> bool:
""" """
Returns `True` if the `Subscription` is in trial period. Returns `True` if the `Subscription` is in trial period.
@ -210,7 +207,9 @@ class Subscription(Document):
return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription() return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription()
@staticmethod @staticmethod
def period_has_passed(end_date: Union[str, datetime.date]) -> bool: def period_has_passed(
end_date: Union[str, datetime.date], posting_date: Optional["DateTimeLikeObject"] = None
) -> bool:
""" """
Returns true if the given `end_date` has passed Returns true if the given `end_date` has passed
""" """
@ -218,7 +217,7 @@ class Subscription(Document):
if not end_date: if not end_date:
return True return True
return getdate(frappe.flags.current_date) > getdate(end_date) return getdate(posting_date) > getdate(end_date)
def get_status_for_past_grace_period(self) -> str: def get_status_for_past_grace_period(self) -> str:
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace")) cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
@ -229,7 +228,7 @@ class Subscription(Document):
return status return status
def is_past_grace_period(self) -> bool: def is_past_grace_period(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
""" """
Returns `True` if the grace period for the `Subscription` has passed Returns `True` if the grace period for the `Subscription` has passed
""" """
@ -237,18 +236,18 @@ class Subscription(Document):
return return
grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period")) grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period"))
return getdate(frappe.flags.current_date) >= getdate( return getdate(posting_date) >= getdate(add_days(self.current_invoice.due_date, grace_period))
add_days(self.current_invoice.due_date, grace_period)
)
def current_invoice_is_past_due(self) -> bool: def current_invoice_is_past_due(
self, posting_date: Optional["DateTimeLikeObject"] = None
) -> bool:
""" """
Returns `True` if the current generated invoice is overdue Returns `True` if the current generated invoice is overdue
""" """
if not self.current_invoice or self.is_paid(self.current_invoice): if not self.current_invoice or self.is_paid(self.current_invoice):
return False return False
return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date) return getdate(posting_date) >= getdate(self.current_invoice.due_date)
@property @property
def invoice_document_type(self) -> str: def invoice_document_type(self) -> str:
@ -270,6 +269,9 @@ class Subscription(Document):
if not self.cost_center: if not self.cost_center:
self.cost_center = get_default_cost_center(self.get("company")) self.cost_center = get_default_cost_center(self.get("company"))
if self.is_new():
self.set_subscription_status()
def validate_trial_period(self) -> None: def validate_trial_period(self) -> None:
""" """
Runs sanity checks on trial period dates for the `Subscription` Runs sanity checks on trial period dates for the `Subscription`
@ -305,10 +307,6 @@ class Subscription(Document):
if billing_info[0]["billing_interval"] != "Month": if billing_info[0]["billing_interval"] != "Month":
frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months")) frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months"))
def after_insert(self) -> None:
# todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype?
self.set_subscription_status()
def generate_invoice( def generate_invoice(
self, self,
from_date: Optional[Union[str, datetime.date]] = None, from_date: Optional[Union[str, datetime.date]] = None,
@ -344,7 +342,7 @@ class Subscription(Document):
invoice.set_posting_time = 1 invoice.set_posting_time = 1
invoice.posting_date = ( invoice.posting_date = (
self.current_invoice_start self.current_invoice_start
if self.generate_invoice_at_period_start if self.generate_invoice_at == "Beginning of the current subscription period"
else self.current_invoice_end else self.current_invoice_end
) )
@ -438,7 +436,7 @@ class Subscription(Document):
prorate_factor = get_prorata_factor( prorate_factor = get_prorata_factor(
self.current_invoice_end, self.current_invoice_end,
self.current_invoice_start, self.current_invoice_start,
cint(self.generate_invoice_at_period_start), cint(self.generate_invoice_at == "Beginning of the current subscription period"),
) )
items = [] items = []
@ -503,42 +501,45 @@ class Subscription(Document):
return items return items
@frappe.whitelist() @frappe.whitelist()
def process(self) -> bool: def process(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
""" """
To be called by task periodically. It checks the subscription and takes appropriate action To be called by task periodically. It checks the subscription and takes appropriate action
as need be. It calls either of these methods depending the `Subscription` status: as need be. It calls either of these methods depending the `Subscription` status:
1. `process_for_active` 1. `process_for_active`
2. `process_for_past_due` 2. `process_for_past_due`
""" """
if ( if not self.is_current_invoice_generated(
not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end) self.current_invoice_start, self.current_invoice_end
and self.can_generate_new_invoice() ) and self.can_generate_new_invoice(posting_date):
):
self.generate_invoice() self.generate_invoice()
self.update_subscription_period(add_days(self.current_invoice_end, 1)) self.update_subscription_period(add_days(self.current_invoice_end, 1))
if self.cancel_at_period_end and ( if self.cancel_at_period_end and (
getdate(frappe.flags.current_date) >= getdate(self.current_invoice_end) getdate(posting_date) >= getdate(self.current_invoice_end)
or getdate(frappe.flags.current_date) >= getdate(self.end_date) or getdate(posting_date) >= getdate(self.end_date)
): ):
self.cancel_subscription() self.cancel_subscription()
self.set_subscription_status() self.set_subscription_status(posting_date=posting_date)
self.save() self.save()
def can_generate_new_invoice(self) -> bool: def can_generate_new_invoice(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
if self.cancelation_date: if self.cancelation_date:
return False return False
elif self.generate_invoice_at_period_start and (
getdate(frappe.flags.current_date) == getdate(self.current_invoice_start) if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
or self.is_new_subscription() return False
if self.generate_invoice_at == "Beginning of the current subscription period" and (
getdate(posting_date) == getdate(self.current_invoice_start) or self.is_new_subscription()
): ):
return True return True
elif getdate(frappe.flags.current_date) == getdate(self.current_invoice_end): elif self.generate_invoice_at == "Days before the current subscription period" and (
if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date: getdate(posting_date) == getdate(add_days(self.current_invoice_start, -1 * self.number_of_days))
return False ):
return True
elif getdate(posting_date) == getdate(self.current_invoice_end):
return True return True
else: else:
return False return False
@ -628,7 +629,10 @@ class Subscription(Document):
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled) frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
to_generate_invoice = ( to_generate_invoice = (
True if self.status == "Active" and not self.generate_invoice_at_period_start else False True
if self.status == "Active"
and not self.generate_invoice_at == "Beginning of the current subscription period"
else False
) )
self.status = "Cancelled" self.status = "Cancelled"
self.cancelation_date = nowdate() self.cancelation_date = nowdate()
@ -639,7 +643,7 @@ class Subscription(Document):
self.save() self.save()
@frappe.whitelist() @frappe.whitelist()
def restart_subscription(self) -> None: def restart_subscription(self, posting_date: Optional["DateTimeLikeObject"] = None) -> None:
""" """
This sets the subscription as active. The subscription will be made to be like a new This sets the subscription as active. The subscription will be made to be like a new
subscription and the `Subscription` will lose all the history of generated invoices subscription and the `Subscription` will lose all the history of generated invoices
@ -650,7 +654,7 @@ class Subscription(Document):
self.status = "Active" self.status = "Active"
self.cancelation_date = None self.cancelation_date = None
self.update_subscription_period(frappe.flags.current_date or nowdate()) self.update_subscription_period(posting_date or nowdate())
self.save() self.save()
@ -671,14 +675,21 @@ def get_prorata_factor(
return diff / plan_days return diff / plan_days
def process_all() -> None: def process_all(
subscription: str | None, posting_date: Optional["DateTimeLikeObject"] = None
) -> None:
""" """
Task to updates the status of all `Subscription` apart from those that are cancelled Task to updates the status of all `Subscription` apart from those that are cancelled
""" """
for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"): filters = {"status": ("!=", "Cancelled")}
if subscription:
filters["name"] = subscription
for subscription in frappe.get_all("Subscription", filters, pluck="name"):
try: try:
subscription = frappe.get_doc("Subscription", subscription) subscription = frappe.get_doc("Subscription", subscription)
subscription.process() subscription.process(posting_date)
frappe.db.commit() frappe.db.commit()
except frappe.ValidationError: except frappe.ValidationError:
frappe.db.rollback() frappe.db.rollback()

View File

@ -8,6 +8,7 @@ from frappe.utils.data import (
add_days, add_days,
add_months, add_months,
add_to_date, add_to_date,
cint,
date_diff, date_diff,
flt, flt,
get_date_str, get_date_str,
@ -20,99 +21,16 @@ from erpnext.accounts.doctype.subscription.subscription import get_prorata_facto
test_dependencies = ("UOM", "Item Group", "Item") test_dependencies = ("UOM", "Item Group", "Item")
def create_plan():
if not frappe.db.exists("Subscription Plan", "_Test Plan Name"):
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = "_Test Plan Name"
plan.item = "_Test Non Stock Item"
plan.price_determination = "Fixed Rate"
plan.cost = 900
plan.billing_interval = "Month"
plan.billing_interval_count = 1
plan.insert()
if not frappe.db.exists("Subscription Plan", "_Test Plan Name 2"):
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = "_Test Plan Name 2"
plan.item = "_Test Non Stock Item"
plan.price_determination = "Fixed Rate"
plan.cost = 1999
plan.billing_interval = "Month"
plan.billing_interval_count = 1
plan.insert()
if not frappe.db.exists("Subscription Plan", "_Test Plan Name 3"):
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = "_Test Plan Name 3"
plan.item = "_Test Non Stock Item"
plan.price_determination = "Fixed Rate"
plan.cost = 1999
plan.billing_interval = "Day"
plan.billing_interval_count = 14
plan.insert()
# Defined a quarterly Subscription Plan
if not frappe.db.exists("Subscription Plan", "_Test Plan Name 4"):
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = "_Test Plan Name 4"
plan.item = "_Test Non Stock Item"
plan.price_determination = "Monthly Rate"
plan.cost = 20000
plan.billing_interval = "Month"
plan.billing_interval_count = 3
plan.insert()
if not frappe.db.exists("Subscription Plan", "_Test Plan Multicurrency"):
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = "_Test Plan Multicurrency"
plan.item = "_Test Non Stock Item"
plan.price_determination = "Fixed Rate"
plan.cost = 50
plan.currency = "USD"
plan.billing_interval = "Month"
plan.billing_interval_count = 1
plan.insert()
def create_parties():
if not frappe.db.exists("Supplier", "_Test Supplier"):
supplier = frappe.new_doc("Supplier")
supplier.supplier_name = "_Test Supplier"
supplier.supplier_group = "All Supplier Groups"
supplier.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer"):
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Subscription Customer"
customer.billing_currency = "USD"
customer.append(
"accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}
)
customer.insert()
def reset_settings():
settings = frappe.get_single("Subscription Settings")
settings.grace_period = 0
settings.cancel_after_grace = 0
settings.save()
class TestSubscription(unittest.TestCase): class TestSubscription(unittest.TestCase):
def setUp(self): def setUp(self):
create_plan() make_plans()
create_parties() create_parties()
reset_settings() reset_settings()
def test_create_subscription_with_trial_with_correct_period(self): def test_create_subscription_with_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Customer" trial_period_start=nowdate(), trial_period_end=add_months(nowdate(), 1)
subscription.party = "_Test Customer" )
subscription.trial_period_start = nowdate()
subscription.trial_period_end = add_months(nowdate(), 1)
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
self.assertEqual(subscription.trial_period_start, nowdate()) self.assertEqual(subscription.trial_period_start, nowdate())
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1)) self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
self.assertEqual( self.assertEqual(
@ -126,12 +44,7 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Trialling") self.assertEqual(subscription.status, "Trialling")
def test_create_subscription_without_trial_with_correct_period(self): def test_create_subscription_without_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription()
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
self.assertEqual(subscription.trial_period_start, None) self.assertEqual(subscription.trial_period_start, None)
self.assertEqual(subscription.trial_period_end, None) self.assertEqual(subscription.trial_period_end, None)
self.assertEqual(subscription.current_invoice_start, nowdate()) self.assertEqual(subscription.current_invoice_start, nowdate())
@ -141,55 +54,28 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
def test_create_subscription_trial_with_wrong_dates(self): def test_create_subscription_trial_with_wrong_dates(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Customer" trial_period_start=add_days(nowdate(), 30), trial_period_end=nowdate(), do_not_save=True
subscription.party = "_Test Customer" )
subscription.trial_period_end = nowdate()
subscription.trial_period_start = add_days(nowdate(), 30)
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save)
def test_create_subscription_multi_with_different_billing_fails(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.trial_period_end = nowdate()
subscription.trial_period_start = add_days(nowdate(), 30)
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save) self.assertRaises(frappe.ValidationError, subscription.save)
def test_invoice_is_generated_at_end_of_billing_period(self): def test_invoice_is_generated_at_end_of_billing_period(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(start_date="2018-01-01")
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.insert()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, "2018-01-01") self.assertEqual(subscription.current_invoice_start, "2018-01-01")
self.assertEqual(subscription.current_invoice_end, "2018-01-31") self.assertEqual(subscription.current_invoice_end, "2018-01-31")
frappe.flags.current_date = "2018-01-31"
subscription.process()
subscription.process(posting_date="2018-01-31")
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.current_invoice_start, "2018-02-01") self.assertEqual(subscription.current_invoice_start, "2018-02-01")
self.assertEqual(subscription.current_invoice_end, "2018-02-28") self.assertEqual(subscription.current_invoice_end, "2018-02-28")
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
def test_status_goes_back_to_active_after_invoice_is_paid(self): def test_status_goes_back_to_active_after_invoice_is_paid(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Customer" start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
subscription.party = "_Test Customer" )
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.process(posting_date="2018-01-01") # generate first invoice
subscription.start_date = "2018-01-01"
subscription.generate_invoice_at_period_start = True
subscription.insert()
frappe.flags.current_date = "2018-01-01"
subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
# Status is unpaid as Days until Due is zero and grace period is Zero # Status is unpaid as Days until Due is zero and grace period is Zero
@ -213,18 +99,10 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = 1 settings.cancel_after_grace = 1
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription(start_date="2018-01-01")
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
# subscription.generate_invoice_at_period_start = True
subscription.start_date = "2018-01-01"
subscription.insert()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
frappe.flags.current_date = "2018-01-31" subscription.process(posting_date="2018-01-31") # generate first invoice
subscription.process() # generate first invoice
# This should change status to Cancelled since grace period is 0 # This should change status to Cancelled since grace period is 0
# And is backdated subscription so subscription will be cancelled after processing # And is backdated subscription so subscription will be cancelled after processing
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
@ -235,13 +113,8 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = 0 settings.cancel_after_grace = 0
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription(start_date="2018-01-01")
subscription.party_type = "Customer" subscription.process(posting_date="2018-01-31") # generate first invoice
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01"
subscription.insert()
subscription.process() # generate first invoice
# Status is unpaid as Days until Due is zero and grace period is Zero # Status is unpaid as Days until Due is zero and grace period is Zero
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
@ -251,21 +124,9 @@ class TestSubscription(unittest.TestCase):
def test_subscription_invoice_days_until_due(self): def test_subscription_invoice_days_until_due(self):
_date = add_months(nowdate(), -1) _date = add_months(nowdate(), -1)
subscription = frappe.new_doc("Subscription") subscription = create_subscription(start_date=_date, days_until_due=10)
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.days_until_due = 10
subscription.start_date = _date
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.insert()
frappe.flags.current_date = subscription.current_invoice_end subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active")
frappe.flags.current_date = add_days(subscription.current_invoice_end, 3)
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
@ -275,16 +136,9 @@ class TestSubscription(unittest.TestCase):
settings.grace_period = 1000 settings.grace_period = 1000
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription(start_date=add_days(nowdate(), -1000))
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = add_days(nowdate(), -1000)
subscription.insert()
frappe.flags.current_date = subscription.current_invoice_end
subscription.process() # generate first invoice
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
self.assertEqual(subscription.status, "Past Due Date") self.assertEqual(subscription.status, "Past Due Date")
subscription.process() subscription.process()
@ -301,12 +155,7 @@ class TestSubscription(unittest.TestCase):
settings.save() settings.save()
def test_subscription_remains_active_during_invoice_period(self): def test_subscription_remains_active_during_invoice_period(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription() # no changes expected
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
subscription.process() # no changes expected
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, nowdate()) self.assertEqual(subscription.current_invoice_start, nowdate())
@ -325,12 +174,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1)) self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 0)
def test_subscription_cancelation(self): def test_subscription_cancellation(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription()
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
subscription.cancel_subscription() subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
@ -341,11 +186,7 @@ class TestSubscription(unittest.TestCase):
settings.prorate = 1 settings.prorate = 1
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription()
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
@ -365,7 +206,7 @@ class TestSubscription(unittest.TestCase):
get_prorata_factor( get_prorata_factor(
subscription.current_invoice_end, subscription.current_invoice_end,
subscription.current_invoice_start, subscription.current_invoice_start,
subscription.generate_invoice_at_period_start, cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
), ),
2, 2,
), ),
@ -383,11 +224,7 @@ class TestSubscription(unittest.TestCase):
settings.prorate = 0 settings.prorate = 0
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription()
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
subscription.cancel_subscription() subscription.cancel_subscription()
invoice = subscription.get_current_invoice() invoice = subscription.get_current_invoice()
@ -402,11 +239,7 @@ class TestSubscription(unittest.TestCase):
settings.prorate = 1 settings.prorate = 1
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription()
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
subscription.cancel_subscription() subscription.cancel_subscription()
invoice = subscription.get_current_invoice() invoice = subscription.get_current_invoice()
@ -421,18 +254,13 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
def test_subcription_cancellation_and_process(self): def test_subscription_cancellation_and_process(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace default_grace_period_action = settings.cancel_after_grace
settings.cancel_after_grace = 1 settings.cancel_after_grace = 1
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription(start_date="2018-01-01")
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01"
subscription.insert()
subscription.process() # generate first invoice subscription.process() # generate first invoice
# Generate an invoice for the cancelled period # Generate an invoice for the cancelled period
@ -458,14 +286,8 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = 0 settings.cancel_after_grace = 0
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription(start_date="2018-01-01")
subscription.party_type = "Customer" subscription.process(posting_date="2018-01-31") # generate first invoice
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01"
subscription.insert()
frappe.flags.current_date = "2018-01-31"
subscription.process() # generate first invoice
# Status is unpaid as Days until Due is zero and grace period is Zero # Status is unpaid as Days until Due is zero and grace period is Zero
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
@ -494,17 +316,10 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = 0 settings.cancel_after_grace = 0
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Customer" start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
subscription.party = "_Test Customer" )
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.process(subscription.current_invoice_start) # generate first invoice
subscription.start_date = "2018-01-01"
subscription.generate_invoice_at_period_start = True
subscription.insert()
frappe.flags.current_date = subscription.current_invoice_start
subscription.process() # generate first invoice
# This should change status to Unpaid since grace period is 0 # This should change status to Unpaid since grace period is 0
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
@ -516,29 +331,18 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
# A new invoice is generated # A new invoice is generated
frappe.flags.current_date = subscription.current_invoice_start subscription.process(posting_date=subscription.current_invoice_start)
subscription.process()
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
def test_restart_active_subscription(self): def test_restart_active_subscription(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription()
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
self.assertRaises(frappe.ValidationError, subscription.restart_subscription) self.assertRaises(frappe.ValidationError, subscription.restart_subscription)
def test_subscription_invoice_discount_percentage(self): def test_subscription_invoice_discount_percentage(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(additional_discount_percentage=10)
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.additional_discount_percentage = 10
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
subscription.cancel_subscription() subscription.cancel_subscription()
invoice = subscription.get_current_invoice() invoice = subscription.get_current_invoice()
@ -547,12 +351,7 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(invoice.apply_discount_on, "Grand Total") self.assertEqual(invoice.apply_discount_on, "Grand Total")
def test_subscription_invoice_discount_amount(self): def test_subscription_invoice_discount_amount(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(additional_discount_amount=11)
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.additional_discount_amount = 11
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
subscription.cancel_subscription() subscription.cancel_subscription()
invoice = subscription.get_current_invoice() invoice = subscription.get_current_invoice()
@ -563,18 +362,13 @@ class TestSubscription(unittest.TestCase):
def test_prepaid_subscriptions(self): def test_prepaid_subscriptions(self):
# Create a non pre-billed subscription, processing should not create # Create a non pre-billed subscription, processing should not create
# invoices. # invoices.
subscription = frappe.new_doc("Subscription") subscription = create_subscription()
subscription.party_type = "Customer"
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
subscription.process() subscription.process()
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 0)
# Change the subscription type to prebilled and process it. # Change the subscription type to prebilled and process it.
# Prepaid invoice should be generated # Prepaid invoice should be generated
subscription.generate_invoice_at_period_start = True subscription.generate_invoice_at = "Beginning of the current subscription period"
subscription.save() subscription.save()
subscription.process() subscription.process()
@ -586,12 +380,9 @@ class TestSubscription(unittest.TestCase):
settings.prorate = 1 settings.prorate = 1
settings.save() settings.save()
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Customer" generate_invoice_at="Beginning of the current subscription period"
subscription.party = "_Test Customer" )
subscription.generate_invoice_at_period_start = True
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.save()
subscription.process() subscription.process()
subscription.cancel_subscription() subscription.cancel_subscription()
@ -609,9 +400,10 @@ class TestSubscription(unittest.TestCase):
def test_subscription_with_follow_calendar_months(self): def test_subscription_with_follow_calendar_months(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.company = "_Test Company"
subscription.party_type = "Supplier" subscription.party_type = "Supplier"
subscription.party = "_Test Supplier" subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at = "Beginning of the current subscription period"
subscription.follow_calendar_months = 1 subscription.follow_calendar_months = 1
# select subscription start date as "2018-01-15" # select subscription start date as "2018-01-15"
@ -625,39 +417,33 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31") self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
def test_subscription_generate_invoice_past_due(self): def test_subscription_generate_invoice_past_due(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Supplier" start_date="2018-01-01",
subscription.party = "_Test Supplier" party_type="Supplier",
subscription.generate_invoice_at_period_start = 1 party="_Test Supplier",
subscription.generate_new_invoices_past_due_date = 1 generate_invoice_at="Beginning of the current subscription period",
# select subscription start date as "2018-01-15" generate_new_invoices_past_due_date=1,
subscription.start_date = "2018-01-01" plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) )
subscription.save()
frappe.flags.current_date = "2018-01-01"
# Process subscription and create first invoice # Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed # Subscription status will be unpaid since due date has already passed
subscription.process() subscription.process(posting_date="2018-01-01")
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
# Now the Subscription is unpaid # Now the Subscription is unpaid
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in # Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
# subscription and the interval between the subscriptions is 3 months # subscription and the interval between the subscriptions is 3 months
frappe.flags.current_date = "2018-04-01" subscription.process(posting_date="2018-04-01")
subscription.process()
self.assertEqual(len(subscription.invoices), 2) self.assertEqual(len(subscription.invoices), 2)
def test_subscription_without_generate_invoice_past_due(self): def test_subscription_without_generate_invoice_past_due(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Supplier" start_date="2018-01-01",
subscription.party = "_Test Supplier" generate_invoice_at="Beginning of the current subscription period",
subscription.generate_invoice_at_period_start = 1 plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
# select subscription start date as "2018-01-15" )
subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save()
# Process subscription and create first invoice # Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed # Subscription status will be unpaid since due date has already passed
@ -668,16 +454,13 @@ class TestSubscription(unittest.TestCase):
subscription.process() subscription.process()
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
def test_multicurrency_subscription(self): def test_multi_currency_subscription(self):
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Customer" start_date="2018-01-01",
subscription.party = "_Test Subscription Customer" generate_invoice_at="Beginning of the current subscription period",
subscription.generate_invoice_at_period_start = 1 plans=[{"plan": "_Test Plan Multicurrency", "qty": 1}],
subscription.company = "_Test Company" party="_Test Subscription Customer",
# select subscription start date as "2018-01-15" )
subscription.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1})
subscription.save()
subscription.process() subscription.process()
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
@ -689,42 +472,135 @@ class TestSubscription(unittest.TestCase):
def test_subscription_recovery(self): def test_subscription_recovery(self):
"""Test if Subscription recovers when start/end date run out of sync with created invoices.""" """Test if Subscription recovers when start/end date run out of sync with created invoices."""
subscription = frappe.new_doc("Subscription") subscription = create_subscription(
subscription.party_type = "Customer" start_date="2021-01-01",
subscription.party = "_Test Subscription Customer" submit_invoice=0,
subscription.company = "_Test Company" generate_new_invoices_past_due_date=1,
subscription.start_date = "2021-12-01" party="_Test Subscription Customer",
subscription.generate_new_invoices_past_due_date = 1 )
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.submit_invoice = 0
subscription.save()
# create invoices for the first two moths # create invoices for the first two moths
frappe.flags.current_date = "2021-12-31" subscription.process(posting_date="2021-01-31")
subscription.process()
frappe.flags.current_date = "2022-01-31" subscription.process(posting_date="2021-02-28")
subscription.process()
self.assertEqual(len(subscription.invoices), 2) self.assertEqual(len(subscription.invoices), 2)
self.assertEqual( self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")), getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
getdate("2021-12-01"), getdate("2021-01-01"),
) )
self.assertEqual( self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")), getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
getdate("2022-01-01"), getdate("2021-02-01"),
) )
# recreate most recent invoice # recreate most recent invoice
subscription.process() subscription.process(posting_date="2022-01-31")
self.assertEqual(len(subscription.invoices), 2) self.assertEqual(len(subscription.invoices), 2)
self.assertEqual( self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")), getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
getdate("2021-12-01"), getdate("2021-01-01"),
) )
self.assertEqual( self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")), getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
getdate("2022-01-01"), getdate("2021-02-01"),
) )
def test_subscription_invoice_generation_before_days(self):
subscription = create_subscription(
start_date="2023-01-01",
generate_invoice_at="Days before the current subscription period",
number_of_days=10,
generate_new_invoices_past_due_date=1,
)
subscription.process(posting_date="2022-12-22")
self.assertEqual(len(subscription.invoices), 1)
subscription.process(posting_date="2023-01-22")
self.assertEqual(len(subscription.invoices), 2)
def make_plans():
create_plan(plan_name="_Test Plan Name", cost=900)
create_plan(plan_name="_Test Plan Name 2", cost=1999)
create_plan(
plan_name="_Test Plan Name 3", cost=1999, billing_interval="Day", billing_interval_count=14
)
create_plan(
plan_name="_Test Plan Name 4", cost=20000, billing_interval="Month", billing_interval_count=3
)
create_plan(
plan_name="_Test Plan Multicurrency", cost=50, billing_interval="Month", currency="USD"
)
def create_plan(**kwargs):
if not frappe.db.exists("Subscription Plan", kwargs.get("plan_name")):
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = kwargs.get("plan_name") or "_Test Plan Name"
plan.item = kwargs.get("item") or "_Test Non Stock Item"
plan.price_determination = kwargs.get("price_determination") or "Fixed Rate"
plan.cost = kwargs.get("cost") or 1000
plan.billing_interval = kwargs.get("billing_interval") or "Month"
plan.billing_interval_count = kwargs.get("billing_interval_count") or 1
plan.currency = kwargs.get("currency")
plan.insert()
def create_parties():
if not frappe.db.exists("Supplier", "_Test Supplier"):
supplier = frappe.new_doc("Supplier")
supplier.supplier_name = "_Test Supplier"
supplier.supplier_group = "All Supplier Groups"
supplier.insert()
if not frappe.db.exists("Customer", "_Test Subscription Customer"):
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Subscription Customer"
customer.billing_currency = "USD"
customer.append(
"accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}
)
customer.insert()
def reset_settings():
settings = frappe.get_single("Subscription Settings")
settings.grace_period = 0
settings.cancel_after_grace = 0
settings.save()
def create_subscription(**kwargs):
subscription = frappe.new_doc("Subscription")
subscription.party_type = (kwargs.get("party_type") or "Customer",)
subscription.company = kwargs.get("company") or "_Test Company"
subscription.party = kwargs.get("party") or "_Test Customer"
subscription.trial_period_start = kwargs.get("trial_period_start")
subscription.trial_period_end = kwargs.get("trial_period_end")
subscription.start_date = kwargs.get("start_date")
subscription.generate_invoice_at = kwargs.get("generate_invoice_at")
subscription.additional_discount_percentage = kwargs.get("additional_discount_percentage")
subscription.additional_discount_amount = kwargs.get("additional_discount_amount")
subscription.follow_calendar_months = kwargs.get("follow_calendar_months")
subscription.generate_new_invoices_past_due_date = kwargs.get(
"generate_new_invoices_past_due_date"
)
subscription.submit_invoice = kwargs.get("submit_invoice")
subscription.days_until_due = kwargs.get("days_until_due")
subscription.number_of_days = kwargs.get("number_of_days")
if not kwargs.get("plans"):
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
else:
for plan in kwargs.get("plans"):
subscription.append("plans", plan)
if kwargs.get("do_not_save"):
return subscription
subscription.save()
return subscription

View File

@ -57,18 +57,17 @@ def get_plan_rate(
prorate = frappe.db.get_single_value("Subscription Settings", "prorate") prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
if prorate: if prorate:
prorate_factor = flt( cost -= plan.cost * get_prorate_factor(start_date, end_date)
date_diff(start_date, get_first_day(start_date))
/ date_diff(get_last_day(start_date), get_first_day(start_date)),
1,
)
prorate_factor += flt(
date_diff(get_last_day(end_date), end_date)
/ date_diff(get_last_day(end_date), get_first_day(end_date)),
1,
)
cost -= plan.cost * prorate_factor
return cost return cost
def get_prorate_factor(start_date, end_date):
total_days_to_skip = date_diff(start_date, get_first_day(start_date))
total_days_in_month = int(get_last_day(start_date).strftime("%d"))
prorate_factor = flt(total_days_to_skip / total_days_in_month)
total_days_to_skip = date_diff(get_last_day(end_date), end_date)
total_days_in_month = int(get_last_day(end_date).strftime("%d"))
prorate_factor += flt(total_days_to_skip / total_days_in_month)
return prorate_factor

View File

@ -271,9 +271,9 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details
) )
else: else:
tax_amount = net_total * tax_details.rate / 100 if net_total > 0 else 0 tax_amount = net_total * tax_details.rate / 100
else: else:
tax_amount = net_total * tax_details.rate / 100 if net_total > 0 else 0 tax_amount = net_total * tax_details.rate / 100
# once tds is deducted, not need to add vouchers in the invoice # once tds is deducted, not need to add vouchers in the invoice
voucher_wise_amount = {} voucher_wise_amount = {}

View File

@ -0,0 +1,83 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-08-22 10:28:10.196712",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"account",
"party_type",
"party",
"reference_doctype",
"reference_name",
"allocated_amount",
"account_currency",
"unlinked"
],
"fields": [
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Reference Name",
"options": "reference_doctype"
},
{
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Allocated Amount",
"options": "account_currency"
},
{
"default": "0",
"fieldname": "unlinked",
"fieldtype": "Check",
"in_list_view": 1,
"label": "Unlinked",
"read_only": 1
},
{
"fieldname": "reference_doctype",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Reference Type",
"options": "DocType"
},
{
"fieldname": "account",
"fieldtype": "Data",
"label": "Account"
},
{
"fieldname": "party_type",
"fieldtype": "Data",
"label": "Party Type"
},
{
"fieldname": "party",
"fieldtype": "Data",
"label": "Party"
},
{
"fieldname": "account_currency",
"fieldtype": "Link",
"label": "Account Currency",
"options": "Currency",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-09-05 09:33:28.620149",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Unreconcile Payment Entries",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class UnreconcilePaymentEntries(Document):
pass

View File

@ -0,0 +1,316 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import today
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.create_company()
self.create_customer()
self.create_usd_receivable_account()
self.create_item()
self.clear_old_entries()
def tearDown(self):
frappe.db.rollback()
def create_sales_invoice(self, do_not_submit=False):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_submit=do_not_submit,
)
return si
def create_payment_entry(self):
pe = create_payment_entry(
company=self.company,
payment_type="Receive",
party_type="Customer",
party=self.customer,
paid_from=self.debit_to,
paid_to=self.cash,
paid_amount=200,
save=True,
)
return pe
def test_01_unreconcile_invoice(self):
si1 = self.create_sales_invoice()
si2 = self.create_sales_invoice()
pe = self.create_payment_entry()
pe.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100},
)
pe.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100},
)
# Allocation payment against both invoices
pe.save().submit()
# Assert outstanding
[doc.reload() for doc in [si1, si2, pe]]
self.assertEqual(si1.outstanding_amount, 0)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(pe.unallocated_amount, 0)
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payments",
"company": self.company,
"voucher_type": pe.doctype,
"voucher_no": pe.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([si1.name, si2.name], allocations)
# unreconcile si1
for x in unreconcile.allocations:
if x.reference_name != si1.name:
unreconcile.remove(x)
unreconcile.save().submit()
# Assert outstanding
[doc.reload() for doc in [si1, si2, pe]]
self.assertEqual(si1.outstanding_amount, 100)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(len(pe.references), 1)
self.assertEqual(pe.unallocated_amount, 100)
def test_02_unreconcile_one_payment_from_multi_payments(self):
"""
Scenario: 2 payments, both split against 2 different invoices
Unreconcile only one payment from one invoice
"""
si1 = self.create_sales_invoice()
si2 = self.create_sales_invoice()
pe1 = self.create_payment_entry()
pe1.paid_amount = 100
# Allocate payment against both invoices
pe1.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
)
pe1.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
)
pe1.save().submit()
pe2 = self.create_payment_entry()
pe2.paid_amount = 100
# Allocate payment against both invoices
pe2.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
)
pe2.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
)
pe2.save().submit()
# Assert outstanding and unallocated
[doc.reload() for doc in [si1, si2, pe1, pe2]]
self.assertEqual(si1.outstanding_amount, 0.0)
self.assertEqual(si2.outstanding_amount, 0.0)
self.assertEqual(pe1.unallocated_amount, 0.0)
self.assertEqual(pe2.unallocated_amount, 0.0)
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payments",
"company": self.company,
"voucher_type": pe2.doctype,
"voucher_no": pe2.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([si1.name, si2.name], allocations)
# unreconcile si1 from pe2
for x in unreconcile.allocations:
if x.reference_name != si1.name:
unreconcile.remove(x)
unreconcile.save().submit()
# Assert outstanding and unallocated
[doc.reload() for doc in [si1, si2, pe1, pe2]]
self.assertEqual(si1.outstanding_amount, 50)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(len(pe1.references), 2)
self.assertEqual(len(pe2.references), 1)
self.assertEqual(pe1.unallocated_amount, 0)
self.assertEqual(pe2.unallocated_amount, 50)
def test_03_unreconciliation_on_multi_currency_invoice(self):
self.create_customer("_Test MC Customer USD", "USD")
si1 = self.create_sales_invoice(do_not_submit=True)
si1.currency = "USD"
si1.debit_to = self.debtors_usd
si1.conversion_rate = 80
si1.save().submit()
si2 = self.create_sales_invoice(do_not_submit=True)
si2.currency = "USD"
si2.debit_to = self.debtors_usd
si2.conversion_rate = 80
si2.save().submit()
pe = self.create_payment_entry()
pe.paid_from = self.debtors_usd
pe.paid_from_account_currency = "USD"
pe.source_exchange_rate = 75
pe.received_amount = 75 * 200
pe.save()
# Allocate payment against both invoices
pe.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100},
)
pe.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100},
)
pe.save().submit()
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payments",
"company": self.company,
"voucher_type": pe.doctype,
"voucher_no": pe.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([si1.name, si2.name], allocations)
# unreconcile si1 from pe
for x in unreconcile.allocations:
if x.reference_name != si1.name:
unreconcile.remove(x)
unreconcile.save().submit()
# Assert outstanding and unallocated
[doc.reload() for doc in [si1, si2, pe]]
self.assertEqual(si1.outstanding_amount, 100)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(len(pe.references), 1)
self.assertEqual(pe.unallocated_amount, 100)
# Exc gain/loss JE should've been cancelled as well
self.assertEqual(
frappe.db.count(
"Journal Entry Account",
filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1},
),
0,
)
def test_04_unreconciliation_on_multi_currency_invoice(self):
"""
2 payments split against 2 foreign currency invoices
"""
self.create_customer("_Test MC Customer USD", "USD")
si1 = self.create_sales_invoice(do_not_submit=True)
si1.currency = "USD"
si1.debit_to = self.debtors_usd
si1.conversion_rate = 80
si1.save().submit()
si2 = self.create_sales_invoice(do_not_submit=True)
si2.currency = "USD"
si2.debit_to = self.debtors_usd
si2.conversion_rate = 80
si2.save().submit()
pe1 = self.create_payment_entry()
pe1.paid_from = self.debtors_usd
pe1.paid_from_account_currency = "USD"
pe1.source_exchange_rate = 75
pe1.received_amount = 75 * 100
pe1.save()
# Allocate payment against both invoices
pe1.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
)
pe1.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
)
pe1.save().submit()
pe2 = self.create_payment_entry()
pe2.paid_from = self.debtors_usd
pe2.paid_from_account_currency = "USD"
pe2.source_exchange_rate = 75
pe2.received_amount = 75 * 100
pe2.save()
# Allocate payment against both invoices
pe2.append(
"references",
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
)
pe2.append(
"references",
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
)
pe2.save().submit()
unreconcile = frappe.get_doc(
{
"doctype": "Unreconcile Payments",
"company": self.company,
"voucher_type": pe2.doctype,
"voucher_no": pe2.name,
}
)
unreconcile.add_references()
self.assertEqual(len(unreconcile.allocations), 2)
allocations = [x.reference_name for x in unreconcile.allocations]
self.assertEquals([si1.name, si2.name], allocations)
# unreconcile si1 from pe2
for x in unreconcile.allocations:
if x.reference_name != si1.name:
unreconcile.remove(x)
unreconcile.save().submit()
# Assert outstanding and unallocated
[doc.reload() for doc in [si1, si2, pe1, pe2]]
self.assertEqual(si1.outstanding_amount, 50)
self.assertEqual(si2.outstanding_amount, 0)
self.assertEqual(len(pe1.references), 2)
self.assertEqual(len(pe2.references), 1)
self.assertEqual(pe1.unallocated_amount, 0)
self.assertEqual(pe2.unallocated_amount, 50)
# Exc gain/loss JE from PE1 should be available
self.assertEqual(
frappe.db.count(
"Journal Entry Account",
filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1},
),
1,
)

View File

@ -0,0 +1,41 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Unreconcile Payments", {
refresh(frm) {
frm.set_query("voucher_type", function() {
return {
filters: {
name: ["in", ["Payment Entry", "Journal Entry"]]
}
}
});
frm.set_query("voucher_no", function(doc) {
return {
filters: {
company: doc.company,
docstatus: 1
}
}
});
},
get_allocations: function(frm) {
frm.clear_table("allocations");
frappe.call({
method: "get_allocations_from_payment",
doc: frm.doc,
callback: function(r) {
if (r.message) {
r.message.forEach(x => {
frm.add_child("allocations", x)
})
frm.refresh_fields();
}
}
})
}
});

View File

@ -0,0 +1,93 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "format:UNREC-{#####}",
"creation": "2023-08-22 10:26:34.421423",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"company",
"voucher_type",
"voucher_no",
"get_allocations",
"allocations",
"amended_from"
],
"fields": [
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Unreconcile Payments",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company"
},
{
"fieldname": "voucher_type",
"fieldtype": "Link",
"label": "Voucher Type",
"options": "DocType"
},
{
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"label": "Voucher No",
"options": "voucher_type"
},
{
"fieldname": "get_allocations",
"fieldtype": "Button",
"label": "Get Allocations"
},
{
"fieldname": "allocations",
"fieldtype": "Table",
"label": "Allocations",
"options": "Unreconcile Payment Entries"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-08-28 17:42:50.261377",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Unreconcile Payments",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"read": 1,
"role": "Accounts Manager",
"select": 1,
"share": 1,
"submit": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"read": 1,
"role": "Accounts User",
"select": 1,
"share": 1,
"submit": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -0,0 +1,158 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _, qb
from frappe.model.document import Document
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Abs, Sum
from frappe.utils.data import comma_and
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
unlink_ref_doc_from_payment_entries,
update_voucher_outstanding,
)
class UnreconcilePayments(Document):
def validate(self):
self.supported_types = ["Payment Entry", "Journal Entry"]
if not self.voucher_type in self.supported_types:
frappe.throw(_("Only {0} are supported").format(comma_and(self.supported_types)))
@frappe.whitelist()
def get_allocations_from_payment(self):
allocated_references = []
ple = qb.DocType("Payment Ledger Entry")
allocated_references = (
qb.from_(ple)
.select(
ple.account,
ple.party_type,
ple.party,
ple.against_voucher_type.as_("reference_doctype"),
ple.against_voucher_no.as_("reference_name"),
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
ple.account_currency,
)
.where(
(ple.docstatus == 1)
& (ple.voucher_type == self.voucher_type)
& (ple.voucher_no == self.voucher_no)
& (ple.voucher_no != ple.against_voucher_no)
)
.groupby(ple.against_voucher_type, ple.against_voucher_no)
.run(as_dict=True)
)
return allocated_references
def add_references(self):
allocations = self.get_allocations_from_payment()
for alloc in allocations:
self.append("allocations", alloc)
def on_submit(self):
# todo: more granular unreconciliation
for alloc in self.allocations:
doc = frappe.get_doc(alloc.reference_doctype, alloc.reference_name)
unlink_ref_doc_from_payment_entries(doc, self.voucher_no)
cancel_exchange_gain_loss_journal(doc, self.voucher_type, self.voucher_no)
update_voucher_outstanding(
alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party
)
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True)
@frappe.whitelist()
def doc_has_references(doctype: str = None, docname: str = None):
if doctype in ["Sales Invoice", "Purchase Invoice"]:
return frappe.db.count(
"Payment Ledger Entry",
filters={"delinked": 0, "against_voucher_no": docname, "amount": ["<", 0]},
)
else:
return frappe.db.count(
"Payment Ledger Entry",
filters={"delinked": 0, "voucher_no": docname, "against_voucher_no": ["!=", docname]},
)
@frappe.whitelist()
def get_linked_payments_for_doc(
company: str = None, doctype: str = None, docname: str = None
) -> list:
if company and doctype and docname:
_dt = doctype
_dn = docname
ple = qb.DocType("Payment Ledger Entry")
if _dt in ["Sales Invoice", "Purchase Invoice"]:
criteria = [
(ple.company == company),
(ple.delinked == 0),
(ple.against_voucher_no == _dn),
(ple.amount < 0),
]
res = (
qb.from_(ple)
.select(
ple.company,
ple.voucher_type,
ple.voucher_no,
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
ple.account_currency,
)
.where(Criterion.all(criteria))
.groupby(ple.voucher_no, ple.against_voucher_no)
.having(qb.Field("allocated_amount") > 0)
.run(as_dict=True)
)
return res
else:
criteria = [
(ple.company == company),
(ple.delinked == 0),
(ple.voucher_no == _dn),
(ple.against_voucher_no != _dn),
]
query = (
qb.from_(ple)
.select(
ple.company,
ple.against_voucher_type.as_("voucher_type"),
ple.against_voucher_no.as_("voucher_no"),
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
ple.account_currency,
)
.where(Criterion.all(criteria))
.groupby(ple.against_voucher_no)
)
res = query.run(as_dict=True)
return res
return []
@frappe.whitelist()
def create_unreconcile_doc_for_selection(selections=None):
if selections:
selections = frappe.json.loads(selections)
# assuming each row is a unique voucher
for row in selections:
unrecon = frappe.new_doc("Unreconcile Payments")
unrecon.company = row.get("company")
unrecon.voucher_type = row.get("voucher_type")
unrecon.voucher_no = row.get("voucher_no")
unrecon.add_references()
# remove unselected references
unrecon.allocations = [
x
for x in unrecon.allocations
if x.reference_doctype == row.get("against_voucher_type")
and x.reference_name == row.get("against_voucher_no")
]
unrecon.save().submit()

View File

@ -539,6 +539,10 @@ def get_round_off_account_and_cost_center(
"Company", company, ["round_off_account", "round_off_cost_center"] "Company", company, ["round_off_account", "round_off_cost_center"]
) or [None, None] ) or [None, None]
# Use expense account as fallback
if not round_off_account:
round_off_account = frappe.get_cached_value("Company", company, "default_expense_account")
meta = frappe.get_meta(voucher_type) meta = frappe.get_meta(voucher_type)
# Give first preference to parent cost center for round off GLE # Give first preference to parent cost center for round off GLE

View File

@ -409,7 +409,7 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals
if (account and account_currency != existing_gle_currency) or not account: if (account and account_currency != existing_gle_currency) or not account:
account = get_party_gle_account(party_type, party, company) account = get_party_gle_account(party_type, party, company)
if include_advance and party_type in ["Customer", "Supplier"]: if include_advance and party_type in ["Customer", "Supplier", "Student"]:
advance_account = get_party_advance_account(party_type, party, company) advance_account = get_party_advance_account(party_type, party, company)
if advance_account: if advance_account:
return [account, advance_account] return [account, advance_account]

View File

@ -37,24 +37,6 @@ frappe.query_reports["Accounts Payable"] = {
} }
} }
}, },
{
"fieldname": "supplier",
"label": __("Supplier"),
"fieldtype": "Link",
"options": "Supplier",
on_change: () => {
var supplier = frappe.query_report.get_filter_value('supplier');
if (supplier) {
frappe.db.get_value('Supplier', supplier, "tax_id", function(value) {
frappe.query_report.set_filter_value('tax_id', value["tax_id"]);
});
} else {
frappe.query_report.set_filter_value('tax_id', "");
}
frappe.query_report.refresh();
}
},
{ {
"fieldname": "party_account", "fieldname": "party_account",
"label": __("Payable Account"), "label": __("Payable Account"),
@ -112,11 +94,35 @@ frappe.query_reports["Accounts Payable"] = {
"fieldtype": "Link", "fieldtype": "Link",
"options": "Payment Terms Template" "options": "Payment Terms Template"
}, },
{
"fieldname":"party_type",
"label": __("Party Type"),
"fieldtype": "Autocomplete",
options: get_party_type_options(),
on_change: function() {
frappe.query_report.set_filter_value('party', "");
frappe.query_report.toggle_filter_display('supplier_group', frappe.query_report.get_filter_value('party_type') !== "Supplier");
}
},
{
"fieldname":"party",
"label": __("Party"),
"fieldtype": "MultiSelectList",
get_data: function(txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value('party_type');
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{ {
"fieldname": "supplier_group", "fieldname": "supplier_group",
"label": __("Supplier Group"), "label": __("Supplier Group"),
"fieldtype": "Link", "fieldtype": "Link",
"options": "Supplier Group" "options": "Supplier Group",
"hidden": 1
}, },
{ {
"fieldname": "group_by_party", "fieldname": "group_by_party",
@ -133,12 +139,6 @@ frappe.query_reports["Accounts Payable"] = {
"label": __("Show Remarks"), "label": __("Show Remarks"),
"fieldtype": "Check", "fieldtype": "Check",
}, },
{
"fieldname": "tax_id",
"label": __("Tax Id"),
"fieldtype": "Data",
"hidden": 1
},
{ {
"fieldname": "show_future_payments", "fieldname": "show_future_payments",
"label": __("Show Future Payments"), "label": __("Show Future Payments"),
@ -164,3 +164,15 @@ frappe.query_reports["Accounts Payable"] = {
} }
erpnext.utils.add_dimensions('Accounts Payable', 9); erpnext.utils.add_dimensions('Accounts Payable', 9);
function get_party_type_options() {
let options = [];
frappe.db.get_list(
"Party Type", {filters:{"account_type": "Payable"}, fields:['name']}
).then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@ -0,0 +1,67 @@
import unittest
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, getdate, today
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.accounts_payable.accounts_payable import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.create_supplier(currency="USD", supplier_name="Test Supplier2")
self.create_usd_payable_account()
def tearDown(self):
frappe.db.rollback()
def test_accounts_payable_for_foreign_currency_supplier(self):
pi = self.create_purchase_invoice(do_not_submit=True)
pi.currency = "USD"
pi.conversion_rate = 80
pi.credit_to = self.creditors_usd
pi = pi.save().submit()
filters = {
"company": self.company,
"party_type": "Supplier",
"party": [self.supplier],
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
}
data = execute(filters)
self.assertEqual(data[1][0].get("outstanding"), 300)
self.assertEqual(data[1][0].get("currency"), "USD")
def create_purchase_invoice(self, do_not_submit=False):
frappe.set_user("Administrator")
pi = make_purchase_invoice(
item=self.item,
company=self.company,
supplier=self.supplier,
is_return=False,
update_stock=False,
posting_date=frappe.utils.datetime.date(2021, 5, 1),
do_not_save=1,
rate=300,
price_list_rate=300,
qty=1,
)
pi = pi.save()
if not do_not_submit:
pi = pi.submit()
return pi

View File

@ -72,10 +72,27 @@ frappe.query_reports["Accounts Payable Summary"] = {
} }
}, },
{ {
"fieldname":"supplier", "fieldname":"party_type",
"label": __("Supplier"), "label": __("Party Type"),
"fieldtype": "Link", "fieldtype": "Autocomplete",
"options": "Supplier" options: get_party_type_options(),
on_change: function() {
frappe.query_report.set_filter_value('party', "");
frappe.query_report.toggle_filter_display('supplier_group', frappe.query_report.get_filter_value('party_type') !== "Supplier");
}
},
{
"fieldname":"party",
"label": __("Party"),
"fieldtype": "MultiSelectList",
get_data: function(txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value('party_type');
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
}, },
{ {
"fieldname":"payment_terms_template", "fieldname":"payment_terms_template",
@ -105,3 +122,15 @@ frappe.query_reports["Accounts Payable Summary"] = {
} }
erpnext.utils.add_dimensions('Accounts Payable Summary', 9); erpnext.utils.add_dimensions('Accounts Payable Summary', 9);
function get_party_type_options() {
let options = [];
frappe.db.get_list(
"Party Type", {filters:{"account_type": "Payable"}, fields:['name']}
).then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@ -1,6 +1,8 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
frappe.provide("erpnext.utils");
frappe.query_reports["Accounts Receivable"] = { frappe.query_reports["Accounts Receivable"] = {
"filters": [ "filters": [
{ {
@ -38,34 +40,28 @@ frappe.query_reports["Accounts Receivable"] = {
} }
}, },
{ {
"fieldname": "customer", "fieldname":"party_type",
"label": __("Customer"), "label": __("Party Type"),
"fieldtype": "Link", "fieldtype": "Autocomplete",
"options": "Customer", options: get_party_type_options(),
on_change: () => { on_change: function() {
var customer = frappe.query_report.get_filter_value('customer'); frappe.query_report.set_filter_value('party', "");
var company = frappe.query_report.get_filter_value('company'); frappe.query_report.toggle_filter_display('customer_group', frappe.query_report.get_filter_value('party_type') !== "Customer");
if (customer) {
frappe.db.get_value('Customer', customer, ["tax_id", "customer_name", "payment_terms"], function(value) {
frappe.query_report.set_filter_value('tax_id', value["tax_id"]);
frappe.query_report.set_filter_value('customer_name', value["customer_name"]);
frappe.query_report.set_filter_value('payment_terms', value["payment_terms"]);
});
frappe.db.get_value('Customer Credit Limit', {'parent': customer, 'company': company},
["credit_limit"], function(value) {
if (value) {
frappe.query_report.set_filter_value('credit_limit', value["credit_limit"]);
}
}, "Customer");
} else {
frappe.query_report.set_filter_value('tax_id', "");
frappe.query_report.set_filter_value('customer_name', "");
frappe.query_report.set_filter_value('credit_limit', "");
frappe.query_report.set_filter_value('payment_terms', "");
}
} }
}, },
{
"fieldname":"party",
"label": __("Party"),
"fieldtype": "MultiSelectList",
get_data: function(txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value('party_type');
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
},
{ {
"fieldname": "party_account", "fieldname": "party_account",
"label": __("Receivable Account"), "label": __("Receivable Account"),
@ -172,34 +168,10 @@ frappe.query_reports["Accounts Receivable"] = {
"label": __("Show Sales Person"), "label": __("Show Sales Person"),
"fieldtype": "Check", "fieldtype": "Check",
}, },
{
"fieldname": "tax_id",
"label": __("Tax Id"),
"fieldtype": "Data",
"hidden": 1
},
{ {
"fieldname": "show_remarks", "fieldname": "show_remarks",
"label": __("Show Remarks"), "label": __("Show Remarks"),
"fieldtype": "Check", "fieldtype": "Check",
},
{
"fieldname": "customer_name",
"label": __("Customer Name"),
"fieldtype": "Data",
"hidden": 1
},
{
"fieldname": "payment_terms",
"label": __("Payment Tems"),
"fieldtype": "Data",
"hidden": 1
},
{
"fieldname": "credit_limit",
"label": __("Credit Limit"),
"fieldtype": "Currency",
"hidden": 1
} }
], ],
@ -221,3 +193,16 @@ frappe.query_reports["Accounts Receivable"] = {
} }
erpnext.utils.add_dimensions('Accounts Receivable', 9); erpnext.utils.add_dimensions('Accounts Receivable', 9);
function get_party_type_options() {
let options = [];
frappe.db.get_list(
"Party Type", {filters:{"account_type": "Receivable"}, fields:['name']}
).then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@ -211,9 +211,8 @@ class ReceivablePayableReport(object):
return return
# amount in "Party Currency", if its supplied. If not, amount in company currency # amount in "Party Currency", if its supplied. If not, amount in company currency
for party_type in self.party_type: if self.filters.get("party_type") and self.filters.get("party"):
if self.filters.get(scrub(party_type)): amount = ple.amount_in_account_currency
amount = ple.amount_in_account_currency
else: else:
amount = ple.amount amount = ple.amount
amount_in_account_currency = ple.amount_in_account_currency amount_in_account_currency = ple.amount_in_account_currency
@ -426,10 +425,9 @@ class ReceivablePayableReport(object):
# customer / supplier name # customer / supplier name
party_details = self.get_party_details(row.party) or {} party_details = self.get_party_details(row.party) or {}
row.update(party_details) row.update(party_details)
for party_type in self.party_type:
if self.filters.get(scrub(party_type)): if self.filters.get("party_type") and self.filters.get("party"):
row.currency = row.account_currency row.currency = row.account_currency
break
else: else:
row.currency = self.company_currency row.currency = self.company_currency
@ -469,6 +467,10 @@ class ReceivablePayableReport(object):
original_row = frappe._dict(row) original_row = frappe._dict(row)
row.payment_terms = [] row.payment_terms = []
# Cr Note's don't have Payment Terms
if not payment_terms_details:
return
# Advance allocated during invoicing is not considered in payment terms # Advance allocated during invoicing is not considered in payment terms
# Deduct that from paid amount pre allocation # Deduct that from paid amount pre allocation
row.paid -= flt(payment_terms_details[0].total_advance) row.paid -= flt(payment_terms_details[0].total_advance)
@ -765,16 +767,14 @@ class ReceivablePayableReport(object):
def prepare_conditions(self): def prepare_conditions(self):
self.qb_selection_filter = [] self.qb_selection_filter = []
self.or_filters = [] self.or_filters = []
for party_type in self.party_type: for party_type in self.party_type:
party_type_field = scrub(party_type) self.add_common_filters()
self.or_filters.append(self.ple.party_type == party_type)
self.add_common_filters(party_type_field=party_type_field) if self.account_type == "Receivable":
if party_type_field == "customer":
self.add_customer_filters() self.add_customer_filters()
elif party_type_field == "supplier": elif self.account_type == "Payable":
self.add_supplier_filters() self.add_supplier_filters()
if self.filters.cost_center: if self.filters.cost_center:
@ -790,15 +790,18 @@ class ReceivablePayableReport(object):
] ]
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list)) self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))
def add_common_filters(self, party_type_field): def add_common_filters(self):
if self.filters.company: if self.filters.company:
self.qb_selection_filter.append(self.ple.company == self.filters.company) self.qb_selection_filter.append(self.ple.company == self.filters.company)
if self.filters.finance_book: if self.filters.finance_book:
self.qb_selection_filter.append(self.ple.finance_book == self.filters.finance_book) self.qb_selection_filter.append(self.ple.finance_book == self.filters.finance_book)
if self.filters.get(party_type_field): if self.filters.get("party_type"):
self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field)) self.qb_selection_filter.append(self.filters.party_type == self.ple.party_type)
if self.filters.get("party"):
self.qb_selection_filter.append(self.ple.party.isin(self.filters.party))
if self.filters.party_account: if self.filters.party_account:
self.qb_selection_filter.append(self.ple.account == self.filters.party_account) self.qb_selection_filter.append(self.ple.account == self.filters.party_account)
@ -960,6 +963,20 @@ class ReceivablePayableReport(object):
fieldtype="Link", fieldtype="Link",
options="Contact", options="Contact",
) )
if self.filters.party_type == "Customer":
self.add_column(
_("Customer Name"),
fieldname="customer_name",
fieldtype="Link",
options="Customer",
)
elif self.filters.party_type == "Supplier":
self.add_column(
_("Supplier Name"),
fieldname="supplier_name",
fieldtype="Link",
options="Supplier",
)
self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data") self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data")
self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data") self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data")

View File

@ -8,20 +8,17 @@ from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute from erpnext.accounts.report.accounts_receivable.accounts_receivable import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
class TestAccountsReceivable(FrappeTestCase): class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
def setUp(self): def setUp(self):
frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 2'") self.create_company()
frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'") self.create_customer()
frappe.db.sql("delete from `tabPayment Entry` where company='_Test Company 2'") self.create_item()
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 2'") self.create_usd_receivable_account()
frappe.db.sql("delete from `tabPayment Ledger Entry` where company='_Test Company 2'") self.clear_old_entries()
frappe.db.sql("delete from `tabJournal Entry` where company='_Test Company 2'")
frappe.db.sql("delete from `tabExchange Rate Revaluation` where company='_Test Company 2'")
self.create_usd_account()
def tearDown(self): def tearDown(self):
frappe.db.rollback() frappe.db.rollback()
@ -49,29 +46,84 @@ class TestAccountsReceivable(FrappeTestCase):
debtors_usd.account_type = debtors.account_type debtors_usd.account_type = debtors.account_type
self.debtors_usd = debtors_usd.save().name self.debtors_usd = debtors_usd.save().name
def create_sales_invoice(self, no_payment_schedule=False, do_not_submit=False):
frappe.set_user("Administrator")
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_save=1,
)
if not no_payment_schedule:
si.append(
"payment_schedule",
dict(due_date=getdate(add_days(today(), 30)), invoice_portion=30.00, payment_amount=30),
)
si.append(
"payment_schedule",
dict(due_date=getdate(add_days(today(), 60)), invoice_portion=50.00, payment_amount=50),
)
si.append(
"payment_schedule",
dict(due_date=getdate(add_days(today(), 90)), invoice_portion=20.00, payment_amount=20),
)
si = si.save()
if not do_not_submit:
si = si.submit()
return si
def create_payment_entry(self, docname):
pe = get_payment_entry("Sales Invoice", docname, bank_account=self.cash, party_amount=40)
pe.paid_from = self.debit_to
pe.insert()
pe.submit()
def create_credit_note(self, docname):
credit_note = create_sales_invoice(
company=self.company,
customer=self.customer,
item=self.item,
qty=-1,
debit_to=self.debit_to,
cost_center=self.cost_center,
is_return=1,
return_against=docname,
)
return credit_note
def test_accounts_receivable(self): def test_accounts_receivable(self):
filters = { filters = {
"company": "_Test Company 2", "company": self.company,
"based_on_payment_terms": 1, "based_on_payment_terms": 1,
"report_date": today(), "report_date": today(),
"range1": 30, "range1": 30,
"range2": 60, "range2": 60,
"range3": 90, "range3": 90,
"range4": 120, "range4": 120,
"show_remarks": True,
} }
# check invoice grand total and invoiced column's value for 3 payment terms # check invoice grand total and invoiced column's value for 3 payment terms
name = make_sales_invoice().name si = self.create_sales_invoice()
name = si.name
report = execute(filters) report = execute(filters)
expected_data = [[100, 30], [100, 50], [100, 20]] expected_data = [[100, 30, "No Remarks"], [100, 50, "No Remarks"], [100, 20, "No Remarks"]]
for i in range(3): for i in range(3):
row = report[1][i - 1] row = report[1][i - 1]
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced]) self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced, row.remarks])
# check invoice grand total, invoiced, paid and outstanding column's value after payment # check invoice grand total, invoiced, paid and outstanding column's value after payment
make_payment(name) self.create_payment_entry(si.name)
report = execute(filters) report = execute(filters)
expected_data_after_payment = [[100, 50, 10, 40], [100, 20, 0, 20]] expected_data_after_payment = [[100, 50, 10, 40], [100, 20, 0, 20]]
@ -84,10 +136,10 @@ class TestAccountsReceivable(FrappeTestCase):
) )
# check invoice grand total, invoiced, paid and outstanding column's value after credit note # check invoice grand total, invoiced, paid and outstanding column's value after credit note
make_credit_note(name) self.create_credit_note(si.name)
report = execute(filters) report = execute(filters)
expected_data_after_credit_note = [100, 0, 0, 40, -40, "Debtors - _TC2"] expected_data_after_credit_note = [100, 0, 0, 40, -40, self.debit_to]
row = report[1][0] row = report[1][0]
self.assertEqual( self.assertEqual(
@ -108,21 +160,20 @@ class TestAccountsReceivable(FrappeTestCase):
""" """
so = make_sales_order( so = make_sales_order(
company="_Test Company 2", company=self.company,
customer="_Test Customer 2", customer=self.customer,
warehouse="Finished Goods - _TC2", warehouse=self.warehouse,
currency="EUR", debit_to=self.debit_to,
debit_to="Debtors - _TC2", income_account=self.income_account,
income_account="Sales - _TC2", expense_account=self.expense_account,
expense_account="Cost of Goods Sold - _TC2", cost_center=self.cost_center,
cost_center="Main - _TC2",
) )
pe = get_payment_entry(so.doctype, so.name) pe = get_payment_entry(so.doctype, so.name)
pe = pe.save().submit() pe = pe.save().submit()
filters = { filters = {
"company": "_Test Company 2", "company": self.company,
"based_on_payment_terms": 0, "based_on_payment_terms": 0,
"report_date": today(), "report_date": today(),
"range1": 30, "range1": 30,
@ -147,34 +198,32 @@ class TestAccountsReceivable(FrappeTestCase):
) )
@change_settings( @change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1} "Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
) )
def test_exchange_revaluation_for_party(self): def test_exchange_revaluation_for_party(self):
""" """
Exchange Revaluation for party on Receivable/Payable shoule be included Exchange Revaluation for party on Receivable/Payable should be included
""" """
company = "_Test Company 2"
customer = "_Test Customer 2"
# Using Exchange Gain/Loss account for unrealized as well. # Using Exchange Gain/Loss account for unrealized as well.
company_doc = frappe.get_doc("Company", company) company_doc = frappe.get_doc("Company", self.company)
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
company_doc.save() company_doc.save()
si = make_sales_invoice(no_payment_schedule=True, do_not_submit=True) si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.currency = "USD" si.currency = "USD"
si.conversion_rate = 0.90 si.conversion_rate = 80
si.debit_to = self.debtors_usd si.debit_to = self.debtors_usd
si = si.save().submit() si = si.save().submit()
# Exchange Revaluation # Exchange Revaluation
err = frappe.new_doc("Exchange Rate Revaluation") err = frappe.new_doc("Exchange Rate Revaluation")
err.company = company err.company = self.company
err.posting_date = today() err.posting_date = today()
accounts = err.get_accounts_data() accounts = err.get_accounts_data()
err.extend("accounts", accounts) err.extend("accounts", accounts)
err.accounts[0].new_exchange_rate = 0.95 err.accounts[0].new_exchange_rate = 85
row = err.accounts[0] row = err.accounts[0]
row.new_balance_in_base_currency = flt( row.new_balance_in_base_currency = flt(
row.new_exchange_rate * flt(row.balance_in_account_currency) row.new_exchange_rate * flt(row.balance_in_account_currency)
@ -189,7 +238,7 @@ class TestAccountsReceivable(FrappeTestCase):
je = je.submit() je = je.submit()
filters = { filters = {
"company": company, "company": self.company,
"report_date": today(), "report_date": today(),
"range1": 30, "range1": 30,
"range2": 60, "range2": 60,
@ -198,7 +247,7 @@ class TestAccountsReceivable(FrappeTestCase):
} }
report = execute(filters) report = execute(filters)
expected_data_for_err = [0, -5, 0, 5] expected_data_for_err = [0, -500, 0, 500]
row = [x for x in report[1] if x.voucher_type == je.doctype and x.voucher_no == je.name][0] row = [x for x in report[1] if x.voucher_type == je.doctype and x.voucher_no == je.name][0]
self.assertEqual( self.assertEqual(
expected_data_for_err, expected_data_for_err,
@ -214,46 +263,43 @@ class TestAccountsReceivable(FrappeTestCase):
""" """
Payment against credit/debit note should be considered against the parent invoice Payment against credit/debit note should be considered against the parent invoice
""" """
company = "_Test Company 2"
customer = "_Test Customer 2"
si1 = make_sales_invoice() si1 = self.create_sales_invoice()
pe = get_payment_entry("Sales Invoice", si1.name, bank_account="Cash - _TC2") pe = get_payment_entry(si1.doctype, si1.name, bank_account=self.cash)
pe.paid_from = "Debtors - _TC2" pe.paid_from = self.debit_to
pe.insert() pe.insert()
pe.submit() pe.submit()
cr_note = make_credit_note(si1.name) cr_note = self.create_credit_note(si1.name)
si2 = make_sales_invoice() si2 = self.create_sales_invoice()
# manually link cr_note with si2 using journal entry # manually link cr_note with si2 using journal entry
je = frappe.new_doc("Journal Entry") je = frappe.new_doc("Journal Entry")
je.company = company je.company = self.company
je.voucher_type = "Credit Note" je.voucher_type = "Credit Note"
je.posting_date = today() je.posting_date = today()
debit_account = "Debtors - _TC2"
debit_entry = { debit_entry = {
"account": debit_account, "account": self.debit_to,
"party_type": "Customer", "party_type": "Customer",
"party": customer, "party": self.customer,
"debit": 100, "debit": 100,
"debit_in_account_currency": 100, "debit_in_account_currency": 100,
"reference_type": cr_note.doctype, "reference_type": cr_note.doctype,
"reference_name": cr_note.name, "reference_name": cr_note.name,
"cost_center": "Main - _TC2", "cost_center": self.cost_center,
} }
credit_entry = { credit_entry = {
"account": debit_account, "account": self.debit_to,
"party_type": "Customer", "party_type": "Customer",
"party": customer, "party": self.customer,
"credit": 100, "credit": 100,
"credit_in_account_currency": 100, "credit_in_account_currency": 100,
"reference_type": si2.doctype, "reference_type": si2.doctype,
"reference_name": si2.name, "reference_name": si2.name,
"cost_center": "Main - _TC2", "cost_center": self.cost_center,
} }
je.append("accounts", debit_entry) je.append("accounts", debit_entry)
@ -261,7 +307,7 @@ class TestAccountsReceivable(FrappeTestCase):
je = je.save().submit() je = je.save().submit()
filters = { filters = {
"company": company, "company": self.company,
"report_date": today(), "report_date": today(),
"range1": 30, "range1": 30,
"range2": 60, "range2": 60,
@ -271,64 +317,329 @@ class TestAccountsReceivable(FrappeTestCase):
report = execute(filters) report = execute(filters)
self.assertEqual(report[1], []) self.assertEqual(report[1], [])
def test_group_by_party(self):
si1 = self.create_sales_invoice(do_not_submit=True)
si1.posting_date = add_days(today(), -1)
si1.save().submit()
si2 = self.create_sales_invoice(do_not_submit=True)
si2.items[0].rate = 85
si2.save().submit()
def make_sales_invoice(no_payment_schedule=False, do_not_submit=False): filters = {
frappe.set_user("Administrator") "company": self.company,
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
"group_by_party": True,
}
report = execute(filters)[1]
self.assertEqual(len(report), 5)
si = create_sales_invoice( # assert voucher rows
company="_Test Company 2", expected_voucher_rows = [
customer="_Test Customer 2", [100.0, 100.0, 100.0, 100.0],
currency="EUR", [85.0, 85.0, 85.0, 85.0],
warehouse="Finished Goods - _TC2", ]
debit_to="Debtors - _TC2", voucher_rows = []
income_account="Sales - _TC2", for x in report[0:2]:
expense_account="Cost of Goods Sold - _TC2", voucher_rows.append(
cost_center="Main - _TC2", [x.invoiced, x.outstanding, x.invoiced_in_account_currency, x.outstanding_in_account_currency]
do_not_save=1, )
) self.assertEqual(expected_voucher_rows, voucher_rows)
if not no_payment_schedule: # assert total rows
si.append( expected_total_rows = [
"payment_schedule", [self.customer, 185.0, 185.0], # party total
dict(due_date=getdate(add_days(today(), 30)), invoice_portion=30.00, payment_amount=30), {}, # empty row for padding
["Total", 185.0, 185.0], # grand total
]
party_total_row = report[2]
self.assertEqual(
expected_total_rows[0],
[
party_total_row.get("party"),
party_total_row.get("invoiced"),
party_total_row.get("outstanding"),
],
) )
si.append( empty_row = report[3]
"payment_schedule", self.assertEqual(expected_total_rows[1], empty_row)
dict(due_date=getdate(add_days(today(), 60)), invoice_portion=50.00, payment_amount=50), grand_total_row = report[4]
) self.assertEqual(
si.append( expected_total_rows[2],
"payment_schedule", [
dict(due_date=getdate(add_days(today(), 90)), invoice_portion=20.00, payment_amount=20), grand_total_row.get("party"),
grand_total_row.get("invoiced"),
grand_total_row.get("outstanding"),
],
) )
si = si.save() def test_future_payments(self):
si = self.create_sales_invoice()
pe = get_payment_entry(si.doctype, si.name)
pe.posting_date = add_days(today(), 1)
pe.paid_amount = 90.0
pe.references[0].allocated_amount = 90.0
pe.save().submit()
filters = {
"company": self.company,
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
"show_future_payments": True,
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
if not do_not_submit: expected_data = [100.0, 100.0, 10.0, 90.0]
si = si.submit()
return si row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount]
)
pe.cancel()
# full payment in future date
pe = get_payment_entry(si.doctype, si.name)
pe.posting_date = add_days(today(), 1)
pe.save().submit()
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [100.0, 100.0, 0.0, 100.0]
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount]
)
def make_payment(docname): pe.cancel()
pe = get_payment_entry("Sales Invoice", docname, bank_account="Cash - _TC2", party_amount=40) # over payment in future date
pe.paid_from = "Debtors - _TC2" pe = get_payment_entry(si.doctype, si.name)
pe.insert() pe.posting_date = add_days(today(), 1)
pe.submit() pe.paid_amount = 110
pe.save().submit()
report = execute(filters)[1]
self.assertEqual(len(report), 2)
expected_data = [[100.0, 0.0, 100.0, 0.0, 100.0], [0.0, 10.0, -10.0, -10.0, 0.0]]
for idx, row in enumerate(report):
self.assertEqual(
expected_data[idx],
[row.invoiced, row.paid, row.outstanding, row.remaining_balance, row.future_amount],
)
def test_sales_person(self):
sales_person = (
frappe.get_doc({"doctype": "Sales Person", "sales_person_name": "John Clark", "enabled": True})
.insert()
.submit()
)
si = self.create_sales_invoice(do_not_submit=True)
si.append("sales_team", {"sales_person": sales_person.name, "allocated_percentage": 100})
si.save().submit()
def make_credit_note(docname): filters = {
credit_note = create_sales_invoice( "company": self.company,
company="_Test Company 2", "report_date": today(),
customer="_Test Customer 2", "range1": 30,
currency="EUR", "range2": 60,
qty=-1, "range3": 90,
warehouse="Finished Goods - _TC2", "range4": 120,
debit_to="Debtors - _TC2", "sales_person": sales_person.name,
income_account="Sales - _TC2", "show_sales_person": True,
expense_account="Cost of Goods Sold - _TC2", }
cost_center="Main - _TC2", report = execute(filters)[1]
is_return=1, self.assertEqual(len(report), 1)
return_against=docname,
)
return credit_note expected_data = [100.0, 100.0, sales_person.name]
row = report[0]
self.assertEqual(expected_data, [row.invoiced, row.outstanding, row.sales_person])
def test_cost_center_filter(self):
si = self.create_sales_invoice()
filters = {
"company": self.company,
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
"cost_center": self.cost_center,
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [100.0, 100.0, self.cost_center]
row = report[0]
self.assertEqual(expected_data, [row.invoiced, row.outstanding, row.cost_center])
def test_customer_group_filter(self):
si = self.create_sales_invoice()
cus_group = frappe.db.get_value("Customer", self.customer, "customer_group")
filters = {
"company": self.company,
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
"customer_group": cus_group,
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [100.0, 100.0, cus_group]
row = report[0]
self.assertEqual(expected_data, [row.invoiced, row.outstanding, row.customer_group])
filters.update({"customer_group": "Individual"})
report = execute(filters)[1]
self.assertEqual(len(report), 0)
def test_party_account_filter(self):
si1 = self.create_sales_invoice()
self.customer2 = (
frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "Jane Doe",
"type": "Individual",
"default_currency": "USD",
}
)
.insert()
.submit()
)
si2 = self.create_sales_invoice(do_not_submit=True)
si2.posting_date = add_days(today(), -1)
si2.customer = self.customer2
si2.currency = "USD"
si2.conversion_rate = 80
si2.debit_to = self.debtors_usd
si2.save().submit()
# Filter on company currency receivable account
filters = {
"company": self.company,
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
"party_account": self.debit_to,
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [100.0, 100.0, self.debit_to, si1.currency]
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.party_account, row.account_currency]
)
# Filter on USD receivable account
filters.update({"party_account": self.debtors_usd})
report = execute(filters)[1]
self.assertEqual(len(report), 1)
expected_data = [8000.0, 8000.0, self.debtors_usd, si2.currency]
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.party_account, row.account_currency]
)
# without filter on party account
filters.pop("party_account")
report = execute(filters)[1]
self.assertEqual(len(report), 2)
expected_data = [
[8000.0, 8000.0, 100.0, 100.0, self.debtors_usd, si2.currency],
[100.0, 100.0, 100.0, 100.0, self.debit_to, si1.currency],
]
for idx, row in enumerate(report):
self.assertEqual(
expected_data[idx],
[
row.invoiced,
row.outstanding,
row.invoiced_in_account_currency,
row.outstanding_in_account_currency,
row.party_account,
row.account_currency,
],
)
def test_usd_customer_filter(self):
filters = {
"company": self.company,
"party_type": "Customer",
"party": [self.customer],
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
}
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.currency = "USD"
si.conversion_rate = 80
si.debit_to = self.debtors_usd
si.save().submit()
name = si.name
# check invoice grand total and invoiced column's value for 3 payment terms
report = execute(filters)
expected = {
"voucher_type": si.doctype,
"voucher_no": si.name,
"party_account": self.debtors_usd,
"customer_name": self.customer,
"invoiced": 100.0,
"outstanding": 100.0,
"account_currency": "USD",
}
self.assertEqual(len(report[1]), 1)
report_output = report[1][0]
for field in expected:
with self.subTest(field=field):
self.assertEqual(report_output.get(field), expected.get(field))
def test_multi_select_party_filter(self):
self.customer1 = self.customer
self.create_customer("_Test Customer 2")
self.customer2 = self.customer
self.create_customer("_Test Customer 3")
self.customer3 = self.customer
filters = {
"company": self.company,
"party_type": "Customer",
"party": [self.customer1, self.customer3],
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
}
si1 = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si1.customer = self.customer1
si1.save().submit()
si2 = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si2.customer = self.customer2
si2.save().submit()
si3 = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si3.customer = self.customer3
si3.save().submit()
# check invoice grand total and invoiced column's value for 3 payment terms
report = execute(filters)
expected_output = {self.customer1, self.customer3}
self.assertEqual(len(report[1]), 2)
output_for = set([x.party for x in report[1]])
self.assertEqual(output_for, expected_output)

View File

@ -72,10 +72,27 @@ frappe.query_reports["Accounts Receivable Summary"] = {
} }
}, },
{ {
"fieldname":"customer", "fieldname":"party_type",
"label": __("Customer"), "label": __("Party Type"),
"fieldtype": "Link", "fieldtype": "Autocomplete",
"options": "Customer" options: get_party_type_options(),
on_change: function() {
frappe.query_report.set_filter_value('party', "");
frappe.query_report.toggle_filter_display('customer_group', frappe.query_report.get_filter_value('party_type') !== "Customer");
}
},
{
"fieldname":"party",
"label": __("Party"),
"fieldtype": "MultiSelectList",
get_data: function(txt) {
if (!frappe.query_report.filters) return;
let party_type = frappe.query_report.get_filter_value('party_type');
if (!party_type) return;
return frappe.db.get_link_options(party_type, txt);
},
}, },
{ {
"fieldname":"customer_group", "fieldname":"customer_group",
@ -133,3 +150,15 @@ frappe.query_reports["Accounts Receivable Summary"] = {
} }
erpnext.utils.add_dimensions('Accounts Receivable Summary', 9); erpnext.utils.add_dimensions('Accounts Receivable Summary', 9);
function get_party_type_options() {
let options = [];
frappe.db.get_list(
"Party Type", {filters:{"account_type": "Receivable"}, fields:['name']}
).then((res) => {
res.forEach((party_type) => {
options.push(party_type.name);
});
});
return options;
}

View File

@ -99,13 +99,11 @@ class AccountsReceivableSummary(ReceivablePayableReport):
# Add all amount columns # Add all amount columns
for k in list(self.party_total[d.party]): for k in list(self.party_total[d.party]):
if k not in ["currency", "sales_person"]: if isinstance(self.party_total[d.party][k], float):
self.party_total[d.party][k] += d.get(k) or 0.0
self.party_total[d.party][k] += d.get(k, 0.0)
# set territory, customer_group, sales person etc # set territory, customer_group, sales person etc
self.set_party_details(d) self.set_party_details(d)
self.party_total[d.party].update({"party_type": d.party_type})
def init_party_total(self, row): def init_party_total(self, row):
self.party_total.setdefault( self.party_total.setdefault(
@ -124,6 +122,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
"total_due": 0.0, "total_due": 0.0,
"future_amount": 0.0, "future_amount": 0.0,
"sales_person": [], "sales_person": [],
"party_type": row.party_type,
} }
), ),
) )
@ -133,13 +132,12 @@ class AccountsReceivableSummary(ReceivablePayableReport):
for key in ("territory", "customer_group", "supplier_group"): for key in ("territory", "customer_group", "supplier_group"):
if row.get(key): if row.get(key):
self.party_total[row.party][key] = row.get(key) self.party_total[row.party][key] = row.get(key, "")
if row.sales_person: if row.sales_person:
self.party_total[row.party].sales_person.append(row.sales_person) self.party_total[row.party].sales_person.append(row.get("sales_person", ""))
if self.filters.sales_partner: if self.filters.sales_partner:
self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner") self.party_total[row.party]["default_sales_partner"] = row.get("default_sales_partner", "")
def get_columns(self): def get_columns(self):
self.columns = [] self.columns = []

View File

@ -58,6 +58,9 @@ def get_data(filters):
def get_asset_categories(filters): def get_asset_categories(filters):
condition = ""
if filters.get("asset_category"):
condition += " and asset_category = %(asset_category)s"
return frappe.db.sql( return frappe.db.sql(
""" """
SELECT asset_category, SELECT asset_category,
@ -98,15 +101,25 @@ def get_asset_categories(filters):
0 0
end), 0) as cost_of_scrapped_asset end), 0) as cost_of_scrapped_asset
from `tabAsset` from `tabAsset`
where docstatus=1 and company=%(company)s and purchase_date <= %(to_date)s where docstatus=1 and company=%(company)s and purchase_date <= %(to_date)s {}
group by asset_category group by asset_category
""", """.format(
{"to_date": filters.to_date, "from_date": filters.from_date, "company": filters.company}, condition
),
{
"to_date": filters.to_date,
"from_date": filters.from_date,
"company": filters.company,
"asset_category": filters.get("asset_category"),
},
as_dict=1, as_dict=1,
) )
def get_assets(filters): def get_assets(filters):
condition = ""
if filters.get("asset_category"):
condition = " and a.asset_category = '{}'".format(filters.get("asset_category"))
return frappe.db.sql( return frappe.db.sql(
""" """
SELECT results.asset_category, SELECT results.asset_category,
@ -138,7 +151,7 @@ def get_assets(filters):
aca.parent = a.asset_category and aca.company_name = %(company)s aca.parent = a.asset_category and aca.company_name = %(company)s
join `tabCompany` company on join `tabCompany` company on
company.name = %(company)s company.name = %(company)s
where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and gle.debit != 0 and gle.is_cancelled = 0 and gle.account = ifnull(aca.depreciation_expense_account, company.depreciation_expense_account) where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s and gle.debit != 0 and gle.is_cancelled = 0 and gle.account = ifnull(aca.depreciation_expense_account, company.depreciation_expense_account) {0}
group by a.asset_category group by a.asset_category
union union
SELECT a.asset_category, SELECT a.asset_category,
@ -154,10 +167,12 @@ def get_assets(filters):
end), 0) as depreciation_eliminated_during_the_period, end), 0) as depreciation_eliminated_during_the_period,
0 as depreciation_amount_during_the_period 0 as depreciation_amount_during_the_period
from `tabAsset` a from `tabAsset` a
where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s where a.docstatus=1 and a.company=%(company)s and a.purchase_date <= %(to_date)s {0}
group by a.asset_category) as results group by a.asset_category) as results
group by results.asset_category group by results.asset_category
""", """.format(
condition
),
{"to_date": filters.to_date, "from_date": filters.from_date, "company": filters.company}, {"to_date": filters.to_date, "from_date": filters.from_date, "company": filters.company},
as_dict=1, as_dict=1,
) )

View File

@ -1,26 +1,23 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
frappe.require("assets/erpnext/js/financial_statements.js", function () { frappe.query_reports["Balance Sheet"] = $.extend(
frappe.query_reports["Balance Sheet"] = $.extend( {},
{}, erpnext.financial_statements
erpnext.financial_statements );
);
erpnext.utils.add_dimensions("Balance Sheet", 10); erpnext.utils.add_dimensions("Balance Sheet", 10);
frappe.query_reports["Balance Sheet"]["filters"].push({ frappe.query_reports["Balance Sheet"]["filters"].push({
fieldname: "accumulated_values", fieldname: "accumulated_values",
label: __("Accumulated Values"), label: __("Accumulated Values"),
fieldtype: "Check", fieldtype: "Check",
default: 1, default: 1,
}); });
console.log(frappe.query_reports["Balance Sheet"]["filters"]);
frappe.query_reports["Balance Sheet"]["filters"].push({
frappe.query_reports["Balance Sheet"]["filters"].push({ fieldname: "include_default_book_entries",
fieldname: "include_default_book_entries", label: __("Include Default Book Entries"),
label: __("Include Default Book Entries"), fieldtype: "Check",
fieldtype: "Check", default: 1,
default: 1,
});
}); });

View File

@ -1,24 +1,24 @@
// Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // For license information, please see license.txt
frappe.require("assets/erpnext/js/financial_statements.js", function() { frappe.query_reports["Cash Flow"] = $.extend(
frappe.query_reports["Cash Flow"] = $.extend({}, {},
erpnext.financial_statements); erpnext.financial_statements
);
erpnext.utils.add_dimensions('Cash Flow', 10); erpnext.utils.add_dimensions('Cash Flow', 10);
// The last item in the array is the definition for Presentation Currency // The last item in the array is the definition for Presentation Currency
// filter. It won't be used in cash flow for now so we pop it. Please take // filter. It won't be used in cash flow for now so we pop it. Please take
// of this if you are working here. // of this if you are working here.
frappe.query_reports["Cash Flow"]["filters"].splice(8, 1); frappe.query_reports["Cash Flow"]["filters"].splice(8, 1);
frappe.query_reports["Cash Flow"]["filters"].push( frappe.query_reports["Cash Flow"]["filters"].push(
{ {
"fieldname": "include_default_book_entries", "fieldname": "include_default_book_entries",
"label": __("Include Default Book Entries"), "label": __("Include Default Book Entries"),
"fieldtype": "Check", "fieldtype": "Check",
"default": 1 "default": 1
} }
); );
});

View File

@ -2,152 +2,150 @@
// For license information, please see license.txt // For license information, please see license.txt
frappe.require("assets/erpnext/js/financial_statements.js", function() { frappe.query_reports["Consolidated Financial Statement"] = {
frappe.query_reports["Consolidated Financial Statement"] = { "filters": [
"filters": [ {
{ "fieldname":"company",
"fieldname":"company", "label": __("Company"),
"label": __("Company"), "fieldtype": "Link",
"fieldtype": "Link", "options": "Company",
"options": "Company", "default": frappe.defaults.get_user_default("Company"),
"default": frappe.defaults.get_user_default("Company"), "reqd": 1
"reqd": 1
},
{
"fieldname":"filter_based_on",
"label": __("Filter Based On"),
"fieldtype": "Select",
"options": ["Fiscal Year", "Date Range"],
"default": ["Fiscal Year"],
"reqd": 1,
on_change: function() {
let filter_based_on = frappe.query_report.get_filter_value('filter_based_on');
frappe.query_report.toggle_filter_display('from_fiscal_year', filter_based_on === 'Date Range');
frappe.query_report.toggle_filter_display('to_fiscal_year', filter_based_on === 'Date Range');
frappe.query_report.toggle_filter_display('period_start_date', filter_based_on === 'Fiscal Year');
frappe.query_report.toggle_filter_display('period_end_date', filter_based_on === 'Fiscal Year');
frappe.query_report.refresh();
}
},
{
"fieldname":"period_start_date",
"label": __("Start Date"),
"fieldtype": "Date",
"hidden": 1,
"reqd": 1
},
{
"fieldname":"period_end_date",
"label": __("End Date"),
"fieldtype": "Date",
"hidden": 1,
"reqd": 1
},
{
"fieldname":"from_fiscal_year",
"label": __("Start Year"),
"fieldtype": "Link",
"options": "Fiscal Year",
"default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
"reqd": 1,
on_change: () => {
frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), function(r) {
let year_start_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), "year_start_date");
frappe.query_report.set_filter_value({
period_start_date: year_start_date
});
});
}
},
{
"fieldname":"to_fiscal_year",
"label": __("End Year"),
"fieldtype": "Link",
"options": "Fiscal Year",
"default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
"reqd": 1,
on_change: () => {
frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), function(r) {
let year_end_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), "year_end_date");
frappe.query_report.set_filter_value({
period_end_date: year_end_date
});
});
}
},
{
"fieldname":"finance_book",
"label": __("Finance Book"),
"fieldtype": "Link",
"options": "Finance Book"
},
{
"fieldname":"report",
"label": __("Report"),
"fieldtype": "Select",
"options": ["Profit and Loss Statement", "Balance Sheet", "Cash Flow"],
"default": "Balance Sheet",
"reqd": 1
},
{
"fieldname": "presentation_currency",
"label": __("Currency"),
"fieldtype": "Select",
"options": erpnext.get_presentation_currency_list(),
"default": frappe.defaults.get_user_default("Currency")
},
{
"fieldname":"accumulated_in_group_company",
"label": __("Accumulated Values in Group Company"),
"fieldtype": "Check",
"default": 0
},
{
"fieldname": "include_default_book_entries",
"label": __("Include Default Book Entries"),
"fieldtype": "Check",
"default": 1
},
{
"fieldname": "show_zero_values",
"label": __("Show zero values"),
"fieldtype": "Check"
}
],
"formatter": function(value, row, column, data, default_formatter) {
if (data && column.fieldname=="account") {
value = data.account_name || value;
column.link_onclick =
"erpnext.financial_statements.open_general_ledger(" + JSON.stringify(data) + ")";
column.is_tree = true;
}
if (data && data.account && column.apply_currency_formatter) {
data.currency = erpnext.get_currency(column.company_name);
}
value = default_formatter(value, row, column, data);
if (!data.parent_account) {
value = $(`<span>${value}</span>`);
var $value = $(value).css("font-weight", "bold");
value = $value.wrap("<p></p>").parent().html();
}
return value;
}, },
onload: function() { {
let fiscal_year = erpnext.utils.get_fiscal_year(frappe.datetime.get_today()); "fieldname":"filter_based_on",
"label": __("Filter Based On"),
"fieldtype": "Select",
"options": ["Fiscal Year", "Date Range"],
"default": ["Fiscal Year"],
"reqd": 1,
on_change: function() {
let filter_based_on = frappe.query_report.get_filter_value('filter_based_on');
frappe.query_report.toggle_filter_display('from_fiscal_year', filter_based_on === 'Date Range');
frappe.query_report.toggle_filter_display('to_fiscal_year', filter_based_on === 'Date Range');
frappe.query_report.toggle_filter_display('period_start_date', filter_based_on === 'Fiscal Year');
frappe.query_report.toggle_filter_display('period_end_date', filter_based_on === 'Fiscal Year');
frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) { frappe.query_report.refresh();
var fy = frappe.model.get_doc("Fiscal Year", fiscal_year); }
frappe.query_report.set_filter_value({ },
period_start_date: fy.year_start_date, {
period_end_date: fy.year_end_date "fieldname":"period_start_date",
"label": __("Start Date"),
"fieldtype": "Date",
"hidden": 1,
"reqd": 1
},
{
"fieldname":"period_end_date",
"label": __("End Date"),
"fieldtype": "Date",
"hidden": 1,
"reqd": 1
},
{
"fieldname":"from_fiscal_year",
"label": __("Start Year"),
"fieldtype": "Link",
"options": "Fiscal Year",
"default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
"reqd": 1,
on_change: () => {
frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), function(r) {
let year_start_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('from_fiscal_year'), "year_start_date");
frappe.query_report.set_filter_value({
period_start_date: year_start_date
});
}); });
}); }
},
{
"fieldname":"to_fiscal_year",
"label": __("End Year"),
"fieldtype": "Link",
"options": "Fiscal Year",
"default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
"reqd": 1,
on_change: () => {
frappe.model.with_doc("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), function(r) {
let year_end_date = frappe.model.get_value("Fiscal Year", frappe.query_report.get_filter_value('to_fiscal_year'), "year_end_date");
frappe.query_report.set_filter_value({
period_end_date: year_end_date
});
});
}
},
{
"fieldname":"finance_book",
"label": __("Finance Book"),
"fieldtype": "Link",
"options": "Finance Book"
},
{
"fieldname":"report",
"label": __("Report"),
"fieldtype": "Select",
"options": ["Profit and Loss Statement", "Balance Sheet", "Cash Flow"],
"default": "Balance Sheet",
"reqd": 1
},
{
"fieldname": "presentation_currency",
"label": __("Currency"),
"fieldtype": "Select",
"options": erpnext.get_presentation_currency_list(),
"default": frappe.defaults.get_user_default("Currency")
},
{
"fieldname":"accumulated_in_group_company",
"label": __("Accumulated Values in Group Company"),
"fieldtype": "Check",
"default": 0
},
{
"fieldname": "include_default_book_entries",
"label": __("Include Default Book Entries"),
"fieldtype": "Check",
"default": 1
},
{
"fieldname": "show_zero_values",
"label": __("Show zero values"),
"fieldtype": "Check"
} }
],
"formatter": function(value, row, column, data, default_formatter) {
if (data && column.fieldname=="account") {
value = data.account_name || value;
column.link_onclick =
"erpnext.financial_statements.open_general_ledger(" + JSON.stringify(data) + ")";
column.is_tree = true;
}
if (data && data.account && column.apply_currency_formatter) {
data.currency = erpnext.get_currency(column.company_name);
}
value = default_formatter(value, row, column, data);
if (!data.parent_account) {
value = $(`<span>${value}</span>`);
var $value = $(value).css("font-weight", "bold");
value = $value.wrap("<p></p>").parent().html();
}
return value;
},
onload: function() {
let fiscal_year = erpnext.utils.get_fiscal_year(frappe.datetime.get_today());
frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) {
var fy = frappe.model.get_doc("Fiscal Year", fiscal_year);
frappe.query_report.set_filter_value({
period_start_date: fy.year_start_date,
period_end_date: fy.year_end_date
});
});
} }
}); }

View File

@ -81,7 +81,7 @@ class TestDeferredRevenueAndExpense(FrappeTestCase, AccountsTestMixin):
self.create_item("_Test Internet Subscription", 0, self.warehouse, self.company) self.create_item("_Test Internet Subscription", 0, self.warehouse, self.company)
item = frappe.get_doc("Item", self.item) item = frappe.get_doc("Item", self.item)
item.enable_deferred_revenue = 1 item.enable_deferred_revenue = 1
item.deferred_revenue_account = self.deferred_revenue_account item.item_defaults[0].deferred_revenue_account = self.deferred_revenue_account
item.no_of_months = 3 item.no_of_months = 3
item.save() item.save()
@ -150,7 +150,7 @@ class TestDeferredRevenueAndExpense(FrappeTestCase, AccountsTestMixin):
self.create_item("_Test Office Desk", 0, self.warehouse, self.company) self.create_item("_Test Office Desk", 0, self.warehouse, self.company)
item = frappe.get_doc("Item", self.item) item = frappe.get_doc("Item", self.item)
item.enable_deferred_expense = 1 item.enable_deferred_expense = 1
item.deferred_expense_account = self.deferred_expense_account item.item_defaults[0].deferred_expense_account = self.deferred_expense_account
item.no_of_months_exp = 3 item.no_of_months_exp = 3
item.save() item.save()

View File

@ -2,83 +2,81 @@
// For license information, please see license.txt // For license information, please see license.txt
frappe.require("assets/erpnext/js/financial_statements.js", function() { frappe.query_reports["Dimension-wise Accounts Balance Report"] = {
frappe.query_reports["Dimension-wise Accounts Balance Report"] = { "filters": [
"filters": [ {
{ "fieldname": "company",
"fieldname": "company", "label": __("Company"),
"label": __("Company"), "fieldtype": "Link",
"fieldtype": "Link", "options": "Company",
"options": "Company", "default": frappe.defaults.get_user_default("Company"),
"default": frappe.defaults.get_user_default("Company"), "reqd": 1
"reqd": 1 },
}, {
{ "fieldname": "fiscal_year",
"fieldname": "fiscal_year", "label": __("Fiscal Year"),
"label": __("Fiscal Year"), "fieldtype": "Link",
"fieldtype": "Link", "options": "Fiscal Year",
"options": "Fiscal Year", "default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
"default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today()), "reqd": 1,
"reqd": 1, "on_change": function(query_report) {
"on_change": function(query_report) { var fiscal_year = query_report.get_values().fiscal_year;
var fiscal_year = query_report.get_values().fiscal_year; if (!fiscal_year) {
if (!fiscal_year) { return;
return;
}
frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) {
var fy = frappe.model.get_doc("Fiscal Year", fiscal_year);
frappe.query_report.set_filter_value({
from_date: fy.year_start_date,
to_date: fy.year_end_date
});
});
} }
}, frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) {
{ var fy = frappe.model.get_doc("Fiscal Year", fiscal_year);
"fieldname": "from_date", frappe.query_report.set_filter_value({
"label": __("From Date"), from_date: fy.year_start_date,
"fieldtype": "Date", to_date: fy.year_end_date
"default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1], });
"reqd": 1 });
}, }
{ },
"fieldname": "to_date", {
"label": __("To Date"), "fieldname": "from_date",
"fieldtype": "Date", "label": __("From Date"),
"default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[2], "fieldtype": "Date",
"reqd": 1 "default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
}, "reqd": 1
{ },
"fieldname": "finance_book", {
"label": __("Finance Book"), "fieldname": "to_date",
"fieldtype": "Link", "label": __("To Date"),
"options": "Finance Book", "fieldtype": "Date",
}, "default": erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[2],
{ "reqd": 1
"fieldname": "dimension", },
"label": __("Select Dimension"), {
"fieldtype": "Select", "fieldname": "finance_book",
"default": "Cost Center", "label": __("Finance Book"),
"options": get_accounting_dimension_options(), "fieldtype": "Link",
"reqd": 1, "options": "Finance Book",
}, },
], {
"formatter": erpnext.financial_statements.formatter, "fieldname": "dimension",
"tree": true, "label": __("Select Dimension"),
"name_field": "account", "fieldtype": "Select",
"parent_field": "parent_account", "default": "Cost Center",
"initial_depth": 3 "options": get_accounting_dimension_options(),
} "reqd": 1,
},
],
"formatter": erpnext.financial_statements.formatter,
"tree": true,
"name_field": "account",
"parent_field": "parent_account",
"initial_depth": 3
}
});
function get_accounting_dimension_options() { function get_accounting_dimension_options() {
let options =["Cost Center", "Project"]; let options =["Cost Center", "Project"];
frappe.db.get_list('Accounting Dimension', frappe.db.get_list('Accounting Dimension',
{fields:['document_type']}).then((res) => { {fields:['document_type']}).then((res) => {
res.forEach((dimension) => { res.forEach((dimension) => {
options.push(dimension.document_type); options.push(dimension.document_type);
}); });
}); });
return options return options
} }

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