Merge branch 'develop' into demo_data_on_install

This commit is contained in:
Ankush Menat 2023-08-10 15:22:52 +05:30
commit ba6de0b4ff
182 changed files with 5050 additions and 1299 deletions

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(
frm.trigger("merge_account"); __("Merge Account"),
}, __('Actions')); function () {
frm.add_custom_button(__('Update Account Name / Number'), function () { frm.trigger("merge_account");
frm.trigger("update_account_number"); },
}, __('Actions')); __("Actions")
);
frm.add_custom_button(
__("Update Account Name / Number"),
function () {
frm.trigger("update_account_number");
},
__("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(
frappe.set_route("Tree", "Account"); __("Chart of Accounts"),
}, __('View')); () => {
frappe.set_route("Tree", "Account");
},
__("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 () {
@ -86,31 +102,35 @@ frappe.ui.form.on('Account', {
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(
return frappe.call({ __("Convert to Group"),
doc: frm.doc, function () {
method: 'convert_ledger_to_group', return frappe.call({
callback: function() { doc: frm.doc,
frm.refresh(); method: "convert_ledger_to_group",
} callback: function () {
}); 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

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

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,11 +501,12 @@ class JournalEntry(AccountsController):
) )
if not against_entries: if not against_entries:
frappe.throw( if self.voucher_type != "Exchange Gain Or Loss":
_( frappe.throw(
"Journal Entry {0} does not have account {1} or already matched against other voucher" _(
).format(d.reference_name, d.account) "Journal Entry {0} does not have account {1} or already matched against other voucher"
) ).format(d.reference_name, d.account)
)
else: else:
dr_or_cr = "debit" if d.credit > 0 else "credit" dr_or_cr = "debit" if d.credit > 0 else "credit"
valid = False valid = False
@ -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,18 +773,23 @@ class JournalEntry(AccountsController):
) )
): ):
# Modified to include the posting date for which to retreive the exchange rate ignore_exchange_rate = False
d.exchange_rate = get_exchange_rate( if self.get("flags") and self.flags.get("ignore_exchange_rate"):
self.posting_date, ignore_exchange_rate = True
d.account,
d.account_currency, if not ignore_exchange_rate:
self.company, # Modified to include the posting date for which to retreive the exchange rate
d.reference_type, d.exchange_rate = get_exchange_rate(
d.reference_name, self.posting_date,
d.debit, d.account,
d.credit, d.account_currency,
d.exchange_rate, self.company,
) d.reference_type,
d.reference_name,
d.debit,
d.credit,
d.exchange_rate,
)
if not d.exchange_rate: if not d.exchange_rate:
frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx)) frappe.throw(_("Row {0}: Exchange Rate is mandatory").format(d.idx))
@ -935,6 +945,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,7 +141,7 @@ 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)

View File

@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
frappe.ui.form.on('Payment Entry', { frappe.ui.form.on('Payment Entry', {
onload: function(frm) { onload: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', "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);

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,
@ -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()
@ -277,12 +285,14 @@ 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 (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount): if (
frappe.throw(fail_message.format(d.idx)) d.payment_term
and (
if d.payment_term and ( (flt(d.allocated_amount)) > 0
(flt(d.allocated_amount)) > 0 and latest.payment_term_outstanding
and flt(d.allocated_amount) > flt(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( frappe.throw(
_( _(
@ -292,6 +302,9 @@ class PaymentEntry(AccountsController):
) )
) )
if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount):
frappe.throw(fail_message.format(d.idx))
# Check for negative outstanding invoices as well # Check for negative outstanding invoices as well
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))
@ -399,7 +412,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(
@ -412,7 +425,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(
@ -677,7 +690,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(
@ -722,6 +737,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()
@ -808,10 +837,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):
@ -1002,6 +1046,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:
@ -1988,7 +2036,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) >= (
@ -2128,7 +2175,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:

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)
@ -1156,6 +1155,52 @@ class TestPaymentEntry(FrappeTestCase):
si3.cancel() si3.cancel()
si3.delete() 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")

View File

@ -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,7 @@ 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,
}, },
{ {
"account": inv.account, "account": inv.account,
@ -669,13 +670,38 @@ 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,
}, },
], ],
} }
) )
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.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

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

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,52 @@ 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): def test_offsetting_entries_for_accounting_dimensions(self):
from erpnext.accounts.doctype.account.test_account import create_account from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.report.trial_balance.test_trial_balance import ( from erpnext.accounts.report.trial_balance.test_trial_balance import (

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

@ -37,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
super.onload(); super.onload();
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log', this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger"]; '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

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
@ -385,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",
) )
@ -1029,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":
@ -1054,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
@ -1176,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")),
@ -1209,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
@ -1646,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

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

@ -2049,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) ["_Test Account Service Tax - _TC", 0.0, 114.41],
for d in [ ["_Test Account VAT - _TC", 0.0, 114.41],
[si.debit_to, 1500, 0.0], [si.debit_to, 1500, 0.0],
["_Test Account Service Tax - _TC", 0.0, 114.41], ["Round Off - _TC", 0.01, 0.01],
["_Test Account VAT - _TC", 0.0, 114.41], ["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 (
@ -2125,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,
@ -3213,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
@ -3254,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):
@ -3371,6 +3376,13 @@ class TestSalesInvoice(unittest.TestCase):
set_advance_flag(company="_Test Company", flag=0, default_account="") set_advance_flag(company="_Test Company", flag=0, default_account="")
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

@ -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,76 +20,60 @@ 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.add_custom_button(
() => frm.events.cancel_this_subscription(frm) __('Fetch Subscription Updates'),
); () => frm.trigger('get_subscription_updates'),
frm.add_custom_button( __('Actions')
__('Fetch Subscription Updates'), );
() => frm.events.get_subscription_updates(frm)
); frm.add_custom_button(
} __('Cancel Subscription'),
else if(frm.doc.status === 'Cancelled'){ () => frm.trigger('cancel_this_subscription'),
frm.add_custom_button( __('Actions')
__('Restart Subscription'), );
() => frm.events.renew_this_subscription(frm) } else if (frm.doc.status === 'Cancelled') {
); frm.add_custom_button(
} __('Restart Subscription'),
() => 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", frm.reload_doc();
args: {name: doc.name},
callback: function(data){
if(!data.exc){
frm.reload_doc();
}
} }
}); });
} }
); );
}, },
renew_this_subscription: function(frm) { renew_this_subscription: function (frm) {
const doc = frm.doc;
frappe.confirm( 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", frm.reload_doc();
args: {name: doc.name},
callback: function(data){
if(!data.exc){
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: frm.reload_doc();
"erpnext.accounts.doctype.subscription.subscription.get_subscription_updates",
args: {name: doc.name},
freeze: true,
callback: function(data){
if(!data.exc){
frm.reload_doc();
}
} }
}); });
} }

View File

@ -19,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) data = dict()
interval = billing_info[0]["billing_interval"]
interval_count = billing_info[0]["billing_interval_count"]
if billing_info: if interval not in ["Day", "Week"]:
data = dict() data["days"] = -1
interval = billing_info[0]["billing_interval"]
interval_count = billing_info[0]["billing_interval_count"]
if interval not in ["Day", "Week"]:
data["days"] = -1
if interval == "Day":
data["days"] = interval_count - 1
elif interval == "Month":
data["months"] = interval_count
elif interval == "Year":
data["years"] = interval_count
# todo: test week
elif interval == "Week":
data["days"] = interval_count * 7 - 1
return data if interval == "Day":
data["days"] = interval_count - 1
elif interval == "Week":
data["days"] = interval_count * 7 - 1
elif interval == "Month":
data["months"] = interval_count
elif interval == "Year":
data["years"] = interval_count
def set_status_grace_period(self): return data
"""
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 def set_subscription_status(self) -> None:
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:
billing_info = self.get_billing_cycle_and_interval() return
if not self.end_date: billing_info = self.get_billing_cycle_and_interval()
frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
if billing_info[0]["billing_interval"] != "Month": if not self.end_date:
frappe.throw( frappe.throw(_("Subscription End Date is mandatory to follow calendar months"))
_("Billing Interval in Subscription Plan must be Month to follow calendar months")
)
def after_insert(self): if billing_info[0]["billing_interval"] != "Month":
frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months"))
def after_insert(self) -> None:
# 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) return False
and getdate(self.current_invoice_end) == getdate(self.current_invoice_start) elif self.generate_invoice_at_period_start and (
) getdate(frappe.flags.current_date) == getdate(self.current_invoice_start)
or self.is_new_subscription()
):
return True
elif getdate(frappe.flags.current_date) == getdate(self.current_invoice_end):
if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
return False
def is_prepaid_to_invoice(self): return True
if not self.generate_invoice_at_period_start: else:
return False return False
if self.is_new_subscription() and getdate() >= getdate(self.current_invoice_start): def is_current_invoice_generated(
return True self,
_current_start_date: Union[datetime.date, str] = None,
# Check invoice dates and make sure it doesn't have outstanding invoices _current_end_date: Union[datetime.date, str] = None,
return getdate() >= getdate(self.current_invoice_start) ) -> bool:
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): if not (_current_start_date and _current_end_date):
_current_start_date, _current_end_date = self.update_subscription_period( _current_start_date, _current_end_date = self._get_subscription_period(
date=add_days(self.current_invoice_end, 1), return_date=True date=add_days(self.current_invoice_end, 1)
) )
if invoice and getdate(_current_start_date) <= getdate(invoice.posting_date) <= getdate( if self.current_invoice and getdate(_current_start_date) <= getdate(
_current_end_date self.current_invoice.posting_date
): ) <= getdate(_current_end_date):
return True 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":
to_generate_invoice = ( frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
True if self.status == "Active" and not self.generate_invoice_at_period_start else False
)
to_prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
self.status = "Cancelled"
self.cancelation_date = nowdate()
if to_generate_invoice:
self.generate_invoice(prorate=to_prorate)
self.save()
def restart_subscription(self): to_generate_invoice = (
True if self.status == "Active" and not self.generate_invoice_at_period_start else False
)
self.status = "Cancelled"
self.cancelation_date = nowdate()
if to_generate_invoice:
self.generate_invoice(self.current_invoice_start, self.cancelation_date)
self.save()
@frappe.whitelist()
def restart_subscription(self) -> None:
""" """
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":
self.status = "Active" frappe.throw(_("You cannot restart a Subscription that is not cancelled."), InvoiceNotCancelled)
self.db_set("start_date", nowdate())
self.update_subscription_period(nowdate())
self.invoices = []
self.save()
else:
frappe.throw(_("You cannot restart a Subscription that is not cancelled."))
def get_precision(self): self.status = "Active"
invoice = self.get_current_invoice() self.cancelation_date = None
if invoice: self.update_subscription_period(frappe.flags.current_date or nowdate())
return invoice.precision("grand_total") self.save()
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)
plan_days = flt(date_diff(period_end, period_start) + 1)
prorate_factor = diff / plan_days
return prorate_factor diff = flt(date_diff(nowdate(), period_start) + 1)
plan_days = flt(date_diff(period_end, period_start) + 1)
return diff / plan_days
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

@ -476,7 +476,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(

View File

@ -316,6 +316,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"
@ -573,6 +609,7 @@ def create_records():
"Test TDS Supplier5", "Test TDS Supplier5",
"Test TDS Supplier6", "Test TDS Supplier6",
"Test TDS Supplier7", "Test TDS Supplier7",
"Test TDS Supplier8",
]: ]:
if frappe.db.exists("Supplier", name): if frappe.db.exists("Supplier", name):
continue continue

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

@ -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,8 +211,9 @@ 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:
amount = ple.amount_in_account_currency if self.filters.get(scrub(party_type)):
amount = ple.amount_in_account_currency
else: else:
amount = ple.amount amount = ple.amount
amount_in_account_currency = ple.amount_in_account_currency amount_in_account_currency = ple.amount_in_account_currency
@ -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,8 +426,10 @@ 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:
row.currency = row.account_currency if self.filters.get(scrub(party_type)):
row.currency = row.account_currency
break
else: else:
row.currency = self.company_currency row.currency = self.company_currency
@ -532,65 +539,67 @@ 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 .where(
and payment_entry.posting_date > %s (pe.docstatus < 2)
and payment_entry.party_type = %s & (pe.posting_date > self.filters.report_date)
""", & (pe.party_type.isin(self.party_type))
(self.filters.report_date, self.party_type), )
as_dict=1, ).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 .where(
`tabJournal Entry` as je inner join `tabJournal Entry Account` as jea (je.docstatus < 2)
on & (je.posting_date > self.filters.report_date)
(jea.parent = je.name) & (jea.party_type.isin(self.party_type))
where & (jea.reference_name.isnotnull())
je.docstatus < 2 & (jea.reference_name != "")
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,
) )
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
# this method allocates pending future payments against a voucher to # this method allocates pending future payments against a voucher to
@ -619,13 +628,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 = {}
if self.filters.get(party_field): for party_type in self.party_type:
filters.update({party_field: self.filters.get(party_field)}) party_field = scrub(party_type)
if 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 +729,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,16 +760,18 @@ 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)
if party_type_field == "customer": if party_type_field == "customer":
self.add_customer_filters() self.add_customer_filters()
elif party_type_field == "supplier": elif party_type_field == "supplier":
self.add_supplier_filters() self.add_supplier_filters()
if self.filters.cost_center: if self.filters.cost_center:
self.get_cost_center_conditions() self.get_cost_center_conditions()
@ -784,11 +800,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 +893,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 +916,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 +937,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 +969,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 +979,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 +997,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 +1018,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"
) )

View File

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

View File

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

View File

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

View File

@ -0,0 +1,72 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
/* eslint-disable */
frappe.query_reports["Financial Ratios"] = {
filters: [
{
fieldname: "company",
label: __("Company"),
fieldtype: "Link",
options: "Company",
default: frappe.defaults.get_user_default("Company"),
reqd: 1,
},
{
fieldname: "from_fiscal_year",
label: __("Start Year"),
fieldtype: "Link",
options: "Fiscal Year",
default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
reqd: 1,
},
{
fieldname: "to_fiscal_year",
label: __("End Year"),
fieldtype: "Link",
options: "Fiscal Year",
default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today()),
reqd: 1,
},
{
fieldname: "periodicity",
label: __("Periodicity"),
fieldtype: "Data",
default: "Yearly",
reqd: 1,
hidden: 1,
},
{
fieldname: "period_start_date",
label: __("From Date"),
fieldtype: "Date",
default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[1],
hidden: 1,
},
{
fieldname: "period_end_date",
label: __("To Date"),
fieldtype: "Date",
default: erpnext.utils.get_fiscal_year(frappe.datetime.get_today(), true)[2],
hidden: 1,
},
],
"formatter": function(value, row, column, data, default_formatter) {
let heading_ratios = ["Liquidity Ratios", "Solvency Ratios","Turnover Ratios"]
if (heading_ratios.includes(value)) {
value = $(`<span>${value}</span>`);
let $value = $(value).css("font-weight", "bold");
value = $value.wrap("<p></p>").parent().html();
}
if (heading_ratios.includes(row[1].content) && column.fieldtype == "Float") {
column.fieldtype = "Data";
}
value = default_formatter(value, row, column, data);
return value;
},
};

View File

@ -0,0 +1,37 @@
{
"add_total_row": 0,
"columns": [],
"creation": "2023-07-13 16:11:11.925096",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"modified": "2023-07-13 16:11:11.925096",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Financial Ratios",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Account",
"report_name": "Financial Ratios",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Auditor"
},
{
"role": "Sales User"
},
{
"role": "Purchase User"
},
{
"role": "Accounts Manager"
}
]
}

View File

