diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 9495fd8202e..58f612a5d64 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1580,250 +1580,6 @@ class SalesInvoice(SellingController): return SalesInvoiceGLComposer(self).compose(inventory_account_map) - def stock_delivered_but_not_billed_gl_entries(self, gl_entries): - if self.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(self.company)): - return - - for item in self.get("items"): - if not item.delivery_note and not item.dn_detail: - continue - - if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"): - continue - - dn_expense_account = frappe.get_cached_value( - "Delivery Note Item", item.dn_detail, "expense_account" - ) - if ( - not dn_expense_account - or frappe.get_cached_value("Account", dn_expense_account, "account_type") - != "Stock Delivered But Not Billed" - or not item.expense_account - or dn_expense_account == item.expense_account - ): - continue - - delivery_note = item.delivery_note or frappe.get_cached_value( - "Delivery Note Item", item.dn_detail, "parent" - ) - if not delivery_note: - continue - - item_g = frappe.get_cached_value( - "Stock Ledger Entry", - { - "voucher_no": delivery_note, - "voucher_detail_no": item.dn_detail, - "item_code": item.item_code, - "is_cancelled": 0, - }, - ["stock_value_difference", "actual_qty"], - as_dict=True, - ) - - if not item_g or not flt(item_g.actual_qty): - continue - valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty) - valuation_amount = valuation_rate * item.stock_qty - dn_account_currency = get_account_currency(dn_expense_account) - item_account_currency = get_account_currency(item.expense_account) - - gl_entries.append( - self.get_gl_dict( - { - "account": dn_expense_account, - "against": item.expense_account, - "credit": flt(valuation_amount), - "credit_in_account_currency": flt(valuation_amount), - "cost_center": item.cost_center, - }, - dn_account_currency, - item=item, - ) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": item.expense_account, - "against": dn_expense_account, - "debit": flt(valuation_amount), - "debit_in_account_currency": flt(valuation_amount), - "cost_center": item.cost_center, - }, - item_account_currency, - item=item, - ) - ) - - def make_customer_gl_entry(self, gl_entries): - # Checked both rounding_adjustment and rounded_total - # because rounded_total had value even before introduction of posting GLE based on rounded total - grand_total = ( - self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total - ) - base_grand_total = flt( - self.base_rounded_total - if (self.base_rounding_adjustment and self.base_rounded_total) - else self.base_grand_total, - self.precision("base_grand_total"), - ) - - if grand_total and not self.is_internal_transfer(): - against_voucher = self.name - if self.is_return and self.return_against and not self.update_outstanding_for_self: - against_voucher = self.return_against - - # Did not use base_grand_total to book rounding loss gle - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "due_date": self.due_date, - "against": self.against_income_account, - "debit": base_grand_total, - "debit_in_account_currency": base_grand_total - if self.party_account_currency == self.company_currency - else grand_total, - "debit_in_transaction_currency": grand_total, - "against_voucher": against_voucher, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ) - ) - - def make_tax_gl_entries(self, gl_entries): - enable_discount_accounting = cint( - frappe.get_single_value("Selling Settings", "enable_discount_accounting") - ) - - for tax in self.get("taxes"): - amount, base_amount = self.get_tax_amounts(tax, enable_discount_accounting) - - if flt(tax.base_tax_amount_after_discount_amount): - account_currency = get_account_currency(tax.account_head) - gl_entries.append( - self.get_gl_dict( - { - "account": tax.account_head, - "against": self.customer, - "credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")), - "credit_in_account_currency": ( - flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")) - if account_currency == self.company_currency - else flt(amount, tax.precision("tax_amount_after_discount_amount")) - ), - "credit_in_transaction_currency": flt( - amount, tax.precision("tax_amount_after_discount_amount") - ), - "cost_center": tax.cost_center, - }, - account_currency, - item=tax, - ) - ) - - def make_internal_transfer_gl_entries(self, gl_entries): - if self.is_internal_transfer() and flt(self.base_total_taxes_and_charges): - account_currency = get_account_currency(self.unrealized_profit_loss_account) - gl_entries.append( - self.get_gl_dict( - { - "account": self.unrealized_profit_loss_account, - "against": self.customer, - "debit": flt(self.total_taxes_and_charges), - "debit_in_account_currency": flt(self.base_total_taxes_and_charges), - "debit_in_transaction_currency": flt(self.total_taxes_and_charges), - "cost_center": self.cost_center, - }, - account_currency, - item=self, - ) - ) - - def make_item_gl_entries(self, gl_entries): - # income account gl entries - enable_discount_accounting = cint( - frappe.get_single_value("Selling Settings", "enable_discount_accounting") - ) - - for item in self.get("items"): - if ( - flt(item.base_net_amount, item.precision("base_net_amount")) - or item.is_fixed_asset - or enable_discount_accounting - ): - # Do not book income for transfer within same company - if self.is_internal_transfer(): - continue - - if item.is_fixed_asset and item.asset: - self.get_gl_entries_for_fixed_asset(item, gl_entries) - else: - income_account = ( - item.income_account - if (not item.enable_deferred_revenue or self.is_return) - else item.deferred_revenue_account - ) - - amount, base_amount = self.get_amount_and_base_amount(item, enable_discount_accounting) - - account_currency = get_account_currency(income_account) - gl_entries.append( - self.get_gl_dict( - { - "account": income_account, - "against": self.customer, - "credit": flt(base_amount, item.precision("base_net_amount")), - "credit_in_account_currency": ( - flt(base_amount, item.precision("base_net_amount")) - if account_currency == self.company_currency - else flt(amount, item.precision("net_amount")) - ), - "credit_in_transaction_currency": flt(amount, item.precision("net_amount")), - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) - ) - - # expense account gl entries - if cint(self.update_stock) and erpnext.is_perpetual_inventory_enabled(self.company): - gl_entries += super().get_gl_entries() - - def get_gl_entries_for_fixed_asset(self, item, gl_entries): - asset = frappe.get_cached_doc("Asset", item.asset) - - if self.is_return: - fixed_asset_gl_entries = get_gl_entries_on_asset_regain( - asset, - item.base_net_amount, - item.finance_book, - self.get("doctype"), - self.get("name"), - self.get("posting_date"), - ) - else: - fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( - asset, - item.base_net_amount, - item.finance_book, - self.get("doctype"), - self.get("name"), - self.get("posting_date"), - ) - - for gle in fixed_asset_gl_entries: - gle["against"] = self.customer - gl_entries.append(self.get_gl_dict(gle, item=item)) - @property def enable_discount_accounting(self): if not hasattr(self, "_enable_discount_accounting"): @@ -1833,246 +1589,6 @@ class SalesInvoice(SellingController): return self._enable_discount_accounting - def make_loyalty_point_redemption_gle(self, gl_entries): - if cint(self.redeem_loyalty_points and self.loyalty_points and not self.is_consolidated): - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": "Expense account - " - + cstr(self.loyalty_redemption_account) - + " for the Loyalty Program", - "credit": self.loyalty_amount, - "credit_in_transaction_currency": self.loyalty_amount, - "against_voucher": self.return_against if cint(self.is_return) else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - }, - item=self, - ) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": self.loyalty_redemption_account, - "cost_center": self.cost_center or self.loyalty_redemption_cost_center, - "against": self.customer, - "debit": self.loyalty_amount, - "debit_in_transaction_currency": self.loyalty_amount, - "remark": "Loyalty Points redeemed by the customer", - }, - item=self, - ) - ) - - def make_pos_gl_entries(self, gl_entries): - if cint(self.is_pos): - skip_change_gl_entries = not cint( - frappe.get_single_value("POS Settings", "post_change_gl_entries") - ) - - for payment_mode in self.payments: - if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount: - payment_mode.base_amount -= flt(self.change_amount) - - against_voucher = self.name - if self.is_return and self.return_against and not self.update_outstanding_for_self: - against_voucher = self.return_against - - if payment_mode.base_amount: - # POS, make payment entries - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": payment_mode.account, - "credit": payment_mode.base_amount, - "credit_in_account_currency": payment_mode.base_amount - if self.party_account_currency == self.company_currency - else payment_mode.amount, - "credit_in_transaction_currency": payment_mode.amount, - "against_voucher": against_voucher, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - }, - self.party_account_currency, - item=self, - ) - ) - - payment_mode_account_currency = get_account_currency(payment_mode.account) - gl_entries.append( - self.get_gl_dict( - { - "account": payment_mode.account, - "against": self.customer, - "debit": payment_mode.base_amount, - "debit_in_account_currency": payment_mode.base_amount - if payment_mode_account_currency == self.company_currency - else payment_mode.amount, - "debit_in_transaction_currency": payment_mode.amount, - "cost_center": self.cost_center, - }, - payment_mode_account_currency, - item=self, - ) - ) - - if not skip_change_gl_entries: - gl_entries.extend(self.get_gle_for_change_amount()) - - def get_gle_for_change_amount(self) -> list[dict]: - if not self.change_amount: - return [] - - if not self.account_for_change_amount: - frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field")) - - return [ - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": self.account_for_change_amount, - "debit": flt(self.base_change_amount), - "debit_in_account_currency": flt(self.base_change_amount) - if self.party_account_currency == self.company_currency - else flt(self.change_amount), - "debit_in_transaction_currency": flt(self.change_amount), - "against_voucher": self.return_against - if cint(self.is_return) and self.return_against - else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ), - self.get_gl_dict( - { - "account": self.account_for_change_amount, - "against": self.customer, - "credit": self.base_change_amount, - "credit_in_transaction_currency": self.change_amount, - "cost_center": self.cost_center, - }, - item=self, - ), - ] - - def make_write_off_gl_entry(self, gl_entries): - # write off entries, applicable if only pos - if ( - self.is_pos - and self.write_off_account - and flt(self.write_off_amount, self.precision("write_off_amount")) - ): - write_off_account_currency = get_account_currency(self.write_off_account) - default_cost_center = frappe.get_cached_value("Company", self.company, "cost_center") - - gl_entries.append( - self.get_gl_dict( - { - "account": self.debit_to, - "party_type": "Customer", - "party": self.customer, - "against": self.write_off_account, - "credit": flt(self.base_write_off_amount, self.precision("base_write_off_amount")), - "credit_in_account_currency": ( - flt(self.base_write_off_amount, self.precision("base_write_off_amount")) - if self.party_account_currency == self.company_currency - else flt(self.write_off_amount, self.precision("write_off_amount")) - ), - "credit_in_transaction_currency": flt( - self.write_off_amount, self.precision("write_off_amount") - ), - "against_voucher": self.return_against if cint(self.is_return) else self.name, - "against_voucher_type": self.doctype, - "cost_center": self.cost_center, - "project": self.project, - }, - self.party_account_currency, - item=self, - ) - ) - gl_entries.append( - self.get_gl_dict( - { - "account": self.write_off_account, - "against": self.customer, - "debit": flt(self.base_write_off_amount, self.precision("base_write_off_amount")), - "debit_in_account_currency": ( - flt(self.base_write_off_amount, self.precision("base_write_off_amount")) - if write_off_account_currency == self.company_currency - else flt(self.write_off_amount, self.precision("write_off_amount")) - ), - "debit_in_transaction_currency": flt( - self.write_off_amount, self.precision("write_off_amount") - ), - "cost_center": self.cost_center or self.write_off_cost_center or default_cost_center, - }, - write_off_account_currency, - item=self, - ) - ) - - def make_gle_for_rounding_adjustment(self, gl_entries): - if ( - flt(self.rounding_adjustment, self.precision("rounding_adjustment")) - and self.base_rounding_adjustment - and not self.is_internal_transfer() - ): - ( - round_off_account, - round_off_cost_center, - round_off_for_opening, - ) = get_round_off_account_and_cost_center( - self.company, "Sales Invoice", self.name, self.use_company_roundoff_cost_center - ) - - if self.is_opening == "Yes" and self.rounding_adjustment: - if not round_off_for_opening: - frappe.throw( - _( - "Opening Invoice has rounding adjustment of {0}.

