Compare commits

..

1 Commits

Author SHA1 Message Date
Ankush Menat
f784b17564 perf: avoid full table scan in sle count check 2023-07-31 20:06:46 +05:30
444 changed files with 14433 additions and 20184 deletions

View File

@@ -68,6 +68,6 @@ if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
wait $wkpid
bench start &>> ~/frappe-bench/bench_start.log &
bench start &> bench_run_logs.txt &
CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes

View File

@@ -23,7 +23,7 @@ jobs:
services:
mysql:
image: mariadb:10.6
image: mariadb:10.3
env:
MARIADB_ROOT_PASSWORD: 'root'
ports:
@@ -45,7 +45,9 @@ jobs:
- name: Setup Python
uses: "actions/setup-python@v4"
with:
python-version: '3.10'
python-version: |
3.7
3.10
- name: Setup Node
uses: actions/setup-node@v2
@@ -100,60 +102,40 @@ jobs:
- name: Run Patch Tests
run: |
cd ~/frappe-bench/
bench remove-app payments --force
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
wget https://erpnext.com/files/v10-erpnext.sql.gz
bench --site test_site --force restore ~/frappe-bench/v10-erpnext.sql.gz
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
for version in $(seq 12 13)
do
echo "Updating to v$version"
branch_name="version-$version-hotfix"
function update_to_version() {
version=$1
git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
branch_name="version-$version-hotfix"
echo "Updating to v$version"
git -C "apps/frappe" checkout -q -f $branch_name
git -C "apps/erpnext" checkout -q -f $branch_name
# Fetch and checkout branches
git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
git -C "apps/frappe" checkout -q -f $branch_name
git -C "apps/erpnext" checkout -q -f $branch_name
rm -rf ~/frappe-bench/env
bench setup env --python python3.7
bench pip install -e ./apps/payments
bench pip install -e ./apps/erpnext
# Resetup env and install apps
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
done
bench --site test_site migrate
}
update_to_version 14
echo "Updating to latest version"
git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
pgrep honcho | xargs kill
rm -rf ~/frappe-bench/env
bench -v setup env
bench -v setup env --python python3.10
bench pip install -e ./apps/payments
bench pip install -e ./apps/erpnext
bench start &>> ~/frappe-bench/bench_start.log &
bench --site test_site migrate
- 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
bench --site test_site install-app payments

View File

@@ -123,10 +123,6 @@ jobs:
CI_BUILD_ID: ${{ github.run_id }}
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
uses: actions/upload-artifact@v3
with:

View File

@@ -15,8 +15,6 @@ pull_request_rules:
- or:
- base=version-13
- base=version-12
- base=version-14
- base=version-15
actions:
close:
comment:

View File

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

View File