@ -0,0 +1,296 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.utils import add_days, flt
from erpnext.accounts.report.financial_statements import get_data, get_period_list
from erpnext.accounts.utils import get_balance_on, get_fiscal_year
def execute(filters=None):
filters["filter_based_on"] = "Fiscal Year"
columns, data = [], []
setup_filters(filters)
period_list = get_period_list(
filters.from_fiscal_year,
filters.to_fiscal_year,
filters.period_start_date,
filters.period_end_date,
filters.filter_based_on,
filters.periodicity,
company=filters.company,
)
columns, years = get_columns(period_list)
data = get_ratios_data(filters, period_list, years)
return columns, data
def setup_filters(filters):
if not filters.get("period_start_date"):
period_start_date = get_fiscal_year(fiscal_year=filters.from_fiscal_year)[1]
filters["period_start_date"] = period_start_date
if not filters.get("period_end_date"):
period_end_date = get_fiscal_year(fiscal_year=filters.to_fiscal_year)[2]
filters["period_end_date"] = period_end_date
def get_columns(period_list):
years = []
columns = [
{
"label": _("Ratios"),
"fieldname": "ratio",
"fieldtype": "Data",
"width": 200,
},
]
for period in period_list:
columns.append(
{
"fieldname": period.key,
"label": period.label,
"fieldtype": "Float",
"width": 150,
}
)
years.append(period.key)
return columns, years
def get_ratios_data(filters, period_list, years):
data = []
assets, liabilities, income, expense = get_gl_data(filters, period_list, years)
current_asset, total_asset = {}, {}
current_liability, total_liability = {}, {}
net_sales, total_income = {}, {}
cogs, total_expense = {}, {}
quick_asset = {}
direct_expense = {}
for year in years:
total_quick_asset = 0
total_net_sales = 0
total_cogs = 0
for d in [
[
current_asset,
total_asset,
"Current Asset",
year,
assets,
"Asset",
quick_asset,
total_quick_asset,
],
[
current_liability,
total_liability,
"Current Liability",
year,
liabilities,
"Liability",
{},
0,
],
[cogs, total_expense, "Cost of Goods Sold", year, expense, "Expense", {}, total_cogs],
[direct_expense, direct_expense, "Direct Expense", year, expense, "Expense", {}, 0],
[net_sales, total_income, "Direct Income", year, income, "Income", {}, total_net_sales],
]:
update_balances(d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7])
add_liquidity_ratios(data, years, current_asset, current_liability, quick_asset)
add_solvency_ratios(
data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense
)
add_turnover_ratios(
data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense
)
return data
def get_gl_data(filters, period_list, years):
data = {}
for d in [
["Asset", "Debit"],
["Liability", "Credit"],
["Income", "Credit"],
["Expense", "Debit"],
]:
data[frappe.scrub(d[0])] = get_data(
filters.company,
d[0],
d[1],
period_list,
only_current_fiscal_year=False,
filters=filters,
)
assets, liabilities, income, expense = (
data.get("asset"),
data.get("liability"),
data.get("income"),
data.get("expense"),
)
return assets, liabilities, income, expense
def add_liquidity_ratios(data, years, current_asset, current_liability, quick_asset):
precision = frappe.db.get_single_value("System Settings", "float_precision")
data.append({"ratio": "Liquidity Ratios"})
ratio_data = [["Current Ratio", current_asset], ["Quick Ratio", quick_asset]]
for d in ratio_data:
row = {
"ratio": d[0],
}
for year in years:
row[year] = calculate_ratio(d[1].get(year, 0), current_liability.get(year, 0), precision)
data.append(row)
def add_solvency_ratios(
data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense
):
precision = frappe.db.get_single_value("System Settings", "float_precision")
data.append({"ratio": "Solvency Ratios"})
debt_equity_ratio = {"ratio": "Debt Equity Ratio"}
gross_profit_ratio = {"ratio": "Gross Profit Ratio"}
net_profit_ratio = {"ratio": "Net Profit Ratio"}
return_on_asset_ratio = {"ratio": "Return on Asset Ratio"}
return_on_equity_ratio = {"ratio": "Return on Equity Ratio"}
for year in years:
profit_after_tax = total_income[year] + total_expense[year]
share_holder_fund = total_asset[year] - total_liability[year]
debt_equity_ratio[year] = calculate_ratio(
total_liability.get(year), share_holder_fund, precision
)
return_on_equity_ratio[year] = calculate_ratio(profit_after_tax, share_holder_fund, precision)
net_profit_ratio[year] = calculate_ratio(profit_after_tax, net_sales.get(year), precision)
gross_profit_ratio[year] = calculate_ratio(
net_sales.get(year, 0) - cogs.get(year, 0), net_sales.get(year), precision
)
return_on_asset_ratio[year] = calculate_ratio(profit_after_tax, total_asset.get(year), precision)
data.append(debt_equity_ratio)
data.append(gross_profit_ratio)
data.append(net_profit_ratio)
data.append(return_on_asset_ratio)
data.append(return_on_equity_ratio)
def add_turnover_ratios(
data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense
):
precision = frappe.db.get_single_value("System Settings", "float_precision")
data.append({"ratio": "Turnover Ratios"})
avg_data = {}
for d in ["Receivable", "Payable", "Stock"]:
avg_data[frappe.scrub(d)] = avg_ratio_balance("Receivable", period_list, precision, filters)
avg_debtors, avg_creditors, avg_stock = (
avg_data.get("receivable"),
avg_data.get("payable"),
avg_data.get("stock"),
)
ratio_data = [
["Fixed Asset Turnover Ratio", net_sales, total_asset],
["Debtor Turnover Ratio", net_sales, avg_debtors],
["Creditor Turnover Ratio", direct_expense, avg_creditors],
["Inventory Turnover Ratio", cogs, avg_stock],
]
for ratio in ratio_data:
row = {
"ratio": ratio[0],
}
for year in years:
row[year] = calculate_ratio(ratio[1].get(year, 0), ratio[2].get(year, 0), precision)
data.append(row)
def update_balances(
ratio_dict,
total_dict,
account_type,
year,
root_type_data,
root_type,
net_dict=None,
total_net=0,
):
for entry in root_type_data:
if not entry.get("parent_account") and entry.get("is_group"):
total_dict[year] = entry[year]
if account_type == "Direct Expense":
total_dict[year] = entry[year] * -1
if root_type in ("Asset", "Liability"):
if entry.get("account_type") == account_type and entry.get("is_group"):
ratio_dict[year] = entry.get(year)
if entry.get("account_type") in ["Bank", "Cash", "Receivable"] and not entry.get("is_group"):
total_net += entry.get(year)
net_dict[year] = total_net
elif root_type == "Income":
if entry.get("account_type") == account_type and entry.get("is_group"):
total_net += entry.get(year)
ratio_dict[year] = total_net
elif root_type == "Expense" and account_type == "Cost of Goods Sold":
if entry.get("account_type") == account_type:
total_net += entry.get(year)
ratio_dict[year] = total_net
else:
if entry.get("account_type") == account_type and entry.get("is_group"):
ratio_dict[year] = entry.get(year)
def avg_ratio_balance(account_type, period_list, precision, filters):
avg_ratio = {}
for period in period_list:
opening_date = add_days(period["from_date"], -1)
closing_date = period["to_date"]
closing_balance = get_balance_on(
date=closing_date,
company=filters.company,
account_type=account_type,
)
opening_balance = get_balance_on(
date=opening_date,
company=filters.company,
account_type=account_type,
)
avg_ratio[period["key"]] = flt(
(flt(closing_balance) + flt(opening_balance)) / 2, precision=precision
)
return avg_ratio
def calculate_ratio(value, denominator, precision):
if flt(denominator):
return flt(flt(value) / denominator, precision)
return 0

View File

@ -188,6 +188,7 @@ def get_data(
filters, filters,
gl_entries_by_account, gl_entries_by_account,
ignore_closing_entries=ignore_closing_entries, ignore_closing_entries=ignore_closing_entries,
root_type=root_type,
) )
calculate_values( calculate_values(
@ -417,13 +418,28 @@ def set_gl_entries_by_account(
gl_entries_by_account, gl_entries_by_account,
ignore_closing_entries=False, ignore_closing_entries=False,
ignore_opening_entries=False, ignore_opening_entries=False,
root_type=None,
): ):
"""Returns a dict like { "account": [gl entries], ... }""" """Returns a dict like { "account": [gl entries], ... }"""
gl_entries = [] gl_entries = []
account_filters = {
"company": company,
"is_group": 0,
"lft": (">=", root_lft),
"rgt": ("<=", root_rgt),
}
if root_type:
account_filters.update(
{
"root_type": root_type,
}
)
accounts_list = frappe.db.get_all( accounts_list = frappe.db.get_all(
"Account", "Account",
filters={"company": company, "is_group": 0, "lft": (">=", root_lft), "rgt": ("<=", root_rgt)}, filters=account_filters,
pluck="name", pluck="name",
) )

View File

@ -0,0 +1,52 @@
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
function get_filters() {
let filters = [
{
"fieldname":"company",
"label": __("Company"),
"fieldtype": "Link",
"options": "Company",
"default": frappe.defaults.get_user_default("Company"),
"reqd": 1
},
{
"fieldname":"period_start_date",
"label": __("Start Date"),
"fieldtype": "Date",
"reqd": 1,
"default": frappe.datetime.add_months(frappe.datetime.get_today(), -1)
},
{
"fieldname":"period_end_date",
"label": __("End Date"),
"fieldtype": "Date",
"reqd": 1,
"default": frappe.datetime.get_today()
},
{
"fieldname":"account",
"label": __("Account"),
"fieldtype": "MultiSelectList",
"options": "Account",
get_data: function(txt) {
return frappe.db.get_link_options('Account', txt, {
company: frappe.query_report.get_filter_value("company"),
account_type: ['in', ["Receivable", "Payable"]]
});
}
},
{
"fieldname":"voucher_no",
"label": __("Voucher No"),
"fieldtype": "Data",
"width": 100,
},
]
return filters;
}
frappe.query_reports["General and Payment Ledger Comparison"] = {
"filters": get_filters()
};

View File

@ -0,0 +1,32 @@
{
"add_total_row": 0,
"columns": [],
"creation": "2023-08-02 17:30:29.494907",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letterhead": null,
"modified": "2023-08-02 17:30:29.494907",
"modified_by": "Administrator",
"module": "Accounts",
"name": "General and Payment Ledger Comparison",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "GL Entry",
"report_name": "General and Payment Ledger Comparison",
"report_type": "Script Report",
"roles": [
{
"role": "Accounts User"
},
{
"role": "Accounts Manager"
},
{
"role": "Auditor"
}
]
}

View File

@ -0,0 +1,221 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _, qb
from frappe.query_builder import Criterion
from frappe.query_builder.functions import Sum
class General_Payment_Ledger_Comparison(object):
"""
A Utility report to compare Voucher-wise balance between General and Payment Ledger
"""
def __init__(self, filters=None):
self.filters = filters
self.gle = []
self.ple = []
def get_accounts(self):
receivable_accounts = [
x[0]
for x in frappe.db.get_all(
"Account",
filters={"company": self.filters.company, "account_type": "Receivable"},
as_list=True,
)
]
payable_accounts = [
x[0]
for x in frappe.db.get_all(
"Account", filters={"company": self.filters.company, "account_type": "Payable"}, as_list=True
)
]
self.account_types = frappe._dict(
{
"receivable": frappe._dict({"accounts": receivable_accounts, "gle": [], "ple": []}),
"payable": frappe._dict({"accounts": payable_accounts, "gle": [], "ple": []}),
}
)
def generate_filters(self):
if self.filters.account:
self.account_types.receivable.accounts = []
self.account_types.payable.accounts = []
for acc in frappe.db.get_all(
"Account", filters={"name": ["in", self.filters.account]}, fields=["name", "account_type"]
):
if acc.account_type == "Receivable":
self.account_types.receivable.accounts.append(acc.name)
else:
self.account_types.payable.accounts.append(acc.name)
def get_gle(self):
gle = qb.DocType("GL Entry")
for acc_type, val in self.account_types.items():
if val.accounts:
filter_criterion = []
if self.filters.voucher_no:
filter_criterion.append((gle.voucher_no == self.filters.voucher_no))
if self.filters.period_start_date:
filter_criterion.append(gle.posting_date.gte(self.filters.period_start_date))
if self.filters.period_end_date:
filter_criterion.append(gle.posting_date.lte(self.filters.period_end_date))
if acc_type == "receivable":
outstanding = (Sum(gle.debit) - Sum(gle.credit)).as_("outstanding")
else:
outstanding = (Sum(gle.credit) - Sum(gle.debit)).as_("outstanding")
self.account_types[acc_type].gle = (
qb.from_(gle)
.select(
gle.company,
gle.account,
gle.voucher_no,
gle.party,
outstanding,
)
.where(
(gle.company == self.filters.company)
& (gle.is_cancelled == 0)
& (gle.account.isin(val.accounts))
)
.where(Criterion.all(filter_criterion))
.groupby(gle.company, gle.account, gle.voucher_no, gle.party)
.run()
)
def get_ple(self):
ple = qb.DocType("Payment Ledger Entry")
for acc_type, val in self.account_types.items():
if val.accounts:
filter_criterion = []
if self.filters.voucher_no:
filter_criterion.append((ple.voucher_no == self.filters.voucher_no))
if self.filters.period_start_date:
filter_criterion.append(ple.posting_date.gte(self.filters.period_start_date))
if self.filters.period_end_date:
filter_criterion.append(ple.posting_date.lte(self.filters.period_end_date))
self.account_types[acc_type].ple = (
qb.from_(ple)
.select(
ple.company, ple.account, ple.voucher_no, ple.party, Sum(ple.amount).as_("outstanding")
)
.where(
(ple.company == self.filters.company)
& (ple.delinked == 0)
& (ple.account.isin(val.accounts))
)
.where(Criterion.all(filter_criterion))
.groupby(ple.company, ple.account, ple.voucher_no, ple.party)
.run()
)
def compare(self):
self.gle_balances = set()
self.ple_balances = set()
# consolidate both receivable and payable balances in one set
for acc_type, val in self.account_types.items():
self.gle_balances = set(val.gle) | self.gle_balances
self.ple_balances = set(val.ple) | self.ple_balances
self.diff1 = self.gle_balances.difference(self.ple_balances)
self.diff2 = self.ple_balances.difference(self.gle_balances)
self.diff = frappe._dict({})
for x in self.diff1:
self.diff[(x[0], x[1], x[2], x[3])] = frappe._dict({"gl_balance": x[4]})
for x in self.diff2:
self.diff[(x[0], x[1], x[2], x[3])].update(frappe._dict({"pl_balance": x[4]}))
def generate_data(self):
self.data = []
for key, val in self.diff.items():
self.data.append(
frappe._dict(
{
"voucher_no": key[2],
"party": key[3],
"gl_balance": val.gl_balance,
"pl_balance": val.pl_balance,
}
)
)
def get_columns(self):
self.columns = []
options = None
self.columns.append(
dict(
label=_("Voucher No"),
fieldname="voucher_no",
fieldtype="Data",
options=options,
width="100",
)
)
self.columns.append(
dict(
label=_("Party"),
fieldname="party",
fieldtype="Data",
options=options,
width="100",
)
)
self.columns.append(
dict(
label=_("GL Balance"),
fieldname="gl_balance",
fieldtype="Currency",
options="Company:company:default_currency",
width="100",
)
)
self.columns.append(
dict(
label=_("Payment Ledger Balance"),
fieldname="pl_balance",
fieldtype="Currency",
options="Company:company:default_currency",
width="100",
)
)
def run(self):
self.get_accounts()
self.generate_filters()
self.get_gle()
self.get_ple()
self.compare()
self.generate_data()
self.get_columns()
return self.columns, self.data
def execute(filters=None):
columns, data = [], []
rpt = General_Payment_Ledger_Comparison(filters)
columns, data = rpt.run()
return columns, data

View File

@ -0,0 +1,100 @@
import unittest
import frappe
from frappe import qb
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.general_and_payment_ledger_comparison.general_and_payment_ledger_comparison import (
execute,
)
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
class TestGeneralAndPaymentLedger(FrappeTestCase, AccountsTestMixin):
def setUp(self):
self.create_company()
self.cleanup()
def tearDown(self):
frappe.db.rollback()
def cleanup(self):
doctypes = []
doctypes.append(qb.DocType("GL Entry"))
doctypes.append(qb.DocType("Payment Ledger Entry"))
doctypes.append(qb.DocType("Sales Invoice"))
for doctype in doctypes:
qb.from_(doctype).delete().where(doctype.company == self.company).run()
def test_01_basic_report_functionality(self):
sinv = create_sales_invoice(
company=self.company,
debit_to=self.debit_to,
expense_account=self.expense_account,
cost_center=self.cost_center,
income_account=self.income_account,
warehouse=self.warehouse,
)
# manually edit the payment ledger entry
ple = frappe.db.get_all(
"Payment Ledger Entry", filters={"voucher_no": sinv.name, "delinked": 0}
)[0]
frappe.db.set_value("Payment Ledger Entry", ple.name, "amount", sinv.grand_total - 1)
filters = frappe._dict({"company": self.company})
columns, data = execute(filters=filters)
self.assertEqual(len(data), 1)
expected = {
"voucher_no": sinv.name,
"party": sinv.customer,
"gl_balance": sinv.grand_total,
"pl_balance": sinv.grand_total - 1,
}
self.assertEqual(expected, data[0])
# account filter
filters = frappe._dict({"company": self.company, "account": self.debit_to})
columns, data = execute(filters=filters)
self.assertEqual(len(data), 1)
self.assertEqual(expected, data[0])
filters = frappe._dict({"company": self.company, "account": self.creditors})
columns, data = execute(filters=filters)
self.assertEqual([], data)
# voucher_no filter
filters = frappe._dict({"company": self.company, "voucher_no": sinv.name})
columns, data = execute(filters=filters)
self.assertEqual(len(data), 1)
self.assertEqual(expected, data[0])
filters = frappe._dict({"company": self.company, "voucher_no": sinv.name + "-1"})
columns, data = execute(filters=filters)
self.assertEqual([], data)
# date range filter
filters = frappe._dict(
{
"company": self.company,
"period_start_date": sinv.posting_date,
"period_end_date": sinv.posting_date,
}
)
columns, data = execute(filters=filters)
self.assertEqual(len(data), 1)
self.assertEqual(expected, data[0])
filters = frappe._dict(
{
"company": self.company,
"period_start_date": add_days(sinv.posting_date, -1),
"period_end_date": add_days(sinv.posting_date, -1),
}
)
columns, data = execute(filters=filters)
self.assertEqual([], data)

View File

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

View File

@ -46,6 +46,7 @@ def get_data(filters):
.select( .select(
gle.voucher_type, gle.voucher_no, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit") gle.voucher_type, gle.voucher_no, Sum(gle.debit).as_("debit"), Sum(gle.credit).as_("credit")
) )
.where(gle.is_cancelled == 0)
.groupby(gle.voucher_no) .groupby(gle.voucher_no)
) )
query = apply_filters(query, filters, gle) query = apply_filters(query, filters, gle)

View File

