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.