diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js index 234ef799..87d26f30 100644 --- a/POS/src/composables/useInvoice.js +++ b/POS/src/composables/useInvoice.js @@ -739,11 +739,15 @@ export function useInvoice() { * Format cart items for server submission. * Used by both online and offline flows for consistent formatting. * + * Legacy carts could mark same-item BOGO only via `free_qty` on the paid line. + * Sales Invoice Item has no `free_qty` field — ERPNext expects a second row with + * is_free_item=1. When there is no matching dedicated free row, synthesize one. + * * @param {Array} items - Raw cart items * @returns {Array} Items formatted for ERPNext Sales Invoice */ function formatItemsForSubmission(items) { - return items.map((item) => ({ + const mapRow = (item) => ({ item_code: item.item_code, item_name: item.item_name, qty: item.quantity || item.qty || 1, @@ -753,6 +757,7 @@ export function useInvoice() { warehouse: item.warehouse, batch_no: item.batch_no, serial_no: item.serial_no, + use_serial_batch_fields: item.batch_no || item.serial_no ? 1 : 0, conversion_factor: item.conversion_factor || 1, discount_percentage: roundCurrency(item.discount_percentage || 0), discount_amount: roundCurrency(item.discount_amount || 0), @@ -761,7 +766,44 @@ export function useInvoice() { is_rate_manually_edited: item.is_rate_manually_edited || 0, original_rate: item.original_rate || null, is_free_item: item.is_free_item || 0, - })) + }) + + const out = [] + for (const item of items) { + out.push(mapRow(item)) + const fq = Number.parseFloat(item.free_qty) || 0 + if (!item.is_free_item && fq > 0) { + const u = item.uom || item.stock_uom + const hasDedicatedFree = items.some( + (i) => + i.is_free_item && + i.item_code === item.item_code && + (i.uom || i.stock_uom) === u, + ) + if (!hasDedicatedFree) { + out.push({ + item_code: item.item_code, + item_name: item.item_name, + qty: fq, + rate: 0, + price_list_rate: 0, + uom: item.uom, + warehouse: item.warehouse, + batch_no: item.batch_no, + serial_no: item.serial_no, + use_serial_batch_fields: item.batch_no || item.serial_no ? 1 : 0, + conversion_factor: item.conversion_factor || 1, + discount_percentage: 0, + discount_amount: 0, + pricing_rules: stringifyPricingRules(item.pricing_rules), + is_rate_manually_edited: 0, + original_rate: null, + is_free_item: 1, + }) + } + } + } + return out } function addPayment(payment) { diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index f39dd3a1..bb1710d3 100644 --- a/POS/src/stores/posCart.js +++ b/POS/src/stores/posCart.js @@ -380,10 +380,11 @@ export const usePOSCartStore = defineStore("posCart", () => { /** * Processes free items from backend offer response. * - * Two cases: - * 1. Same item (free item matches an existing cart item) → sets free_qty on that item - * 2. Different product (free item not in cart) → adds a dedicated free item row - * with is_free_item=1, rate=0, non-editable in UI + * ERPNext represents product discounts as separate SI rows: paid line(s) plus + * one or more rows with is_free_item=1 (see pricing_rule tests for same_item). + * We always add a dedicated free row so formatItemsForSubmission sends qty > 0 + * for stock and accounting; annotating only free_qty on the paid line never + * reaches Sales Invoice Item (there is no free_qty field server-side). * * @param {Array} freeItems - Array of free items from backend (e.g., [{item_code, qty, uom, item_name}]) * @returns {void} @@ -418,30 +419,26 @@ export const usePOSCartStore = defineStore("posCart", () => { (item.uom || item.stock_uom) === freeUom ) - if (cartItem) { - // Same item is already in cart — just annotate with free_qty - cartItem.free_qty = freeQty - } else { - // Different product — add a dedicated free item row - invoiceItems.value.push({ - item_code: freeItem.item_code, - item_name: freeItem.item_name || freeItem.item_code, - rate: 0, - price_list_rate: 0, - quantity: freeQty, - discount_amount: 0, - discount_percentage: 0, - tax_amount: 0, - amount: 0, - stock_qty: 0, - uom: freeUom, - stock_uom: freeItem.stock_uom || freeUom, - conversion_factor: freeItem.conversion_factor || 1, - is_free_item: 1, - free_qty: freeQty, - pricing_rules: freeItem.pricing_rules || null, - }) - } + const cf = freeItem.conversion_factor || cartItem?.conversion_factor || 1 + invoiceItems.value.push({ + item_code: freeItem.item_code, + item_name: freeItem.item_name || cartItem?.item_name || freeItem.item_code, + rate: 0, + price_list_rate: 0, + quantity: freeQty, + discount_amount: 0, + discount_percentage: 0, + tax_amount: 0, + amount: 0, + stock_qty: 0, + uom: freeUom, + stock_uom: freeItem.stock_uom || freeUom, + conversion_factor: cf, + is_free_item: 1, + free_qty: freeQty, + pricing_rules: freeItem.pricing_rules || null, + warehouse: freeItem.warehouse || cartItem?.warehouse, + }) } rebuildIncrementalCache() @@ -822,7 +819,7 @@ export const usePOSCartStore = defineStore("posCart", () => { * In offline mode, we: * 1. Check eligibility using posOffers.checkOfferEligibility * 2. Apply discount percentage/amount directly to cart items - * 3. Handle free items (product discounts) by setting free_qty + * 3. Handle free items (product discounts) via dedicated is_free_item rows (same as online) * 4. Mark offers as applied (with source: "offline") * * Supports: @@ -1022,14 +1019,49 @@ export const usePOSCartStore = defineStore("posCart", () => { } } - if (freeItemsToGive > 0 && (!item.free_qty || item.free_qty === 0)) { - item.free_qty = freeItemsToGive - item.pricing_rules = item.pricing_rules || [] - if (!item.pricing_rules.includes(offer.name)) { - item.pricing_rules.push(offer.name) - } - applied = true + if (freeItemsToGive <= 0) { + continue } + const uomKey = item.uom || item.stock_uom + const existingFreeRow = invoiceItems.value.find( + (r) => + r.is_free_item && + r.item_code === item.item_code && + (r.uom || r.stock_uom) === uomKey, + ) + if (existingFreeRow) { + existingFreeRow.quantity = freeItemsToGive + existingFreeRow.free_qty = freeItemsToGive + const pr = existingFreeRow.pricing_rules + const prArr = Array.isArray(pr) + ? [...pr] + : pr + ? String(pr).split(',').map((s) => s.trim()).filter(Boolean) + : [] + if (!prArr.includes(offer.name)) prArr.push(offer.name) + existingFreeRow.pricing_rules = prArr + } else { + invoiceItems.value.push({ + item_code: item.item_code, + item_name: item.item_name || item.item_code, + rate: 0, + price_list_rate: 0, + quantity: freeItemsToGive, + discount_amount: 0, + discount_percentage: 0, + tax_amount: 0, + amount: 0, + stock_qty: 0, + uom: uomKey, + stock_uom: item.stock_uom || uomKey, + conversion_factor: item.conversion_factor || 1, + is_free_item: 1, + free_qty: freeItemsToGive, + pricing_rules: [offer.name], + warehouse: item.warehouse, + }) + } + applied = true } } else if (freeItemCode) { // Free item is a specific different item diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 0d592f8f..13887d9d 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -40,9 +40,13 @@ from erpnext.accounts.doctype.pricing_rule.utils import ( get_applied_pricing_rules as erpnext_get_applied_pricing_rules, ) + from erpnext.accounts.doctype.pricing_rule.utils import ( + apply_pricing_rule_on_transaction as erpnext_apply_pricing_rule_on_transaction, + ) except Exception: # pragma: no cover - ERPNext not installed in some environments erpnext_apply_pricing_rule = None erpnext_get_applied_pricing_rules = None + erpnext_apply_pricing_rule_on_transaction = None # ========================================== @@ -2677,6 +2681,114 @@ def search_invoices_for_return( # ========================================== +def _evaluate_transaction_offers( + invoice, + profile, + pricing_items, + customer, + customer_group, + territory, + posting_date, + currency, + price_list, + rule_map, + selected_offer_names, +): + """Run ERPNext's transaction-level pricing engine and collect free items. + + ERPNext routes `apply_on = "Transaction"` rules through a different entry + point (`apply_pricing_rule_on_transaction`) than the per-item engine. That + function mutates a real Sales Invoice document in place — appending free + item rows via `doc.append("items", ...)` — so we build a transient, + never-saved Sales Invoice document for evaluation only. + + Returns {"free_items": dict keyed by (item_code, rule_name), "applied_rules": set}. + """ + if not erpnext_apply_pricing_rule_on_transaction or not pricing_items: + return {"free_items": {}, "applied_rules": set()} + + total_qty = sum(flt(it.qty) for it in pricing_items) + total = sum(flt(it.qty) * flt(it.rate) for it in pricing_items) + if total <= 0: + return {"free_items": {}, "applied_rules": set()} + + doc = frappe.new_doc("Sales Invoice") + doc.update( + { + "is_pos": 1, + "company": profile.company, + "currency": currency, + "conversion_rate": 1, + "selling_price_list": price_list, + "price_list_currency": currency, + "plc_conversion_rate": 1, + "customer": customer, + "customer_group": customer_group, + "territory": territory, + "transaction_date": posting_date, + "posting_date": posting_date, + "pos_profile": invoice.get("pos_profile"), + "coupon_code": invoice.get("coupon_code") or None, + } + ) + doc.flags.ignore_mandatory = True + + for prep in pricing_items: + doc.append( + "items", + { + "item_code": prep.item_code, + "item_name": prep.item_name, + "item_group": prep.item_group, + "brand": prep.brand, + "qty": prep.qty, + "stock_qty": prep.stock_qty, + "conversion_factor": prep.conversion_factor, + "uom": prep.uom, + "stock_uom": prep.stock_uom, + "rate": prep.rate, + "price_list_rate": prep.price_list_rate, + "base_rate": prep.base_rate, + "base_price_list_rate": prep.base_price_list_rate, + "amount": flt(prep.rate) * flt(prep.qty), + "warehouse": prep.warehouse, + }, + ) + + # filter_pricing_rules_for_qty_amount reads these straight off the doc + # (erpnext/accounts/doctype/pricing_rule/utils.py:572). + doc.total_qty = total_qty + doc.total = total + + initial_item_count = len(doc.items) + try: + erpnext_apply_pricing_rule_on_transaction(doc) + except Exception: + # A misconfigured transaction-scoped rule must not break the per-item + # discounts that have already been computed by the caller. + frappe.log_error( + frappe.get_traceback(), "POS Apply Offers (Transaction Rules)" + ) + return {"free_items": {}, "applied_rules": set()} + + free_items = {} + applied_rules = set() + for row in doc.items[initial_item_count:]: + if not getattr(row, "is_free_item", 0): + continue + rule_name = row.get("pricing_rules") + if not rule_name or rule_name not in rule_map: + continue + if selected_offer_names and rule_name not in selected_offer_names: + continue + fid = frappe._dict(row.as_dict()) + fid.applied_promotional_scheme = rule_map[rule_name].promotional_scheme + free_items[(row.item_code, rule_name)] = fid + applied_rules.add(rule_name) + + return {"free_items": free_items, "applied_rules": applied_rules} + + @frappe.whitelist() def apply_offers(invoice_data, selected_offers=None): """Calculate and apply promotional offers using ERPNext Pricing Rules. @@ -2917,6 +3029,32 @@ def apply_offers(invoice_data, selected_offers=None): # Include both promotional scheme rules and standalone pricing rules rule_map[record.name] = record + # Top up rule_map with transaction-scoped rules. The per-item engine + # never surfaces apply_on="Transaction" rules, so without this they + # would be dropped at the `if not rule_map: return` check below. + # ERPNext's own SQL inside apply_pricing_rule_on_transaction handles + # date/currency/pos_only filtering, so a broad superset is sufficient. + if erpnext_apply_pricing_rule_on_transaction: + txn_rule_records = frappe.get_all( + "Pricing Rule", + filters={ + "disable": 0, + "apply_on": "Transaction", + "company": profile.company, + "selling": 1, + "coupon_code_based": 0, + }, + fields=[ + "name", + "promotional_scheme", + "coupon_code_based", + "promotional_scheme_id", + "price_or_product_discount", + ], + ) + for record in txn_rule_records: + rule_map.setdefault(record.name, record) + if selected_offer_names: # Restrict available rules to the ones explicitly selected from the UI. rule_map = { @@ -3052,6 +3190,29 @@ def apply_offers(invoice_data, selected_offers=None): ].promotional_scheme free_items_map[(free_item.get("item_code"), rule_name)] = free_item_doc + # Evaluate apply_on="Transaction" rules through ERPNext's separate + # transaction-level engine. The per-item engine above does not see + # them, so without this step "Entire Transaction" promotional schemes + # (free product based on cart total) would never apply. + txn_result = _evaluate_transaction_offers( + invoice, + profile, + pricing_items, + customer, + customer_group, + territory, + invoice.get("posting_date") or nowdate(), + pricing_args.currency, + pricing_args.price_list, + rule_map, + selected_offer_names, + ) + # Per-item results win on collisions because they already carry full + # discount metadata from the per-item engine result. + for key, free_item_doc in txn_result.get("free_items", {}).items(): + free_items_map.setdefault(key, free_item_doc) + applied_rules.update(txn_result.get("applied_rules", set())) + return { "items": [dict(item) for item in prepared_items], "free_items": [dict(item) for item in free_items_map.values()], diff --git a/pos_next/overrides/sales_invoice.py b/pos_next/overrides/sales_invoice.py index 069d5047..6c017795 100644 --- a/pos_next/overrides/sales_invoice.py +++ b/pos_next/overrides/sales_invoice.py @@ -12,6 +12,47 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice from erpnext.accounts.utils import get_account_currency + +def _find_paid_bundle_row_for_free(si_doc, free_row): + """Pick the paid SI item row whose bundle qty should absorb this free bundle's packed items.""" + candidates = [] + for row in si_doc.get("items"): + if row.name == free_row.name or cint(row.is_free_item): + continue + if row.item_code != free_row.item_code: + continue + if (row.warehouse or "") != (free_row.warehouse or ""): + continue + candidates.append(row) + if not candidates: + return None + free_idx = free_row.idx or 0 + before = [r for r in candidates if (r.idx or 0) < free_idx] + if before: + return max(before, key=lambda r: r.idx or 0) + return candidates[0] + + +def _find_matching_packed_item_for_merge(si_doc, paid_row, component_item_code, warehouse): + """Match a packed item on the paid bundle line; prefer same warehouse.""" + w = warehouse or "" + matches = [] + for pi in si_doc.get("packed_items"): + if pi.parent_detail_docname != paid_row.name: + continue + if pi.parent_item != paid_row.item_code: + continue + if pi.item_code != component_item_code: + continue + matches.append(pi) + if not matches: + return None + for pi in matches: + if (pi.warehouse or "") == w: + return pi + return matches[0] + + def _get_post_change_gl_entries_setting(): """ Get post_change_gl_entries setting compatible with ERPNext v15 and v16. @@ -44,6 +85,7 @@ def _get_post_change_gl_entries_setting(): ) return cint(result[0][0]) if result else 0 + class CustomSalesInvoice(SalesInvoice): """ Custom Sales Invoice class that handles wallet payments correctly. @@ -161,3 +203,72 @@ def get_party_and_party_type_for_pos_gl_entry(self, mode_of_payment, account): party_type, party = "Customer", self.customer return party_type, party + + def update_packing_list(self): + super().update_packing_list() + self._combine_packed_qty_for_free_product_bundles() + self._set_use_serial_batch_fields_on_packed_items() + + def _set_use_serial_batch_fields_on_packed_items(self): + """ + Force packed_items for batch/serial-tracked Items to use legacy fields path. + + ERPNext's auto-SBB creation during SLE.on_submit fails to link the bundle + because SBB.voucher_detail_no gets remapped to the parent SI Item row name + (set_serial_and_batch_values) while validation expects either a matching SLE + or a Packed Item with that name. Routing through use_serial_batch_fields=1 + bypasses the broken auto-creation for the row. + """ + if not self.get("packed_items"): + return + for pi in self.get("packed_items"): + if pi.get("serial_and_batch_bundle"): + continue + tracking = frappe.get_cached_value( + "Item", + pi.item_code, + ["has_batch_no", "has_serial_no"], + as_dict=True, + ) + if not tracking: + continue + if tracking.has_batch_no or tracking.has_serial_no: + pi.use_serial_batch_fields = 1 + + def _combine_packed_qty_for_free_product_bundles(self): + """ + Merge packed_items from free bundle lines into the matching paid bundle line. + + ERPNext builds packed rows per Sales Invoice Item row. For BOGO / pricing-rule + free rows, the same product bundle often appears twice (paid + is_free_item). + That duplicates component rows. Stock and picking should follow total bundle + qty on one set of packed lines tied to the paid row. + """ + if self.is_return or not self.get("packed_items"): + return + + free_bundle_rows = [ + row + for row in self.get("items") + if row.item_code and cint(row.is_free_item) and self.has_product_bundle(row.item_code) + ] + if not free_bundle_rows: + return + + for free_row in free_bundle_rows: + paid_row = _find_paid_bundle_row_for_free(self, free_row) + if not paid_row: + continue + + to_remove = [] + for pi in list(self.get("packed_items")): + if pi.parent_detail_docname != free_row.name or pi.parent_item != free_row.item_code: + continue + tgt = _find_matching_packed_item_for_merge(self, paid_row, pi.item_code, pi.warehouse) + if tgt: + prec = tgt.precision("qty") + tgt.qty = flt(flt(tgt.qty) + flt(pi.qty), prec) + to_remove.append(pi) + + for pi in to_remove: + self.remove(pi) diff --git a/pos_next/pos_next/print_format/pos_next_receipt/pos_next_receipt.json b/pos_next/pos_next/print_format/pos_next_receipt/pos_next_receipt.json index 71330fcc..d7d90804 100644 --- a/pos_next/pos_next/print_format/pos_next_receipt/pos_next_receipt.json +++ b/pos_next/pos_next/print_format/pos_next_receipt/pos_next_receipt.json @@ -9,14 +9,14 @@ "docstatus": 0, "doctype": "Print Format", "font_size": 12, - "html": "\n\n{%- set currency_symbol = frappe.db.get_value(\"Currency\", doc.currency, \"symbol\") -%}\n{%- if not currency_symbol -%}\n\t{%- set currency_symbol = doc.currency -%}\n{%- endif -%}\n\n\n
{{ doc.company }}
\n
TAX INVOICE
\n\n\n
\n\t
Invoice: {{ doc.name }}
\n\t
Date: {{ doc.posting_date }} {{ (doc.posting_time|string).split('.')[0] if doc.posting_time else '' }}
\n\t{%- if doc.customer_name -%}\n\t
Customer: {{ doc.customer_name }}
\n\t{%- endif -%}\n\t{%- if doc.status == \"Partly Paid\" or (doc.outstanding_amount and doc.outstanding_amount > 0 and doc.outstanding_amount < doc.grand_total) -%}\n\t
Status: PARTIAL PAYMENT
\n\t{%- endif -%}\n
\n\n
\n\n\n{%- for item in doc.items -%}\n
\n\t
{{ item.item_name }}
\n\t
\n\t\t{{ \"%.0f\"|format(item.qty) }} \u00d7 {{ currency_symbol }} {{ \"%.2f\"|format(item.price_list_rate or item.rate) }}\n\t\t{{ currency_symbol }} {{ \"%.2f\"|format(item.qty * (item.price_list_rate or item.rate)) }}\n\t
\n\t{%- if item.discount_percentage or item.discount_amount -%}\n\t
\n\t\tDiscount{%- if item.discount_percentage -%} ({{ \"%.1f\"|format(item.discount_percentage) }}%){%- endif -%}\n\t\t-{{ currency_symbol }} {{ \"%.2f\"|format(item.discount_amount or 0) }}\n\t
\n\t{%- endif -%}\n\t{%- if item.serial_no -%}\n\t
\n\t\t
Serial No:
\n\t\t
{{ item.serial_no | replace(\"\\n\", \", \") }}
\n\t
\n\t{%- endif -%}\n
\n{%- endfor -%}\n\n
\n\n\n{%- if doc.total_taxes_and_charges and doc.total_taxes_and_charges > 0 -%}\n
\n\tSubtotal:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total - doc.total_taxes_and_charges) }}\n
\n
\n\tTax:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.total_taxes_and_charges) }}\n
\n{%- endif -%}\n{%- if doc.discount_amount -%}\n
\n\tAdditional Discount{%- if doc.additional_discount_percentage -%} ({{ \"%.1f\"|format(doc.additional_discount_percentage) }}%){%- endif -%}:\n\t-{{ currency_symbol }} {{ \"%.2f\"|format(doc.discount_amount|abs) }}\n
\n{%- endif -%}\n
\n\tTOTAL:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total) }}\n
\n\n\n{%- if doc.payments -%}\n
\n
Payments:
\n{%- for payment in doc.payments -%}\n
\n\t{{ payment.mode_of_payment }}:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(payment.amount) }}\n
\n{%- endfor -%}\n
\n\tTotal Paid:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.paid_amount or 0) }}\n
\n{%- if doc.change_amount > 0 -%}\n
\n\tChange:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.change_amount) }}\n
\n{%- endif -%}\n{%- if doc.outstanding_amount and doc.outstanding_amount > 0 -%}\n
\n\tBALANCE DUE:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.outstanding_amount) }}\n
\n{%- endif -%}\n{%- endif -%}\n\n\n
\n\t
Thank you for your business!
\n\t
Powered by POS Next
\n
", + "html": "\n\n{%- set currency_symbol = frappe.db.get_value(\"Currency\", doc.currency, \"symbol\") -%}\n{%- if not currency_symbol -%}\n\t{%- set currency_symbol = doc.currency -%}\n{%- endif -%}\n\n\n
{{ doc.company }}
\n
TAX INVOICE
\n\n\n
\n\t
Invoice: {{ doc.name }}
\n\t
Date: {{ doc.posting_date }} {{ (doc.posting_time|string).split('.')[0] if doc.posting_time else '' }}
\n\t{%- if doc.customer_name -%}\n\t
Customer: {{ doc.customer_name }}
\n\t{%- endif -%}\n\t{%- if doc.status == \"Partly Paid\" or (doc.outstanding_amount and doc.outstanding_amount > 0 and doc.outstanding_amount < doc.grand_total) -%}\n\t
Status: PARTIAL PAYMENT
\n\t{%- endif -%}\n
\n\n
\n\n\n{%- for item in doc.items -%}\n
\n\t
{{ item.item_name }}{%- if item.is_free_item %} ({{ _(\"FREE\") }}){%- endif -%}
\n\t
\n\t\t{%- if item.is_free_item -%}\n\t\t{{ \"%.0f\"|format(item.qty) }} \u00d7 {{ _(\"FREE\") }}\n\t\t{{ currency_symbol }} 0.00\n\t\t{%- else -%}\n\t\t{{ \"%.0f\"|format(item.qty) }} \u00d7 {{ currency_symbol }} {{ \"%.2f\"|format(item.price_list_rate or item.rate) }}\n\t\t{{ currency_symbol }} {{ \"%.2f\"|format(item.qty * (item.price_list_rate or item.rate)) }}\n\t\t{%- endif -%}\n\t
\n\t{%- if item.discount_percentage or item.discount_amount -%}\n\t
\n\t\tDiscount{%- if item.discount_percentage -%} ({{ \"%.1f\"|format(item.discount_percentage) }}%){%- endif -%}\n\t\t-{{ currency_symbol }} {{ \"%.2f\"|format(item.discount_amount or 0) }}\n\t
\n\t{%- endif -%}\n\t{%- if item.serial_no -%}\n\t
\n\t\t
Serial No:
\n\t\t
{{ item.serial_no | replace(\"\\n\", \", \") }}
\n\t
\n\t{%- endif -%}\n
\n{%- endfor -%}\n\n
\n\n\n{%- if doc.total_taxes_and_charges and doc.total_taxes_and_charges > 0 -%}\n
\n\tSubtotal:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total - doc.total_taxes_and_charges) }}\n
\n
\n\tTax:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.total_taxes_and_charges) }}\n
\n{%- endif -%}\n{%- if doc.discount_amount -%}\n
\n\tAdditional Discount{%- if doc.additional_discount_percentage -%} ({{ \"%.1f\"|format(doc.additional_discount_percentage) }}%){%- endif -%}:\n\t-{{ currency_symbol }} {{ \"%.2f\"|format(doc.discount_amount|abs) }}\n
\n{%- endif -%}\n
\n\tTOTAL:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total) }}\n
\n\n\n{%- if doc.payments -%}\n
\n
Payments:
\n{%- for payment in doc.payments -%}\n
\n\t{{ payment.mode_of_payment }}:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(payment.amount) }}\n
\n{%- endfor -%}\n
\n\tTotal Paid:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.paid_amount or 0) }}\n
\n{%- if doc.change_amount > 0 -%}\n
\n\tChange:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.change_amount) }}\n
\n{%- endif -%}\n{%- if doc.outstanding_amount and doc.outstanding_amount > 0 -%}\n
\n\tBALANCE DUE:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.outstanding_amount) }}\n
\n{%- endif -%}\n{%- endif -%}\n\n\n
\n\t
Thank you for your business!
\n\t
Powered by POS Next
\n
", "idx": 0, "line_breaks": 0, "margin_bottom": 0.0, "margin_left": 0.0, "margin_right": 0.0, "margin_top": 0.0, - "modified": "2026-02-26 15:22:47.699244", + "modified": "2026-05-13 12:30:00.000000", "modified_by": "Administrator", "module": "POS Next", "name": "POS Next Receipt",