@ -4,7 +4,7 @@ from erpnext.stock.doctype.item.test_item import create_item
class AccountsTestMixin: class AccountsTestMixin:
def create_customer(self, customer_name, currency=None): def create_customer(self, customer_name="_Test Customer", currency=None):
if not frappe.db.exists("Customer", customer_name): if not frappe.db.exists("Customer", customer_name):
customer = frappe.new_doc("Customer") customer = frappe.new_doc("Customer")
customer.customer_name = customer_name customer.customer_name = customer_name
@ -17,7 +17,7 @@ class AccountsTestMixin:
else: else:
self.customer = customer_name self.customer = customer_name
def create_supplier(self, supplier_name, currency=None): def create_supplier(self, supplier_name="_Test Supplier", currency=None):
if not frappe.db.exists("Supplier", supplier_name): if not frappe.db.exists("Supplier", supplier_name):
supplier = frappe.new_doc("Supplier") supplier = frappe.new_doc("Supplier")
supplier.supplier_name = supplier_name supplier.supplier_name = supplier_name
@ -31,7 +31,7 @@ class AccountsTestMixin:
else: else:
self.supplier = supplier_name self.supplier = supplier_name
def create_item(self, item_name, is_stock=0, warehouse=None, company=None): def create_item(self, item_name="_Test Item", is_stock=0, warehouse=None, company=None):
item = create_item(item_name, is_stock_item=is_stock, warehouse=warehouse, company=company) item = create_item(item_name, is_stock_item=is_stock, warehouse=warehouse, company=company)
self.item = item.name self.item = item.name
@ -62,19 +62,44 @@ class AccountsTestMixin:
self.debit_usd = "Debtors USD - " + abbr self.debit_usd = "Debtors USD - " + abbr
self.cash = "Cash - " + abbr self.cash = "Cash - " + abbr
self.creditors = "Creditors - " + abbr self.creditors = "Creditors - " + abbr
self.retained_earnings = "Retained Earnings - " + abbr
# create bank account # Deferred revenue, expense and bank accounts
bank_account = "HDFC - " + abbr other_accounts = [
if frappe.db.exists("Account", bank_account): frappe._dict(
self.bank = bank_account
else:
bank_acc = frappe.get_doc(
{ {
"doctype": "Account", "attribute_name": "deferred_revenue",
"account_name": "Deferred Revenue",
"parent_account": "Current Liabilities - " + abbr,
}
),
frappe._dict(
{
"attribute_name": "deferred_expense",
"account_name": "Deferred Expense",
"parent_account": "Current Assets - " + abbr,
}
),
frappe._dict(
{
"attribute_name": "bank",
"account_name": "HDFC", "account_name": "HDFC",
"parent_account": "Bank Accounts - " + abbr, "parent_account": "Bank Accounts - " + abbr,
"company": self.company,
} }
) ),
bank_acc.save() ]
self.bank = bank_acc.name for acc in other_accounts:
acc_name = acc.account_name + " - " + abbr
if frappe.db.exists("Account", acc_name):
setattr(self, acc.attribute_name, acc_name)
else:
new_acc = frappe.get_doc(
{
"doctype": "Account",
"account_name": acc.account_name,
"parent_account": acc.parent_account,
"company": self.company,
}
)
new_acc.save()
setattr(self, acc.attribute_name, new_acc.name)

View File

@ -80,18 +80,27 @@ class TestUtils(unittest.TestCase):
item = make_item().name item = make_item().name
purchase_invoice = make_purchase_invoice( purchase_invoice = make_purchase_invoice(
item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32 item=item, supplier="_Test Supplier USD", currency="USD", conversion_rate=82.32, do_not_submit=1
) )
purchase_invoice.credit_to = "_Test Payable USD - _TC"
purchase_invoice.submit() purchase_invoice.submit()
payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name) payment_entry = get_payment_entry(purchase_invoice.doctype, purchase_invoice.name)
payment_entry.target_exchange_rate = 62.9
payment_entry.paid_amount = 15725 payment_entry.paid_amount = 15725
payment_entry.deductions = [] payment_entry.deductions = []
payment_entry.insert() payment_entry.save()
# below is the difference between base_received_amount and base_paid_amount
self.assertEqual(payment_entry.difference_amount, -4855.0)
payment_entry.target_exchange_rate = 62.9
payment_entry.save()
# below is due to change in exchange rate
self.assertEqual(payment_entry.references[0].exchange_gain_loss, -4855.0)
self.assertEqual(payment_entry.difference_amount, -4855.00)
payment_entry.references = [] payment_entry.references = []
self.assertEqual(payment_entry.difference_amount, 0.0)
payment_entry.submit() payment_entry.submit()
payment_reconciliation = frappe.new_doc("Payment Reconciliation") payment_reconciliation = frappe.new_doc("Payment Reconciliation")

View File

@ -179,6 +179,7 @@ def get_balance_on(
in_account_currency=True, in_account_currency=True,
cost_center=None, cost_center=None,
ignore_account_permission=False, ignore_account_permission=False,
account_type=None,
): ):
if not account and frappe.form_dict.get("account"): if not account and frappe.form_dict.get("account"):
account = frappe.form_dict.get("account") account = frappe.form_dict.get("account")
@ -254,6 +255,21 @@ def get_balance_on(
else: else:
cond.append("""gle.account = %s """ % (frappe.db.escape(account, percent=False),)) cond.append("""gle.account = %s """ % (frappe.db.escape(account, percent=False),))
if account_type:
accounts = frappe.db.get_all(
"Account",
filters={"company": company, "account_type": account_type, "is_group": 0},
pluck="name",
order_by="lft",
)
cond.append(
"""
gle.account in (%s)
"""
% (", ".join([frappe.db.escape(account) for account in accounts]))
)
if party_type and party: if party_type and party:
cond.append( cond.append(
"""gle.party_type = %s and gle.party = %s """ """gle.party_type = %s and gle.party = %s """
@ -263,7 +279,8 @@ def get_balance_on(
if company: if company:
cond.append("""gle.company = %s """ % (frappe.db.escape(company, percent=False))) cond.append("""gle.company = %s """ % (frappe.db.escape(company, percent=False)))
if account or (party_type and party): if account or (party_type and party) or account_type:
if in_account_currency: if in_account_currency:
select_field = "sum(debit_in_account_currency) - sum(credit_in_account_currency)" select_field = "sum(debit_in_account_currency) - sum(credit_in_account_currency)"
else: else:
@ -276,7 +293,6 @@ def get_balance_on(
select_field, " and ".join(cond) select_field, " and ".join(cond)
) )
)[0][0] )[0][0]
# if bal is None, return 0 # if bal is None, return 0
return flt(bal) return flt(bal)
@ -459,6 +475,9 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n
# update ref in advance entry # update ref in advance entry
if voucher_type == "Journal Entry": if voucher_type == "Journal Entry":
update_reference_in_journal_entry(entry, doc, do_not_save=True) update_reference_in_journal_entry(entry, doc, do_not_save=True)
# advance section in sales/purchase invoice and reconciliation tool,both pass on exchange gain/loss
# amount and account in args
doc.make_exchange_gain_loss_journal(args)
else: else:
update_reference_in_payment_entry( update_reference_in_payment_entry(
entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe
@ -618,9 +637,7 @@ def update_reference_in_payment_entry(
"total_amount": d.grand_total, "total_amount": d.grand_total,
"outstanding_amount": d.outstanding_amount, "outstanding_amount": d.outstanding_amount,
"allocated_amount": d.allocated_amount, "allocated_amount": d.allocated_amount,
"exchange_rate": d.exchange_rate "exchange_rate": d.exchange_rate if d.exchange_gain_loss else payment_entry.get_exchange_rate(),
if not d.exchange_gain_loss
else payment_entry.get_exchange_rate(),
"exchange_gain_loss": d.exchange_gain_loss, # only populated from invoice in case of advance allocation "exchange_gain_loss": d.exchange_gain_loss, # only populated from invoice in case of advance allocation
"account": d.account, "account": d.account,
} }
@ -642,28 +659,48 @@ def update_reference_in_payment_entry(
new_row.docstatus = 1 new_row.docstatus = 1
new_row.update(reference_details) new_row.update(reference_details)
if d.difference_amount and d.difference_account:
account_details = {
"account": d.difference_account,
"cost_center": payment_entry.cost_center
or frappe.get_cached_value("Company", payment_entry.company, "cost_center"),
}
if d.difference_amount:
account_details["amount"] = d.difference_amount
payment_entry.set_gain_or_loss(account_details=account_details)
payment_entry.flags.ignore_validate_update_after_submit = True payment_entry.flags.ignore_validate_update_after_submit = True
payment_entry.setup_party_account_field() payment_entry.setup_party_account_field()
payment_entry.set_missing_values() payment_entry.set_missing_values()
if not skip_ref_details_update_for_pe: if not skip_ref_details_update_for_pe:
payment_entry.set_missing_ref_details() payment_entry.set_missing_ref_details()
payment_entry.set_amounts() payment_entry.set_amounts()
payment_entry.make_exchange_gain_loss_journal()
if not do_not_save: if not do_not_save:
payment_entry.save(ignore_permissions=True) payment_entry.save(ignore_permissions=True)
def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None:
"""
Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any.
"""
if parent_doc.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]:
journals = frappe.db.get_all(
"Journal Entry Account",
filters={
"reference_type": parent_doc.doctype,
"reference_name": parent_doc.name,
"docstatus": 1,
},
fields=["parent"],
as_list=1,
)
if journals:
gain_loss_journals = frappe.db.get_all(
"Journal Entry",
filters={
"name": ["in", [x[0] for x in journals]],
"voucher_type": "Exchange Gain Or Loss",
"docstatus": 1,
},
as_list=1,
)
for doc in gain_loss_journals:
frappe.get_doc("Journal Entry", doc[0]).cancel()
def unlink_ref_doc_from_payment_entries(ref_doc): def unlink_ref_doc_from_payment_entries(ref_doc):
remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name) remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name)
remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name) remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name)
@ -1820,3 +1857,74 @@ class QueryPaymentLedger(object):
self.query_for_outstanding() self.query_for_outstanding()
return self.voucher_outstandings return self.voucher_outstandings
def create_gain_loss_journal(
company,
party_type,
party,
party_account,
gain_loss_account,
exc_gain_loss,
dr_or_cr,
reverse_dr_or_cr,
ref1_dt,
ref1_dn,
ref1_detail_no,
ref2_dt,
ref2_dn,
ref2_detail_no,
) -> str:
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Exchange Gain Or Loss"
journal_entry.company = company
journal_entry.posting_date = nowdate()
journal_entry.multi_currency = 1
party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency")
if not gain_loss_account:
frappe.throw(_("Please set default Exchange Gain/Loss Account in Company {}").format(company))
gain_loss_account_currency = get_account_currency(gain_loss_account)
company_currency = frappe.get_cached_value("Company", company, "default_currency")
if gain_loss_account_currency != company_currency:
frappe.throw(_("Currency for {0} must be {1}").format(gain_loss_account, company_currency))
journal_account = frappe._dict(
{
"account": party_account,
"party_type": party_type,
"party": party,
"account_currency": party_account_currency,
"exchange_rate": 0,
"cost_center": erpnext.get_default_cost_center(company),
"reference_type": ref1_dt,
"reference_name": ref1_dn,
"reference_detail_no": ref1_detail_no,
dr_or_cr: abs(exc_gain_loss),
dr_or_cr + "_in_account_currency": 0,
}
)
journal_entry.append("accounts", journal_account)
journal_account = frappe._dict(
{
"account": gain_loss_account,
"account_currency": gain_loss_account_currency,
"exchange_rate": 1,
"cost_center": erpnext.get_default_cost_center(company),
"reference_type": ref2_dt,
"reference_name": ref2_dn,
"reference_detail_no": ref2_detail_no,
reverse_dr_or_cr + "_in_account_currency": 0,
reverse_dr_or_cr: abs(exc_gain_loss),
}
)
journal_entry.append("accounts", journal_account)
journal_entry.save()
journal_entry.submit()
return journal_entry.name

View File

@ -207,34 +207,39 @@ frappe.ui.form.on('Asset', {
}, },
render_depreciation_schedule_view: function(frm, depr_schedule) { render_depreciation_schedule_view: function(frm, depr_schedule) {
var wrapper = $(frm.fields_dict["depreciation_schedule_view"].wrapper).empty(); let wrapper = $(frm.fields_dict["depreciation_schedule_view"].wrapper).empty();
let table = $(`<table class="table table-bordered" style="margin-top:0px;"> let data = [];
<thead>
<tr>
<td align="center">${__("No.")}</td>
<td>${__("Schedule Date")}</td>
<td align="right">${__("Depreciation Amount")}</td>
<td align="right">${__("Accumulated Depreciation Amount")}</td>
<td>${__("Journal Entry")}</td>
</tr>
</thead>
<tbody></tbody>
</table>`);
depr_schedule.forEach((sch) => { depr_schedule.forEach((sch) => {
const row = $(`<tr> const row = [
<td align="center">${sch['idx']}</td> sch['idx'],
<td><b>${frappe.format(sch['schedule_date'], { fieldtype: 'Date' })}</b></td> frappe.format(sch['schedule_date'], { fieldtype: 'Date' }),
<td><b>${frappe.format(sch['depreciation_amount'], { fieldtype: 'Currency' })}</b></td> frappe.format(sch['depreciation_amount'], { fieldtype: 'Currency' }),
<td>${frappe.format(sch['accumulated_depreciation_amount'], { fieldtype: 'Currency' })}</td> frappe.format(sch['accumulated_depreciation_amount'], { fieldtype: 'Currency' }),
<td><a href="/app/journal-entry/${sch['journal_entry'] || ''}">${sch['journal_entry'] || ''}</a></td> sch['journal_entry'] || ''
</tr>`); ];
table.find("tbody").append(row); data.push(row);
}); });
wrapper.append(table); let datatable = new frappe.DataTable(wrapper.get(0), {
columns: [
{name: __("No."), editable: false, resizable: false, format: value => value, width: 60},
{name: __("Schedule Date"), editable: false, resizable: false, width: 270},
{name: __("Depreciation Amount"), editable: false, resizable: false, width: 164},
{name: __("Accumulated Depreciation Amount"), editable: false, resizable: false, width: 164},
{name: __("Journal Entry"), editable: false, resizable: false, format: value => `<a href="/app/journal-entry/${value}">${value}</a>`, width: 312}
],
data: data,
serialNoColumn: false,
checkboxColumn: true,
cellHeight: 35
});
datatable.style.setStyle(`.dt-scrollable`, {'font-size': '0.75rem', 'margin-bottom': '1rem'});
datatable.style.setStyle(`.dt-cell--col-1`, {'text-align': 'center'});
datatable.style.setStyle(`.dt-cell--col-2`, {'font-weight': 600});
datatable.style.setStyle(`.dt-cell--col-3`, {'font-weight': 600});
}, },
setup_chart_and_depr_schedule_view: async function(frm) { setup_chart_and_depr_schedule_view: async function(frm) {

View File

@ -43,6 +43,7 @@
"column_break_33", "column_break_33",
"opening_accumulated_depreciation", "opening_accumulated_depreciation",
"number_of_depreciations_booked", "number_of_depreciations_booked",
"is_fully_depreciated",
"section_break_36", "section_break_36",
"finance_books", "finance_books",
"section_break_33", "section_break_33",
@ -205,6 +206,7 @@
"fieldname": "disposal_date", "fieldname": "disposal_date",
"fieldtype": "Date", "fieldtype": "Date",
"label": "Disposal Date", "label": "Disposal Date",
"no_copy": 1,
"read_only": 1 "read_only": 1
}, },
{ {
@ -244,19 +246,17 @@
"label": "Is Existing Asset" "label": "Is Existing Asset"
}, },
{ {
"depends_on": "is_existing_asset", "depends_on": "eval:(doc.is_existing_asset)",
"fieldname": "opening_accumulated_depreciation", "fieldname": "opening_accumulated_depreciation",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Opening Accumulated Depreciation", "label": "Opening Accumulated Depreciation",
"no_copy": 1,
"options": "Company:company:default_currency" "options": "Company:company:default_currency"
}, },
{ {
"depends_on": "eval:(doc.is_existing_asset && doc.opening_accumulated_depreciation)", "depends_on": "eval:(doc.is_existing_asset)",
"fieldname": "number_of_depreciations_booked", "fieldname": "number_of_depreciations_booked",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Number of Depreciations Booked", "label": "Number of Depreciations Booked"
"no_copy": 1
}, },
{ {
"collapsible": 1, "collapsible": 1,
@ -500,6 +500,13 @@
"fieldtype": "HTML", "fieldtype": "HTML",
"hidden": 1, "hidden": 1,
"label": "Depreciation Schedule View" "label": "Depreciation Schedule View"
},
{
"default": "0",
"depends_on": "eval:(doc.is_existing_asset)",
"fieldname": "is_fully_depreciated",
"fieldtype": "Check",
"label": "Is Fully Depreciated"
} }
], ],
"idx": 72, "idx": 72,
@ -526,6 +533,11 @@
"link_doctype": "Asset Depreciation Schedule", "link_doctype": "Asset Depreciation Schedule",
"link_fieldname": "asset" "link_fieldname": "asset"
}, },
{
"group": "Activity",
"link_doctype": "Asset Activity",
"link_fieldname": "asset"
},
{ {
"group": "Journal Entry", "group": "Journal Entry",
"link_doctype": "Journal Entry", "link_doctype": "Journal Entry",
@ -533,7 +545,7 @@
"table_fieldname": "accounts" "table_fieldname": "accounts"
} }
], ],
"modified": "2023-07-26 13:33:36.821534", "modified": "2023-07-28 20:12:44.819616",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset", "name": "Asset",
@ -577,4 +589,4 @@
"states": [], "states": [],
"title_field": "asset_name", "title_field": "asset_name",
"track_changes": 1 "track_changes": 1
} }

View File

