Merge branch 'develop' into view-projects-in-customer-portal

This commit is contained in:
Gursheen Kaur Anand 2023-08-18 13:32:08 +05:30 committed by GitHub
commit 6a7b45f689
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
499 changed files with 12060 additions and 58848 deletions

View File

@ -2,65 +2,32 @@
"env": { "env": {
"browser": true, "browser": true,
"node": true, "node": true,
"es6": true "es2022": true
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 11,
"sourceType": "module" "sourceType": "module"
}, },
"extends": "eslint:recommended", "extends": "eslint:recommended",
"rules": { "rules": {
"indent": [ "indent": "off",
"error", "brace-style": "off",
"tab", "no-mixed-spaces-and-tabs": "off",
{ "SwitchCase": 1 } "no-useless-escape": "off",
], "space-unary-ops": ["error", { "words": true }],
"brace-style": [ "linebreak-style": "off",
"error", "quotes": ["off"],
"1tbs" "semi": "off",
], "camelcase": "off",
"space-unary-ops": [ "no-unused-vars": "off",
"error", "no-console": ["warn"],
{ "words": true } "no-extra-boolean-cast": ["off"],
], "no-control-regex": ["off"]
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"off"
],
"semi": [
"warn",
"always"
],
"camelcase": [
"off"
],
"no-unused-vars": [
"warn"
],
"no-redeclare": [
"warn"
],
"no-console": [
"warn"
],
"no-extra-boolean-cast": [
"off"
],
"no-control-regex": [
"off"
],
"space-before-blocks": "warn",
"keyword-spacing": "warn",
"comma-spacing": "warn",
"key-spacing": "warn"
}, },
"root": true, "root": true,
"globals": { "globals": {
"frappe": true, "frappe": true,
"Vue": true, "Vue": true,
"SetVueGlobals": true,
"erpnext": true, "erpnext": true,
"hub": true, "hub": true,
"$": true, "$": true,
@ -97,8 +64,10 @@
"is_null": true, "is_null": true,
"in_list": true, "in_list": true,
"has_common": true, "has_common": true,
"posthog": true,
"has_words": true, "has_words": true,
"validate_email": true, "validate_email": true,
"open_web_template_values_editor": true,
"get_number_format": true, "get_number_format": true,
"format_number": true, "format_number": true,
"format_currency": true, "format_currency": true,

View File

@ -9,21 +9,22 @@ jobs:
name: linters name: linters
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: Set up Python 3.10 - name: Set up Python 3.10
uses: actions/setup-python@v2 uses: actions/setup-python@v4
with: with:
python-version: '3.10' python-version: '3.10'
cache: pip
- name: Install and Run Pre-commit - name: Install and Run Pre-commit
uses: pre-commit/action@v2.0.3 uses: pre-commit/action@v3.0.0
- name: Download Semgrep rules - name: Download Semgrep rules
run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules
- name: Download semgrep - name: Download semgrep
run: pip install semgrep==0.97.0 run: pip install semgrep
- name: Run Semgrep rules - name: Run Semgrep rules
run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness

View File

@ -7,11 +7,9 @@ on:
- '**.css' - '**.css'
- '**.md' - '**.md'
- '**.html' - '**.html'
push: schedule:
branches: [ develop ] # Run everday at midnight UTC / 5:30 IST
paths-ignore: - cron: "0 0 * * *"
- '**.js'
- '**.md'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
user: user:

View File

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

View File

@ -16,8 +16,26 @@ repos:
- id: check-merge-conflict - id: check-merge-conflict
- id: check-ast - id: check-ast
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.44.0
hooks:
- id: eslint
types_or: [javascript]
args: ['--quiet']
# Ignore any files that might contain jinja / bundles
exclude: |
(?x)^(
erpnext/public/dist/.*|
cypress/.*|
.*node_modules.*|
.*boilerplate.*|
erpnext/public/js/controllers/.*|
erpnext/templates/pages/order.js|
erpnext/templates/includes/.*
)$
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 5.0.4 rev: 6.0.0
hooks: hooks:
- id: flake8 - id: flake8
additional_dependencies: [ additional_dependencies: [

View File

@ -4,18 +4,19 @@
"creation": "2020-07-17 11:25:34.593061", "creation": "2020-07-17 11:25:34.593061",
"docstatus": 0, "docstatus": 0,
"doctype": "Dashboard Chart", "doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_fiscal_year\":\"frappe.sys_defaults.fiscal_year\",\"to_fiscal_year\":\"frappe.sys_defaults.fiscal_year\"}", "dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_fiscal_year\":\"erpnext.utils.get_fiscal_year()\",\"to_fiscal_year\":\"erpnext.utils.get_fiscal_year()\"}",
"filters_json": "{\"period\":\"Monthly\",\"budget_against\":\"Cost Center\",\"show_cumulative\":0}", "filters_json": "{\"period\":\"Monthly\",\"budget_against\":\"Cost Center\",\"show_cumulative\":0}",
"idx": 0, "idx": 0,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"modified": "2020-07-22 12:24:49.144210", "modified": "2023-07-19 13:13:13.307073",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Budget Variance", "name": "Budget Variance",
"number_of_groups": 0, "number_of_groups": 0,
"owner": "Administrator", "owner": "Administrator",
"report_name": "Budget Variance Report", "report_name": "Budget Variance Report",
"roles": [],
"timeseries": 0, "timeseries": 0,
"type": "Bar", "type": "Bar",
"use_report_chart": 1, "use_report_chart": 1,

View File

@ -4,18 +4,19 @@
"creation": "2020-07-17 11:25:34.448572", "creation": "2020-07-17 11:25:34.448572",
"docstatus": 0, "docstatus": 0,
"doctype": "Dashboard Chart", "doctype": "Dashboard Chart",
"dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_fiscal_year\":\"frappe.sys_defaults.fiscal_year\",\"to_fiscal_year\":\"frappe.sys_defaults.fiscal_year\"}", "dynamic_filters_json": "{\"company\":\"frappe.defaults.get_user_default(\\\"Company\\\")\",\"from_fiscal_year\":\"erpnext.utils.get_fiscal_year()\",\"to_fiscal_year\":\"erpnext.utils.get_fiscal_year()\"}",
"filters_json": "{\"filter_based_on\":\"Fiscal Year\",\"period_start_date\":\"2020-04-01\",\"period_end_date\":\"2021-03-31\",\"periodicity\":\"Yearly\",\"include_default_book_entries\":1}", "filters_json": "{\"filter_based_on\":\"Fiscal Year\",\"period_start_date\":\"2020-04-01\",\"period_end_date\":\"2021-03-31\",\"periodicity\":\"Yearly\",\"include_default_book_entries\":1}",
"idx": 0, "idx": 0,
"is_public": 1, "is_public": 1,
"is_standard": 1, "is_standard": 1,
"modified": "2020-07-22 12:33:48.888943", "modified": "2023-07-19 13:08:56.470390",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Profit and Loss", "name": "Profit and Loss",
"number_of_groups": 0, "number_of_groups": 0,
"owner": "Administrator", "owner": "Administrator",
"report_name": "Profit and Loss Statement", "report_name": "Profit and Loss Statement",
"roles": [],
"timeseries": 0, "timeseries": 0,
"type": "Bar", "type": "Bar",
"use_report_chart": 1, "use_report_chart": 1,

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" "enable_deferred_revenue" if doc.doctype == "Sales Invoice" else "enable_deferred_expense"
) )
accounts_frozen_upto = frappe.get_cached_value("Accounts Settings", "None", "acc_frozen_upto") accounts_frozen_upto = frappe.db.get_single_value("Accounts Settings", "acc_frozen_upto")
def _book_deferred_revenue_or_expense( def _book_deferred_revenue_or_expense(
item, item,

View File

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

View File

@ -123,7 +123,7 @@
"label": "Account Type", "label": "Account Type",
"oldfieldname": "account_type", "oldfieldname": "account_type",
"oldfieldtype": "Select", "oldfieldtype": "Select",
"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" "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"
}, },
{ {
"description": "Rate at which this tax is applied", "description": "Rate at which this tax is applied",
@ -192,7 +192,7 @@
"idx": 1, "idx": 1,
"is_tree": 1, "is_tree": 1,
"links": [], "links": [],
"modified": "2023-04-11 16:08:46.983677", "modified": "2023-07-20 18:18:44.405723",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Account", "name": "Account",
@ -243,7 +243,6 @@
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "Accounts Manager", "role": "Accounts Manager",
"set_user_permissions": 1,
"share": 1, "share": 1,
"write": 1 "write": 1
} }

View File

@ -45,6 +45,7 @@ class Account(NestedSet):
if frappe.local.flags.allow_unverified_charts: if frappe.local.flags.allow_unverified_charts:
return return
self.validate_parent() self.validate_parent()
self.validate_parent_child_account_type()
self.validate_root_details() self.validate_root_details()
validate_field_number("Account", self.name, self.account_number, self.company, "account_number") validate_field_number("Account", self.name, self.account_number, self.company, "account_number")
self.validate_group_or_ledger() self.validate_group_or_ledger()
@ -55,6 +56,20 @@ class Account(NestedSet):
self.validate_account_currency() self.validate_account_currency()
self.validate_root_company_and_sync_account_to_children() 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): def validate_parent(self):
"""Fetch Parent Details and validate parent account""" """Fetch Parent Details and validate parent account"""
if self.parent_account: if self.parent_account:

View File

@ -194,8 +194,8 @@ frappe.treeview_settings["Account"] = {
click: function(node, btn) { click: function(node, btn) {
frappe.route_options = { frappe.route_options = {
"account": node.label, "account": node.label,
"from_date": frappe.sys_defaults.year_start_date, "from_date": erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
"to_date": frappe.sys_defaults.year_end_date, "to_date": erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[2],
"company": frappe.treeview_settings['Account'].treeview.page.fields_dict.company.get_value() "company": frappe.treeview_settings['Account'].treeview.page.fields_dict.company.get_value()
}; };
frappe.set_route("query-report", "General Ledger"); frappe.set_route("query-report", "General Ledger");

View File

@ -14,10 +14,8 @@ class AccountClosingBalance(Document):
pass pass
def make_closing_entries(closing_entries, voucher_name): def make_closing_entries(closing_entries, voucher_name, company, closing_date):
accounting_dimensions = get_accounting_dimensions() accounting_dimensions = get_accounting_dimensions()
company = closing_entries[0].get("company")
closing_date = closing_entries[0].get("closing_date")
previous_closing_entries = get_previous_closing_entries( previous_closing_entries = get_previous_closing_entries(
company, closing_date, accounting_dimensions company, closing_date, accounting_dimensions

View File

@ -15,6 +15,17 @@ frappe.ui.form.on('Accounting Dimension', {
}; };
}); });
frm.set_query("offsetting_account", "dimension_defaults", function(doc, cdt, cdn) {
let d = locals[cdt][cdn];
return {
filters: {
company: d.company,
root_type: ["in", ["Asset", "Liability"]],
is_group: 0
}
}
});
if (!frm.is_new()) { if (!frm.is_new()) {
frm.add_custom_button(__('Show {0}', [frm.doc.document_type]), function () { frm.add_custom_button(__('Show {0}', [frm.doc.document_type]), function () {
frappe.set_route("List", frm.doc.document_type); frappe.set_route("List", frm.doc.document_type);

View File

@ -39,6 +39,8 @@ class AccountingDimension(Document):
if not self.is_new(): if not self.is_new():
self.validate_document_type_change() self.validate_document_type_change()
self.validate_dimension_defaults()
def validate_document_type_change(self): def validate_document_type_change(self):
doctype_before_save = frappe.db.get_value("Accounting Dimension", self.name, "document_type") doctype_before_save = frappe.db.get_value("Accounting Dimension", self.name, "document_type")
if doctype_before_save != self.document_type: if doctype_before_save != self.document_type:
@ -46,6 +48,14 @@ class AccountingDimension(Document):
message += _("Please create a new Accounting Dimension if required.") message += _("Please create a new Accounting Dimension if required.")
frappe.throw(message) frappe.throw(message)
def validate_dimension_defaults(self):
companies = []
for default in self.get("dimension_defaults"):
if default.company not in companies:
companies.append(default.company)
else:
frappe.throw(_("Company {0} is added more than once").format(frappe.bold(default.company)))
def after_insert(self): def after_insert(self):
if frappe.flags.in_test: if frappe.flags.in_test:
make_dimension_in_accounting_doctypes(doc=self) make_dimension_in_accounting_doctypes(doc=self)

View File

@ -8,7 +8,10 @@
"reference_document", "reference_document",
"default_dimension", "default_dimension",
"mandatory_for_bs", "mandatory_for_bs",
"mandatory_for_pl" "mandatory_for_pl",
"column_break_lqns",
"automatically_post_balancing_accounting_entry",
"offsetting_account"
], ],
"fields": [ "fields": [
{ {
@ -50,6 +53,23 @@
"fieldtype": "Check", "fieldtype": "Check",
"in_list_view": 1, "in_list_view": 1,
"label": "Mandatory For Profit and Loss Account" "label": "Mandatory For Profit and Loss Account"
},
{
"default": "0",
"fieldname": "automatically_post_balancing_accounting_entry",
"fieldtype": "Check",
"label": "Automatically post balancing accounting entry"
},
{
"fieldname": "offsetting_account",
"fieldtype": "Link",
"label": "Offsetting Account",
"mandatory_depends_on": "eval: doc.automatically_post_balancing_accounting_entry",
"options": "Account"
},
{
"fieldname": "column_break_lqns",
"fieldtype": "Column Break"
} }
], ],
"istable": 1, "istable": 1,

View File

@ -20,5 +20,11 @@ frappe.ui.form.on('Accounting Period', {
} }
}); });
} }
frm.set_query("document_type", "closed_documents", () => {
return {
query: "erpnext.controllers.queries.get_doctypes_for_closing",
}
});
} }
}); });

View File

@ -11,6 +11,10 @@ class OverlapError(frappe.ValidationError):
pass pass
class ClosedAccountingPeriod(frappe.ValidationError):
pass
class AccountingPeriod(Document): class AccountingPeriod(Document):
def validate(self): def validate(self):
self.validate_overlap() self.validate_overlap()
@ -65,3 +69,42 @@ class AccountingPeriod(Document):
"closed_documents", "closed_documents",
{"document_type": doctype_for_closing.document_type, "closed": doctype_for_closing.closed}, {"document_type": doctype_for_closing.document_type, "closed": doctype_for_closing.closed},
) )
def validate_accounting_period_on_doc_save(doc, method=None):
if doc.doctype == "Bank Clearance":
return
elif doc.doctype == "Asset":
if doc.is_existing_asset:
return
else:
date = doc.available_for_use_date
elif doc.doctype == "Asset Repair":
date = doc.completion_date
else:
date = doc.posting_date
ap = frappe.qb.DocType("Accounting Period")
cd = frappe.qb.DocType("Closed Document")
accounting_period = (
frappe.qb.from_(ap)
.from_(cd)
.select(ap.name)
.where(
(ap.name == cd.parent)
& (ap.company == doc.company)
& (cd.closed == 1)
& (cd.document_type == doc.doctype)
& (date >= ap.start_date)
& (date <= ap.end_date)
)
).run(as_dict=1)
if accounting_period:
frappe.throw(
_("You cannot create a {0} within the closed Accounting Period {1}").format(
doc.doctype, frappe.bold(accounting_period[0]["name"])
),
ClosedAccountingPeriod,
)

View File

@ -6,9 +6,11 @@ import unittest
import frappe import frappe
from frappe.utils import add_months, nowdate from frappe.utils import add_months, nowdate
from erpnext.accounts.doctype.accounting_period.accounting_period import OverlapError from erpnext.accounts.doctype.accounting_period.accounting_period import (
ClosedAccountingPeriod,
OverlapError,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.general_ledger import ClosedAccountingPeriod
test_dependencies = ["Item"] test_dependencies = ["Item"]
@ -33,9 +35,9 @@ class TestAccountingPeriod(unittest.TestCase):
ap1.save() ap1.save()
doc = create_sales_invoice( doc = create_sales_invoice(
do_not_submit=1, cost_center="_Test Company - _TC", warehouse="Stores - _TC" do_not_save=1, cost_center="_Test Company - _TC", warehouse="Stores - _TC"
) )
self.assertRaises(ClosedAccountingPeriod, doc.submit) self.assertRaises(ClosedAccountingPeriod, doc.save)
def tearDown(self): def tearDown(self):
for d in frappe.get_all("Accounting Period"): for d in frappe.get_all("Accounting Period"):

View File

@ -58,6 +58,7 @@
"closing_settings_tab", "closing_settings_tab",
"period_closing_settings_section", "period_closing_settings_section",
"acc_frozen_upto", "acc_frozen_upto",
"ignore_account_closing_balance",
"column_break_25", "column_break_25",
"frozen_accounts_modifier", "frozen_accounts_modifier",
"tab_break_dpet", "tab_break_dpet",
@ -406,6 +407,13 @@
"fieldname": "enable_fuzzy_matching", "fieldname": "enable_fuzzy_matching",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Enable Fuzzy Matching" "label": "Enable Fuzzy Matching"
},
{
"default": "0",
"description": "Financial reports will be generated using GL Entry doctypes (should be enabled if Period Closing Voucher is not posted for all years sequentially or missing) ",
"fieldname": "ignore_account_closing_balance",
"fieldtype": "Check",
"label": "Ignore Account Closing Balance"
} }
], ],
"icon": "icon-cog", "icon": "icon-cog",
@ -413,7 +421,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2023-06-15 16:35:45.123456", "modified": "2023-07-27 15:05:34.000264",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Accounts Settings", "name": "Accounts Settings",

View File