@@ -341,7 +341,7 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None):
"enable_deferred_revenue" if doc.doctype == "Sales Invoice" else "enable_deferred_expense"
)
accounts_frozen_upto = frappe.db.get_single_value("Accounts Settings", "acc_frozen_upto")
accounts_frozen_upto = frappe.get_cached_value("Accounts Settings", "None", "acc_frozen_upto")
def _book_deferred_revenue_or_expense(
item,

View File

@@ -1,83 +1,67 @@
// Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
frappe.ui.form.on("Account", {
setup: function (frm) {
frm.add_fetch("parent_account", "report_type", "report_type");
frm.add_fetch("parent_account", "root_type", "root_type");
frappe.ui.form.on('Account', {
setup: function(frm) {
frm.add_fetch('parent_account', 'report_type', 'report_type');
frm.add_fetch('parent_account', 'root_type', 'root_type');
},
onload: function (frm) {
frm.set_query("parent_account", function (doc) {
onload: function(frm) {
frm.set_query('parent_account', function(doc) {
return {
filters: {
is_group: 1,
company: doc.company,
},
"is_group": 1,
"company": doc.company
}
};
});
},
refresh: function (frm) {
frm.toggle_display("account_name", frm.is_new());
refresh: function(frm) {
frm.toggle_display('account_name', frm.is_new());
// hide fields if group
frm.toggle_display(["tax_rate"], cint(frm.doc.is_group) == 0);
frm.toggle_display(['account_type', 'tax_rate'], cint(frm.doc.is_group) == 0);
// disable fields
frm.toggle_enable(["is_group", "company"], false);
frm.toggle_enable(['is_group', 'company'], false);
if (cint(frm.doc.is_group) == 0) {
frm.toggle_display(
"freeze_account",
frm.doc.__onload && frm.doc.__onload.can_freeze_account
);
frm.toggle_display('freeze_account', frm.doc.__onload
&& frm.doc.__onload.can_freeze_account);
}
// read-only for root accounts
if (!frm.is_new()) {
if (!frm.doc.parent_account) {
frm.set_read_only();
frm.set_intro(
__("This is a root account and cannot be edited.")
);
frm.set_intro(__("This is a root account and cannot be edited."));
} else {
// credit days and type if customer or supplier
frm.set_intro(null);
frm.trigger("account_type");
frm.trigger('account_type');
// show / hide convert buttons
frm.trigger("add_toolbar_buttons");
frm.trigger('add_toolbar_buttons');
}
if (frm.has_perm("write")) {
frm.add_custom_button(
__("Merge Account"),
function () {
frm.trigger("merge_account");
},
__("Actions")
);
frm.add_custom_button(
__("Update Account Name / Number"),
function () {
frm.trigger("update_account_number");
},
__("Actions")
);
if (frm.has_perm('write')) {
frm.add_custom_button(__('Merge Account'), function () {
frm.trigger("merge_account");
}, __('Actions'));
frm.add_custom_button(__('Update Account Name / Number'), function () {
frm.trigger("update_account_number");
}, __('Actions'));
}
}
},
account_type: function (frm) {
if (frm.doc.is_group == 0) {
frm.toggle_display(["tax_rate"], frm.doc.account_type == "Tax");
frm.toggle_display("warehouse", frm.doc.account_type == "Stock");
frm.toggle_display(['tax_rate'], frm.doc.account_type == 'Tax');
frm.toggle_display('warehouse', frm.doc.account_type == 'Stock');
}
},
add_toolbar_buttons: function (frm) {
frm.add_custom_button(
__("Chart of Accounts"),
() => {
frappe.set_route("Tree", "Account");
},
__("View")
);
add_toolbar_buttons: function(frm) {
frm.add_custom_button(__('Chart of Accounts'), () => {
frappe.set_route("Tree", "Account");
}, __('View'));
if (frm.doc.is_group == 1) {
frm.add_custom_button(__('Convert to Non-Group'), function () {
@@ -102,81 +86,77 @@ frappe.ui.form.on("Account", {
frappe.set_route("query-report", "General Ledger");
}, __('View'));
frm.add_custom_button(
__("Convert to Group"),
function () {
return frappe.call({
doc: frm.doc,
method: "convert_ledger_to_group",
callback: function () {
frm.refresh();
},
});
},
__("Actions")
);
frm.add_custom_button(__('Convert to Group'), function () {
return frappe.call({
doc: frm.doc,
method: 'convert_ledger_to_group',
callback: function() {
frm.refresh();
}
});
}, __('Actions'));
}
},
merge_account: function (frm) {
merge_account: function(frm) {
var d = new frappe.ui.Dialog({
title: __("Merge with Existing Account"),
title: __('Merge with Existing Account'),
fields: [
{
label: "Name",
fieldname: "name",
fieldtype: "Data",
reqd: 1,
default: frm.doc.name,
},
"label" : "Name",
"fieldname": "name",
"fieldtype": "Data",
"reqd": 1,
"default": frm.doc.name
}
],
primary_action: function () {
primary_action: function() {
var data = d.get_values();
frappe.call({
method: "erpnext.accounts.doctype.account.account.merge_account",
args: {
old: frm.doc.name,
new: data.name,
is_group: frm.doc.is_group,
root_type: frm.doc.root_type,
company: frm.doc.company
},
callback: function (r) {
if (!r.exc) {
if (r.message) {
callback: function(r) {
if(!r.exc) {
if(r.message) {
frappe.set_route("Form", "Account", r.message);
}
d.hide();
}
},
}
});
},
primary_action_label: __("Merge"),
primary_action_label: __('Merge')
});
d.show();
},
update_account_number: function (frm) {
update_account_number: function(frm) {
var d = new frappe.ui.Dialog({
title: __("Update Account Number / Name"),
title: __('Update Account Number / Name'),
fields: [
{
label: "Account Name",
fieldname: "account_name",
fieldtype: "Data",
reqd: 1,
default: frm.doc.account_name,
"label": "Account Name",
"fieldname": "account_name",
"fieldtype": "Data",
"reqd": 1,
"default": frm.doc.account_name
},
{
label: "Account Number",
fieldname: "account_number",
fieldtype: "Data",
default: frm.doc.account_number,
},
"label": "Account Number",
"fieldname": "account_number",
"fieldtype": "Data",
"default": frm.doc.account_number
}
],
primary_action: function () {
primary_action: function() {
var data = d.get_values();
if (
data.account_number === frm.doc.account_number &&
data.account_name === frm.doc.account_name
) {
if(data.account_number === frm.doc.account_number && data.account_name === frm.doc.account_name) {
d.hide();
return;
}
@@ -186,29 +166,23 @@ frappe.ui.form.on("Account", {
args: {
account_number: data.account_number,
account_name: data.account_name,
name: frm.doc.name,
name: frm.doc.name
},
callback: function (r) {
if (!r.exc) {
if (r.message) {
callback: function(r) {
if(!r.exc) {
if(r.message) {
frappe.set_route("Form", "Account", r.message);
} else {
frm.set_value(
"account_number",
data.account_number
);
frm.set_value(
"account_name",
data.account_name
);
frm.set_value("account_number", data.account_number);
frm.set_value("account_name", data.account_name);
}
d.hide();
}
},
}
});
},
primary_action_label: __("Update"),
primary_action_label: __('Update')
});
d.show();
},
}
});

View File

@@ -123,7 +123,7 @@
"label": "Account Type",
"oldfieldname": "account_type",
"oldfieldtype": "Select",
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary"
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nDepreciation\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nPayable\nReceivable\nRound Off\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary"
},
{
"description": "Rate at which this tax is applied",
@@ -192,7 +192,7 @@
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2023-07-20 18:18:44.405723",
"modified": "2023-04-11 16:08:46.983677",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Account",
@@ -243,6 +243,7 @@
"read": 1,
"report": 1,
"role": "Accounts Manager",
"set_user_permissions": 1,
"share": 1,
"write": 1
}

View File

@@ -18,10 +18,6 @@ class BalanceMismatchError(frappe.ValidationError):
pass
class InvalidAccountMergeError(frappe.ValidationError):
pass
class Account(NestedSet):
nsm_parent_field = "parent_account"
@@ -49,7 +45,6 @@ class Account(NestedSet):
if frappe.local.flags.allow_unverified_charts:
return
self.validate_parent()
self.validate_parent_child_account_type()
self.validate_root_details()
validate_field_number("Account", self.name, self.account_number, self.company, "account_number")
self.validate_group_or_ledger()
@@ -60,20 +55,6 @@ class Account(NestedSet):
self.validate_account_currency()
self.validate_root_company_and_sync_account_to_children()
def validate_parent_child_account_type(self):
if self.parent_account:
if self.account_type in [
"Direct Income",
"Indirect Income",
"Current Asset",
"Current Liability",
"Direct Expense",
"Indirect Expense",
]:
parent_account_type = frappe.db.get_value("Account", self.parent_account, ["account_type"])
if parent_account_type == self.account_type:
throw(_("Only Parent can be of type {0}").format(self.account_type))
def validate_parent(self):
"""Fetch Parent Details and validate parent account"""
if self.parent_account:
@@ -464,34 +445,25 @@ def update_account_number(name, account_name, account_number=None, from_descenda
@frappe.whitelist()
def merge_account(old, new):
def merge_account(old, new, is_group, root_type, company):
# Validate properties before merging
new_account = frappe.get_cached_doc("Account", new)
old_account = frappe.get_cached_doc("Account", old)
if not new_account:
throw(_("Account {0} does not exist").format(new))
if (
cint(new_account.is_group),
new_account.root_type,
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),
if (new_account.is_group, new_account.root_type, new_account.company) != (
cint(is_group),
root_type,
company,
):
throw(
msg=_(
"""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,
_(
"""Merging is only possible if following properties are same in both records. Is Group, Root Type, Company"""
)
)
if old_account.is_group and new_account.parent_account == old:
if is_group and new_account.parent_account == old:
new_account.db_set("parent_account", frappe.get_cached_value("Account", old, "parent_account"))
frappe.rename_doc("Account", old, new, merge=1, force=1)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,100 +1,9 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
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
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
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)
class TestBankReconciliationTool(unittest.TestCase):
pass

View File

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

View File

@@ -47,7 +47,7 @@ class TestBankTransaction(FrappeTestCase):
from_date=bank_transaction.date,
to_date=utils.today(),
)
self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
self.assertTrue(linked_payments[0][6] == "Conrad Electronic")
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
def test_reconcile(self):
@@ -93,7 +93,7 @@ class TestBankTransaction(FrappeTestCase):
from_date=bank_transaction.date,
to_date=utils.today(),
)
self.assertTrue(linked_payments[0]["paid_amount"])
self.assertTrue(linked_payments[0][3])
# Check error if already reconciled
def test_already_reconciled(self):
@@ -188,7 +188,7 @@ class TestBankTransaction(FrappeTestCase):
repayment_entry = create_loan_and_repayment()
linked_payments = get_linked_payments(bank_transaction.name, ["loan_repayment", "exact_match"])
self.assertEqual(linked_payments[0]["name"], repayment_entry.name)
self.assertEqual(linked_payments[0][2], repayment_entry.name)
@if_lending_app_installed

View File

@@ -3,296 +3,6 @@
import unittest
import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, today
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.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))
class TestExchangeRateRevaluation(unittest.TestCase):
pass

View File

@@ -32,11 +32,7 @@
"finance_book",
"to_rename",
"due_date",
"is_cancelled",
"transaction_currency",
"debit_in_transaction_currency",
"credit_in_transaction_currency",
"transaction_exchange_rate"
"is_cancelled"
],
"fields": [
{
@@ -257,40 +253,15 @@
"fieldname": "is_cancelled",
"fieldtype": "Check",
"label": "Is Cancelled"
},
{
"fieldname": "transaction_currency",
"fieldtype": "Link",
"label": "Transaction Currency",
"options": "Currency"
},
{
"fieldname": "transaction_exchange_rate",
"fieldtype": "Float",
"label": "Transaction Exchange Rate"
},
{
"fieldname": "debit_in_transaction_currency",
"fieldtype": "Currency",
"label": "Debit Amount in Transaction Currency",
"options": "transaction_currency"
},
{
"fieldname": "credit_in_transaction_currency",
"fieldtype": "Currency",
"label": "Credit Amount in Transaction Currency",
"options": "transaction_currency"
}
],
"icon": "fa fa-list",
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2023-08-16 21:38:44.072267",
"modified": "2020-04-07 16:22:33.766994",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GL Entry",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -319,6 +290,5 @@
"quick_entry": 1,
"search_fields": "voucher_no,account,posting_date,against_voucher",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
"sort_order": "DESC"
}

View File

@@ -58,13 +58,6 @@ class GLEntry(Document):
validate_balance_type(self.account, adv_adj)
validate_frozen_account(self.account, adv_adj)
if (
self.voucher_type == "Journal Entry"
and frappe.get_cached_value("Journal Entry", self.voucher_no, "voucher_type")
== "Exchange Gain Or Loss"
):
return
if frappe.get_cached_value("Account", self.account, "account_type") not in [
"Receivable",
"Payable",

View File

@@ -8,7 +8,7 @@ frappe.provide("erpnext.journal_entry");
frappe.ui.form.on("Journal Entry", {
setup: function(frm) {
frm.add_fetch("bank_account", "account", "account");
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger", 'Asset', 'Asset Movement', 'Asset Depreciation Schedule', "Repost Accounting Ledger"];
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger", 'Asset', 'Asset Movement', 'Asset Depreciation Schedule'];
},
refresh: function(frm) {
@@ -50,8 +50,6 @@ frappe.ui.form.on("Journal Entry", {
frm.trigger("make_inter_company_journal_entry");
}, __('Make'));
}
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
},
make_inter_company_journal_entry: function(frm) {

View File

@@ -9,7 +9,6 @@
"engine": "InnoDB",
"field_order": [
"entry_type_and_date",
"is_system_generated",
"title",
"voucher_type",
"naming_series",
@@ -534,22 +533,13 @@
"label": "Process Deferred Accounting",
"options": "Process Deferred Accounting",
"read_only": 1
},
{
"default": "0",
"depends_on": "eval:doc.is_system_generated == 1;",
"fieldname": "is_system_generated",
"fieldtype": "Check",
"label": "Is System Generated",
"no_copy": 1,
"read_only": 1
}
],
"icon": "fa fa-file-text",
"idx": 176,
"is_submittable": 1,
"links": [],
"modified": "2023-08-10 14:32:22.366895",
"modified": "2023-03-01 14:58:59.286591",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry",

View File

@@ -18,7 +18,6 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
get_balance_on,
get_stock_accounts,
@@ -88,16 +87,15 @@ class JournalEntry(AccountsController):
self.update_invoice_discounting()
def on_cancel(self):
# References for this Journal are removed on the `on_cancel` event in accounts_controller
super(JournalEntry, self).on_cancel()
from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries
unlink_ref_doc_from_payment_entries(self)
self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Payment Ledger Entry",
"Repost Payment Ledger",
"Repost Payment Ledger Items",
"Repost Accounting Ledger",
"Repost Accounting Ledger Items",
)
self.make_gl_entries(1)
self.update_advance_paid()
@@ -501,12 +499,11 @@ class JournalEntry(AccountsController):
)
if not against_entries:
if self.voucher_type != "Exchange Gain Or Loss":
frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(d.reference_name, d.account)
)
frappe.throw(
_(
"Journal Entry {0} does not have account {1} or already matched against other voucher"
).format(d.reference_name, d.account)
)
else:
dr_or_cr = "debit" if d.credit > 0 else "credit"
valid = False
@@ -589,9 +586,7 @@ class JournalEntry(AccountsController):
else:
party_account = against_voucher[1]
if (
against_voucher[0] != cstr(d.party) or party_account != d.account
) and self.voucher_type != "Exchange Gain Or Loss":
if against_voucher[0] != cstr(d.party) or party_account != d.account:
frappe.throw(
_("Row {0}: Party / Account does not match with {1} / {2} in {3} {4}").format(
d.idx,
@@ -773,23 +768,18 @@ class JournalEntry(AccountsController):
)
):
ignore_exchange_rate = False
if self.get("flags") and self.flags.get("ignore_exchange_rate"):
ignore_exchange_rate = True
if not ignore_exchange_rate:
# Modified to include the posting date for which to retreive the exchange rate
d.exchange_rate = get_exchange_rate(
self.posting_date,
d.account,
d.account_currency,
self.company,
d.reference_type,
d.reference_name,
d.debit,
d.credit,
d.exchange_rate,
)
# Modified to include the posting date for which to retreive the exchange rate
d.exchange_rate = get_exchange_rate(
self.posting_date,
d.account,
d.account_currency,
self.company,
d.reference_type,
d.reference_name,
d.debit,
d.credit,
d.exchange_rate,
)
if not d.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx))
@@ -797,9 +787,6 @@ class JournalEntry(AccountsController):
def create_remarks(self):
r = []
if self.flags.skip_remarks_creation:
return
if self.user_remark:
r.append(_("Note: {0}").format(self.user_remark))
@@ -948,8 +935,6 @@ class JournalEntry(AccountsController):
merge_entries=merge_entries,
update_outstanding=update_outstanding,
)
if cancel:
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
@frappe.whitelist()
def get_balance(self, difference_account=None):

View File

@@ -5,7 +5,6 @@
import unittest
import frappe
from frappe.tests.utils import change_settings
from frappe.utils import flt, nowdate
from erpnext.accounts.doctype.account.test_account import get_inventory_account
@@ -14,7 +13,6 @@ from erpnext.exceptions import InvalidAccountCurrency
class TestJournalEntry(unittest.TestCase):
@change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_journal_entry_with_against_jv(self):
jv_invoice = frappe.copy_doc(test_records[2])
base_jv = frappe.copy_doc(test_records[0])

View File

@@ -203,7 +203,7 @@
"fieldtype": "Select",
"label": "Reference Type",
"no_copy": 1,
"options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement\nPayment Entry"
"options": "\nSales Invoice\nPurchase Invoice\nJournal Entry\nSales Order\nPurchase Order\nExpense Claim\nAsset\nLoan\nPayroll Entry\nEmployee Advance\nExchange Rate Revaluation\nInvoice Discounting\nFees\nFull and Final Statement"
},
{
"fieldname": "reference_name",
@@ -284,7 +284,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2023-06-16 14:11:13.507807",
"modified": "2022-10-26 20:03:10.906259",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Journal Entry Account",

View File

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

View File

@@ -141,12 +141,12 @@ def validate_loyalty_points(ref_doc, points_to_redeem):
)
if points_to_redeem > loyalty_program_details.loyalty_points:
frappe.throw(_("You don't have enough Loyalty Points to redeem"))
frappe.throw(_("You don't have enought Loyalty Points to redeem"))
loyalty_amount = flt(points_to_redeem * loyalty_program_details.conversion_factor)
if loyalty_amount > ref_doc.rounded_total:
frappe.throw(_("You can't redeem Loyalty Points having more value than the Rounded Total."))
if loyalty_amount > ref_doc.grand_total:
frappe.throw(_("You can't redeem Loyalty Points having more value than the Grand Total."))
if not ref_doc.loyalty_amount and ref_doc.loyalty_amount != loyalty_amount:
ref_doc.loyalty_amount = loyalty_amount

View File

@@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
frappe.ui.form.on('Payment Entry', {
onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payments', 'Unreconcile Payment Entries'];
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Repost Payment Ledger"];
if(frm.doc.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
@@ -154,7 +154,6 @@ frappe.ui.form.on('Payment Entry', {
frm.events.set_dynamic_labels(frm);
frm.events.show_general_ledger(frm);
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(frm);
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
},
validate_company: (frm) => {
@@ -536,21 +535,15 @@ frappe.ui.form.on('Payment Entry', {
},
source_exchange_rate: function(frm) {
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.paid_amount) {
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
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("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);
}
// set_unallocated_amount is called by below method,
// no need trigger separately
frm.events.set_total_allocated_amount(frm);
frm.events.set_unallocated_amount(frm);
}
// Make read only if Accounts Settings doesn't allow stale rates
@@ -559,7 +552,6 @@ frappe.ui.form.on('Payment Entry', {
target_exchange_rate: function(frm) {
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) {
frm.set_value("base_received_amount",
@@ -569,14 +561,9 @@ frappe.ui.form.on('Payment Entry', {
(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("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);
}
// set_unallocated_amount is called by below method,
// no need trigger separately
frm.events.set_total_allocated_amount(frm);
frm.events.set_unallocated_amount(frm);
}
frm.set_paid_amount_based_on_received_amount = false;
@@ -892,18 +879,12 @@ frappe.ui.form.on('Payment Entry', {
},
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 base_total_allocated_amount = 0.0;
$.each(frm.doc.references || [], function(i, row) {
if (row.allocated_amount) {
total_allocated_amount += flt(row.allocated_amount);
base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(exchange_rate),
base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(row.exchange_rate),
precision("base_paid_amount"));
}
});

View File

@@ -28,12 +28,7 @@ from erpnext.accounts.general_ledger import (
process_gl_map,
)
from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
get_balance_on,
get_outstanding_invoices,
)
from erpnext.accounts.utils import get_account_currency, get_balance_on, get_outstanding_invoices
from erpnext.controllers.accounts_controller import (
AccountsController,
get_supplier_block_status,
@@ -71,7 +66,7 @@ class PaymentEntry(AccountsController):
self.setup_party_account_field()
self.set_missing_values()
self.set_liability_account()
self.set_missing_ref_details(force=True)
self.set_missing_ref_details()
self.validate_payment_type()
self.validate_party_details()
self.set_exchange_rate()
@@ -98,6 +93,7 @@ class PaymentEntry(AccountsController):
if self.difference_amount:
frappe.throw(_("Difference Amount must be zero"))
self.make_gl_entries()
self.make_advance_gl_entries()
self.update_outstanding_amounts()
self.update_advance_paid()
self.update_payment_schedule()
@@ -146,13 +142,9 @@ class PaymentEntry(AccountsController):
"Payment Ledger Entry",
"Repost Payment Ledger",
"Repost Payment Ledger Items",
"Repost Accounting Ledger",
"Repost Accounting Ledger Items",
"Unreconcile Payments",
"Unreconcile Payment Entries",
)
super(PaymentEntry, self).on_cancel()
self.make_gl_entries(cancel=1)
self.make_advance_gl_entries(cancel=1)
self.update_outstanding_amounts()
self.update_advance_paid()
self.delink_advance_entry_references()
@@ -230,88 +222,79 @@ class PaymentEntry(AccountsController):
return False
def validate_allocated_amount_with_latest_data(self):
if self.references:
uniq_vouchers = set([(x.reference_doctype, x.reference_name) for x in self.references])
vouchers = [frappe._dict({"voucher_type": x[0], "voucher_no": x[1]}) for x in uniq_vouchers]
latest_references = get_outstanding_reference_documents(
{
"posting_date": self.posting_date,
"company": self.company,
"party_type": self.party_type,
"payment_type": self.payment_type,
"party": self.party,
"party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to,
"get_outstanding_invoices": True,
"get_orders_to_be_billed": True,
"vouchers": vouchers,
},
validate=True,
)
latest_references = get_outstanding_reference_documents(
{
"posting_date": self.posting_date,
"company": self.company,
"party_type": self.party_type,
"payment_type": self.payment_type,
"party": self.party,
"party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to,
"get_outstanding_invoices": True,
"get_orders_to_be_billed": True,
},
validate=True,
)
# Group latest_references by (voucher_type, voucher_no)
latest_lookup = {}
for d in latest_references:
d = frappe._dict(d)
latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d
# Group latest_references by (voucher_type, voucher_no)
latest_lookup = {}
for d in latest_references:
d = frappe._dict(d)
latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d
for idx, d in enumerate(self.get("references"), start=1):
latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict()
for idx, d in enumerate(self.get("references"), start=1):
latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict()
# If term based allocation is enabled, throw
if (
d.payment_term is None or d.payment_term == ""
) and self.term_based_allocation_enabled_for_reference(
d.reference_doctype, d.reference_name
):
frappe.throw(
_(
"{0} has Payment Term based allocation enabled. Select a Payment Term for Row #{1} in Payment References section"
).format(frappe.bold(d.reference_name), frappe.bold(idx))
# If term based allocation is enabled, throw
if (
d.payment_term is None or d.payment_term == ""
) and self.term_based_allocation_enabled_for_reference(
d.reference_doctype, d.reference_name
):
frappe.throw(
_(
"{0} has Payment Term based allocation enabled. Select a Payment Term for Row #{1} in Payment References section"
).format(frappe.bold(d.reference_name), frappe.bold(idx))
)
# 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)
# The reference has already been fully paid
if not latest:
frappe.throw(
_("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name)
)
# The reference has already been partly paid
elif latest.outstanding_amount < latest.invoice_amount and flt(
d.outstanding_amount, d.precision("outstanding_amount")
) != flt(latest.outstanding_amount, d.precision("outstanding_amount")):
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."
).format(_(d.reference_doctype), d.reference_name)
)
fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")
if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount):
frappe.throw(fail_message.format(d.idx))
if d.payment_term and (
(flt(d.allocated_amount)) > 0
and flt(d.allocated_amount) > flt(latest.payment_term_outstanding)
):
frappe.throw(
_(
"Row #{0}: Allocated amount:{1} is greater than outstanding amount:{2} for Payment Term {3}"
).format(
d.idx, d.allocated_amount, latest.payment_term_outstanding, d.payment_term
)
)
# 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)
# The reference has already been fully paid
if not latest:
frappe.throw(
_("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name)
)
# The reference has already been partly paid
elif latest.outstanding_amount < latest.invoice_amount and flt(
d.outstanding_amount, d.precision("outstanding_amount")
) != flt(latest.outstanding_amount, d.precision("outstanding_amount")):
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."
).format(_(d.reference_doctype), d.reference_name)
)
fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")
if (
d.payment_term
and (
(flt(d.allocated_amount)) > 0
and latest.payment_term_outstanding
and (flt(d.allocated_amount) > flt(latest.payment_term_outstanding))
)
and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name)
):
frappe.throw(
_(
"Row #{0}: Allocated amount:{1} is greater than outstanding amount:{2} for Payment Term {3}"
).format(
d.idx, d.allocated_amount, latest.payment_term_outstanding, d.payment_term
)
)
if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount):
frappe.throw(fail_message.format(d.idx))
# Check for negative outstanding invoices as well
if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(latest.outstanding_amount):
frappe.throw(fail_message.format(d.idx))
# Check for negative outstanding invoices as well
if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(latest.outstanding_amount):
frappe.throw(fail_message.format(d.idx))
def delink_advance_entry_references(self):
for reference in self.references:
@@ -416,7 +399,7 @@ class PaymentEntry(AccountsController):
else:
if ref_doc:
if self.paid_from_account_currency == ref_doc.currency:
self.source_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
self.source_exchange_rate = ref_doc.get("exchange_rate")
if not self.source_exchange_rate:
self.source_exchange_rate = get_exchange_rate(
@@ -429,7 +412,7 @@ class PaymentEntry(AccountsController):
elif self.paid_to and not self.target_exchange_rate:
if ref_doc:
if self.paid_to_account_currency == ref_doc.currency:
self.target_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
self.target_exchange_rate = ref_doc.get("exchange_rate")
if not self.target_exchange_rate:
self.target_exchange_rate = get_exchange_rate(
@@ -694,9 +677,7 @@ class PaymentEntry(AccountsController):
if not self.apply_tax_withholding_amount:
return
order_amount = self.get_order_net_total()
net_total = flt(order_amount) + flt(self.unallocated_amount)
net_total = self.paid_amount
# Adding args as purchase invoice to get TDS amount
args = frappe._dict(
@@ -741,20 +722,6 @@ class PaymentEntry(AccountsController):
for d in to_remove:
self.remove(d)
def get_order_net_total(self):
if self.party_type == "Supplier":
doctype = "Purchase Order"
else:
doctype = "Sales Order"
docnames = [d.reference_name for d in self.references if d.reference_doctype == doctype]
tax_withholding_net_total = frappe.db.get_value(
doctype, {"name": ["in", docnames]}, ["sum(base_tax_withholding_net_total)"]
)
return tax_withholding_net_total
def apply_taxes(self):
self.initialize_taxes()
self.determine_exclusive_rate()
@@ -841,30 +808,10 @@ class PaymentEntry(AccountsController):
flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
)
else:
# Use source/target exchange rate, so no difference amount is calculated.
# then update exchange gain/loss amount in reference table
# if there is an exchange gain/loss amount in reference table, submit a JE for that
exchange_rate = 1
if self.payment_type == "Receive":
exchange_rate = self.source_exchange_rate
elif self.payment_type == "Pay":
exchange_rate = self.target_exchange_rate
base_allocated_amount += flt(
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(
flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount")
)
d.exchange_gain_loss = base_allocated_amount - allocated_amount_in_pe_exchange_rate
return base_allocated_amount
def set_total_allocated_amount(self):
@@ -1004,14 +951,14 @@ class PaymentEntry(AccountsController):
if self.payment_type == "Internal Transfer":
remarks = [
_("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:
remarks = [
_("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,
_("received from") if self.payment_type == "Receive" else _("to"),
self.party,
@@ -1028,14 +975,14 @@ class PaymentEntry(AccountsController):
if d.allocated_amount:
remarks.append(
_("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"):
if d.amount:
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))
@@ -1055,12 +1002,6 @@ class PaymentEntry(AccountsController):
gl_entries = self.build_gl_map()
gl_entries = process_gl_map(gl_entries)
make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj)
if cancel:
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
else:
self.make_exchange_gain_loss_journal()
self.make_advance_gl_entries(cancel=cancel)
def add_party_gl_entries(self, gl_entries):
if self.party_account:
@@ -1130,7 +1071,7 @@ class PaymentEntry(AccountsController):
if self.book_advance_payments_in_separate_party_account:
gl_entries = []
for d in self.get("references"):
if d.reference_doctype in ("Sales Invoice", "Purchase Invoice", "Journal Entry"):
if d.reference_doctype in ("Sales Invoice", "Purchase Invoice"):
if not (against_voucher_type and against_voucher) or (
d.reference_doctype == against_voucher_type and d.reference_name == against_voucher
):
@@ -1166,13 +1107,6 @@ class PaymentEntry(AccountsController):
"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"
args_dict["account"] = invoice.account
args_dict[dr_or_cr] = invoice.allocated_amount
@@ -1181,7 +1115,6 @@ class PaymentEntry(AccountsController):
{
"against_voucher_type": invoice.reference_doctype,
"against_voucher": invoice.reference_name,
"posting_date": posting_date,
}
)
gle = self.get_gl_dict(
@@ -1588,14 +1521,6 @@ def get_outstanding_reference_documents(args, validate=False):
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"):
condition += " and company = {0}".format(frappe.db.escape(args.get("company")))
@@ -1614,7 +1539,6 @@ def get_outstanding_reference_documents(args, validate=False):
min_outstanding=args.get("outstanding_amt_greater_than"),
max_outstanding=args.get("outstanding_amt_less_than"),
accounting_dimensions=accounting_dimensions_filter,
vouchers=args.get("vouchers") or None,
)
outstanding_invoices = split_invoices_based_on_payment_terms(
@@ -2016,15 +1940,10 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre
if not total_amount:
if party_account_currency == company_currency:
# for handling cases that don't have multi-currency (base field)
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")
)
total_amount = ref_doc.get("base_grand_total") or ref_doc.get("grand_total")
exchange_rate = 1
else:
total_amount = ref_doc.get("rounded_total") or ref_doc.get("grand_total")
total_amount = ref_doc.get("grand_total")
if not exchange_rate:
# Get the exchange rate from the original ref doc
# or get it based on the posting date of the ref doc.
@@ -2069,6 +1988,7 @@ def get_payment_entry(
payment_type=None,
reference_date=None,
):
reference_doc = None
doc = frappe.get_doc(dt, dn)
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (
@@ -2208,7 +2128,7 @@ def get_payment_entry(
update_accounting_dimensions(pe, doc)
if party_account and bank:
pe.set_exchange_rate(ref_doc=doc)
pe.set_exchange_rate(ref_doc=reference_doc)
pe.set_amounts()
if discount_amount:
@@ -2323,7 +2243,7 @@ def set_paid_amount_and_received_amount(
if bank_amount:
received_amount = bank_amount
else:
if bank and company_currency != bank.account_currency:
if company_currency != bank.account_currency:
received_amount = paid_amount / doc.get("conversion_rate", 1)
else:
received_amount = paid_amount * doc.get("conversion_rate", 1)
@@ -2332,7 +2252,7 @@ def set_paid_amount_and_received_amount(
if bank_amount:
paid_amount = bank_amount
else:
if bank and company_currency != bank.account_currency:
if company_currency != bank.account_currency:
paid_amount = received_amount / doc.get("conversion_rate", 1)
else:
# if party account currency and bank currency is different then populate paid amount as well

View File

@@ -31,16 +31,6 @@ class TestPaymentEntry(FrappeTestCase):
def tearDown(self):
frappe.db.rollback()
def get_journals_for(self, voucher_type: str, voucher_no: str) -> list:
journals = []
if voucher_type and voucher_no:
journals = frappe.db.get_all(
"Journal Entry Account",
filters={"reference_type": voucher_type, "reference_name": voucher_no, "docstatus": 1},
fields=["parent"],
)
return journals
def test_payment_entry_against_order(self):
so = make_sales_order()
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
@@ -601,15 +591,21 @@ class TestPaymentEntry(FrappeTestCase):
pe.target_exchange_rate = 45.263
pe.reference_no = "1"
pe.reference_date = "2016-01-01"
pe.append(
"deductions",
{
"account": "_Test Exchange Gain/Loss - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": 94.80,
},
)
pe.save()
self.assertEqual(flt(pe.difference_amount, 2), 0.0)
self.assertEqual(flt(pe.unallocated_amount, 2), 0.0)
# the exchange gain/loss amount is captured in reference table and a separate Journal will be submitted for them
# payment entry will not be generating difference amount
self.assertEqual(flt(pe.references[0].exchange_gain_loss, 2), -94.74)
def test_payment_entry_retrieves_last_exchange_rate(self):
from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
save_new_records,
@@ -702,50 +698,7 @@ class TestPaymentEntry(FrappeTestCase):
pe2.submit()
# create return entry against si1
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()
create_sales_invoice(is_return=1, return_against=si1.name, qty=-1)
si1_outstanding = frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount")
self.assertEqual(si1_outstanding, -100)
@@ -839,28 +792,33 @@ class TestPaymentEntry(FrappeTestCase):
pe.reference_no = "1"
pe.reference_date = "2016-01-01"
pe.source_exchange_rate = 55
pe.append(
"deductions",
{
"account": "_Test Exchange Gain/Loss - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": -500,
},
)
pe.save()
self.assertEqual(pe.unallocated_amount, 0)
self.assertEqual(pe.difference_amount, 0)
self.assertEqual(pe.references[0].exchange_gain_loss, 500)
pe.submit()
expected_gle = dict(
(d[0], d)
for d in [
["_Test Receivable USD - _TC", 0, 5500, si.name],
["_Test Receivable USD - _TC", 0, 5000, si.name],
["_Test Bank USD - _TC", 5500, 0, None],
["_Test Exchange Gain/Loss - _TC", 0, 500, None],
]
)
self.validate_gl_entries(pe.name, expected_gle)
# Exchange gain/loss should have been posted through a journal
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
self.assertEqual(exc_je_for_si, exc_je_for_pe)
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0)
@@ -1198,70 +1156,6 @@ class TestPaymentEntry(FrappeTestCase):
si3.cancel()
si3.delete()
@change_settings(
"Accounts Settings",
{
"unlink_payment_on_cancellation_of_invoice": 1,
"delete_linked_ledger_entries": 1,
"allow_multi_currency_invoices_against_single_party_account": 1,
},
)
def test_overallocation_validation_shouldnt_misfire(self):
"""
Overallocation validation shouldn't fire for Template without "Allocate Payment based on Payment Terms" enabled
"""
customer = create_customer()
create_payment_terms_template()
template = frappe.get_doc("Payment Terms Template", "Test Receivable Template")
template.allocate_payment_based_on_payment_terms = 0
template.save()
# Validate allocation on base/company currency
si = create_sales_invoice(do_not_save=1, qty=1, rate=200)
si.payment_terms_template = "Test Receivable Template"
si.save().submit()
si.reload()
pe = get_payment_entry(si.doctype, si.name).save()
# There will no term based allocation
self.assertEqual(len(pe.references), 1)
self.assertEqual(pe.references[0].payment_term, None)
self.assertEqual(flt(pe.references[0].allocated_amount), flt(si.grand_total))
pe.save()
# specify a term
pe.references[0].payment_term = template.terms[0].payment_term
# no validation error should be thrown
pe.save()
pe.paid_amount = si.grand_total + 1
pe.references[0].allocated_amount = si.grand_total + 1
self.assertRaises(frappe.ValidationError, pe.save)
template = frappe.get_doc("Payment Terms Template", "Test Receivable Template")
template.allocate_payment_based_on_payment_terms = 1
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):
payment_entry = frappe.new_doc("Payment Entry")

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,8 @@
import frappe
from frappe import _, msgprint, qb
from frappe.model.document import Document
from frappe.query_builder import Criterion
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, get_link_to_form, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
@@ -15,7 +14,6 @@ from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_rec
)
from erpnext.accounts.utils import (
QueryPaymentLedger,
create_gain_loss_journal,
get_outstanding_invoices,
reconcile_against_document,
)
@@ -75,9 +73,6 @@ class PaymentReconciliation(Document):
}
)
if self.payment_name:
condition.update({"name": self.payment_name})
payment_entries = get_advance_payment_entries(
self.party_type,
self.party,
@@ -93,9 +88,6 @@ class PaymentReconciliation(Document):
def get_jv_entries(self):
condition = self.get_conditions()
if self.payment_name:
condition += f" and t1.name like '%%{self.payment_name}%%'"
if self.get("cost_center"):
condition += f" and t2.cost_center = '{self.cost_center}' "
@@ -116,7 +108,7 @@ class PaymentReconciliation(Document):
"Journal Entry" as reference_type, t1.name as reference_name,
t1.posting_date, t1.remark as remarks, t2.name as reference_row,
{dr_or_cr} as amount, t2.is_advance, t2.exchange_rate,
t2.account_currency as currency, t2.cost_center as cost_center
t2.account_currency as currency
from
`tabJournal Entry` t1, `tabJournal Entry Account` t2
where
@@ -153,15 +145,6 @@ class PaymentReconciliation(Document):
def get_return_invoices(self):
voucher_type = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
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 = (
qb.from_(doc)
.select(
@@ -169,7 +152,11 @@ class PaymentReconciliation(Document):
doc.name.as_("voucher_no"),
doc.return_against,
)
.where(Criterion.all(conditions))
.where(
(doc.docstatus == 1)
& (doc[frappe.scrub(self.party_type)] == self.party)
& (doc.is_return == 1)
)
.run(as_dict=True)
)
@@ -186,12 +173,15 @@ class PaymentReconciliation(Document):
self.common_filter_conditions.append(ple.account == self.receivable_payable_account)
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 = []
if self.return_invoices:
if return_invoices:
ple_query = QueryPaymentLedger()
return_outstanding = ple_query.get_voucher_outstandings(
vouchers=self.return_invoices,
vouchers=return_invoices,
common_filter=self.common_filter_conditions,
posting_date=self.ple_posting_date_filter,
min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None,
@@ -209,7 +199,6 @@ class PaymentReconciliation(Document):
"amount": -(inv.outstanding_in_account_currency),
"posting_date": inv.posting_date,
"currency": inv.currency,
"cost_center": inv.cost_center,
}
)
)
@@ -236,8 +225,6 @@ class PaymentReconciliation(Document):
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,
accounting_dimensions=self.accounting_dimension_filter_conditions,
limit=self.invoice_limit,
voucher_no=self.invoice_name,
)
cr_dr_notes = (
@@ -289,11 +276,6 @@ class PaymentReconciliation(Document):
def calculate_difference_on_allocation_change(self, payment_entry, invoice, allocated_amount):
invoice_exchange_map = self.get_invoice_exchange_map(invoice, payment_entry)
invoice[0]["exchange_rate"] = invoice_exchange_map.get(invoice[0].get("invoice_number"))
if payment_entry[0].get("reference_type") in ["Sales Invoice", "Purchase Invoice"]:
payment_entry[0]["exchange_rate"] = invoice_exchange_map.get(
payment_entry[0].get("reference_name")
)
new_difference_amount = self.get_difference_amount(
payment_entry[0], invoice[0], allocated_amount
)
@@ -358,7 +340,6 @@ class PaymentReconciliation(Document):
"allocated_amount": allocated_amount,
"difference_amount": pay.get("difference_amount"),
"currency": inv.get("currency"),
"cost_center": pay.get("cost_center"),
}
)
@@ -382,6 +363,12 @@ class PaymentReconciliation(Document):
payment_details = self.get_payment_details(row, dr_or_cr)
reconciled_entry.append(payment_details)
if payment_details.difference_amount and row.reference_type not in [
"Sales Invoice",
"Purchase Invoice",
]:
self.make_difference_entry(payment_details)
if entry_list:
reconcile_against_document(entry_list, skip_ref_details_update_for_pe)
@@ -414,6 +401,59 @@ class PaymentReconciliation(Document):
self.get_unreconciled_entries()
def make_difference_entry(self, row):
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Exchange Gain Or Loss"
journal_entry.company = self.company
journal_entry.posting_date = nowdate()
journal_entry.multi_currency = 1
party_account_currency = frappe.get_cached_value(
"Account", self.receivable_payable_account, "account_currency"
)
difference_account_currency = frappe.get_cached_value(
"Account", row.difference_account, "account_currency"
)
# Account Currency has balance
dr_or_cr = "debit" if self.party_type == "Customer" else "credit"
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
journal_account = frappe._dict(
{
"account": self.receivable_payable_account,
"party_type": self.party_type,
"party": self.party,
"account_currency": party_account_currency,
"exchange_rate": 0,
"cost_center": erpnext.get_default_cost_center(self.company),
"reference_type": row.against_voucher_type,
"reference_name": row.against_voucher,
dr_or_cr: flt(row.difference_amount),
dr_or_cr + "_in_account_currency": 0,
}
)
journal_entry.append("accounts", journal_account)
journal_account = frappe._dict(
{
"account": row.difference_account,
"account_currency": difference_account_currency,
"exchange_rate": 1,
"cost_center": erpnext.get_default_cost_center(self.company),
reverse_dr_or_cr + "_in_account_currency": flt(row.difference_amount),
reverse_dr_or_cr: flt(row.difference_amount),
}
)
journal_entry.append("accounts", journal_account)
journal_entry.save()
journal_entry.submit()
return journal_entry
def get_payment_details(self, row, dr_or_cr):
return frappe._dict(
{
@@ -433,7 +473,6 @@ class PaymentReconciliation(Document):
"allocated_amount": flt(row.get("allocated_amount")),
"difference_amount": flt(row.get("difference_amount")),
"difference_account": row.get("difference_account"),
"cost_center": row.get("cost_center"),
}
)
@@ -580,6 +619,16 @@ class PaymentReconciliation(Document):
def reconcile_dr_cr_note(dr_cr_notes, company):
def get_difference_row(inv):
if inv.difference_amount != 0 and inv.difference_account:
difference_row = {
"account": inv.difference_account,
inv.dr_or_cr: abs(inv.difference_amount) if inv.difference_amount > 0 else 0,
reconcile_dr_or_cr: abs(inv.difference_amount) if inv.difference_amount < 0 else 0,
"cost_center": erpnext.get_default_cost_center(company),
}
return difference_row
for inv in dr_cr_notes:
voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note"
@@ -606,9 +655,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
inv.dr_or_cr: abs(inv.allocated_amount),
"reference_type": inv.against_voucher_type,
"reference_name": inv.against_voucher,
"cost_center": inv.cost_center or erpnext.get_default_cost_center(company),
"exchange_rate": inv.exchange_rate,
"user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} against {inv.against_voucher}",
"cost_center": erpnext.get_default_cost_center(company),
},
{
"account": inv.account,
@@ -621,45 +668,14 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
),
"reference_type": inv.voucher_type,
"reference_name": inv.voucher_no,
"cost_center": inv.cost_center or erpnext.get_default_cost_center(company),
"exchange_rate": inv.exchange_rate,
"user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} from {inv.voucher_no}",
"cost_center": erpnext.get_default_cost_center(company),
},
],
}
)
if difference_entry := get_difference_row(inv):
jv.append("accounts", difference_entry)
jv.flags.ignore_mandatory = True
jv.flags.ignore_exchange_rate = True
jv.remark = None
jv.flags.skip_remarks_creation = True
jv.is_system_generated = True
jv.submit()
if inv.difference_amount != 0:
# make gain/loss journal
if inv.party_type == "Customer":
dr_or_cr = "credit" if inv.difference_amount < 0 else "debit"
else:
dr_or_cr = "debit" if inv.difference_amount < 0 else "credit"
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
create_gain_loss_journal(
company,
today(),
inv.party_type,
inv.party,
inv.account,
inv.difference_account,
inv.difference_amount,
dr_or_cr,
reverse_dr_or_cr,
inv.voucher_type,
inv.voucher_no,
None,
inv.against_voucher_type,
inv.against_voucher,
None,
inv.cost_center,
)

View File

@@ -686,24 +686,14 @@ class TestPaymentReconciliation(FrappeTestCase):
# Check if difference journal entry gets generated for difference amount after reconciliation
pr.reconcile()
total_credit_amount = frappe.db.get_all(
total_debit_amount = frappe.db.get_all(
"Journal Entry Account",
{"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
"sum(credit) as amount",
"sum(debit) as amount",
group_by="reference_name",
)[0].amount
# total credit includes the exchange gain/loss amount
self.assertEqual(flt(total_credit_amount, 2), 8500)
jea_parent = frappe.db.get_all(
"Journal Entry Account",
filters={"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name, "credit": 500},
fields=["parent"],
)[0]
self.assertEqual(
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
self.assertEqual(flt(total_debit_amount, 2), -500)
def test_difference_amount_via_payment_entry(self):
# Make Sale Invoice

View File

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

View File

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

View File

@@ -144,7 +144,8 @@ class TestPaymentRequest(unittest.TestCase):
(d[0], d)
for d in [
["_Test Receivable USD - _TC", 0, 5000, si_usd.name],
[pr.payment_account, 5000.0, 0, None],
[pr.payment_account, 6290.0, 0, None],
["_Test Exchange Gain/Loss - _TC", 0, 1290, None],
]
)

View File

@@ -126,7 +126,7 @@ class PeriodClosingVoucher(AccountsController):
def make_gl_entries(self, get_opening_entries=False):
gl_entries = self.get_gl_entries()
closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries)
if len(gl_entries + closing_entries) > 3000:
if len(gl_entries) > 5000:
frappe.enqueue(
process_gl_entries,
gl_entries=gl_entries,

View File

@@ -153,7 +153,7 @@ frappe.ui.form.on('POS Closing Entry', {
frappe.ui.form.on('POS Closing Entry Detail', {
closing_amount: (frm, cdt, cdn) => {
const row = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, "difference", flt(row.closing_amount - row.expected_amount));
frappe.model.set_value(cdt, cdn, "difference", flt(row.expected_amount - row.closing_amount));
}
})
@@ -185,7 +185,6 @@ function refresh_payments(d, frm) {
}
if (payment) {
payment.expected_amount += flt(p.amount);
payment.closing_amount = payment.expected_amount;
payment.difference = payment.closing_amount - payment.expected_amount;
} else {
frm.add_child("payment_reconciliation", {

View File

@@ -221,7 +221,6 @@
"read_only": 1
},
{
"default": "Now",
"fieldname": "posting_time",
"fieldtype": "Time",
"label": "Posting Time",
@@ -236,7 +235,7 @@
"link_fieldname": "pos_closing_entry"
}
],
"modified": "2023-08-10 16:25:49.322697",
"modified": "2022-08-01 11:37:14.991228",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Closing Entry",

View File

@@ -5,18 +5,12 @@ import unittest
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 (
make_closing_entry_from_opening,
)
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.selling.page.point_of_sale.point_of_sale import get_items
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@@ -73,36 +67,6 @@ class TestPOSClosingEntry(unittest.TestCase):
self.assertTrue(pcv_doc.name)
def test_pos_qty_for_item(self):
"""
Test if quantity is calculated correctly for an item in POS Closing Entry
"""
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
test_item_qty = get_test_item_qty(pos_profile)
pos_inv1 = create_pos_invoice(rate=3500, do_not_submit=1)
pos_inv1.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
pos_inv1.submit()
pos_inv2 = create_pos_invoice(rate=3200, do_not_submit=1)
pos_inv2.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3200})
pos_inv2.submit()
# make return entry of pos_inv2
pos_return = make_sales_return(pos_inv2.name)
pos_return.paid_amount = pos_return.grand_total
pos_return.save()
pos_return.submit()
pcv_doc = make_closing_entry_from_opening(opening_entry)
pcv_doc.submit()
opening_entry = create_opening_entry(pos_profile, test_user.name)
test_item_qty_after_sales = get_test_item_qty(pos_profile)
self.assertEqual(test_item_qty_after_sales, test_item_qty - 1)
def test_cancelling_of_pos_closing_entry(self):
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
@@ -144,43 +108,6 @@ class TestPOSClosingEntry(unittest.TestCase):
pos_inv1.load_from_db()
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):
user = "test@example.com"
@@ -190,28 +117,9 @@ def init_user_and_profile(**args):
test_user.add_roles(*roles)
frappe.set_user(user)
if args.get("do_not_create_pos_profile"):
return test_user
pos_profile = make_pos_profile(**args)
pos_profile.append("applicable_for_users", {"default": 1, "user": user})
pos_profile.save()
return test_user, pos_profile
def get_test_item_qty(pos_profile):
test_item_pos = get_items(
start=0,
page_length=5,
price_list="Standard Selling",
pos_profile=pos_profile.name,
search_term="_Test Item",
item_group="All Item Groups",
)
test_item_qty = [item for item in test_item_pos["items"] if item["item_code"] == "_Test Item"][
0
].get("actual_qty")
return test_item_qty

View File

@@ -131,7 +131,6 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
args: { "pos_profile": frm.pos_profile },
callback: ({ message: profile }) => {
this.update_customer_groups_settings(profile?.customer_groups);
this.frm.set_value("company", profile?.company);
},
});
}

View File

@@ -49,7 +49,6 @@ class POSInvoice(SalesInvoice):
self.validate_pos()
self.validate_payment_amount()
self.validate_loyalty_transaction()
self.validate_company_with_pos_company()
if self.coupon_code:
from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code
@@ -282,14 +281,6 @@ class POSInvoice(SalesInvoice):
if total_amount_in_payments and total_amount_in_payments < invoice_total:
frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))
def validate_company_with_pos_company(self):
if self.company != frappe.db.get_value("POS Profile", self.pos_profile, "company"):
frappe.throw(
_("Company {} does not match with POS Profile Company {}").format(
self.company, frappe.db.get_value("POS Profile", self.pos_profile, "company")
)
)
def validate_loyalty_transaction(self):
if self.redeem_loyalty_points and (
not self.loyalty_redemption_account or not self.loyalty_redemption_cost_center
@@ -368,7 +359,6 @@ class POSInvoice(SalesInvoice):
profile = {}
if self.pos_profile:
profile = frappe.get_doc("POS Profile", self.pos_profile)
self.company = profile.get("company")
if not self.get("payments") and not for_validate:
update_multi_mode_option(self, profile)
@@ -552,7 +542,6 @@ def get_stock_availability(item_code, warehouse):
is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse)
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
return bin_qty - pos_sales_qty, is_stock_item
else:
is_stock_item = True
@@ -606,6 +595,7 @@ def get_pos_reserved_qty(item_code, warehouse):
.where(
(p_inv.name == p_item.parent)
& (IfNull(p_inv.consolidated_invoice, "") == "")
& (p_inv.is_return == 0)
& (p_item.docstatus == 1)
& (p_item.item_code == item_code)
& (p_item.warehouse == warehouse)

View File

@@ -12,8 +12,6 @@ from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
from frappe.utils.background_jobs import enqueue, is_job_enqueued
from frappe.utils.scheduler import is_scheduler_inactive
from erpnext.accounts.doctype.pos_profile.pos_profile import required_accounting_dimensions
class POSInvoiceMergeLog(Document):
def validate(self):
@@ -97,6 +95,7 @@ class POSInvoiceMergeLog(Document):
sales_invoice = self.process_merging_into_sales_invoice(sales)
self.save() # save consolidated_sales_invoice & consolidated_credit_note ref in merge log
self.update_pos_invoices(pos_invoice_docs, sales_invoice, credit_note)
def on_cancel(self):
@@ -109,6 +108,7 @@ class POSInvoiceMergeLog(Document):
def process_merging_into_sales_invoice(self, data):
sales_invoice = self.get_new_sales_invoice()
sales_invoice = self.merge_pos_invoice_into(sales_invoice, data)
sales_invoice.is_consolidated = 1
@@ -241,22 +241,6 @@ class POSInvoiceMergeLog(Document):
invoice.disable_rounded_total = cint(
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":
invoice.flags.ignore_pos_profile = True
@@ -401,7 +385,6 @@ def split_invoices(invoices):
for d in invoices
if d.is_return and d.return_against
]
for pos_invoice in pos_return_docs:
for item in pos_invoice.items:
if not item.serial_no and not item.serial_and_batch_bundle:
@@ -443,9 +426,11 @@ def create_merge_logs(invoice_by_customer, closing_entry=None):
)
merge_log.customer = customer
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
merge_log.set("pos_invoices", _invoices)
merge_log.save(ignore_permissions=True)
merge_log.submit()
if closing_entry:
closing_entry.set_status(update=True, status="Submitted")
closing_entry.db_set("error_message", "")

View File

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

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _, msgprint, scrub, unscrub
from frappe import _, msgprint
from frappe.model.document import Document
from frappe.utils import get_link_to_form, now
@@ -14,21 +14,6 @@ class POSProfile(Document):
self.validate_all_link_fields()
self.validate_duplicate_groups()
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):
for row in self.applicable_for_users:
@@ -167,24 +152,6 @@ 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.validate_and_sanitize_search_inputs
def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):

View File

@@ -5,10 +5,7 @@ import unittest
import frappe
from erpnext.accounts.doctype.pos_profile.pos_profile import (
get_child_nodes,
required_accounting_dimensions,
)
from erpnext.accounts.doctype.pos_profile.pos_profile import get_child_nodes
from erpnext.stock.get_item_details import get_pos_profile
test_dependencies = ["Item"]
@@ -121,7 +118,6 @@ def make_pos_profile(**args):
"warehouse": args.warehouse or "_Test Warehouse - _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",
"location": "Block 1" if not args.do_not_set_accounting_dimension else None,
}
)
@@ -136,7 +132,6 @@ def make_pos_profile(**args):
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 args.get("do_not_insert"):
pos_profile.insert()
pos_profile.insert()
return pos_profile

View File

@@ -146,7 +146,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-08-11 10:56:51.699137",
"modified": "2023-04-21 17:19:30.912953",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Payment Reconciliation",
@@ -154,25 +154,15 @@
"owner": "Administrator",
"permissions": [
{
"amend": 1,
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"role": "Accounts Manager",
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"amend": 1,
"cancel": 1,
"create": 1,
"delete": 1,
"read": 1,
"role": "Accounts User",
"share": 1,
"submit": 1,
"write": 1
}
],

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

View File

@@ -51,7 +51,6 @@
"column_break_21",
"start_date",
"section_break_33",
"pdf_name",
"subject",
"column_break_28",
"cc_to",
@@ -276,7 +275,7 @@
"fieldname": "help_text",
"fieldtype": "HTML",
"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.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 %} -->"
"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 %} -->"
},
{
"fieldname": "subject",
@@ -371,15 +370,10 @@
"fieldname": "based_on_payment_terms",
"fieldtype": "Check",
"label": "Based On Payment Terms"
},
{
"fieldname": "pdf_name",
"fieldtype": "Data",
"label": "PDF Name"
}
],
"links": [],
"modified": "2023-08-28 12:59:53.071334",
"modified": "2023-06-23 10:13:15.051950",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Process Statement Of Accounts",

View File

@@ -27,13 +27,7 @@ class ProcessStatementOfAccounts(Document):
if not self.subject:
self.subject = "Statement Of Accounts for {{ customer.customer_name }}"
if not self.body:
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 }}"
self.body = "Hello {{ customer.name }},<br>PFA your Statement Of Accounts from {{ doc.from_date }} to {{ doc.to_date }}."
validate_template(self.subject)
validate_template(self.body)
@@ -64,6 +58,11 @@ def get_report_pdf(doc, consolidated=True):
filters = get_common_filters(doc)
if doc.report == "General Ledger":
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)
for x in [0, -2, -1]:
@@ -71,11 +70,8 @@ def get_report_pdf(doc, consolidated=True):
if len(res) == 3:
continue
else:
filters.update(get_ar_filters(doc, entry))
ar_res = get_ar_soa(filters)
col, res = ar_res[0], ar_res[1]
if not res:
continue
statement_dict[entry.customer] = get_html(doc, filters, entry, col, res, ageing)
@@ -145,7 +141,6 @@ def get_ar_filters(doc, entry):
return {
"report_date": doc.posting_date if doc.posting_date else None,
"customer": 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,
"sales_partner": doc.sales_partner if doc.sales_partner else None,
"sales_person": doc.sales_person if doc.sales_person else None,
@@ -371,20 +366,18 @@ def download_statements(document_name):
@frappe.whitelist()
def send_emails(document_name, from_scheduler=False, posting_date=None):
def send_emails(document_name, from_scheduler=False):
doc = frappe.get_doc("Process Statement Of Accounts", document_name)
report = get_report_pdf(doc, consolidated=False)
if report:
for customer, report_pdf in report.items():
context = get_context(customer, doc)
filename = frappe.render_template(doc.pdf_name, context)
attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}]
attachments = [{"fname": customer + ".pdf", "fcontent": report_pdf}]
recipients, cc = get_recipients_and_cc(customer, doc)
if not recipients:
continue
context = get_context(customer, doc)
subject = frappe.render_template(doc.subject, context)
message = frappe.render_template(doc.body, context)
@@ -403,7 +396,7 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
)
if doc.enable_auto_email and from_scheduler:
new_to_date = getdate(posting_date or today())
new_to_date = getdate(today())
if doc.frequency == "Weekly":
new_to_date = add_days(new_to_date, 7)
else:
@@ -412,11 +405,8 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
doc.add_comment(
"Comment", "Emails sent on: " + frappe.utils.format_datetime(frappe.utils.now())
)
if doc.report == "General Ledger":
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)
doc.db_set("to_date", new_to_date, commit=True)
doc.db_set("from_date", new_from_date, commit=True)
return True
else:
return False
@@ -426,8 +416,7 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
def send_auto_email():
selected = frappe.get_list(
"Process Statement Of Accounts",
filters={"enable_auto_email": 1},
or_filters={"to_date": format_date(today()), "posting_date": format_date(today())},
filters={"to_date": format_date(today()), "enable_auto_email": 1},
)
for entry in selected:
send_emails(entry.name, from_scheduler=True)

View File

@@ -8,24 +8,9 @@
}
</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>
<h4 class="text-center">
{{ filters.customer_name }}
{{ filters.customer }}
</h4>
<h6 class="text-center">
{% if (filters.tax_id) %}
@@ -356,9 +341,4 @@
</tbody>
</table>
{% endif %}
{% if terms_and_conditions %}
<div>
{{ terms_and_conditions }}
</div>
{% endif %}
<p class="text-right text-muted">{{ _("Printed On ") }}{{ frappe.utils.now() }}</p>

View File

@@ -1,42 +1,9 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import unittest
import frappe
from frappe.utils import add_days, getdate, today
from erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts import (
send_emails,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
class TestProcessStatementOfAccounts(unittest.TestCase):
def setUp(self):
self.si = create_sales_invoice()
self.process_soa = create_process_soa()
def test_auto_email_for_process_soa_ar(self):
send_emails(self.process_soa.name, from_scheduler=True)
self.process_soa.load_from_db()
self.assertEqual(self.process_soa.posting_date, getdate(add_days(today(), 7)))
def tearDown(self):
frappe.delete_doc_if_exists("Process Statement Of Accounts", "Test Process SOA")
def create_process_soa():
frappe.delete_doc_if_exists("Process Statement Of Accounts", "Test Process SOA")
process_soa = frappe.new_doc("Process Statement Of Accounts")
soa_dict = {
"name": "Test Process SOA",
"company": "_Test Company",
}
process_soa.update(soa_dict)
process_soa.set("customers", [{"customer": "_Test Customer"}])
process_soa.enable_auto_email = 1
process_soa.frequency = "Weekly"
process_soa.report = "Accounts Receivable"
process_soa.save()
return process_soa
pass

View File

@@ -35,7 +35,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
super.onload();
// Ignore linked advances
this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger", "Repost Accounting Ledger"];
this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger"];
if(!this.frm.doc.__islocal) {
// show credit_to in print format
@@ -86,7 +86,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
}
if(doc.docstatus == 1 && doc.outstanding_amount != 0 && !doc.on_hold) {
if(doc.docstatus == 1 && doc.outstanding_amount != 0
&& !(doc.is_return && doc.return_against) && !doc.on_hold) {
this.frm.add_custom_button(
__('Payment'),
() => this.make_payment_entry(),
@@ -161,7 +162,6 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
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() {

View File

@@ -167,7 +167,6 @@
"column_break_63",
"unrealized_profit_loss_account",
"subscription_section",
"subscription",
"auto_repeat",
"update_auto_repeat_reference",
"column_break_114",
@@ -1424,12 +1423,6 @@
"options": "Advance Tax",
"read_only": 1
},
{
"fieldname": "subscription",
"fieldtype": "Link",
"label": "Subscription",
"options": "Subscription"
},
{
"default": "0",
"fieldname": "is_old_subcontracting_flow",
@@ -1584,7 +1577,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2023-07-25 17:22:59.145031",
"modified": "2023-07-04 17:22:59.145031",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -229,7 +229,7 @@ class PurchaseInvoice(BuyingController):
)
if (
cint(frappe.db.get_single_value("Buying Settings", "maintain_same_rate"))
cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate"))
and not self.is_return
and not self.is_internal_supplier
):
@@ -266,7 +266,9 @@ class PurchaseInvoice(BuyingController):
stock_not_billed_account = self.get_company_default("stock_received_but_not_billed")
stock_items = self.get_stock_items()
asset_received_but_not_billed = None
asset_items = [d.is_fixed_asset for d in self.items if d.is_fixed_asset]
if len(asset_items) > 0:
asset_received_but_not_billed = self.get_company_default("asset_received_but_not_billed")
if self.update_stock:
self.validate_item_code()
@@ -360,8 +362,6 @@ class PurchaseInvoice(BuyingController):
)
item.expense_account = asset_category_account
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
elif not item.expense_account and for_validate:
throw(_("Expense account is mandatory for item {0}").format(item.item_code or item.item_name))
@@ -536,7 +536,6 @@ class PurchaseInvoice(BuyingController):
merge_entries=False,
from_repost=from_repost,
)
self.make_exchange_gain_loss_journal()
elif self.docstatus == 2:
provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"]
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
@@ -581,6 +580,7 @@ class PurchaseInvoice(BuyingController):
self.get_asset_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
self.make_exchange_gain_loss_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
gl_entries = make_regional_gl_entries(gl_entries, self)
@@ -628,7 +628,9 @@ class PurchaseInvoice(BuyingController):
"credit_in_account_currency": base_grand_total
if self.party_account_currency == self.company_currency
else grand_total,
"against_voucher": self.name,
"against_voucher": self.return_against
if cint(self.is_return) and self.return_against
else self.name,
"against_voucher_type": self.doctype,
"project": self.project,
"cost_center": self.cost_center,
@@ -759,22 +761,21 @@ class PurchaseInvoice(BuyingController):
# Amount added through landed-cost-voucher
if landed_cost_entries:
if (item.item_code, item.name) in landed_cost_entries:
for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
gl_entries.append(
self.get_gl_dict(
{
"account": account,
"against": item.expense_account,
"cost_center": item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(amount["base_amount"]),
"credit_in_account_currency": flt(amount["amount"]),
"project": item.project or self.project,
},
item=item,
)
for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
gl_entries.append(
self.get_gl_dict(
{
"account": account,
"against": item.expense_account,
"cost_center": item.cost_center,
"remarks": self.get("remarks") or _("Accounting Entry for Stock"),
"credit": flt(amount["base_amount"]),
"credit_in_account_currency": flt(amount["amount"]),
"project": item.project or self.project,
},
item=item,
)
)
# sub-contracting warehouse
if flt(item.rm_supp_cost):
@@ -968,10 +969,33 @@ class PurchaseInvoice(BuyingController):
item.item_tax_amount, item.precision("item_tax_amount")
)
def make_precision_loss_gl_entry(self, gl_entries):
round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center
)
precision_loss = self.get("base_net_total") - flt(
self.get("net_total") * self.conversion_rate, self.precision("net_total")
)
if precision_loss:
gl_entries.append(
self.get_gl_dict(
{
"account": round_off_account,
"against": self.supplier,
"credit": precision_loss,
"cost_center": round_off_cost_center
if self.use_company_roundoff_cost_center
else self.cost_center or round_off_cost_center,
"remarks": _("Net total calculation precision loss"),
}
)
)
def get_asset_gl_entry(self, gl_entries):
arbnb_account = None
eiiav_account = None
asset_eiiav_currency = None
arbnb_account = self.get_company_default("asset_received_but_not_billed")
eiiav_account = self.get_company_default("expenses_included_in_asset_valuation")
for item in self.get("items"):
if item.is_fixed_asset:
@@ -983,8 +1007,6 @@ class PurchaseInvoice(BuyingController):
"Asset Received But Not Billed",
"Fixed Asset",
]:
if not arbnb_account:
arbnb_account = self.get_company_default("asset_received_but_not_billed")
item.expense_account = arbnb_account
if not self.update_stock:
@@ -1007,10 +1029,7 @@ class PurchaseInvoice(BuyingController):
)
if item.item_tax_amount:
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)
asset_eiiav_currency = get_account_currency(eiiav_account)
gl_entries.append(
self.get_gl_dict(
{
@@ -1053,10 +1072,7 @@ class PurchaseInvoice(BuyingController):
)
if item.item_tax_amount and not cint(erpnext.is_perpetual_inventory_enabled(self.company)):
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)
asset_eiiav_currency = get_account_currency(eiiav_account)
gl_entries.append(
self.get_gl_dict(
{
@@ -1076,46 +1092,47 @@ class PurchaseInvoice(BuyingController):
)
)
# When update stock is checked
# Assets are bought through this document then it will be linked to this document
if flt(item.landed_cost_voucher_amount):
if not eiiav_account:
eiiav_account = self.get_company_default("expenses_included_in_asset_valuation")
gl_entries.append(
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,
if self.update_stock:
if flt(item.landed_cost_voucher_amount):
gl_entries.append(
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(
self.get_gl_dict(
{
"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,
gl_entries.append(
self.get_gl_dict(
{
"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))
# 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
@@ -1422,8 +1439,6 @@ class PurchaseInvoice(BuyingController):
"Repost Item Valuation",
"Repost Payment Ledger",
"Repost Payment Ledger Items",
"Repost Accounting Ledger",
"Repost Accounting Ledger Items",
"Payment Ledger Entry",
"Tax Withheld Vouchers",
"Serial and Batch Bundle",
@@ -1651,8 +1666,12 @@ class PurchaseInvoice(BuyingController):
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid"
# Check if outstanding amount is 0 due to debit note issued against invoice
elif self.is_return == 0 and frappe.db.get_value(
"Purchase Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
elif (
outstanding_amount <= 0
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"
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.enable_deferred_expense = 1
item.item_defaults[0].deferred_expense_account = deferred_account
item.deferred_expense_account = deferred_account
item.save()
pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True)
@@ -1273,11 +1273,10 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
pi.save()
pi.submit()
creditors_account = pi.credit_to
expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 37500.0],
["_Test Payable USD - _TC", -37500.0],
["_Test Payable USD - _TC", -35000.0],
["Exchange Gain/Loss - _TC", -2500.0],
]
gl_entries = frappe.db.sql(
@@ -1294,31 +1293,6 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance)
pi.reload()
self.assertEqual(pi.outstanding_amount, 0)
total_debit_amount = frappe.db.get_all(
"Journal Entry Account",
{"account": creditors_account, "docstatus": 1, "reference_name": pi.name},
"sum(debit) as amount",
group_by="reference_name",
)[0].amount
self.assertEqual(flt(total_debit_amount, 2), 2500)
jea_parent = frappe.db.get_all(
"Journal Entry Account",
filters={
"account": creditors_account,
"docstatus": 1,
"reference_name": pi.name,
"debit": 2500,
"debit_in_account_currency": 0,
},
fields=["parent"],
)[0]
self.assertEqual(
frappe.db.get_value("Journal Entry", jea_parent.parent, "voucher_type"), "Exchange Gain Or Loss"
)
pi_2 = make_purchase_invoice(
supplier="_Test Supplier USD",
currency="USD",
@@ -1343,12 +1317,10 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
pi_2.save()
pi_2.submit()
pi_2.reload()
self.assertEqual(pi_2.outstanding_amount, 0)
expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 36500.0],
["_Test Payable USD - _TC", -36500.0],
["_Test Payable USD - _TC", -35000.0],
["Exchange Gain/Loss - _TC", -1500.0],
]
gl_entries = frappe.db.sql(
@@ -1379,39 +1351,12 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance)
total_debit_amount = frappe.db.get_all(
"Journal Entry Account",
{"account": creditors_account, "docstatus": 1, "reference_name": pi_2.name},
"sum(debit) as amount",
group_by="reference_name",
)[0].amount
self.assertEqual(flt(total_debit_amount, 2), 1500)
jea_parent_2 = frappe.db.get_all(
"Journal Entry Account",
filters={
"account": creditors_account,
"docstatus": 1,
"reference_name": pi_2.name,
"debit": 1500,
"debit_in_account_currency": 0,
},
fields=["parent"],
)[0]
self.assertEqual(
frappe.db.get_value("Journal Entry", jea_parent_2.parent, "voucher_type"),
"Exchange Gain Or Loss",
)
pi.reload()
pi.cancel()
self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent.parent, "docstatus"), 2)
pi_2.reload()
pi_2.cancel()
self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent_2.parent, "docstatus"), 2)
pay.reload()
pay.cancel()
@@ -1791,52 +1736,6 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
rate = flt(sle.stock_value_difference) / flt(sle.actual_qty)
self.assertAlmostEqual(returned_inv.items[0].rate, rate)
def test_payment_allocation_for_payment_terms(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
create_pr_against_po,
create_purchase_order,
)
from erpnext.selling.doctype.sales_order.test_sales_order import (
automatically_fetch_payment_terms,
)
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as make_pi_from_pr,
)
automatically_fetch_payment_terms()
frappe.db.set_value(
"Payment Terms Template",
"_Test Payment Term Template",
"allocate_payment_based_on_payment_terms",
0,
)
po = create_purchase_order(do_not_save=1)
po.payment_terms_template = "_Test Payment Term Template"
po.save()
po.submit()
pr = create_pr_against_po(po.name, received_qty=4)
pi = make_pi_from_pr(pr.name)
self.assertEqual(pi.payment_schedule[0].payment_amount, 1000)
frappe.db.set_value(
"Payment Terms Template",
"_Test Payment Term Template",
"allocate_payment_based_on_payment_terms",
1,
)
pi = make_pi_from_pr(pr.name)
self.assertEqual(pi.payment_schedule[0].payment_amount, 2500)
automatically_fetch_payment_terms(enable=0)
frappe.db.set_value(
"Payment Terms Template",
"_Test Payment Term Template",
"allocate_payment_based_on_payment_terms",
0,
)
def test_offsetting_entries_for_accounting_dimensions(self):
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.report.trial_balance.test_trial_balance import (

View File

@@ -1,44 +0,0 @@
<style>
.print-format {
padding: 4mm;
font-size: 8.0pt !important;
}
.print-format td {
vertical-align:middle !important;
}
.old {
background-color: #FFB3C0;
}
.new {
background-color: #B3FFCC;
}
</style>
<table class="table table-bordered table-condensed">
<colgroup>
{% for col in gl_columns%}
<col style="width: 18mm;">
{% endfor %}
</colgroup>
<thead>
<tr>
{% for col in gl_columns%}
<td>{{ col.label }}</td>
{% endfor %}
</tr>
</thead>
{% for gl in gl_data%}
{% if gl["old"]%}
<tr class="old">
{% else %}
<tr class="new">
{% endif %}
{% for col in gl_columns %}
<td class="text-right">
{{ gl[col.fieldname] }}
</td>
{% endfor %}
</tr>
{% endfor %}
</table>

View File

@@ -1,50 +0,0 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on("Repost Accounting Ledger", {
setup: function(frm) {
frm.fields_dict['vouchers'].grid.get_field('voucher_type').get_query = function(doc) {
return {
filters: {
name: ['in', ['Purchase Invoice', 'Sales Invoice', 'Payment Entry', 'Journal Entry']],
}
}
}
frm.fields_dict['vouchers'].grid.get_field('voucher_no').get_query = function(doc) {
if (doc.company) {
return {
filters: {
company: doc.company,
docstatus: 1
}
}
}
}
},
refresh: function(frm) {
frm.add_custom_button(__('Show Preview'), () => {
frm.call({
method: 'generate_preview',
doc: frm.doc,
freeze: true,
freeze_message: __('Generating Preview'),
callback: function(r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: {orientation: "landscape"},
columns: [],
data: [],
}
frappe.render_grid(opts);
}
}
});
});
}
});

View File

@@ -1,81 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "format:ACC-REPOST-{#####}",
"creation": "2023-07-04 13:07:32.923675",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"company",
"column_break_vpup",
"delete_cancelled_entries",
"section_break_metl",
"vouchers",
"amended_from"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company"
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Repost Accounting Ledger",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "vouchers",
"fieldtype": "Table",
"label": "Vouchers",
"options": "Repost Accounting Ledger Items"
},
{
"fieldname": "column_break_vpup",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_metl",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "delete_cancelled_entries",
"fieldtype": "Check",
"label": "Delete Cancelled Ledger Entries"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-07-27 15:47:58.975034",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,183 +0,0 @@
# 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.utils.data import comma_and
class RepostAccountingLedger(Document):
def __init__(self, *args, **kwargs):
super(RepostAccountingLedger, self).__init__(*args, **kwargs)
self._allowed_types = set(
["Purchase Invoice", "Sales Invoice", "Payment Entry", "Journal Entry"]
)
def validate(self):
self.validate_vouchers()
self.validate_for_closed_fiscal_year()
self.validate_for_deferred_accounting()
def validate_for_deferred_accounting(self):
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"]
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])
)
)
)
def validate_for_closed_fiscal_year(self):
if self.vouchers:
latest_pcv = (
frappe.db.get_all(
"Period Closing Voucher",
filters={"company": self.company},
order_by="posting_date desc",
pluck="posting_date",
limit=1,
)
or None
)
if not latest_pcv:
return
for vtype in self._allowed_types:
if names := [x.voucher_no for x in self.vouchers if x.voucher_type == vtype]:
latest_voucher = frappe.db.get_all(
vtype,
filters={"name": ["in", names]},
pluck="posting_date",
order_by="posting_date desc",
limit=1,
)[0]
if latest_voucher and latest_pcv[0] >= latest_voucher:
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
def validate_vouchers(self):
if self.vouchers:
# Validate voucher types
voucher_types = set([x.voucher_type for x in self.vouchers])
if disallowed_types := voucher_types.difference(self._allowed_types):
frappe.throw(
_("{0} types are not allowed. Only {1} are.").format(
frappe.bold(comma_and(list(disallowed_types))),
frappe.bold(comma_and(list(self._allowed_types))),
)
)
def get_existing_ledger_entries(self):
vouchers = [x.voucher_no for x in self.vouchers]
gl = qb.DocType("GL Entry")
existing_gles = (
qb.from_(gl)
.select(gl.star)
.where((gl.voucher_no.isin(vouchers)) & (gl.is_cancelled == 0))
.run(as_dict=True)
)
self.gles = frappe._dict({})
for gle in existing_gles:
self.gles.setdefault((gle.voucher_type, gle.voucher_no), frappe._dict({})).setdefault(
"existing", []
).append(gle.update({"old": True}))
def generate_preview_data(self):
self.gl_entries = []
self.get_existing_ledger_entries()
for x in self.vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
if doc.doctype in ["Payment Entry", "Journal Entry"]:
gle_map = doc.build_gl_map()
else:
gle_map = doc.get_gl_entries()
old_entries = self.gles.get((x.voucher_type, x.voucher_no))
if old_entries:
self.gl_entries.extend(old_entries.existing)
self.gl_entries.extend(gle_map)
@frappe.whitelist()
def generate_preview(self):
from erpnext.accounts.report.general_ledger.general_ledger import get_columns as get_gl_columns
gl_columns = []
gl_data = []
self.generate_preview_data()
if self.gl_entries:
filters = {"company": self.company, "include_dimensions": 1}
for x in get_gl_columns(filters):
if x["fieldname"] == "gl_entry":
x["fieldname"] = "name"
gl_columns.append(x)
gl_data = self.gl_entries
rendered_page = frappe.render_template(
"erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.html",
{"gl_columns": gl_columns, "gl_data": gl_data},
)
return rendered_page
def on_submit(self):
job_name = "repost_accounting_ledger_" + self.name
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
account_repost_doc=self.name,
is_async=True,
job_name=job_name,
)
frappe.msgprint(_("Repost has started in the background"))
@frappe.whitelist()
def start_repost(account_repost_doc=str) -> None:
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
if repost_doc.docstatus == 1:
# Prevent repost on invoices with deferred accounting
repost_doc.validate_for_deferred_accounting()
for x in repost_doc.vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
if repost_doc.delete_cancelled_entries:
frappe.db.delete("GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name})
frappe.db.delete(
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
)
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel()
doc.docstatus = 1
doc.make_gl_entries()
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
if not repost_doc.delete_cancelled_entries:
doc.make_gl_entries(1)
doc.make_gl_entries()
frappe.db.commit()

View File

@@ -1,202 +0,0 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe import qb
from frappe.query_builder.functions import Sum
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, nowdate, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import start_repost
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.accounts.utils import get_fiscal_year
class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
def teadDown(self):
frappe.db.rollback()
def test_01_basic_functions(self):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
)
preq = frappe.get_doc(
make_payment_request(
dt=si.doctype,
dn=si.name,
payment_request_type="Inward",
party_type="Customer",
party=si.customer,
)
)
preq.save().submit()
# Test Validation Error
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append(
"vouchers", {"voucher_type": preq.doctype, "voucher_no": preq.name}
) # this should throw validation error
self.assertRaises(frappe.ValidationError, ral.save)
ral.vouchers.pop()
preq.cancel()
preq.delete()
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save()
# manually set an incorrect debit amount in DB
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to})
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
gl = qb.DocType("GL Entry")
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.run()
)
# Assert incorrect ledger balance
self.assertNotEqual(res[0], (si.name, 100, 100))
# Submit repost document
ral.save().submit()
# background jobs don't run on test cases. Manually triggering repost function.
start_repost(ral.name)
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.run()
)
# Ledger should reflect correct amount post repost
self.assertEqual(res[0], (si.name, 100, 100))
def test_02_deferred_accounting_valiations(self):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
do_not_submit=True,
)
si.items[0].enable_deferred_revenue = True
si.items[0].deferred_revenue_account = self.deferred_revenue
si.items[0].service_start_date = nowdate()
si.items[0].service_end_date = add_days(nowdate(), 90)
si.save().submit()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_04_pcv_validation(self):
# Clear old GL entries so PCV can be submitted.
gl = frappe.qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == self.company).run()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
)
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"posting_date": today(),
"company": self.company,
"fiscal_year": get_fiscal_year(today(), company=self.company)[0],
"cost_center": self.cost_center,
"closing_account_head": self.retained_earnings,
"remarks": "test",
}
)
pcv.save().submit()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
pcv.reload()
pcv.cancel()
pcv.delete()
def test_03_deletion_flag_and_preview_function(self):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
# without deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.delete_cancelled_entries = False
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save()
# assert preview data is generated
preview = ral.generate_preview()
self.assertIsNotNone(preview)
ral.save().submit()
# background jobs don't run on test cases. Manually triggering repost function.
start_repost(ral.name)
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
# with deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = self.company
ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
start_repost(ral.name)
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))

View File

@@ -1,40 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-07-04 14:14:01.243848",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"voucher_type",
"voucher_no"
],
"fields": [
{
"fieldname": "voucher_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Voucher Type",
"options": "DocType"
},
{
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"label": "Voucher No",
"options": "voucher_type"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-07-04 14:15:51.165584",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger Items",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@@ -1,9 +0,0 @@
# 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 RepostAccountingLedgerItems(Document):
pass

View File

@@ -37,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload();
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", "Unreconcile Payments", "Unreconcile Payment Entries"];
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger"];
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format
@@ -98,7 +98,8 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
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(
__('Payment'),
() => this.make_payment_entry(),
@@ -183,11 +184,8 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
}, __('Create'));
}
}
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm);
}
make_maintenance_schedule() {
frappe.model.open_mapped_doc({
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule",

View File

@@ -194,7 +194,6 @@
"select_print_heading",
"language",
"subscription_section",
"subscription",
"from_date",
"auto_repeat",
"column_break_140",
@@ -716,7 +715,6 @@
"fieldtype": "Table",
"hide_days": 1,
"hide_seconds": 1,
"label": "Items",
"oldfieldname": "entries",
"oldfieldtype": "Table",
"options": "Sales Invoice Item",
@@ -2019,12 +2017,6 @@
"label": "Amount Eligible for Commission",
"read_only": 1
},
{
"fieldname": "subscription",
"fieldtype": "Link",
"label": "Subscription",
"options": "Subscription"
},
{
"default": "0",
"depends_on": "eval: doc.apply_discount_on == \"Grand Total\"",
@@ -2165,7 +2157,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2023-07-25 16:02:18.988799",
"modified": "2023-06-21 16:02:18.988799",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -23,7 +23,7 @@ from erpnext.accounts.doctype.tax_withholding_category.tax_withholding_category
)
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import get_due_date, get_party_account, get_party_details
from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_account_currency
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.depreciation import (
depreciate_asset,
get_disposal_account_and_cost_center,
@@ -32,7 +32,6 @@ from erpnext.assets.doctype.asset.depreciation import (
reset_depreciation_schedule,
reverse_depreciation_entry_made_after_disposal,
)
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.selling_controller import SellingController
from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data
@@ -386,10 +385,6 @@ class SalesInvoice(SellingController):
"Repost Item Valuation",
"Repost Payment Ledger",
"Repost Payment Ledger Items",
"Repost Accounting Ledger",
"Repost Accounting Ledger Items",
"Unreconcile Payments",
"Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
)
@@ -1034,10 +1029,7 @@ class SalesInvoice(SellingController):
merge_entries=False,
from_repost=from_repost,
)
self.make_exchange_gain_loss_journal()
elif self.docstatus == 2:
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
if update_outstanding == "No":
@@ -1062,10 +1054,10 @@ class SalesInvoice(SellingController):
self.make_customer_gl_entry(gl_entries)
self.make_tax_gl_entries(gl_entries)
self.make_exchange_gain_loss_gl_entries(gl_entries)
self.make_internal_transfer_gl_entries(gl_entries)
self.make_item_gl_entries(gl_entries)
self.make_precision_loss_gl_entry(gl_entries)
self.make_discount_gl_entries(gl_entries)
# merge gl entries before adding pos entries
@@ -1106,7 +1098,9 @@ class SalesInvoice(SellingController):
"debit_in_account_currency": base_grand_total
if self.party_account_currency == self.company_currency
else grand_total,
"against_voucher": self.name,
"against_voucher": self.return_against
if cint(self.is_return) and self.return_against
else self.name,
"against_voucher_type": self.doctype,
"cost_center": self.cost_center,
"project": self.project,
@@ -1182,13 +1176,12 @@ class SalesInvoice(SellingController):
self.get("posting_date"),
)
asset.db_set("disposal_date", None)
add_asset_activity(asset.name, _("Asset returned"))
if asset.calculate_depreciation:
posting_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
reverse_depreciation_entry_made_after_disposal(asset, posting_date)
notes = _(
"This schedule was created when Asset {0} was returned through Sales Invoice {1}."
"This schedule was created when Asset {0} was returned after being sold through Sales Invoice {1}."
).format(
get_link_to_form(asset.doctype, asset.name),
get_link_to_form(self.doctype, self.get("name")),
@@ -1216,7 +1209,6 @@ class SalesInvoice(SellingController):
self.get("posting_date"),
)
asset.db_set("disposal_date", self.posting_date)
add_asset_activity(asset.name, _("Asset sold"))
for gle in fixed_asset_gl_entries:
gle["against"] = self.customer
@@ -1654,13 +1646,15 @@ class SalesInvoice(SellingController):
frappe.db.set_value("Customer", self.customer, "loyalty_program_tier", lp_details.tier_name)
def get_returned_amount(self):
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Coalesce, Sum
doc = frappe.qb.DocType(self.doctype)
returned_amount = (
frappe.qb.from_(doc)
.select(Sum(doc.grand_total))
.where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.name))
.where(
(doc.docstatus == 1) & (doc.is_return == 1) & (Coalesce(doc.return_against, "") == self.name)
)
).run()
return abs(returned_amount[0][0]) if returned_amount[0][0] else 0
@@ -1732,8 +1726,12 @@ class SalesInvoice(SellingController):
elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
self.status = "Unpaid"
# Check if outstanding amount is 0 due to credit note issued against invoice
elif self.is_return == 0 and frappe.db.get_value(
"Sales Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
elif (
outstanding_amount <= 0
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"
elif self.is_return == 1:

View File

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

View File

@@ -1500,8 +1500,8 @@ class TestSalesInvoice(unittest.TestCase):
self.assertEqual(party_credited, 1000)
# Check 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"), 2500)
self.assertFalse(si1.outstanding_amount)
self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 1500)
def test_gle_made_when_asset_is_returned(self):
create_asset_data()
@@ -2049,27 +2049,28 @@ class TestSalesInvoice(unittest.TestCase):
self.assertEqual(si.total_taxes_and_charges, 228.82)
self.assertEqual(si.rounding_adjustment, -0.01)
expected_values = [
["_Test Account Service Tax - _TC", 0.0, 114.41],
["_Test Account VAT - _TC", 0.0, 114.41],
[si.debit_to, 1500, 0.0],
["Round Off - _TC", 0.01, 0.01],
["Sales - _TC", 0.0, 1271.18],
]
expected_values = dict(
(d[0], d)
for d in [
[si.debit_to, 1500, 0.0],
["_Test Account Service Tax - _TC", 0.0, 114.41],
["_Test Account VAT - _TC", 0.0, 114.41],
["Sales - _TC", 0.0, 1271.18],
]
)
gl_entries = frappe.db.sql(
"""select account, sum(debit) as debit, sum(credit) as credit
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
group by account
order by account asc""",
si.name,
as_dict=1,
)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_values[i][0], gle.account)
self.assertEqual(expected_values[i][1], gle.debit)
self.assertEqual(expected_values[i][2], gle.credit)
for gle in gl_entries:
self.assertEqual(expected_values[gle.account][0], gle.account)
self.assertEqual(expected_values[gle.account][1], gle.debit)
self.assertEqual(expected_values[gle.account][2], gle.credit)
def test_rounding_adjustment_3(self):
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
@@ -2124,14 +2125,13 @@ class TestSalesInvoice(unittest.TestCase):
["_Test Account Service Tax - _TC", 0.0, 240.43],
["_Test Account VAT - _TC", 0.0, 240.43],
["Sales - _TC", 0.0, 4007.15],
["Round Off - _TC", 0.02, 0.01],
["Round Off - _TC", 0.01, 0],
]
)
gl_entries = frappe.db.sql(
"""select account, sum(debit) as debit, sum(credit) as credit
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
group by account
order by account asc""",
si.name,
as_dict=1,
@@ -2322,7 +2322,7 @@ class TestSalesInvoice(unittest.TestCase):
item = create_item("_Test Item for Deferred Accounting")
item.enable_deferred_revenue = 1
item.item_defaults[0].deferred_revenue_account = deferred_account
item.deferred_revenue_account = deferred_account
item.no_of_months = 12
item.save()
@@ -3102,7 +3102,7 @@ class TestSalesInvoice(unittest.TestCase):
item = create_item("_Test Item for Deferred Accounting")
item.enable_deferred_expense = 1
item.item_defaults[0].deferred_revenue_account = deferred_account
item.deferred_revenue_account = deferred_account
item.save()
si = create_sales_invoice(
@@ -3213,10 +3213,15 @@ class TestSalesInvoice(unittest.TestCase):
account.disabled = 0
account.save()
@change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_gain_loss_with_advance_entry(self):
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
unlink_enabled = frappe.db.get_value(
"Accounts Settings", "Accounts Settings", "unlink_payment_on_cancel_of_invoice"
)
frappe.db.set_single_value("Accounts Settings", "unlink_payment_on_cancel_of_invoice", 1)
jv = make_journal_entry("_Test Receivable USD - _TC", "_Test Bank - _TC", -7000, save=False)
jv.accounts[0].exchange_rate = 70
@@ -3249,28 +3254,18 @@ class TestSalesInvoice(unittest.TestCase):
)
si.save()
si.submit()
expected_gle = [
["_Test Exchange Gain/Loss - _TC", 500.0, 0.0, nowdate()],
["_Test Receivable USD - _TC", 7500.0, 0.0, nowdate()],
["_Test Receivable USD - _TC", 0.0, 500.0, nowdate()],
["Sales - _TC", 0.0, 7500.0, nowdate()],
]
check_gl_entries(self, si.name, expected_gle, nowdate())
si.reload()
self.assertEqual(si.outstanding_amount, 0)
journals = frappe.db.get_all(
"Journal Entry Account",
filters={"reference_type": "Sales Invoice", "reference_name": si.name, "docstatus": 1},
pluck="parent",
)
journals = [x for x in journals if x != jv.name]
self.assertEqual(len(journals), 1)
je_type = frappe.get_cached_value("Journal Entry", journals[0], "voucher_type")
self.assertEqual(je_type, "Exchange Gain Or Loss")
ledger_outstanding = frappe.db.get_all(
"Payment Ledger Entry",
filters={"against_voucher_no": si.name, "delinked": 0},
fields=["sum(amount), sum(amount_in_account_currency)"],
as_list=1,
frappe.db.set_single_value(
"Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled
)
def test_batch_expiry_for_sales_invoice_return(self):
@@ -3376,14 +3371,6 @@ class TestSalesInvoice(unittest.TestCase):
set_advance_flag(company="_Test Company", flag=0, default_account="")
@change_settings("Selling Settings", {"allow_negative_rates_for_items": 0})
def test_sales_return_negative_rate(self):
si = create_sales_invoice(is_return=1, qty=-2, rate=-10, do_not_save=True)
self.assertRaises(frappe.ValidationError, si.save)
si.items[0].rate = 10
si.save()
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(

View File

@@ -2,16 +2,16 @@
// For license information, please see license.txt
frappe.ui.form.on('Subscription', {
setup: function (frm) {
frm.set_query('party_type', function () {
setup: function(frm) {
frm.set_query('party_type', function() {
return {
filters: {
filters : {
name: ['in', ['Customer', 'Supplier']]
}
}
});
frm.set_query('cost_center', function () {
frm.set_query('cost_center', function() {
return {
filters: {
company: frm.doc.company
@@ -20,60 +20,76 @@ frappe.ui.form.on('Subscription', {
});
},
refresh: function (frm) {
if (frm.is_new()) return;
if (frm.doc.status !== 'Cancelled') {
frm.add_custom_button(
__('Fetch Subscription Updates'),
() => frm.trigger('get_subscription_updates'),
__('Actions')
);
frm.add_custom_button(
__('Cancel Subscription'),
() => frm.trigger('cancel_this_subscription'),
__('Actions')
);
} else if (frm.doc.status === 'Cancelled') {
frm.add_custom_button(
__('Restart Subscription'),
() => frm.trigger('renew_this_subscription'),
__('Actions')
);
refresh: function(frm) {
if(!frm.is_new()){
if(frm.doc.status !== 'Cancelled'){
frm.add_custom_button(
__('Cancel Subscription'),
() => frm.events.cancel_this_subscription(frm)
);
frm.add_custom_button(
__('Fetch Subscription Updates'),
() => frm.events.get_subscription_updates(frm)
);
}
else if(frm.doc.status === 'Cancelled'){
frm.add_custom_button(
__('Restart Subscription'),
() => frm.events.renew_this_subscription(frm)
);
}
}
},
cancel_this_subscription: function (frm) {
cancel_this_subscription: function(frm) {
const doc = frm.doc;
frappe.confirm(
__('This action will stop future billing. Are you sure you want to cancel this subscription?'),
() => {
frm.call('cancel_subscription').then(r => {
if (!r.exec) {
frm.reload_doc();
function() {
frappe.call({
method:
"erpnext.accounts.doctype.subscription.subscription.cancel_subscription",
args: {name: doc.name},
callback: function(data){
if(!data.exc){
frm.reload_doc();
}
}
});
}
);
},
renew_this_subscription: function (frm) {
renew_this_subscription: function(frm) {
const doc = frm.doc;
frappe.confirm(
__('Are you sure you want to restart this subscription?'),
() => {
frm.call('restart_subscription').then(r => {
if (!r.exec) {
frm.reload_doc();
__('You will lose records of previously generated invoices. Are you sure you want to restart this subscription?'),
function() {
frappe.call({
method:
"erpnext.accounts.doctype.subscription.subscription.restart_subscription",
args: {name: doc.name},
callback: function(data){
if(!data.exc){
frm.reload_doc();
}
}
});
}
);
},
get_subscription_updates: function (frm) {
frm.call('process').then(r => {
if (!r.exec) {
frm.reload_doc();
get_subscription_updates: function(frm) {
const doc = frm.doc;
frappe.call({
method:
"erpnext.accounts.doctype.subscription.subscription.get_subscription_updates",
args: {name: doc.name},
freeze: true,
callback: function(data){
if(!data.exc){
frm.reload_doc();
}
}
});
}

View File

@@ -19,7 +19,6 @@
"trial_period_end",
"follow_calendar_months",
"generate_new_invoices_past_due_date",
"submit_invoice",
"column_break_11",
"current_invoice_start",
"current_invoice_end",
@@ -36,8 +35,12 @@
"cb_2",
"additional_discount_percentage",
"additional_discount_amount",
"sb_3",
"submit_invoice",
"invoices",
"accounting_dimensions_section",
"cost_center"
"cost_center",
"dimension_col_break"
],
"fields": [
{
@@ -159,12 +162,29 @@
"fieldtype": "Currency",
"label": "Additional DIscount Amount"
},
{
"depends_on": "eval:doc.invoices",
"fieldname": "sb_3",
"fieldtype": "Section Break",
"label": "Invoices"
},
{
"collapsible": 1,
"fieldname": "invoices",
"fieldtype": "Table",
"label": "Invoices",
"options": "Subscription Invoice"
},
{
"collapsible": 1,
"fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break",
"label": "Accounting Dimensions"
},
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{
"fieldname": "party_type",
"fieldtype": "Link",
@@ -239,27 +259,15 @@
"default": "1",
"fieldname": "submit_invoice",
"fieldtype": "Check",
"label": "Submit Generated Invoices"
"label": "Submit Invoice Automatically"
}
],
"index_web_pages_for_search": 1,
"links": [
{
"group": "Buying",
"link_doctype": "Purchase Invoice",
"link_fieldname": "subscription"
},
{
"group": "Selling",
"link_doctype": "Sales Invoice",
"link_fieldname": "subscription"
}
],
"modified": "2022-02-18 23:24:57.185054",
"links": [],
"modified": "2021-04-19 15:24:27.550797",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscription",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -301,6 +309,5 @@
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@@ -2,17 +2,14 @@
# For license information, please see license.txt
from datetime import datetime
from typing import Dict, List, Optional, Union
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils.data import (
add_days,
add_months,
add_to_date,
cint,
cstr,
date_diff,
flt,
get_last_day,
@@ -20,7 +17,8 @@ from frappe.utils.data import (
nowdate,
)
from erpnext import get_default_company, get_default_cost_center
import erpnext
from erpnext import get_default_company
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
@@ -28,39 +26,33 @@ from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_pla
from erpnext.accounts.party import get_party_account_currency
class InvoiceCancelled(frappe.ValidationError):
pass
class InvoiceNotCancelled(frappe.ValidationError):
pass
class Subscription(Document):
def before_insert(self):
# update start just before the subscription doc is created
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=None, return_date=False):
"""
Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period.
The beginning of the billing period is represented in the doctype as
`current_invoice_start` and the end of the billing period is represented
as `current_invoice_end`.
"""
self.current_invoice_start = self.get_current_invoice_start(date)
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
def _get_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
If return_date is True, it wont update the start and end dates.
This is implemented to get the dates to check if is_current_invoice_generated
"""
_current_invoice_start = self.get_current_invoice_start(date)
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
return _current_invoice_start, _current_invoice_end
if return_date:
return _current_invoice_start, _current_invoice_end
def get_current_invoice_start(
self, date: Optional[Union[datetime.date, str]] = None
) -> Union[datetime.date, str]:
self.current_invoice_start = _current_invoice_start
self.current_invoice_end = _current_invoice_end
def get_current_invoice_start(self, date=None):
"""
This returns the date of the beginning of the current billing period.
If the `date` parameter is not given , it will be automatically set as today's
@@ -83,13 +75,13 @@ class Subscription(Document):
return _current_invoice_start
def get_current_invoice_end(
self, date: Optional[Union[datetime.date, str]] = None
) -> Union[datetime.date, str]:
def get_current_invoice_end(self, date=None):
"""
This returns the date of the end of the current billing period.
If the subscription is in trial period, it will be set as the end of the
trial period.
If is not in a trial period, it will be `x` days from the beginning of the
current billing period where `x` is the billing interval from the
`Subscription Plan` in the `Subscription`.
@@ -113,13 +105,24 @@ class Subscription(Document):
_current_invoice_end = get_last_day(date)
if self.follow_calendar_months:
# Sets the end date
# eg if date is 17-Feb-2022, the invoice will be generated per month ie
# the invoice will be created from 17 Feb to 28 Feb
billing_info = self.get_billing_cycle_and_interval()
billing_interval_count = billing_info[0]["billing_interval_count"]
_end = add_months(getdate(date), billing_interval_count - 1)
_current_invoice_end = get_last_day(_end)
calendar_months = get_calendar_months(billing_interval_count)
calendar_month = 0
current_invoice_end_month = getdate(_current_invoice_end).month
current_invoice_end_year = getdate(_current_invoice_end).year
for month in calendar_months:
if month <= current_invoice_end_month:
calendar_month = month
if cint(calendar_month - billing_interval_count) <= 0 and getdate(date).month != 1:
calendar_month = 12
current_invoice_end_year -= 1
_current_invoice_end = get_last_day(
cstr(current_invoice_end_year) + "-" + cstr(calendar_month) + "-01"
)
if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date):
_current_invoice_end = self.end_date
@@ -127,7 +130,7 @@ class Subscription(Document):
return _current_invoice_end
@staticmethod
def validate_plans_billing_cycle(billing_cycle_data: List[Dict[str, str]]) -> None:
def validate_plans_billing_cycle(billing_cycle_data):
"""
Makes sure that all `Subscription Plan` in the `Subscription` have the
same billing interval
@@ -135,9 +138,10 @@ class Subscription(Document):
if billing_cycle_data and len(billing_cycle_data) != 1:
frappe.throw(_("You can only have Plans with the same billing cycle in a Subscription"))
def get_billing_cycle_and_interval(self) -> List[Dict[str, str]]:
def get_billing_cycle_and_interval(self):
"""
Returns a dict representing the billing interval and cycle for this `Subscription`.
You shouldn't need to call this directly. Use `get_billing_cycle` instead.
"""
plan_names = [plan.plan for plan in self.plans]
@@ -152,65 +156,72 @@ class Subscription(Document):
return billing_info
def get_billing_cycle_data(self) -> Dict[str, int]:
def get_billing_cycle_data(self):
"""
Returns dict contain the billing cycle data.
You shouldn't need to call this directly. Use `get_billing_cycle` instead.
"""
billing_info = self.get_billing_cycle_and_interval()
if not billing_info:
return None
data = dict()
interval = billing_info[0]["billing_interval"]
interval_count = billing_info[0]["billing_interval_count"]
self.validate_plans_billing_cycle(billing_info)
if interval not in ["Day", "Week"]:
data["days"] = -1
if billing_info:
data = dict()
interval = billing_info[0]["billing_interval"]
interval_count = billing_info[0]["billing_interval_count"]
if interval not in ["Day", "Week"]:
data["days"] = -1
if interval == "Day":
data["days"] = interval_count - 1
elif interval == "Month":
data["months"] = interval_count
elif interval == "Year":
data["years"] = interval_count
# todo: test week
elif interval == "Week":
data["days"] = interval_count * 7 - 1
if interval == "Day":
data["days"] = interval_count - 1
elif interval == "Week":
data["days"] = interval_count * 7 - 1
elif interval == "Month":
data["months"] = interval_count
elif interval == "Year":
data["years"] = interval_count
return data
return data
def set_status_grace_period(self):
"""
Sets the `Subscription` `status` based on the preference set in `Subscription Settings`.
def set_subscription_status(self) -> None:
Used when the `Subscription` needs to decide what to do after the current generated
invoice is past it's due date and grace period.
"""
subscription_settings = frappe.get_single("Subscription Settings")
if self.status == "Past Due Date" and self.is_past_grace_period():
self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid"
def set_subscription_status(self):
"""
Sets the status of the `Subscription`
"""
if self.is_trialling():
self.status = "Trialling"
elif (
self.status == "Active"
and self.end_date
and getdate(frappe.flags.current_date) > getdate(self.end_date)
):
elif self.status == "Active" and self.end_date and getdate() > getdate(self.end_date):
self.status = "Completed"
elif self.is_past_grace_period():
self.status = self.get_status_for_past_grace_period()
self.cancelation_date = (
getdate(frappe.flags.current_date) if self.status == "Cancelled" else None
)
subscription_settings = frappe.get_single("Subscription Settings")
self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid"
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
self.status = "Past Due Date"
elif not self.has_outstanding_invoice() or self.is_new_subscription():
elif not self.has_outstanding_invoice():
self.status = "Active"
elif self.is_new_subscription():
self.status = "Active"
self.save()
def is_trialling(self) -> bool:
def is_trialling(self):
"""
Returns `True` if the `Subscription` is in trial period.
"""
return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription()
@staticmethod
def period_has_passed(end_date: Union[str, datetime.date]) -> bool:
def period_has_passed(end_date):
"""
Returns true if the given `end_date` has passed
"""
@@ -218,59 +229,61 @@ class Subscription(Document):
if not end_date:
return True
return getdate(frappe.flags.current_date) > getdate(end_date)
end_date = getdate(end_date)
return getdate() > getdate(end_date)
def get_status_for_past_grace_period(self) -> str:
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
status = "Unpaid"
if cancel_after_grace:
status = "Cancelled"
return status
def is_past_grace_period(self) -> bool:
def is_past_grace_period(self):
"""
Returns `True` if the grace period for the `Subscription` has passed
"""
if not self.current_invoice_is_past_due():
return
current_invoice = self.get_current_invoice()
if self.current_invoice_is_past_due(current_invoice):
subscription_settings = frappe.get_single("Subscription Settings")
grace_period = cint(subscription_settings.grace_period)
grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period"))
return getdate(frappe.flags.current_date) >= getdate(
add_days(self.current_invoice.due_date, grace_period)
)
return getdate() > add_days(current_invoice.due_date, grace_period)
def current_invoice_is_past_due(self) -> bool:
def current_invoice_is_past_due(self, current_invoice=None):
"""
Returns `True` if the current generated invoice is overdue
"""
if not self.current_invoice or self.is_paid(self.current_invoice):
if not current_invoice:
current_invoice = self.get_current_invoice()
if not current_invoice or self.is_paid(current_invoice):
return False
else:
return getdate() > getdate(current_invoice.due_date)
return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date)
def get_current_invoice(self):
"""
Returns the most recent generated invoice.
"""
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
@property
def invoice_document_type(self) -> str:
return "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
if len(self.invoices):
current = self.invoices[-1]
if frappe.db.exists(doctype, current.get("invoice")):
doc = frappe.get_doc(doctype, current.get("invoice"))
return doc
else:
frappe.throw(_("Invoice {0} no longer exists").format(current.get("invoice")))
def is_new_subscription(self) -> bool:
def is_new_subscription(self):
"""
Returns `True` if `Subscription` has never generated an invoice
"""
return self.is_new() or not frappe.db.exists(
{"doctype": self.invoice_document_type, "subscription": self.name}
)
return len(self.invoices) == 0
def validate(self) -> None:
def validate(self):
self.validate_trial_period()
self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval())
self.validate_end_date()
self.validate_to_follow_calendar_months()
if not self.cost_center:
self.cost_center = get_default_cost_center(self.get("company"))
self.cost_center = erpnext.get_default_cost_center(self.get("company"))
def validate_trial_period(self) -> None:
def validate_trial_period(self):
"""
Runs sanity checks on trial period dates for the `Subscription`
"""
@@ -284,7 +297,7 @@ class Subscription(Document):
if self.trial_period_start and getdate(self.trial_period_start) > getdate(self.start_date):
frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date"))
def validate_end_date(self) -> None:
def validate_end_date(self):
billing_cycle_info = self.get_billing_cycle_data()
end_date = add_to_date(self.start_date, **billing_cycle_info)
@@ -293,53 +306,53 @@ class Subscription(Document):
_("Subscription End Date must be after {0} as per the subscription plan").format(end_date)
)
def validate_to_follow_calendar_months(self) -> None:
if not self.follow_calendar_months:
return
def validate_to_follow_calendar_months(self):
if self.follow_calendar_months:
billing_info = self.get_billing_cycle_and_interval()
billing_info = self.get_billing_cycle_and_interval()
if not self.end_date:
frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
if not self.end_date:
frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
if billing_info[0]["billing_interval"] != "Month":
frappe.throw(
_("Billing Interval in Subscription Plan must be Month to follow calendar months")
)
if billing_info[0]["billing_interval"] != "Month":
frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months"))
def after_insert(self) -> None:
def after_insert(self):
# todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype?
self.set_subscription_status()
def generate_invoice(
self,
from_date: Optional[Union[str, datetime.date]] = None,
to_date: Optional[Union[str, datetime.date]] = None,
) -> Document:
def generate_invoice(self, prorate=0):
"""
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
saves the `Subscription`.
Backwards compatibility
"""
return self.create_invoice(from_date=from_date, to_date=to_date)
def create_invoice(
self,
from_date: Optional[Union[str, datetime.date]] = None,
to_date: Optional[Union[str, datetime.date]] = None,
) -> Document:
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
invoice = self.create_invoice(prorate)
self.append("invoices", {"document_type": doctype, "invoice": invoice.name})
self.save()
return invoice
def create_invoice(self, prorate):
"""
Creates a `Invoice`, submits it and returns it
"""
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
invoice = frappe.new_doc(doctype)
# For backward compatibility
# Earlier subscription didn't had any company field
company = self.get("company") or get_default_company()
if not company:
# fmt: off
frappe.throw(
_("Company is mandatory was generating invoice. Please set default company in Global Defaults.")
_("Company is mandatory was generating invoice. Please set default company in Global Defaults")
)
# fmt: on
invoice = frappe.new_doc(self.invoice_document_type)
invoice.company = company
invoice.set_posting_time = 1
invoice.posting_date = (
@@ -350,17 +363,17 @@ class Subscription(Document):
invoice.cost_center = self.cost_center
if self.invoice_document_type == "Sales Invoice":
if doctype == "Sales Invoice":
invoice.customer = self.party
else:
invoice.supplier = self.party
if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"):
invoice.apply_tds = 1
# Add party currency to invoice
### Add party currency to invoice
invoice.currency = get_party_account_currency(self.party_type, self.party, self.company)
# Add dimensions in invoice for subscription:
## Add dimensions in invoice for subscription:
accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions:
@@ -369,7 +382,7 @@ class Subscription(Document):
# Subscription is better suited for service items. I won't update `update_stock`
# for that reason
items_list = self.get_items_from_plans(self.plans, is_prorate())
items_list = self.get_items_from_plans(self.plans, prorate)
for item in items_list:
item["cost_center"] = self.cost_center
invoice.append("items", item)
@@ -377,9 +390,9 @@ class Subscription(Document):
# Taxes
tax_template = ""
if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
if doctype == "Sales Invoice" and self.sales_tax_template:
tax_template = self.sales_tax_template
if self.invoice_document_type == "Purchase Invoice" and self.purchase_tax_template:
if doctype == "Purchase Invoice" and self.purchase_tax_template:
tax_template = self.purchase_tax_template
if tax_template:
@@ -411,9 +424,8 @@ class Subscription(Document):
invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
# Subscription period
invoice.subscription = self.name
invoice.from_date = from_date or self.current_invoice_start
invoice.to_date = to_date or self.current_invoice_end
invoice.from_date = self.current_invoice_start
invoice.to_date = self.current_invoice_end
invoice.flags.ignore_mandatory = True
@@ -425,20 +437,13 @@ class Subscription(Document):
return invoice
def get_items_from_plans(
self, plans: List[Dict[str, str]], prorate: Optional[bool] = None
) -> List[Dict]:
def get_items_from_plans(self, plans, prorate=0):
"""
Returns the `Item`s linked to `Subscription Plan`
"""
if prorate is None:
prorate = False
if prorate:
prorate_factor = get_prorata_factor(
self.current_invoice_end,
self.current_invoice_start,
cint(self.generate_invoice_at_period_start),
self.current_invoice_end, self.current_invoice_start, self.generate_invoice_at_period_start
)
items = []
@@ -460,11 +465,7 @@ class Subscription(Document):
"item_code": item_code,
"qty": plan.qty,
"rate": get_plan_rate(
plan.plan,
plan.qty,
party,
self.current_invoice_start,
self.current_invoice_end,
plan.plan, plan.qty, party, self.current_invoice_start, self.current_invoice_end
),
"cost_center": plan_doc.cost_center,
}
@@ -502,184 +503,254 @@ class Subscription(Document):
return items
@frappe.whitelist()
def process(self) -> bool:
def process(self):
"""
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:
1. `process_for_active`
2. `process_for_past_due`
"""
if (
not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
and self.can_generate_new_invoice()
):
self.generate_invoice()
self.update_subscription_period(add_days(self.current_invoice_end, 1))
if self.cancel_at_period_end and (
getdate(frappe.flags.current_date) >= getdate(self.current_invoice_end)
or getdate(frappe.flags.current_date) >= getdate(self.end_date)
):
self.cancel_subscription()
if self.status == "Active":
self.process_for_active()
elif self.status in ["Past Due Date", "Unpaid"]:
self.process_for_past_due_date()
self.set_subscription_status()
self.save()
def can_generate_new_invoice(self) -> bool:
if self.cancelation_date:
return False
elif self.generate_invoice_at_period_start and (
getdate(frappe.flags.current_date) == getdate(self.current_invoice_start)
or self.is_new_subscription()
):
return True
elif getdate(frappe.flags.current_date) == getdate(self.current_invoice_end):
if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
return False
def is_postpaid_to_invoice(self):
return getdate() > getdate(self.current_invoice_end) or (
getdate() >= getdate(self.current_invoice_end)
and getdate(self.current_invoice_end) == getdate(self.current_invoice_start)
)
return True
else:
def is_prepaid_to_invoice(self):
if not self.generate_invoice_at_period_start:
return False
def is_current_invoice_generated(
self,
_current_start_date: Union[datetime.date, str] = None,
_current_end_date: Union[datetime.date, str] = None,
) -> bool:
if self.is_new_subscription() and getdate() >= getdate(self.current_invoice_start):
return True
# Check invoice dates and make sure it doesn't have outstanding invoices
return getdate() >= getdate(self.current_invoice_start)
def is_current_invoice_generated(self, _current_start_date=None, _current_end_date=None):
invoice = self.get_current_invoice()
if not (_current_start_date and _current_end_date):
_current_start_date, _current_end_date = self._get_subscription_period(
date=add_days(self.current_invoice_end, 1)
_current_start_date, _current_end_date = self.update_subscription_period(
date=add_days(self.current_invoice_end, 1), return_date=True
)
if self.current_invoice and getdate(_current_start_date) <= getdate(
self.current_invoice.posting_date
) <= getdate(_current_end_date):
if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate(
_current_end_date
):
return True
return False
@property
def current_invoice(self) -> Union[Document, None]:
def process_for_active(self):
"""
Adds property for accessing the current_invoice
"""
return self.get_current_invoice()
Called by `process` if the status of the `Subscription` is 'Active'.
def get_current_invoice(self) -> Union[Document, None]:
The possible outcomes of this method are:
1. Generate a new invoice
2. Change the `Subscription` status to 'Past Due Date'
3. Change the `Subscription` status to 'Cancelled'
"""
Returns the most recent generated invoice.
"""
invoice = frappe.get_all(
self.invoice_document_type,
{
"subscription": self.name,
},
limit=1,
order_by="to_date desc",
pluck="name",
)
if invoice:
return frappe.get_doc(self.invoice_document_type, invoice[0])
if not self.is_current_invoice_generated(
self.current_invoice_start, self.current_invoice_end
) and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()):
def cancel_subscription_at_period_end(self) -> None:
prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
self.generate_invoice(prorate)
if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice():
self.update_subscription_period(add_days(self.current_invoice_end, 1))
if self.cancel_at_period_end and getdate() > getdate(self.current_invoice_end):
self.cancel_subscription_at_period_end()
def cancel_subscription_at_period_end(self):
"""
Called when `Subscription.cancel_at_period_end` is truthy
"""
self.status = "Cancelled"
self.cancelation_date = nowdate()
if self.end_date and getdate() < getdate(self.end_date):
return
@property
def invoices(self) -> List[Dict]:
return frappe.get_all(
self.invoice_document_type,
filters={"subscription": self.name},
order_by="from_date asc",
)
self.status = "Cancelled"
if not self.cancelation_date:
self.cancelation_date = nowdate()
def process_for_past_due_date(self):
"""
Called by `process` if the status of the `Subscription` is 'Past Due Date'.
The possible outcomes of this method are:
1. Change the `Subscription` status to 'Active'
2. Change the `Subscription` status to 'Cancelled'
3. Change the `Subscription` status to 'Unpaid'
"""
current_invoice = self.get_current_invoice()
if not current_invoice:
frappe.throw(_("Current invoice {0} is missing").format(current_invoice.invoice))
else:
if not self.has_outstanding_invoice():
self.status = "Active"
else:
self.set_status_grace_period()
if getdate() > getdate(self.current_invoice_end):
self.update_subscription_period(add_days(self.current_invoice_end, 1))
# Generate invoices periodically even if current invoice are unpaid
if (
self.generate_new_invoices_past_due_date
and not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice())
):
prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
self.generate_invoice(prorate)
@staticmethod
def is_paid(invoice: Document) -> bool:
def is_paid(invoice):
"""
Return `True` if the given invoice is paid
"""
return invoice.status == "Paid"
def has_outstanding_invoice(self) -> int:
def has_outstanding_invoice(self):
"""
Returns `True` if the most recent invoice for the `Subscription` is not paid
"""
return frappe.db.count(
self.invoice_document_type,
{
"subscription": self.name,
"status": ["!=", "Paid"],
},
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
current_invoice = self.get_current_invoice()
invoice_list = [d.invoice for d in self.invoices]
outstanding_invoices = frappe.get_all(
doctype, fields=["name"], filters={"status": ("!=", "Paid"), "name": ("in", invoice_list)}
)
@frappe.whitelist()
def cancel_subscription(self) -> None:
if outstanding_invoices:
return True
else:
False
def cancel_subscription(self):
"""
This sets the subscription as cancelled. It will stop invoices from being generated
but it will not affect already created invoices.
"""
if self.status == "Cancelled":
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
if self.status != "Cancelled":
to_generate_invoice = (
True if self.status == "Active" and not self.generate_invoice_at_period_start else False
)
to_prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
self.status = "Cancelled"
self.cancelation_date = nowdate()
if to_generate_invoice:
self.generate_invoice(prorate=to_prorate)
self.save()
to_generate_invoice = (
True if self.status == "Active" and not self.generate_invoice_at_period_start else False
)
self.status = "Cancelled"
self.cancelation_date = nowdate()
if to_generate_invoice:
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
self.save()
@frappe.whitelist()
def restart_subscription(self) -> None:
def restart_subscription(self):
"""
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
it has.
"""
if not self.status == "Cancelled":
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
if self.status == "Cancelled":
self.status = "Active"
self.db_set("start_date", nowdate())
self.update_subscription_period(nowdate())
self.invoices = []
self.save()
else:
frappe.throw(_("You cannot restart a Subscription that is not cancelled."))
self.status = "Active"
self.cancelation_date = None
self.update_subscription_period(frappe.flags.current_date or nowdate())
self.save()
def get_precision(self):
invoice = self.get_current_invoice()
if invoice:
return invoice.precision("grand_total")
def is_prorate() -> int:
return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
def get_calendar_months(billing_interval):
calendar_months = []
start = 0
while start < 12:
start += billing_interval
calendar_months.append(start)
return calendar_months
def get_prorata_factor(
period_end: Union[datetime.date, str],
period_start: Union[datetime.date, str],
is_prepaid: Optional[int] = None,
) -> Union[int, float]:
def get_prorata_factor(period_end, period_start, is_prepaid):
if is_prepaid:
return 1
prorate_factor = 1
else:
diff = flt(date_diff(nowdate(), period_start) + 1)
plan_days = flt(date_diff(period_end, period_start) + 1)
prorate_factor = diff / plan_days
diff = flt(date_diff(nowdate(), period_start) + 1)
plan_days = flt(date_diff(period_end, period_start) + 1)
return diff / plan_days
return prorate_factor
def process_all() -> None:
def process_all():
"""
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"):
subscriptions = get_all_subscriptions()
for subscription in subscriptions:
process(subscription)
def get_all_subscriptions():
"""
Returns all `Subscription` documents
"""
return frappe.db.get_all("Subscription", {"status": ("!=", "Cancelled")})
def process(data):
"""
Checks a `Subscription` and updates it status as necessary
"""
if data:
try:
subscription = frappe.get_doc("Subscription", subscription)
subscription = frappe.get_doc("Subscription", data["name"])
subscription.process()
frappe.db.commit()
except frappe.ValidationError:
frappe.db.rollback()
subscription.log_error("Subscription failed")
@frappe.whitelist()
def cancel_subscription(name):
"""
Cancels a `Subscription`. This will stop the `Subscription` from further invoicing the
`Subscriber` but all already outstanding invoices will not be affected.
"""
subscription = frappe.get_doc("Subscription", name)
subscription.cancel_subscription()
@frappe.whitelist()
def restart_subscription(name):
"""
Restarts a cancelled `Subscription`. The `Subscription` will 'forget' the history of
all invoices it has generated
"""
subscription = frappe.get_doc("Subscription", name)
subscription.restart_subscription()
@frappe.whitelist()
def get_subscription_updates(name):
"""
Use this to get the latest state of the given `Subscription`
"""
subscription = frappe.get_doc("Subscription", name)
subscription.process()

View File

@@ -11,7 +11,6 @@ from frappe.utils.data import (
date_diff,
flt,
get_date_str,
getdate,
nowdate,
)
@@ -91,18 +90,10 @@ def create_parties():
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):
def setUp(self):
create_plan()
create_parties()
reset_settings()
def test_create_subscription_with_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription")
@@ -125,6 +116,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.invoices, [])
self.assertEqual(subscription.status, "Trialling")
subscription.delete()
def test_create_subscription_without_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -140,6 +133,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(len(subscription.invoices), 0)
self.assertEqual(subscription.status, "Active")
subscription.delete()
def test_create_subscription_trial_with_wrong_dates(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -149,6 +144,7 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save)
subscription.delete()
def test_create_subscription_multi_with_different_billing_fails(self):
subscription = frappe.new_doc("Subscription")
@@ -160,6 +156,7 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save)
subscription.delete()
def test_invoice_is_generated_at_end_of_billing_period(self):
subscription = frappe.new_doc("Subscription")
@@ -172,13 +169,13 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
self.assertEqual(subscription.current_invoice_end, "2018-01-31")
frappe.flags.current_date = "2018-01-31"
subscription.process()
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.current_invoice_start, "2018-02-01")
self.assertEqual(subscription.current_invoice_end, "2018-02-28")
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
subscription.process()
self.assertEqual(subscription.status, "Unpaid")
subscription.delete()
def test_status_goes_back_to_active_after_invoice_is_paid(self):
subscription = frappe.new_doc("Subscription")
@@ -186,9 +183,7 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
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)
@@ -208,8 +203,11 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
self.assertEqual(len(subscription.invoices), 1)
subscription.delete()
def test_subscription_cancel_after_grace_period(self):
settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace
settings.cancel_after_grace = 1
settings.save()
@@ -217,18 +215,20 @@ class TestSubscription(unittest.TestCase):
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")
frappe.flags.current_date = "2018-01-31"
subscription.process() # generate first invoice
# This should change status to Cancelled since grace period is 0
# And is backdated subscription so subscription will be cancelled after processing
self.assertEqual(subscription.status, "Cancelled")
settings.cancel_after_grace = default_grace_period_action
settings.save()
subscription.delete()
def test_subscription_unpaid_after_grace_period(self):
settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace
@@ -248,26 +248,21 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = default_grace_period_action
settings.save()
subscription.delete()
def test_subscription_invoice_days_until_due(self):
_date = add_months(nowdate(), -1)
subscription = frappe.new_doc("Subscription")
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.days_until_due = 10
subscription.start_date = add_months(nowdate(), -1)
subscription.insert()
frappe.flags.current_date = subscription.current_invoice_end
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(subscription.status, "Active")
subscription.delete()
def test_subscription_is_past_due_doesnt_change_within_grace_period(self):
settings = frappe.get_single("Subscription Settings")
@@ -281,8 +276,6 @@ class TestSubscription(unittest.TestCase):
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
self.assertEqual(subscription.status, "Past Due Date")
@@ -299,6 +292,7 @@ class TestSubscription(unittest.TestCase):
settings.grace_period = grace_period
settings.save()
subscription.delete()
def test_subscription_remains_active_during_invoice_period(self):
subscription = frappe.new_doc("Subscription")
@@ -325,6 +319,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0)
subscription.delete()
def test_subscription_cancelation(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -335,6 +331,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Cancelled")
subscription.delete()
def test_subscription_cancellation_invoices(self):
settings = frappe.get_single("Subscription Settings")
to_prorate = settings.prorate
@@ -374,6 +372,7 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
self.assertEqual(subscription.status, "Cancelled")
subscription.delete()
settings.prorate = to_prorate
settings.save()
@@ -396,6 +395,8 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate
settings.save()
subscription.delete()
def test_subscription_cancellation_invoices_with_prorata_true(self):
settings = frappe.get_single("Subscription Settings")
to_prorate = settings.prorate
@@ -421,6 +422,8 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate
settings.save()
subscription.delete()
def test_subcription_cancellation_and_process(self):
settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace
@@ -434,22 +437,23 @@ class TestSubscription(unittest.TestCase):
subscription.start_date = "2018-01-01"
subscription.insert()
subscription.process() # generate first invoice
invoices = len(subscription.invoices)
# Generate an invoice for the cancelled period
subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(len(subscription.invoices), invoices)
subscription.process()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(len(subscription.invoices), invoices)
subscription.process()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(len(subscription.invoices), invoices)
settings.cancel_after_grace = default_grace_period_action
settings.save()
subscription.delete()
def test_subscription_restart_and_process(self):
settings = frappe.get_single("Subscription Settings")
@@ -464,7 +468,6 @@ class TestSubscription(unittest.TestCase):
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
@@ -475,18 +478,19 @@ class TestSubscription(unittest.TestCase):
subscription.restart_subscription()
self.assertEqual(subscription.status, "Active")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(len(subscription.invoices), 0)
subscription.process()
self.assertEqual(subscription.status, "Unpaid")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active")
self.assertEqual(len(subscription.invoices), 0)
subscription.process()
self.assertEqual(subscription.status, "Unpaid")
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active")
self.assertEqual(len(subscription.invoices), 0)
settings.cancel_after_grace = default_grace_period_action
settings.save()
subscription.delete()
def test_subscription_unpaid_back_to_active(self):
settings = frappe.get_single("Subscription Settings")
@@ -499,11 +503,8 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
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
self.assertEqual(subscription.status, "Unpaid")
@@ -516,12 +517,12 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active")
# A new invoice is generated
frappe.flags.current_date = subscription.current_invoice_start
subscription.process()
self.assertEqual(subscription.status, "Unpaid")
settings.cancel_after_grace = default_grace_period_action
settings.save()
subscription.delete()
def test_restart_active_subscription(self):
subscription = frappe.new_doc("Subscription")
@@ -532,6 +533,8 @@ class TestSubscription(unittest.TestCase):
self.assertRaises(frappe.ValidationError, subscription.restart_subscription)
subscription.delete()
def test_subscription_invoice_discount_percentage(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -546,6 +549,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(invoice.additional_discount_percentage, 10)
self.assertEqual(invoice.apply_discount_on, "Grand Total")
subscription.delete()
def test_subscription_invoice_discount_amount(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
@@ -560,6 +565,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(invoice.discount_amount, 11)
self.assertEqual(invoice.apply_discount_on, "Grand Total")
subscription.delete()
def test_prepaid_subscriptions(self):
# Create a non pre-billed subscription, processing should not create
# invoices.
@@ -607,6 +614,8 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate
settings.save()
subscription.delete()
def test_subscription_with_follow_calendar_months(self):
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Supplier"
@@ -614,14 +623,14 @@ class TestSubscription(unittest.TestCase):
subscription.generate_invoice_at_period_start = 1
subscription.follow_calendar_months = 1
# select subscription start date as "2018-01-15"
# select subscription start date as '2018-01-15'
subscription.start_date = "2018-01-15"
subscription.end_date = "2018-07-15"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save()
# even though subscription starts at "2018-01-15" and Billing interval is Month and count 3
# First invoice will end at "2018-03-31" instead of "2018-04-14"
# even though subscription starts at '2018-01-15' and Billing interval is Month and count 3
# First invoice will end at '2018-03-31' instead of '2018-04-14'
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
def test_subscription_generate_invoice_past_due(self):
@@ -630,12 +639,11 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1
subscription.generate_new_invoices_past_due_date = 1
# select subscription start date as "2018-01-15"
# 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()
frappe.flags.current_date = "2018-01-01"
# Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed
subscription.process()
@@ -644,8 +652,8 @@ class TestSubscription(unittest.TestCase):
# Now the Subscription is unpaid
# 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
frappe.flags.current_date = "2018-04-01"
# subscription
subscription.process()
self.assertEqual(len(subscription.invoices), 2)
@@ -654,7 +662,7 @@ class TestSubscription(unittest.TestCase):
subscription.party_type = "Supplier"
subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1
# select subscription start date as "2018-01-15"
# 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()
@@ -674,7 +682,7 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Subscription Customer"
subscription.generate_invoice_at_period_start = 1
subscription.company = "_Test Company"
# select subscription start date as "2018-01-15"
# 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()
@@ -684,47 +692,5 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Unpaid")
# Check the currency of the created invoice
currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency")
currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].invoice, "currency")
self.assertEqual(currency, "USD")
def test_subscription_recovery(self):
"""Test if Subscription recovers when start/end date run out of sync with created invoices."""
subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer"
subscription.party = "_Test Subscription Customer"
subscription.company = "_Test Company"
subscription.start_date = "2021-12-01"
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
frappe.flags.current_date = "2021-12-31"
subscription.process()
frappe.flags.current_date = "2022-01-31"
subscription.process()
self.assertEqual(len(subscription.invoices), 2)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
getdate("2021-12-01"),
)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
getdate("2022-01-01"),
)
# recreate most recent invoice
subscription.process()
self.assertEqual(len(subscription.invoices), 2)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
getdate("2021-12-01"),
)
self.assertEqual(
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
getdate("2022-01-01"),
)

View File

@@ -57,17 +57,18 @@ def get_plan_rate(
prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
if prorate:
cost -= plan.cost * get_prorate_factor(start_date, end_date)
prorate_factor = flt(
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
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

@@ -100,14 +100,11 @@ def get_party_tax_withholding_details(inv, tax_withholding_category=None):
tax_details = get_tax_withholding_details(tax_withholding_category, posting_date, inv.company)
if not tax_details:
frappe.msgprint(
_(
"Skipping Tax Withholding Category {0} as there is no associated account set for Company {1} in it."
).format(tax_withholding_category, inv.company)
frappe.throw(
_("Please set associated account in Tax Withholding Category {0} against Company {1}").format(
tax_withholding_category, inv.company
)
)
if inv.doctype == "Purchase Invoice":
return {}, [], {}
return {}
if party_type == "Customer" and not tax_details.cumulative_threshold:
# TCS is only chargeable on sum of invoiced value
@@ -265,20 +262,14 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
if tax_deducted:
net_total = inv.tax_withholding_net_total
if ldc:
limit_consumed = get_limit_consumed(ldc, parties)
if is_valid_certificate(ldc, posting_date, limit_consumed):
tax_amount = get_lower_deduction_amount(
net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details
)
else:
tax_amount = net_total * tax_details.rate / 100
tax_amount = get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total)
else:
tax_amount = net_total * tax_details.rate / 100
tax_amount = net_total * tax_details.rate / 100 if net_total > 0 else 0
# once tds is deducted, not need to add vouchers in the invoice
voucher_wise_amount = {}
else:
tax_amount = get_tds_amount(ldc, parties, inv, tax_details, vouchers)
tax_amount = get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers)
elif party_type == "Customer":
if tax_deducted:
@@ -425,7 +416,7 @@ def get_deducted_tax(taxable_vouchers, tax_details):
return sum(entries)
def get_tds_amount(ldc, parties, inv, tax_details, vouchers):
def get_tds_amount(ldc, parties, inv, tax_details, tax_deducted, vouchers):
tds_amount = 0
invoice_filters = {"name": ("in", vouchers), "docstatus": 1, "apply_tds": 1}
@@ -485,12 +476,7 @@ def get_tds_amount(ldc, parties, inv, tax_details, vouchers):
threshold = tax_details.get("threshold", 0)
cumulative_threshold = tax_details.get("cumulative_threshold", 0)
if inv.doctype != "Payment Entry":
tax_withholding_net_total = inv.base_tax_withholding_net_total
else:
tax_withholding_net_total = inv.tax_withholding_net_total
if (threshold and tax_withholding_net_total >= threshold) or (
if (threshold and inv.tax_withholding_net_total >= threshold) or (
cumulative_threshold and supp_credit_amt >= cumulative_threshold
):
if (cumulative_threshold and supp_credit_amt >= cumulative_threshold) and cint(
@@ -505,10 +491,15 @@ def get_tds_amount(ldc, parties, inv, tax_details, vouchers):
net_total += inv.tax_withholding_net_total
supp_credit_amt = net_total - cumulative_threshold
if ldc and is_valid_certificate(ldc, inv.get("posting_date") or inv.get("transaction_date"), 0):
tds_amount = get_lower_deduction_amount(
supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details
)
if ldc and is_valid_certificate(
ldc.valid_from,
ldc.valid_upto,
inv.get("posting_date") or inv.get("transaction_date"),
tax_deducted,
inv.tax_withholding_net_total,
ldc.certificate_limit,
):
tds_amount = get_ltds_amount(supp_credit_amt, 0, ldc.certificate_limit, ldc.rate, tax_details)
else:
tds_amount = supp_credit_amt * tax_details.rate / 100 if supp_credit_amt > 0 else 0
@@ -586,7 +577,8 @@ def get_invoice_total_without_tcs(inv, tax_details):
return inv.grand_total - tcs_tax_row_amount
def get_limit_consumed(ldc, parties):
def get_tds_amount_from_ldc(ldc, parties, tax_details, posting_date, net_total):
tds_amount = 0
limit_consumed = frappe.db.get_value(
"Purchase Invoice",
{
@@ -600,29 +592,37 @@ def get_limit_consumed(ldc, parties):
"sum(tax_withholding_net_total)",
)
return limit_consumed
if is_valid_certificate(
ldc.valid_from, ldc.valid_upto, posting_date, limit_consumed, net_total, ldc.certificate_limit
):
tds_amount = get_ltds_amount(
net_total, limit_consumed, ldc.certificate_limit, ldc.rate, tax_details
)
return tds_amount
def get_lower_deduction_amount(
current_amount, limit_consumed, certificate_limit, rate, tax_details
):
if certificate_limit - flt(limit_consumed) - flt(current_amount) >= 0:
def get_ltds_amount(current_amount, deducted_amount, certificate_limit, rate, tax_details):
if certificate_limit - flt(deducted_amount) - flt(current_amount) >= 0:
return current_amount * rate / 100
else:
ltds_amount = certificate_limit - flt(limit_consumed)
ltds_amount = certificate_limit - flt(deducted_amount)
tds_amount = current_amount - ltds_amount
return ltds_amount * rate / 100 + tds_amount * tax_details.rate / 100
def is_valid_certificate(ldc, posting_date, limit_consumed):
available_amount = flt(ldc.certificate_limit) - flt(limit_consumed)
if (
getdate(ldc.valid_from) <= getdate(posting_date) <= getdate(ldc.valid_upto)
) and available_amount > 0:
return True
def is_valid_certificate(
valid_from, valid_upto, posting_date, deducted_amount, current_amount, certificate_limit
):
valid = False
return False
available_amount = flt(certificate_limit) - flt(deducted_amount)
if (getdate(valid_from) <= getdate(posting_date) <= getdate(valid_upto)) and available_amount > 0:
valid = True
return valid
def normal_round(number):

View File

@@ -4,7 +4,6 @@
import unittest
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.utils import today
from erpnext.accounts.utils import get_fiscal_year
@@ -18,7 +17,6 @@ class TestTaxWithholdingCategory(unittest.TestCase):
# create relevant supplier, etc
create_records()
create_tax_withholding_category_records()
make_pan_no_field()
def tearDown(self):
cancel_invoices()
@@ -318,42 +316,6 @@ class TestTaxWithholdingCategory(unittest.TestCase):
for d in reversed(orders):
d.cancel()
def test_tds_deduction_for_po_via_payment_entry(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
frappe.db.set_value(
"Supplier", "Test TDS Supplier8", "tax_withholding_category", "Cumulative Threshold TDS"
)
order = create_purchase_order(supplier="Test TDS Supplier8", rate=40000, do_not_save=True)
# Add some tax on the order
order.append(
"taxes",
{
"category": "Total",
"charge_type": "Actual",
"account_head": "_Test Account VAT - _TC",
"cost_center": "Main - _TC",
"tax_amount": 8000,
"description": "Test",
"add_deduct_tax": "Add",
},
)
order.save()
order.apply_tds = 1
order.tax_withholding_category = "Cumulative Threshold TDS"
order.submit()
self.assertEqual(order.taxes[0].tax_amount, 4000)
payment = get_payment_entry(order.doctype, order.name)
payment.apply_tax_withholding_amount = 1
payment.tax_withholding_category = "Cumulative Threshold TDS"
payment.submit()
self.assertEqual(payment.taxes[0].tax_amount, 4000)
def test_multi_category_single_supplier(self):
frappe.db.set_value(
"Supplier", "Test TDS Supplier5", "tax_withholding_category", "Test Service Category"
@@ -453,40 +415,6 @@ class TestTaxWithholdingCategory(unittest.TestCase):
pe2.cancel()
pe3.cancel()
def test_lower_deduction_certificate_application(self):
frappe.db.set_value(
"Supplier",
"Test LDC Supplier",
{
"tax_withholding_category": "Test Service Category",
"pan": "ABCTY1234D",
},
)
create_lower_deduction_certificate(
supplier="Test LDC Supplier",
certificate_no="1AE0423AAJ",
tax_withholding_category="Test Service Category",
tax_rate=2,
limit=50000,
)
pi1 = create_purchase_invoice(supplier="Test LDC Supplier", rate=35000)
pi1.submit()
self.assertEqual(pi1.taxes[0].tax_amount, 700)
pi2 = create_purchase_invoice(supplier="Test LDC Supplier", rate=35000)
pi2.submit()
self.assertEqual(pi2.taxes[0].tax_amount, 2300)
pi3 = create_purchase_invoice(supplier="Test LDC Supplier", rate=35000)
pi3.submit()
self.assertEqual(pi3.taxes[0].tax_amount, 3500)
pi1.cancel()
pi2.cancel()
pi3.cancel()
def cancel_invoices():
purchase_invoices = frappe.get_all(
@@ -645,8 +573,6 @@ def create_records():
"Test TDS Supplier5",
"Test TDS Supplier6",
"Test TDS Supplier7",
"Test TDS Supplier8",
"Test LDC Supplier",
]:
if frappe.db.exists("Supplier", name):
continue
@@ -843,39 +769,3 @@ def create_tax_withholding_category(
"accounts": [{"company": "_Test Company", "account": account}],
}
).insert()
def create_lower_deduction_certificate(
supplier, tax_withholding_category, tax_rate, certificate_no, limit
):
fiscal_year = get_fiscal_year(today(), company="_Test Company")
if not frappe.db.exists("Lower Deduction Certificate", certificate_no):
frappe.get_doc(
{
"doctype": "Lower Deduction Certificate",
"company": "_Test Company",
"supplier": supplier,
"certificate_no": certificate_no,
"tax_withholding_category": tax_withholding_category,
"fiscal_year": fiscal_year[0],
"valid_from": fiscal_year[1],
"valid_upto": fiscal_year[2],
"rate": tax_rate,
"certificate_limit": limit,
}
).insert()
def make_pan_no_field():
pan_field = {
"Supplier": [
{
"fieldname": "pan",
"label": "PAN",
"fieldtype": "Data",
"translatable": 0,
}
]
}
create_custom_fields(pan_field, update=1)

View File

@@ -1,83 +0,0 @@
{
"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

@@ -1,9 +0,0 @@
# 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

@@ -1,316 +0,0 @@
# 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

@@ -1,41 +0,0 @@
// 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

@@ -1,93 +0,0 @@
{
"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

@@ -1,158 +0,0 @@
# 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,10 +539,6 @@ def get_round_off_account_and_cost_center(
"Company", company, ["round_off_account", "round_off_cost_center"]
) 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)
# Give first preference to parent cost center for round off GLE

View File

@@ -14,7 +14,6 @@ from frappe.contacts.doctype.address.address import (
from frappe.contacts.doctype.contact.contact import get_contact_details
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import Abs, Date, Sum
from frappe.utils import (
add_days,
add_months,
@@ -409,7 +408,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:
account = get_party_gle_account(party_type, party, company)
if include_advance and party_type in ["Customer", "Supplier", "Student"]:
if include_advance and party_type in ["Customer", "Supplier"]:
advance_account = get_party_advance_account(party_type, party, company)
if advance_account:
return [account, advance_account]
@@ -707,7 +706,6 @@ def get_payment_terms_template(party_name, party_type, company=None):
if party_type not in ("Customer", "Supplier"):
return
template = None
if party_type == "Customer":
customer = frappe.get_cached_value(
"Customer", party_name, fieldname=["payment_terms", "customer_group"], as_dict=1
@@ -924,32 +922,30 @@ def get_party_shipping_address(doctype: str, name: str) -> Optional[str]:
def get_partywise_advanced_payment_amount(
party_type, posting_date=None, future_payment=0, company=None, party=None
):
ple = frappe.qb.DocType("Payment Ledger Entry")
query = (
frappe.qb.from_(ple)
.select(ple.party, Abs(Sum(ple.amount).as_("amount")))
.where(
(ple.party_type.isin(party_type))
& (ple.amount < 0)
& (ple.against_voucher_no == ple.voucher_no)
& (ple.delinked == 0)
)
.groupby(ple.party)
)
cond = "1=1"
if posting_date:
if future_payment:
query = query.where((ple.posting_date <= posting_date) | (Date(ple.creation) <= posting_date))
cond = "(posting_date <= '{0}' OR DATE(creation) <= '{0}')" "".format(posting_date)
else:
query = query.where(ple.posting_date <= posting_date)
cond = "posting_date <= '{0}'".format(posting_date)
if company:
query = query.where(ple.company == company)
cond += "and company = {0}".format(frappe.db.escape(company))
if party:
query = query.where(ple.party == party)
cond += "and party = {0}".format(frappe.db.escape(party))
data = query.run()
data = frappe.db.sql(
""" SELECT party, sum({0}) as amount
FROM `tabGL Entry`
WHERE
party_type = %s and against_voucher is null
and is_cancelled = 0
and {1} GROUP BY party""".format(
("credit") if party_type == "Customer" else "debit", cond
),
party_type,
)
if data:
return frappe._dict(data)

View File

@@ -37,6 +37,24 @@ 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",
"label": __("Payable Account"),
@@ -94,38 +112,11 @@ frappe.query_reports["Accounts Payable"] = {
"fieldtype": "Link",
"options": "Payment Terms Template"
},
{
"fieldname": "party_type",
"label": __("Party Type"),
"fieldtype": "Link",
"options": "Party Type",
get_query: () => {
return {
filters: {
'account_type': 'Payable'
}
};
},
on_change: () => {
frappe.query_report.set_filter_value('party', "");
let party_type = frappe.query_report.get_filter_value('party_type');
frappe.query_report.toggle_filter_display('supplier_group', frappe.query_report.get_filter_value('party_type') !== "Supplier");
}
},
{
"fieldname":"party",
"label": __("Party"),
"fieldtype": "Dynamic Link",
"options": "party_type",
},
{
"fieldname": "supplier_group",
"label": __("Supplier Group"),
"fieldtype": "Link",
"options": "Supplier Group",
"hidden": 1
"options": "Supplier Group"
},
{
"fieldname": "group_by_party",
@@ -142,6 +133,12 @@ frappe.query_reports["Accounts Payable"] = {
"label": __("Show Remarks"),
"fieldtype": "Check",
},
{
"fieldname": "tax_id",
"label": __("Tax Id"),
"fieldtype": "Data",
"hidden": 1
},
{
"fieldname": "show_future_payments",
"label": __("Show Future Payments"),

View File

@@ -7,7 +7,7 @@ from erpnext.accounts.report.accounts_receivable.accounts_receivable import Rece
def execute(filters=None):
args = {
"account_type": "Payable",
"party_type": "Supplier",
"naming_by": ["Buying Settings", "supp_master_name"],
}
return ReceivablePayableReport(filters).run(args)

View File

@@ -1,67 +0,0 @@
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

@@ -9,7 +9,7 @@ from erpnext.accounts.report.accounts_receivable_summary.accounts_receivable_sum
def execute(filters=None):
args = {
"account_type": "Payable",
"party_type": "Supplier",
"naming_by": ["Buying Settings", "supp_master_name"],
}
return AccountsReceivableSummary(filters).run(args)

View File

@@ -38,31 +38,34 @@ frappe.query_reports["Accounts Receivable"] = {
}
},
{
"fieldname": "party_type",
"label": __("Party Type"),
"fieldname": "customer",
"label": __("Customer"),
"fieldtype": "Link",
"options": "Party Type",
"Default": "Customer",
get_query: () => {
return {
filters: {
'account_type': 'Receivable'
}
};
},
"options": "Customer",
on_change: () => {
frappe.query_report.set_filter_value('party', "");
let party_type = frappe.query_report.get_filter_value('party_type');
frappe.query_report.toggle_filter_display('customer_group', frappe.query_report.get_filter_value('party_type') !== "Customer");
var customer = frappe.query_report.get_filter_value('customer');
var company = frappe.query_report.get_filter_value('company');
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": "Dynamic Link",
"options": "party_type",
},
{
"fieldname": "party_account",
"label": __("Receivable Account"),
@@ -169,10 +172,34 @@ frappe.query_reports["Accounts Receivable"] = {
"label": __("Show Sales Person"),
"fieldtype": "Check",
},
{
"fieldname": "tax_id",
"label": __("Tax Id"),
"fieldtype": "Data",
"hidden": 1
},
{
"fieldname": "show_remarks",
"label": __("Show Remarks"),
"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
}
],

View File

@@ -7,7 +7,7 @@ from collections import OrderedDict
import frappe
from frappe import _, qb, scrub
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Date, Sum
from frappe.query_builder.functions import Date
from frappe.utils import cint, cstr, flt, getdate, nowdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -34,7 +34,7 @@ from erpnext.accounts.utils import get_currency_precision
def execute(filters=None):
args = {
"account_type": "Receivable",
"party_type": "Customer",
"naming_by": ["Selling Settings", "cust_master_name"],
}
return ReceivablePayableReport(filters).run(args)
@@ -70,11 +70,8 @@ class ReceivablePayableReport(object):
"Company", self.filters.get("company"), "default_currency"
)
self.currency_precision = get_currency_precision() or 2
self.dr_or_cr = "debit" if self.filters.account_type == "Receivable" else "credit"
self.account_type = self.filters.account_type
self.party_type = frappe.db.get_all(
"Party Type", {"account_type": self.account_type}, pluck="name"
)
self.dr_or_cr = "debit" if self.filters.party_type == "Customer" else "credit"
self.party_type = self.filters.party_type
self.party_details = {}
self.invoices = set()
self.skip_total_row = 0
@@ -200,7 +197,6 @@ class ReceivablePayableReport(object):
# no invoice, this is an invoice / stand-alone payment / credit note
row = self.voucher_balance.get((ple.voucher_type, ple.voucher_no, ple.party))
row.party_type = ple.party_type
return row
def update_voucher_balance(self, ple):
@@ -211,7 +207,7 @@ class ReceivablePayableReport(object):
return
# amount in "Party Currency", if its supplied. If not, amount in company currency
if self.filters.get("party_type") and self.filters.get("party"):
if self.filters.get(scrub(self.party_type)):
amount = ple.amount_in_account_currency
else:
amount = ple.amount
@@ -366,7 +362,7 @@ class ReceivablePayableReport(object):
def get_invoice_details(self):
self.invoice_details = frappe._dict()
if self.account_type == "Receivable":
if self.party_type == "Customer":
si_list = frappe.db.sql(
"""
select name, due_date, po_no
@@ -394,7 +390,7 @@ class ReceivablePayableReport(object):
d.sales_person
)
if self.account_type == "Payable":
if self.party_type == "Supplier":
for pi in frappe.db.sql(
"""
select name, due_date, bill_no, bill_date
@@ -425,8 +421,7 @@ class ReceivablePayableReport(object):
# customer / supplier name
party_details = self.get_party_details(row.party) or {}
row.update(party_details)
if self.filters.get("party_type") and self.filters.get("party"):
if self.filters.get(scrub(self.filters.party_type)):
row.currency = row.account_currency
else:
row.currency = self.company_currency
@@ -434,11 +429,12 @@ class ReceivablePayableReport(object):
def allocate_outstanding_based_on_payment_terms(self, row):
self.get_payment_terms(row)
for term in row.payment_terms:
# update "paid" and "outstanding" for this term
# update "paid" and "oustanding" for this term
if not term.paid:
self.allocate_closing_to_term(row, term, "paid")
# update "credit_note" and "outstanding" for this term
# update "credit_note" and "oustanding" for this term
if term.outstanding:
self.allocate_closing_to_term(row, term, "credit_note")
@@ -450,8 +446,7 @@ class ReceivablePayableReport(object):
"""
select
si.name, si.party_account_currency, si.currency, si.conversion_rate,
si.total_advance, ps.due_date, ps.payment_term, ps.payment_amount, ps.base_payment_amount,
ps.description, ps.paid_amount, ps.discounted_amount
ps.due_date, ps.payment_term, ps.payment_amount, ps.description, ps.paid_amount, ps.discounted_amount
from `tab{0}` si, `tabPayment Schedule` ps
where
si.name = ps.parent and
@@ -467,14 +462,6 @@ class ReceivablePayableReport(object):
original_row = frappe._dict(row)
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
# Deduct that from paid amount pre allocation
row.paid -= flt(payment_terms_details[0].total_advance)
# If no or single payment terms, no need to split the row
if len(payment_terms_details) <= 1:
return
@@ -489,7 +476,7 @@ class ReceivablePayableReport(object):
) and d.currency == d.party_account_currency:
invoiced = d.payment_amount
else:
invoiced = d.base_payment_amount
invoiced = flt(flt(d.payment_amount) * flt(d.conversion_rate), self.currency_precision)
row.payment_terms.append(
term.update(
@@ -545,66 +532,64 @@ class ReceivablePayableReport(object):
self.future_payments.setdefault((d.invoice_no, d.party), []).append(d)
def get_future_payments_from_payment_entry(self):
pe = frappe.qb.DocType("Payment Entry")
pe_ref = frappe.qb.DocType("Payment Entry Reference")
return (
frappe.qb.from_(pe)
.inner_join(pe_ref)
.on(pe_ref.parent == pe.name)
.select(
(pe_ref.reference_name).as_("invoice_no"),
pe.party,
pe.party_type,
(pe.posting_date).as_("future_date"),
(pe_ref.allocated_amount).as_("future_amount"),
(pe.reference_no).as_("future_ref"),
)
.where(
(pe.docstatus < 2)
& (pe.posting_date > self.filters.report_date)
& (pe.party_type.isin(self.party_type))
)
).run(as_dict=True)
def get_future_payments_from_journal_entry(self):
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
query = (
frappe.qb.from_(je)
.inner_join(jea)
.on(jea.parent == je.name)
.select(
jea.reference_name.as_("invoice_no"),
jea.party,
jea.party_type,
je.posting_date.as_("future_date"),
je.cheque_no.as_("future_ref"),
)
.where(
(je.docstatus < 2)
& (je.posting_date > self.filters.report_date)
& (jea.party_type.isin(self.party_type))
& (jea.reference_name.isnotnull())
& (jea.reference_name != "")
)
return frappe.db.sql(
"""
select
ref.reference_name as invoice_no,
payment_entry.party,
payment_entry.party_type,
payment_entry.posting_date as future_date,
ref.allocated_amount as future_amount,
payment_entry.reference_no as future_ref
from
`tabPayment Entry` as payment_entry inner join `tabPayment Entry Reference` as ref
on
(ref.parent = payment_entry.name)
where
payment_entry.docstatus < 2
and payment_entry.posting_date > %s
and payment_entry.party_type = %s
""",
(self.filters.report_date, self.party_type),
as_dict=1,
)
def get_future_payments_from_journal_entry(self):
if self.filters.get("party"):
if self.account_type == "Payable":
query = query.select(
Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("future_amount")
)
else:
query = query.select(
Sum(jea.credit_in_account_currency - jea.debit_in_account_currency).as_("future_amount")
)
else:
query = query.select(
Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_("future_amount")
amount_field = (
"jea.debit_in_account_currency - jea.credit_in_account_currency"
if self.party_type == "Supplier"
else "jea.credit_in_account_currency - jea.debit_in_account_currency"
)
else:
amount_field = "jea.debit - " if self.party_type == "Supplier" else "jea.credit"
query = query.having(qb.Field("future_amount") > 0)
return query.run(as_dict=True)
return frappe.db.sql(
"""
select
jea.reference_name as invoice_no,
jea.party,
jea.party_type,
je.posting_date as future_date,
sum('{0}') as future_amount,
je.cheque_no as future_ref
from
`tabJournal Entry` as je inner join `tabJournal Entry Account` as jea
on
(jea.parent = je.name)
where
je.docstatus < 2
and je.posting_date > %s
and jea.party_type = %s
and jea.reference_name is not null and jea.reference_name != ''
group by je.name, jea.reference_name
having future_amount > 0
""".format(
amount_field
),
(self.filters.report_date, self.party_type),
as_dict=1,
)
def allocate_future_payments(self, row):
# future payments are captured in additional columns
@@ -634,17 +619,13 @@ class ReceivablePayableReport(object):
row.future_ref = ", ".join(row.future_ref)
def get_return_entries(self):
doctype = "Sales Invoice" if self.account_type == "Receivable" else "Purchase Invoice"
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
filters = {"is_return": 1, "docstatus": 1, "company": self.filters.company}
or_filters = {}
for party_type in self.party_type:
party_field = scrub(party_type)
if self.filters.get(party_field):
or_filters.update({party_field: self.filters.get(party_field)})
party_field = scrub(self.filters.party_type)
if self.filters.get(party_field):
filters.update({party_field: self.filters.get(party_field)})
self.return_entries = frappe._dict(
frappe.get_all(
doctype, filters=filters, or_filters=or_filters, fields=["name", "return_against"], as_list=1
)
frappe.get_all(doctype, filters, ["name", "return_against"], as_list=1)
)
def set_ageing(self, row):
@@ -735,7 +716,6 @@ class ReceivablePayableReport(object):
)
.where(ple.delinked == 0)
.where(Criterion.all(self.qb_selection_filter))
.where(Criterion.any(self.or_filters))
)
if self.filters.get("group_by_party"):
@@ -766,16 +746,16 @@ class ReceivablePayableReport(object):
def prepare_conditions(self):
self.qb_selection_filter = []
self.or_filters = []
party_type_field = scrub(self.party_type)
self.qb_selection_filter.append(self.ple.party_type == self.party_type)
for party_type in self.party_type:
self.add_common_filters()
self.add_common_filters(party_type_field=party_type_field)
if self.account_type == "Receivable":
self.add_customer_filters()
if party_type_field == "customer":
self.add_customer_filters()
elif self.account_type == "Payable":
self.add_supplier_filters()
elif party_type_field == "supplier":
self.add_supplier_filters()
if self.filters.cost_center:
self.get_cost_center_conditions()
@@ -790,27 +770,25 @@ class ReceivablePayableReport(object):
]
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))
def add_common_filters(self):
def add_common_filters(self, party_type_field):
if self.filters.company:
self.qb_selection_filter.append(self.ple.company == self.filters.company)
if self.filters.finance_book:
self.qb_selection_filter.append(self.ple.finance_book == self.filters.finance_book)
if self.filters.get("party_type"):
self.qb_selection_filter.append(self.filters.party_type == self.ple.party_type)
if self.filters.get("party"):
self.qb_selection_filter.append(self.filters.party == self.ple.party)
if self.filters.get(party_type_field):
self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field))
if self.filters.party_account:
self.qb_selection_filter.append(self.ple.account == self.filters.party_account)
else:
# get GL with "receivable" or "payable" account_type
account_type = "Receivable" if self.party_type == "Customer" else "Payable"
accounts = [
d.name
for d in frappe.get_all(
"Account", filters={"account_type": self.account_type, "company": self.filters.company}
"Account", filters={"account_type": account_type, "company": self.filters.company}
)
]
@@ -900,7 +878,7 @@ class ReceivablePayableReport(object):
def get_party_details(self, party):
if not party in self.party_details:
if self.account_type == "Receivable":
if self.party_type == "Customer":
fields = ["customer_name", "territory", "customer_group", "customer_primary_contact"]
if self.filters.get("sales_partner"):
@@ -923,20 +901,14 @@ class ReceivablePayableReport(object):
self.columns = []
self.add_column("Posting Date", fieldtype="Date")
self.add_column(
label="Party Type",
fieldname="party_type",
fieldtype="Data",
width=100,
)
self.add_column(
label="Party",
label=_(self.party_type),
fieldname="party",
fieldtype="Dynamic Link",
options="party_type",
fieldtype="Link",
options=self.party_type,
width=180,
)
self.add_column(
label=self.account_type + " Account",
label="Receivable Account" if self.party_type == "Customer" else "Payable Account",
fieldname="party_account",
fieldtype="Link",
options="Account",
@@ -944,39 +916,19 @@ class ReceivablePayableReport(object):
)
if self.party_naming_by == "Naming Series":
if self.account_type == "Payable":
label = "Supplier Name"
fieldname = "supplier_name"
else:
label = "Customer Name"
fieldname = "customer_name"
self.add_column(
label=label,
fieldname=fieldname,
_("{0} Name").format(self.party_type),
fieldname=scrub(self.party_type) + "_name",
fieldtype="Data",
)
if self.account_type == "Receivable":
if self.party_type == "Customer":
self.add_column(
_("Customer Contact"),
fieldname="customer_primary_contact",
fieldtype="Link",
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=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data")
@@ -990,7 +942,7 @@ class ReceivablePayableReport(object):
self.add_column(label="Due Date", fieldtype="Date")
if self.account_type == "Payable":
if self.party_type == "Supplier":
self.add_column(label=_("Bill No"), fieldname="bill_no", fieldtype="Data")
self.add_column(label=_("Bill Date"), fieldname="bill_date", fieldtype="Date")
@@ -1000,7 +952,7 @@ class ReceivablePayableReport(object):
self.add_column(_("Invoiced Amount"), fieldname="invoiced")
self.add_column(_("Paid Amount"), fieldname="paid")
if self.account_type == "Receivable":
if self.party_type == "Customer":
self.add_column(_("Credit Note"), fieldname="credit_note")
else:
# note: fieldname is still `credit_note`
@@ -1018,7 +970,7 @@ class ReceivablePayableReport(object):
self.add_column(label=_("Future Payment Amount"), fieldname="future_amount")
self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance")
if self.filters.account_type == "Receivable":
if self.filters.party_type == "Customer":
self.add_column(label=_("Customer LPO"), fieldname="po_no", fieldtype="Data")
# comma separated list of linked delivery notes
@@ -1039,7 +991,7 @@ class ReceivablePayableReport(object):
if self.filters.sales_partner:
self.add_column(label=_("Sales Partner"), fieldname="default_sales_partner", fieldtype="Data")
if self.filters.account_type == "Payable":
if self.filters.party_type == "Supplier":
self.add_column(
label=_("Supplier Group"),
fieldname="supplier_group",
@@ -1107,10 +1059,7 @@ class ReceivablePayableReport(object):
.where(
(je.company == self.filters.company)
& (je.posting_date.lte(self.filters.report_date))
& (
(je.voucher_type == "Exchange Rate Revaluation")
| (je.voucher_type == "Exchange Gain Or Loss")
)
& (je.voucher_type == "Exchange Rate Revaluation")
)
.run()
)

View File

@@ -8,17 +8,20 @@ from erpnext import get_default_cost_center
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.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
class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
class TestAccountsReceivable(FrappeTestCase):
def setUp(self):
self.create_company()
self.create_customer()
self.create_item()
self.create_usd_receivable_account()
self.clear_old_entries()
frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 2'")
frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'")
frappe.db.sql("delete from `tabPayment Entry` where company='_Test Company 2'")
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 2'")
frappe.db.sql("delete from `tabPayment Ledger Entry` where company='_Test Company 2'")
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):
frappe.db.rollback()
@@ -46,84 +49,29 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
debtors_usd.account_type = debtors.account_type
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):
filters = {
"company": self.company,
"company": "_Test Company 2",
"based_on_payment_terms": 1,
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
"show_remarks": True,
}
# check invoice grand total and invoiced column's value for 3 payment terms
si = self.create_sales_invoice()
name = si.name
name = make_sales_invoice().name
report = execute(filters)
expected_data = [[100, 30, "No Remarks"], [100, 50, "No Remarks"], [100, 20, "No Remarks"]]
expected_data = [[100, 30], [100, 50], [100, 20]]
for i in range(3):
row = report[1][i - 1]
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced, row.remarks])
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced])
# check invoice grand total, invoiced, paid and outstanding column's value after payment
self.create_payment_entry(si.name)
make_payment(name)
report = execute(filters)
expected_data_after_payment = [[100, 50, 10, 40], [100, 20, 0, 20]]
@@ -136,10 +84,10 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
)
# check invoice grand total, invoiced, paid and outstanding column's value after credit note
self.create_credit_note(si.name)
make_credit_note(name)
report = execute(filters)
expected_data_after_credit_note = [100, 0, 0, 40, -40, self.debit_to]
expected_data_after_credit_note = [100, 0, 0, 40, -40, "Debtors - _TC2"]
row = report[1][0]
self.assertEqual(
@@ -160,20 +108,21 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"""
so = make_sales_order(
company=self.company,
customer=self.customer,
warehouse=self.warehouse,
debit_to=self.debit_to,
income_account=self.income_account,
expense_account=self.expense_account,
cost_center=self.cost_center,
company="_Test Company 2",
customer="_Test Customer 2",
warehouse="Finished Goods - _TC2",
currency="EUR",
debit_to="Debtors - _TC2",
income_account="Sales - _TC2",
expense_account="Cost of Goods Sold - _TC2",
cost_center="Main - _TC2",
)
pe = get_payment_entry(so.doctype, so.name)
pe = pe.save().submit()
filters = {
"company": self.company,
"company": "_Test Company 2",
"based_on_payment_terms": 0,
"report_date": today(),
"range1": 30,
@@ -198,32 +147,34 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
)
@change_settings(
"Accounts Settings",
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
def test_exchange_revaluation_for_party(self):
"""
Exchange Revaluation for party on Receivable/Payable should be included
Exchange Revaluation for party on Receivable/Payable shoule be included
"""
company = "_Test Company 2"
customer = "_Test Customer 2"
# Using Exchange Gain/Loss account for unrealized as well.
company_doc = frappe.get_doc("Company", self.company)
company_doc = frappe.get_doc("Company", company)
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
company_doc.save()
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si = make_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.currency = "USD"
si.conversion_rate = 80
si.conversion_rate = 0.90
si.debit_to = self.debtors_usd
si = si.save().submit()
# Exchange Revaluation
err = frappe.new_doc("Exchange Rate Revaluation")
err.company = self.company
err.company = company
err.posting_date = today()
accounts = err.get_accounts_data()
err.extend("accounts", accounts)
err.accounts[0].new_exchange_rate = 85
err.accounts[0].new_exchange_rate = 0.95
row = err.accounts[0]
row.new_balance_in_base_currency = flt(
row.new_exchange_rate * flt(row.balance_in_account_currency)
@@ -238,7 +189,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
je = je.submit()
filters = {
"company": self.company,
"company": company,
"report_date": today(),
"range1": 30,
"range2": 60,
@@ -247,7 +198,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
}
report = execute(filters)
expected_data_for_err = [0, -500, 0, 500]
expected_data_for_err = [0, -5, 0, 5]
row = [x for x in report[1] if x.voucher_type == je.doctype and x.voucher_no == je.name][0]
self.assertEqual(
expected_data_for_err,
@@ -263,43 +214,46 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
"""
Payment against credit/debit note should be considered against the parent invoice
"""
company = "_Test Company 2"
customer = "_Test Customer 2"
si1 = self.create_sales_invoice()
si1 = make_sales_invoice()
pe = get_payment_entry(si1.doctype, si1.name, bank_account=self.cash)
pe.paid_from = self.debit_to
pe = get_payment_entry("Sales Invoice", si1.name, bank_account="Cash - _TC2")
pe.paid_from = "Debtors - _TC2"
pe.insert()
pe.submit()
cr_note = self.create_credit_note(si1.name)
cr_note = make_credit_note(si1.name)
si2 = self.create_sales_invoice()
si2 = make_sales_invoice()
# manually link cr_note with si2 using journal entry
je = frappe.new_doc("Journal Entry")
je.company = self.company
je.company = company
je.voucher_type = "Credit Note"
je.posting_date = today()
debit_account = "Debtors - _TC2"
debit_entry = {
"account": self.debit_to,
"account": debit_account,
"party_type": "Customer",
"party": self.customer,
"party": customer,
"debit": 100,
"debit_in_account_currency": 100,
"reference_type": cr_note.doctype,
"reference_name": cr_note.name,
"cost_center": self.cost_center,
"cost_center": "Main - _TC2",
}
credit_entry = {
"account": self.debit_to,
"account": debit_account,
"party_type": "Customer",
"party": self.customer,
"party": customer,
"credit": 100,
"credit_in_account_currency": 100,
"reference_type": si2.doctype,
"reference_name": si2.name,
"cost_center": self.cost_center,
"cost_center": "Main - _TC2",
}
je.append("accounts", debit_entry)
@@ -307,7 +261,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
je = je.save().submit()
filters = {
"company": self.company,
"company": company,
"report_date": today(),
"range1": 30,
"range2": 60,
@@ -317,291 +271,64 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
report = execute(filters)
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()
filters = {
"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)
def make_sales_invoice(no_payment_schedule=False, do_not_submit=False):
frappe.set_user("Administrator")
# assert voucher rows
expected_voucher_rows = [
[100.0, 100.0, 100.0, 100.0],
[85.0, 85.0, 85.0, 85.0],
]
voucher_rows = []
for x in report[0:2]:
voucher_rows.append(
[x.invoiced, x.outstanding, x.invoiced_in_account_currency, x.outstanding_in_account_currency]
)
self.assertEqual(expected_voucher_rows, voucher_rows)
si = create_sales_invoice(
company="_Test Company 2",
customer="_Test Customer 2",
currency="EUR",
warehouse="Finished Goods - _TC2",
debit_to="Debtors - _TC2",
income_account="Sales - _TC2",
expense_account="Cost of Goods Sold - _TC2",
cost_center="Main - _TC2",
do_not_save=1,
)
# assert total rows
expected_total_rows = [
[self.customer, 185.0, 185.0], # party total
{}, # 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"),
],
if not no_payment_schedule:
si.append(
"payment_schedule",
dict(due_date=getdate(add_days(today(), 30)), invoice_portion=30.00, payment_amount=30),
)
empty_row = report[3]
self.assertEqual(expected_total_rows[1], empty_row)
grand_total_row = report[4]
self.assertEqual(
expected_total_rows[2],
[
grand_total_row.get("party"),
grand_total_row.get("invoiced"),
grand_total_row.get("outstanding"),
],
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),
)
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)
si = si.save()
expected_data = [100.0, 100.0, 10.0, 90.0]
if not do_not_submit:
si = si.submit()
row = report[0]
self.assertEqual(
expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount]
)
return si
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]
)
pe.cancel()
# over payment in future date
pe = get_payment_entry(si.doctype, si.name)
pe.posting_date = add_days(today(), 1)
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 make_payment(docname):
pe = get_payment_entry("Sales Invoice", docname, bank_account="Cash - _TC2", party_amount=40)
pe.paid_from = "Debtors - _TC2"
pe.insert()
pe.submit()
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()
filters = {
"company": self.company,
"report_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
"sales_person": sales_person.name,
"show_sales_person": True,
}
report = execute(filters)[1]
self.assertEqual(len(report), 1)
def make_credit_note(docname):
credit_note = create_sales_invoice(
company="_Test Company 2",
customer="_Test Customer 2",
currency="EUR",
qty=-1,
warehouse="Finished Goods - _TC2",
debit_to="Debtors - _TC2",
income_account="Sales - _TC2",
expense_account="Cost of Goods Sold - _TC2",
cost_center="Main - _TC2",
is_return=1,
return_against=docname,
)
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))
return credit_note

View File

@@ -12,7 +12,7 @@ from erpnext.accounts.report.accounts_receivable.accounts_receivable import Rece
def execute(filters=None):
args = {
"account_type": "Receivable",
"party_type": "Customer",
"naming_by": ["Selling Settings", "cust_master_name"],
}
@@ -21,10 +21,7 @@ def execute(filters=None):
class AccountsReceivableSummary(ReceivablePayableReport):
def run(self, args):
self.account_type = args.get("account_type")
self.party_type = frappe.db.get_all(
"Party Type", {"account_type": self.account_type}, pluck="name"
)
self.party_type = args.get("party_type")
self.party_naming_by = frappe.db.get_value(
args.get("naming_by")[0], None, args.get("naming_by")[1]
)
@@ -38,24 +35,19 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.get_party_total(args)
party = None
for party_type in self.party_type:
if self.filters.get(scrub(party_type)):
party = self.filters.get(scrub(party_type))
party_advance_amount = (
get_partywise_advanced_payment_amount(
self.party_type,
self.filters.report_date,
self.filters.show_future_payments,
self.filters.company,
party=party,
party=self.filters.get(scrub(self.party_type)),
)
or {}
)
if self.filters.show_gl_balance:
gl_balance_map = get_gl_balance(self.filters.report_date, self.filters.company)
gl_balance_map = get_gl_balance(self.filters.report_date)
for party, party_dict in self.party_total.items():
if party_dict.outstanding == 0:
@@ -65,13 +57,9 @@ class AccountsReceivableSummary(ReceivablePayableReport):
row.party = party
if self.party_naming_by == "Naming Series":
if self.account_type == "Payable":
doctype = "Supplier"
fieldname = "supplier_name"
else:
doctype = "Customer"
fieldname = "customer_name"
row.party_name = frappe.get_cached_value(doctype, party, fieldname)
row.party_name = frappe.get_cached_value(
self.party_type, party, scrub(self.party_type) + "_name"
)
row.update(party_dict)
@@ -105,7 +93,6 @@ class AccountsReceivableSummary(ReceivablePayableReport):
# set territory, customer_group, sales person etc
self.set_party_details(d)
self.party_total[d.party].update({"party_type": d.party_type})
def init_party_total(self, row):
self.party_total.setdefault(
@@ -144,27 +131,17 @@ class AccountsReceivableSummary(ReceivablePayableReport):
def get_columns(self):
self.columns = []
self.add_column(
label=_("Party Type"),
fieldname="party_type",
fieldtype="Data",
width=100,
)
self.add_column(
label=_("Party"),
label=_(self.party_type),
fieldname="party",
fieldtype="Dynamic Link",
options="party_type",
fieldtype="Link",
options=self.party_type,
width=180,
)
if self.party_naming_by == "Naming Series":
self.add_column(
label=_("Supplier Name") if self.account_type == "Payable" else _("Customer Name"),
fieldname="party_name",
fieldtype="Data",
)
self.add_column(_("{0} Name").format(self.party_type), fieldname="party_name", fieldtype="Data")
credit_debit_label = "Credit Note" if self.account_type == "Receivable" else "Debit Note"
credit_debit_label = "Credit Note" if self.party_type == "Customer" else "Debit Note"
self.add_column(_("Advance Amount"), fieldname="advance")
self.add_column(_("Invoiced Amount"), fieldname="invoiced")
@@ -182,7 +159,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.add_column(label=_("Future Payment Amount"), fieldname="future_amount")
self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance")
if self.account_type == "Receivable":
if self.party_type == "Customer":
self.add_column(
label=_("Territory"), fieldname="territory", fieldtype="Link", options="Territory"
)
@@ -232,12 +209,12 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.add_column(label="Total Amount Due", fieldname="total_due")
def get_gl_balance(report_date, company):
def get_gl_balance(report_date):
return frappe._dict(
frappe.db.get_all(
"GL Entry",
fields=["party", "sum(debit - credit)"],
filters={"posting_date": ("<=", report_date), "is_cancelled": 0, "company": company},
filters={"posting_date": ("<=", report_date), "is_cancelled": 0},
group_by="party",
as_list=1,
)

View File

@@ -1,203 +0,0 @@
import unittest
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import today
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.report.accounts_receivable_summary.accounts_receivable_summary import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
def setUp(self):
self.maxDiff = None
self.create_company()
self.create_customer()
self.create_item()
self.clear_old_entries()
def tearDown(self):
frappe.db.rollback()
def test_01_receivable_summary_output(self):
"""
Test for Invoices, Paid, Advance and Outstanding
"""
filters = {
"company": self.company,
"customer": self.customer,
"posting_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
}
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=200,
price_list_rate=200,
)
customer_group, customer_territory = frappe.db.get_all(
"Customer",
filters={"name": self.customer},
fields=["customer_group", "territory"],
as_list=True,
)[0]
report = execute(filters)
rpt_output = report[1]
expected_data = {
"party_type": "Customer",
"advance": 0,
"party": self.customer,
"invoiced": 200.0,
"paid": 0.0,
"credit_note": 0.0,
"outstanding": 200.0,
"range1": 200.0,
"range2": 0.0,
"range3": 0.0,
"range4": 0.0,
"range5": 0.0,
"total_due": 200.0,
"future_amount": 0.0,
"sales_person": [],
"currency": si.currency,
"territory": customer_territory,
"customer_group": customer_group,
}
self.assertEqual(len(rpt_output), 1)
self.assertDictEqual(rpt_output[0], expected_data)
# simulate advance payment
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = 50
pe.references[0].allocated_amount = 0 # this essitially removes the reference
pe.save().submit()
# update expected data with advance
expected_data.update(
{
"advance": 50.0,
"outstanding": 150.0,
"range1": 150.0,
"total_due": 150.0,
}
)
report = execute(filters)
rpt_output = report[1]
self.assertEqual(len(rpt_output), 1)
self.assertDictEqual(rpt_output[0], expected_data)
# make partial payment
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = 125
pe.references[0].allocated_amount = 125
pe.save().submit()
# update expected data after advance and partial payment
expected_data.update(
{"advance": 50.0, "paid": 125.0, "outstanding": 25.0, "range1": 25.0, "total_due": 25.0}
)
report = execute(filters)
rpt_output = report[1]
self.assertEqual(len(rpt_output), 1)
self.assertDictEqual(rpt_output[0], expected_data)
@change_settings("Selling Settings", {"cust_master_name": "Naming Series"})
def test_02_various_filters_and_output(self):
filters = {
"company": self.company,
"customer": self.customer,
"posting_date": today(),
"range1": 30,
"range2": 60,
"range3": 90,
"range4": 120,
}
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=200,
price_list_rate=200,
)
# make partial payment
pe = get_payment_entry(si.doctype, si.name)
pe.paid_amount = 150
pe.references[0].allocated_amount = 150
pe.save().submit()
customer_group, customer_territory = frappe.db.get_all(
"Customer",
filters={"name": self.customer},
fields=["customer_group", "territory"],
as_list=True,
)[0]
report = execute(filters)
rpt_output = report[1]
expected_data = {
"party_type": "Customer",
"advance": 0,
"party": self.customer,
"party_name": self.customer,
"invoiced": 200.0,
"paid": 150.0,
"credit_note": 0.0,
"outstanding": 50.0,
"range1": 50.0,
"range2": 0.0,
"range3": 0.0,
"range4": 0.0,
"range5": 0.0,
"total_due": 50.0,
"future_amount": 0.0,
"sales_person": [],
"currency": si.currency,
"territory": customer_territory,
"customer_group": customer_group,
}
self.assertEqual(len(rpt_output), 1)
self.assertDictEqual(rpt_output[0], expected_data)
# with gl balance filter
filters.update({"show_gl_balance": True})
expected_data.update({"gl_balance": 50.0, "diff": 0.0})
report = execute(filters)
rpt_output = report[1]
self.assertEqual(len(rpt_output), 1)
self.assertDictEqual(rpt_output[0], expected_data)
# with gl balance and future payments filter
filters.update({"show_future_payments": True})
expected_data.update({"remaining_balance": 50.0})
report = execute(filters)
rpt_output = report[1]
self.assertEqual(len(rpt_output), 1)
self.assertDictEqual(rpt_output[0], expected_data)
# invoice fully paid
pe = get_payment_entry(si.doctype, si.name).save().submit()
report = execute(filters)
rpt_output = report[1]
self.assertEqual(len(rpt_output), 0)

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import today
from erpnext.accounts.report.balance_sheet.balance_sheet import execute
class TestBalanceSheet(FrappeTestCase):
def test_balance_sheet(self):
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,
make_sales_invoice,
)
from erpnext.accounts.utils import get_fiscal_year
frappe.db.sql("delete from `tabPurchase Invoice` where company='_Test Company 6'")
frappe.db.sql("delete from `tabSales Invoice` where company='_Test Company 6'")
frappe.db.sql("delete from `tabGL Entry` where company='_Test Company 6'")
pi = make_purchase_invoice(
company="_Test Company 6",
warehouse="Finished Goods - _TC6",
expense_account="Cost of Goods Sold - _TC6",
cost_center="Main - _TC6",
qty=10,
rate=100,
)
si = create_sales_invoice(
company="_Test Company 6",
debit_to="Debtors - _TC6",
income_account="Sales - _TC6",
cost_center="Main - _TC6",
qty=5,
rate=110,
)
filters = frappe._dict(
company="_Test Company 6",
period_start_date=today(),
period_end_date=today(),
periodicity="Yearly",
)
result = execute(filters)[1]
for account_dict in result:
if account_dict.get("account") == "Current Liabilities - _TC6":
self.assertEqual(account_dict.total, 1000)
if account_dict.get("account") == "Current Assets - _TC6":
self.assertEqual(account_dict.total, 550)

View File

@@ -6,7 +6,6 @@ from collections import defaultdict
import frappe
from frappe import _
from frappe.query_builder import Criterion
from frappe.utils import flt, getdate
import erpnext
@@ -360,7 +359,6 @@ def get_data(
accounts_by_name,
accounts,
ignore_closing_entries=False,
root_type=root_type,
)
calculate_values(accounts_by_name, gl_entries_by_account, companies, filters, fiscal_year)
@@ -605,7 +603,6 @@ def set_gl_entries_by_account(
accounts_by_name,
accounts,
ignore_closing_entries=False,
root_type=None,
):
"""Returns a dict like { "account": [gl entries], ... }"""
@@ -613,6 +610,7 @@ def set_gl_entries_by_account(
"Company", filters.get("company"), ["lft", "rgt"]
)
additional_conditions = get_additional_conditions(from_date, ignore_closing_entries, filters)
companies = frappe.db.sql(
""" select name, default_currency from `tabCompany`
where lft >= %(company_lft)s and rgt <= %(company_rgt)s""",
@@ -628,43 +626,27 @@ def set_gl_entries_by_account(
)
for d in companies:
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
query = (
frappe.qb.from_(gle)
.inner_join(account)
.on(account.name == gle.account)
.select(
gle.posting_date,
gle.account,
gle.debit,
gle.credit,
gle.is_opening,
gle.company,
gle.fiscal_year,
gle.debit_in_account_currency,
gle.credit_in_account_currency,
gle.account_currency,
account.account_name,
account.account_number,
)
.where(
(gle.company == d.name)
& (gle.is_cancelled == 0)
& (gle.posting_date <= to_date)
& (account.lft >= root_lft)
& (account.rgt <= root_rgt)
)
.orderby(gle.account, gle.posting_date)
gl_entries = frappe.db.sql(
"""select gl.posting_date, gl.account, gl.debit, gl.credit, gl.is_opening, gl.company,
gl.fiscal_year, gl.debit_in_account_currency, gl.credit_in_account_currency, gl.account_currency,
acc.account_name, acc.account_number
from `tabGL Entry` gl, `tabAccount` acc where acc.name = gl.account and gl.company = %(company)s and gl.is_cancelled = 0
{additional_conditions} and gl.posting_date <= %(to_date)s and acc.lft >= %(lft)s and acc.rgt <= %(rgt)s
order by gl.account, gl.posting_date""".format(
additional_conditions=additional_conditions
),
{
"from_date": from_date,
"to_date": to_date,
"lft": root_lft,
"rgt": root_rgt,
"company": d.name,
"finance_book": filters.get("finance_book"),
"company_fb": frappe.get_cached_value("Company", d.name, "default_finance_book"),
},
as_dict=True,
)
if root_type:
query = query.where(account.root_type == root_type)
additional_conditions = get_additional_conditions(from_date, ignore_closing_entries, filters, d)
if additional_conditions:
query = query.where(Criterion.all(additional_conditions))
gl_entries = query.run(as_dict=True)
if filters and filters.get("presentation_currency") != d.default_currency:
currency_info["company"] = d.name
currency_info["company_currency"] = d.default_currency
@@ -734,30 +716,23 @@ def validate_entries(key, entry, accounts_by_name, accounts):
accounts.insert(idx + 1, args)
def get_additional_conditions(from_date, ignore_closing_entries, filters, d):
gle = frappe.qb.DocType("GL Entry")
def get_additional_conditions(from_date, ignore_closing_entries, filters):
additional_conditions = []
if ignore_closing_entries:
additional_conditions.append((gle.voucher_type != "Period Closing Voucher"))
additional_conditions.append("gl.voucher_type != 'Period Closing Voucher'")
if from_date:
additional_conditions.append(gle.posting_date >= from_date)
finance_books = []
finance_books.append("")
if filter_fb := filters.get("finance_book"):
finance_books.append(filter_fb)
additional_conditions.append("gl.posting_date >= %(from_date)s")
if filters.get("include_default_book_entries"):
if company_fb := frappe.get_cached_value("Company", d.name, "default_finance_book"):
finance_books.append(company_fb)
additional_conditions.append((gle.finance_book.isin(finance_books)) | gle.finance_book.isnull())
additional_conditions.append(
"(finance_book in (%(finance_book)s, %(company_fb)s, '') OR finance_book IS NULL)"
)
else:
additional_conditions.append((gle.finance_book.isin(finance_books)) | gle.finance_book.isnull())
additional_conditions.append("(finance_book in (%(finance_book)s, '') OR finance_book IS NULL)")
return additional_conditions
return " and {}".format(" and ".join(additional_conditions)) if additional_conditions else ""
def add_total_row(out, root_type, balance_must_be, companies, company_currency):

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