@ -25,6 +25,7 @@ from erpnext.assets.doctype.asset.depreciation import (
get_depreciation_accounts, get_depreciation_accounts,
get_disposal_account_and_cost_center, get_disposal_account_and_cost_center,
) )
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
cancel_asset_depr_schedules, cancel_asset_depr_schedules,
@ -59,7 +60,7 @@ class Asset(AccountsController):
self.make_asset_movement() self.make_asset_movement()
if not self.booked_fixed_asset and self.validate_make_gl_entry(): if not self.booked_fixed_asset and self.validate_make_gl_entry():
self.make_gl_entries() self.make_gl_entries()
if not self.split_from: if self.calculate_depreciation and not self.split_from:
asset_depr_schedules_names = make_draft_asset_depr_schedules_if_not_present(self) asset_depr_schedules_names = make_draft_asset_depr_schedules_if_not_present(self)
convert_draft_asset_depr_schedules_into_active(self) convert_draft_asset_depr_schedules_into_active(self)
if asset_depr_schedules_names: if asset_depr_schedules_names:
@ -71,6 +72,7 @@ class Asset(AccountsController):
"Asset Depreciation Schedules created:<br>{0}<br><br>Please check, edit if needed, and submit the Asset." "Asset Depreciation Schedules created:<br>{0}<br><br>Please check, edit if needed, and submit the Asset."
).format(asset_depr_schedules_links) ).format(asset_depr_schedules_links)
) )
add_asset_activity(self.name, _("Asset submitted"))
def on_cancel(self): def on_cancel(self):
self.validate_cancellation() self.validate_cancellation()
@ -81,9 +83,10 @@ class Asset(AccountsController):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry") self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
make_reverse_gl_entries(voucher_type="Asset", voucher_no=self.name) make_reverse_gl_entries(voucher_type="Asset", voucher_no=self.name)
self.db_set("booked_fixed_asset", 0) self.db_set("booked_fixed_asset", 0)
add_asset_activity(self.name, _("Asset cancelled"))
def after_insert(self): def after_insert(self):
if not self.split_from: if self.calculate_depreciation and not self.split_from:
asset_depr_schedules_names = make_draft_asset_depr_schedules(self) asset_depr_schedules_names = make_draft_asset_depr_schedules(self)
asset_depr_schedules_links = get_comma_separated_links( asset_depr_schedules_links = get_comma_separated_links(
asset_depr_schedules_names, "Asset Depreciation Schedule" asset_depr_schedules_names, "Asset Depreciation Schedule"
@ -93,6 +96,16 @@ class Asset(AccountsController):
"Asset Depreciation Schedules created:<br>{0}<br><br>Please check, edit if needed, and submit the Asset." "Asset Depreciation Schedules created:<br>{0}<br><br>Please check, edit if needed, and submit the Asset."
).format(asset_depr_schedules_links) ).format(asset_depr_schedules_links)
) )
if not frappe.db.exists(
{
"doctype": "Asset Activity",
"asset": self.name,
}
):
add_asset_activity(self.name, _("Asset created"))
def after_delete(self):
add_asset_activity(self.name, _("Asset deleted"))
def validate_asset_and_reference(self): def validate_asset_and_reference(self):
if self.purchase_invoice or self.purchase_receipt: if self.purchase_invoice or self.purchase_receipt:
@ -135,17 +148,33 @@ class Asset(AccountsController):
frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code)) frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code))
def validate_cost_center(self): def validate_cost_center(self):
if not self.cost_center: if self.cost_center:
return cost_center_company, cost_center_is_group = frappe.db.get_value(
"Cost Center", self.cost_center, ["company", "is_group"]
cost_center_company = frappe.db.get_value("Cost Center", self.cost_center, "company")
if cost_center_company != self.company:
frappe.throw(
_("Selected Cost Center {} doesn't belongs to {}").format(
frappe.bold(self.cost_center), frappe.bold(self.company)
),
title=_("Invalid Cost Center"),
) )
if cost_center_company != self.company:
frappe.throw(
_("Cost Center {} doesn't belong to Company {}").format(
frappe.bold(self.cost_center), frappe.bold(self.company)
),
title=_("Invalid Cost Center"),
)
if cost_center_is_group:
frappe.throw(
_(
"Cost Center {} is a group cost center and group cost centers cannot be used in transactions"
).format(frappe.bold(self.cost_center)),
title=_("Invalid Cost Center"),
)
else:
if not frappe.get_cached_value("Company", self.company, "depreciation_cost_center"):
frappe.throw(
_(
"Please set a Cost Center for the Asset or set an Asset Depreciation Cost Center for the Company {}"
).format(frappe.bold(self.company)),
title=_("Missing Cost Center"),
)
def validate_in_use_date(self): def validate_in_use_date(self):
if not self.available_for_use_date: if not self.available_for_use_date:
@ -194,8 +223,11 @@ class Asset(AccountsController):
if not self.calculate_depreciation: if not self.calculate_depreciation:
return return
elif not self.finance_books: else:
frappe.throw(_("Enter depreciation details")) if not self.finance_books:
frappe.throw(_("Enter depreciation details"))
if self.is_fully_depreciated:
frappe.throw(_("Depreciation cannot be calculated for fully depreciated assets"))
if self.is_existing_asset: if self.is_existing_asset:
return return
@ -276,7 +308,7 @@ class Asset(AccountsController):
depreciable_amount = flt(self.gross_purchase_amount) - flt(row.expected_value_after_useful_life) depreciable_amount = flt(self.gross_purchase_amount) - flt(row.expected_value_after_useful_life)
if flt(self.opening_accumulated_depreciation) > depreciable_amount: if flt(self.opening_accumulated_depreciation) > depreciable_amount:
frappe.throw( frappe.throw(
_("Opening Accumulated Depreciation must be less than equal to {0}").format( _("Opening Accumulated Depreciation must be less than or equal to {0}").format(
depreciable_amount depreciable_amount
) )
) )
@ -412,7 +444,9 @@ class Asset(AccountsController):
expected_value_after_useful_life = self.finance_books[idx].expected_value_after_useful_life expected_value_after_useful_life = self.finance_books[idx].expected_value_after_useful_life
value_after_depreciation = self.finance_books[idx].value_after_depreciation value_after_depreciation = self.finance_books[idx].value_after_depreciation
if flt(value_after_depreciation) <= expected_value_after_useful_life: if (
flt(value_after_depreciation) <= expected_value_after_useful_life or self.is_fully_depreciated
):
status = "Fully Depreciated" status = "Fully Depreciated"
elif flt(value_after_depreciation) < flt(self.gross_purchase_amount): elif flt(value_after_depreciation) < flt(self.gross_purchase_amount):
status = "Partially Depreciated" status = "Partially Depreciated"
@ -444,7 +478,9 @@ class Asset(AccountsController):
@frappe.whitelist() @frappe.whitelist()
def get_manual_depreciation_entries(self): def get_manual_depreciation_entries(self):
(_, _, depreciation_expense_account) = get_depreciation_accounts(self) (_, _, depreciation_expense_account) = get_depreciation_accounts(
self.asset_category, self.company
)
gle = frappe.qb.DocType("GL Entry") gle = frappe.qb.DocType("GL Entry")
@ -787,10 +823,10 @@ def get_asset_account(account_name, asset=None, asset_category=None, company=Non
def make_journal_entry(asset_name): def make_journal_entry(asset_name):
asset = frappe.get_doc("Asset", asset_name) asset = frappe.get_doc("Asset", asset_name)
( (
fixed_asset_account, _,
accumulated_depreciation_account, accumulated_depreciation_account,
depreciation_expense_account, depreciation_expense_account,
) = get_depreciation_accounts(asset) ) = get_depreciation_accounts(asset.asset_category, asset.company)
depreciation_cost_center, depreciation_series = frappe.get_cached_value( depreciation_cost_center, depreciation_series = frappe.get_cached_value(
"Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"]
@ -898,6 +934,13 @@ def update_existing_asset(asset, remaining_qty, new_asset_name):
}, },
) )
add_asset_activity(
asset.name,
_("Asset updated after being split into Asset {0}").format(
get_link_to_form("Asset", new_asset_name)
),
)
for row in asset.get("finance_books"): for row in asset.get("finance_books"):
value_after_depreciation = flt( value_after_depreciation = flt(
(row.value_after_depreciation * remaining_qty) / asset.asset_quantity (row.value_after_depreciation * remaining_qty) / asset.asset_quantity
@ -965,6 +1008,15 @@ def create_new_asset_after_split(asset, split_qty):
(row.expected_value_after_useful_life * split_qty) / asset.asset_quantity (row.expected_value_after_useful_life * split_qty) / asset.asset_quantity
) )
new_asset.insert()
add_asset_activity(
new_asset.name,
_("Asset created after being split from Asset {0}").format(
get_link_to_form("Asset", asset.name)
),
)
new_asset.submit() new_asset.submit()
new_asset.set_status() new_asset.set_status()

View File

