refactor: move Sales Invoice GL row builders onto the composer

Relocates all 11 Sales Invoice-specific GL entry builders from the document
onto SalesInvoiceGLComposer, operating on self.doc. The perpetual-inventory
super().get_gl_entries() call becomes super(SalesInvoice, doc).get_gl_entries().
Shared bucket-A helpers (get_gl_dict, make_discount_gl_entries, etc.) remain on
AccountsController for now, invoked via self.doc, until all doctypes use a
composer. No behavior change: Phase 0 snapshots and the SI tests covering
perpetual inventory, POS, write-off, returns, fixed assets, internal transfer
and loyalty all stay green.
This commit is contained in:
Nabin Hait
2026-05-27 01:24:56 +05:30
parent cf1817c1ea
commit b5c96dfef0
3 changed files with 521 additions and 498 deletions

View File

@@ -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}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> 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

View File

@@ -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}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> 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,
)
)

View File

@@ -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.