diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index d19be15485c..caa86c3225b 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -38,6 +38,7 @@ web_include_icons = [
doctype_js = {
"Address": "public/js/address.js",
+ "Sales Order": "public/js/sales_order_proforma.js",
"Communication": "public/js/communication.js",
"Event": "public/js/event.js",
"Newsletter": "public/js/newsletter.js",
diff --git a/erpnext/public/js/sales_order_proforma.js b/erpnext/public/js/sales_order_proforma.js
new file mode 100644
index 00000000000..5a72e350a41
--- /dev/null
+++ b/erpnext/public/js/sales_order_proforma.js
@@ -0,0 +1,358 @@
+// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+// License: GNU General Public License v3. See license.txt
+
+frappe.ui.form.on("Sales Order", {
+ refresh(frm) {
+ erpnext.proforma.toggle_tab(frm, false);
+ if (frm.doc.docstatus !== 1) return;
+
+ frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice").then((enabled) => {
+ if (!enabled) return;
+
+ // Defer so the button lands after the standard Create options, not before them.
+ setTimeout(() => {
+ frm.add_custom_button(
+ __("Proforma Invoice"),
+ () => erpnext.proforma.open_dialog(frm),
+ __("Create")
+ );
+ }, 0);
+ erpnext.proforma.render_list(frm);
+ });
+ },
+});
+
+frappe.provide("erpnext.proforma");
+
+Object.assign(erpnext.proforma, {
+ toggle_tab(frm, show) {
+ // Toggle the Tab Break itself: set_df_property refreshes the field control but not the
+ // tab link, so drive the Tab object directly to actually show/hide the tab.
+ const tab = frm.get_field("proforma_html")?.tab;
+ if (tab) {
+ tab.df.hidden = show ? 0 : 1;
+ tab.toggle(show);
+ } else {
+ frm.set_df_property("proforma_tab", "hidden", show ? 0 : 1);
+ }
+ },
+
+ open_dialog(frm) {
+ frappe.call({
+ method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.get_sales_order_items",
+ args: { sales_order: frm.doc.name },
+ callback: (r) => this.show_dialog(frm, r.message || []),
+ });
+ },
+
+ show_dialog(frm, so_items) {
+ frappe.model.with_doctype("Proforma Invoice", () => {
+ const series = frappe.meta.get_docfield("Proforma Invoice", "naming_series");
+ frappe.db
+ .get_single_value("Selling Settings", "default_proforma_print_format")
+ .then((default_print_format) => {
+ this.build_dialog(frm, so_items, series ? series.options : "", default_print_format);
+ });
+ });
+ },
+
+ build_dialog(frm, so_items, series_options, default_print_format) {
+ const dialog = new frappe.ui.Dialog({
+ title: __("Create Proforma Invoice"),
+ size: "large",
+ fields: [
+ {
+ fieldname: "naming_series",
+ fieldtype: "Select",
+ label: __("Series"),
+ options: series_options,
+ default: (series_options || "").split("\n")[0],
+ reqd: 1,
+ },
+ { fieldname: "cb_series", fieldtype: "Column Break" },
+ {
+ fieldname: "print_format",
+ fieldtype: "Link",
+ label: __("Print Format"),
+ options: "Print Format",
+ default: default_print_format,
+ get_query: () => ({ filters: { doc_type: "Sales Order" } }),
+ },
+ {
+ fieldname: "letter_head",
+ fieldtype: "Link",
+ label: __("Letter Head"),
+ options: "Letter Head",
+ },
+ { fieldname: "items_section", fieldtype: "Section Break", label: __("Items") },
+ {
+ fieldname: "based_on",
+ fieldtype: "Select",
+ label: __("Based On"),
+ options: ["Quantity", "Amount"],
+ default: "Quantity",
+ onchange: () => this.toggle_basis(dialog),
+ },
+ {
+ fieldname: "hide_item_qty",
+ fieldtype: "Check",
+ label: __("Hide Item Quantity in Print"),
+ depends_on: 'eval:doc.based_on=="Amount"',
+ },
+ {
+ fieldname: "items",
+ fieldtype: "Table",
+ cannot_add_rows: true,
+ // Pre-fill the remaining (ordered minus already-proformed) for each basis.
+ data: so_items.map((row) => ({
+ ...row,
+ qty: Math.max(0, flt(row.qty) - flt(row.proformed_qty)),
+ amount: Math.max(0, flt(row.amount) - flt(row.proformed_amount)),
+ })),
+ fields: [
+ {
+ fieldname: "item_code",
+ fieldtype: "Data",
+ label: __("Item"),
+ read_only: 1,
+ in_list_view: 1,
+ },
+ {
+ fieldname: "qty",
+ fieldtype: "Float",
+ label: __("Qty"),
+ in_list_view: 1,
+ onchange: function () {
+ // In Quantity basis, Amount is derived (qty x rate). Recompute across
+ // all rows and re-render — refreshing a single row only updates the
+ // active one, so rows beyond the edited one would go stale.
+ if (dialog.get_value("based_on") === "Quantity") {
+ const grid = dialog.get_field("items").grid;
+ (grid.grid_rows || []).forEach((row) => {
+ if (row.doc) row.doc.amount = flt(row.doc.qty) * flt(row.doc.rate);
+ });
+ grid.refresh();
+ }
+ erpnext.proforma.update_warning(dialog);
+ },
+ },
+ {
+ fieldname: "amount",
+ fieldtype: "Currency",
+ label: __("Amount"),
+ in_list_view: 1,
+ read_only: 1,
+ onchange: () => this.update_warning(dialog),
+ },
+ { fieldname: "item_name", fieldtype: "Data", hidden: 1 },
+ { fieldname: "rate", fieldtype: "Currency", hidden: 1 },
+ { fieldname: "so_detail", fieldtype: "Data", hidden: 1 },
+ ],
+ },
+ { fieldname: "warning_html", fieldtype: "HTML" },
+ ],
+ primary_action_label: __("Create"),
+ primary_action: (values) => this.create(frm, dialog, values),
+ });
+
+ dialog._so_items = so_items;
+ dialog.show();
+ this.update_warning(dialog);
+ },
+
+ // Qty is always editable; Amount is editable only in Amount basis (else it is derived).
+ toggle_basis(dialog) {
+ const by_amount = dialog.get_value("based_on") === "Amount";
+ const grid = dialog.get_field("items").grid;
+ grid.toggle_enable("qty", true);
+ grid.toggle_enable("amount", by_amount);
+ this.update_warning(dialog);
+ },
+
+ // Non-blocking notice below the table: flag lines whose total proforma qty/amount (this
+ // proforma plus already-issued ones) exceeds the ordered qty/amount for the chosen basis.
+ update_warning(dialog) {
+ const by_amount = dialog.get_value("based_on") === "Amount";
+ const field = by_amount ? "amount" : "qty";
+ const proformed_field = by_amount ? "proformed_amount" : "proformed_qty";
+ const so_item = {};
+ (dialog._so_items || []).forEach((row) => (so_item[row.so_detail] = row));
+
+ const exceeded = [];
+ (dialog.get_value("items") || []).forEach((row) => {
+ const item = so_item[row.so_detail];
+ if (!item) return;
+ const ordered = flt(by_amount ? item.amount : item.qty);
+ const total = flt(item[proformed_field]) + flt(row[field]);
+ if (total > ordered + 0.0001) exceeded.push(item.item_code);
+ });
+
+ const $wrapper = dialog.get_field("warning_html").$wrapper;
+ if (!exceeded.length) {
+ $wrapper.empty();
+ return;
+ }
+ const basis = by_amount ? __("amount") : __("quantity");
+ $wrapper.html(
+ `
${__(
+ "Total proforma {0} (including past proformas) exceeds the ordered {0} for: {1}",
+ [basis, frappe.utils.escape_html(exceeded.join(", "))]
+ )}
`
+ );
+ },
+
+ create(frm, dialog, values) {
+ const by_amount = values.based_on === "Amount";
+ const items = (values.items || [])
+ .filter((row) => flt(by_amount ? row.amount : row.qty) > 0)
+ .map((row) =>
+ by_amount
+ ? { so_detail: row.so_detail, qty: row.qty, amount: row.amount }
+ : { so_detail: row.so_detail, qty: row.qty }
+ );
+
+ if (!items.length) {
+ frappe.msgprint(__("Please enter a quantity or amount for at least one item."));
+ return;
+ }
+
+ frappe.call({
+ method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.make_proforma_invoice",
+ args: {
+ sales_order: frm.doc.name,
+ items: JSON.stringify(items),
+ based_on: values.based_on,
+ hide_item_qty: values.hide_item_qty ? 1 : 0,
+ naming_series: values.naming_series,
+ print_format: values.print_format,
+ letter_head: values.letter_head,
+ },
+ freeze: true,
+ freeze_message: __("Creating Proforma Invoice..."),
+ callback: (r) => {
+ if (!r.message) return;
+ dialog.hide();
+ frappe.show_alert({
+ message: __("Proforma Invoice {0} created", [r.message]),
+ indicator: "green",
+ });
+ // Open the Proforma tab once the reloaded form has rendered the list.
+ frm._activate_proforma_tab = true;
+ frm.reload_doc();
+ },
+ });
+ },
+
+ render_list(frm) {
+ // EmbeddedList is a lazy bundle (not on the eager desk bundle), so pull it in first.
+ frappe.require("embedded_list.bundle.js", () => this.build_list(frm));
+ },
+
+ build_list(frm) {
+ const container = frm.get_field("proforma_html").$wrapper.empty();
+ const list = new frappe.ui.EmbeddedList({
+ wrapper: $("").appendTo(container),
+ doctype: "Proforma Invoice",
+ // Include cancelled (docstatus 2) so voided proformas stay visible for audit.
+ filters: { sales_order: frm.doc.name, docstatus: ["in", [1, 2]] },
+ fields: ["name", "proforma_date", "grand_total", "status", "proforma_pdf", "sent_on", "currency"],
+ order_by: "creation desc",
+ empty_message: __("No proforma invoices yet."),
+ // Show the Proforma tab only once at least one proforma exists for this order.
+ after_render() {
+ const has_proformas = (this._all_data || []).length > 0;
+ erpnext.proforma.toggle_tab(frm, has_proformas);
+ if (has_proformas && frm._activate_proforma_tab) {
+ frm._activate_proforma_tab = false;
+ frm.get_field("proforma_html")?.tab?.set_active();
+ }
+ },
+ columns: [
+ {
+ label: __("Proforma No"),
+ type: "link",
+ fieldname: "name",
+ route: (row) => ["Form", "Proforma Invoice", row.name],
+ },
+ {
+ label: __("Date"),
+ fieldname: "proforma_date",
+ render: (row) => frappe.datetime.str_to_user(row.proforma_date),
+ },
+ {
+ label: __("Grand Total"),
+ fieldname: "grand_total",
+ render: (row) => format_currency(row.grand_total, row.currency),
+ },
+ {
+ label: __("Status"),
+ type: "badge",
+ fieldname: "status",
+ color: (row) => (row.status === "Cancelled" ? "red" : "green"),
+ },
+ {
+ type: "actions",
+ actions: [
+ {
+ icon: "printer",
+ label: __("View PDF"),
+ action: (row) => row.proforma_pdf && window.open(row.proforma_pdf, "_blank"),
+ },
+ {
+ icon: "mail",
+ label: __("Send Email"),
+ action: (row, refresh) => {
+ if (row.status === "Cancelled") {
+ frappe.msgprint(__("A cancelled Proforma Invoice cannot be emailed."));
+ return;
+ }
+ this.send_email(frm, row.name, refresh);
+ },
+ },
+ ],
+ },
+ ],
+ });
+ list.refresh();
+
+ frappe.ui
+ .button({
+ label: __("New Proforma Invoice"),
+ icon: "plus",
+ variant: "subtle",
+ size: "sm",
+ onclick: () => this.open_dialog(frm),
+ })
+ .appendTo(
+ $('').appendTo(container)
+ );
+ },
+
+ send_email(frm, proforma_name, refresh) {
+ frappe.prompt(
+ [
+ {
+ fieldname: "recipients",
+ fieldtype: "Data",
+ label: __("Recipients"),
+ reqd: 1,
+ default: frm.doc.contact_email,
+ description: __("Comma separated email addresses"),
+ },
+ ],
+ (values) => {
+ frappe.call({
+ method: "erpnext.selling.doctype.proforma_invoice.proforma_invoice.send_proforma_email",
+ args: { proforma_name, recipients: values.recipients },
+ freeze: true,
+ callback: () => {
+ frappe.show_alert({ message: __("Proforma emailed"), indicator: "green" });
+ (refresh || (() => this.render_list(frm)))();
+ },
+ });
+ },
+ __("Send Proforma Invoice"),
+ __("Send")
+ );
+ },
+});
diff --git a/erpnext/selling/doctype/proforma_invoice/__init__.py b/erpnext/selling/doctype/proforma_invoice/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.js b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.js
new file mode 100644
index 00000000000..a8f08572bc5
--- /dev/null
+++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("Proforma Invoice", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json
new file mode 100644
index 00000000000..9fe2b9616af
--- /dev/null
+++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.json
@@ -0,0 +1,285 @@
+{
+ "actions": [],
+ "autoname": "naming_series:",
+ "creation": "2026-07-16 00:00:00",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "details_section",
+ "naming_series",
+ "sales_order",
+ "customer",
+ "customer_name",
+ "company",
+ "column_break_header",
+ "proforma_date",
+ "currency",
+ "based_on",
+ "hide_item_qty",
+ "items_section",
+ "items",
+ "totals_section",
+ "column_break_totals",
+ "total_qty",
+ "column_break_fukr",
+ "grand_total",
+ "print_section",
+ "print_format",
+ "letter_head",
+ "column_break_print",
+ "proforma_pdf",
+ "status_section",
+ "status",
+ "sent_on",
+ "column_break_status",
+ "emailed_to",
+ "amended_from"
+ ],
+ "fields": [
+ {
+ "fieldname": "details_section",
+ "fieldtype": "Section Break",
+ "label": "Details"
+ },
+ {
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "label": "Series",
+ "no_copy": 1,
+ "options": "PRO-.YYYY.-",
+ "print_hide": 1,
+ "reqd": 1,
+ "set_only_once": 1
+ },
+ {
+ "fieldname": "sales_order",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Sales Order",
+ "options": "Sales Order",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fetch_from": "sales_order.customer",
+ "fieldname": "customer",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Customer",
+ "options": "Customer",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "customer.customer_name",
+ "fieldname": "customer_name",
+ "fieldtype": "Data",
+ "in_global_search": 1,
+ "label": "Customer Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_header",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "Today",
+ "fieldname": "proforma_date",
+ "fieldtype": "Date",
+ "in_list_view": 1,
+ "label": "Date",
+ "reqd": 1
+ },
+ {
+ "fetch_from": "sales_order.company",
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "label": "Company",
+ "options": "Company",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fetch_from": "sales_order.currency",
+ "fieldname": "currency",
+ "fieldtype": "Link",
+ "label": "Currency",
+ "options": "Currency",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "default": "Quantity",
+ "fieldname": "based_on",
+ "fieldtype": "Select",
+ "label": "Based On",
+ "options": "Quantity\nAmount",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.based_on==\"Amount\"",
+ "description": "Hide the item quantity and rate on the printed proforma.",
+ "fieldname": "hide_item_qty",
+ "fieldtype": "Check",
+ "label": "Hide Item Quantity in Print",
+ "read_only": 1
+ },
+ {
+ "fieldname": "items_section",
+ "fieldtype": "Section Break",
+ "label": "Items"
+ },
+ {
+ "fieldname": "items",
+ "fieldtype": "Table",
+ "label": "Items",
+ "options": "Proforma Invoice Item",
+ "reqd": 1
+ },
+ {
+ "fieldname": "totals_section",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "column_break_totals",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "total_qty",
+ "fieldtype": "Float",
+ "label": "Total Quantity",
+ "read_only": 1
+ },
+ {
+ "fieldname": "grand_total",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Grand Total",
+ "options": "currency",
+ "read_only": 1
+ },
+ {
+ "fieldname": "print_section",
+ "fieldtype": "Section Break",
+ "label": "Print Settings"
+ },
+ {
+ "fieldname": "print_format",
+ "fieldtype": "Link",
+ "label": "Print Format",
+ "options": "Print Format",
+ "read_only": 1
+ },
+ {
+ "fieldname": "letter_head",
+ "fieldtype": "Link",
+ "label": "Letter Head",
+ "options": "Letter Head",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_print",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "proforma_pdf",
+ "fieldtype": "Attach",
+ "label": "Proforma PDF",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "status_section",
+ "fieldtype": "Section Break",
+ "label": "Status"
+ },
+ {
+ "default": "Draft",
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ "label": "Status",
+ "no_copy": 1,
+ "options": "Draft\nIssued\nCancelled",
+ "read_only": 1
+ },
+ {
+ "fieldname": "sent_on",
+ "fieldtype": "Datetime",
+ "label": "Sent On",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_status",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "emailed_to",
+ "fieldtype": "Small Text",
+ "label": "Emailed To",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "amended_from",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "label": "Amended From",
+ "no_copy": 1,
+ "options": "Proforma Invoice",
+ "print_hide": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_fukr",
+ "fieldtype": "Column Break"
+ }
+ ],
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "is_submittable": 1,
+ "links": [],
+ "modified": "2026-07-19 11:15:50.347119",
+ "modified_by": "Administrator",
+ "module": "Selling",
+ "name": "Proforma Invoice",
+ "naming_rule": "By \"Naming Series\" field",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Sales User",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ },
+ {
+ "cancel": 1,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Sales Manager",
+ "share": 1,
+ "submit": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "customer_name"
+}
diff --git a/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py
new file mode 100644
index 00000000000..2fbf068d882
--- /dev/null
+++ b/erpnext/selling/doctype/proforma_invoice/proforma_invoice.py
@@ -0,0 +1,235 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.query_builder.functions import Sum
+from frappe.utils import flt, now
+from frappe.utils.file_manager import save_file
+
+
+class ProformaInvoice(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ from erpnext.selling.doctype.proforma_invoice_item.proforma_invoice_item import ProformaInvoiceItem
+
+ amended_from: DF.Link | None
+ based_on: DF.Literal["Quantity", "Amount"]
+ company: DF.Link
+ currency: DF.Link | None
+ customer: DF.Link | None
+ customer_name: DF.Data | None
+ emailed_to: DF.SmallText | None
+ grand_total: DF.Currency
+ hide_item_qty: DF.Check
+ items: DF.Table[ProformaInvoiceItem]
+ letter_head: DF.Link | None
+ naming_series: DF.Literal["PRO-.YYYY.-"]
+ print_format: DF.Link | None
+ proforma_date: DF.Date
+ proforma_pdf: DF.Attach | None
+ sales_order: DF.Link
+ sent_on: DF.Datetime | None
+ status: DF.Literal["Draft", "Issued", "Cancelled"]
+ total_qty: DF.Float
+ # end: auto-generated types
+
+ def validate(self) -> None:
+ validate_feature_enabled()
+ self.set_total_qty()
+
+ def before_submit(self) -> None:
+ self.status = "Issued"
+
+ def on_submit(self) -> None:
+ self.generate_and_attach_pdf()
+
+ def on_cancel(self) -> None:
+ self.db_set("status", "Cancelled")
+
+ def set_total_qty(self) -> None:
+ self.total_qty = sum(flt(item.qty) for item in self.items)
+
+ def generate_and_attach_pdf(self) -> None:
+ if self.proforma_pdf:
+ return
+ printed = self.render_pdf()
+ file = save_file(printed["fname"], printed["fcontent"], self.doctype, self.name, is_private=1)
+ self.db_set("proforma_pdf", file.file_url)
+
+ def render_pdf(self) -> dict:
+ """Render the proforma PDF from an in-memory, adjusted copy of the Sales Order.
+
+ The Sales Order copy is never saved; it exists only to reuse the standard tax/total
+ calculation and print format so the proforma shows the accurate gross. Each line's qty
+ and rate are set from the proforma (amount-based lines carry a derived rate), so the
+ recomputed amount matches whichever basis the proforma was created on.
+ """
+ sales_order = frappe.get_doc("Sales Order", self.sales_order)
+ lines = {item.so_detail: item for item in self.items}
+ sales_order.items = [item for item in sales_order.items if item.name in lines]
+ for item in sales_order.items:
+ item.qty = lines[item.name].qty
+ item.rate = lines[item.name].rate
+ item.discount_amount = 0
+ item.discount_percentage = 0
+ sales_order.run_method("calculate_taxes_and_totals")
+ sales_order.proforma_no = self.name
+ sales_order.proforma_date = self.proforma_date
+ sales_order.hide_item_qty = self.hide_item_qty
+ self.db_set("grand_total", sales_order.grand_total)
+ return frappe.attach_print(
+ "Sales Order",
+ sales_order.name,
+ doc=sales_order,
+ file_name=self.name,
+ print_format=self.print_format,
+ letterhead=self.letter_head,
+ )
+
+
+@frappe.whitelist()
+def get_sales_order_items(sales_order: str) -> list[dict]:
+ """Sales Order lines (with already-proformed totals) to drive the create-proforma dialog."""
+ sales_order_doc = frappe.get_doc("Sales Order", sales_order)
+ proformed = get_proformed_totals(sales_order)
+ return [
+ {
+ "item_code": item.item_code,
+ "item_name": item.item_name,
+ "uom": item.uom,
+ "so_detail": item.name,
+ "qty": flt(item.qty),
+ "rate": flt(item.rate),
+ "amount": flt(item.amount),
+ "proformed_qty": flt(proformed.get(item.name, {}).get("qty")),
+ "proformed_amount": flt(proformed.get(item.name, {}).get("amount")),
+ }
+ for item in sales_order_doc.items
+ ]
+
+
+def get_proformed_totals(sales_order: str) -> dict[str, dict]:
+ """Sum of issued (docstatus = 1) proforma qty and amount per Sales Order Item row."""
+ proformas = frappe.get_all(
+ "Proforma Invoice", filters={"sales_order": sales_order, "docstatus": 1}, pluck="name"
+ )
+ if not proformas:
+ return {}
+ item = frappe.qb.DocType("Proforma Invoice Item")
+ rows = (
+ frappe.qb.from_(item)
+ .select(item.so_detail, Sum(item.qty).as_("qty"), Sum(item.amount).as_("amount"))
+ .where(item.parent.isin(proformas))
+ .groupby(item.so_detail)
+ ).run(as_dict=True)
+ return {row.so_detail: {"qty": flt(row.qty), "amount": flt(row.amount)} for row in rows}
+
+
+@frappe.whitelist()
+def make_proforma_invoice(
+ sales_order: str,
+ items: str,
+ based_on: str = "Quantity",
+ hide_item_qty: bool | int = 0,
+ naming_series: str | None = None,
+ print_format: str | None = None,
+ letter_head: str | None = None,
+) -> str:
+ """The sole creation path for a Proforma Invoice (the doctype is `in_create`).
+
+ `based_on` decides what the user edited per line: "Quantity" (rate fixed, amount = qty x rate)
+ or "Amount" (both qty and amount entered, rate derived). `hide_item_qty` (Amount basis only)
+ hides the qty and rate on the printed proforma for a clean value-based document.
+ """
+ validate_feature_enabled()
+ selected = frappe.parse_json(items)
+ sales_order_doc = frappe.get_doc("Sales Order", sales_order)
+ if sales_order_doc.docstatus != 1:
+ frappe.throw(_("A Proforma Invoice can only be created against a submitted Sales Order."))
+ so_items = {item.name: item for item in sales_order_doc.items}
+
+ proforma = frappe.new_doc("Proforma Invoice")
+ proforma.sales_order = sales_order
+ proforma.based_on = based_on
+ proforma.hide_item_qty = 1 if (based_on == "Amount" and int(hide_item_qty or 0)) else 0
+ if naming_series:
+ proforma.naming_series = naming_series
+ proforma.print_format = print_format or frappe.db.get_single_value(
+ "Selling Settings", "default_proforma_print_format"
+ )
+ proforma.letter_head = letter_head
+
+ for row in selected:
+ so_item = so_items.get(row.get("so_detail"))
+ if not so_item:
+ continue
+ line = _proforma_line(so_item, based_on, row)
+ if line:
+ proforma.append("items", line)
+
+ if not proforma.items:
+ frappe.throw(_("Please enter a quantity or amount for at least one item."))
+
+ proforma.insert()
+ proforma.submit()
+ return proforma.name
+
+
+def _proforma_line(so_item, based_on: str, row: dict) -> dict | None:
+ if based_on == "Amount":
+ # Amount basis: both qty and amount are user-entered; the rate is derived.
+ qty = flt(row.get("qty"))
+ amount = flt(row.get("amount"))
+ if amount <= 0 or qty <= 0:
+ return None
+ rate = amount / qty
+ else:
+ qty = flt(row.get("qty"))
+ if qty <= 0:
+ return None
+ rate = flt(so_item.rate)
+ amount = qty * rate
+
+ return {
+ "item_code": so_item.item_code,
+ "item_name": so_item.item_name,
+ "uom": so_item.uom,
+ "qty": qty,
+ "rate": rate,
+ "amount": amount,
+ "so_detail": so_item.name,
+ }
+
+
+@frappe.whitelist()
+def send_proforma_email(proforma_name: str, recipients: str) -> None:
+ proforma = frappe.get_doc("Proforma Invoice", proforma_name)
+ if proforma.docstatus != 1:
+ frappe.throw(_("Only an issued Proforma Invoice can be emailed."))
+ if not proforma.proforma_pdf:
+ frappe.throw(_("This Proforma Invoice has no PDF to send."))
+
+ file_name = frappe.db.get_value("File", {"file_url": proforma.proforma_pdf}, "name")
+ if not file_name:
+ frappe.throw(_("The attached PDF file could not be found."))
+ frappe.sendmail(
+ recipients=[email.strip() for email in recipients.split(",") if email.strip()],
+ subject=_("Proforma Invoice {0}").format(proforma.name),
+ message=_("Please find attached the proforma invoice {0}.").format(proforma.name),
+ attachments=[{"fid": file_name}],
+ )
+ proforma.db_set("sent_on", now())
+ proforma.db_set("emailed_to", recipients)
+
+
+def validate_feature_enabled() -> None:
+ if not frappe.db.get_single_value("Selling Settings", "enable_proforma_invoice"):
+ frappe.throw(_("Proforma Invoice is not enabled in Selling Settings."))
diff --git a/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py
new file mode 100644
index 00000000000..2d9f7843e78
--- /dev/null
+++ b/erpnext/selling/doctype/proforma_invoice/test_proforma_invoice.py
@@ -0,0 +1,164 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe.utils import flt
+
+from erpnext.selling.doctype.proforma_invoice.proforma_invoice import (
+ get_sales_order_items,
+ make_proforma_invoice,
+ send_proforma_email,
+)
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+from erpnext.tests.utils import ERPNextTestSuite
+
+
+class TestProformaInvoice(ERPNextTestSuite):
+ def setUp(self):
+ frappe.db.set_single_value("Selling Settings", "enable_proforma_invoice", 1)
+
+ def create_proforma(self, sales_order, lines, **kwargs):
+ items = [{"so_detail": so_detail, "qty": qty} for so_detail, qty in lines]
+ name = make_proforma_invoice(sales_order.name, json.dumps(items), **kwargs)
+ return frappe.get_doc("Proforma Invoice", name)
+
+ def test_partial_proforma_is_non_blocking(self):
+ """A proforma must not touch delivery/billing or the source Sales Order."""
+ sales_order = make_sales_order(qty=10)
+ so_detail = sales_order.items[0].name
+
+ proforma = self.create_proforma(sales_order, [(so_detail, 4)])
+
+ self.assertEqual(proforma.status, "Issued")
+ self.assertEqual(proforma.docstatus, 1)
+ self.assertTrue(proforma.proforma_pdf, "PDF should be generated and attached")
+
+ sales_order.reload()
+ item = sales_order.items[0]
+ # fulfillment untouched
+ self.assertEqual(flt(item.delivered_qty), 0)
+ self.assertEqual(flt(item.billed_amt), 0)
+ self.assertEqual(flt(sales_order.per_delivered), 0)
+ self.assertEqual(flt(sales_order.per_billed), 0)
+ # ordered qty untouched (in-memory SO copy never persisted)
+ self.assertEqual(flt(item.qty), 10)
+
+ def test_taxes_scale_to_partial_qty(self):
+ sales_order = make_sales_order(qty=10, do_not_submit=True)
+ sales_order.append(
+ "taxes",
+ {
+ "charge_type": "On Net Total",
+ "account_head": "_Test Account CST - _TC",
+ "description": "CST",
+ "rate": 10,
+ },
+ )
+ sales_order.submit()
+
+ # full order: net 1000 + 10% tax = 1100
+ self.assertEqual(flt(sales_order.grand_total), 1100)
+
+ proforma = self.create_proforma(sales_order, [(sales_order.items[0].name, 4)])
+ # partial (4 of 10): net 400 + 10% tax = 440
+ self.assertEqual(flt(proforma.grand_total), 440)
+
+ def test_amount_based_proforma(self):
+ """Amount basis: qty and amount are both entered; the rate is derived from them."""
+ sales_order = make_sales_order(qty=10) # rate 100
+ so_detail = sales_order.items[0].name
+
+ name = make_proforma_invoice(
+ sales_order.name,
+ json.dumps([{"so_detail": so_detail, "qty": 5, "amount": 250}]),
+ based_on="Amount",
+ )
+ proforma = frappe.get_doc("Proforma Invoice", name)
+
+ self.assertEqual(proforma.based_on, "Amount")
+ item = proforma.items[0]
+ self.assertEqual(flt(item.qty), 5)
+ self.assertEqual(flt(item.rate), 50) # 250 / 5
+ self.assertEqual(flt(item.amount), 250)
+ self.assertEqual(flt(proforma.grand_total), 250)
+
+ def test_cancelled_proforma_keeps_pdf(self):
+ """Cancelling voids the proforma but keeps its PDF and status for the audit trail."""
+ sales_order = make_sales_order(qty=10)
+ proforma = self.create_proforma(sales_order, [(sales_order.items[0].name, 4)])
+ pdf = proforma.proforma_pdf
+ self.assertTrue(pdf)
+
+ proforma.cancel()
+ proforma.reload()
+ self.assertEqual(proforma.status, "Cancelled")
+ self.assertEqual(proforma.proforma_pdf, pdf)
+
+ def test_proformed_totals_exclude_cancelled(self):
+ """Cumulative issued proforma qty/amount per line, used by the dialog warning."""
+ sales_order = make_sales_order(qty=10) # rate 100
+ so_detail = sales_order.items[0].name
+
+ first = self.create_proforma(sales_order, [(so_detail, 4)])
+ self.create_proforma(sales_order, [(so_detail, 3)])
+
+ data = get_sales_order_items(sales_order.name)[0]
+ self.assertEqual(flt(data["proformed_qty"]), 7)
+ self.assertEqual(flt(data["proformed_amount"]), 700)
+
+ first.cancel()
+ data = get_sales_order_items(sales_order.name)[0]
+ self.assertEqual(flt(data["proformed_qty"]), 3)
+ self.assertEqual(flt(data["proformed_amount"]), 300)
+
+ def test_hide_item_qty_only_applies_to_amount_basis(self):
+ sales_order = make_sales_order(qty=10)
+ so_detail = sales_order.items[0].name
+
+ amount_based = make_proforma_invoice(
+ sales_order.name,
+ json.dumps([{"so_detail": so_detail, "qty": 5, "amount": 250}]),
+ based_on="Amount",
+ hide_item_qty=1,
+ )
+ self.assertEqual(frappe.db.get_value("Proforma Invoice", amount_based, "hide_item_qty"), 1)
+
+ # ignored outside Amount basis
+ qty_based = make_proforma_invoice(
+ sales_order.name,
+ json.dumps([{"so_detail": so_detail, "qty": 4}]),
+ based_on="Quantity",
+ hide_item_qty=1,
+ )
+ self.assertEqual(frappe.db.get_value("Proforma Invoice", qty_based, "hide_item_qty"), 0)
+
+ def test_feature_toggle_is_enforced(self):
+ sales_order = make_sales_order(qty=10)
+ frappe.db.set_single_value("Selling Settings", "enable_proforma_invoice", 0)
+
+ self.assertRaises(
+ frappe.ValidationError,
+ self.create_proforma,
+ sales_order,
+ [(sales_order.items[0].name, 4)],
+ )
+
+ def test_cannot_email_cancelled_proforma(self):
+ sales_order = make_sales_order(qty=10)
+ proforma = self.create_proforma(sales_order, [(sales_order.items[0].name, 4)])
+ proforma.cancel()
+
+ self.assertRaises(frappe.ValidationError, send_proforma_email, proforma.name, "customer@example.com")
+
+ def test_requires_submitted_sales_order(self):
+ """The server rejects a proforma against a draft Sales Order (the button is JS-gated only)."""
+ sales_order = make_sales_order(qty=10, do_not_submit=True)
+
+ self.assertRaises(
+ frappe.ValidationError,
+ self.create_proforma,
+ sales_order,
+ [(sales_order.items[0].name, 4)],
+ )
diff --git a/erpnext/selling/doctype/proforma_invoice_item/__init__.py b/erpnext/selling/doctype/proforma_invoice_item/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json
new file mode 100644
index 00000000000..d3ba6403a18
--- /dev/null
+++ b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.json
@@ -0,0 +1,91 @@
+{
+ "actions": [],
+ "creation": "2026-07-16 00:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "item_code",
+ "item_name",
+ "column_break_qty",
+ "qty",
+ "uom",
+ "rate",
+ "amount",
+ "so_detail"
+ ],
+ "fields": [
+ {
+ "columns": 4,
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Item Code",
+ "options": "Item",
+ "reqd": 1
+ },
+ {
+ "fetch_from": "item_code.item_name",
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Item Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_qty",
+ "fieldtype": "Column Break"
+ },
+ {
+ "columns": 2,
+ "fieldname": "qty",
+ "fieldtype": "Float",
+ "in_list_view": 1,
+ "label": "Quantity",
+ "reqd": 1
+ },
+ {
+ "fieldname": "uom",
+ "fieldtype": "Link",
+ "label": "UOM",
+ "options": "UOM",
+ "read_only": 1
+ },
+ {
+ "columns": 2,
+ "fieldname": "rate",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Rate",
+ "read_only": 1
+ },
+ {
+ "columns": 2,
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Amount",
+ "read_only": 1
+ },
+ {
+ "fieldname": "so_detail",
+ "fieldtype": "Data",
+ "label": "Sales Order Item",
+ "no_copy": 1,
+ "print_hide": 1,
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-07-16 00:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Selling",
+ "name": "Proforma Invoice Item",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py
new file mode 100644
index 00000000000..86a326aa774
--- /dev/null
+++ b/erpnext/selling/doctype/proforma_invoice_item/proforma_invoice_item.py
@@ -0,0 +1,28 @@
+# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+from frappe.model.document import Document
+
+
+class ProformaInvoiceItem(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ amount: DF.Currency
+ item_code: DF.Link
+ item_name: DF.Data | None
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ qty: DF.Float
+ rate: DF.Currency
+ so_detail: DF.Data | None
+ uom: DF.Link | None
+ # end: auto-generated types
+
+ pass
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index 0b00a2d8613..9c8ed1bf649 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -178,6 +178,8 @@
"column_break_yvzv",
"inter_company_order_reference",
"party_account_currency",
+ "proforma_tab",
+ "proforma_html",
"connections_tab"
],
"fields": [
@@ -1526,6 +1528,17 @@
"fieldname": "column_break_49",
"fieldtype": "Column Break"
},
+ {
+ "fieldname": "proforma_tab",
+ "fieldtype": "Tab Break",
+ "hidden": 1,
+ "label": "Proforma"
+ },
+ {
+ "fieldname": "proforma_html",
+ "fieldtype": "HTML",
+ "label": "Proforma Invoices"
+ },
{
"fieldname": "connections_tab",
"fieldtype": "Tab Break",
diff --git a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
index ea9c8d2f96e..f6767c533d0 100644
--- a/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
+++ b/erpnext/selling/doctype/sales_order/sales_order_dashboard.py
@@ -24,6 +24,7 @@ def get_data():
"label": _("Fulfillment"),
"items": ["Sales Invoice", "Pick List", "Delivery Note", "Maintenance Visit"],
},
+ {"label": _("Proforma"), "items": ["Proforma Invoice"]},
{"label": _("Purchasing"), "items": ["Material Request", "Purchase Order"]},
{"label": _("Projects"), "items": ["Project"]},
{"label": _("Manufacturing"), "items": ["Work Order", "BOM", "Blanket Order"]},
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.js b/erpnext/selling/doctype/selling_settings/selling_settings.js
index 9ffe9390a24..5f7ee27ee95 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.js
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.js
@@ -50,6 +50,7 @@ function get_transactions(frm) {
{ label: __("Sales Order"), doctype: "Sales Order" },
{ label: __("Sales Invoice"), doctype: "Sales Invoice" },
{ label: __("Delivery Note"), doctype: "Delivery Note" },
+ { label: __("Proforma Invoice"), doctype: "Proforma Invoice" },
];
if (frm.doc.cust_master_name !== "Naming Series") {
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json
index ebae841dde9..4cd5c6d2625 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.json
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.json
@@ -48,6 +48,9 @@
"allow_zero_qty_in_sales_order",
"blanket_orders_section",
"blanket_order_allowance",
+ "proforma_invoice_section",
+ "enable_proforma_invoice",
+ "default_proforma_print_format",
"advanced_features_tab",
"section_break_avhb",
"enable_tracking_sales_commissions",
@@ -341,6 +344,26 @@
"fieldtype": "Check",
"label": "Deliver secondary Items"
},
+ {
+ "fieldname": "proforma_invoice_section",
+ "fieldtype": "Section Break",
+ "label": "Proforma Invoice"
+ },
+ {
+ "default": "0",
+ "description": "Allow issuing Proforma Invoices against a Sales Order.",
+ "fieldname": "enable_proforma_invoice",
+ "fieldtype": "Check",
+ "label": "Enable Proforma Invoice"
+ },
+ {
+ "depends_on": "enable_proforma_invoice",
+ "description": "Default print format used when generating a Proforma Invoice PDF.",
+ "fieldname": "default_proforma_print_format",
+ "fieldtype": "Link",
+ "label": "Default Proforma Print Format",
+ "options": "Print Format"
+ },
{
"fieldname": "customer_defaults_tab",
"fieldtype": "Tab Break",
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py
index bf8750cc1b8..66e4bf5d93a 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.py
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.py
@@ -41,6 +41,7 @@ class SellingSettings(Document):
blanket_order_allowance: DF.Float
cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"]
customer_group: DF.Link | None
+ default_proforma_print_format: DF.Link | None
deliver_secondary_items: DF.Check
dn_required: DF.Literal["No", "Yes"]
dont_reserve_sales_order_qty_on_sales_return: DF.Check
@@ -48,6 +49,7 @@ class SellingSettings(Document):
editable_price_list_rate: DF.Check
enable_cutoff_date_on_bulk_delivery_note_creation: DF.Check
enable_discount_accounting: DF.Check
+ enable_proforma_invoice: DF.Check
enable_tracking_sales_commissions: DF.Check
enable_utm: DF.Check
fallback_to_default_price_list: DF.Check
diff --git a/erpnext/selling/print_format/proforma_invoice/__init__.py b/erpnext/selling/print_format/proforma_invoice/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json b/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json
new file mode 100644
index 00000000000..8ef8184928d
--- /dev/null
+++ b/erpnext/selling/print_format/proforma_invoice/proforma_invoice.json
@@ -0,0 +1,33 @@
+{
+ "absolute_value": 0,
+ "align_labels_right": 0,
+ "creation": "2026-07-16 00:00:00.000000",
+ "custom_format": 1,
+ "default_print_language": "en",
+ "disabled": 0,
+ "doc_type": "Sales Order",
+ "docstatus": 0,
+ "doctype": "Print Format",
+ "font_size": 0,
+ "html": "\n",
+ "idx": 0,
+ "line_breaks": 0,
+ "margin_bottom": 15.0,
+ "margin_left": 15.0,
+ "margin_right": 15.0,
+ "margin_top": 15.0,
+ "modified": "2026-07-16 00:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Selling",
+ "name": "Proforma Invoice",
+ "owner": "Administrator",
+ "page_number": "Hide",
+ "pdf_generator": "wkhtmltopdf",
+ "print_format_builder": 0,
+ "print_format_builder_beta": 0,
+ "print_format_for": "",
+ "print_format_type": "Jinja",
+ "raw_printing": 0,
+ "show_section_headings": 0,
+ "standard": "Yes"
+}