@ -4,6 +4,8 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.query_builder import Order
from frappe.query_builder.functions import Max, Min
from frappe.utils import ( from frappe.utils import (
add_months, add_months,
cint, cint,
@ -21,6 +23,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_checks_for_pl_and_bs_accounts, get_checks_for_pl_and_bs_accounts,
) )
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
get_asset_depr_schedule_doc, get_asset_depr_schedule_doc,
get_asset_depr_schedule_name, get_asset_depr_schedule_name,
@ -42,11 +45,48 @@ def post_depreciation_entries(date=None):
failed_asset_names = [] failed_asset_names = []
error_log_names = [] error_log_names = []
for asset_name in get_depreciable_assets(date): depreciable_asset_depr_schedules_data = get_depreciable_asset_depr_schedules_data(date)
asset_doc = frappe.get_doc("Asset", asset_name)
credit_and_debit_accounts_for_asset_category_and_company = {}
depreciation_cost_center_and_depreciation_series_for_company = (
get_depreciation_cost_center_and_depreciation_series_for_company()
)
accounting_dimensions = get_checks_for_pl_and_bs_accounts()
for asset_depr_schedule_data in depreciable_asset_depr_schedules_data:
(
asset_depr_schedule_name,
asset_name,
asset_category,
asset_company,
sch_start_idx,
sch_end_idx,
) = asset_depr_schedule_data
if (
asset_category,
asset_company,
) not in credit_and_debit_accounts_for_asset_category_and_company:
credit_and_debit_accounts_for_asset_category_and_company.update(
{
(asset_category, asset_company): get_credit_and_debit_accounts_for_asset_category_and_company(
asset_category, asset_company
),
}
)
try: try:
make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date) make_depreciation_entry(
asset_depr_schedule_name,
date,
sch_start_idx,
sch_end_idx,
credit_and_debit_accounts_for_asset_category_and_company[(asset_category, asset_company)],
depreciation_cost_center_and_depreciation_series_for_company[asset_company],
accounting_dimensions,
)
frappe.db.commit() frappe.db.commit()
except Exception as e: except Exception as e:
frappe.db.rollback() frappe.db.rollback()
@ -61,18 +101,36 @@ def post_depreciation_entries(date=None):
frappe.db.commit() frappe.db.commit()
def get_depreciable_assets(date): def get_depreciable_asset_depr_schedules_data(date):
return frappe.db.sql_list( a = frappe.qb.DocType("Asset")
"""select distinct a.name ads = frappe.qb.DocType("Asset Depreciation Schedule")
from tabAsset a, `tabAsset Depreciation Schedule` ads, `tabDepreciation Schedule` ds ds = frappe.qb.DocType("Depreciation Schedule")
where a.name = ads.asset and ads.name = ds.parent and a.docstatus=1 and ads.docstatus=1
and a.status in ('Submitted', 'Partially Depreciated') res = (
and a.calculate_depreciation = 1 frappe.qb.from_(ads)
and ds.schedule_date<=%s .join(a)
and ifnull(ds.journal_entry, '')=''""", .on(ads.asset == a.name)
date, .join(ds)
.on(ads.name == ds.parent)
.select(ads.name, a.name, a.asset_category, a.company, Min(ds.idx) - 1, Max(ds.idx))
.where(a.calculate_depreciation == 1)
.where(a.docstatus == 1)
.where(ads.docstatus == 1)
.where(a.status.isin(["Submitted", "Partially Depreciated"]))
.where(ds.journal_entry.isnull())
.where(ds.schedule_date <= date)
.groupby(ads.name)
.orderby(a.creation, order=Order.desc)
) )
acc_frozen_upto = get_acc_frozen_upto()
if acc_frozen_upto:
res = res.where(ds.schedule_date > acc_frozen_upto)
res = res.run()
return res
def make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date=None): def make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date=None):
for row in asset_doc.get("finance_books"): for row in asset_doc.get("finance_books"):
@ -82,8 +140,60 @@ def make_depreciation_entry_for_all_asset_depr_schedules(asset_doc, date=None):
make_depreciation_entry(asset_depr_schedule_name, date) make_depreciation_entry(asset_depr_schedule_name, date)
def get_acc_frozen_upto():
acc_frozen_upto = frappe.db.get_single_value("Accounts Settings", "acc_frozen_upto")
if not acc_frozen_upto:
return
frozen_accounts_modifier = frappe.db.get_single_value(
"Accounts Settings", "frozen_accounts_modifier"
)
if frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator":
return getdate(acc_frozen_upto)
return
def get_credit_and_debit_accounts_for_asset_category_and_company(asset_category, company):
(
_,
accumulated_depreciation_account,
depreciation_expense_account,
) = get_depreciation_accounts(asset_category, company)
credit_account, debit_account = get_credit_and_debit_accounts(
accumulated_depreciation_account, depreciation_expense_account
)
return (credit_account, debit_account)
def get_depreciation_cost_center_and_depreciation_series_for_company():
company_names = frappe.db.get_all("Company", pluck="name")
res = {}
for company_name in company_names:
depreciation_cost_center, depreciation_series = frappe.get_cached_value(
"Company", company_name, ["depreciation_cost_center", "series_for_depreciation_entry"]
)
res.update({company_name: (depreciation_cost_center, depreciation_series)})
return res
@frappe.whitelist() @frappe.whitelist()
def make_depreciation_entry(asset_depr_schedule_name, date=None): def make_depreciation_entry(
asset_depr_schedule_name,
date=None,
sch_start_idx=None,
sch_end_idx=None,
credit_and_debit_accounts=None,
depreciation_cost_center_and_depreciation_series=None,
accounting_dimensions=None,
):
frappe.has_permission("Journal Entry", throw=True) frappe.has_permission("Journal Entry", throw=True)
if not date: if not date:
@ -91,100 +201,144 @@ def make_depreciation_entry(asset_depr_schedule_name, date=None):
asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name) asset_depr_schedule_doc = frappe.get_doc("Asset Depreciation Schedule", asset_depr_schedule_name)
asset_name = asset_depr_schedule_doc.asset asset = frappe.get_doc("Asset", asset_depr_schedule_doc.asset)
asset = frappe.get_doc("Asset", asset_name) if credit_and_debit_accounts:
( credit_account, debit_account = credit_and_debit_accounts
fixed_asset_account, else:
accumulated_depreciation_account, credit_account, debit_account = get_credit_and_debit_accounts_for_asset_category_and_company(
depreciation_expense_account, asset.asset_category, asset.company
) = get_depreciation_accounts(asset) )
depreciation_cost_center, depreciation_series = frappe.get_cached_value( if depreciation_cost_center_and_depreciation_series:
"Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] depreciation_cost_center, depreciation_series = depreciation_cost_center_and_depreciation_series
) else:
depreciation_cost_center, depreciation_series = frappe.get_cached_value(
"Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"]
)
depreciation_cost_center = asset.cost_center or depreciation_cost_center depreciation_cost_center = asset.cost_center or depreciation_cost_center
accounting_dimensions = get_checks_for_pl_and_bs_accounts() if not accounting_dimensions:
accounting_dimensions = get_checks_for_pl_and_bs_accounts()
for d in asset_depr_schedule_doc.get("depreciation_schedule"): depreciation_posting_error = None
if not d.journal_entry and getdate(d.schedule_date) <= getdate(date):
je = frappe.new_doc("Journal Entry")
je.voucher_type = "Depreciation Entry"
je.naming_series = depreciation_series
je.posting_date = d.schedule_date
je.company = asset.company
je.finance_book = asset_depr_schedule_doc.finance_book
je.remark = "Depreciation Entry against {0} worth {1}".format(asset_name, d.depreciation_amount)
credit_account, debit_account = get_credit_and_debit_accounts( for d in asset_depr_schedule_doc.get("depreciation_schedule")[
accumulated_depreciation_account, depreciation_expense_account sch_start_idx or 0 : sch_end_idx or len(asset_depr_schedule_doc.get("depreciation_schedule"))
]:
try:
_make_journal_entry_for_depreciation(
asset_depr_schedule_doc,
asset,
date,
d,
sch_start_idx,
sch_end_idx,
depreciation_cost_center,
depreciation_series,
credit_account,
debit_account,
accounting_dimensions,
) )
frappe.db.commit()
credit_entry = { except Exception as e:
"account": credit_account, frappe.db.rollback()
"credit_in_account_currency": d.depreciation_amount, depreciation_posting_error = e
"reference_type": "Asset",
"reference_name": asset.name,
"cost_center": depreciation_cost_center,
}
debit_entry = {
"account": debit_account,
"debit_in_account_currency": d.depreciation_amount,
"reference_type": "Asset",
"reference_name": asset.name,
"cost_center": depreciation_cost_center,
}
for dimension in accounting_dimensions:
if asset.get(dimension["fieldname"]) or dimension.get("mandatory_for_bs"):
credit_entry.update(
{
dimension["fieldname"]: asset.get(dimension["fieldname"])
or dimension.get("default_dimension")
}
)
if asset.get(dimension["fieldname"]) or dimension.get("mandatory_for_pl"):
debit_entry.update(
{
dimension["fieldname"]: asset.get(dimension["fieldname"])
or dimension.get("default_dimension")
}
)
je.append("accounts", credit_entry)
je.append("accounts", debit_entry)
je.flags.ignore_permissions = True
je.flags.planned_depr_entry = True
je.save()
d.db_set("journal_entry", je.name)
if not je.meta.get_workflow():
je.submit()
idx = cint(asset_depr_schedule_doc.finance_book_id)
row = asset.get("finance_books")[idx - 1]
row.value_after_depreciation -= d.depreciation_amount
row.db_update()
asset.db_set("depr_entry_posting_status", "Successful")
asset.set_status() asset.set_status()
return asset_depr_schedule_doc if not depreciation_posting_error:
asset.db_set("depr_entry_posting_status", "Successful")
return asset_depr_schedule_doc
raise depreciation_posting_error
def get_depreciation_accounts(asset): def _make_journal_entry_for_depreciation(
asset_depr_schedule_doc,
asset,
date,
depr_schedule,
sch_start_idx,
sch_end_idx,
depreciation_cost_center,
depreciation_series,
credit_account,
debit_account,
accounting_dimensions,
):
if not (sch_start_idx and sch_end_idx) and not (
not depr_schedule.journal_entry and getdate(depr_schedule.schedule_date) <= getdate(date)
):
return
je = frappe.new_doc("Journal Entry")
je.voucher_type = "Depreciation Entry"
je.naming_series = depreciation_series
je.posting_date = depr_schedule.schedule_date
je.company = asset.company
je.finance_book = asset_depr_schedule_doc.finance_book
je.remark = "Depreciation Entry against {0} worth {1}".format(
asset.name, depr_schedule.depreciation_amount
)
credit_entry = {
"account": credit_account,
"credit_in_account_currency": depr_schedule.depreciation_amount,
"reference_type": "Asset",
"reference_name": asset.name,
"cost_center": depreciation_cost_center,
}
debit_entry = {
"account": debit_account,
"debit_in_account_currency": depr_schedule.depreciation_amount,
"reference_type": "Asset",
"reference_name": asset.name,
"cost_center": depreciation_cost_center,
}
for dimension in accounting_dimensions:
if asset.get(dimension["fieldname"]) or dimension.get("mandatory_for_bs"):
credit_entry.update(
{
dimension["fieldname"]: asset.get(dimension["fieldname"])
or dimension.get("default_dimension")
}
)
if asset.get(dimension["fieldname"]) or dimension.get("mandatory_for_pl"):
debit_entry.update(
{
dimension["fieldname"]: asset.get(dimension["fieldname"])
or dimension.get("default_dimension")
}
)
je.append("accounts", credit_entry)
je.append("accounts", debit_entry)
je.flags.ignore_permissions = True
je.flags.planned_depr_entry = True
je.save()
depr_schedule.db_set("journal_entry", je.name)
if not je.meta.get_workflow():
je.submit()
idx = cint(asset_depr_schedule_doc.finance_book_id)
row = asset.get("finance_books")[idx - 1]
row.value_after_depreciation -= depr_schedule.depreciation_amount
row.db_update()
def get_depreciation_accounts(asset_category, company):
fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None fixed_asset_account = accumulated_depreciation_account = depreciation_expense_account = None
accounts = frappe.db.get_value( accounts = frappe.db.get_value(
"Asset Category Account", "Asset Category Account",
filters={"parent": asset.asset_category, "company_name": asset.company}, filters={"parent": asset_category, "company_name": company},
fieldname=[ fieldname=[
"fixed_asset_account", "fixed_asset_account",
"accumulated_depreciation_account", "accumulated_depreciation_account",
@ -200,7 +354,7 @@ def get_depreciation_accounts(asset):
if not accumulated_depreciation_account or not depreciation_expense_account: if not accumulated_depreciation_account or not depreciation_expense_account:
accounts = frappe.get_cached_value( accounts = frappe.get_cached_value(
"Company", asset.company, ["accumulated_depreciation_account", "depreciation_expense_account"] "Company", company, ["accumulated_depreciation_account", "depreciation_expense_account"]
) )
if not accumulated_depreciation_account: if not accumulated_depreciation_account:
@ -215,7 +369,7 @@ def get_depreciation_accounts(asset):
): ):
frappe.throw( frappe.throw(
_("Please set Depreciation related Accounts in Asset Category {0} or Company {1}").format( _("Please set Depreciation related Accounts in Asset Category {0} or Company {1}").format(
asset.asset_category, asset.company asset_category, company
) )
) )
@ -325,6 +479,8 @@ def scrap_asset(asset_name):
frappe.db.set_value("Asset", asset_name, "journal_entry_for_scrap", je.name) frappe.db.set_value("Asset", asset_name, "journal_entry_for_scrap", je.name)
asset.set_status("Scrapped") asset.set_status("Scrapped")
add_asset_activity(asset_name, _("Asset scrapped"))
frappe.msgprint(_("Asset scrapped via Journal Entry {0}").format(je.name)) frappe.msgprint(_("Asset scrapped via Journal Entry {0}").format(je.name))
@ -349,6 +505,8 @@ def restore_asset(asset_name):
asset.set_status() asset.set_status()
add_asset_activity(asset_name, _("Asset restored"))
def depreciate_asset(asset_doc, date, notes): def depreciate_asset(asset_doc, date, notes):
asset_doc.flags.ignore_validate_update_after_submit = True asset_doc.flags.ignore_validate_update_after_submit = True
@ -398,6 +556,15 @@ def reverse_depreciation_entry_made_after_disposal(asset, date):
reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry) reverse_journal_entry = make_reverse_journal_entry(schedule.journal_entry)
reverse_journal_entry.posting_date = nowdate() reverse_journal_entry.posting_date = nowdate()
for account in reverse_journal_entry.accounts:
account.update(
{
"reference_type": "Asset",
"reference_name": asset.name,
}
)
frappe.flags.is_reverse_depr_entry = True frappe.flags.is_reverse_depr_entry = True
reverse_journal_entry.submit() reverse_journal_entry.submit()
@ -551,8 +718,8 @@ def get_gl_entries_on_asset_disposal(
def get_asset_details(asset, finance_book=None): def get_asset_details(asset, finance_book=None):
fixed_asset_account, accumulated_depr_account, depr_expense_account = get_depreciation_accounts( fixed_asset_account, accumulated_depr_account, _ = get_depreciation_accounts(
asset asset.asset_category, asset.company
) )
disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company) disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(asset.company)
depreciation_cost_center = asset.cost_center or depreciation_cost_center depreciation_cost_center = asset.cost_center or depreciation_cost_center

View File

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

View File

@ -0,0 +1,109 @@
{
"actions": [],
"creation": "2023-07-28 12:41:13.232505",
"default_view": "List",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"asset",
"column_break_vkdy",
"date",
"column_break_kkxv",
"user",
"section_break_romx",
"subject"
],
"fields": [
{
"fieldname": "asset",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Asset",
"options": "Asset",
"print_width": "165",
"read_only": 1,
"reqd": 1,
"width": "165"
},
{
"fieldname": "column_break_vkdy",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_romx",
"fieldtype": "Section Break"
},
{
"fieldname": "subject",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Subject",
"print_width": "518",
"read_only": 1,
"reqd": 1,
"width": "518"
},
{
"default": "now",
"fieldname": "date",
"fieldtype": "Datetime",
"in_list_view": 1,
"label": "Date",
"print_width": "158",
"read_only": 1,
"reqd": 1,
"width": "158"
},
{
"fieldname": "user",
"fieldtype": "Link",
"in_list_view": 1,
"label": "User",
"options": "User",
"print_width": "150",
"read_only": 1,
"reqd": 1,
"width": "150"
},
{
"fieldname": "column_break_kkxv",
"fieldtype": "Column Break"
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-08-01 11:09:52.584482",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Activity",
"owner": "Administrator",
"permissions": [
{
"email": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1
},
{
"email": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1
},
{
"email": 1,
"read": 1,
"report": 1,
"role": "Quality Manager",
"share": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,20 @@
# 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 AssetActivity(Document):
pass
def add_asset_activity(asset, subject):
frappe.get_doc(
{
"doctype": "Asset Activity",
"asset": asset,
"subject": subject,
"user": frappe.session.user,
}
).insert(ignore_permissions=True, ignore_links=True)

View File

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

View File

@ -18,6 +18,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.assets.doctype.asset_category.asset_category import get_asset_category_account from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
from erpnext.controllers.stock_controller import StockController from erpnext.controllers.stock_controller import StockController
from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.brand.brand import get_brand_defaults
@ -329,7 +330,7 @@ class AssetCapitalization(StockController):
gl_entries = self.get_gl_entries() gl_entries = self.get_gl_entries()
if gl_entries: if gl_entries:
make_gl_entries(gl_entries, from_repost=from_repost) make_gl_entries(gl_entries, merge_entries=False, from_repost=from_repost)
elif self.docstatus == 2: elif self.docstatus == 2:
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
@ -359,9 +360,6 @@ class AssetCapitalization(StockController):
gl_entries, target_account, target_against, precision gl_entries, target_account, target_against, precision
) )
if not self.stock_items and not self.service_items and self.are_all_asset_items_non_depreciable:
return []
self.get_gl_entries_for_target_item(gl_entries, target_against, precision) self.get_gl_entries_for_target_item(gl_entries, target_against, precision)
return gl_entries return gl_entries
@ -519,6 +517,13 @@ class AssetCapitalization(StockController):
"fixed_asset_account", item=self.target_item_code, company=asset_doc.company "fixed_asset_account", item=self.target_item_code, company=asset_doc.company
) )
add_asset_activity(
asset_doc.name,
_("Asset created after Asset Capitalization {0} was submitted").format(
get_link_to_form("Asset Capitalization", self.name)
),
)
frappe.msgprint( frappe.msgprint(
_( _(
"Asset {0} has been created. Please set the depreciation details if any and submit it." "Asset {0} has been created. Please set the depreciation details if any and submit it."
@ -542,9 +547,30 @@ class AssetCapitalization(StockController):
def set_consumed_asset_status(self, asset): def set_consumed_asset_status(self, asset):
if self.docstatus == 1: if self.docstatus == 1:
asset.set_status("Capitalized" if self.target_is_fixed_asset else "Decapitalized") if self.target_is_fixed_asset:
asset.set_status("Capitalized")
add_asset_activity(
asset.name,
_("Asset capitalized after Asset Capitalization {0} was submitted").format(
get_link_to_form("Asset Capitalization", self.name)
),
)
else:
asset.set_status("Decapitalized")
add_asset_activity(
asset.name,
_("Asset decapitalized after Asset Capitalization {0} was submitted").format(
get_link_to_form("Asset Capitalization", self.name)
),
)
else: else:
asset.set_status() asset.set_status()
add_asset_activity(
asset.name,
_("Asset restored after Asset Capitalization {0} was cancelled").format(
get_link_to_form("Asset Capitalization", self.name)
),
)
@frappe.whitelist() @frappe.whitelist()

View File

@ -5,6 +5,9 @@
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 import get_link_to_form
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
class AssetMovement(Document): class AssetMovement(Document):
@ -128,5 +131,24 @@ class AssetMovement(Document):
current_location = latest_movement_entry[0][0] current_location = latest_movement_entry[0][0]
current_employee = latest_movement_entry[0][1] current_employee = latest_movement_entry[0][1]
frappe.db.set_value("Asset", d.asset, "location", current_location) frappe.db.set_value("Asset", d.asset, "location", current_location, update_modified=False)
frappe.db.set_value("Asset", d.asset, "custodian", current_employee) frappe.db.set_value("Asset", d.asset, "custodian", current_employee, update_modified=False)
if current_location and current_employee:
add_asset_activity(
d.asset,
_("Asset received at Location {0} and issued to Employee {1}").format(
get_link_to_form("Location", current_location),
get_link_to_form("Employee", current_employee),
),
)
elif current_location:
add_asset_activity(
d.asset,
_("Asset transferred to Location {0}").format(get_link_to_form("Location", current_location)),
)
elif current_employee:
add_asset_activity(
d.asset,
_("Asset issued to Employee {0}").format(get_link_to_form("Employee", current_employee)),
)

View File

@ -8,6 +8,7 @@ from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, time_
import erpnext import erpnext
from erpnext.accounts.general_ledger import make_gl_entries from erpnext.accounts.general_ledger import make_gl_entries
from erpnext.assets.doctype.asset.asset import get_asset_account from erpnext.assets.doctype.asset.asset import get_asset_account
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
get_depr_schedule, get_depr_schedule,
make_new_active_asset_depr_schedules_and_cancel_current_ones, make_new_active_asset_depr_schedules_and_cancel_current_ones,
@ -25,8 +26,14 @@ class AssetRepair(AccountsController):
self.calculate_total_repair_cost() self.calculate_total_repair_cost()
def update_status(self): def update_status(self):
if self.repair_status == "Pending": if self.repair_status == "Pending" and self.asset_doc.status != "Out of Order":
frappe.db.set_value("Asset", self.asset, "status", "Out of Order") frappe.db.set_value("Asset", self.asset, "status", "Out of Order")
add_asset_activity(
self.asset,
_("Asset out of order due to Asset Repair {0}").format(
get_link_to_form("Asset Repair", self.name)
),
)
else: else:
self.asset_doc.set_status() self.asset_doc.set_status()
@ -68,6 +75,13 @@ class AssetRepair(AccountsController):
make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes) make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes)
self.asset_doc.save() self.asset_doc.save()
add_asset_activity(
self.asset,
_("Asset updated after completion of Asset Repair {0}").format(
get_link_to_form("Asset Repair", self.name)
),
)
def before_cancel(self): def before_cancel(self):
self.asset_doc = frappe.get_doc("Asset", self.asset) self.asset_doc = frappe.get_doc("Asset", self.asset)
@ -95,6 +109,13 @@ class AssetRepair(AccountsController):
make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes) make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes)
self.asset_doc.save() self.asset_doc.save()
add_asset_activity(
self.asset,
_("Asset updated after cancellation of Asset Repair {0}").format(
get_link_to_form("Asset Repair", self.name)
),
)
def after_delete(self): def after_delete(self):
frappe.get_doc("Asset", self.asset).set_status() frappe.get_doc("Asset", self.asset).set_status()

View File

@ -12,6 +12,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
) )
from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts from erpnext.assets.doctype.asset.depreciation import get_depreciation_accounts
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
get_asset_depr_schedule_doc, get_asset_depr_schedule_doc,
get_depreciation_amount, get_depreciation_amount,
@ -27,9 +28,21 @@ class AssetValueAdjustment(Document):
def on_submit(self): def on_submit(self):
self.make_depreciation_entry() self.make_depreciation_entry()
self.reschedule_depreciations(self.new_asset_value) self.reschedule_depreciations(self.new_asset_value)
add_asset_activity(
self.asset,
_("Asset's value adjusted after submission of Asset Value Adjustment {0}").format(
get_link_to_form("Asset Value Adjustment", self.name)
),
)
def on_cancel(self): def on_cancel(self):
self.reschedule_depreciations(self.current_asset_value) self.reschedule_depreciations(self.current_asset_value)
add_asset_activity(
self.asset,
_("Asset's value adjusted after cancellation of Asset Value Adjustment {0}").format(
get_link_to_form("Asset Value Adjustment", self.name)
),
)
def validate_date(self): def validate_date(self):
asset_purchase_date = frappe.db.get_value("Asset", self.asset, "purchase_date") asset_purchase_date = frappe.db.get_value("Asset", self.asset, "purchase_date")
@ -51,10 +64,10 @@ class AssetValueAdjustment(Document):
def make_depreciation_entry(self): def make_depreciation_entry(self):
asset = frappe.get_doc("Asset", self.asset) asset = frappe.get_doc("Asset", self.asset)
( (
fixed_asset_account, _,
accumulated_depreciation_account, accumulated_depreciation_account,
depreciation_expense_account, depreciation_expense_account,
) = get_depreciation_accounts(asset) ) = get_depreciation_accounts(asset.asset_category, asset.company)
depreciation_cost_center, depreciation_series = frappe.get_cached_value( depreciation_cost_center, depreciation_series = frappe.get_cached_value(
"Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"] "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"]
@ -65,21 +78,23 @@ class AssetValueAdjustment(Document):
je.naming_series = depreciation_series je.naming_series = depreciation_series
je.posting_date = self.date je.posting_date = self.date
je.company = self.company je.company = self.company
je.remark = _("Depreciation Entry against {0} worth {1}").format( je.remark = "Depreciation Entry against {0} worth {1}".format(self.asset, self.difference_amount)
self.asset, self.difference_amount
)
je.finance_book = self.finance_book je.finance_book = self.finance_book
credit_entry = { credit_entry = {
"account": accumulated_depreciation_account, "account": accumulated_depreciation_account,
"credit_in_account_currency": self.difference_amount, "credit_in_account_currency": self.difference_amount,
"cost_center": depreciation_cost_center or self.cost_center, "cost_center": depreciation_cost_center or self.cost_center,
"reference_type": "Asset",
"reference_name": self.asset,
} }
debit_entry = { debit_entry = {
"account": depreciation_expense_account, "account": depreciation_expense_account,
"debit_in_account_currency": self.difference_amount, "debit_in_account_currency": self.difference_amount,
"cost_center": depreciation_cost_center or self.cost_center, "cost_center": depreciation_cost_center or self.cost_center,
"reference_type": "Asset",
"reference_name": self.asset,
} }
accounting_dimensions = get_checks_for_pl_and_bs_accounts() accounting_dimensions = get_checks_for_pl_and_bs_accounts()

View File

@ -0,0 +1,33 @@
{
"add_total_row": 0,
"columns": [],
"creation": "2023-08-01 11:14:46.581234",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"json": "{}",
"letterhead": null,
"modified": "2023-08-01 11:14:46.581234",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset Activity",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "Asset Activity",
"report_name": "Asset Activity",
"report_type": "Report Builder",
"roles": [
{
"role": "System Manager"
},
{
"role": "Accounts User"
},
{
"role": "Quality Manager"
}
]
}

View File

@ -183,6 +183,17 @@
"link_type": "Report", "link_type": "Report",
"onboard": 0, "onboard": 0,
"type": "Link" "type": "Link"
},
{
"dependencies": "Asset Activity",
"hidden": 0,
"is_query_report": 0,
"label": "Asset Activity",
"link_count": 0,
"link_to": "Asset Activity",
"link_type": "Report",
"onboard": 0,
"type": "Link"
} }
], ],
"modified": "2023-05-24 14:47:20.243146", "modified": "2023-05-24 14:47:20.243146",

View File

@ -366,7 +366,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends e
}, },
allow_child_item_selection: true, allow_child_item_selection: true,
child_fieldname: "items", child_fieldname: "items",
child_columns: ["item_code", "qty"] child_columns: ["item_code", "qty", "ordered_qty"]
}) })
}, __("Get Items From")); }, __("Get Items From"));

View File