'{1}' account is required to post these values. Please set it in Company: {2}.

Or, '{3}' can be enabled to not post any rounding adjustment." - ).format( - frappe.bold(self.rounding_adjustment), - frappe.bold("Round Off for Opening"), - get_link_to_form("Company", self.company), - frappe.bold("Disable Rounded Total"), - ) - ) - else: - round_off_account = round_off_for_opening - - gl_entries.append( - self.get_gl_dict( - { - "account": round_off_account, - "against": self.customer, - "credit_in_account_currency": flt( - self.rounding_adjustment, self.precision("rounding_adjustment") - ), - "credit_in_transaction_currency": flt( - self.rounding_adjustment, self.precision("rounding_adjustment") - ), - "credit": flt( - self.base_rounding_adjustment, self.precision("base_rounding_adjustment") - ), - "cost_center": round_off_cost_center - if self.use_company_roundoff_cost_center - else (self.cost_center or round_off_cost_center), - }, - item=self, - ) - ) - def update_billing_status_in_dn(self, update_modified=True): if self.is_return and not self.update_billed_amount_in_delivery_note: return diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py index df6e58a6ee7..21c00d28da2 100644 --- a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py +++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py @@ -2,16 +2,26 @@ # License: GNU General Public License v3. See license.txt import frappe +from frappe import _ +from frappe.utils import cint, cstr, flt, get_link_to_form +import erpnext +from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center from erpnext.accounts.services.base_gl_composer import BaseGLComposer +from erpnext.accounts.utils import get_account_currency +from erpnext.assets.doctype.asset.depreciation import ( + get_gl_entries_on_asset_disposal, + get_gl_entries_on_asset_regain, +) class SalesInvoiceGLComposer(BaseGLComposer): """Assembles the GL entries for a Sales Invoice. - Orchestration only for now: the voucher-specific row builders still live on - the Sales Invoice document and are invoked via ``self.doc``. They migrate - onto this composer in a later increment. + The voucher-specific row builders live here and operate on ``self.doc``. + Shared helpers (get_gl_dict, make_discount_gl_entries, make_precision_loss_gl_entry, + set_transaction_currency_and_rate_in_gl_map, get_tax_amounts, get_amount_and_base_amount) + remain on the document for now and are invoked via ``self.doc``. """ def compose(self, inventory_account_map=None): @@ -21,17 +31,17 @@ class SalesInvoiceGLComposer(BaseGLComposer): doc = self.doc gl_entries = [] - doc.make_customer_gl_entry(gl_entries) + self.make_customer_gl_entry(gl_entries) - doc.make_tax_gl_entries(gl_entries) - doc.make_internal_transfer_gl_entries(gl_entries) + self.make_tax_gl_entries(gl_entries) + self.make_internal_transfer_gl_entries(gl_entries) - doc.make_item_gl_entries(gl_entries) + self.make_item_gl_entries(gl_entries) disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr") if not (doc.is_return and disable_sdbnb_in_sr): - doc.stock_delivered_but_not_billed_gl_entries(gl_entries) + self.stock_delivered_but_not_billed_gl_entries(gl_entries) doc.make_precision_loss_gl_entry(gl_entries) doc.make_discount_gl_entries(gl_entries) @@ -41,11 +51,508 @@ class SalesInvoiceGLComposer(BaseGLComposer): # merge gl entries before adding pos entries gl_entries = merge_similar_entries(gl_entries) - doc.make_loyalty_point_redemption_gle(gl_entries) - doc.make_pos_gl_entries(gl_entries) + self.make_loyalty_point_redemption_gle(gl_entries) + self.make_pos_gl_entries(gl_entries) - doc.make_write_off_gl_entry(gl_entries) - doc.make_gle_for_rounding_adjustment(gl_entries) + self.make_write_off_gl_entry(gl_entries) + self.make_gle_for_rounding_adjustment(gl_entries) doc.set_transaction_currency_and_rate_in_gl_map(gl_entries) return gl_entries + + def stock_delivered_but_not_billed_gl_entries(self, gl_entries): + doc = self.doc + if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)): + return + + for item in doc.get("items"): + if not item.delivery_note and not item.dn_detail: + continue + + if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"): + continue + + dn_expense_account = frappe.get_cached_value( + "Delivery Note Item", item.dn_detail, "expense_account" + ) + if ( + not dn_expense_account + or frappe.get_cached_value("Account", dn_expense_account, "account_type") + != "Stock Delivered But Not Billed" + or not item.expense_account + or dn_expense_account == item.expense_account + ): + continue + + delivery_note = item.delivery_note or frappe.get_cached_value( + "Delivery Note Item", item.dn_detail, "parent" + ) + if not delivery_note: + continue + + item_g = frappe.get_cached_value( + "Stock Ledger Entry", + { + "voucher_no": delivery_note, + "voucher_detail_no": item.dn_detail, + "item_code": item.item_code, + "is_cancelled": 0, + }, + ["stock_value_difference", "actual_qty"], + as_dict=True, + ) + + if not item_g or not flt(item_g.actual_qty): + continue + valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty) + valuation_amount = valuation_rate * item.stock_qty + dn_account_currency = get_account_currency(dn_expense_account) + item_account_currency = get_account_currency(item.expense_account) + + gl_entries.append( + doc.get_gl_dict( + { + "account": dn_expense_account, + "against": item.expense_account, + "credit": flt(valuation_amount), + "credit_in_account_currency": flt(valuation_amount), + "cost_center": item.cost_center, + }, + dn_account_currency, + item=item, + ) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": item.expense_account, + "against": dn_expense_account, + "debit": flt(valuation_amount), + "debit_in_account_currency": flt(valuation_amount), + "cost_center": item.cost_center, + }, + item_account_currency, + item=item, + ) + ) + + def make_customer_gl_entry(self, gl_entries): + doc = self.doc + # Checked both rounding_adjustment and rounded_total + # because rounded_total had value even before introduction of posting GLE based on rounded total + grand_total = ( + doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total + ) + base_grand_total = flt( + doc.base_rounded_total + if (doc.base_rounding_adjustment and doc.base_rounded_total) + else doc.base_grand_total, + doc.precision("base_grand_total"), + ) + + if grand_total and not doc.is_internal_transfer(): + against_voucher = doc.name + if doc.is_return and doc.return_against and not doc.update_outstanding_for_self: + against_voucher = doc.return_against + + # Did not use base_grand_total to book rounding loss gle + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "due_date": doc.due_date, + "against": doc.against_income_account, + "debit": base_grand_total, + "debit_in_account_currency": base_grand_total + if doc.party_account_currency == doc.company_currency + else grand_total, + "debit_in_transaction_currency": grand_total, + "against_voucher": against_voucher, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + "project": doc.project, + }, + doc.party_account_currency, + item=doc, + ) + ) + + def make_tax_gl_entries(self, gl_entries): + doc = self.doc + enable_discount_accounting = cint( + frappe.get_single_value("Selling Settings", "enable_discount_accounting") + ) + + for tax in doc.get("taxes"): + amount, base_amount = doc.get_tax_amounts(tax, enable_discount_accounting) + + if flt(tax.base_tax_amount_after_discount_amount): + account_currency = get_account_currency(tax.account_head) + gl_entries.append( + doc.get_gl_dict( + { + "account": tax.account_head, + "against": doc.customer, + "credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")), + "credit_in_account_currency": ( + flt(base_amount, tax.precision("base_tax_amount_after_discount_amount")) + if account_currency == doc.company_currency + else flt(amount, tax.precision("tax_amount_after_discount_amount")) + ), + "credit_in_transaction_currency": flt( + amount, tax.precision("tax_amount_after_discount_amount") + ), + "cost_center": tax.cost_center, + }, + account_currency, + item=tax, + ) + ) + + def make_internal_transfer_gl_entries(self, gl_entries): + doc = self.doc + if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges): + account_currency = get_account_currency(doc.unrealized_profit_loss_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.unrealized_profit_loss_account, + "against": doc.customer, + "debit": flt(doc.total_taxes_and_charges), + "debit_in_account_currency": flt(doc.base_total_taxes_and_charges), + "debit_in_transaction_currency": flt(doc.total_taxes_and_charges), + "cost_center": doc.cost_center, + }, + account_currency, + item=doc, + ) + ) + + def make_item_gl_entries(self, gl_entries): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice + + doc = self.doc + # income account gl entries + enable_discount_accounting = cint( + frappe.get_single_value("Selling Settings", "enable_discount_accounting") + ) + + for item in doc.get("items"): + if ( + flt(item.base_net_amount, item.precision("base_net_amount")) + or item.is_fixed_asset + or enable_discount_accounting + ): + # Do not book income for transfer within same company + if doc.is_internal_transfer(): + continue + + if item.is_fixed_asset and item.asset: + self.get_gl_entries_for_fixed_asset(item, gl_entries) + else: + income_account = ( + item.income_account + if (not item.enable_deferred_revenue or doc.is_return) + else item.deferred_revenue_account + ) + + amount, base_amount = doc.get_amount_and_base_amount(item, enable_discount_accounting) + + account_currency = get_account_currency(income_account) + gl_entries.append( + doc.get_gl_dict( + { + "account": income_account, + "against": doc.customer, + "credit": flt(base_amount, item.precision("base_net_amount")), + "credit_in_account_currency": ( + flt(base_amount, item.precision("base_net_amount")) + if account_currency == doc.company_currency + else flt(amount, item.precision("net_amount")) + ), + "credit_in_transaction_currency": flt(amount, item.precision("net_amount")), + "cost_center": item.cost_center, + "project": item.project or doc.project, + }, + account_currency, + item=item, + ) + ) + + # expense account gl entries + if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company): + gl_entries += super(SalesInvoice, doc).get_gl_entries() + + def get_gl_entries_for_fixed_asset(self, item, gl_entries): + doc = self.doc + asset = frappe.get_cached_doc("Asset", item.asset) + + if doc.is_return: + fixed_asset_gl_entries = get_gl_entries_on_asset_regain( + asset, + item.base_net_amount, + item.finance_book, + doc.get("doctype"), + doc.get("name"), + doc.get("posting_date"), + ) + else: + fixed_asset_gl_entries = get_gl_entries_on_asset_disposal( + asset, + item.base_net_amount, + item.finance_book, + doc.get("doctype"), + doc.get("name"), + doc.get("posting_date"), + ) + + for gle in fixed_asset_gl_entries: + gle["against"] = doc.customer + gl_entries.append(doc.get_gl_dict(gle, item=item)) + + def make_loyalty_point_redemption_gle(self, gl_entries): + doc = self.doc + if cint(doc.redeem_loyalty_points and doc.loyalty_points and not doc.is_consolidated): + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "against": "Expense account - " + + cstr(doc.loyalty_redemption_account) + + " for the Loyalty Program", + "credit": doc.loyalty_amount, + "credit_in_transaction_currency": doc.loyalty_amount, + "against_voucher": doc.return_against if cint(doc.is_return) else doc.name, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + }, + item=doc, + ) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.loyalty_redemption_account, + "cost_center": doc.cost_center or doc.loyalty_redemption_cost_center, + "against": doc.customer, + "debit": doc.loyalty_amount, + "debit_in_transaction_currency": doc.loyalty_amount, + "remark": "Loyalty Points redeemed by the customer", + }, + item=doc, + ) + ) + + def make_pos_gl_entries(self, gl_entries): + doc = self.doc + if cint(doc.is_pos): + skip_change_gl_entries = not cint( + frappe.get_single_value("POS Settings", "post_change_gl_entries") + ) + + for payment_mode in doc.payments: + if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount: + payment_mode.base_amount -= flt(doc.change_amount) + + against_voucher = doc.name + if doc.is_return and doc.return_against and not doc.update_outstanding_for_self: + against_voucher = doc.return_against + + if payment_mode.base_amount: + # POS, make payment entries + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "against": payment_mode.account, + "credit": payment_mode.base_amount, + "credit_in_account_currency": payment_mode.base_amount + if doc.party_account_currency == doc.company_currency + else payment_mode.amount, + "credit_in_transaction_currency": payment_mode.amount, + "against_voucher": against_voucher, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + }, + doc.party_account_currency, + item=doc, + ) + ) + + payment_mode_account_currency = get_account_currency(payment_mode.account) + gl_entries.append( + doc.get_gl_dict( + { + "account": payment_mode.account, + "against": doc.customer, + "debit": payment_mode.base_amount, + "debit_in_account_currency": payment_mode.base_amount + if payment_mode_account_currency == doc.company_currency + else payment_mode.amount, + "debit_in_transaction_currency": payment_mode.amount, + "cost_center": doc.cost_center, + }, + payment_mode_account_currency, + item=doc, + ) + ) + + if not skip_change_gl_entries: + gl_entries.extend(self.get_gle_for_change_amount()) + + def get_gle_for_change_amount(self) -> list[dict]: + doc = self.doc + if not doc.change_amount: + return [] + + if not doc.account_for_change_amount: + frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field")) + + return [ + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "against": doc.account_for_change_amount, + "debit": flt(doc.base_change_amount), + "debit_in_account_currency": flt(doc.base_change_amount) + if doc.party_account_currency == doc.company_currency + else flt(doc.change_amount), + "debit_in_transaction_currency": flt(doc.change_amount), + "against_voucher": doc.return_against + if cint(doc.is_return) and doc.return_against + else doc.name, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + "project": doc.project, + }, + doc.party_account_currency, + item=doc, + ), + doc.get_gl_dict( + { + "account": doc.account_for_change_amount, + "against": doc.customer, + "credit": doc.base_change_amount, + "credit_in_transaction_currency": doc.change_amount, + "cost_center": doc.cost_center, + }, + item=doc, + ), + ] + + def make_write_off_gl_entry(self, gl_entries): + doc = self.doc + # write off entries, applicable if only pos + if ( + doc.is_pos + and doc.write_off_account + and flt(doc.write_off_amount, doc.precision("write_off_amount")) + ): + write_off_account_currency = get_account_currency(doc.write_off_account) + default_cost_center = frappe.get_cached_value("Company", doc.company, "cost_center") + + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.debit_to, + "party_type": "Customer", + "party": doc.customer, + "against": doc.write_off_account, + "credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")), + "credit_in_account_currency": ( + flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")) + if doc.party_account_currency == doc.company_currency + else flt(doc.write_off_amount, doc.precision("write_off_amount")) + ), + "credit_in_transaction_currency": flt( + doc.write_off_amount, doc.precision("write_off_amount") + ), + "against_voucher": doc.return_against if cint(doc.is_return) else doc.name, + "against_voucher_type": doc.doctype, + "cost_center": doc.cost_center, + "project": doc.project, + }, + doc.party_account_currency, + item=doc, + ) + ) + gl_entries.append( + doc.get_gl_dict( + { + "account": doc.write_off_account, + "against": doc.customer, + "debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")), + "debit_in_account_currency": ( + flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")) + if write_off_account_currency == doc.company_currency + else flt(doc.write_off_amount, doc.precision("write_off_amount")) + ), + "debit_in_transaction_currency": flt( + doc.write_off_amount, doc.precision("write_off_amount") + ), + "cost_center": doc.cost_center or doc.write_off_cost_center or default_cost_center, + }, + write_off_account_currency, + item=doc, + ) + ) + + def make_gle_for_rounding_adjustment(self, gl_entries): + doc = self.doc + if ( + flt(doc.rounding_adjustment, doc.precision("rounding_adjustment")) + and doc.base_rounding_adjustment + and not doc.is_internal_transfer() + ): + ( + round_off_account, + round_off_cost_center, + round_off_for_opening, + ) = get_round_off_account_and_cost_center( + doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center + ) + + if doc.is_opening == "Yes" and doc.rounding_adjustment: + if not round_off_for_opening: + frappe.throw( + _( + "Opening Invoice has rounding adjustment of {0}.

'{1}' account is required to post these values. Please set it in Company: {2}.

Or, '{3}' can be enabled to not post any rounding adjustment." + ).format( + frappe.bold(doc.rounding_adjustment), + frappe.bold("Round Off for Opening"), + get_link_to_form("Company", doc.company), + frappe.bold("Disable Rounded Total"), + ) + ) + else: + round_off_account = round_off_for_opening + + gl_entries.append( + doc.get_gl_dict( + { + "account": round_off_account, + "against": doc.customer, + "credit_in_account_currency": flt( + doc.rounding_adjustment, doc.precision("rounding_adjustment") + ), + "credit_in_transaction_currency": flt( + doc.rounding_adjustment, doc.precision("rounding_adjustment") + ), + "credit": flt( + doc.base_rounding_adjustment, doc.precision("base_rounding_adjustment") + ), + "cost_center": round_off_cost_center + if doc.use_company_roundoff_cost_center + else (doc.cost_center or round_off_cost_center), + }, + item=doc, + ) + ) diff --git a/specs/accounts_refactor_spec.md b/specs/accounts_refactor_spec.md index 099a4119933..4922e1bbd22 100644 --- a/specs/accounts_refactor_spec.md +++ b/specs/accounts_refactor_spec.md @@ -71,8 +71,8 @@ Characterization tests snapshotting `gl_entries` output for representative trans ### Phase 1 — Extract `gl_validator.py` (lowest risk) — DONE Moved the 6 pure list-level validators to `erpnext/accounts/services/gl_validator.py`; `general_ledger.py` imports and calls them at the existing call sites (no behavior change). A consolidated `gl_validator.validate(gl_entries)` facade is deferred — the current checks run at different points (make_gl_entries / save_entries per-entry / make_reverse_gl_entries), so collapsing them into one call would alter ordering. Verified: all 12 Phase-0 snapshots byte-identical. -### Phase 2 — Pilot composer on Sales Invoice only -Create `BaseGLComposer` + `SalesInvoiceGLComposer`; lift bucket-A helpers from `accounts_controller`; move SI's `get_gl_entries` body into `.compose()`; old method becomes a thin shim. Do not over-generalise the base from one example. +### Phase 2 — Pilot composer on Sales Invoice only — DONE +Added `BaseGLComposer` (minimal: holds `self.doc`) and `SalesInvoiceGLComposer`. SI's `get_gl_entries` is a thin shim delegating to `SalesInvoiceGLComposer(self).compose()`. All 11 SI-specific row builders (make_customer/tax/item/internal_transfer/pos/loyalty/write_off/rounding GL entries, stock_delivered_but_not_billed, get_gl_entries_for_fixed_asset, get_gle_for_change_amount) moved onto the composer and operate on `self.doc`. The `super().get_gl_entries()` stock-expense call became `super(SalesInvoice, doc).get_gl_entries()` (MRO-faithful). Bucket-A shared helpers (`get_gl_dict`, `make_discount_gl_entries`, `make_precision_loss_gl_entry`, `set_transaction_currency_and_rate_in_gl_map`, `get_tax_amounts`, `get_amount_and_base_amount`) **stay on the controller** — they're still called via `self.doc` and only lift to `BaseGLComposer` once all doctypes use composers (can't move while other doctypes inherit them). Verified: 12 snapshots + 10 existing SI tests (perpetual `super()`, POS change, write-off, returns, fixed-asset disposal/regain, internal transfer, loyalty) all green. ### Phase 3 — Second doctype: Purchase Invoice (base earns its shape) Add `PurchaseInvoiceGLComposer`; reshape `BaseGLComposer` from what SI + PI *actually* share. Two real consumers is the minimum to size the abstraction — prevents premature abstraction.