extend_doctype_class Patterns (v16)
Problem: Extend Sales Invoice from your custom app to add franchise-specific validation and methods — without conflicting with other apps installed on the same bench.
Solution: Use the v16 v16 extend_doctype_class hook. Unlike the
older override_doctype_class (which let only one app win), extend_doctype_class
merges your methods into the original DocType’s method resolution order, so multiple
apps can extend the same DocType at once. Each extension calls super() to chain with
the original logic and every other app’s extensions.
Step 1: Register the extension in hooks.py
Section titled “Step 1: Register the extension in hooks.py”Map each DocType name to the dotted path of your extension class. The key insight is the verb: this extends rather than overrides, so other apps can map the same DocType safely.
# v16+: extend (not override) — multiple apps can safely extend the same DocTypeextend_doctype_class = { "Sales Invoice": "scoopjoy.overrides.sales_invoice.ScoopJoySalesInvoice", "POS Invoice": "scoopjoy.overrides.pos_invoice.ScoopJoyPOSInvoice", "Stock Entry": "scoopjoy.overrides.stock_entry.ScoopJoyStockEntry",}Step 2: Write the extension class
Section titled “Step 2: Write the extension class”The extension class is a plain class — it does not subclass the DocType. Frappe merges
its methods into the original class chain. New methods are added directly; existing
methods like validate and on_submit are overridden, and you call super() to chain
back into the original Sales Invoice logic plus any other app’s extensions.
import frappefrom frappe import _from frappe.utils import flt
class ScoopJoySalesInvoice: """ Extends Sales Invoice with franchise-specific logic.
In v16, extend_doctype_class merges methods into the original class. - New methods are added directly. - Existing methods can be overridden; call super() to chain with the original. - Multiple apps can extend the same DocType safely. """
# ── Add new validation: franchise credit limit check ─────────── def validate(self): # IMPORTANT: call super() to run the original Sales Invoice validation # plus any validations added by other apps super().validate() self.validate_franchise_credit_limit() self.set_franchise_cost_center()
def validate_franchise_credit_limit(self): """Check if the franchise has exceeded its credit limit.""" if not self.custom_franchise_agreement: return
credit_limit = frappe.db.get_value( "Franchise Agreement", self.custom_franchise_agreement, "credit_limit", )
if not credit_limit: return
outstanding = frappe.db.sql( """ SELECT COALESCE(SUM(outstanding_amount), 0) as total FROM `tabSales Invoice` WHERE custom_franchise_agreement = %(agreement)s AND docstatus = 1 AND outstanding_amount > 0 AND name != %(name)s """, {"agreement": self.custom_franchise_agreement, "name": self.name}, as_dict=True, )[0].total
new_total = flt(outstanding) + flt(self.grand_total) if new_total > flt(credit_limit): frappe.throw( msg=_( "This invoice would bring franchise outstanding to {0}, " "exceeding the credit limit of {1}." ).format( frappe.format_value(new_total, {"fieldtype": "Currency"}), frappe.format_value(credit_limit, {"fieldtype": "Currency"}), ), title=_("Credit Limit Exceeded"), exc=frappe.ValidationError, )
def set_franchise_cost_center(self): """Auto-set cost center from franchise agreement if not already set.""" if not self.custom_franchise_agreement or self.cost_center: return
cost_center = frappe.db.get_value( "Franchise Agreement", self.custom_franchise_agreement, "franchise_cost_center", ) if cost_center: self.cost_center = cost_center for item in self.items: if not item.cost_center: item.cost_center = cost_center
# ── Add a new whitelisted method to Sales Invoice ────────────── @frappe.whitelist() def calculate_franchise_royalty(self): """Calculate royalty owed on this invoice. Callable from client JS.""" if not self.custom_franchise_agreement: frappe.throw(_("This invoice is not linked to a franchise agreement."))
royalty_pct = frappe.db.get_value( "Franchise Agreement", self.custom_franchise_agreement, "royalty_percentage", )
royalty_amount = flt(self.grand_total) * flt(royalty_pct) / 100 return { "royalty_percentage": royalty_pct, "royalty_amount": royalty_amount, "invoice_total": self.grand_total, }
# ── Override on_submit to add franchise-specific post-submit logic ─ def on_submit(self): super().on_submit() self.update_franchise_sales_tracker()
def update_franchise_sales_tracker(self): """Update the franchise's running sales total for the current period.""" if not self.custom_franchise_agreement: return
frappe.db.sql( """ UPDATE `tabFranchise Agreement` SET custom_current_month_sales = COALESCE(custom_current_month_sales, 0) + %(amount)s WHERE name = %(agreement)s """, {"amount": flt(self.grand_total), "agreement": self.custom_franchise_agreement}, )The new validate adds a credit-limit check and auto-fills the cost center, while
calculate_franchise_royalty is a brand-new @frappe.whitelist() method bolted onto
Sales Invoice — callable straight from client JS. The overridden on_submit calls
super().on_submit() first, then bumps the franchise’s running monthly sales total.
Step 3: A second app extending the same DocType
Section titled “Step 3: A second app extending the same DocType”This is what the old override_doctype_class could not do. A separate loyalty app
maps Sales Invoice to its own extension class, and v16 merges both into the chain
without either app stepping on the other.
extend_doctype_class = { "Sales Invoice": "scoopjoy_loyalty.overrides.sales_invoice.LoyaltySalesInvoice",}import frappefrom frappe import _from frappe.utils import flt
class LoyaltySalesInvoice: """ A second app extending Sales Invoice — no conflict with ScoopJoySalesInvoice. Frappe v16 merges both extensions into the class chain. """
def on_submit(self): super().on_submit() self.award_loyalty_points()
def award_loyalty_points(self): """Award loyalty points based on invoice total.""" if not self.customer: return
points_per_100 = frappe.db.get_single_value("Loyalty Settings", "points_per_100_rupees") or 1 points = int(flt(self.grand_total) / 100) * points_per_100
if points > 0: frappe.get_doc({ "doctype": "Loyalty Point Entry", "customer": self.customer, "invoice": self.name, "points": points, "posting_date": self.posting_date, }).insert(ignore_permissions=True)When a Sales Invoice is submitted, both ScoopJoySalesInvoice.on_submit (sales
tracker) and LoyaltySalesInvoice.on_submit (loyalty points) run, threaded together
by their super() calls.