@ -244,19 +244,21 @@ frappe.ui.form.on("Request for Quotation",{
] ]
}); });
dialog.fields_dict['supplier'].df.onchange = () => { dialog.fields_dict["supplier"].df.onchange = () => {
var supplier = dialog.get_value('supplier'); frm.call("get_supplier_email_preview", {
frm.call('get_supplier_email_preview', {supplier: supplier}).then(result => { supplier: dialog.get_value("supplier"),
}).then(({ message }) => {
dialog.fields_dict.email_preview.$wrapper.empty(); dialog.fields_dict.email_preview.$wrapper.empty();
dialog.fields_dict.email_preview.$wrapper.append(result.message); dialog.fields_dict.email_preview.$wrapper.append(
message.message
);
dialog.set_value("subject", message.subject);
}); });
};
}
dialog.fields_dict.note.$wrapper.append(`<p class="small text-muted">This is a preview of the email to be sent. A PDF of the document will dialog.fields_dict.note.$wrapper.append(`<p class="small text-muted">This is a preview of the email to be sent. A PDF of the document will
automatically be attached with the email.</p>`); automatically be attached with the email.</p>`);
dialog.set_value("subject", frm.doc.subject);
dialog.show(); dialog.show();
} }
}) })

View File

@ -20,11 +20,11 @@
"items_section", "items_section",
"items", "items",
"supplier_response_section", "supplier_response_section",
"salutation",
"subject",
"col_break_email_1",
"email_template", "email_template",
"preview", "preview",
"col_break_email_1",
"html_llwp",
"send_attached_files",
"sec_break_email_2", "sec_break_email_2",
"message_for_supplier", "message_for_supplier",
"terms_section_break", "terms_section_break",
@ -236,23 +236,6 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{
"fetch_from": "email_template.subject",
"fetch_if_empty": 1,
"fieldname": "subject",
"fieldtype": "Data",
"label": "Subject",
"print_hide": 1
},
{
"description": "Select a greeting for the receiver. E.g. Mr., Ms., etc.",
"fieldname": "salutation",
"fieldtype": "Link",
"label": "Salutation",
"no_copy": 1,
"options": "Salutation",
"print_hide": 1
},
{ {
"fieldname": "col_break_email_1", "fieldname": "col_break_email_1",
"fieldtype": "Column Break" "fieldtype": "Column Break"
@ -285,13 +268,28 @@
"fieldname": "named_place", "fieldname": "named_place",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Named Place" "label": "Named Place"
},
{
"fieldname": "html_llwp",
"fieldtype": "HTML",
"options": "<p>In your <b>Email Template</b>, you can use the following special variables:\n</p>\n<ul>\n <li>\n <code>{{ update_password_link }}</code>: A link where your supplier can set a new password to log into your portal.\n </li>\n <li>\n <code>{{ portal_link }}</code>: A link to this RFQ in your supplier portal.\n </li>\n <li>\n <code>{{ supplier_name }}</code>: The company name of your supplier.\n </li>\n <li>\n <code>{{ contact.salutation }} {{ contact.last_name }}</code>: The contact person of your supplier.\n </li><li>\n <code>{{ user_fullname }}</code>: Your full name.\n </li>\n </ul>\n<p></p>\n<p>Apart from these, you can access all values in this RFQ, like <code>{{ message_for_supplier }}</code> or <code>{{ terms }}</code>.</p>",
"print_hide": 1,
"read_only": 1,
"report_hide": 1
},
{
"default": "1",
"description": "If enabled, all files attached to this document will be attached to each email",
"fieldname": "send_attached_files",
"fieldtype": "Check",
"label": "Send Attached Files"
} }
], ],
"icon": "fa fa-shopping-cart", "icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2023-01-31 23:22:06.684694", "modified": "2023-08-08 16:30:10.870429",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Request for Quotation", "name": "Request for Quotation",

View File

@ -116,7 +116,10 @@ class RequestforQuotation(BuyingController):
route = frappe.db.get_value( route = frappe.db.get_value(
"Portal Menu Item", {"reference_doctype": "Request for Quotation"}, ["route"] "Portal Menu Item", {"reference_doctype": "Request for Quotation"}, ["route"]
) )
return get_url("/app/{0}/".format(route) + self.name) if not route:
frappe.throw(_("Please add Request for Quotation to the sidebar in Portal Settings."))
return get_url(f"{route}/{self.name}")
def update_supplier_part_no(self, supplier): def update_supplier_part_no(self, supplier):
self.vendor = supplier self.vendor = supplier
@ -179,37 +182,32 @@ class RequestforQuotation(BuyingController):
if full_name == "Guest": if full_name == "Guest":
full_name = "Administrator" full_name = "Administrator"
# send document dict and some important data from suppliers row
# to render message_for_supplier from any template
doc_args = self.as_dict() doc_args = self.as_dict()
doc_args.update({"supplier": data.get("supplier"), "supplier_name": data.get("supplier_name")})
# Get Contact Full Name
supplier_name = None
if data.get("contact"): if data.get("contact"):
contact_name = frappe.db.get_value( contact = frappe.get_doc("Contact", data.get("contact"))
"Contact", data.get("contact"), ["first_name", "middle_name", "last_name"] doc_args["contact"] = contact.as_dict()
)
supplier_name = (" ").join(x for x in contact_name if x) # remove any blank values
args = { doc_args.update(
"update_password_link": update_password_link, {
"message": frappe.render_template(self.message_for_supplier, doc_args), "supplier": data.get("supplier"),
"rfq_link": rfq_link, "supplier_name": data.get("supplier_name"),
"user_fullname": full_name, "update_password_link": f'<a href="{update_password_link}" class="btn btn-default btn-xs" target="_blank">{_("Set Password")}</a>',
"supplier_name": supplier_name or data.get("supplier_name"), "portal_link": f'<a href="{rfq_link}" class="btn btn-default btn-sm" target="_blank"> {_("Submit your Quotation")} </a>',
"supplier_salutation": self.salutation or "Dear Mx.", "user_fullname": full_name,
} }
)
subject = self.subject or _("Request for Quotation") email_template = frappe.get_doc("Email Template", self.email_template)
template = "templates/emails/request_for_quotation.html" message = frappe.render_template(email_template.response_, doc_args)
subject = frappe.render_template(email_template.subject, doc_args)
sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None sender = frappe.session.user not in STANDARD_USERS and frappe.session.user or None
message = frappe.get_template(template).render(args)
if preview: if preview:
return message return {"message": message, "subject": subject}
attachments = self.get_attachments() attachments = None
if self.send_attached_files:
attachments = self.get_attachments()
self.send_email(data, sender, subject, message, attachments) self.send_email(data, sender, subject, message, attachments)

View File

@ -2,11 +2,14 @@
# See license.txt # See license.txt
from urllib.parse import urlparse
import frappe import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests.utils import FrappeTestCase
from frappe.utils import nowdate from frappe.utils import nowdate
from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
RequestforQuotation,
create_supplier_quotation, create_supplier_quotation,
get_pdf, get_pdf,
make_supplier_quotation_from_rfq, make_supplier_quotation_from_rfq,
@ -125,13 +128,18 @@ class TestRequestforQuotation(FrappeTestCase):
rfq.status = "Draft" rfq.status = "Draft"
rfq.submit() rfq.submit()
def test_get_link(self):
rfq = make_request_for_quotation()
parsed_link = urlparse(rfq.get_link())
self.assertEqual(parsed_link.path, f"/rfq/{rfq.name}")
def test_get_pdf(self): def test_get_pdf(self):
rfq = make_request_for_quotation() rfq = make_request_for_quotation()
get_pdf(rfq.name, rfq.get("suppliers")[0].supplier) get_pdf(rfq.name, rfq.get("suppliers")[0].supplier)
self.assertEqual(frappe.local.response.type, "pdf") self.assertEqual(frappe.local.response.type, "pdf")
def make_request_for_quotation(**args): def make_request_for_quotation(**args) -> "RequestforQuotation":
""" """
:param supplier_data: List containing supplier data :param supplier_data: List containing supplier data
""" """

View File

@ -339,29 +339,35 @@ def make_default_records():
{ {
"min_grade": 0.0, "min_grade": 0.0,
"prevent_rfqs": 1, "prevent_rfqs": 1,
"warn_rfqs": 0,
"notify_supplier": 0, "notify_supplier": 0,
"max_grade": 30.0, "max_grade": 30.0,
"prevent_pos": 1, "prevent_pos": 1,
"warn_pos": 0,
"standing_color": "Red", "standing_color": "Red",
"notify_employee": 0, "notify_employee": 0,
"standing_name": "Very Poor", "standing_name": "Very Poor",
}, },
{ {
"min_grade": 30.0, "min_grade": 30.0,
"prevent_rfqs": 1, "prevent_rfqs": 0,
"warn_rfqs": 1,
"notify_supplier": 0, "notify_supplier": 0,
"max_grade": 50.0, "max_grade": 50.0,
"prevent_pos": 0, "prevent_pos": 0,
"standing_color": "Red", "warn_pos": 1,
"standing_color": "Yellow",
"notify_employee": 0, "notify_employee": 0,
"standing_name": "Poor", "standing_name": "Poor",
}, },
{ {
"min_grade": 50.0, "min_grade": 50.0,
"prevent_rfqs": 0, "prevent_rfqs": 0,
"warn_rfqs": 0,
"notify_supplier": 0, "notify_supplier": 0,
"max_grade": 80.0, "max_grade": 80.0,
"prevent_pos": 0, "prevent_pos": 0,
"warn_pos": 0,
"standing_color": "Green", "standing_color": "Green",
"notify_employee": 0, "notify_employee": 0,
"standing_name": "Average", "standing_name": "Average",
@ -369,9 +375,11 @@ def make_default_records():
{ {
"min_grade": 80.0, "min_grade": 80.0,
"prevent_rfqs": 0, "prevent_rfqs": 0,
"warn_rfqs": 0,
"notify_supplier": 0, "notify_supplier": 0,
"max_grade": 100.0, "max_grade": 100.0,
"prevent_pos": 0, "prevent_pos": 0,
"warn_pos": 0,
"standing_color": "Blue", "standing_color": "Blue",
"notify_employee": 0, "notify_employee": 0,
"standing_name": "Excellent", "standing_name": "Excellent",

View File

@ -5,7 +5,7 @@
import json import json
import frappe import frappe
from frappe import _, bold, throw from frappe import _, bold, qb, throw
from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.custom import ConstantColumn
from frappe.query_builder.functions import Abs, Sum from frappe.query_builder.functions import Abs, Sum
@ -32,13 +32,19 @@ from erpnext.accounts.doctype.pricing_rule.utils import (
apply_pricing_rule_on_transaction, apply_pricing_rule_on_transaction,
get_applied_pricing_rules, get_applied_pricing_rules,
) )
from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
from erpnext.accounts.party import ( from erpnext.accounts.party import (
get_party_account, get_party_account,
get_party_account_currency, get_party_account_currency,
get_party_gle_currency, get_party_gle_currency,
validate_party_frozen_disabled, validate_party_frozen_disabled,
) )
from erpnext.accounts.utils import get_account_currency, get_fiscal_years, validate_fiscal_year from erpnext.accounts.utils import (
create_gain_loss_journal,
get_account_currency,
get_fiscal_years,
validate_fiscal_year,
)
from erpnext.buying.utils import update_last_purchase_rate from erpnext.buying.utils import update_last_purchase_rate
from erpnext.controllers.print_settings import ( from erpnext.controllers.print_settings import (
set_print_templates_for_item_table, set_print_templates_for_item_table,
@ -968,67 +974,160 @@ class AccountsController(TransactionBase):
d.exchange_gain_loss = difference d.exchange_gain_loss = difference
def make_exchange_gain_loss_gl_entries(self, gl_entries): def make_precision_loss_gl_entry(self, gl_entries):
if self.get("doctype") in ["Purchase Invoice", "Sales Invoice"]: round_off_account, round_off_cost_center = get_round_off_account_and_cost_center(
for d in self.get("advances"): self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center
if d.exchange_gain_loss: )
is_purchase_invoice = self.get("doctype") == "Purchase Invoice"
party = self.supplier if is_purchase_invoice else self.customer
party_account = self.credit_to if is_purchase_invoice else self.debit_to
party_type = "Supplier" if is_purchase_invoice else "Customer"
gain_loss_account = frappe.get_cached_value( precision_loss = self.get("base_net_total") - flt(
"Company", self.company, "exchange_gain_loss_account" self.get("net_total") * self.conversion_rate, self.precision("net_total")
) )
if not gain_loss_account:
frappe.throw(
_("Please set default Exchange Gain/Loss Account in Company {}").format(self.get("company"))
)
account_currency = get_account_currency(gain_loss_account)
if account_currency != self.company_currency:
frappe.throw(
_("Currency for {0} must be {1}").format(gain_loss_account, self.company_currency)
)
# for purchase credit_or_debit = "credit" if self.doctype == "Purchase Invoice" else "debit"
dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit" against = self.supplier if self.doctype == "Purchase Invoice" else self.customer
if not is_purchase_invoice:
# just reverse for sales?
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gl_entries.append( if precision_loss:
self.get_gl_dict( gl_entries.append(
{ self.get_gl_dict(
"account": gain_loss_account, {
"account_currency": account_currency, "account": round_off_account,
"against": party, "against": against,
dr_or_cr + "_in_account_currency": abs(d.exchange_gain_loss), credit_or_debit: precision_loss,
dr_or_cr: abs(d.exchange_gain_loss), "cost_center": round_off_cost_center
"cost_center": self.cost_center or erpnext.get_default_cost_center(self.company), if self.use_company_roundoff_cost_center
"project": self.project, else self.cost_center or round_off_cost_center,
}, "remarks": _("Net total calculation precision loss"),
item=d, }
)
)
def make_exchange_gain_loss_journal(self, args: dict = None) -> None:
"""
Make Exchange Gain/Loss journal for Invoices and Payments
"""
# Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event.
# see accounts/utils.py:cancel_exchange_gain_loss_journal()
if self.docstatus == 1:
if self.get("doctype") == "Journal Entry":
# 'args' is populated with exchange gain/loss account and the amount to be booked.
# These are generated by Sales/Purchase Invoice during reconciliation and advance allocation.
# and below logic is only for such scenarios
if args:
for arg in args:
# Advance section uses `exchange_gain_loss` and reconciliation uses `difference_amount`
if (
arg.get("difference_amount", 0) != 0 or arg.get("exchange_gain_loss", 0) != 0
) and arg.get("difference_account"):
party_account = arg.get("account")
gain_loss_account = arg.get("difference_account")
difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss")
if difference_amount > 0:
dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit"
else:
dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit"
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
je = create_gain_loss_journal(
self.company,
arg.get("party_type"),
arg.get("party"),
party_account,
gain_loss_account,
difference_amount,
dr_or_cr,
reverse_dr_or_cr,
arg.get("against_voucher_type"),
arg.get("against_voucher"),
arg.get("idx"),
self.doctype,
self.name,
arg.get("idx"),
)
frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format(
get_link_to_form("Journal Entry", je)
)
)
if self.get("doctype") == "Payment Entry":
# For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation
gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0]
booked = []
if gain_loss_to_book:
vtypes = [x.reference_doctype for x in gain_loss_to_book]
vnames = [x.reference_name for x in gain_loss_to_book]
je = qb.DocType("Journal Entry")
jea = qb.DocType("Journal Entry Account")
parents = (
qb.from_(jea)
.select(jea.parent)
.where(
(jea.reference_type == "Payment Entry")
& (jea.reference_name == self.name)
& (jea.docstatus == 1)
) )
.run()
) )
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" booked = []
if parents:
gl_entries.append( booked = (
self.get_gl_dict( qb.from_(je)
{ .inner_join(jea)
"account": party_account, .on(je.name == jea.parent)
"party_type": party_type, .select(jea.reference_type, jea.reference_name, jea.reference_detail_no)
"party": party, .where(
"against": gain_loss_account, (je.docstatus == 1)
dr_or_cr + "_in_account_currency": flt(abs(d.exchange_gain_loss) / self.conversion_rate), & (je.name.isin(parents))
dr_or_cr: abs(d.exchange_gain_loss), & (je.voucher_type == "Exchange Gain or Loss")
"cost_center": self.cost_center, )
"project": self.project, .run()
}, )
self.party_account_currency,
item=self, for d in gain_loss_to_book:
# Filter out References for which Gain/Loss is already booked
if d.exchange_gain_loss and (
(d.reference_doctype, d.reference_name, str(d.idx)) not in booked
):
if self.payment_type == "Receive":
party_account = self.paid_from
elif self.payment_type == "Pay":
party_account = self.paid_to
dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit"
if d.reference_doctype == "Purchase Invoice":
dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
gain_loss_account = frappe.get_cached_value(
"Company", self.company, "exchange_gain_loss_account"
)
je = create_gain_loss_journal(
self.company,
self.party_type,
self.party,
party_account,
gain_loss_account,
d.exchange_gain_loss,
dr_or_cr,
reverse_dr_or_cr,
d.reference_doctype,
d.reference_name,
d.idx,
self.doctype,
self.name,
d.idx,
)
frappe.msgprint(
_("Exchange Gain/Loss amount has been booked through {0}").format(
get_link_to_form("Journal Entry", je)
)
) )
)
def update_against_document_in_jv(self): def update_against_document_in_jv(self):
""" """
@ -1090,9 +1189,15 @@ class AccountsController(TransactionBase):
reconcile_against_document(lst) reconcile_against_document(lst)
def on_cancel(self): def on_cancel(self):
from erpnext.accounts.utils import unlink_ref_doc_from_payment_entries from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
unlink_ref_doc_from_payment_entries,
)
if self.doctype in ["Sales Invoice", "Purchase Invoice", "Payment Entry", "Journal Entry"]:
# Cancel Exchange Gain/Loss Journal before unlinking
cancel_exchange_gain_loss_journal(self)
if self.doctype in ["Sales Invoice", "Purchase Invoice"]:
if frappe.db.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"): if frappe.db.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"):
unlink_ref_doc_from_payment_entries(self) unlink_ref_doc_from_payment_entries(self)
@ -1679,8 +1784,13 @@ class AccountsController(TransactionBase):
) )
self.append("payment_schedule", data) self.append("payment_schedule", data)
allocate_payment_based_on_payment_terms = frappe.db.get_value(
"Payment Terms Template", self.payment_terms_template, "allocate_payment_based_on_payment_terms"
)
if not ( if not (
automatically_fetch_payment_terms automatically_fetch_payment_terms
and allocate_payment_based_on_payment_terms
and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype) and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
): ):
for d in self.get("payment_schedule"): for d in self.get("payment_schedule"):