@ -14,22 +14,33 @@ from erpnext.stock.utils import check_pending_reposting
class AccountsSettings(Document): class AccountsSettings(Document):
def on_update(self):
frappe.clear_cache()
def validate(self): def validate(self):
old_doc = self.get_doc_before_save()
clear_cache = False
if old_doc.add_taxes_from_item_tax_template != self.add_taxes_from_item_tax_template:
frappe.db.set_default( frappe.db.set_default(
"add_taxes_from_item_tax_template", self.get("add_taxes_from_item_tax_template", 0) "add_taxes_from_item_tax_template", self.get("add_taxes_from_item_tax_template", 0)
) )
clear_cache = True
if old_doc.enable_common_party_accounting != self.enable_common_party_accounting:
frappe.db.set_default( frappe.db.set_default(
"enable_common_party_accounting", self.get("enable_common_party_accounting", 0) "enable_common_party_accounting", self.get("enable_common_party_accounting", 0)
) )
clear_cache = True
self.validate_stale_days() self.validate_stale_days()
if old_doc.show_payment_schedule_in_print != self.show_payment_schedule_in_print:
self.enable_payment_schedule_in_print() self.enable_payment_schedule_in_print()
if old_doc.acc_frozen_upto != self.acc_frozen_upto:
self.validate_pending_reposts() self.validate_pending_reposts()
if clear_cache:
frappe.clear_cache()
def validate_stale_days(self): def validate_stale_days(self):
if not self.allow_stale and cint(self.stale_days) <= 0: if not self.allow_stale and cint(self.stale_days) <= 0:
frappe.msgprint( frappe.msgprint(

View File

@ -102,7 +102,7 @@ erpnext.integrations.refreshPlaidLink = class refreshPlaidLink {
} }
onScriptLoaded(me) { onScriptLoaded(me) {
me.linkHandler = Plaid.create({ me.linkHandler = Plaid.create({ // eslint-disable-line no-undef
env: me.plaid_env, env: me.plaid_env,
token: me.token, token: me.token,
onSuccess: me.plaid_success onSuccess: me.plaid_success

View File

@ -70,7 +70,7 @@ frappe.ui.form.on('Cost Center', {
} }
], ],
primary_action: function() { primary_action: function() {
var data = d.get_values(); let data = d.get_values();
if(data.cost_center_name === frm.doc.cost_center_name && data.cost_center_number === frm.doc.cost_center_number) { if(data.cost_center_name === frm.doc.cost_center_name && data.cost_center_number === frm.doc.cost_center_number) {
d.hide(); d.hide();
return; return;
@ -91,8 +91,8 @@ frappe.ui.form.on('Cost Center', {
if(r.message) { if(r.message) {
frappe.set_route("Form", "Cost Center", r.message); frappe.set_route("Form", "Cost Center", r.message);
} else { } else {
me.frm.set_value("cost_center_name", data.cost_center_name); frm.set_value("cost_center_name", data.cost_center_name);
me.frm.set_value("cost_center_number", data.cost_center_number); frm.set_value("cost_center_number", data.cost_center_number);
} }
d.hide(); d.hide();
} }

View File

@ -1,13 +1,14 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on("Dunning", { frappe.ui.form.on("Dunning", {
setup: function (frm) { setup: function (frm) {
frm.set_query("sales_invoice", () => { frm.set_query("sales_invoice", "overdue_payments", () => {
return { return {
filters: { filters: {
docstatus: 1, docstatus: 1,
company: frm.doc.company, company: frm.doc.company,
customer: frm.doc.customer,
outstanding_amount: [">", 0], outstanding_amount: [">", 0],
status: "Overdue" status: "Overdue"
}, },
@ -22,14 +23,24 @@ frappe.ui.form.on("Dunning", {
} }
}; };
}); });
frm.set_query("cost_center", () => {
return {
filters: {
company: frm.doc.company,
is_group: 0
}
};
});
frm.set_query("contact_person", erpnext.queries.contact_query);
frm.set_query("customer_address", erpnext.queries.address_query);
frm.set_query("company_address", erpnext.queries.company_address_query);
// cannot add rows manually, only via button "Fetch Overdue Payments"
frm.set_df_property("overdue_payments", "cannot_add_rows", true);
}, },
refresh: function (frm) { refresh: function (frm) {
frm.set_df_property("company", "read_only", frm.doc.__islocal ? 0 : 1); frm.set_df_property("company", "read_only", frm.doc.__islocal ? 0 : 1);
frm.set_df_property(
"sales_invoice",
"read_only",
frm.doc.__islocal ? 0 : 1
);
if (frm.doc.docstatus === 1 && frm.doc.status === "Unresolved") { if (frm.doc.docstatus === 1 && frm.doc.status === "Unresolved") {
frm.add_custom_button(__("Resolve"), () => { frm.add_custom_button(__("Resolve"), () => {
frm.set_value("status", "Resolved"); frm.set_value("status", "Resolved");
@ -40,42 +51,111 @@ frappe.ui.form.on("Dunning", {
__("Payment"), __("Payment"),
function () { function () {
frm.events.make_payment_entry(frm); frm.events.make_payment_entry(frm);
},__("Create") }, __("Create")
); );
frm.page.set_inner_btn_group_as_primary(__("Create")); frm.page.set_inner_btn_group_as_primary(__("Create"));
} }
if(frm.doc.docstatus > 0) { if (frm.doc.docstatus === 0) {
frm.add_custom_button(__('Ledger'), function() { frm.add_custom_button(__("Fetch Overdue Payments"), () => {
frappe.route_options = { erpnext.utils.map_current_doc({
"voucher_no": frm.doc.name, method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
"from_date": frm.doc.posting_date, source_doctype: "Sales Invoice",
"to_date": frm.doc.posting_date, date_field: "due_date",
"company": frm.doc.company, target: frm,
"show_cancelled_entries": frm.doc.docstatus === 2 setters: {
}; customer: frm.doc.customer || undefined,
frappe.set_route("query-report", "General Ledger"); },
}, __('View')); get_query_filters: {
docstatus: 1,
status: "Overdue",
company: frm.doc.company
},
allow_child_item_selection: true,
child_fieldname: "payment_schedule",
child_columns: ["due_date", "outstanding"],
});
});
}
frappe.dynamic_link = { doc: frm.doc, fieldname: 'customer', doctype: 'Customer' };
frm.toggle_display("customer_name", (frm.doc.customer_name && frm.doc.customer_name !== frm.doc.customer));
},
// When multiple companies are set up. in case company name is changed set default company address
company: function (frm) {
if (frm.doc.company) {
frappe.call({
method: "erpnext.setup.doctype.company.company.get_default_company_address",
args: { name: frm.doc.company, existing_address: frm.doc.company_address || "" },
debounce: 2000,
callback: function (r) {
frm.set_value("company_address", r && r.message || "");
}
});
if (frm.fields_dict.currency) {
const company_currency = erpnext.get_currency(frm.doc.company);
if (!frm.doc.currency) {
frm.set_value("currency", company_currency);
}
if (frm.doc.currency == company_currency) {
frm.set_value("conversion_rate", 1.0);
}
}
const company_doc = frappe.get_doc(":Company", frm.doc.company);
if (company_doc.default_letter_head) {
if (frm.fields_dict.letter_head) {
frm.set_value("letter_head", company_doc.default_letter_head);
}
}
} }
}, },
overdue_days: function (frm) { currency: function (frm) {
frappe.db.get_value( // this.set_dynamic_labels();
"Dunning Type", const company_currency = erpnext.get_currency(frm.doc.company);
{ // Added `ignore_pricing_rule` to determine if document is loading after mapping from another doc
start_day: ["<", frm.doc.overdue_days], if (frm.doc.currency && frm.doc.currency !== company_currency) {
end_day: [">=", frm.doc.overdue_days], frappe.call({
method: "erpnext.setup.utils.get_exchange_rate",
args: {
transaction_date: frm.doc.posting_date,
from_currency: frm.doc.currency,
to_currency: company_currency,
args: "for_selling"
}, },
"dunning_type", freeze: true,
(r) => { freeze_message: __("Fetching exchange rates ..."),
if (r) { callback: function(r) {
frm.set_value("dunning_type", r.dunning_type); const exchange_rate = flt(r.message);
if (exchange_rate != frm.doc.conversion_rate) {
frm.set_value("conversion_rate", exchange_rate);
}
}
});
} else { } else {
frm.set_value("dunning_type", ""); frm.trigger("conversion_rate");
frm.set_value("rate_of_interest", "");
frm.set_value("dunning_fee", "");
} }
},
customer: (frm) => {
erpnext.utils.get_party_details(frm);
},
conversion_rate: function (frm) {
if (frm.doc.currency === erpnext.get_currency(frm.doc.company)) {
frm.set_value("conversion_rate", 1.0);
} }
);
// Make read only if Accounts Settings doesn't allow stale rates
frm.set_df_property("conversion_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
},
customer_address: function (frm) {
erpnext.utils.get_address_display(frm, "customer_address");
},
company_address: function (frm) {
erpnext.utils.get_address_display(frm, "company_address");
}, },
dunning_type: function (frm) { dunning_type: function (frm) {
frm.trigger("get_dunning_letter_text"); frm.trigger("get_dunning_letter_text");
@ -106,44 +186,57 @@ frappe.ui.form.on("Dunning", {
}); });
} }
}, },
due_date: function (frm) {
frm.trigger("calculate_overdue_days");
},
posting_date: function (frm) { posting_date: function (frm) {
frm.trigger("calculate_overdue_days"); frm.trigger("calculate_overdue_days");
}, },
rate_of_interest: function (frm) { rate_of_interest: function (frm) {
frm.trigger("calculate_interest_and_amount"); frm.trigger("calculate_interest");
},
outstanding_amount: function (frm) {
frm.trigger("calculate_interest_and_amount");
},
interest_amount: function (frm) {
frm.trigger("calculate_interest_and_amount");
}, },
dunning_fee: function (frm) { dunning_fee: function (frm) {
frm.trigger("calculate_interest_and_amount"); frm.trigger("calculate_totals");
}, },
sales_invoice: function (frm) { overdue_payments_add: function (frm) {
frm.trigger("calculate_overdue_days"); frm.trigger("calculate_totals");
},
overdue_payments_remove: function (frm) {
frm.trigger("calculate_totals");
}, },
calculate_overdue_days: function (frm) { calculate_overdue_days: function (frm) {
if (frm.doc.posting_date && frm.doc.due_date) { frm.doc.overdue_payments.forEach((row) => {
if (frm.doc.posting_date && row.due_date) {
const overdue_days = moment(frm.doc.posting_date).diff( const overdue_days = moment(frm.doc.posting_date).diff(
frm.doc.due_date, row.due_date,
"days" "days"
); );
frm.set_value("overdue_days", overdue_days); frappe.model.set_value(row.doctype, row.name, "overdue_days", overdue_days);
} }
});
}, },
calculate_interest_and_amount: function (frm) { calculate_interest: function (frm) {
const interest_per_year = frm.doc.outstanding_amount * frm.doc.rate_of_interest / 100; frm.doc.overdue_payments.forEach((row) => {
const interest_amount = flt((interest_per_year * cint(frm.doc.overdue_days)) / 365 || 0, precision('interest_amount')); const interest_per_day = frm.doc.rate_of_interest / 100 / 365;
const dunning_amount = flt(interest_amount + frm.doc.dunning_fee, precision('dunning_amount')); const interest = flt((interest_per_day * row.overdue_days * row.outstanding), precision("interest", row));
const grand_total = flt(frm.doc.outstanding_amount + dunning_amount, precision('grand_total')); frappe.model.set_value(row.doctype, row.name, "interest", interest);
frm.set_value("interest_amount", interest_amount); });
frm.set_value("dunning_amount", dunning_amount); },
frm.set_value("grand_total", grand_total); calculate_totals: function (frm) {
const total_interest = frm.doc.overdue_payments
.reduce((prev, cur) => prev + cur.interest, 0);
const total_outstanding = frm.doc.overdue_payments
.reduce((prev, cur) => prev + cur.outstanding, 0);
const dunning_amount = total_interest + frm.doc.dunning_fee;
const base_dunning_amount = dunning_amount * frm.doc.conversion_rate;
const grand_total = total_outstanding + dunning_amount;
function setWithPrecison(field, value) {
frm.set_value(field, flt(value, precision(field)));
}
setWithPrecison("total_outstanding", total_outstanding);
setWithPrecison("total_interest", total_interest);
setWithPrecison("dunning_amount", dunning_amount);
setWithPrecison("base_dunning_amount", base_dunning_amount);
setWithPrecison("grand_total", grand_total);
}, },
make_payment_entry: function (frm) { make_payment_entry: function (frm) {
return frappe.call({ return frappe.call({
@ -160,3 +253,9 @@ frappe.ui.form.on("Dunning", {
}); });
}, },
}); });
frappe.ui.form.on("Overdue Payment", {
interest: function (frm) {
frm.trigger("calculate_totals");
}
});

View File

@ -2,49 +2,60 @@
"actions": [], "actions": [],
"allow_events_in_timeline": 1, "allow_events_in_timeline": 1,
"autoname": "naming_series:", "autoname": "naming_series:",
"beta": 1,
"creation": "2019-07-05 16:34:31.013238", "creation": "2019-07-05 16:34:31.013238",
"doctype": "DocType", "doctype": "DocType",
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"title",
"naming_series", "naming_series",
"sales_invoice",
"customer", "customer",
"customer_name", "customer_name",
"outstanding_amount",
"currency",
"conversion_rate",
"column_break_3", "column_break_3",
"company", "company",
"posting_date", "posting_date",
"posting_time", "posting_time",
"due_date", "status",
"overdue_days", "section_break_9",
"currency",
"column_break_11",
"conversion_rate",
"address_and_contact_section", "address_and_contact_section",
"customer_address",
"address_display", "address_display",
"contact_person",
"contact_display", "contact_display",
"column_break_16",
"company_address",
"company_address_display",
"contact_mobile", "contact_mobile",
"contact_email", "contact_email",
"column_break_18",
"company_address_display",
"section_break_6", "section_break_6",
"dunning_type", "dunning_type",
"dunning_fee",
"column_break_8", "column_break_8",
"rate_of_interest", "rate_of_interest",
"interest_amount",
"section_break_12", "section_break_12",
"dunning_amount", "overdue_payments",
"grand_total", "section_break_28",
"income_account", "total_interest",
"dunning_fee",
"column_break_17", "column_break_17",
"status", "dunning_amount",
"printing_setting_section", "base_dunning_amount",
"section_break_32",
"spacer",
"column_break_33",
"total_outstanding",
"grand_total",
"printing_settings_section",
"language", "language",
"body_text", "body_text",
"column_break_22", "column_break_22",
"letter_head", "letter_head",
"closing_text", "closing_text",
"accounting_details_section",
"income_account",
"column_break_48",
"cost_center",
"amended_from" "amended_from"
], ],
"fields": [ "fields": [
@ -60,32 +71,17 @@
"fieldname": "naming_series", "fieldname": "naming_series",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Series", "label": "Series",
"options": "DUNN-.MM.-.YY.-" "options": "DUNN-.MM.-.YY.-",
"print_hide": 1
}, },
{ {
"fieldname": "sales_invoice", "fetch_from": "customer.customer_name",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Sales Invoice",
"options": "Sales Invoice",
"reqd": 1
},
{
"fetch_from": "sales_invoice.customer_name",
"fieldname": "customer_name", "fieldname": "customer_name",
"fieldtype": "Data", "fieldtype": "Data",
"in_list_view": 1, "in_list_view": 1,
"label": "Customer Name", "label": "Customer Name",
"read_only": 1 "read_only": 1
}, },
{
"fetch_from": "sales_invoice.outstanding_amount",
"fieldname": "outstanding_amount",
"fieldtype": "Currency",
"label": "Outstanding Amount",
"read_only": 1
},
{ {
"fieldname": "column_break_3", "fieldname": "column_break_3",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@ -94,13 +90,8 @@
"default": "Today", "default": "Today",
"fieldname": "posting_date", "fieldname": "posting_date",
"fieldtype": "Date", "fieldtype": "Date",
"label": "Date" "label": "Date",
}, "reqd": 1
{
"fieldname": "overdue_days",
"fieldtype": "Int",
"label": "Overdue Days",
"read_only": 1
}, },
{ {
"fieldname": "section_break_6", "fieldname": "section_break_6",
@ -112,16 +103,7 @@
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Dunning Type", "label": "Dunning Type",
"options": "Dunning Type", "options": "Dunning Type"
"reqd": 1
},
{
"default": "0",
"fieldname": "interest_amount",
"fieldtype": "Currency",
"label": "Interest Amount",
"precision": "2",
"read_only": 1
}, },
{ {
"fieldname": "column_break_8", "fieldname": "column_break_8",
@ -134,6 +116,7 @@
"fieldname": "dunning_fee", "fieldname": "dunning_fee",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Dunning Fee", "label": "Dunning Fee",
"options": "currency",
"precision": "2" "precision": "2"
}, },
{ {
@ -144,36 +127,24 @@
"fieldname": "column_break_17", "fieldname": "column_break_17",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "printing_setting_section",
"fieldtype": "Section Break",
"label": "Printing Setting"
},
{ {
"fieldname": "language", "fieldname": "language",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Print Language", "label": "Print Language",
"options": "Language" "options": "Language",
"print_hide": 1
}, },
{ {
"fieldname": "letter_head", "fieldname": "letter_head",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Letter Head", "label": "Letter Head",
"options": "Letter Head" "options": "Letter Head",
"print_hide": 1
}, },
{ {
"fieldname": "column_break_22", "fieldname": "column_break_22",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fetch_from": "sales_invoice.currency",
"fieldname": "currency",
"fieldtype": "Link",
"hidden": 1,
"label": "Currency",
"options": "Currency",
"read_only": 1
},
{ {
"fieldname": "amended_from", "fieldname": "amended_from",
"fieldtype": "Link", "fieldtype": "Link",
@ -183,14 +154,6 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{
"allow_on_submit": 1,
"default": "{customer_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title"
},
{ {
"fieldname": "body_text", "fieldname": "body_text",
"fieldtype": "Text Editor", "fieldtype": "Text Editor",
@ -201,13 +164,6 @@
"fieldtype": "Text Editor", "fieldtype": "Text Editor",
"label": "Closing Text" "label": "Closing Text"
}, },
{
"fetch_from": "sales_invoice.due_date",
"fieldname": "due_date",
"fieldtype": "Date",
"label": "Due Date",
"read_only": 1
},
{ {
"fieldname": "posting_time", "fieldname": "posting_time",
"fieldtype": "Time", "fieldtype": "Time",
@ -222,26 +178,24 @@
"label": "Rate of Interest (%) Yearly" "label": "Rate of Interest (%) Yearly"
}, },
{ {
"collapsible": 1,
"fieldname": "address_and_contact_section", "fieldname": "address_and_contact_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Address and Contact" "label": "Address and Contact"
}, },
{ {
"fetch_from": "sales_invoice.address_display",
"fieldname": "address_display", "fieldname": "address_display",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Address", "label": "Address",
"read_only": 1 "read_only": 1
}, },
{ {
"fetch_from": "sales_invoice.contact_display",
"fieldname": "contact_display", "fieldname": "contact_display",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Contact", "label": "Contact",
"read_only": 1 "read_only": 1
}, },
{ {
"fetch_from": "sales_invoice.contact_mobile",
"fieldname": "contact_mobile", "fieldname": "contact_mobile",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Mobile No", "label": "Mobile No",
@ -249,18 +203,12 @@
"read_only": 1 "read_only": 1
}, },
{ {
"fieldname": "column_break_18",
"fieldtype": "Column Break"
},
{
"fetch_from": "sales_invoice.company_address_display",
"fieldname": "company_address_display", "fieldname": "company_address_display",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Company Address", "label": "Company Address Display",
"read_only": 1 "read_only": 1
}, },
{ {
"fetch_from": "sales_invoice.contact_email",
"fieldname": "contact_email", "fieldname": "contact_email",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Contact Email", "label": "Contact Email",
@ -268,18 +216,18 @@
"read_only": 1 "read_only": 1
}, },
{ {
"fetch_from": "sales_invoice.customer",
"fieldname": "customer", "fieldname": "customer",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Customer", "label": "Customer",
"options": "Customer", "options": "Customer",
"read_only": 1 "reqd": 1
}, },
{ {
"default": "0", "default": "0",
"fieldname": "grand_total", "fieldname": "grand_total",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Grand Total", "label": "Grand Total",
"options": "currency",
"precision": "2", "precision": "2",
"read_only": 1 "read_only": 1
}, },
@ -290,33 +238,150 @@
"fieldtype": "Select", "fieldtype": "Select",
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Status", "label": "Status",
"options": "Draft\nResolved\nUnresolved\nCancelled" "options": "Draft\nResolved\nUnresolved\nCancelled",
},
{
"fieldname": "dunning_amount",
"fieldtype": "Currency",
"hidden": 1,
"label": "Dunning Amount",
"read_only": 1 "read_only": 1
}, },
{ {
"description": "For dunning fee and interest",
"fetch_from": "dunning_type.income_account",
"fieldname": "income_account", "fieldname": "income_account",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Income Account", "label": "Income Account",
"options": "Account" "options": "Account",
"print_hide": 1
},
{
"fieldname": "overdue_payments",
"fieldtype": "Table",
"label": "Overdue Payments",
"options": "Overdue Payment"
},
{
"fieldname": "section_break_28",
"fieldtype": "Section Break"
},
{
"default": "0",
"fieldname": "total_interest",
"fieldtype": "Currency",
"label": "Total Interest",
"options": "currency",
"precision": "2",
"read_only": 1
},
{
"fieldname": "total_outstanding",
"fieldtype": "Currency",
"label": "Total Outstanding",
"options": "currency",
"read_only": 1
},
{
"fieldname": "customer_address",
"fieldtype": "Link",
"label": "Customer Address",
"options": "Address",
"print_hide": 1
},
{
"fieldname": "contact_person",
"fieldtype": "Link",
"label": "Contact Person",
"options": "Contact",
"print_hide": 1
},
{
"default": "0",
"fieldname": "dunning_amount",
"fieldtype": "Currency",
"label": "Dunning Amount",
"options": "currency",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "accounting_details_section",
"fieldtype": "Section Break",
"label": "Accounting Details"
},
{
"fetch_from": "dunning_type.cost_center",
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center",
"print_hide": 1
},
{
"collapsible": 1,
"fieldname": "printing_settings_section",
"fieldtype": "Section Break",
"label": "Printing Settings"
},
{
"fieldname": "section_break_32",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_33",
"fieldtype": "Column Break"
},
{
"fieldname": "spacer",
"fieldtype": "Data",
"hidden": 1,
"label": "Spacer",
"print_hide": 1,
"read_only": 1,
"report_hide": 1
},
{
"fieldname": "column_break_16",
"fieldtype": "Column Break"
},
{
"fieldname": "company_address",
"fieldtype": "Link",
"label": "Company Address",
"options": "Address",
"print_hide": 1
},
{
"fieldname": "section_break_9",
"fieldtype": "Section Break",
"label": "Currency"
},
{
"fieldname": "currency",
"fieldtype": "Link",
"label": "Currency",
"options": "Currency"
},
{
"fieldname": "column_break_11",
"fieldtype": "Column Break"
}, },
{ {
"fetch_from": "sales_invoice.conversion_rate",
"fieldname": "conversion_rate", "fieldname": "conversion_rate",
"fieldtype": "Float", "fieldtype": "Float",
"hidden": 1, "label": "Conversion Rate"
"label": "Conversion Rate", },
{
"default": "0",
"fieldname": "base_dunning_amount",
"fieldtype": "Currency",
"label": "Dunning Amount (Company Currency)",
"options": "Company:company:default_currency",
"read_only": 1 "read_only": 1
},
{
"fieldname": "column_break_48",
"fieldtype": "Column Break"
} }
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-06-03 16:24:01.677026", "modified": "2023-06-15 15:46:53.865712",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Dunning", "name": "Dunning",

View File

@ -1,131 +1,150 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
"""
# Accounting
1. Payment of outstanding invoices with dunning amount
- Debit full amount to bank
- Credit invoiced amount to receivables
- Credit dunning amount to interest and similar revenue
-> Resolves dunning automatically
"""
import json import json
import frappe import frappe
from frappe.utils import cint, flt, getdate from frappe import _
from frappe.contacts.doctype.address.address import get_address_display
from frappe.utils import getdate
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries
from erpnext.controllers.accounts_controller import AccountsController from erpnext.controllers.accounts_controller import AccountsController
class Dunning(AccountsController): class Dunning(AccountsController):
def validate(self): def validate(self):
self.validate_overdue_days() self.validate_same_currency()
self.validate_amount() self.validate_overdue_payments()
if not self.income_account: self.validate_totals()
self.income_account = frappe.get_cached_value("Company", self.company, "default_income_account") self.set_party_details()
self.set_dunning_level()
def validate_overdue_days(self): def validate_same_currency(self):
self.overdue_days = (getdate(self.posting_date) - getdate(self.due_date)).days or 0 """
Throw an error if invoice currency differs from dunning currency.
def validate_amount(self): """
amounts = calculate_interest_and_amount( for row in self.overdue_payments:
self.outstanding_amount, self.rate_of_interest, self.dunning_fee, self.overdue_days invoice_currency = frappe.get_value("Sales Invoice", row.sales_invoice, "currency")
if invoice_currency != self.currency:
frappe.throw(
_(
"The currency of invoice {} ({}) is different from the currency of this dunning ({})."
).format(row.sales_invoice, invoice_currency, self.currency)
) )
if self.interest_amount != amounts.get("interest_amount"):
self.interest_amount = flt(amounts.get("interest_amount"), self.precision("interest_amount"))
if self.dunning_amount != amounts.get("dunning_amount"):
self.dunning_amount = flt(amounts.get("dunning_amount"), self.precision("dunning_amount"))
if self.grand_total != amounts.get("grand_total"):
self.grand_total = flt(amounts.get("grand_total"), self.precision("grand_total"))
def on_submit(self): def validate_overdue_payments(self):
self.make_gl_entries() daily_interest = self.rate_of_interest / 100 / 365
def on_cancel(self): for row in self.overdue_payments:
if self.dunning_amount: row.overdue_days = (getdate(self.posting_date) - getdate(row.due_date)).days or 0
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry") row.interest = row.outstanding * daily_interest * row.overdue_days
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
def make_gl_entries(self): def validate_totals(self):
if not self.dunning_amount: self.total_outstanding = sum(row.outstanding for row in self.overdue_payments)
return self.total_interest = sum(row.interest for row in self.overdue_payments)
gl_entries = [] self.dunning_amount = self.total_interest + self.dunning_fee
invoice_fields = [ self.base_dunning_amount = self.dunning_amount * self.conversion_rate
"project", self.grand_total = self.total_outstanding + self.dunning_amount
"cost_center",
"debit_to",
"party_account_currency",
"conversion_rate",
"cost_center",
]
inv = frappe.db.get_value("Sales Invoice", self.sales_invoice, invoice_fields, as_dict=1)
accounting_dimensions = get_accounting_dimensions() def set_party_details(self):
invoice_fields.extend(accounting_dimensions) from erpnext.accounts.party import _get_party_details
dunning_in_company_currency = flt(self.dunning_amount * inv.conversion_rate) party_details = _get_party_details(
default_cost_center = frappe.get_cached_value("Company", self.company, "cost_center") self.customer,
ignore_permissions=self.flags.ignore_permissions,
doctype=self.doctype,
company=self.company,
posting_date=self.get("posting_date"),
fetch_payment_terms_template=False,
party_address=self.customer_address,
company_address=self.get("company_address"),
)
for field in [
"customer_address",
"address_display",
"company_address",
"contact_person",
"contact_display",
"contact_mobile",
]:
self.set(field, party_details.get(field))
gl_entries.append( self.set("company_address_display", get_address_display(self.company_address))
self.get_gl_dict(
{ def set_dunning_level(self):
"account": inv.debit_to, for row in self.overdue_payments:
"party_type": "Customer", past_dunnings = frappe.get_all(
"party": self.customer, "Overdue Payment",
"due_date": self.due_date, filters={
"against": self.income_account, "payment_schedule": row.payment_schedule,
"debit": dunning_in_company_currency, "parent": ("!=", row.parent),
"debit_in_account_currency": self.dunning_amount, "docstatus": 1,
"against_voucher": self.name,
"against_voucher_type": "Dunning",
"cost_center": inv.cost_center or default_cost_center,
"project": inv.project,
}, },
inv.party_account_currency,
item=inv,
)
)
gl_entries.append(
self.get_gl_dict(
{
"account": self.income_account,
"against": self.customer,
"credit": dunning_in_company_currency,
"cost_center": inv.cost_center or default_cost_center,
"credit_in_account_currency": self.dunning_amount,
"project": inv.project,
},
item=inv,
)
)
make_gl_entries(
gl_entries, cancel=(self.docstatus == 2), update_outstanding="No", merge_entries=False
) )
row.dunning_level = len(past_dunnings) + 1
def resolve_dunning(doc, state): def resolve_dunning(doc, state):
"""
Check if all payments have been made and resolve dunning, if yes. Called
when a Payment Entry is submitted.
"""
for reference in doc.references: for reference in doc.references:
if reference.reference_doctype == "Sales Invoice" and reference.outstanding_amount <= 0: # Consider partial and full payments:
dunnings = frappe.get_list( # Submitting full payment: outstanding_amount will be 0
"Dunning", # Submitting 1st partial payment: outstanding_amount will be the pending installment
filters={"sales_invoice": reference.reference_name, "status": ("!=", "Resolved")}, # Cancelling full payment: outstanding_amount will revert to total amount
ignore_permissions=True, # Cancelling last partial payment: outstanding_amount will revert to pending amount
) submit_condition = reference.outstanding_amount < reference.total_amount
cancel_condition = reference.outstanding_amount <= reference.total_amount
if reference.reference_doctype == "Sales Invoice" and (
submit_condition if doc.docstatus == 1 else cancel_condition
):
state = "Resolved" if doc.docstatus == 2 else "Unresolved"
dunnings = get_linked_dunnings_as_per_state(reference.reference_name, state)
for dunning in dunnings: for dunning in dunnings:
frappe.db.set_value("Dunning", dunning.name, "status", "Resolved") resolve = True
dunning = frappe.get_doc("Dunning", dunning.get("name"))
for overdue_payment in dunning.overdue_payments:
outstanding_inv = frappe.get_value(
"Sales Invoice", overdue_payment.sales_invoice, "outstanding_amount"
)
outstanding_ps = frappe.get_value(
"Payment Schedule", overdue_payment.payment_schedule, "outstanding"
)
resolve = False if (outstanding_ps > 0 and outstanding_inv > 0) else True
dunning.status = "Resolved" if resolve else "Unresolved"
dunning.save()
def calculate_interest_and_amount(outstanding_amount, rate_of_interest, dunning_fee, overdue_days): def get_linked_dunnings_as_per_state(sales_invoice, state):
interest_amount = 0 dunning = frappe.qb.DocType("Dunning")
grand_total = flt(outstanding_amount) + flt(dunning_fee) overdue_payment = frappe.qb.DocType("Overdue Payment")
if rate_of_interest:
interest_per_year = flt(outstanding_amount) * flt(rate_of_interest) / 100 return (
interest_amount = (interest_per_year * cint(overdue_days)) / 365 frappe.qb.from_(dunning)
grand_total += flt(interest_amount) .join(overdue_payment)
dunning_amount = flt(interest_amount) + flt(dunning_fee) .on(overdue_payment.parent == dunning.name)
return { .select(dunning.name)
"interest_amount": interest_amount, .where(
"grand_total": grand_total, (dunning.status == state)
"dunning_amount": dunning_amount, & (dunning.docstatus != 2)
} & (overdue_payment.sales_invoice == sales_invoice)
)
).run(as_dict=True)
@frappe.whitelist() @frappe.whitelist()

View File

@ -1,12 +0,0 @@
from frappe import _
def get_data():
return {
"fieldname": "dunning",
"non_standard_fieldnames": {
"Journal Entry": "reference_name",
"Payment Entry": "reference_name",
},
"transactions": [{"label": _("Payment"), "items": ["Payment Entry", "Journal Entry"]}],
}

View File

@ -1,162 +1,197 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
import unittest
import frappe import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, nowdate, today from frappe.utils import add_days, nowdate, today
from erpnext.accounts.doctype.dunning.dunning import calculate_interest_and_amount from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import ( from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
unlink_payment_on_cancel_of_invoice, unlink_payment_on_cancel_of_invoice,
) )
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
create_dunning as create_dunning_from_sales_invoice,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
create_sales_invoice_against_cost_center, create_sales_invoice_against_cost_center,
) )
test_dependencies = ["Company", "Cost Center"]
class TestDunning(unittest.TestCase):
class TestDunning(FrappeTestCase):
@classmethod @classmethod
def setUpClass(self): def setUpClass(cls):
create_dunning_type() super().setUpClass()
create_dunning_type_with_zero_interest_rate() create_dunning_type("First Notice", fee=0.0, interest=0.0, is_default=1)
create_dunning_type("Second Notice", fee=10.0, interest=10.0, is_default=0)
unlink_payment_on_cancel_of_invoice() unlink_payment_on_cancel_of_invoice()
@classmethod @classmethod
def tearDownClass(self): def tearDownClass(cls):
unlink_payment_on_cancel_of_invoice(0) unlink_payment_on_cancel_of_invoice(0)
super().tearDownClass()
def test_dunning(self): def test_dunning_without_fees(self):
dunning = create_dunning() dunning = create_dunning(overdue_days=20)
amounts = calculate_interest_and_amount(
dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days
)
self.assertEqual(round(amounts.get("interest_amount"), 2), 0.44)
self.assertEqual(round(amounts.get("dunning_amount"), 2), 20.44)
self.assertEqual(round(amounts.get("grand_total"), 2), 120.44)
def test_dunning_with_zero_interest_rate(self): self.assertEqual(round(dunning.total_outstanding, 2), 100.00)
dunning = create_dunning_with_zero_interest_rate() self.assertEqual(round(dunning.total_interest, 2), 0.00)
amounts = calculate_interest_and_amount( self.assertEqual(round(dunning.dunning_fee, 2), 0.00)
dunning.outstanding_amount, dunning.rate_of_interest, dunning.dunning_fee, dunning.overdue_days self.assertEqual(round(dunning.dunning_amount, 2), 0.00)
) self.assertEqual(round(dunning.grand_total, 2), 100.00)
self.assertEqual(round(amounts.get("interest_amount"), 2), 0)
self.assertEqual(round(amounts.get("dunning_amount"), 2), 20)
self.assertEqual(round(amounts.get("grand_total"), 2), 120)
def test_gl_entries(self): def test_dunning_with_fees_and_interest(self):
dunning = create_dunning() dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit()
gl_entries = frappe.db.sql(
"""select account, debit, credit
from `tabGL Entry` where voucher_type='Dunning' and voucher_no=%s
order by account asc""",
dunning.name,
as_dict=1,
)
self.assertTrue(gl_entries)
expected_values = dict(
(d[0], d) for d in [["Debtors - _TC", 20.44, 0.0], ["Sales - _TC", 0.0, 20.44]]
)
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_payment_entry(self): self.assertEqual(round(dunning.total_outstanding, 2), 100.00)
dunning = create_dunning() self.assertEqual(round(dunning.total_interest, 2), 0.41)
self.assertEqual(round(dunning.dunning_fee, 2), 10.00)
self.assertEqual(round(dunning.dunning_amount, 2), 10.41)
self.assertEqual(round(dunning.grand_total, 2), 110.41)
def test_dunning_with_payment_entry(self):
dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC")
dunning.submit() dunning.submit()
pe = get_payment_entry("Dunning", dunning.name) pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no = "1" pe.reference_no = "1"
pe.reference_date = nowdate() pe.reference_date = nowdate()
pe.paid_from_account_currency = dunning.currency
pe.paid_to_account_currency = dunning.currency
pe.source_exchange_rate = 1
pe.target_exchange_rate = 1
pe.insert() pe.insert()
pe.submit() pe.submit()
si_doc = frappe.get_doc("Sales Invoice", dunning.sales_invoice)
self.assertEqual(si_doc.outstanding_amount, 0)
for overdue_payment in dunning.overdue_payments:
def create_dunning(): outstanding_amount = frappe.get_value(
posting_date = add_days(today(), -20) "Sales Invoice", overdue_payment.sales_invoice, "outstanding_amount"
due_date = add_days(today(), -15)
sales_invoice = create_sales_invoice_against_cost_center(
posting_date=posting_date, due_date=due_date, status="Overdue"
) )
dunning_type = frappe.get_doc("Dunning Type", "First Notice") self.assertEqual(outstanding_amount, 0)
dunning = frappe.new_doc("Dunning")
dunning.sales_invoice = sales_invoice.name dunning.reload()
dunning.customer_name = sales_invoice.customer_name self.assertEqual(dunning.status, "Resolved")
dunning.outstanding_amount = sales_invoice.outstanding_amount
dunning.debit_to = sales_invoice.debit_to def test_dunning_and_payment_against_partially_due_invoice(self):
dunning.currency = sales_invoice.currency """
dunning.company = sales_invoice.company Create SI with first installment overdue. Check impact of Dunning and Payment Entry.
dunning.posting_date = nowdate() """
dunning.due_date = sales_invoice.due_date create_payment_terms_template_for_dunning()
dunning.dunning_type = "First Notice" sales_invoice = create_sales_invoice_against_cost_center(
posting_date=add_days(today(), -1 * 6),
qty=1,
rate=100,
do_not_submit=True,
)
sales_invoice.payment_terms_template = "_Test 50-50 for Dunning"
sales_invoice.submit()
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
self.assertEqual(len(dunning.overdue_payments), 1)
self.assertEqual(dunning.overdue_payments[0].payment_term, "_Test Payment Term 1 for Dunning")
dunning.submit()
pe = get_payment_entry("Dunning", dunning.name)
pe.reference_no, pe.reference_date = "2", nowdate()
pe.insert()
pe.submit()
sales_invoice.load_from_db()
dunning.load_from_db()
self.assertEqual(sales_invoice.status, "Partly Paid")
self.assertEqual(sales_invoice.payment_schedule[0].outstanding, 0)
self.assertEqual(dunning.status, "Resolved")
# Test impact on cancellation of PE
pe.cancel()
sales_invoice.reload()
dunning.reload()
self.assertEqual(sales_invoice.status, "Overdue")
self.assertEqual(dunning.status, "Unresolved")
def create_dunning(overdue_days, dunning_type_name=None):
posting_date = add_days(today(), -1 * overdue_days)
sales_invoice = create_sales_invoice_against_cost_center(
posting_date=posting_date, qty=1, rate=100
)
dunning = create_dunning_from_sales_invoice(sales_invoice.name)
if dunning_type_name:
dunning_type = frappe.get_doc("Dunning Type", dunning_type_name)
dunning.dunning_type = dunning_type.name
dunning.rate_of_interest = dunning_type.rate_of_interest dunning.rate_of_interest = dunning_type.rate_of_interest
dunning.dunning_fee = dunning_type.dunning_fee dunning.dunning_fee = dunning_type.dunning_fee
dunning.save() dunning.income_account = dunning_type.income_account
return dunning dunning.cost_center = dunning_type.cost_center
return dunning.save()
def create_dunning_with_zero_interest_rate(): def create_dunning_type(title, fee, interest, is_default):
posting_date = add_days(today(), -20) company = "_Test Company"
due_date = add_days(today(), -15) if frappe.db.exists("Dunning Type", f"{title} - _TC"):
sales_invoice = create_sales_invoice_against_cost_center( return
posting_date=posting_date, due_date=due_date, status="Overdue"
)
dunning_type = frappe.get_doc("Dunning Type", "First Notice with 0% Rate of Interest")
dunning = frappe.new_doc("Dunning")
dunning.sales_invoice = sales_invoice.name
dunning.customer_name = sales_invoice.customer_name
dunning.outstanding_amount = sales_invoice.outstanding_amount
dunning.debit_to = sales_invoice.debit_to
dunning.currency = sales_invoice.currency
dunning.company = sales_invoice.company
dunning.posting_date = nowdate()
dunning.due_date = sales_invoice.due_date
dunning.dunning_type = "First Notice with 0% Rate of Interest"
dunning.rate_of_interest = dunning_type.rate_of_interest
dunning.dunning_fee = dunning_type.dunning_fee
dunning.save()
return dunning
def create_dunning_type():
dunning_type = frappe.new_doc("Dunning Type") dunning_type = frappe.new_doc("Dunning Type")
dunning_type.dunning_type = "First Notice" dunning_type.dunning_type = title
dunning_type.start_day = 10 dunning_type.company = company
dunning_type.end_day = 20 dunning_type.is_default = is_default
dunning_type.dunning_fee = 20 dunning_type.dunning_fee = fee
dunning_type.rate_of_interest = 8 dunning_type.rate_of_interest = interest
dunning_type.income_account = get_income_account(company)
dunning_type.cost_center = get_default_cost_center(company)
dunning_type.append( dunning_type.append(
"dunning_letter_text", "dunning_letter_text",
{ {
"language": "en", "language": "en",
"body_text": "We have still not received payment for our invoice ", "body_text": "We have still not received payment for our invoice",
"closing_text": "We kindly request that you pay the outstanding amount immediately, including interest and late fees.", "closing_text": "We kindly request that you pay the outstanding amount immediately, including interest and late fees.",
}, },
) )
dunning_type.save() dunning_type.insert()
def create_dunning_type_with_zero_interest_rate(): def get_income_account(company):
dunning_type = frappe.new_doc("Dunning Type") return (
dunning_type.dunning_type = "First Notice with 0% Rate of Interest" frappe.get_value("Company", company, "default_income_account")
dunning_type.start_day = 10 or frappe.get_all(
dunning_type.end_day = 20 "Account",
dunning_type.dunning_fee = 20 filters={"is_group": 0, "company": company},
dunning_type.rate_of_interest = 0 or_filters={
dunning_type.append( "report_type": "Profit and Loss",
"dunning_letter_text", "account_type": ("in", ("Income Account", "Temporary")),
{
"language": "en",
"body_text": "We have still not received payment for our invoice ",
"closing_text": "We kindly request that you pay the outstanding amount immediately, and late fees.",
}, },
limit=1,
pluck="name",
)[0]
) )
dunning_type.save()
def create_payment_terms_template_for_dunning():
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_term
create_payment_term("_Test Payment Term 1 for Dunning")
create_payment_term("_Test Payment Term 2 for Dunning")
if not frappe.db.exists("Payment Terms Template", "_Test 50-50 for Dunning"):
frappe.get_doc(
{
"doctype": "Payment Terms Template",
"template_name": "_Test 50-50 for Dunning",
"allocate_payment_based_on_payment_terms": 1,
"terms": [
{
"doctype": "Payment Terms Template Detail",
"payment_term": "_Test Payment Term 1 for Dunning",
"invoice_portion": 50.00,
"credit_days_based_on": "Day(s) after invoice date",
"credit_days": 5,
},
{
"doctype": "Payment Terms Template Detail",
"payment_term": "_Test Payment Term 2 for Dunning",
"invoice_portion": 50.00,
"credit_days_based_on": "Day(s) after invoice date",
"credit_days": 10,
},
],
}
).insert()

View File

@ -1,8 +1,24 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // For license information, please see license.txt
frappe.ui.form.on('Dunning Type', { frappe.ui.form.on("Dunning Type", {
// refresh: function(frm) { setup: function (frm) {
frm.set_query("income_account", () => {
// } return {
filters: {
root_type: "Income",
is_group: 0,
company: frm.doc.company,
},
};
});
frm.set_query("cost_center", () => {
return {
filters: {
is_group: 0,
company: frm.doc.company,
},
};
});
},
}); });

View File

@ -1,23 +1,26 @@
{ {
"actions": [], "actions": [],
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:dunning_type", "beta": 1,
"creation": "2019-12-04 04:59:08.003664", "creation": "2019-12-04 04:59:08.003664",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"dunning_type", "dunning_type",
"overdue_interval_section", "is_default",
"start_day", "column_break_3",
"column_break_4", "company",
"end_day",
"section_break_6", "section_break_6",
"dunning_fee", "dunning_fee",
"column_break_8", "column_break_8",
"rate_of_interest", "rate_of_interest",
"text_block_section", "text_block_section",
"dunning_letter_text" "dunning_letter_text",
"section_break_9",
"income_account",
"column_break_13",
"cost_center"
], ],
"fields": [ "fields": [
{ {
@ -45,10 +48,6 @@
"fieldtype": "Table", "fieldtype": "Table",
"options": "Dunning Letter Text" "options": "Dunning Letter Text"
}, },
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{ {
"fieldname": "section_break_6", "fieldname": "section_break_6",
"fieldtype": "Section Break" "fieldtype": "Section Break"
@ -57,33 +56,62 @@
"fieldname": "column_break_8", "fieldname": "column_break_8",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "overdue_interval_section",
"fieldtype": "Section Break",
"label": "Overdue Interval"
},
{
"fieldname": "start_day",
"fieldtype": "Int",
"label": "Start Day"
},
{
"fieldname": "end_day",
"fieldtype": "Int",
"label": "End Day"
},
{ {
"fieldname": "rate_of_interest", "fieldname": "rate_of_interest",
"fieldtype": "Float", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Rate of Interest (%) Yearly" "label": "Rate of Interest (%) Yearly"
},
{
"default": "0",
"fieldname": "is_default",
"fieldtype": "Check",
"label": "Is Default"
},
{
"fieldname": "section_break_9",
"fieldtype": "Section Break",
"label": "Accounting Details"
},
{
"fieldname": "income_account",
"fieldtype": "Link",
"label": "Income Account",
"options": "Account"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
},
{
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "column_break_13",
"fieldtype": "Column Break"
} }
], ],
"links": [], "links": [
"modified": "2020-07-15 17:14:17.835074", {
"link_doctype": "Dunning",
"link_fieldname": "dunning_type"
}
],
"modified": "2021-11-13 00:25:35.659283",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Dunning Type", "name": "Dunning Type",
"naming_rule": "By script",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View File

@ -2,9 +2,11 @@
# For license information, please see license.txt # For license information, please see license.txt
# import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
class DunningType(Document): class DunningType(Document):
pass def autoname(self):
company_abbr = frappe.get_value("Company", self.company, "abbr")
self.name = f"{self.dunning_type} - {company_abbr}"

View File

@ -0,0 +1,36 @@
[
{
"doctype": "Dunning Type",
"dunning_type": "_Test First Notice",
"company": "_Test Company",
"is_default": 1,
"dunning_fee": 0.0,
"rate_of_interest": 0.0,
"dunning_letter_text": [
{
"language": "en",
"body_text": "We have still not received payment for our invoice",
"closing_text": "We kindly request that you pay the outstanding amount immediately, including interest and late fees."
}
],
"income_account": "Sales - _TC",
"cost_center": "_Test Cost Center - _TC"
},
{
"doctype": "Dunning Type",
"dunning_type": "_Test Second Notice",
"company": "_Test Company",
"is_default": 0,
"dunning_fee": 10.0,
"rate_of_interest": 10.0,
"dunning_letter_text": [
{
"language": "en",
"body_text": "We have still not received payment for our invoice",
"closing_text": "We kindly request that you pay the outstanding amount immediately, including interest and late fees."
}
],
"income_account": "Sales - _TC",
"cost_center": "_Test Cost Center - _TC"
}
]

View File

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

View File

@ -58,6 +58,13 @@ class GLEntry(Document):
validate_balance_type(self.account, adv_adj) validate_balance_type(self.account, adv_adj)
validate_frozen_account(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 [ if frappe.get_cached_value("Account", self.account, "account_type") not in [
"Receivable", "Receivable",
"Payable", "Payable",

View File

@ -8,7 +8,7 @@ frappe.provide("erpnext.journal_entry");
frappe.ui.form.on("Journal Entry", { frappe.ui.form.on("Journal Entry", {
setup: function(frm) { setup: function(frm) {
frm.add_fetch("bank_account", "account", "account"); 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']; frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', "Repost Payment Ledger", 'Asset', 'Asset Movement', 'Asset Depreciation Schedule', "Repost Accounting Ledger"];
}, },
refresh: function(frm) { refresh: function(frm) {
@ -264,11 +264,11 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
} }
if(jvd.party_type && jvd.party) { if(jvd.party_type && jvd.party) {
var party_field = ""; let party_field = "";
if(jvd.reference_type.indexOf("Sales")===0) { if(jvd.reference_type.indexOf("Sales")===0) {
var party_field = "customer"; party_field = "customer";
} else if (jvd.reference_type.indexOf("Purchase")===0) { } else if (jvd.reference_type.indexOf("Purchase")===0) {
var party_field = "supplier"; party_field = "supplier";
} }
if (party_field) { if (party_field) {
@ -368,7 +368,7 @@ cur_frm.cscript.update_totals = function(doc) {
td += flt(accounts[i].debit, precision("debit", accounts[i])); td += flt(accounts[i].debit, precision("debit", accounts[i]));
tc += flt(accounts[i].credit, precision("credit", accounts[i])); tc += flt(accounts[i].credit, precision("credit", accounts[i]));
} }
var doc = locals[doc.doctype][doc.name]; doc = locals[doc.doctype][doc.name];
doc.total_debit = td; doc.total_debit = td;
doc.total_credit = tc; doc.total_credit = tc;
doc.difference = flt((td - tc), precision("difference")); doc.difference = flt((td - tc), precision("difference"));

View File

@ -9,6 +9,7 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"entry_type_and_date", "entry_type_and_date",
"is_system_generated",
"title", "title",
"voucher_type", "voucher_type",
"naming_series", "naming_series",
@ -533,13 +534,22 @@
"label": "Process Deferred Accounting", "label": "Process Deferred Accounting",
"options": "Process Deferred Accounting", "options": "Process Deferred Accounting",
"read_only": 1 "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", "icon": "fa fa-file-text",
"idx": 176, "idx": 176,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-03-01 14:58:59.286591", "modified": "2023-08-10 14:32:22.366895",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Journal Entry", "name": "Journal Entry",

View File

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

View File

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

View File

@ -203,7 +203,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"label": "Reference Type", "label": "Reference Type",
"no_copy": 1, "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" "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"
}, },
{ {
"fieldname": "reference_name", "fieldname": "reference_name",
@ -284,7 +284,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-10-26 20:03:10.906259", "modified": "2023-06-16 14:11:13.507807",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Journal Entry Account", "name": "Journal Entry Account",

View File

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

View File

@ -0,0 +1,170 @@
{
"actions": [],
"creation": "2021-09-15 18:34:27.172906",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"sales_invoice",
"payment_schedule",
"dunning_level",
"payment_term",
"section_break_15",
"description",
"section_break_4",
"due_date",
"overdue_days",
"mode_of_payment",
"column_break_5",
"invoice_portion",
"section_break_16",
"payment_amount",
"outstanding",
"paid_amount",
"discounted_amount",
"interest"
],
"fields": [
{
"columns": 2,
"fieldname": "payment_term",
"fieldtype": "Link",
"label": "Payment Term",
"options": "Payment Term",
"print_hide": 1,
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "section_break_15",
"fieldtype": "Section Break",
"label": "Description"
},
{
"columns": 2,
"fetch_from": "payment_term.description",
"fieldname": "description",
"fieldtype": "Small Text",
"label": "Description",
"read_only": 1
},
{
"fieldname": "section_break_4",
"fieldtype": "Section Break"
},
{
"columns": 2,
"fieldname": "due_date",
"fieldtype": "Date",
"label": "Due Date",
"read_only": 1
},
{
"fieldname": "mode_of_payment",
"fieldtype": "Link",
"label": "Mode of Payment",
"options": "Mode of Payment",
"read_only": 1
},
{
"fieldname": "column_break_5",
"fieldtype": "Column Break"
},
{
"columns": 2,
"fieldname": "invoice_portion",
"fieldtype": "Percent",
"label": "Invoice Portion",
"read_only": 1
},
{
"columns": 2,
"fieldname": "payment_amount",
"fieldtype": "Currency",
"label": "Payment Amount",
"options": "currency",
"read_only": 1
},
{
"fetch_from": "payment_amount",
"fieldname": "outstanding",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Outstanding",
"options": "currency",
"read_only": 1
},
{
"depends_on": "paid_amount",
"fieldname": "paid_amount",
"fieldtype": "Currency",
"label": "Paid Amount",
"options": "currency"
},
{
"default": "0",
"depends_on": "discounted_amount",
"fieldname": "discounted_amount",
"fieldtype": "Currency",
"label": "Discounted Amount",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "sales_invoice",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Sales Invoice",
"options": "Sales Invoice",
"read_only": 1,
"reqd": 1
},
{
"fieldname": "payment_schedule",
"fieldtype": "Data",
"label": "Payment Schedule",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "overdue_days",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Overdue Days",
"read_only": 1
},
{
"default": "1",
"fieldname": "dunning_level",
"fieldtype": "Int",
"in_list_view": 1,
"label": "Dunning Level",
"read_only": 1
},
{
"fieldname": "section_break_16",
"fieldtype": "Section Break"
},
{
"fieldname": "interest",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Interest",
"options": "currency",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-09-23 13:48:27.898830",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Overdue Payment",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

View File

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

View File

@ -1,13 +1,15 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // For license information, please see license.txt
{% include "erpnext/public/js/controllers/accounts.js" %}
frappe.provide("erpnext.accounts.dimensions"); frappe.provide("erpnext.accounts.dimensions");
cur_frm.cscript.tax_table = "Advance Taxes and Charges"; cur_frm.cscript.tax_table = "Advance Taxes and Charges";
erpnext.accounts.taxes.setup_tax_validations("Payment Entry");
erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
frappe.ui.form.on('Payment Entry', { frappe.ui.form.on('Payment Entry', {
onload: function(frm) { onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "Repost Payment Ledger"]; frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger'];
if(frm.doc.__islocal) { if(frm.doc.__islocal) {
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null); if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
@ -106,12 +108,11 @@ frappe.ui.form.on('Payment Entry', {
}); });
frm.set_query("reference_doctype", "references", function() { frm.set_query("reference_doctype", "references", function() {
let doctypes = ["Journal Entry"];
if (frm.doc.party_type == "Customer") { if (frm.doc.party_type == "Customer") {
var doctypes = ["Sales Order", "Sales Invoice", "Journal Entry", "Dunning"]; doctypes = ["Sales Order", "Sales Invoice", "Journal Entry", "Dunning"];
} else if (frm.doc.party_type == "Supplier") { } else if (frm.doc.party_type == "Supplier") {
var doctypes = ["Purchase Order", "Purchase Invoice", "Journal Entry"]; doctypes = ["Purchase Order", "Purchase Invoice", "Journal Entry"];
} else {
var doctypes = ["Journal Entry"];
} }
return { return {
@ -122,13 +123,10 @@ frappe.ui.form.on('Payment Entry', {
frm.set_query('payment_term', 'references', function(frm, cdt, cdn) { frm.set_query('payment_term', 'references', function(frm, cdt, cdn) {
const child = locals[cdt][cdn]; const child = locals[cdt][cdn];
if (in_list(['Purchase Invoice', 'Sales Invoice'], child.reference_doctype) && child.reference_name) { if (in_list(['Purchase Invoice', 'Sales Invoice'], child.reference_doctype) && child.reference_name) {
let payment_term_list = frappe.get_list('Payment Schedule', {'parent': child.reference_name});
payment_term_list = payment_term_list.map(pt => pt.payment_term);
return { return {
query: "erpnext.controllers.queries.get_payment_terms_for_references",
filters: { filters: {
'name': ['in', payment_term_list] 'reference': child.reference_name
} }
} }
} }
@ -165,6 +163,7 @@ frappe.ui.form.on('Payment Entry', {
}, },
company: function(frm) { company: function(frm) {
frm.trigger('party');
frm.events.hide_unhide_fields(frm); frm.events.hide_unhide_fields(frm);
frm.events.set_dynamic_labels(frm); frm.events.set_dynamic_labels(frm);
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
@ -287,6 +286,13 @@ frappe.ui.form.on('Payment Entry', {
} }
}, },
mode_of_payment: function(frm) {
erpnext.accounts.pos.get_payment_mode_account(frm, frm.doc.mode_of_payment, function(account){
let payment_account_field = frm.doc.payment_type == "Receive" ? "paid_to" : "paid_from";
frm.set_value(payment_account_field, account);
})
},
party_type: function(frm) { party_type: function(frm) {
let party_types = Object.keys(frappe.boot.party_account_types); let party_types = Object.keys(frappe.boot.party_account_types);
@ -319,10 +325,6 @@ frappe.ui.form.on('Payment Entry', {
} }
}, },
company: function(frm){
frm.trigger('party');
},
party: function(frm) { party: function(frm) {
if (frm.doc.contact_email || frm.doc.contact_person) { if (frm.doc.contact_email || frm.doc.contact_person) {
frm.set_value("contact_email", ""); frm.set_value("contact_email", "");
@ -901,12 +903,12 @@ frappe.ui.form.on('Payment Entry', {
if(frm.doc.payment_type == "Receive" if(frm.doc.payment_type == "Receive"
&& frm.doc.base_total_allocated_amount < frm.doc.base_received_amount + total_deductions && frm.doc.base_total_allocated_amount < frm.doc.base_received_amount + total_deductions
&& frm.doc.total_allocated_amount < frm.doc.paid_amount + (total_deductions / frm.doc.source_exchange_rate)) { && frm.doc.total_allocated_amount < frm.doc.paid_amount + (total_deductions / frm.doc.source_exchange_rate)) {
unallocated_amount = (frm.doc.base_received_amount + total_deductions + frm.doc.base_total_taxes_and_charges unallocated_amount = (frm.doc.base_received_amount + total_deductions + flt(frm.doc.base_total_taxes_and_charges)
- frm.doc.base_total_allocated_amount) / frm.doc.source_exchange_rate; - frm.doc.base_total_allocated_amount) / frm.doc.source_exchange_rate;
} else if (frm.doc.payment_type == "Pay" } else if (frm.doc.payment_type == "Pay"
&& frm.doc.base_total_allocated_amount < frm.doc.base_paid_amount - total_deductions && frm.doc.base_total_allocated_amount < frm.doc.base_paid_amount - total_deductions
&& frm.doc.total_allocated_amount < frm.doc.received_amount + (total_deductions / frm.doc.target_exchange_rate)) { && frm.doc.total_allocated_amount < frm.doc.received_amount + (total_deductions / frm.doc.target_exchange_rate)) {
unallocated_amount = (frm.doc.base_paid_amount + frm.doc.base_total_taxes_and_charges - (total_deductions unallocated_amount = (frm.doc.base_paid_amount + flt(frm.doc.base_total_taxes_and_charges) - (total_deductions
+ frm.doc.base_total_allocated_amount)) / frm.doc.target_exchange_rate; + frm.doc.base_total_allocated_amount)) / frm.doc.target_exchange_rate;
} }
} }
@ -1106,7 +1108,7 @@ frappe.ui.form.on('Payment Entry', {
if (tax.charge_type === 'On Net Total') { if (tax.charge_type === 'On Net Total') {
tax.charge_type = 'On Paid Amount'; tax.charge_type = 'On Paid Amount';
} }
me.frm.add_child("taxes", tax); frm.add_child("taxes", tax);
} }
frm.events.apply_taxes(frm); frm.events.apply_taxes(frm);
frm.events.set_unallocated_amount(frm); frm.events.set_unallocated_amount(frm);
@ -1222,7 +1224,7 @@ frappe.ui.form.on('Payment Entry', {
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item; tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item;
} else { } else {
tax.grand_total_fraction_for_current_item = tax.grand_total_fraction_for_current_item =
me.frm.doc["taxes"][i-1].grand_total_fraction_for_current_item + frm.doc["taxes"][i-1].grand_total_fraction_for_current_item +
tax.tax_fraction_for_current_item; tax.tax_fraction_for_current_item;
} }
@ -1269,7 +1271,7 @@ frappe.ui.form.on('Payment Entry', {
} }
}); });
$.each(me.frm.doc["taxes"] || [], function(i, tax) { $.each(frm.doc["taxes"] || [], function(i, tax) {
let current_tax_amount = frm.events.get_current_tax_amount(frm, tax); let current_tax_amount = frm.events.get_current_tax_amount(frm, tax);
// Adjust divisional loss to the last item // Adjust divisional loss to the last item

View File

@ -28,7 +28,12 @@ from erpnext.accounts.general_ledger import (
process_gl_map, process_gl_map,
) )
from erpnext.accounts.party import get_party_account from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import get_account_currency, get_balance_on, get_outstanding_invoices from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
get_balance_on,
get_outstanding_invoices,
)
from erpnext.controllers.accounts_controller import ( from erpnext.controllers.accounts_controller import (
AccountsController, AccountsController,
get_supplier_block_status, get_supplier_block_status,
@ -66,7 +71,7 @@ class PaymentEntry(AccountsController):
self.setup_party_account_field() self.setup_party_account_field()
self.set_missing_values() self.set_missing_values()
self.set_liability_account() self.set_liability_account()
self.set_missing_ref_details() self.set_missing_ref_details(force=True)
self.validate_payment_type() self.validate_payment_type()
self.validate_party_details() self.validate_party_details()
self.set_exchange_rate() self.set_exchange_rate()
@ -142,7 +147,10 @@ class PaymentEntry(AccountsController):
"Payment Ledger Entry", "Payment Ledger Entry",
"Repost Payment Ledger", "Repost Payment Ledger",
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger",
"Repost Accounting Ledger Items",
) )
super(PaymentEntry, self).on_cancel()
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
self.make_advance_gl_entries(cancel=1) self.make_advance_gl_entries(cancel=1)
self.update_outstanding_amounts() self.update_outstanding_amounts()
@ -207,7 +215,24 @@ class PaymentEntry(AccountsController):
if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(d.outstanding_amount): if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(d.outstanding_amount):
frappe.throw(fail_message.format(d.idx)) frappe.throw(fail_message.format(d.idx))
def term_based_allocation_enabled_for_reference(
self, reference_doctype: str, reference_name: str
) -> bool:
if (
reference_doctype
and reference_doctype in ["Sales Invoice", "Sales Order", "Purchase Order", "Purchase Invoice"]
and reference_name
):
if template := frappe.db.get_value(reference_doctype, reference_name, "payment_terms_template"):
return frappe.db.get_value(
"Payment Terms Template", template, "allocate_payment_based_on_payment_terms"
)
return False
def validate_allocated_amount_with_latest_data(self): 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( latest_references = get_outstanding_reference_documents(
{ {
"posting_date": self.posting_date, "posting_date": self.posting_date,
@ -218,6 +243,7 @@ class PaymentEntry(AccountsController):
"party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to, "party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to,
"get_outstanding_invoices": True, "get_outstanding_invoices": True,
"get_orders_to_be_billed": True, "get_orders_to_be_billed": True,
"vouchers": vouchers,
}, },
validate=True, validate=True,
) )
@ -226,10 +252,25 @@ class PaymentEntry(AccountsController):
latest_lookup = {} latest_lookup = {}
for d in latest_references: for d in latest_references:
d = frappe._dict(d) d = frappe._dict(d)
latest_lookup.update({(d.voucher_type, d.voucher_no): d}) latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d
for d in self.get("references"): for idx, d in enumerate(self.get("references"), start=1):
latest = latest_lookup.get((d.reference_doctype, d.reference_name)) 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 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 # The reference has already been fully paid
if not latest: if not latest:
@ -248,6 +289,23 @@ class PaymentEntry(AccountsController):
fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.") 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): if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount):
frappe.throw(fail_message.format(d.idx)) frappe.throw(fail_message.format(d.idx))
@ -358,7 +416,7 @@ class PaymentEntry(AccountsController):
else: else:
if ref_doc: if ref_doc:
if self.paid_from_account_currency == ref_doc.currency: if self.paid_from_account_currency == ref_doc.currency:
self.source_exchange_rate = ref_doc.get("exchange_rate") self.source_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
if not self.source_exchange_rate: if not self.source_exchange_rate:
self.source_exchange_rate = get_exchange_rate( self.source_exchange_rate = get_exchange_rate(
@ -371,7 +429,7 @@ class PaymentEntry(AccountsController):
elif self.paid_to and not self.target_exchange_rate: elif self.paid_to and not self.target_exchange_rate:
if ref_doc: if ref_doc:
if self.paid_to_account_currency == ref_doc.currency: if self.paid_to_account_currency == ref_doc.currency:
self.target_exchange_rate = ref_doc.get("exchange_rate") self.target_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
if not self.target_exchange_rate: if not self.target_exchange_rate:
self.target_exchange_rate = get_exchange_rate( self.target_exchange_rate = get_exchange_rate(
@ -473,7 +531,7 @@ class PaymentEntry(AccountsController):
_( _(
"References {0} of type {1} had no outstanding amount left before submitting the Payment Entry. Now they have a negative outstanding amount." "References {0} of type {1} had no outstanding amount left before submitting the Payment Entry. Now they have a negative outstanding amount."
).format( ).format(
frappe.bold(comma_and((d.reference_name for d in references))), frappe.bold(comma_and([d.reference_name for d in references])),
_(reference_doctype), _(reference_doctype),
) )
+ "<br><br>" + "<br><br>"
@ -636,7 +694,9 @@ class PaymentEntry(AccountsController):
if not self.apply_tax_withholding_amount: if not self.apply_tax_withholding_amount:
return return
net_total = self.paid_amount order_amount = self.get_order_net_total()
net_total = flt(order_amount) + flt(self.unallocated_amount)
# Adding args as purchase invoice to get TDS amount # Adding args as purchase invoice to get TDS amount
args = frappe._dict( args = frappe._dict(
@ -681,6 +741,20 @@ class PaymentEntry(AccountsController):
for d in to_remove: for d in to_remove:
self.remove(d) 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): def apply_taxes(self):
self.initialize_taxes() self.initialize_taxes()
self.determine_exclusive_rate() self.determine_exclusive_rate()
@ -767,10 +841,25 @@ class PaymentEntry(AccountsController):
flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount") flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
) )
else: 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( base_allocated_amount += flt(
flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount") flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount")
) )
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 return base_allocated_amount
def set_total_allocated_amount(self): def set_total_allocated_amount(self):
@ -961,6 +1050,10 @@ class PaymentEntry(AccountsController):
gl_entries = self.build_gl_map() gl_entries = self.build_gl_map()
gl_entries = process_gl_map(gl_entries) gl_entries = process_gl_map(gl_entries)
make_gl_entries(gl_entries, cancel=cancel, adv_adj=adv_adj) 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()
def add_party_gl_entries(self, gl_entries): def add_party_gl_entries(self, gl_entries):
if self.party_account: if self.party_account:
@ -1498,9 +1591,12 @@ def get_outstanding_reference_documents(args, validate=False):
min_outstanding=args.get("outstanding_amt_greater_than"), min_outstanding=args.get("outstanding_amt_greater_than"),
max_outstanding=args.get("outstanding_amt_less_than"), max_outstanding=args.get("outstanding_amt_less_than"),
accounting_dimensions=accounting_dimensions_filter, accounting_dimensions=accounting_dimensions_filter,
vouchers=args.get("vouchers") or None,
) )
outstanding_invoices = split_invoices_based_on_payment_terms(outstanding_invoices) outstanding_invoices = split_invoices_based_on_payment_terms(
outstanding_invoices, args.get("company")
)
for d in outstanding_invoices: for d in outstanding_invoices:
d["exchange_rate"] = 1 d["exchange_rate"] = 1
@ -1560,8 +1656,27 @@ def get_outstanding_reference_documents(args, validate=False):
return data return data
def split_invoices_based_on_payment_terms(outstanding_invoices): def split_invoices_based_on_payment_terms(outstanding_invoices, company):
invoice_ref_based_on_payment_terms = {} invoice_ref_based_on_payment_terms = {}
company_currency = (
frappe.db.get_value("Company", company, "default_currency") if company else None
)
exc_rates = frappe._dict()
for doctype in ["Sales Invoice", "Purchase Invoice"]:
invoices = [x.voucher_no for x in outstanding_invoices if x.voucher_type == doctype]
for x in frappe.db.get_all(
doctype,
filters={"name": ["in", invoices]},
fields=["name", "currency", "conversion_rate", "party_account_currency"],
):
exc_rates[x.name] = frappe._dict(
conversion_rate=x.conversion_rate,
currency=x.currency,
party_account_currency=x.party_account_currency,
company_currency=company_currency,
)
for idx, d in enumerate(outstanding_invoices): for idx, d in enumerate(outstanding_invoices):
if d.voucher_type in ["Sales Invoice", "Purchase Invoice"]: if d.voucher_type in ["Sales Invoice", "Purchase Invoice"]:
payment_term_template = frappe.db.get_value( payment_term_template = frappe.db.get_value(
@ -1578,6 +1693,14 @@ def split_invoices_based_on_payment_terms(outstanding_invoices):
for payment_term in payment_schedule: for payment_term in payment_schedule:
if payment_term.outstanding > 0.1: if payment_term.outstanding > 0.1:
doc_details = exc_rates.get(payment_term.parent, None)
is_multi_currency_acc = (doc_details.currency != doc_details.company_currency) and (
doc_details.party_account_currency != doc_details.company_currency
)
payment_term_outstanding = flt(payment_term.outstanding)
if not is_multi_currency_acc:
payment_term_outstanding = doc_details.conversion_rate * flt(payment_term.outstanding)
invoice_ref_based_on_payment_terms.setdefault(idx, []) invoice_ref_based_on_payment_terms.setdefault(idx, [])
invoice_ref_based_on_payment_terms[idx].append( invoice_ref_based_on_payment_terms[idx].append(
frappe._dict( frappe._dict(
@ -1589,6 +1712,10 @@ def split_invoices_based_on_payment_terms(outstanding_invoices):
"posting_date": d.posting_date, "posting_date": d.posting_date,
"invoice_amount": flt(d.invoice_amount), "invoice_amount": flt(d.invoice_amount),
"outstanding_amount": flt(d.outstanding_amount), "outstanding_amount": flt(d.outstanding_amount),
"payment_term_outstanding": payment_term_outstanding,
"allocated_amount": payment_term_outstanding
if payment_term_outstanding
else d.outstanding_amount,
"payment_amount": payment_term.payment_amount, "payment_amount": payment_term.payment_amount,
"payment_term": payment_term.payment_term, "payment_term": payment_term.payment_term,
"account": d.account, "account": d.account,
@ -1664,7 +1791,7 @@ def get_orders_to_be_billed(
{party_type} = %s {party_type} = %s
and docstatus = 1 and docstatus = 1
and company = %s and company = %s
and ifnull(status, "") != "Closed" and status != "Closed"
and if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) > advance_paid and if({rounded_total_field}, {rounded_total_field}, {grand_total_field}) > advance_paid
and abs(100 - per_billed) > 0.01 and abs(100 - per_billed) > 0.01
{condition} {condition}
@ -1914,7 +2041,6 @@ def get_payment_entry(
payment_type=None, payment_type=None,
reference_date=None, reference_date=None,
): ):
reference_doc = None
doc = frappe.get_doc(dt, dn) doc = frappe.get_doc(dt, dn)
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance") 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) >= ( if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) >= (
@ -2010,28 +2136,27 @@ def get_payment_entry(
pe.append("references", reference) pe.append("references", reference)
else: else:
if dt == "Dunning": if dt == "Dunning":
for overdue_payment in doc.overdue_payments:
pe.append( pe.append(
"references", "references",
{ {
"reference_doctype": "Sales Invoice", "reference_doctype": "Sales Invoice",
"reference_name": doc.get("sales_invoice"), "reference_name": overdue_payment.sales_invoice,
"bill_no": doc.get("bill_no"), "payment_term": overdue_payment.payment_term,
"due_date": doc.get("due_date"), "due_date": overdue_payment.due_date,
"total_amount": doc.get("outstanding_amount"), "total_amount": overdue_payment.outstanding,
"outstanding_amount": doc.get("outstanding_amount"), "outstanding_amount": overdue_payment.outstanding,
"allocated_amount": doc.get("outstanding_amount"), "allocated_amount": overdue_payment.outstanding,
}, },
) )
pe.append( pe.append(
"references", "deductions",
{ {
"reference_doctype": dt, "account": doc.income_account,
"reference_name": dn, "cost_center": doc.cost_center,
"bill_no": doc.get("bill_no"), "amount": -1 * doc.dunning_amount,
"due_date": doc.get("due_date"), "description": _("Interest and/or dunning fee"),
"total_amount": doc.get("dunning_amount"),
"outstanding_amount": doc.get("dunning_amount"),
"allocated_amount": doc.get("dunning_amount"),
}, },
) )
else: else:
@ -2055,7 +2180,7 @@ def get_payment_entry(
update_accounting_dimensions(pe, doc) update_accounting_dimensions(pe, doc)
if party_account and bank: if party_account and bank:
pe.set_exchange_rate(ref_doc=reference_doc) pe.set_exchange_rate(ref_doc=doc)
pe.set_amounts() pe.set_amounts()
if discount_amount: if discount_amount:
@ -2125,8 +2250,10 @@ def set_party_account_currency(dt, party_account, doc):
def set_payment_type(dt, doc): def set_payment_type(dt, doc):
if ( if (
dt == "Sales Order" or (dt in ("Sales Invoice", "Dunning") and doc.outstanding_amount > 0) (dt == "Sales Order" or (dt == "Sales Invoice" and doc.outstanding_amount > 0))
) or (dt == "Purchase Invoice" and doc.outstanding_amount < 0): or (dt == "Purchase Invoice" and doc.outstanding_amount < 0)
or dt == "Dunning"
):
payment_type = "Receive" payment_type = "Receive"
else: else:
payment_type = "Pay" payment_type = "Pay"
@ -2371,6 +2498,7 @@ def get_reference_as_per_payment_terms(
"due_date": doc.get("due_date"), "due_date": doc.get("due_date"),
"total_amount": grand_total, "total_amount": grand_total,
"outstanding_amount": outstanding_amount, "outstanding_amount": outstanding_amount,
"payment_term_outstanding": payment_term_outstanding,
"payment_term": payment_term.payment_term, "payment_term": payment_term.payment_term,
"allocated_amount": payment_term_outstanding, "allocated_amount": payment_term_outstanding,
} }

View File

@ -31,6 +31,16 @@ class TestPaymentEntry(FrappeTestCase):
def tearDown(self): def tearDown(self):
frappe.db.rollback() 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): def test_payment_entry_against_order(self):
so = make_sales_order() so = make_sales_order()
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC") pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
@ -591,21 +601,15 @@ class TestPaymentEntry(FrappeTestCase):
pe.target_exchange_rate = 45.263 pe.target_exchange_rate = 45.263
pe.reference_no = "1" pe.reference_no = "1"
pe.reference_date = "2016-01-01" 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() pe.save()
self.assertEqual(flt(pe.difference_amount, 2), 0.0) self.assertEqual(flt(pe.difference_amount, 2), 0.0)
self.assertEqual(flt(pe.unallocated_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): def test_payment_entry_retrieves_last_exchange_rate(self):
from erpnext.setup.doctype.currency_exchange.test_currency_exchange import ( from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
save_new_records, save_new_records,
@ -792,33 +796,28 @@ class TestPaymentEntry(FrappeTestCase):
pe.reference_no = "1" pe.reference_no = "1"
pe.reference_date = "2016-01-01" pe.reference_date = "2016-01-01"
pe.source_exchange_rate = 55 pe.source_exchange_rate = 55
pe.append(
"deductions",
{
"account": "_Test Exchange Gain/Loss - _TC",
"cost_center": "_Test Cost Center - _TC",
"amount": -500,
},
)
pe.save() pe.save()
self.assertEqual(pe.unallocated_amount, 0) self.assertEqual(pe.unallocated_amount, 0)
self.assertEqual(pe.difference_amount, 0) self.assertEqual(pe.difference_amount, 0)
self.assertEqual(pe.references[0].exchange_gain_loss, 500)
pe.submit() pe.submit()
expected_gle = dict( expected_gle = dict(
(d[0], d) (d[0], d)
for d in [ for d in [
["_Test Receivable USD - _TC", 0, 5000, si.name], ["_Test Receivable USD - _TC", 0, 5500, si.name],
["_Test Bank USD - _TC", 5500, 0, None], ["_Test Bank USD - _TC", 5500, 0, None],
["_Test Exchange Gain/Loss - _TC", 0, 500, None],
] ]
) )
self.validate_gl_entries(pe.name, expected_gle) 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")) outstanding_amount = flt(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"))
self.assertEqual(outstanding_amount, 0) self.assertEqual(outstanding_amount, 0)
@ -1061,6 +1060,147 @@ class TestPaymentEntry(FrappeTestCase):
} }
self.assertDictEqual(ref_details, expected_response) self.assertDictEqual(ref_details, expected_response)
@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_on_payment_terms(self):
"""
Validate Allocation on Payment Entry based on Payment Schedule. Upon overallocation, validation error must be thrown.
"""
customer = create_customer()
create_payment_terms_template()
# Validate allocation on base/company currency
si1 = create_sales_invoice(do_not_save=1, qty=1, rate=200)
si1.payment_terms_template = "Test Receivable Template"
si1.save().submit()
si1.reload()
pe = get_payment_entry(si1.doctype, si1.name).save()
# Allocated amount should be according to the payment schedule
for idx, schedule in enumerate(si1.payment_schedule):
with self.subTest(idx=idx):
self.assertEqual(flt(schedule.payment_amount), flt(pe.references[idx].allocated_amount))
pe.save()
# Overallocation validation should trigger
pe.paid_amount = 400
pe.references[0].allocated_amount = 200
pe.references[1].allocated_amount = 200
self.assertRaises(frappe.ValidationError, pe.save)
pe.delete()
si1.cancel()
si1.delete()
# Validate allocation on foreign currency
si2 = create_sales_invoice(
customer="_Test Customer USD",
debit_to="_Test Receivable USD - _TC",
currency="USD",
conversion_rate=80,
do_not_save=1,
)
si2.payment_terms_template = "Test Receivable Template"
si2.save().submit()
si2.reload()
pe = get_payment_entry(si2.doctype, si2.name).save()
# Allocated amount should be according to the payment schedule
for idx, schedule in enumerate(si2.payment_schedule):
with self.subTest(idx=idx):
self.assertEqual(flt(schedule.payment_amount), flt(pe.references[idx].allocated_amount))
pe.save()
# Overallocation validation should trigger
pe.paid_amount = 200
pe.references[0].allocated_amount = 100
pe.references[1].allocated_amount = 100
self.assertRaises(frappe.ValidationError, pe.save)
pe.delete()
si2.cancel()
si2.delete()
# Validate allocation in base/company currency on a foreign currency document
# when invoice is made is foreign currency, but posted to base/company currency debtors account
si3 = create_sales_invoice(
customer=customer,
currency="USD",
conversion_rate=80,
do_not_save=1,
)
si3.payment_terms_template = "Test Receivable Template"
si3.save().submit()
si3.reload()
pe = get_payment_entry(si3.doctype, si3.name).save()
# Allocated amount should be equal to payment term outstanding
self.assertEqual(len(pe.references), 2)
for idx, ref in enumerate(pe.references):
with self.subTest(idx=idx):
self.assertEqual(ref.payment_term_outstanding, ref.allocated_amount)
pe.save()
# Overallocation validation should trigger
pe.paid_amount = 16000
pe.references[0].allocated_amount = 8000
pe.references[1].allocated_amount = 8000
self.assertRaises(frappe.ValidationError, pe.save)
pe.delete()
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 create_payment_entry(**args): def create_payment_entry(**args):
payment_entry = frappe.new_doc("Payment Entry") payment_entry = frappe.new_doc("Payment Entry")
@ -1150,3 +1290,17 @@ def create_payment_terms_template_with_discount(
def create_payment_term(name): def create_payment_term(name):
if not frappe.db.exists("Payment Term", name): if not frappe.db.exists("Payment Term", name):
frappe.get_doc({"doctype": "Payment Term", "payment_term_name": name}).insert() frappe.get_doc({"doctype": "Payment Term", "payment_term_name": name}).insert()
def create_customer(name="_Test Customer 2 USD", currency="USD"):
customer = None
if frappe.db.exists("Customer", name):
customer = name
else:
customer = frappe.new_doc("Customer")
customer.customer_name = name
customer.default_currency = currency
customer.type = "Individual"
customer.save()
customer = customer.name
return customer

View File

@ -124,7 +124,7 @@ frappe.ui.form.on('Payment Order', {
return frappe.call({ return frappe.call({
method: "erpnext.accounts.doctype.payment_order.payment_order.make_payment_records", method: "erpnext.accounts.doctype.payment_order.payment_order.make_payment_records",
args: { args: {
"name": me.frm.doc.name, "name": frm.doc.name,
"supplier": args.supplier, "supplier": args.supplier,
"mode_of_payment": args.mode_of_payment "mode_of_payment": args.mode_of_payment
}, },

View File

@ -6,7 +6,7 @@ import frappe
from frappe import _, msgprint, qb from frappe import _, msgprint, qb
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt, get_link_to_form, getdate, nowdate, today from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
import erpnext import erpnext
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import ( from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
@ -14,6 +14,7 @@ from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_rec
) )
from erpnext.accounts.utils import ( from erpnext.accounts.utils import (
QueryPaymentLedger, QueryPaymentLedger,
create_gain_loss_journal,
get_outstanding_invoices, get_outstanding_invoices,
reconcile_against_document, reconcile_against_document,
) )
@ -276,6 +277,11 @@ class PaymentReconciliation(Document):
def calculate_difference_on_allocation_change(self, payment_entry, invoice, allocated_amount): def calculate_difference_on_allocation_change(self, payment_entry, invoice, allocated_amount):
invoice_exchange_map = self.get_invoice_exchange_map(invoice, payment_entry) invoice_exchange_map = self.get_invoice_exchange_map(invoice, payment_entry)
invoice[0]["exchange_rate"] = invoice_exchange_map.get(invoice[0].get("invoice_number")) 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( new_difference_amount = self.get_difference_amount(
payment_entry[0], invoice[0], allocated_amount payment_entry[0], invoice[0], allocated_amount
) )
@ -363,12 +369,6 @@ class PaymentReconciliation(Document):
payment_details = self.get_payment_details(row, dr_or_cr) payment_details = self.get_payment_details(row, dr_or_cr)
reconciled_entry.append(payment_details) 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: if entry_list:
reconcile_against_document(entry_list, skip_ref_details_update_for_pe) reconcile_against_document(entry_list, skip_ref_details_update_for_pe)
@ -656,6 +656,8 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
"reference_type": inv.against_voucher_type, "reference_type": inv.against_voucher_type,
"reference_name": inv.against_voucher, "reference_name": inv.against_voucher,
"cost_center": erpnext.get_default_cost_center(company), "cost_center": 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}",
}, },
{ {
"account": inv.account, "account": inv.account,
@ -669,13 +671,42 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
"reference_type": inv.voucher_type, "reference_type": inv.voucher_type,
"reference_name": inv.voucher_no, "reference_name": inv.voucher_no,
"cost_center": erpnext.get_default_cost_center(company), "cost_center": 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}",
}, },
], ],
} }
) )
if difference_entry := get_difference_row(inv):
jv.append("accounts", difference_entry)
jv.flags.ignore_mandatory = True 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() 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,
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,
)

View File

@ -686,14 +686,24 @@ class TestPaymentReconciliation(FrappeTestCase):
# Check if difference journal entry gets generated for difference amount after reconciliation # Check if difference journal entry gets generated for difference amount after reconciliation
pr.reconcile() pr.reconcile()
total_debit_amount = frappe.db.get_all( total_credit_amount = frappe.db.get_all(
"Journal Entry Account", "Journal Entry Account",
{"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name}, {"account": self.debtors_eur, "docstatus": 1, "reference_name": si.name},
"sum(debit) as amount", "sum(credit) as amount",
group_by="reference_name", group_by="reference_name",
)[0].amount )[0].amount
self.assertEqual(flt(total_debit_amount, 2), -500) # 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"
)
def test_difference_amount_via_payment_entry(self): def test_difference_amount_via_payment_entry(self):
# Make Sale Invoice # Make Sale Invoice

View File

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

View File

@ -14,7 +14,7 @@ frappe.ui.form.on('Payment Term', {
if (frm.doc.discount) { if (frm.doc.discount) {
let description = __("{0}% of total invoice value will be given as discount.", [frm.doc.discount]); let description = __("{0}% of total invoice value will be given as discount.", [frm.doc.discount]);
if (frm.doc.discount_type == 'Amount') { if (frm.doc.discount_type == 'Amount') {
description = __("{0} will be given as discount.", [fmt_money(frm.doc.discount)]); description = __("{0} will be given as discount.", [frm.doc.discount]);
} }
frm.set_df_property("discount", "description", description); frm.set_df_property("discount", "description", description);
} }

View File

@ -126,13 +126,14 @@ class PeriodClosingVoucher(AccountsController):
def make_gl_entries(self, get_opening_entries=False): def make_gl_entries(self, get_opening_entries=False):
gl_entries = self.get_gl_entries() gl_entries = self.get_gl_entries()
closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries) closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries)
if gl_entries:
if len(gl_entries) > 5000: if len(gl_entries) > 5000:
frappe.enqueue( frappe.enqueue(
process_gl_entries, process_gl_entries,
gl_entries=gl_entries, gl_entries=gl_entries,
closing_entries=closing_entries, closing_entries=closing_entries,
voucher_name=self.name, voucher_name=self.name,
company=self.company,
closing_date=self.posting_date,
queue="long", queue="long",
) )
frappe.msgprint( frappe.msgprint(
@ -140,7 +141,7 @@ class PeriodClosingVoucher(AccountsController):
alert=True, alert=True,
) )
else: else:
process_gl_entries(gl_entries, closing_entries, voucher_name=self.name) process_gl_entries(gl_entries, closing_entries, self.name, self.company, self.posting_date)
def get_grouped_gl_entries(self, get_opening_entries=False): def get_grouped_gl_entries(self, get_opening_entries=False):
closing_entries = [] closing_entries = []
@ -321,24 +322,22 @@ class PeriodClosingVoucher(AccountsController):
return query.run(as_dict=1) return query.run(as_dict=1)
def process_gl_entries(gl_entries, closing_entries, voucher_name=None): def process_gl_entries(gl_entries, closing_entries, voucher_name, company, closing_date):
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import ( from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
make_closing_entries, make_closing_entries,
) )
from erpnext.accounts.general_ledger import make_gl_entries from erpnext.accounts.general_ledger import make_gl_entries
try: try:
if gl_entries:
make_gl_entries(gl_entries, merge_entries=False) make_gl_entries(gl_entries, merge_entries=False)
make_closing_entries(gl_entries + closing_entries, voucher_name=voucher_name)
frappe.db.set_value( make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date)
"Period Closing Voucher", gl_entries[0].get("voucher_no"), "gle_processing_status", "Completed" frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Completed")
)
except Exception as e: except Exception as e:
frappe.db.rollback() frappe.db.rollback()
frappe.log_error(e) frappe.log_error(e)
frappe.db.set_value( frappe.db.set_value("Period Closing Voucher", voucher_name, "gle_processing_status", "Failed")
"Period Closing Voucher", gl_entries[0].get("voucher_no"), "gle_processing_status", "Failed"
)
def make_reverse_gl_entries(voucher_type, voucher_no): def make_reverse_gl_entries(voucher_type, voucher_no):

View File

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

View File

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

View File

@ -8,9 +8,11 @@ import frappe
from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import ( from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import (
make_closing_entry_from_opening, make_closing_entry_from_opening,
) )
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_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_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.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 from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@ -49,6 +51,54 @@ class TestPOSClosingEntry(unittest.TestCase):
self.assertEqual(pcv_doc.total_quantity, 2) self.assertEqual(pcv_doc.total_quantity, 2)
self.assertEqual(pcv_doc.net_total, 6700) self.assertEqual(pcv_doc.net_total, 6700)
def test_pos_closing_without_item_code(self):
"""
Test if POS Closing Entry is created without item code
"""
test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name)
pos_inv = create_pos_invoice(
rate=3500, do_not_submit=1, item_name="Test Item", without_item_code=1
)
pos_inv.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
pos_inv.submit()
pcv_doc = make_closing_entry_from_opening(opening_entry)
pcv_doc.submit()
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): def test_cancelling_of_pos_closing_entry(self):
test_user, pos_profile = init_user_and_profile() test_user, pos_profile = init_user_and_profile()
opening_entry = create_opening_entry(pos_profile, test_user.name) opening_entry = create_opening_entry(pos_profile, test_user.name)
@ -105,3 +155,19 @@ def init_user_and_profile(**args):
pos_profile.save() pos_profile.save()
return test_user, pos_profile 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

@ -1,9 +1,10 @@
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // For license information, please see license.txt
{% include 'erpnext/selling/sales_common.js' %};
frappe.provide("erpnext.accounts"); frappe.provide("erpnext.accounts");
erpnext.sales_common.setup_selling_controller();
erpnext.accounts.pos.setup("POS Invoice");
erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnext.selling.SellingController { erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnext.selling.SellingController {
settings = {}; settings = {};

View File

@ -542,6 +542,7 @@ def get_stock_availability(item_code, warehouse):
is_stock_item = True is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse) bin_qty = get_bin_qty(item_code, warehouse)
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse) pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
return bin_qty - pos_sales_qty, is_stock_item return bin_qty - pos_sales_qty, is_stock_item
else: else:
is_stock_item = True is_stock_item = True
@ -595,7 +596,6 @@ def get_pos_reserved_qty(item_code, warehouse):
.where( .where(
(p_inv.name == p_item.parent) (p_inv.name == p_item.parent)
& (IfNull(p_inv.consolidated_invoice, "") == "") & (IfNull(p_inv.consolidated_invoice, "") == "")
& (p_inv.is_return == 0)
& (p_item.docstatus == 1) & (p_item.docstatus == 1)
& (p_item.item_code == item_code) & (p_item.item_code == item_code)
& (p_item.warehouse == warehouse) & (p_item.warehouse == warehouse)

View File

@ -986,10 +986,7 @@ def create_pos_invoice(**args):
msg = f"Serial No {args.serial_no} not available for Item {args.item}" msg = f"Serial No {args.serial_no} not available for Item {args.item}"
frappe.throw(_(msg)) frappe.throw(_(msg))
pos_inv.append( pos_invoice_item = {
"items",
{
"item_code": args.item or args.item_code or "_Test Item",
"warehouse": args.warehouse or "_Test Warehouse - _TC", "warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": args.qty or 1, "qty": args.qty or 1,
"rate": args.rate if args.get("rate") is not None else 100, "rate": args.rate if args.get("rate") is not None else 100,
@ -997,6 +994,24 @@ def create_pos_invoice(**args):
"expense_account": args.expense_account or "Cost of Goods Sold - _TC", "expense_account": args.expense_account or "Cost of Goods Sold - _TC",
"cost_center": args.cost_center or "_Test Cost Center - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC",
"serial_and_batch_bundle": bundle_id, "serial_and_batch_bundle": bundle_id,
}
# append in pos invoice items without item_code by checking flag without_item_code
if args.without_item_code:
pos_inv.append(
"items",
{
**pos_invoice_item,
"item_name": args.item_name or "_Test Item",
"description": args.item_name or "_Test Item",
},
)
else:
pos_inv.append(
"items",
{
**pos_invoice_item,
"item_code": args.item or args.item_code or "_Test Item",
}, },
) )

View File

@ -95,7 +95,6 @@ class POSInvoiceMergeLog(Document):
sales_invoice = self.process_merging_into_sales_invoice(sales) sales_invoice = self.process_merging_into_sales_invoice(sales)
self.save() # save consolidated_sales_invoice & consolidated_credit_note ref in merge log self.save() # save consolidated_sales_invoice & consolidated_credit_note ref in merge log
self.update_pos_invoices(pos_invoice_docs, sales_invoice, credit_note) self.update_pos_invoices(pos_invoice_docs, sales_invoice, credit_note)
def on_cancel(self): def on_cancel(self):
@ -108,7 +107,6 @@ class POSInvoiceMergeLog(Document):
def process_merging_into_sales_invoice(self, data): def process_merging_into_sales_invoice(self, data):
sales_invoice = self.get_new_sales_invoice() sales_invoice = self.get_new_sales_invoice()
sales_invoice = self.merge_pos_invoice_into(sales_invoice, data) sales_invoice = self.merge_pos_invoice_into(sales_invoice, data)
sales_invoice.is_consolidated = 1 sales_invoice.is_consolidated = 1
@ -165,8 +163,7 @@ class POSInvoiceMergeLog(Document):
for i in items: for i in items:
if ( if (
i.item_code == item.item_code i.item_code == item.item_code
and not i.serial_no and not i.serial_and_batch_bundle
and not i.batch_no
and i.uom == item.uom and i.uom == item.uom
and i.net_rate == item.net_rate and i.net_rate == item.net_rate
and i.warehouse == item.warehouse and i.warehouse == item.warehouse
@ -385,6 +382,7 @@ def split_invoices(invoices):
for d in invoices for d in invoices
if d.is_return and d.return_against if d.is_return and d.return_against
] ]
for pos_invoice in pos_return_docs: for pos_invoice in pos_return_docs:
for item in pos_invoice.items: for item in pos_invoice.items:
if not item.serial_no and not item.serial_and_batch_bundle: if not item.serial_no and not item.serial_and_batch_bundle:

View File

@ -1,8 +1,6 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
{% include "erpnext/public/js/controllers/accounts.js" %}
frappe.ui.form.on('POS Profile', { frappe.ui.form.on('POS Profile', {
setup: function(frm) { setup: function(frm) {
frm.set_query("selling_price_list", function() { frm.set_query("selling_price_list", function() {

View File

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

View File

@ -140,7 +140,7 @@ def get_gl_filters(doc, entry, tax_id, presentation_currency):
def get_ar_filters(doc, entry): def get_ar_filters(doc, entry):
return { return {
"report_date": doc.posting_date if doc.posting_date else None, "report_date": doc.posting_date if doc.posting_date else None,
"customer_name": entry.customer, "customer": entry.customer,
"payment_terms_template": doc.payment_terms_template if doc.payment_terms_template else None, "payment_terms_template": doc.payment_terms_template if doc.payment_terms_template else None,
"sales_partner": doc.sales_partner if doc.sales_partner else None, "sales_partner": doc.sales_partner if doc.sales_partner else None,
"sales_person": doc.sales_person if doc.sales_person else None, "sales_person": doc.sales_person if doc.sales_person else None,

View File

@ -10,11 +10,7 @@
<h2 class="text-center" style="margin-top:0">{{ _(report.report_name) }}</h2> <h2 class="text-center" style="margin-top:0">{{ _(report.report_name) }}</h2>
<h4 class="text-center"> <h4 class="text-center">
{% if (filters.customer_name) %} {{ filters.customer }}
{{ filters.customer_name }}
{% else %}
{{ filters.customer ~ filters.supplier }}
{% endif %}
</h4> </h4>
<h6 class="text-center"> <h6 class="text-center">
{% if (filters.tax_id) %} {% if (filters.tax_id) %}

View File

@ -2,7 +2,11 @@
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
frappe.provide("erpnext.accounts"); frappe.provide("erpnext.accounts");
{% include 'erpnext/public/js/controllers/buying.js' %};
erpnext.accounts.payment_triggers.setup("Purchase Invoice");
erpnext.accounts.taxes.setup_tax_filters("Purchase Taxes and Charges");
erpnext.accounts.taxes.setup_tax_validations("Purchase Invoice");
erpnext.buying.setup_buying_controller();
erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.BuyingController { erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.BuyingController {
setup(doc) { setup(doc) {
@ -31,7 +35,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
super.onload(); super.onload();
// Ignore linked advances // Ignore linked advances
this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger"]; this.frm.ignore_doctypes_on_cancel_all = ['Journal Entry', 'Payment Entry', 'Purchase Invoice', "Repost Payment Ledger", "Repost Accounting Ledger"];
if(!this.frm.doc.__islocal) { if(!this.frm.doc.__islocal) {
// show credit_to in print format // show credit_to in print format
@ -97,12 +101,6 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
cur_frm.add_custom_button(__('Return / Debit Note'), cur_frm.add_custom_button(__('Return / Debit Note'),
this.make_debit_note, __('Create')); this.make_debit_note, __('Create'));
} }
if(!doc.auto_repeat) {
cur_frm.add_custom_button(__('Subscription'), function() {
erpnext.utils.make_subscription(doc.doctype, doc.name)
}, __('Create'))
}
} }
if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) { if (doc.outstanding_amount > 0 && !cint(doc.is_return) && !doc.on_hold) {
@ -506,7 +504,8 @@ frappe.ui.form.on("Purchase Invoice", {
setup: function(frm) { setup: function(frm) {
frm.custom_make_buttons = { frm.custom_make_buttons = {
'Purchase Invoice': 'Return / Debit Note', 'Purchase Invoice': 'Return / Debit Note',
'Payment Entry': 'Payment' 'Payment Entry': 'Payment',
'Landed Cost Voucher': function () { frm.trigger('create_landed_cost_voucher') },
} }
frm.set_query("additional_discount_account", function() { frm.set_query("additional_discount_account", function() {
@ -544,6 +543,26 @@ frappe.ui.form.on("Purchase Invoice", {
frm.events.add_custom_buttons(frm); frm.events.add_custom_buttons(frm);
}, },
mode_of_payment: function(frm) {
erpnext.accounts.pos.get_payment_mode_account(frm, frm.doc.mode_of_payment, function(account) {
frm.set_value("cash_bank_account", account);
})
},
create_landed_cost_voucher: function (frm) {
let lcv = frappe.model.get_new_doc('Landed Cost Voucher');
lcv.company = frm.doc.company;
let lcv_receipt = frappe.model.get_new_doc('Landed Cost Purchase Invoice');
lcv_receipt.receipt_document_type = 'Purchase Invoice';
lcv_receipt.receipt_document = frm.doc.name;
lcv_receipt.supplier = frm.doc.supplier;
lcv_receipt.grand_total = frm.doc.grand_total;
lcv.purchase_receipts = [lcv_receipt];
frappe.set_route("Form", lcv.doctype, lcv.name);
},
add_custom_buttons: function(frm) { add_custom_buttons: function(frm) {
if (frm.doc.docstatus == 1 && frm.doc.per_received < 100) { if (frm.doc.docstatus == 1 && frm.doc.per_received < 100) {
frm.add_custom_button(__('Purchase Receipt'), () => { frm.add_custom_button(__('Purchase Receipt'), () => {

View File

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

View File

@ -229,7 +229,7 @@ class PurchaseInvoice(BuyingController):
) )
if ( if (
cint(frappe.get_cached_value("Buying Settings", "None", "maintain_same_rate")) cint(frappe.db.get_single_value("Buying Settings", "maintain_same_rate"))
and not self.is_return and not self.is_return
and not self.is_internal_supplier and not self.is_internal_supplier
): ):
@ -536,6 +536,7 @@ class PurchaseInvoice(BuyingController):
merge_entries=False, merge_entries=False,
from_repost=from_repost, from_repost=from_repost,
) )
self.make_exchange_gain_loss_journal()
elif self.docstatus == 2: elif self.docstatus == 2:
provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"] 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) make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
@ -580,7 +581,6 @@ class PurchaseInvoice(BuyingController):
self.get_asset_gl_entry(gl_entries) self.get_asset_gl_entry(gl_entries)
self.make_tax_gl_entries(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_internal_transfer_gl_entries(gl_entries)
gl_entries = make_regional_gl_entries(gl_entries, self) gl_entries = make_regional_gl_entries(gl_entries, self)
@ -969,30 +969,6 @@ class PurchaseInvoice(BuyingController):
item.item_tax_amount, item.precision("item_tax_amount") 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): def get_asset_gl_entry(self, gl_entries):
arbnb_account = self.get_company_default("asset_received_but_not_billed") arbnb_account = self.get_company_default("asset_received_but_not_billed")
eiiav_account = self.get_company_default("expenses_included_in_asset_valuation") eiiav_account = self.get_company_default("expenses_included_in_asset_valuation")
@ -1439,6 +1415,8 @@ class PurchaseInvoice(BuyingController):
"Repost Item Valuation", "Repost Item Valuation",
"Repost Payment Ledger", "Repost Payment Ledger",
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger",
"Repost Accounting Ledger Items",
"Payment Ledger Entry", "Payment Ledger Entry",
"Tax Withheld Vouchers", "Tax Withheld Vouchers",
"Serial and Batch Bundle", "Serial and Batch Bundle",

View File

@ -1273,10 +1273,11 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
pi.save() pi.save()
pi.submit() pi.submit()
creditors_account = pi.credit_to
expected_gle = [ expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 37500.0], ["_Test Account Cost for Goods Sold - _TC", 37500.0],
["_Test Payable USD - _TC", -35000.0], ["_Test Payable USD - _TC", -37500.0],
["Exchange Gain/Loss - _TC", -2500.0],
] ]
gl_entries = frappe.db.sql( gl_entries = frappe.db.sql(
@ -1293,6 +1294,31 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
self.assertEqual(expected_gle[i][0], gle.account) self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance) 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( pi_2 = make_purchase_invoice(
supplier="_Test Supplier USD", supplier="_Test Supplier USD",
currency="USD", currency="USD",
@ -1317,10 +1343,12 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
pi_2.save() pi_2.save()
pi_2.submit() pi_2.submit()
pi_2.reload()
self.assertEqual(pi_2.outstanding_amount, 0)
expected_gle = [ expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 36500.0], ["_Test Account Cost for Goods Sold - _TC", 36500.0],
["_Test Payable USD - _TC", -35000.0], ["_Test Payable USD - _TC", -36500.0],
["Exchange Gain/Loss - _TC", -1500.0],
] ]
gl_entries = frappe.db.sql( gl_entries = frappe.db.sql(
@ -1351,12 +1379,39 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
self.assertEqual(expected_gle[i][0], gle.account) self.assertEqual(expected_gle[i][0], gle.account)
self.assertEqual(expected_gle[i][1], gle.balance) 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.reload()
pi.cancel() pi.cancel()
self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent.parent, "docstatus"), 2)
pi_2.reload() pi_2.reload()
pi_2.cancel() pi_2.cancel()
self.assertEqual(frappe.db.get_value("Journal Entry", jea_parent_2.parent, "docstatus"), 2)
pay.reload() pay.reload()
pay.cancel() pay.cancel()
@ -1736,6 +1791,107 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
rate = flt(sle.stock_value_difference) / flt(sle.actual_qty) rate = flt(sle.stock_value_difference) / flt(sle.actual_qty)
self.assertAlmostEqual(returned_inv.items[0].rate, rate) 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 (
clear_dimension_defaults,
create_accounting_dimension,
disable_dimension,
)
create_account(
account_name="Offsetting",
company="_Test Company",
parent_account="Temporary Accounts - _TC",
)
create_accounting_dimension(company="_Test Company", offsetting_account="Offsetting - _TC")
branch1 = frappe.new_doc("Branch")
branch1.branch = "Location 1"
branch1.insert(ignore_if_duplicate=True)
branch2 = frappe.new_doc("Branch")
branch2.branch = "Location 2"
branch2.insert(ignore_if_duplicate=True)
pi = make_purchase_invoice(
company="_Test Company",
customer="_Test Supplier",
do_not_save=True,
do_not_submit=True,
rate=1000,
price_list_rate=1000,
qty=1,
)
pi.branch = branch1.branch
pi.items[0].branch = branch2.branch
pi.save()
pi.submit()
expected_gle = [
["_Test Account Cost for Goods Sold - _TC", 1000, 0.0, nowdate(), branch2.branch],
["Creditors - _TC", 0.0, 1000, nowdate(), branch1.branch],
["Offsetting - _TC", 1000, 0.0, nowdate(), branch1.branch],
["Offsetting - _TC", 0.0, 1000, nowdate(), branch2.branch],
]
check_gl_entries(
self,
pi.name,
expected_gle,
nowdate(),
voucher_type="Purchase Invoice",
additional_columns=["branch"],
)
clear_dimension_defaults("Branch")
disable_dimension()
def set_advance_flag(company, flag, default_account): def set_advance_flag(company, flag, default_account):
frappe.db.set_value( frappe.db.set_value(
@ -1748,9 +1904,16 @@ def set_advance_flag(company, flag, default_account):
) )
def check_gl_entries(doc, voucher_no, expected_gle, posting_date, voucher_type="Purchase Invoice"): def check_gl_entries(
doc,
voucher_no,
expected_gle,
posting_date,
voucher_type="Purchase Invoice",
additional_columns=None,
):
gl = frappe.qb.DocType("GL Entry") gl = frappe.qb.DocType("GL Entry")
q = ( query = (
frappe.qb.from_(gl) frappe.qb.from_(gl)
.select(gl.account, gl.debit, gl.credit, gl.posting_date) .select(gl.account, gl.debit, gl.credit, gl.posting_date)
.where( .where(
@ -1761,7 +1924,12 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date, voucher_type="
) )
.orderby(gl.posting_date, gl.account, gl.creation) .orderby(gl.posting_date, gl.account, gl.creation)
) )
gl_entries = q.run(as_dict=True)
if additional_columns:
for col in additional_columns:
query = query.select(gl[col])
gl_entries = query.run(as_dict=True)
for i, gle in enumerate(gl_entries): for i, gle in enumerate(gl_entries):
doc.assertEqual(expected_gle[i][0], gle.account) doc.assertEqual(expected_gle[i][0], gle.account)
@ -1769,6 +1937,12 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date, voucher_type="
doc.assertEqual(expected_gle[i][2], gle.credit) doc.assertEqual(expected_gle[i][2], gle.credit)
doc.assertEqual(getdate(expected_gle[i][3]), gle.posting_date) doc.assertEqual(getdate(expected_gle[i][3]), gle.posting_date)
if additional_columns:
j = 4
for col in additional_columns:
doc.assertEqual(expected_gle[i][j], gle[col])
j += 1
def create_tax_witholding_category(category_name, company, account): def create_tax_witholding_category(category_name, company, account):
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year

View File

@ -443,7 +443,8 @@
"hidden": 1, "hidden": 1,
"label": "Batch No", "label": "Batch No",
"options": "Batch", "options": "Batch",
"read_only": 1 "read_only": 1,
"search_index": 1
}, },
{ {
"fieldname": "col_br_wh", "fieldname": "col_br_wh",
@ -890,7 +891,8 @@
"label": "Serial and Batch Bundle", "label": "Serial and Batch Bundle",
"no_copy": 1, "no_copy": 1,
"options": "Serial and Batch Bundle", "options": "Serial and Batch Bundle",
"print_hide": 1 "print_hide": 1,
"search_index": 1
}, },
{ {
"depends_on": "eval:parent.update_stock == 1", "depends_on": "eval:parent.update_stock == 1",
@ -905,7 +907,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-07-04 17:22:21.501152", "modified": "2023-07-26 12:54:53.178156",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice Item", "name": "Purchase Invoice Item",

View File

@ -1,12 +1,12 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
cur_frm.cscript.tax_table = "Purchase Taxes and Charges"; erpnext.accounts.taxes.setup_tax_validations("Purchase Taxes and Charges Template");
erpnext.accounts.taxes.setup_tax_filters("Purchase Taxes and Charges");
{% include "erpnext/public/js/controllers/accounts.js" %} frappe.ui.form.on("Purchase Taxes and Charges", {
add_deduct_tax(doc, cdt, cdn) {
frappe.ui.form.on("Purchase Taxes and Charges", "add_deduct_tax", function(doc, cdt, cdn) { let d = locals[cdt][cdn];
var d = locals[cdt][cdn];
if(!d.category && d.add_deduct_tax) { if(!d.category && d.add_deduct_tax) {
frappe.msgprint(__("Please select Category first")); frappe.msgprint(__("Please select Category first"));
@ -17,14 +17,15 @@ frappe.ui.form.on("Purchase Taxes and Charges", "add_deduct_tax", function(doc,
d.add_deduct_tax = ''; d.add_deduct_tax = '';
} }
refresh_field('add_deduct_tax', d.name, 'taxes'); refresh_field('add_deduct_tax', d.name, 'taxes');
}); },
frappe.ui.form.on("Purchase Taxes and Charges", "category", function(doc, cdt, cdn) { category(doc, cdt, cdn) {
var d = locals[cdt][cdn]; let d = locals[cdt][cdn];
if (d.category != 'Total' && d.add_deduct_tax == 'Deduct') { if(d.category != 'Total' && d.add_deduct_tax == 'Deduct') {
frappe.msgprint(__("Cannot deduct when category is for 'Valuation' or 'Vaulation and Total'")); frappe.msgprint(__("Cannot deduct when category is for 'Valuation' or 'Valuation and Total'"));
d.add_deduct_tax = ''; d.add_deduct_tax = '';
} }
refresh_field('add_deduct_tax', d.name, 'taxes'); refresh_field('add_deduct_tax', d.name, 'taxes');
}
}); });

View File

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

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

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

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

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

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

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

View File

@ -1,3 +1,23 @@
{% include "erpnext/regional/italy/sales_invoice.js" %} frappe.ui.form.on("Sales Invoice", {
refresh: (frm) => {
erpnext.setup_e_invoice_button('Sales Invoice') if(frm.doc.docstatus == 1) {
frm.add_custom_button(__('Generate E-Invoice'), () => {
frm.call({
method: "erpnext.regional.italy.utils.generate_single_invoice",
args: {
docname: frm.doc.name
},
callback: function(r) {
frm.reload_doc();
if(r.message) {
open_url_post(frappe.request.url, {
cmd: 'frappe.core.doctype.file.file.download_file',
file_url: r.message
});
}
}
});
});
}
}
});

View File

@ -1,10 +1,13 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
{% include 'erpnext/selling/sales_common.js' %};
frappe.provide("erpnext.accounts"); frappe.provide("erpnext.accounts");
erpnext.accounts.taxes.setup_tax_validations("Sales Invoice");
erpnext.accounts.payment_triggers.setup("Sales Invoice");
erpnext.accounts.pos.setup("Sales Invoice");
erpnext.accounts.taxes.setup_tax_filters("Sales Taxes and Charges");
erpnext.sales_common.setup_selling_controller();
erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends erpnext.selling.SellingController { erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends erpnext.selling.SellingController {
setup(doc) { setup(doc) {
this.setup_posting_date_time_check(); this.setup_posting_date_time_check();
@ -34,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload(); super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger"]; 'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger"];
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) { if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
// show debit_to in print format // show debit_to in print format
@ -142,9 +145,15 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
cur_frm.events.create_invoice_discounting(cur_frm); cur_frm.events.create_invoice_discounting(cur_frm);
}, __('Create')); }, __('Create'));
if (doc.due_date < frappe.datetime.get_today()) { const payment_is_overdue = doc.payment_schedule.map(
cur_frm.add_custom_button(__('Dunning'), function() { row => Date.parse(row.due_date) < Date.now()
cur_frm.events.create_dunning(cur_frm); ).reduce(
(prev, current) => prev || current
);
if (payment_is_overdue) {
this.frm.add_custom_button(__('Dunning'), () => {
this.frm.events.create_dunning(this.frm);
}, __('Create')); }, __('Create'));
} }
} }
@ -154,12 +163,6 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
cur_frm.cscript.make_maintenance_schedule(); cur_frm.cscript.make_maintenance_schedule();
}, __('Create')); }, __('Create'));
} }
if(!doc.auto_repeat) {
cur_frm.add_custom_button(__('Subscription'), function() {
erpnext.utils.make_subscription(doc.doctype, doc.name)
}, __('Create'))
}
} }
// Show buttons only when pos view is active // Show buttons only when pos view is active
@ -711,7 +714,7 @@ frappe.ui.form.on('Sales Invoice', {
frm.set_query('pos_profile', function(doc) { frm.set_query('pos_profile', function(doc) {
if(!doc.company) { if(!doc.company) {
frappe.throw(_('Please set Company')); frappe.throw(__('Please set Company'));
} }
return { return {
@ -767,7 +770,6 @@ frappe.ui.form.on('Sales Invoice', {
update_stock: function(frm, dt, dn) { update_stock: function(frm, dt, dn) {
frm.events.hide_fields(frm); frm.events.hide_fields(frm);
frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock);
frm.trigger('reset_posting_time'); frm.trigger('reset_posting_time');
}, },
@ -858,7 +860,7 @@ frappe.ui.form.on('Sales Invoice', {
kwargs = Object(); kwargs = Object();
} }
if (!kwargs.hasOwnProperty("project") && frm.doc.project) { if (!Object.prototype.hasOwnProperty.call(kwargs, "project") && frm.doc.project) {
kwargs.project = frm.doc.project; kwargs.project = frm.doc.project;
} }
@ -891,6 +893,8 @@ frappe.ui.form.on('Sales Invoice', {
frm.events.append_time_log(frm, timesheet, 1.0); frm.events.append_time_log(frm, timesheet, 1.0);
} }
}); });
frm.refresh_field("timesheets");
frm.trigger("calculate_timesheet_totals");
}, },
async get_exchange_rate(frm, from_currency, to_currency) { async get_exchange_rate(frm, from_currency, to_currency) {
@ -930,9 +934,6 @@ frappe.ui.form.on('Sales Invoice', {
row.billing_amount = flt(time_log.billing_amount) * flt(exchange_rate); row.billing_amount = flt(time_log.billing_amount) * flt(exchange_rate);
row.timesheet_detail = time_log.name; row.timesheet_detail = time_log.name;
row.project_name = time_log.project_name; row.project_name = time_log.project_name;
frm.refresh_field("timesheets");
frm.trigger("calculate_timesheet_totals");
}, },
calculate_timesheet_totals: function(frm) { calculate_timesheet_totals: function(frm) {

View File

@ -194,6 +194,7 @@
"select_print_heading", "select_print_heading",
"language", "language",
"subscription_section", "subscription_section",
"subscription",
"from_date", "from_date",
"auto_repeat", "auto_repeat",
"column_break_140", "column_break_140",
@ -2017,6 +2018,12 @@
"label": "Amount Eligible for Commission", "label": "Amount Eligible for Commission",
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "subscription",
"fieldtype": "Link",
"label": "Subscription",
"options": "Subscription"
},
{ {
"default": "0", "default": "0",
"depends_on": "eval: doc.apply_discount_on == \"Grand Total\"", "depends_on": "eval: doc.apply_discount_on == \"Grand Total\"",
@ -2157,7 +2164,7 @@
"link_fieldname": "consolidated_invoice" "link_fieldname": "consolidated_invoice"
} }
], ],
"modified": "2023-06-21 16:02:18.988799", "modified": "2023-07-25 16:02:18.988799",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice", "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.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.party import get_due_date, get_party_account, get_party_details
from erpnext.accounts.utils import get_account_currency from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_account_currency
from erpnext.assets.doctype.asset.depreciation import ( from erpnext.assets.doctype.asset.depreciation import (
depreciate_asset, depreciate_asset,
get_disposal_account_and_cost_center, get_disposal_account_and_cost_center,
@ -32,6 +32,7 @@ from erpnext.assets.doctype.asset.depreciation import (
reset_depreciation_schedule, reset_depreciation_schedule,
reverse_depreciation_entry_made_after_disposal, 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.accounts_controller import validate_account_head
from erpnext.controllers.selling_controller import SellingController from erpnext.controllers.selling_controller import SellingController
from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data
@ -113,7 +114,6 @@ class SalesInvoice(SellingController):
if cint(self.update_stock): if cint(self.update_stock):
self.validate_dropship_item() self.validate_dropship_item()
self.validate_item_code()
self.validate_warehouse() self.validate_warehouse()
self.update_current_stock() self.update_current_stock()
self.validate_delivery_note() self.validate_delivery_note()
@ -386,6 +386,8 @@ class SalesInvoice(SellingController):
"Repost Item Valuation", "Repost Item Valuation",
"Repost Payment Ledger", "Repost Payment Ledger",
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Repost Accounting Ledger",
"Repost Accounting Ledger Items",
"Payment Ledger Entry", "Payment Ledger Entry",
"Serial and Batch Bundle", "Serial and Batch Bundle",
) )
@ -854,11 +856,6 @@ class SalesInvoice(SellingController):
): ):
frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total")) frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total"))
def validate_item_code(self):
for d in self.get("items"):
if not d.item_code and self.is_opening == "No":
msgprint(_("Item Code required at Row No {0}").format(d.idx), raise_exception=True)
def validate_warehouse(self): def validate_warehouse(self):
super(SalesInvoice, self).validate_warehouse() super(SalesInvoice, self).validate_warehouse()
@ -1035,7 +1032,10 @@ class SalesInvoice(SellingController):
merge_entries=False, merge_entries=False,
from_repost=from_repost, from_repost=from_repost,
) )
self.make_exchange_gain_loss_journal()
elif self.docstatus == 2: 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) make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
if update_outstanding == "No": if update_outstanding == "No":
@ -1060,10 +1060,10 @@ class SalesInvoice(SellingController):
self.make_customer_gl_entry(gl_entries) self.make_customer_gl_entry(gl_entries)
self.make_tax_gl_entries(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_internal_transfer_gl_entries(gl_entries)
self.make_item_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) self.make_discount_gl_entries(gl_entries)
# merge gl entries before adding pos entries # merge gl entries before adding pos entries
@ -1182,12 +1182,13 @@ class SalesInvoice(SellingController):
self.get("posting_date"), self.get("posting_date"),
) )
asset.db_set("disposal_date", None) asset.db_set("disposal_date", None)
add_asset_activity(asset.name, _("Asset returned"))
if asset.calculate_depreciation: if asset.calculate_depreciation:
posting_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date") posting_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
reverse_depreciation_entry_made_after_disposal(asset, posting_date) reverse_depreciation_entry_made_after_disposal(asset, posting_date)
notes = _( notes = _(
"This schedule was created when Asset {0} was returned after being sold through Sales Invoice {1}." "This schedule was created when Asset {0} was returned through Sales Invoice {1}."
).format( ).format(
get_link_to_form(asset.doctype, asset.name), get_link_to_form(asset.doctype, asset.name),
get_link_to_form(self.doctype, self.get("name")), get_link_to_form(self.doctype, self.get("name")),
@ -1215,6 +1216,7 @@ class SalesInvoice(SellingController):
self.get("posting_date"), self.get("posting_date"),
) )
asset.db_set("disposal_date", self.posting_date) asset.db_set("disposal_date", self.posting_date)
add_asset_activity(asset.name, _("Asset sold"))
for gle in fixed_asset_gl_entries: for gle in fixed_asset_gl_entries:
gle["against"] = self.customer gle["against"] = self.customer
@ -1652,15 +1654,13 @@ class SalesInvoice(SellingController):
frappe.db.set_value("Customer", self.customer, "loyalty_program_tier", lp_details.tier_name) frappe.db.set_value("Customer", self.customer, "loyalty_program_tier", lp_details.tier_name)
def get_returned_amount(self): def get_returned_amount(self):
from frappe.query_builder.functions import Coalesce, Sum from frappe.query_builder.functions import Sum
doc = frappe.qb.DocType(self.doctype) doc = frappe.qb.DocType(self.doctype)
returned_amount = ( returned_amount = (
frappe.qb.from_(doc) frappe.qb.from_(doc)
.select(Sum(doc.grand_total)) .select(Sum(doc.grand_total))
.where( .where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.name))
(doc.docstatus == 1) & (doc.is_return == 1) & (Coalesce(doc.return_against, "") == self.name)
)
).run() ).run()
return abs(returned_amount[0][0]) if returned_amount[0][0] else 0 return abs(returned_amount[0][0]) if returned_amount[0][0] else 0
@ -1839,7 +1839,7 @@ def validate_inter_company_party(doctype, party, company, inter_company_referenc
doc = frappe.get_doc(ref_doc, inter_company_reference) doc = frappe.get_doc(ref_doc, inter_company_reference)
ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer
if not frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") == party: if not frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") == party:
frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(partytype)) frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype)))
if not frappe.get_cached_value(ref_partytype, ref_party, "represents_company") == company: if not frappe.get_cached_value(ref_partytype, ref_party, "represents_company") == company:
frappe.throw(_("Invalid Company for Inter Company Transaction.")) frappe.throw(_("Invalid Company for Inter Company Transaction."))
@ -1853,7 +1853,7 @@ def validate_inter_company_party(doctype, party, company, inter_company_referenc
if not company in companies: if not company in companies:
frappe.throw( frappe.throw(
_("{0} not allowed to transact with {1}. Please change the Company.").format( _("{0} not allowed to transact with {1}. Please change the Company.").format(
partytype, company _(partytype), company
) )
) )
@ -2516,55 +2516,49 @@ def get_mode_of_payment_info(mode_of_payment, company):
@frappe.whitelist() @frappe.whitelist()
def create_dunning(source_name, target_doc=None): def create_dunning(source_name, target_doc=None, ignore_permissions=False):
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from erpnext.accounts.doctype.dunning.dunning import ( def postprocess_dunning(source, target):
calculate_interest_and_amount, from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
get_dunning_letter_text,
)
def set_missing_values(source, target): dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
target.sales_invoice = source_name if dunning_type:
target.outstanding_amount = source.outstanding_amount dunning_type = frappe.get_doc("Dunning Type", dunning_type)
overdue_days = (getdate(target.posting_date) - getdate(source.due_date)).days
target.overdue_days = overdue_days
if frappe.db.exists(
"Dunning Type", {"start_day": ["<", overdue_days], "end_day": [">=", overdue_days]}
):
dunning_type = frappe.get_doc(
"Dunning Type", {"start_day": ["<", overdue_days], "end_day": [">=", overdue_days]}
)
target.dunning_type = dunning_type.name target.dunning_type = dunning_type.name
target.rate_of_interest = dunning_type.rate_of_interest target.rate_of_interest = dunning_type.rate_of_interest
target.dunning_fee = dunning_type.dunning_fee target.dunning_fee = dunning_type.dunning_fee
letter_text = get_dunning_letter_text(dunning_type=dunning_type.name, doc=target.as_dict()) target.income_account = dunning_type.income_account
target.cost_center = dunning_type.cost_center
letter_text = get_dunning_letter_text(
dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
)
if letter_text: if letter_text:
target.body_text = letter_text.get("body_text") target.body_text = letter_text.get("body_text")
target.closing_text = letter_text.get("closing_text") target.closing_text = letter_text.get("closing_text")
target.language = letter_text.get("language") target.language = letter_text.get("language")
amounts = calculate_interest_and_amount(
target.outstanding_amount,
target.rate_of_interest,
target.dunning_fee,
target.overdue_days,
)
target.interest_amount = amounts.get("interest_amount")
target.dunning_amount = amounts.get("dunning_amount")
target.grand_total = amounts.get("grand_total")
doclist = get_mapped_doc( target.validate()
"Sales Invoice",
source_name, return get_mapped_doc(
{ from_doctype="Sales Invoice",
from_docname=source_name,
target_doc=target_doc,
table_maps={
"Sales Invoice": { "Sales Invoice": {
"doctype": "Dunning", "doctype": "Dunning",
} "field_map": {"customer_address": "customer_address", "parent": "sales_invoice"},
}, },
target_doc, "Payment Schedule": {
set_missing_values, "doctype": "Overdue Payment",
"field_map": {"name": "payment_schedule", "parent": "sales_invoice"},
"condition": lambda doc: doc.outstanding > 0 and getdate(doc.due_date) < getdate(),
},
},
postprocess=postprocess_dunning,
ignore_permissions=ignore_permissions,
) )
return doclist
def check_if_return_invoice_linked_with_payment_entry(self): def check_if_return_invoice_linked_with_payment_entry(self):

View File

@ -15,6 +15,7 @@ def get_data():
}, },
"internal_links": { "internal_links": {
"Sales Order": ["items", "sales_order"], "Sales Order": ["items", "sales_order"],
"Delivery Note": ["items", "delivery_note"],
"Timesheet": ["timesheets", "time_sheet"], "Timesheet": ["timesheets", "time_sheet"],
}, },
"transactions": [ "transactions": [

View File

@ -1900,16 +1900,22 @@ class TestSalesInvoice(unittest.TestCase):
si = self.create_si_to_test_tax_breakup() si = self.create_si_to_test_tax_breakup()
itemised_tax, itemised_taxable_amount = get_itemised_tax_breakup_data(si) itemised_tax_data = get_itemised_tax_breakup_data(si)
expected_itemised_tax = { expected_itemised_tax = [
"_Test Item": {"Service Tax": {"tax_rate": 10.0, "tax_amount": 1000.0}}, {
"_Test Item 2": {"Service Tax": {"tax_rate": 10.0, "tax_amount": 500.0}}, "item": "_Test Item",
} "taxable_amount": 10000.0,
expected_itemised_taxable_amount = {"_Test Item": 10000.0, "_Test Item 2": 5000.0} "Service Tax": {"tax_rate": 10.0, "tax_amount": 1000.0},
},
{
"item": "_Test Item 2",
"taxable_amount": 5000.0,
"Service Tax": {"tax_rate": 10.0, "tax_amount": 500.0},
},
]
self.assertEqual(itemised_tax, expected_itemised_tax) self.assertEqual(itemised_tax_data, expected_itemised_tax)
self.assertEqual(itemised_taxable_amount, expected_itemised_taxable_amount)
frappe.flags.country = None frappe.flags.country = None
@ -2043,28 +2049,27 @@ class TestSalesInvoice(unittest.TestCase):
self.assertEqual(si.total_taxes_and_charges, 228.82) self.assertEqual(si.total_taxes_and_charges, 228.82)
self.assertEqual(si.rounding_adjustment, -0.01) self.assertEqual(si.rounding_adjustment, -0.01)
expected_values = dict( expected_values = [
(d[0], d)
for d in [
[si.debit_to, 1500, 0.0],
["_Test Account Service Tax - _TC", 0.0, 114.41], ["_Test Account Service Tax - _TC", 0.0, 114.41],
["_Test Account VAT - _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], ["Sales - _TC", 0.0, 1271.18],
] ]
)
gl_entries = frappe.db.sql( gl_entries = frappe.db.sql(
"""select account, debit, credit """select account, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
group by account
order by account asc""", order by account asc""",
si.name, si.name,
as_dict=1, as_dict=1,
) )
for gle in gl_entries: for i, gle in enumerate(gl_entries):
self.assertEqual(expected_values[gle.account][0], gle.account) self.assertEqual(expected_values[i][0], gle.account)
self.assertEqual(expected_values[gle.account][1], gle.debit) self.assertEqual(expected_values[i][1], gle.debit)
self.assertEqual(expected_values[gle.account][2], gle.credit) self.assertEqual(expected_values[i][2], gle.credit)
def test_rounding_adjustment_3(self): def test_rounding_adjustment_3(self):
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
@ -2119,13 +2124,14 @@ class TestSalesInvoice(unittest.TestCase):
["_Test Account Service Tax - _TC", 0.0, 240.43], ["_Test Account Service Tax - _TC", 0.0, 240.43],
["_Test Account VAT - _TC", 0.0, 240.43], ["_Test Account VAT - _TC", 0.0, 240.43],
["Sales - _TC", 0.0, 4007.15], ["Sales - _TC", 0.0, 4007.15],
["Round Off - _TC", 0.01, 0], ["Round Off - _TC", 0.02, 0.01],
] ]
) )
gl_entries = frappe.db.sql( gl_entries = frappe.db.sql(
"""select account, debit, credit """select account, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s from `tabGL Entry` where voucher_type='Sales Invoice' and voucher_no=%s
group by account
order by account asc""", order by account asc""",
si.name, si.name,
as_dict=1, as_dict=1,
@ -3207,15 +3213,10 @@ class TestSalesInvoice(unittest.TestCase):
account.disabled = 0 account.disabled = 0
account.save() account.save()
@change_settings("Accounts Settings", {"unlink_payment_on_cancellation_of_invoice": 1})
def test_gain_loss_with_advance_entry(self): def test_gain_loss_with_advance_entry(self):
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
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 = make_journal_entry("_Test Receivable USD - _TC", "_Test Bank - _TC", -7000, save=False)
jv.accounts[0].exchange_rate = 70 jv.accounts[0].exchange_rate = 70
@ -3248,18 +3249,28 @@ class TestSalesInvoice(unittest.TestCase):
) )
si.save() si.save()
si.submit() si.submit()
expected_gle = [ 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", 7500.0, 0.0, nowdate()],
["_Test Receivable USD - _TC", 0.0, 500.0, nowdate()],
["Sales - _TC", 0.0, 7500.0, nowdate()], ["Sales - _TC", 0.0, 7500.0, nowdate()],
] ]
check_gl_entries(self, si.name, expected_gle, nowdate()) check_gl_entries(self, si.name, expected_gle, nowdate())
frappe.db.set_single_value( si.reload()
"Accounts Settings", "unlink_payment_on_cancel_of_invoice", unlink_enabled 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,
) )
def test_batch_expiry_for_sales_invoice_return(self): def test_batch_expiry_for_sales_invoice_return(self):
@ -3365,6 +3376,14 @@ class TestSalesInvoice(unittest.TestCase):
set_advance_flag(company="_Test Company", flag=0, default_account="") set_advance_flag(company="_Test Company", flag=0, default_account="")
@change_settings("Selling Settings", {"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): def set_advance_flag(company, flag, default_account):
frappe.db.set_value( frappe.db.set_value(

View File

@ -604,7 +604,8 @@
"hidden": 1, "hidden": 1,
"label": "Batch No", "label": "Batch No",
"options": "Batch", "options": "Batch",
"read_only": 1 "read_only": 1,
"search_index": 1
}, },
{ {
"fieldname": "col_break5", "fieldname": "col_break5",
@ -894,13 +895,14 @@
"label": "Serial and Batch Bundle", "label": "Serial and Batch Bundle",
"no_copy": 1, "no_copy": 1,
"options": "Serial and Batch Bundle", "options": "Serial and Batch Bundle",
"print_hide": 1 "print_hide": 1,
"search_index": 1
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-03-12 13:42:24.303113", "modified": "2023-07-26 12:53:22.404057",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice Item", "name": "Sales Invoice Item",

View File

@ -1,6 +1,5 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt // License: GNU General Public License v3. See license.txt
cur_frm.cscript.tax_table = "Sales Taxes and Charges"; erpnext.accounts.taxes.setup_tax_validations("Sales Taxes and Charges Template");
erpnext.accounts.taxes.setup_tax_filters("Sales Taxes and Charges");
{% include "erpnext/public/js/controllers/accounts.js" %}

View File

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

View File

@ -19,6 +19,7 @@
"trial_period_end", "trial_period_end",
"follow_calendar_months", "follow_calendar_months",
"generate_new_invoices_past_due_date", "generate_new_invoices_past_due_date",
"submit_invoice",
"column_break_11", "column_break_11",
"current_invoice_start", "current_invoice_start",
"current_invoice_end", "current_invoice_end",
@ -35,12 +36,8 @@
"cb_2", "cb_2",
"additional_discount_percentage", "additional_discount_percentage",
"additional_discount_amount", "additional_discount_amount",
"sb_3",
"submit_invoice",
"invoices",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center"
"dimension_col_break"
], ],
"fields": [ "fields": [
{ {
@ -162,29 +159,12 @@
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Additional DIscount Amount" "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, "collapsible": 1,
"fieldname": "accounting_dimensions_section", "fieldname": "accounting_dimensions_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Accounting Dimensions" "label": "Accounting Dimensions"
}, },
{
"fieldname": "dimension_col_break",
"fieldtype": "Column Break"
},
{ {
"fieldname": "party_type", "fieldname": "party_type",
"fieldtype": "Link", "fieldtype": "Link",
@ -259,15 +239,27 @@
"default": "1", "default": "1",
"fieldname": "submit_invoice", "fieldname": "submit_invoice",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Submit Invoice Automatically" "label": "Submit Generated Invoices"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [
"modified": "2021-04-19 15:24:27.550797", {
"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",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Subscription", "name": "Subscription",
"naming_rule": "Expression (old style)",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@ -309,5 +301,6 @@
], ],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -2,14 +2,17 @@
# For license information, please see license.txt # For license information, please see license.txt
from datetime import datetime
from typing import Dict, List, Optional, Union
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.data import ( from frappe.utils.data import (
add_days, add_days,
add_months,
add_to_date, add_to_date,
cint, cint,
cstr,
date_diff, date_diff,
flt, flt,
get_last_day, get_last_day,
@ -17,8 +20,7 @@ from frappe.utils.data import (
nowdate, nowdate,
) )
import erpnext from erpnext import get_default_company, get_default_cost_center
from erpnext import get_default_company
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions, get_accounting_dimensions,
) )
@ -26,33 +28,39 @@ from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_pla
from erpnext.accounts.party import get_party_account_currency from erpnext.accounts.party import get_party_account_currency
class InvoiceCancelled(frappe.ValidationError):
pass
class InvoiceNotCancelled(frappe.ValidationError):
pass
class Subscription(Document): class Subscription(Document):
def before_insert(self): def before_insert(self):
# update start just before the subscription doc is created # update start just before the subscription doc is created
self.update_subscription_period(self.start_date) self.update_subscription_period(self.start_date)
def update_subscription_period(self, date=None, return_date=False): def update_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
""" """
Subscription period is the period to be billed. This method updates the Subscription period is the period to be billed. This method updates the
beginning of the billing period and end of the billing period. beginning of the billing period and end of the billing period.
The beginning of the billing period is represented in the doctype as The beginning of the billing period is represented in the doctype as
`current_invoice_start` and the end of the billing period is represented `current_invoice_start` and the end of the billing period is represented
as `current_invoice_end`. as `current_invoice_end`.
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
""" """
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):
_current_invoice_start = self.get_current_invoice_start(date) _current_invoice_start = self.get_current_invoice_start(date)
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start) _current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
if return_date:
return _current_invoice_start, _current_invoice_end return _current_invoice_start, _current_invoice_end
self.current_invoice_start = _current_invoice_start def get_current_invoice_start(
self.current_invoice_end = _current_invoice_end self, date: Optional[Union[datetime.date, str]] = None
) -> Union[datetime.date, str]:
def get_current_invoice_start(self, date=None):
""" """
This returns the date of the beginning of the current billing period. 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 If the `date` parameter is not given , it will be automatically set as today's
@ -75,13 +83,13 @@ class Subscription(Document):
return _current_invoice_start return _current_invoice_start
def get_current_invoice_end(self, date=None): def get_current_invoice_end(
self, date: Optional[Union[datetime.date, str]] = None
) -> Union[datetime.date, str]:
""" """
This returns the date of the end of the current billing period. This returns the date of the end of the current billing period.
If the subscription is in trial period, it will be set as the end of the If the subscription is in trial period, it will be set as the end of the
trial period. trial period.
If is not in a trial period, it will be `x` days from the beginning of the 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 current billing period where `x` is the billing interval from the
`Subscription Plan` in the `Subscription`. `Subscription Plan` in the `Subscription`.
@ -105,24 +113,13 @@ class Subscription(Document):
_current_invoice_end = get_last_day(date) _current_invoice_end = get_last_day(date)
if self.follow_calendar_months: 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_info = self.get_billing_cycle_and_interval()
billing_interval_count = billing_info[0]["billing_interval_count"] billing_interval_count = billing_info[0]["billing_interval_count"]
calendar_months = get_calendar_months(billing_interval_count) _end = add_months(getdate(date), billing_interval_count - 1)
calendar_month = 0 _current_invoice_end = get_last_day(_end)
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): if self.end_date and getdate(_current_invoice_end) > getdate(self.end_date):
_current_invoice_end = self.end_date _current_invoice_end = self.end_date
@ -130,7 +127,7 @@ class Subscription(Document):
return _current_invoice_end return _current_invoice_end
@staticmethod @staticmethod
def validate_plans_billing_cycle(billing_cycle_data): def validate_plans_billing_cycle(billing_cycle_data: List[Dict[str, str]]) -> None:
""" """
Makes sure that all `Subscription Plan` in the `Subscription` have the Makes sure that all `Subscription Plan` in the `Subscription` have the
same billing interval same billing interval
@ -138,10 +135,9 @@ class Subscription(Document):
if billing_cycle_data and len(billing_cycle_data) != 1: 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")) frappe.throw(_("You can only have Plans with the same billing cycle in a Subscription"))
def get_billing_cycle_and_interval(self): def get_billing_cycle_and_interval(self) -> List[Dict[str, str]]:
""" """
Returns a dict representing the billing interval and cycle for this `Subscription`. 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. You shouldn't need to call this directly. Use `get_billing_cycle` instead.
""" """
plan_names = [plan.plan for plan in self.plans] plan_names = [plan.plan for plan in self.plans]
@ -156,72 +152,65 @@ class Subscription(Document):
return billing_info return billing_info
def get_billing_cycle_data(self): def get_billing_cycle_data(self) -> Dict[str, int]:
""" """
Returns dict contain the billing cycle data. Returns dict contain the billing cycle data.
You shouldn't need to call this directly. Use `get_billing_cycle` instead. You shouldn't need to call this directly. Use `get_billing_cycle` instead.
""" """
billing_info = self.get_billing_cycle_and_interval() billing_info = self.get_billing_cycle_and_interval()
if not billing_info:
return None
self.validate_plans_billing_cycle(billing_info)
if billing_info:
data = dict() data = dict()
interval = billing_info[0]["billing_interval"] interval = billing_info[0]["billing_interval"]
interval_count = billing_info[0]["billing_interval_count"] interval_count = billing_info[0]["billing_interval_count"]
if interval not in ["Day", "Week"]: if interval not in ["Day", "Week"]:
data["days"] = -1 data["days"] = -1
if interval == "Day": if interval == "Day":
data["days"] = interval_count - 1 data["days"] = interval_count - 1
elif interval == "Week":
data["days"] = interval_count * 7 - 1
elif interval == "Month": elif interval == "Month":
data["months"] = interval_count data["months"] = interval_count
elif interval == "Year": elif interval == "Year":
data["years"] = interval_count data["years"] = interval_count
# todo: test week
elif interval == "Week":
data["days"] = interval_count * 7 - 1
return data return data
def set_status_grace_period(self): def set_subscription_status(self) -> None:
"""
Sets the `Subscription` `status` based on the preference set in `Subscription Settings`.
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` Sets the status of the `Subscription`
""" """
if self.is_trialling(): if self.is_trialling():
self.status = "Trialling" self.status = "Trialling"
elif self.status == "Active" and self.end_date and getdate() > getdate(self.end_date): elif (
self.status == "Active"
and self.end_date
and getdate(frappe.flags.current_date) > getdate(self.end_date)
):
self.status = "Completed" self.status = "Completed"
elif self.is_past_grace_period(): elif self.is_past_grace_period():
subscription_settings = frappe.get_single("Subscription Settings") self.status = self.get_status_for_past_grace_period()
self.status = "Cancelled" if cint(subscription_settings.cancel_after_grace) else "Unpaid" self.cancelation_date = (
getdate(frappe.flags.current_date) if self.status == "Cancelled" else None
)
elif self.current_invoice_is_past_due() and not self.is_past_grace_period(): elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
self.status = "Past Due Date" self.status = "Past Due Date"
elif not self.has_outstanding_invoice(): elif not self.has_outstanding_invoice() or self.is_new_subscription():
self.status = "Active"
elif self.is_new_subscription():
self.status = "Active" self.status = "Active"
self.save() self.save()
def is_trialling(self): def is_trialling(self) -> bool:
""" """
Returns `True` if the `Subscription` is in trial period. Returns `True` if the `Subscription` is in trial period.
""" """
return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription() return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription()
@staticmethod @staticmethod
def period_has_passed(end_date): def period_has_passed(end_date: Union[str, datetime.date]) -> bool:
""" """
Returns true if the given `end_date` has passed Returns true if the given `end_date` has passed
""" """
@ -229,61 +218,59 @@ class Subscription(Document):
if not end_date: if not end_date:
return True return True
end_date = getdate(end_date) return getdate(frappe.flags.current_date) > getdate(end_date)
return getdate() > getdate(end_date)
def is_past_grace_period(self): 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:
""" """
Returns `True` if the grace period for the `Subscription` has passed Returns `True` if the grace period for the `Subscription` has passed
""" """
current_invoice = self.get_current_invoice() if not self.current_invoice_is_past_due():
if self.current_invoice_is_past_due(current_invoice): return
subscription_settings = frappe.get_single("Subscription Settings")
grace_period = cint(subscription_settings.grace_period)
return getdate() > add_days(current_invoice.due_date, 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)
)
def current_invoice_is_past_due(self, current_invoice=None): def current_invoice_is_past_due(self) -> bool:
""" """
Returns `True` if the current generated invoice is overdue Returns `True` if the current generated invoice is overdue
""" """
if not current_invoice: if not self.current_invoice or self.is_paid(self.current_invoice):
current_invoice = self.get_current_invoice()
if not current_invoice or self.is_paid(current_invoice):
return False return False
else:
return getdate() > getdate(current_invoice.due_date)
def get_current_invoice(self): return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date)
"""
Returns the most recent generated invoice.
"""
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice"
if len(self.invoices): @property
current = self.invoices[-1] def invoice_document_type(self) -> str:
if frappe.db.exists(doctype, current.get("invoice")): return "Sales Invoice" if self.party_type == "Customer" else "Purchase 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): def is_new_subscription(self) -> bool:
""" """
Returns `True` if `Subscription` has never generated an invoice Returns `True` if `Subscription` has never generated an invoice
""" """
return len(self.invoices) == 0 return self.is_new() or not frappe.db.exists(
{"doctype": self.invoice_document_type, "subscription": self.name}
)
def validate(self): def validate(self) -> None:
self.validate_trial_period() self.validate_trial_period()
self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval()) self.validate_plans_billing_cycle(self.get_billing_cycle_and_interval())
self.validate_end_date() self.validate_end_date()
self.validate_to_follow_calendar_months() self.validate_to_follow_calendar_months()
if not self.cost_center: if not self.cost_center:
self.cost_center = erpnext.get_default_cost_center(self.get("company")) self.cost_center = get_default_cost_center(self.get("company"))
def validate_trial_period(self): def validate_trial_period(self) -> None:
""" """
Runs sanity checks on trial period dates for the `Subscription` Runs sanity checks on trial period dates for the `Subscription`
""" """
@ -297,7 +284,7 @@ class Subscription(Document):
if self.trial_period_start and getdate(self.trial_period_start) > getdate(self.start_date): 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")) frappe.throw(_("Trial Period Start date cannot be after Subscription Start Date"))
def validate_end_date(self): def validate_end_date(self) -> None:
billing_cycle_info = self.get_billing_cycle_data() billing_cycle_info = self.get_billing_cycle_data()
end_date = add_to_date(self.start_date, **billing_cycle_info) end_date = add_to_date(self.start_date, **billing_cycle_info)
@ -306,53 +293,53 @@ class Subscription(Document):
_("Subscription End Date must be after {0} as per the subscription plan").format(end_date) _("Subscription End Date must be after {0} as per the subscription plan").format(end_date)
) )
def validate_to_follow_calendar_months(self): def validate_to_follow_calendar_months(self) -> None:
if self.follow_calendar_months: if not self.follow_calendar_months:
return
billing_info = self.get_billing_cycle_and_interval() billing_info = self.get_billing_cycle_and_interval()
if not self.end_date: if not self.end_date:
frappe.throw(_("Subscription End Date is mandatory to follow calendar months")) frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
if billing_info[0]["billing_interval"] != "Month": if billing_info[0]["billing_interval"] != "Month":
frappe.throw( frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months"))
_("Billing Interval in Subscription Plan must be Month to follow calendar months")
)
def after_insert(self): def after_insert(self) -> None:
# todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype? # todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype?
self.set_subscription_status() self.set_subscription_status()
def generate_invoice(self, prorate=0): def generate_invoice(
self,
from_date: Optional[Union[str, datetime.date]] = None,
to_date: Optional[Union[str, datetime.date]] = None,
) -> Document:
""" """
Creates a `Invoice` for the `Subscription`, updates `self.invoices` and Creates a `Invoice` for the `Subscription`, updates `self.invoices` and
saves the `Subscription`. saves the `Subscription`.
Backwards compatibility
""" """
return self.create_invoice(from_date=from_date, to_date=to_date)
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" def create_invoice(
self,
invoice = self.create_invoice(prorate) from_date: Optional[Union[str, datetime.date]] = None,
self.append("invoices", {"document_type": doctype, "invoice": invoice.name}) to_date: Optional[Union[str, datetime.date]] = None,
) -> Document:
self.save()
return invoice
def create_invoice(self, prorate):
""" """
Creates a `Invoice`, submits it and returns it 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 # For backward compatibility
# Earlier subscription didn't had any company field # Earlier subscription didn't had any company field
company = self.get("company") or get_default_company() company = self.get("company") or get_default_company()
if not company: if not company:
# fmt: off
frappe.throw( 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.company = company
invoice.set_posting_time = 1 invoice.set_posting_time = 1
invoice.posting_date = ( invoice.posting_date = (
@ -363,17 +350,17 @@ class Subscription(Document):
invoice.cost_center = self.cost_center invoice.cost_center = self.cost_center
if doctype == "Sales Invoice": if self.invoice_document_type == "Sales Invoice":
invoice.customer = self.party invoice.customer = self.party
else: else:
invoice.supplier = self.party invoice.supplier = self.party
if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"): if frappe.db.get_value("Supplier", self.party, "tax_withholding_category"):
invoice.apply_tds = 1 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) 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() accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions: for dimension in accounting_dimensions:
@ -382,7 +369,7 @@ class Subscription(Document):
# Subscription is better suited for service items. I won't update `update_stock` # Subscription is better suited for service items. I won't update `update_stock`
# for that reason # for that reason
items_list = self.get_items_from_plans(self.plans, prorate) items_list = self.get_items_from_plans(self.plans, is_prorate())
for item in items_list: for item in items_list:
item["cost_center"] = self.cost_center item["cost_center"] = self.cost_center
invoice.append("items", item) invoice.append("items", item)
@ -390,9 +377,9 @@ class Subscription(Document):
# Taxes # Taxes
tax_template = "" tax_template = ""
if doctype == "Sales Invoice" and self.sales_tax_template: if self.invoice_document_type == "Sales Invoice" and self.sales_tax_template:
tax_template = self.sales_tax_template tax_template = self.sales_tax_template
if doctype == "Purchase Invoice" and self.purchase_tax_template: if self.invoice_document_type == "Purchase Invoice" and self.purchase_tax_template:
tax_template = self.purchase_tax_template tax_template = self.purchase_tax_template
if tax_template: if tax_template:
@ -424,8 +411,9 @@ class Subscription(Document):
invoice.apply_discount_on = discount_on if discount_on else "Grand Total" invoice.apply_discount_on = discount_on if discount_on else "Grand Total"
# Subscription period # Subscription period
invoice.from_date = self.current_invoice_start invoice.subscription = self.name
invoice.to_date = self.current_invoice_end invoice.from_date = from_date or self.current_invoice_start
invoice.to_date = to_date or self.current_invoice_end
invoice.flags.ignore_mandatory = True invoice.flags.ignore_mandatory = True
@ -437,13 +425,20 @@ class Subscription(Document):
return invoice return invoice
def get_items_from_plans(self, plans, prorate=0): def get_items_from_plans(
self, plans: List[Dict[str, str]], prorate: Optional[bool] = None
) -> List[Dict]:
""" """
Returns the `Item`s linked to `Subscription Plan` Returns the `Item`s linked to `Subscription Plan`
""" """
if prorate is None:
prorate = False
if prorate: if prorate:
prorate_factor = get_prorata_factor( prorate_factor = get_prorata_factor(
self.current_invoice_end, self.current_invoice_start, self.generate_invoice_at_period_start self.current_invoice_end,
self.current_invoice_start,
cint(self.generate_invoice_at_period_start),
) )
items = [] items = []
@ -465,7 +460,11 @@ class Subscription(Document):
"item_code": item_code, "item_code": item_code,
"qty": plan.qty, "qty": plan.qty,
"rate": get_plan_rate( "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, "cost_center": plan_doc.cost_center,
} }
@ -503,254 +502,184 @@ class Subscription(Document):
return items return items
def process(self): @frappe.whitelist()
def process(self) -> bool:
""" """
To be called by task periodically. It checks the subscription and takes appropriate action To be called by task periodically. It checks the subscription and takes appropriate action
as need be. It calls either of these methods depending the `Subscription` status: as need be. It calls either of these methods depending the `Subscription` status:
1. `process_for_active` 1. `process_for_active`
2. `process_for_past_due` 2. `process_for_past_due`
""" """
if self.status == "Active": if (
self.process_for_active() not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
elif self.status in ["Past Due Date", "Unpaid"]: and self.can_generate_new_invoice()
self.process_for_past_due_date() ):
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()
self.set_subscription_status() self.set_subscription_status()
self.save() self.save()
def is_postpaid_to_invoice(self): def can_generate_new_invoice(self) -> bool:
return getdate() > getdate(self.current_invoice_end) or ( if self.cancelation_date:
getdate() >= getdate(self.current_invoice_end)
and getdate(self.current_invoice_end) == getdate(self.current_invoice_start)
)
def is_prepaid_to_invoice(self):
if not self.generate_invoice_at_period_start:
return False return False
elif self.generate_invoice_at_period_start and (
if self.is_new_subscription() and getdate() >= getdate(self.current_invoice_start): getdate(frappe.flags.current_date) == getdate(self.current_invoice_start)
return True or self.is_new_subscription()
# 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.update_subscription_period(
date=add_days(self.current_invoice_end, 1), return_date=True
)
if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate(
_current_end_date
): ):
return True 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
return True
else:
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 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)
)
if self.current_invoice and getdate(_current_start_date) <= getdate(
self.current_invoice.posting_date
) <= getdate(_current_end_date):
return True
return False return False
def process_for_active(self): @property
def current_invoice(self) -> Union[Document, None]:
""" """
Called by `process` if the status of the `Subscription` is 'Active'. Adds property for accessing the current_invoice
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'
""" """
return self.get_current_invoice()
if not self.is_current_invoice_generated( def get_current_invoice(self) -> Union[Document, None]:
self.current_invoice_start, self.current_invoice_end """
) and (self.is_postpaid_to_invoice() or self.is_prepaid_to_invoice()): 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",
)
prorate = frappe.db.get_single_value("Subscription Settings", "prorate") if invoice:
self.generate_invoice(prorate) return frappe.get_doc(self.invoice_document_type, invoice[0])
if getdate() > getdate(self.current_invoice_end) and self.is_prepaid_to_invoice(): def cancel_subscription_at_period_end(self) -> None:
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 Called when `Subscription.cancel_at_period_end` is truthy
""" """
if self.end_date and getdate() < getdate(self.end_date):
return
self.status = "Cancelled" self.status = "Cancelled"
if not self.cancelation_date:
self.cancelation_date = nowdate() self.cancelation_date = nowdate()
def process_for_past_due_date(self): @property
""" def invoices(self) -> List[Dict]:
Called by `process` if the status of the `Subscription` is 'Past Due Date'. return frappe.get_all(
self.invoice_document_type,
The possible outcomes of this method are: filters={"subscription": self.name},
1. Change the `Subscription` status to 'Active' order_by="from_date asc",
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 @staticmethod
def is_paid(invoice): def is_paid(invoice: Document) -> bool:
""" """
Return `True` if the given invoice is paid Return `True` if the given invoice is paid
""" """
return invoice.status == "Paid" return invoice.status == "Paid"
def has_outstanding_invoice(self): def has_outstanding_invoice(self) -> int:
""" """
Returns `True` if the most recent invoice for the `Subscription` is not paid Returns `True` if the most recent invoice for the `Subscription` is not paid
""" """
doctype = "Sales Invoice" if self.party_type == "Customer" else "Purchase Invoice" return frappe.db.count(
current_invoice = self.get_current_invoice() self.invoice_document_type,
invoice_list = [d.invoice for d in self.invoices] {
"subscription": self.name,
outstanding_invoices = frappe.get_all( "status": ["!=", "Paid"],
doctype, fields=["name"], filters={"status": ("!=", "Paid"), "name": ("in", invoice_list)} },
) )
if outstanding_invoices: @frappe.whitelist()
return True def cancel_subscription(self) -> None:
else:
False
def cancel_subscription(self):
""" """
This sets the subscription as cancelled. It will stop invoices from being generated This sets the subscription as cancelled. It will stop invoices from being generated
but it will not affect already created invoices. but it will not affect already created invoices.
""" """
if self.status != "Cancelled": if self.status == "Cancelled":
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
to_generate_invoice = ( to_generate_invoice = (
True if self.status == "Active" and not self.generate_invoice_at_period_start else False True if self.status == "Active" and not self.generate_invoice_at_period_start else False
) )
to_prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
self.status = "Cancelled" self.status = "Cancelled"
self.cancelation_date = nowdate() self.cancelation_date = nowdate()
if to_generate_invoice: if to_generate_invoice:
self.generate_invoice(prorate=to_prorate) self.generate_invoice(self.current_invoice_start, self.cancelation_date)
self.save() self.save()
def restart_subscription(self): @frappe.whitelist()
def restart_subscription(self) -> None:
""" """
This sets the subscription as active. The subscription will be made to be like a new This sets the subscription as active. The subscription will be made to be like a new
subscription and the `Subscription` will lose all the history of generated invoices subscription and the `Subscription` will lose all the history of generated invoices
it has. it has.
""" """
if self.status == "Cancelled": if not self.status == "Cancelled":
frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
self.status = "Active" self.status = "Active"
self.db_set("start_date", nowdate()) self.cancelation_date = None
self.update_subscription_period(nowdate()) self.update_subscription_period(frappe.flags.current_date or nowdate())
self.invoices = []
self.save() self.save()
else:
frappe.throw(_("You cannot restart a Subscription that is not cancelled."))
def get_precision(self):
invoice = self.get_current_invoice()
if invoice:
return invoice.precision("grand_total")
def get_calendar_months(billing_interval): def is_prorate() -> int:
calendar_months = [] return cint(frappe.db.get_single_value("Subscription Settings", "prorate"))
start = 0
while start < 12:
start += billing_interval
calendar_months.append(start)
return calendar_months
def get_prorata_factor(period_end, period_start, is_prepaid): def get_prorata_factor(
period_end: Union[datetime.date, str],
period_start: Union[datetime.date, str],
is_prepaid: Optional[int] = None,
) -> Union[int, float]:
if is_prepaid: if is_prepaid:
prorate_factor = 1 return 1
else:
diff = flt(date_diff(nowdate(), period_start) + 1) diff = flt(date_diff(nowdate(), period_start) + 1)
plan_days = flt(date_diff(period_end, period_start) + 1) plan_days = flt(date_diff(period_end, period_start) + 1)
prorate_factor = diff / plan_days return diff / plan_days
return prorate_factor
def process_all(): def process_all() -> None:
""" """
Task to updates the status of all `Subscription` apart from those that are cancelled Task to updates the status of all `Subscription` apart from those that are cancelled
""" """
subscriptions = get_all_subscriptions() for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"):
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: try:
subscription = frappe.get_doc("Subscription", data["name"]) subscription = frappe.get_doc("Subscription", subscription)
subscription.process() subscription.process()
frappe.db.commit() frappe.db.commit()
except frappe.ValidationError: except frappe.ValidationError:
frappe.db.rollback() frappe.db.rollback()
subscription.log_error("Subscription failed") 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,6 +11,7 @@ from frappe.utils.data import (
date_diff, date_diff,
flt, flt,
get_date_str, get_date_str,
getdate,
nowdate, nowdate,
) )
@ -90,10 +91,18 @@ def create_parties():
customer.insert() customer.insert()
def reset_settings():
settings = frappe.get_single("Subscription Settings")
settings.grace_period = 0
settings.cancel_after_grace = 0
settings.save()
class TestSubscription(unittest.TestCase): class TestSubscription(unittest.TestCase):
def setUp(self): def setUp(self):
create_plan() create_plan()
create_parties() create_parties()
reset_settings()
def test_create_subscription_with_trial_with_correct_period(self): def test_create_subscription_with_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@ -116,8 +125,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.invoices, []) self.assertEqual(subscription.invoices, [])
self.assertEqual(subscription.status, "Trialling") self.assertEqual(subscription.status, "Trialling")
subscription.delete()
def test_create_subscription_without_trial_with_correct_period(self): def test_create_subscription_without_trial_with_correct_period(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@ -133,8 +140,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 0)
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
subscription.delete()
def test_create_subscription_trial_with_wrong_dates(self): def test_create_subscription_trial_with_wrong_dates(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@ -144,7 +149,6 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save) self.assertRaises(frappe.ValidationError, subscription.save)
subscription.delete()
def test_create_subscription_multi_with_different_billing_fails(self): def test_create_subscription_multi_with_different_billing_fails(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@ -156,7 +160,6 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1})
self.assertRaises(frappe.ValidationError, subscription.save) self.assertRaises(frappe.ValidationError, subscription.save)
subscription.delete()
def test_invoice_is_generated_at_end_of_billing_period(self): def test_invoice_is_generated_at_end_of_billing_period(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@ -169,13 +172,13 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
self.assertEqual(subscription.current_invoice_start, "2018-01-01") self.assertEqual(subscription.current_invoice_start, "2018-01-01")
self.assertEqual(subscription.current_invoice_end, "2018-01-31") self.assertEqual(subscription.current_invoice_end, "2018-01-31")
frappe.flags.current_date = "2018-01-31"
subscription.process() subscription.process()
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.current_invoice_start, "2018-01-01") self.assertEqual(subscription.current_invoice_start, "2018-02-01")
subscription.process() self.assertEqual(subscription.current_invoice_end, "2018-02-28")
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
subscription.delete()
def test_status_goes_back_to_active_after_invoice_is_paid(self): def test_status_goes_back_to_active_after_invoice_is_paid(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@ -183,7 +186,9 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Customer" subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.generate_invoice_at_period_start = True
subscription.insert() subscription.insert()
frappe.flags.current_date = "2018-01-01"
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
@ -203,11 +208,8 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1)) self.assertEqual(subscription.current_invoice_start, add_months(subscription.start_date, 1))
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
subscription.delete()
def test_subscription_cancel_after_grace_period(self): def test_subscription_cancel_after_grace_period(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace
settings.cancel_after_grace = 1 settings.cancel_after_grace = 1
settings.save() settings.save()
@ -215,20 +217,18 @@ class TestSubscription(unittest.TestCase):
subscription.party_type = "Customer" subscription.party_type = "Customer"
subscription.party = "_Test Customer" subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
# subscription.generate_invoice_at_period_start = True
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.insert() subscription.insert()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
frappe.flags.current_date = "2018-01-31"
subscription.process() # generate first invoice subscription.process() # generate first invoice
# This should change status to Cancelled since grace period is 0 # This should change status to Cancelled since grace period is 0
# And is backdated subscription so subscription will be cancelled after processing # And is backdated subscription so subscription will be cancelled after processing
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
settings.cancel_after_grace = default_grace_period_action
settings.save()
subscription.delete()
def test_subscription_unpaid_after_grace_period(self): def test_subscription_unpaid_after_grace_period(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace default_grace_period_action = settings.cancel_after_grace
@ -248,21 +248,26 @@ class TestSubscription(unittest.TestCase):
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
subscription.delete()
def test_subscription_invoice_days_until_due(self): def test_subscription_invoice_days_until_due(self):
_date = add_months(nowdate(), -1)
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
subscription.party = "_Test Customer" subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.days_until_due = 10 subscription.days_until_due = 10
subscription.start_date = add_months(nowdate(), -1) subscription.start_date = _date
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.insert() subscription.insert()
frappe.flags.current_date = subscription.current_invoice_end
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(len(subscription.invoices), 1) self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
subscription.delete() frappe.flags.current_date = add_days(subscription.current_invoice_end, 3)
self.assertEqual(len(subscription.invoices), 1)
self.assertEqual(subscription.status, "Active")
def test_subscription_is_past_due_doesnt_change_within_grace_period(self): def test_subscription_is_past_due_doesnt_change_within_grace_period(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
@ -276,6 +281,8 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = add_days(nowdate(), -1000) subscription.start_date = add_days(nowdate(), -1000)
subscription.insert() subscription.insert()
frappe.flags.current_date = subscription.current_invoice_end
subscription.process() # generate first invoice subscription.process() # generate first invoice
self.assertEqual(subscription.status, "Past Due Date") self.assertEqual(subscription.status, "Past Due Date")
@ -292,7 +299,6 @@ class TestSubscription(unittest.TestCase):
settings.grace_period = grace_period settings.grace_period = grace_period
settings.save() settings.save()
subscription.delete()
def test_subscription_remains_active_during_invoice_period(self): def test_subscription_remains_active_during_invoice_period(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@ -319,8 +325,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1)) self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 0)
subscription.delete()
def test_subscription_cancelation(self): def test_subscription_cancelation(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@ -331,8 +335,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
subscription.delete()
def test_subscription_cancellation_invoices(self): def test_subscription_cancellation_invoices(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
to_prorate = settings.prorate to_prorate = settings.prorate
@ -372,7 +374,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2)) self.assertEqual(flt(invoice.grand_total, 2), flt(prorate_factor * 900, 2))
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
subscription.delete()
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
@ -395,8 +396,6 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
subscription.delete()
def test_subscription_cancellation_invoices_with_prorata_true(self): def test_subscription_cancellation_invoices_with_prorata_true(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
to_prorate = settings.prorate to_prorate = settings.prorate
@ -422,8 +421,6 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
subscription.delete()
def test_subcription_cancellation_and_process(self): def test_subcription_cancellation_and_process(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
default_grace_period_action = settings.cancel_after_grace default_grace_period_action = settings.cancel_after_grace
@ -437,23 +434,22 @@ class TestSubscription(unittest.TestCase):
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.insert() subscription.insert()
subscription.process() # generate first invoice subscription.process() # generate first invoice
invoices = len(subscription.invoices)
# Generate an invoice for the cancelled period
subscription.cancel_subscription() subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoices) self.assertEqual(len(subscription.invoices), 1)
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoices) self.assertEqual(len(subscription.invoices), 1)
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Cancelled") self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoices) self.assertEqual(len(subscription.invoices), 1)
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
subscription.delete()
def test_subscription_restart_and_process(self): def test_subscription_restart_and_process(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
@ -468,6 +464,7 @@ class TestSubscription(unittest.TestCase):
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.insert() subscription.insert()
frappe.flags.current_date = "2018-01-31"
subscription.process() # generate first invoice subscription.process() # generate first invoice
# Status is unpaid as Days until Due is zero and grace period is Zero # Status is unpaid as Days until Due is zero and grace period is Zero
@ -478,19 +475,18 @@ class TestSubscription(unittest.TestCase):
subscription.restart_subscription() subscription.restart_subscription()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 1)
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Unpaid")
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 1)
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Unpaid")
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 1)
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
subscription.delete()
def test_subscription_unpaid_back_to_active(self): def test_subscription_unpaid_back_to_active(self):
settings = frappe.get_single("Subscription Settings") settings = frappe.get_single("Subscription Settings")
@ -503,8 +499,11 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Customer" subscription.party = "_Test Customer"
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
subscription.start_date = "2018-01-01" subscription.start_date = "2018-01-01"
subscription.generate_invoice_at_period_start = True
subscription.insert() subscription.insert()
frappe.flags.current_date = subscription.current_invoice_start
subscription.process() # generate first invoice subscription.process() # generate first invoice
# This should change status to Unpaid since grace period is 0 # This should change status to Unpaid since grace period is 0
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
@ -517,12 +516,12 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Active") self.assertEqual(subscription.status, "Active")
# A new invoice is generated # A new invoice is generated
frappe.flags.current_date = subscription.current_invoice_start
subscription.process() subscription.process()
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
settings.cancel_after_grace = default_grace_period_action settings.cancel_after_grace = default_grace_period_action
settings.save() settings.save()
subscription.delete()
def test_restart_active_subscription(self): def test_restart_active_subscription(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
@ -533,8 +532,6 @@ class TestSubscription(unittest.TestCase):
self.assertRaises(frappe.ValidationError, subscription.restart_subscription) self.assertRaises(frappe.ValidationError, subscription.restart_subscription)
subscription.delete()
def test_subscription_invoice_discount_percentage(self): def test_subscription_invoice_discount_percentage(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@ -549,8 +546,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(invoice.additional_discount_percentage, 10) self.assertEqual(invoice.additional_discount_percentage, 10)
self.assertEqual(invoice.apply_discount_on, "Grand Total") self.assertEqual(invoice.apply_discount_on, "Grand Total")
subscription.delete()
def test_subscription_invoice_discount_amount(self): def test_subscription_invoice_discount_amount(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Customer" subscription.party_type = "Customer"
@ -565,8 +560,6 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(invoice.discount_amount, 11) self.assertEqual(invoice.discount_amount, 11)
self.assertEqual(invoice.apply_discount_on, "Grand Total") self.assertEqual(invoice.apply_discount_on, "Grand Total")
subscription.delete()
def test_prepaid_subscriptions(self): def test_prepaid_subscriptions(self):
# Create a non pre-billed subscription, processing should not create # Create a non pre-billed subscription, processing should not create
# invoices. # invoices.
@ -614,8 +607,6 @@ class TestSubscription(unittest.TestCase):
settings.prorate = to_prorate settings.prorate = to_prorate
settings.save() settings.save()
subscription.delete()
def test_subscription_with_follow_calendar_months(self): def test_subscription_with_follow_calendar_months(self):
subscription = frappe.new_doc("Subscription") subscription = frappe.new_doc("Subscription")
subscription.party_type = "Supplier" subscription.party_type = "Supplier"
@ -623,14 +614,14 @@ class TestSubscription(unittest.TestCase):
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at_period_start = 1
subscription.follow_calendar_months = 1 subscription.follow_calendar_months = 1
# select subscription start date as '2018-01-15' # select subscription start date as "2018-01-15"
subscription.start_date = "2018-01-15" subscription.start_date = "2018-01-15"
subscription.end_date = "2018-07-15" subscription.end_date = "2018-07-15"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save() subscription.save()
# even though subscription starts at '2018-01-15' and Billing interval is Month and count 3 # 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' # 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") self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
def test_subscription_generate_invoice_past_due(self): def test_subscription_generate_invoice_past_due(self):
@ -639,11 +630,12 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Supplier" subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at_period_start = 1
subscription.generate_new_invoices_past_due_date = 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.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save() subscription.save()
frappe.flags.current_date = "2018-01-01"
# Process subscription and create first invoice # Process subscription and create first invoice
# Subscription status will be unpaid since due date has already passed # Subscription status will be unpaid since due date has already passed
subscription.process() subscription.process()
@ -652,8 +644,8 @@ class TestSubscription(unittest.TestCase):
# Now the Subscription is unpaid # Now the Subscription is unpaid
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in # Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
# subscription # subscription and the interval between the subscriptions is 3 months
frappe.flags.current_date = "2018-04-01"
subscription.process() subscription.process()
self.assertEqual(len(subscription.invoices), 2) self.assertEqual(len(subscription.invoices), 2)
@ -662,7 +654,7 @@ class TestSubscription(unittest.TestCase):
subscription.party_type = "Supplier" subscription.party_type = "Supplier"
subscription.party = "_Test Supplier" subscription.party = "_Test Supplier"
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at_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.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
subscription.save() subscription.save()
@ -682,7 +674,7 @@ class TestSubscription(unittest.TestCase):
subscription.party = "_Test Subscription Customer" subscription.party = "_Test Subscription Customer"
subscription.generate_invoice_at_period_start = 1 subscription.generate_invoice_at_period_start = 1
subscription.company = "_Test Company" 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.start_date = "2018-01-01"
subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1}) subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1})
subscription.save() subscription.save()
@ -692,5 +684,47 @@ class TestSubscription(unittest.TestCase):
self.assertEqual(subscription.status, "Unpaid") self.assertEqual(subscription.status, "Unpaid")
# Check the currency of the created invoice # Check the currency of the created invoice
currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].invoice, "currency") currency = frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "currency")
self.assertEqual(currency, "USD") 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

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

View File

@ -4,6 +4,7 @@
import unittest import unittest
import frappe import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from frappe.utils import today from frappe.utils import today
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
@ -17,6 +18,7 @@ class TestTaxWithholdingCategory(unittest.TestCase):
# create relevant supplier, etc # create relevant supplier, etc
create_records() create_records()
create_tax_withholding_category_records() create_tax_withholding_category_records()
make_pan_no_field()
def tearDown(self): def tearDown(self):
cancel_invoices() cancel_invoices()
@ -316,6 +318,42 @@ class TestTaxWithholdingCategory(unittest.TestCase):
for d in reversed(orders): for d in reversed(orders):
d.cancel() 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): def test_multi_category_single_supplier(self):
frappe.db.set_value( frappe.db.set_value(
"Supplier", "Test TDS Supplier5", "tax_withholding_category", "Test Service Category" "Supplier", "Test TDS Supplier5", "tax_withholding_category", "Test Service Category"
@ -415,6 +453,40 @@ class TestTaxWithholdingCategory(unittest.TestCase):
pe2.cancel() pe2.cancel()
pe3.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(): def cancel_invoices():
purchase_invoices = frappe.get_all( purchase_invoices = frappe.get_all(
@ -573,6 +645,8 @@ def create_records():
"Test TDS Supplier5", "Test TDS Supplier5",
"Test TDS Supplier6", "Test TDS Supplier6",
"Test TDS Supplier7", "Test TDS Supplier7",
"Test TDS Supplier8",
"Test LDC Supplier",
]: ]:
if frappe.db.exists("Supplier", name): if frappe.db.exists("Supplier", name):
continue continue
@ -769,3 +843,39 @@ def create_tax_withholding_category(
"accounts": [{"company": "_Test Company", "account": account}], "accounts": [{"company": "_Test Company", "account": account}],
} }
).insert() ).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

@ -13,14 +13,11 @@ import erpnext
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions, get_accounting_dimensions,
) )
from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
from erpnext.accounts.utils import create_payment_ledger_entry from erpnext.accounts.utils import create_payment_ledger_entry
class ClosedAccountingPeriod(frappe.ValidationError):
pass
def make_gl_entries( def make_gl_entries(
gl_map, gl_map,
cancel=False, cancel=False,
@ -31,6 +28,7 @@ def make_gl_entries(
): ):
if gl_map: if gl_map:
if not cancel: if not cancel:
make_acc_dimensions_offsetting_entry(gl_map)
validate_accounting_period(gl_map) validate_accounting_period(gl_map)
validate_disabled_accounts(gl_map) validate_disabled_accounts(gl_map)
gl_map = process_gl_map(gl_map, merge_entries) gl_map = process_gl_map(gl_map, merge_entries)
@ -54,6 +52,63 @@ def make_gl_entries(
make_reverse_gl_entries(gl_map, adv_adj=adv_adj, update_outstanding=update_outstanding) make_reverse_gl_entries(gl_map, adv_adj=adv_adj, update_outstanding=update_outstanding)
def make_acc_dimensions_offsetting_entry(gl_map):
accounting_dimensions_to_offset = get_accounting_dimensions_for_offsetting_entry(
gl_map, gl_map[0].company
)
no_of_dimensions = len(accounting_dimensions_to_offset)
if no_of_dimensions == 0:
return
offsetting_entries = []
for gle in gl_map:
for dimension in accounting_dimensions_to_offset:
offsetting_entry = gle.copy()
debit = flt(gle.credit) / no_of_dimensions if gle.credit != 0 else 0
credit = flt(gle.debit) / no_of_dimensions if gle.debit != 0 else 0
offsetting_entry.update(
{
"account": dimension.offsetting_account,
"debit": debit,
"credit": credit,
"debit_in_account_currency": debit,
"credit_in_account_currency": credit,
"remarks": _("Offsetting for Accounting Dimension") + " - {0}".format(dimension.name),
"against_voucher": None,
}
)
offsetting_entry["against_voucher_type"] = None
offsetting_entries.append(offsetting_entry)
gl_map += offsetting_entries
def get_accounting_dimensions_for_offsetting_entry(gl_map, company):
acc_dimension = frappe.qb.DocType("Accounting Dimension")
dimension_detail = frappe.qb.DocType("Accounting Dimension Detail")
acc_dimensions = (
frappe.qb.from_(acc_dimension)
.inner_join(dimension_detail)
.on(acc_dimension.name == dimension_detail.parent)
.select(acc_dimension.fieldname, acc_dimension.name, dimension_detail.offsetting_account)
.where(
(acc_dimension.disabled == 0)
& (dimension_detail.company == company)
& (dimension_detail.automatically_post_balancing_accounting_entry == 1)
)
).run(as_dict=True)
accounting_dimensions_to_offset = []
for acc_dimension in acc_dimensions:
values = set([entry.get(acc_dimension.fieldname) for entry in gl_map])
if len(values) > 1:
accounting_dimensions_to_offset.append(acc_dimension)
return accounting_dimensions_to_offset
def validate_disabled_accounts(gl_map): def validate_disabled_accounts(gl_map):
accounts = [d.account for d in gl_map if d.account] accounts = [d.account for d in gl_map if d.account]
@ -108,6 +163,7 @@ def process_gl_map(gl_map, merge_entries=True, precision=None):
if not gl_map: if not gl_map:
return [] return []
if gl_map[0].voucher_type != "Period Closing Voucher":
gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision) gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision)
if merge_entries: if merge_entries:

View File

@ -14,6 +14,7 @@ from frappe.contacts.doctype.address.address import (
from frappe.contacts.doctype.contact.contact import get_contact_details from frappe.contacts.doctype.contact.contact import get_contact_details
from frappe.core.doctype.user_permission.user_permission import get_permitted_documents from frappe.core.doctype.user_permission.user_permission import get_permitted_documents
from frappe.model.utils import get_fetch_values from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import Date, Sum
from frappe.utils import ( from frappe.utils import (
add_days, add_days,
add_months, add_months,
@ -33,6 +34,7 @@ import erpnext
from erpnext import get_company_currency from erpnext import get_company_currency
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen from erpnext.exceptions import InvalidAccountCurrency, PartyDisabled, PartyFrozen
from erpnext.utilities.regional import temporary_flag
PURCHASE_TRANSACTION_TYPES = {"Purchase Order", "Purchase Receipt", "Purchase Invoice"} PURCHASE_TRANSACTION_TYPES = {"Purchase Order", "Purchase Receipt", "Purchase Invoice"}
SALES_TRANSACTION_TYPES = { SALES_TRANSACTION_TYPES = {
@ -261,8 +263,7 @@ def set_address_details(
) )
if doctype in TRANSACTION_TYPES: if doctype in TRANSACTION_TYPES:
# required to set correct region with temporary_flag("company", company):
frappe.flags.company = company
get_regional_address_details(party_details, doctype, company) get_regional_address_details(party_details, doctype, company)
return party_address, shipping_address return party_address, shipping_address
@ -706,6 +707,7 @@ def get_payment_terms_template(party_name, party_type, company=None):
if party_type not in ("Customer", "Supplier"): if party_type not in ("Customer", "Supplier"):
return return
template = None template = None
if party_type == "Customer": if party_type == "Customer":
customer = frappe.get_cached_value( customer = frappe.get_cached_value(
"Customer", party_name, fieldname=["payment_terms", "customer_group"], as_dict=1 "Customer", party_name, fieldname=["payment_terms", "customer_group"], as_dict=1
@ -920,32 +922,35 @@ def get_party_shipping_address(doctype: str, name: str) -> Optional[str]:
def get_partywise_advanced_payment_amount( def get_partywise_advanced_payment_amount(
party_type, posting_date=None, future_payment=0, company=None, party=None party_type, posting_date=None, future_payment=0, company=None, party=None, account_type=None
): ):
cond = "1=1" gle = frappe.qb.DocType("GL Entry")
query = (
frappe.qb.from_(gle)
.select(gle.party)
.where(
(gle.party_type.isin(party_type)) & (gle.against_voucher.isnull()) & (gle.is_cancelled == 0)
)
.groupby(gle.party)
)
if account_type == "Receivable":
query = query.select(Sum(gle.credit).as_("amount"))
else:
query = query.select(Sum(gle.debit).as_("amount"))
if posting_date: if posting_date:
if future_payment: if future_payment:
cond = "(posting_date <= '{0}' OR DATE(creation) <= '{0}')" "".format(posting_date) query = query.where((gle.posting_date <= posting_date) | (Date(gle.creation) <= posting_date))
else: else:
cond = "posting_date <= '{0}'".format(posting_date) query = query.where(gle.posting_date <= posting_date)
if company: if company:
cond += "and company = {0}".format(frappe.db.escape(company)) query = query.where(gle.company == company)
if party: if party:
cond += "and party = {0}".format(frappe.db.escape(party)) query = query.where(gle.party == party)
data = frappe.db.sql( data = query.run(as_dict=True)
""" 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: if data:
return frappe._dict(data) return frappe._dict(data)

View File

@ -1,4 +1,5 @@
{ {
"absolute_value": 0,
"align_labels_right": 0, "align_labels_right": 0,
"creation": "2019-12-11 04:37:14.012805", "creation": "2019-12-11 04:37:14.012805",
"css": ".print-format th {\n background-color: transparent !important;\n border-bottom: 1px solid !important;\n border-top: none !important;\n}\n.print-format .ql-editor {\n padding-left: 0px;\n padding-right: 0px;\n}\n\n.print-format table {\n margin-bottom: 0px;\n }\n.print-format .table-data tr:last-child { \n border-bottom: 1px solid !important;\n}\n\n.print-format .table-inner tr:last-child {\n border-bottom:none !important;\n}\n.print-format .table-inner {\n margin: 0px 0px;\n}\n\n.print-format .table-data ul li { \n color:#787878 !important;\n}\n\n.no-top-border {\n border-top:none !important;\n}\n\n.table-inner td {\n padding-left: 0px !important; \n padding-top: 1px !important;\n padding-bottom: 1px !important;\n color:#787878 !important;\n}\n\n.total {\n background-color: lightgrey !important;\n padding-top: 4px !important;\n padding-bottom: 4px !important;\n}\n", "css": ".print-format th {\n background-color: transparent !important;\n border-bottom: 1px solid !important;\n border-top: none !important;\n}\n.print-format .ql-editor {\n padding-left: 0px;\n padding-right: 0px;\n}\n\n.print-format table {\n margin-bottom: 0px;\n }\n.print-format .table-data tr:last-child { \n border-bottom: 1px solid !important;\n}\n\n.print-format .table-inner tr:last-child {\n border-bottom:none !important;\n}\n.print-format .table-inner {\n margin: 0px 0px;\n}\n\n.print-format .table-data ul li { \n color:#787878 !important;\n}\n\n.no-top-border {\n border-top:none !important;\n}\n\n.table-inner td {\n padding-left: 0px !important; \n padding-top: 1px !important;\n padding-bottom: 1px !important;\n color:#787878 !important;\n}\n\n.total {\n background-color: lightgrey !important;\n padding-top: 4px !important;\n padding-bottom: 4px !important;\n}\n",
@ -9,10 +10,10 @@
"docstatus": 0, "docstatus": 0,
"doctype": "Print Format", "doctype": "Print Format",
"font": "Arial", "font": "Arial",
"format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"<div></div>\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"<b>{{doc.customer_name}}</b> <br />\\n{{doc.address_display}}\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"<div style=\\\"text-align:left;\\\">\\n<div style=\\\"font-size:24px; text-transform:uppercase;\\\">{{_(doc.dunning_type)}}</div>\\n<div style=\\\"font-size:16px;padding-bottom:5px;\\\">{{ doc.name }}</div>\\n</div>\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldname\": \"sales_invoice\", \"print_hide\": 0, \"label\": \"Sales Invoice\"}, {\"fieldname\": \"due_date\", \"print_hide\": 0, \"label\": \"Due Date\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"body_text\", \"print_hide\": 0, \"label\": \"Body Text\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"<table class=\\\"table table-borderless table-data\\\">\\n <tbody>\\n <tr>\\n <th>{{_(\\\"Description\\\")}}</th>\\n\\t <th style=\\\"text-align: right;\\\">{{_(\\\"Amount\\\")}}</th>\\n </tr>\\n <tr>\\n <td>\\n {{_(\\\"Outstanding Amount\\\")}}\\n </td>\\n <td style=\\\"text-align: right;\\\">\\n {{doc.get_formatted(\\\"outstanding_amount\\\")}}\\n </td>\\n </tr>\\n {%if doc.rate_of_interest > 0%}\\n <tr>\\n <td>\\n {{_(\\\"Interest \\\")}} {{doc.rate_of_interest}}% p.a. ({{doc.overdue_days}} {{_(\\\"days\\\")}})\\n </td>\\n <td style=\\\"text-align: right;\\\">\\n {{doc.get_formatted(\\\"interest_amount\\\")}}\\n </td>\\n </tr>\\n {% endif %}\\n {%if doc.dunning_fee > 0%}\\n <tr>\\n <td>\\n {{_(\\\"Dunning Fee\\\")}}\\n </td>\\n <td style=\\\"text-align: right;\\\">\\n {{doc.get_formatted(\\\"dunning_fee\\\")}}\\n </td>\\n </tr>\\n {% endif %}\\n </tbody>\\n</table>\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"\\n<div class=\\\"row total\\\" style =\\\"margin-right: 0px;\\\">\\n\\t\\t<div class=\\\"col-xs-5\\\">\\n\\t\\t\\t<b>{{_(\\\"Grand Total\\\")}}</b></div>\\n\\t\\t<div class=\\\"col-xs-7 text-right\\\" style=\\\"padding-right: 4px;\\\">\\n\\t\\t\\t<b>{{doc.get_formatted(\\\"grand_total\\\")}}</b>\\n\\t\\t</div>\\n</div>\\n\\n\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"closing_text\", \"print_hide\": 0, \"label\": \"Closing Text\"}]", "format_data": "[{\"fieldname\": \"print_heading_template\", \"fieldtype\": \"Custom HTML\", \"options\": \"<div></div>\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"<b>{{doc.customer_name}}</b> <br />\\n{{doc.address_display}}\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"_custom_html\", \"print_hide\": 0, \"label\": \"Custom HTML\", \"fieldtype\": \"HTML\", \"options\": \"<div style=\\\"text-align:left;\\\">\\n<div style=\\\"font-size:24px; text-transform:uppercase;\\\">{{_(doc.dunning_type)}}</div>\\n<div style=\\\"font-size:16px;padding-bottom:5px;\\\">{{ doc.name }}</div>\\n</div>\"}, {\"fieldname\": \"posting_date\", \"print_hide\": 0, \"label\": \"Date\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"body_text\", \"print_hide\": 0, \"label\": \"Body Text\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"overdue_payments\", \"print_hide\": 0, \"label\": \"Overdue Payments\", \"visible_columns\": [{\"fieldname\": \"sales_invoice\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"dunning_level\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"due_date\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"overdue_days\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"invoice_portion\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"outstanding\", \"print_width\": \"\", \"print_hide\": 0}, {\"fieldname\": \"interest\", \"print_width\": \"\", \"print_hide\": 0}]}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"total_outstanding\", \"print_hide\": 0, \"label\": \"Total Outstanding\"}, {\"fieldname\": \"dunning_fee\", \"print_hide\": 0, \"label\": \"Dunning Fee\"}, {\"fieldname\": \"total_interest\", \"print_hide\": 0, \"label\": \"Total Interest\"}, {\"fieldname\": \"grand_total\", \"print_hide\": 0, \"label\": \"Grand Total\"}, {\"fieldtype\": \"Section Break\", \"label\": \"\"}, {\"fieldtype\": \"Column Break\"}, {\"fieldname\": \"closing_text\", \"print_hide\": 0, \"label\": \"Closing Text\"}]",
"idx": 0, "idx": 0,
"line_breaks": 0, "line_breaks": 0,
"modified": "2020-07-14 18:25:44.348207", "modified": "2021-09-30 10:22:02.603871",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Dunning Letter", "name": "Dunning Letter",

View File

@ -1,6 +1,6 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors // Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt // For license information, please see license.txt
/* eslint-disable */
frappe.query_reports["Account Balance"] = { frappe.query_reports["Account Balance"] = {
"filters": [ "filters": [

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ from erpnext.accounts.report.accounts_receivable.accounts_receivable import Rece
def execute(filters=None): def execute(filters=None):
args = { args = {
"party_type": "Customer", "account_type": "Receivable",
"naming_by": ["Selling Settings", "cust_master_name"], "naming_by": ["Selling Settings", "cust_master_name"],
} }
@ -21,7 +21,10 @@ def execute(filters=None):
class AccountsReceivableSummary(ReceivablePayableReport): class AccountsReceivableSummary(ReceivablePayableReport):
def run(self, args): def run(self, args):
self.party_type = args.get("party_type") 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_naming_by = frappe.db.get_value( self.party_naming_by = frappe.db.get_value(
args.get("naming_by")[0], None, args.get("naming_by")[1] args.get("naming_by")[0], None, args.get("naming_by")[1]
) )
@ -35,13 +38,19 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.get_party_total(args) 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 = ( party_advance_amount = (
get_partywise_advanced_payment_amount( get_partywise_advanced_payment_amount(
self.party_type, self.party_type,
self.filters.report_date, self.filters.report_date,
self.filters.show_future_payments, self.filters.show_future_payments,
self.filters.company, self.filters.company,
party=self.filters.get(scrub(self.party_type)), party=party,
account_type=self.account_type,
) )
or {} or {}
) )
@ -57,9 +66,13 @@ class AccountsReceivableSummary(ReceivablePayableReport):
row.party = party row.party = party
if self.party_naming_by == "Naming Series": if self.party_naming_by == "Naming Series":
row.party_name = frappe.get_cached_value( if self.account_type == "Payable":
self.party_type, party, scrub(self.party_type) + "_name" doctype = "Supplier"
) fieldname = "supplier_name"
else:
doctype = "Customer"
fieldname = "customer_name"
row.party_name = frappe.get_cached_value(doctype, party, fieldname)
row.update(party_dict) row.update(party_dict)
@ -93,6 +106,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
# set territory, customer_group, sales person etc # set territory, customer_group, sales person etc
self.set_party_details(d) self.set_party_details(d)
self.party_total[d.party].update({"party_type": d.party_type})
def init_party_total(self, row): def init_party_total(self, row):
self.party_total.setdefault( self.party_total.setdefault(
@ -131,17 +145,27 @@ class AccountsReceivableSummary(ReceivablePayableReport):
def get_columns(self): def get_columns(self):
self.columns = [] self.columns = []
self.add_column( self.add_column(
label=_(self.party_type), label=_("Party Type"),
fieldname="party_type",
fieldtype="Data",
width=100,
)
self.add_column(
label=_("Party"),
fieldname="party", fieldname="party",
fieldtype="Link", fieldtype="Dynamic Link",
options=self.party_type, options="party_type",
width=180, width=180,
) )
if self.party_naming_by == "Naming Series": if self.party_naming_by == "Naming Series":
self.add_column(_("{0} Name").format(self.party_type), fieldname="party_name", fieldtype="Data") self.add_column(
label=_("Supplier Name") if self.account_type == "Payable" else _("Customer Name"),
fieldname="party_name",
fieldtype="Data",
)
credit_debit_label = "Credit Note" if self.party_type == "Customer" else "Debit Note" credit_debit_label = "Credit Note" if self.account_type == "Receivable" else "Debit Note"
self.add_column(_("Advance Amount"), fieldname="advance") self.add_column(_("Advance Amount"), fieldname="advance")
self.add_column(_("Invoiced Amount"), fieldname="invoiced") self.add_column(_("Invoiced Amount"), fieldname="invoiced")
@ -159,7 +183,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.add_column(label=_("Future Payment Amount"), fieldname="future_amount") self.add_column(label=_("Future Payment Amount"), fieldname="future_amount")
self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance") self.add_column(label=_("Remaining Balance"), fieldname="remaining_balance")
if self.party_type == "Customer": if self.account_type == "Receivable":
self.add_column( self.add_column(
label=_("Territory"), fieldname="territory", fieldtype="Link", options="Territory" label=_("Territory"), fieldname="territory", fieldtype="Link", options="Territory"
) )

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