View File

@ -233,6 +233,9 @@ class StatusUpdater(Document):
if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"): if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"):
frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code)) frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code))
if hasattr(d, "item_code") and hasattr(d, "rate") and d.rate < 0:
frappe.throw(_("For an item {0}, rate must be a positive number").format(d.item_code))
if d.doctype == args["source_dt"] and d.get(args["join_field"]): if d.doctype == args["source_dt"] and d.get(args["join_field"]):
args["name"] = d.get(args["join_field"]) args["name"] = d.get(args["join_field"])

View File

@ -15,7 +15,7 @@ from erpnext.accounts.general_ledger import (
make_reverse_gl_entries, make_reverse_gl_entries,
process_gl_map, process_gl_map,
) )
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController from erpnext.controllers.accounts_controller import AccountsController
from erpnext.stock import get_warehouse_account_map from erpnext.stock import get_warehouse_account_map
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import ( from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
@ -534,6 +534,7 @@ class StockController(AccountsController):
make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher) make_sl_entries(sl_entries, allow_negative_stock, via_landed_cost_voucher)
def make_gl_entries_on_cancel(self): def make_gl_entries_on_cancel(self):
cancel_exchange_gain_loss_journal(frappe._dict(doctype=self.doctype, name=self.name))
if frappe.db.sql( if frappe.db.sql(
"""select name from `tabGL Entry` where voucher_type=%s """select name from `tabGL Entry` where voucher_type=%s
and voucher_no=%s""", and voucher_no=%s""",

View File

@ -550,7 +550,7 @@ class SubcontractingController(StockController):
if rm_obj.serial_and_batch_bundle: if rm_obj.serial_and_batch_bundle:
args["serial_and_batch_bundle"] = rm_obj.serial_and_batch_bundle args["serial_and_batch_bundle"] = rm_obj.serial_and_batch_bundle
rm_obj.rate = bom_item.rate if self.backflush_based_on == "BOM" else get_incoming_rate(args) rm_obj.rate = get_incoming_rate(args)
def __get_qty_based_on_material_transfer(self, item_row, transfer_item): def __get_qty_based_on_material_transfer(self, item_row, transfer_item):
key = (item_row.item_code, item_row.get(self.subcontract_data.order_field)) key = (item_row.item_code, item_row.get(self.subcontract_data.order_field))

View File

@ -0,0 +1,999 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import unittest
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, flt, nowdate
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.party import get_party_account
from erpnext.stock.doctype.item.test_item import create_item
def make_customer(customer_name, currency=None):
if not frappe.db.exists("Customer", customer_name):
customer = frappe.new_doc("Customer")
customer.customer_name = customer_name
customer.customer_type = "Individual"
if currency:
customer.default_currency = currency
customer.save()
return customer.name
else:
return customer_name
def make_supplier(supplier_name, currency=None):
if not frappe.db.exists("Supplier", supplier_name):
supplier = frappe.new_doc("Supplier")
supplier.supplier_name = supplier_name
supplier.supplier_type = "Individual"
supplier.supplier_group = "All Supplier Groups"
if currency:
supplier.default_currency = currency
supplier.save()
return supplier.name
else:
return supplier_name
class TestAccountsController(FrappeTestCase):
"""
Test Exchange Gain/Loss booking on various scenarios.
Test Cases are numbered for better organization
10 series - Sales Invoice against Payment Entries
20 series - Sales Invoice against Journals
30 series - Sales Invoice against Credit Notes
"""
def setUp(self):
self.create_company()
self.create_account()
self.create_item()
self.create_parties()
self.clear_old_entries()
def tearDown(self):
frappe.db.rollback()
def create_company(self):
company_name = "_Test Company"
self.company_abbr = abbr = "_TC"
if frappe.db.exists("Company", company_name):
company = frappe.get_doc("Company", company_name)
else:
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": company_name,
"country": "India",
"default_currency": "INR",
"create_chart_of_accounts_based_on": "Standard Template",
"chart_of_accounts": "Standard",
}
)
company = company.save()
self.company = company.name
self.cost_center = company.cost_center
self.warehouse = "Stores - " + abbr
self.finished_warehouse = "Finished Goods - " + abbr
self.income_account = "Sales - " + abbr
self.expense_account = "Cost of Goods Sold - " + abbr
self.debit_to = "Debtors - " + abbr
self.debit_usd = "Debtors USD - " + abbr
self.cash = "Cash - " + abbr
self.creditors = "Creditors - " + abbr
def create_item(self):
item = create_item(
item_code="_Test Notebook", is_stock_item=0, company=self.company, warehouse=self.warehouse
)
self.item = item if isinstance(item, str) else item.item_code
def create_parties(self):
self.create_customer()
self.create_supplier()
def create_customer(self):
self.customer = make_customer("_Test MC Customer USD", "USD")
def create_supplier(self):
self.supplier = make_supplier("_Test MC Supplier USD", "USD")
def create_account(self):
account_name = "Debtors USD"
if not frappe.db.get_value(
"Account", filters={"account_name": account_name, "company": self.company}
):
acc = frappe.new_doc("Account")
acc.account_name = account_name
acc.parent_account = "Accounts Receivable - " + self.company_abbr
acc.company = self.company
acc.account_currency = "USD"
acc.account_type = "Receivable"
acc.insert()
else:
name = frappe.db.get_value(
"Account",
filters={"account_name": account_name, "company": self.company},
fieldname="name",
pluck=True,
)
acc = frappe.get_doc("Account", name)
self.debtors_usd = acc.name
def create_sales_invoice(
self,
qty=1,
rate=1,
conversion_rate=80,
posting_date=nowdate(),
do_not_save=False,
do_not_submit=False,
):
"""
Helper function to populate default values in sales invoice
"""
sinv = create_sales_invoice(
qty=qty,
rate=rate,
company=self.company,
customer=self.customer,
item_code=self.item,
item_name=self.item,
cost_center=self.cost_center,
warehouse=self.warehouse,
debit_to=self.debit_usd,
parent_cost_center=self.cost_center,
update_stock=0,
currency="USD",
conversion_rate=conversion_rate,
is_pos=0,
is_return=0,
return_against=None,
income_account=self.income_account,
expense_account=self.expense_account,
do_not_save=do_not_save,
do_not_submit=do_not_submit,
)
return sinv
def create_payment_entry(
self, amount=1, source_exc_rate=75, posting_date=nowdate(), customer=None
):
"""
Helper function to populate default values in payment entry
"""
payment = create_payment_entry(
company=self.company,
payment_type="Receive",
party_type="Customer",
party=customer or self.customer,
paid_from=self.debit_usd,
paid_to=self.cash,
paid_amount=amount,
)
payment.source_exchange_rate = source_exc_rate
payment.received_amount = source_exc_rate * amount
payment.posting_date = posting_date
return payment
def clear_old_entries(self):
doctype_list = [
"GL Entry",
"Payment Ledger Entry",
"Sales Invoice",
"Purchase Invoice",
"Payment Entry",
"Journal Entry",
]
for doctype in doctype_list:
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
def create_payment_reconciliation(self):
pr = frappe.new_doc("Payment Reconciliation")
pr.company = self.company
pr.party_type = "Customer"
pr.party = self.customer
pr.receivable_payable_account = get_party_account(pr.party_type, pr.party, pr.company)
pr.from_invoice_date = pr.to_invoice_date = pr.from_payment_date = pr.to_payment_date = nowdate()
return pr
def create_journal_entry(
self,
acc1=None,
acc1_exc_rate=None,
acc2_exc_rate=None,
acc2=None,
acc1_amount=0,
acc2_amount=0,
posting_date=None,
cost_center=None,
):
je = frappe.new_doc("Journal Entry")
je.posting_date = posting_date or nowdate()
je.company = self.company
je.user_remark = "test"
je.multi_currency = True
if not cost_center:
cost_center = self.cost_center
je.set(
"accounts",
[
{
"account": acc1,
"exchange_rate": acc1_exc_rate or 1,
"cost_center": cost_center,
"debit_in_account_currency": acc1_amount if acc1_amount > 0 else 0,
"credit_in_account_currency": abs(acc1_amount) if acc1_amount < 0 else 0,
"debit": acc1_amount * acc1_exc_rate if acc1_amount > 0 else 0,
"credit": abs(acc1_amount * acc1_exc_rate) if acc1_amount < 0 else 0,
},
{
"account": acc2,
"exchange_rate": acc2_exc_rate or 1,
"cost_center": cost_center,
"credit_in_account_currency": acc2_amount if acc2_amount > 0 else 0,
"debit_in_account_currency": abs(acc2_amount) if acc2_amount < 0 else 0,
"credit": acc2_amount * acc2_exc_rate if acc2_amount > 0 else 0,
"debit": abs(acc2_amount * acc2_exc_rate) if acc2_amount < 0 else 0,
},
],
)
return je
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 assert_ledger_outstanding(
self,
voucher_type: str,
voucher_no: str,
outstanding: float,
outstanding_in_account_currency: float,
) -> None:
"""
Assert outstanding amount based on ledger on both company/base currency and account currency
"""
ple = qb.DocType("Payment Ledger Entry")
current_outstanding = (
qb.from_(ple)
.select(
Sum(ple.amount).as_("outstanding"),
Sum(ple.amount_in_account_currency).as_("outstanding_in_account_currency"),
)
.where(
(ple.against_voucher_type == voucher_type)
& (ple.against_voucher_no == voucher_no)
& (ple.delinked == 0)
)
.run(as_dict=True)[0]
)
self.assertEqual(outstanding, current_outstanding.outstanding)
self.assertEqual(
outstanding_in_account_currency, current_outstanding.outstanding_in_account_currency
)
def test_10_payment_against_sales_invoice(self):
# Sales Invoice in Foreign Currency
rate = 80
rate_in_account_currency = 1
si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency)
# Test payments with different exchange rates
for exc_rate in [75.9, 83.1, 80.01]:
with self.subTest(exc_rate=exc_rate):
pe = self.create_payment_entry(amount=1, source_exc_rate=exc_rate).save()
pe.append(
"references",
{"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
)
pe = pe.save().submit()
# Outstanding in both currencies should be '0'
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exchange Gain/Loss Journal should've been created.
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.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_pe), 1)
self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0])
# Cancel Payment
pe.cancel()
# outstanding should be same as grand total
si.reload()
self.assertEqual(si.outstanding_amount, rate_in_account_currency)
self.assert_ledger_outstanding(si.doctype, si.name, rate, rate_in_account_currency)
# Exchange Gain/Loss Journal should've been cancelled
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, [])
self.assertEqual(exc_je_for_pe, [])
def test_11_advance_against_sales_invoice(self):
# Advance Payment
adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
adv.reload()
# Sales Invoices in different exchange rates
for exc_rate in [75.9, 83.1, 80.01]:
with self.subTest(exc_rate=exc_rate):
si = self.create_sales_invoice(qty=1, conversion_rate=exc_rate, rate=1, do_not_submit=True)
advances = si.get_advance_entries()
self.assertEqual(len(advances), 1)
self.assertEqual(advances[0].reference_name, adv.name)
si.append(
"advances",
{
"doctype": "Sales Invoice Advance",
"reference_type": advances[0].reference_type,
"reference_name": advances[0].reference_name,
"reference_row": advances[0].reference_row,
"advance_amount": 1,
"allocated_amount": 1,
"ref_exchange_rate": advances[0].exchange_rate,
"remarks": advances[0].remarks,
},
)
si = si.save()
si = si.submit()
# Outstanding in both currencies should be '0'
adv.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exchange Gain/Loss Journal should've been created.
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_adv), 1)
self.assertEqual(exc_je_for_si, exc_je_for_adv)
# Cancel Invoice
si.cancel()
# Exchange Gain/Loss Journal should've been cancelled
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
self.assertEqual(exc_je_for_si, [])
self.assertEqual(exc_je_for_adv, [])
def test_12_partial_advance_and_payment_for_sales_invoice(self):
"""
Sales invoice with partial advance payment, and a normal payment reconciled
"""
# Partial Advance
adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
adv.reload()
# sales invoice with advance(partial amount)
rate = 80
rate_in_account_currency = 1
si = self.create_sales_invoice(
qty=2, conversion_rate=80, rate=rate_in_account_currency, do_not_submit=True
)
advances = si.get_advance_entries()
self.assertEqual(len(advances), 1)
self.assertEqual(advances[0].reference_name, adv.name)
si.append(
"advances",
{
"doctype": "Sales Invoice Advance",
"reference_type": advances[0].reference_type,
"reference_name": advances[0].reference_name,
"advance_amount": 1,
"allocated_amount": 1,
"ref_exchange_rate": advances[0].exchange_rate,
"remarks": advances[0].remarks,
},
)
si = si.save()
si = si.submit()
# Outstanding should be there in both currencies
si.reload()
self.assertEqual(si.outstanding_amount, 1) # account currency
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
# Exchange Gain/Loss Journal should've been created for the partial advance
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_adv), 1)
self.assertEqual(exc_je_for_si, exc_je_for_adv)
# Payment for remaining amount
pe = self.create_payment_entry(amount=1, source_exc_rate=75).save()
pe.append(
"references",
{"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
)
pe = pe.save().submit()
# Outstanding in both currencies should be '0'
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exchange Gain/Loss Journal should've been created for the payment
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.assertNotEqual(exc_je_for_si, [])
# There should be 2 JE's now. One for the advance and one for the payment
self.assertEqual(len(exc_je_for_si), 2)
self.assertEqual(len(exc_je_for_pe), 1)
self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv)
# Cancel Invoice
si.reload()
si.cancel()
# Exchange Gain/Loss Journal should been cancelled
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
self.assertEqual(exc_je_for_si, [])
self.assertEqual(exc_je_for_pe, [])
self.assertEqual(exc_je_for_adv, [])
def test_13_partial_advance_and_payment_for_invoice_with_cancellation(self):
"""
Invoice with partial advance payment, and a normal payment. Then cancel advance and payment.
"""
# Partial Advance
adv = self.create_payment_entry(amount=1, source_exc_rate=85).save().submit()
adv.reload()
# invoice with advance(partial amount)
si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1, do_not_submit=True)
advances = si.get_advance_entries()
self.assertEqual(len(advances), 1)
self.assertEqual(advances[0].reference_name, adv.name)
si.append(
"advances",
{
"doctype": "Sales Invoice Advance",
"reference_type": advances[0].reference_type,
"reference_name": advances[0].reference_name,
"advance_amount": 1,
"allocated_amount": 1,
"ref_exchange_rate": advances[0].exchange_rate,
"remarks": advances[0].remarks,
},
)
si = si.save()
si = si.submit()
# Outstanding should be there in both currencies
si.reload()
self.assertEqual(si.outstanding_amount, 1) # account currency
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
# Exchange Gain/Loss Journal should've been created for the partial advance
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_adv), 1)
self.assertEqual(exc_je_for_si, exc_je_for_adv)
# Payment(remaining amount)
pe = self.create_payment_entry(amount=1, source_exc_rate=75).save()
pe.append(
"references",
{"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
)
pe = pe.save().submit()
# Outstanding should be '0' in both currencies
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exchange Gain/Loss Journal should've been created for the payment
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.assertNotEqual(exc_je_for_si, [])
# There should be 2 JE's now. One for the advance and one for the payment
self.assertEqual(len(exc_je_for_si), 2)
self.assertEqual(len(exc_je_for_pe), 1)
self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv)
adv.reload()
adv.cancel()
# Outstanding should be there in both currencies, since advance is cancelled.
si.reload()
self.assertEqual(si.outstanding_amount, 1) # account currency
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name)
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
# Exchange Gain/Loss Journal for advance should been cancelled
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_pe), 1)
self.assertEqual(exc_je_for_adv, [])
def test_14_same_payment_split_against_invoice(self):
# Invoice in Foreign Currency
si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1)
# Payment
pe = self.create_payment_entry(amount=2, source_exc_rate=75).save()
pe.append(
"references",
{"reference_doctype": si.doctype, "reference_name": si.name, "allocated_amount": 1},
)
pe = pe.save().submit()
# There should be outstanding in both currencies
si.reload()
self.assertEqual(si.outstanding_amount, 1)
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
# Exchange Gain/Loss Journal should've been created.
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.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_pe), 1)
self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0])
# Reconcile the remaining amount
pr = frappe.get_doc("Payment Reconciliation")
pr.company = self.company
pr.party_type = "Customer"
pr.party = self.customer
pr.receivable_payable_account = self.debit_usd
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# Exc gain/loss journal should have been creaetd for the reconciled amount
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(len(exc_je_for_si), 2)
self.assertEqual(len(exc_je_for_pe), 2)
self.assertEqual(exc_je_for_si, exc_je_for_pe)
# There should be no outstanding
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Cancel Payment
pe.reload()
pe.cancel()
si.reload()
self.assertEqual(si.outstanding_amount, 2)
self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0)
# Exchange Gain/Loss Journal should've been cancelled
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, [])
self.assertEqual(exc_je_for_pe, [])
def test_20_journal_against_sales_invoice(self):
# Invoice in Foreign Currency
si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1)
# Payment
je = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=75,
acc2=self.cash,
acc1_amount=-1,
acc2_amount=-75,
acc2_exc_rate=1,
)
je.accounts[0].party_type = "Customer"
je.accounts[0].party = self.customer
je = je.save().submit()
# Reconcile the remaining amount
pr = self.create_payment_reconciliation()
# pr.receivable_payable_account = self.debit_usd
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# There should be no outstanding in both currencies
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exchange Gain/Loss Journal should've been created.
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_je = self.get_journals_for(je.doctype, je.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(
len(exc_je_for_si), 2
) # payment also has reference. so, there are 2 journals referencing invoice
self.assertEqual(len(exc_je_for_je), 1)
self.assertIn(exc_je_for_je[0], exc_je_for_si)
# Cancel Payment
je.reload()
je.cancel()
si.reload()
self.assertEqual(si.outstanding_amount, 1)
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
# Exchange Gain/Loss Journal should've been cancelled
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_je = self.get_journals_for(je.doctype, je.name)
self.assertEqual(exc_je_for_si, [])
self.assertEqual(exc_je_for_je, [])
def test_21_advance_journal_against_sales_invoice(self):
# Advance Payment
adv_exc_rate = 80
adv = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=adv_exc_rate,
acc2=self.cash,
acc1_amount=-1,
acc2_amount=adv_exc_rate * -1,
acc2_exc_rate=1,
)
adv.accounts[0].party_type = "Customer"
adv.accounts[0].party = self.customer
adv.accounts[0].is_advance = "Yes"
adv = adv.save().submit()
adv.reload()
# Sales Invoices in different exchange rates
for exc_rate in [75.9, 83.1]:
with self.subTest(exc_rate=exc_rate):
si = self.create_sales_invoice(qty=1, conversion_rate=exc_rate, rate=1, do_not_submit=True)
advances = si.get_advance_entries()
self.assertEqual(len(advances), 1)
self.assertEqual(advances[0].reference_name, adv.name)
si.append(
"advances",
{
"doctype": "Sales Invoice Advance",
"reference_type": advances[0].reference_type,
"reference_name": advances[0].reference_name,
"reference_row": advances[0].reference_row,
"advance_amount": 1,
"allocated_amount": 1,
"ref_exchange_rate": advances[0].exchange_rate,
"remarks": advances[0].remarks,
},
)
si = si.save()
si = si.submit()
# Outstanding in both currencies should be '0'
adv.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exchange Gain/Loss Journal should've been created.
exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.name]
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_adv), 1)
self.assertEqual(exc_je_for_si, exc_je_for_adv)
# Cancel Invoice
si.cancel()
# Exchange Gain/Loss Journal should've been cancelled
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
self.assertEqual(exc_je_for_si, [])
self.assertEqual(exc_je_for_adv, [])
def test_22_partial_advance_and_payment_for_invoice_with_cancellation(self):
"""
Invoice with partial advance payment as Journal, and a normal payment. Then cancel advance and payment.
"""
# Partial Advance
adv_exc_rate = 75
adv = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=adv_exc_rate,
acc2=self.cash,
acc1_amount=-1,
acc2_amount=adv_exc_rate * -1,
acc2_exc_rate=1,
)
adv.accounts[0].party_type = "Customer"
adv.accounts[0].party = self.customer
adv.accounts[0].is_advance = "Yes"
adv = adv.save().submit()
adv.reload()
# invoice with advance(partial amount)
si = self.create_sales_invoice(qty=3, conversion_rate=80, rate=1, do_not_submit=True)
advances = si.get_advance_entries()
self.assertEqual(len(advances), 1)
self.assertEqual(advances[0].reference_name, adv.name)
si.append(
"advances",
{
"doctype": "Sales Invoice Advance",
"reference_type": advances[0].reference_type,
"reference_name": advances[0].reference_name,
"reference_row": advances[0].reference_row,
"advance_amount": 1,
"allocated_amount": 1,
"ref_exchange_rate": advances[0].exchange_rate,
"remarks": advances[0].remarks,
},
)
si = si.save()
si = si.submit()
# Outstanding should be there in both currencies
si.reload()
self.assertEqual(si.outstanding_amount, 2) # account currency
self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0)
# Exchange Gain/Loss Journal should've been created for the partial advance
exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != adv.name]
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_adv), 1)
self.assertEqual(exc_je_for_si, exc_je_for_adv)
# Payment
adv2_exc_rate = 83
pay = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=adv2_exc_rate,
acc2=self.cash,
acc1_amount=-2,
acc2_amount=adv2_exc_rate * -2,
acc2_exc_rate=1,
)
pay.accounts[0].party_type = "Customer"
pay.accounts[0].party = self.customer
pay.accounts[0].is_advance = "Yes"
pay = pay.save().submit()
pay.reload()
# Reconcile the remaining amount
pr = self.create_payment_reconciliation()
# pr.receivable_payable_account = self.debit_usd
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# Outstanding should be '0' in both currencies
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Exchange Gain/Loss Journal should've been created for the payment
exc_je_for_si = [
x
for x in self.get_journals_for(si.doctype, si.name)
if x.parent != adv.name and x.parent != pay.name
]
exc_je_for_pe = self.get_journals_for(pay.doctype, pay.name)
self.assertNotEqual(exc_je_for_si, [])
# There should be 2 JE's now. One for the advance and one for the payment
self.assertEqual(len(exc_je_for_si), 2)
self.assertEqual(len(exc_je_for_pe), 1)
self.assertEqual(exc_je_for_si, exc_je_for_pe + exc_je_for_adv)
adv.reload()
adv.cancel()
# Outstanding should be there in both currencies, since advance is cancelled.
si.reload()
self.assertEqual(si.outstanding_amount, 1) # account currency
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
exc_je_for_si = [
x
for x in self.get_journals_for(si.doctype, si.name)
if x.parent != adv.name and x.parent != pay.name
]
exc_je_for_pe = self.get_journals_for(pay.doctype, pay.name)
exc_je_for_adv = self.get_journals_for(adv.doctype, adv.name)
# Exchange Gain/Loss Journal for advance should been cancelled
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_pe), 1)
self.assertEqual(exc_je_for_adv, [])
def test_23_same_journal_split_against_single_invoice(self):
# Invoice in Foreign Currency
si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1)
# Payment
je = self.create_journal_entry(
acc1=self.debit_usd,
acc1_exc_rate=75,
acc2=self.cash,
acc1_amount=-2,
acc2_amount=-150,
acc2_exc_rate=1,
)
je.accounts[0].party_type = "Customer"
je.accounts[0].party = self.customer
je = je.save().submit()
# Reconcile the first half
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
difference_amount = pr.calculate_difference_on_allocation_change(
[x.as_dict() for x in pr.payments], [x.as_dict() for x in pr.invoices], 1
)
pr.allocation[0].allocated_amount = 1
pr.allocation[0].difference_amount = difference_amount
pr.reconcile()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
# There should be outstanding in both currencies
si.reload()
self.assertEqual(si.outstanding_amount, 1)
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
# Exchange Gain/Loss Journal should've been created.
exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name]
exc_je_for_je = self.get_journals_for(je.doctype, je.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_je), 1)
self.assertIn(exc_je_for_je[0], exc_je_for_si)
# reconcile remaining half
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
pr.allocation[0].allocated_amount = 1
pr.allocation[0].difference_amount = difference_amount
pr.reconcile()
self.assertEqual(len(pr.invoices), 0)
self.assertEqual(len(pr.payments), 0)
# Exchange Gain/Loss Journal should've been created.
exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name]
exc_je_for_je = self.get_journals_for(je.doctype, je.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 2)
self.assertEqual(len(exc_je_for_je), 2)
self.assertIn(exc_je_for_je[0], exc_je_for_si)
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0)
# Cancel Payment
je.reload()
je.cancel()
si.reload()
self.assertEqual(si.outstanding_amount, 2)
self.assert_ledger_outstanding(si.doctype, si.name, 160.0, 2.0)
# Exchange Gain/Loss Journal should've been cancelled
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_je = self.get_journals_for(je.doctype, je.name)
self.assertEqual(exc_je_for_si, [])
self.assertEqual(exc_je_for_je, [])
def test_30_cr_note_against_sales_invoice(self):
"""
Reconciling Cr Note against Sales Invoice, both having different exchange rates
"""
# Invoice in Foreign currency
si = self.create_sales_invoice(qty=2, conversion_rate=80, rate=1)
# Cr Note in Foreign currency of different exchange rate
cr_note = self.create_sales_invoice(qty=-2, conversion_rate=75, rate=1, do_not_save=True)
cr_note.is_return = 1
cr_note.save().submit()
# Reconcile the first half
pr = self.create_payment_reconciliation()
pr.get_unreconciled_entries()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
invoices = [x.as_dict() for x in pr.invoices]
payments = [x.as_dict() for x in pr.payments]
pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments}))
difference_amount = pr.calculate_difference_on_allocation_change(
[x.as_dict() for x in pr.payments], [x.as_dict() for x in pr.invoices], 1
)
pr.allocation[0].allocated_amount = 1
pr.allocation[0].difference_amount = difference_amount
pr.reconcile()
self.assertEqual(len(pr.invoices), 1)
self.assertEqual(len(pr.payments), 1)
# Exchange Gain/Loss Journal should've been created.
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 2)
self.assertEqual(len(exc_je_for_cr), 2)
self.assertEqual(exc_je_for_cr, exc_je_for_si)
si.reload()
self.assertEqual(si.outstanding_amount, 1)
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)
cr_note.reload()
cr_note.cancel()
# Exchange Gain/Loss Journal should've been created.
exc_je_for_si = self.get_journals_for(si.doctype, si.name)
exc_je_for_cr = self.get_journals_for(cr_note.doctype, cr_note.name)
self.assertNotEqual(exc_je_for_si, [])
self.assertEqual(len(exc_je_for_si), 1)
self.assertEqual(len(exc_je_for_cr), 0)
# The Credit Note JE is still active and is referencing the sales invoice
# So, outstanding stays the same
si.reload()
self.assertEqual(si.outstanding_amount, 1)
self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0)

View File

@ -182,7 +182,7 @@ class Lead(SellingController, CRMNote):
"last_name": self.last_name, "last_name": self.last_name,
"salutation": self.salutation, "salutation": self.salutation,
"gender": self.gender, "gender": self.gender,
"job_title": self.job_title, "designation": self.job_title,
"company_name": self.company_name, "company_name": self.company_name,
} }
) )

View File

@ -439,7 +439,6 @@ scheduler_events = {
"erpnext.controllers.accounts_controller.update_invoice_status", "erpnext.controllers.accounts_controller.update_invoice_status",
"erpnext.accounts.doctype.fiscal_year.fiscal_year.auto_create_fiscal_year", "erpnext.accounts.doctype.fiscal_year.fiscal_year.auto_create_fiscal_year",
"erpnext.projects.doctype.task.task.set_tasks_as_overdue", "erpnext.projects.doctype.task.task.set_tasks_as_overdue",
"erpnext.assets.doctype.asset.depreciation.post_depreciation_entries",
"erpnext.stock.doctype.serial_no.serial_no.update_maintenance_status", "erpnext.stock.doctype.serial_no.serial_no.update_maintenance_status",
"erpnext.buying.doctype.supplier_scorecard.supplier_scorecard.refresh_scorecards", "erpnext.buying.doctype.supplier_scorecard.supplier_scorecard.refresh_scorecards",
"erpnext.setup.doctype.company.company.cache_companies_monthly_sales_history", "erpnext.setup.doctype.company.company.cache_companies_monthly_sales_history",
@ -464,6 +463,7 @@ scheduler_events = {
"erpnext.setup.doctype.email_digest.email_digest.send", "erpnext.setup.doctype.email_digest.email_digest.send",
"erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms", "erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool.auto_update_latest_price_in_all_boms",
"erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event",
"erpnext.assets.doctype.asset.depreciation.post_depreciation_entries",
], ],
"monthly_long": [ "monthly_long": [
"erpnext.accounts.deferred_revenue.process_deferred_accounting", "erpnext.accounts.deferred_revenue.process_deferred_accounting",

View File

@ -114,7 +114,7 @@ class Workstation(Document):
if schedule_date in tuple(get_holidays(self.holiday_list)): if schedule_date in tuple(get_holidays(self.holiday_list)):
schedule_date = add_days(schedule_date, 1) schedule_date = add_days(schedule_date, 1)
self.validate_workstation_holiday(schedule_date, skip_holiday_list_check=True) return self.validate_workstation_holiday(schedule_date, skip_holiday_list_check=True)
return schedule_date return schedule_date

View File

@ -320,6 +320,8 @@ erpnext.patches.v15_0.update_gpa_and_ndb_for_assdeprsch
erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance erpnext.patches.v14_0.create_accounting_dimensions_for_closing_balance
erpnext.patches.v14_0.update_closing_balances #14-07-2023 erpnext.patches.v14_0.update_closing_balances #14-07-2023
execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0) execute:frappe.db.set_single_value("Accounts Settings", "merge_similar_account_heads", 0)
erpnext.patches.v14_0.update_reference_type_in_journal_entry_accounts
erpnext.patches.v14_0.update_subscription_details
# below migration patches should always run last # below migration patches should always run last
erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.patches.v14_0.migrate_gl_to_payment_ledger
execute:frappe.delete_doc_if_exists("Report", "Tax Detail") execute:frappe.delete_doc_if_exists("Report", "Tax Detail")

View File

@ -0,0 +1,22 @@
import frappe
def execute():
"""
Update Propery Setters for Journal Entry with new 'Entry Type'
"""
new_reference_type = "Payment Entry"
prop_setter = frappe.db.get_list(
"Property Setter",
filters={
"doc_type": "Journal Entry Account",
"field_name": "reference_type",
"property": "options",
},
)
if prop_setter:
property_setter_doc = frappe.get_doc("Property Setter", prop_setter[0].get("name"))
if new_reference_type not in property_setter_doc.value.split("\n"):
property_setter_doc.value += "\n" + new_reference_type
property_setter_doc.save()

View File

@ -0,0 +1,17 @@
import frappe
def execute():
subscription_invoices = frappe.get_all(
"Subscription Invoice", fields=["document_type", "invoice", "parent"]
)
for subscription_invoice in subscription_invoices:
frappe.db.set_value(
subscription_invoice.document_type,
subscription_invoice.invoice,
"subscription",
subscription_invoice.parent,
)
frappe.delete_doc_if_exists("DocType", "Subscription Invoice")

View File

@ -24,12 +24,14 @@ erpnext.setup.slides_settings = [
fieldtype: 'Data', fieldtype: 'Data',
reqd: 1 reqd: 1
}, },
{ fieldtype: "Column Break" },
{ {
fieldname: 'company_abbr', fieldname: 'company_abbr',
label: __('Company Abbreviation'), label: __('Company Abbreviation'),
fieldtype: 'Data', fieldtype: 'Data',
hidden: 1 reqd: 1
}, },
{ fieldtype: "Section Break" },
{ {
fieldname: 'chart_of_accounts', label: __('Chart of Accounts'), fieldname: 'chart_of_accounts', label: __('Chart of Accounts'),
options: "", fieldtype: 'Select' options: "", fieldtype: 'Select'
@ -135,18 +137,20 @@ erpnext.setup.slides_settings = [
me.charts_modal(slide, chart_template); me.charts_modal(slide, chart_template);
}); });
slide.get_input("company_name").on("change", function () { slide.get_input("company_name").on("input", function () {
let parts = slide.get_input("company_name").val().split(" "); let parts = slide.get_input("company_name").val().split(" ");
let abbr = $.map(parts, function (p) { return p ? p.substr(0, 1) : null }).join(""); let abbr = $.map(parts, function (p) { return p ? p.substr(0, 1) : null }).join("");
slide.get_field("company_abbr").set_value(abbr.slice(0, 10).toUpperCase()); slide.get_field("company_abbr").set_value(abbr.slice(0, 10).toUpperCase());
}).val(frappe.boot.sysdefaults.company_name || "").trigger("change"); }).val(frappe.boot.sysdefaults.company_name || "").trigger("change");
slide.get_input("company_abbr").on("change", function () { slide.get_input("company_abbr").on("change", function () {
if (slide.get_input("company_abbr").val().length > 10) { let abbr = slide.get_input("company_abbr").val();
if (abbr.length > 10) {
frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters")); frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters"));
slide.get_field("company_abbr").set_value(""); abbr = abbr.slice(0, 10);
} }
}); slide.get_field("company_abbr").set_value(abbr);
}).val(frappe.boot.sysdefaults.company_abbr || "").trigger("change");
}, },
charts_modal: function(slide, chart_template) { charts_modal: function(slide, chart_template) {

View File

@ -62,10 +62,10 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"fetch_from": "process_owner.full_name", "fetch_from": "procedure.process_owner_full_name",
"fieldname": "full_name", "fieldname": "full_name",
"fieldtype": "Data", "fieldtype": "Data",
"hidden": 1, "read_only": 1,
"label": "Full Name" "label": "Full Name"
}, },
{ {
@ -81,7 +81,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-02-26 15:27:47.247814", "modified": "2023-07-31 08:10:47.247814",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Quality Management", "module": "Quality Management",
"name": "Non Conformance", "name": "Non Conformance",

View File

@ -56,6 +56,7 @@
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"default": "Open",
"columns": 2, "columns": 2,
"fieldname": "status", "fieldname": "status",
"fieldtype": "Select", "fieldtype": "Select",
@ -67,7 +68,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-10-27 16:28:20.908637", "modified": "2023-07-31 09:20:20.908637",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Quality Management", "module": "Quality Management",
"name": "Quality Review Objective", "name": "Quality Review Objective",
@ -76,4 +77,4 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1 "track_changes": 1
} }

View File

@ -34,6 +34,7 @@ class LowerDeductionCertificate(Document):
"supplier": self.supplier, "supplier": self.supplier,
"tax_withholding_category": self.tax_withholding_category, "tax_withholding_category": self.tax_withholding_category,
"name": ("!=", self.name), "name": ("!=", self.name),
"company": self.company,
}, },
["name", "valid_from", "valid_upto"], ["name", "valid_from", "valid_upto"],
as_dict=True, as_dict=True,

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