Selling, CRM & POS
The Selling module is where revenue begins. From capturing a lead’s first inquiry to closing a deal, delivering goods, and collecting payment, ERPNext provides a complete sales lifecycle. For our ScoopJoy franchise, this means managing walk-in customers at POS terminals, B2B bulk orders from event caterers, loyalty programs for repeat customers, and promotional pricing during the summer season.
The sales cycle
Section titled “The sales cycle”ERPNext implements the full order-to-cash cycle. Each document links to the next, creating a traceable chain.
flowchart LR L["Lead"] --> O["Opportunity"] O --> Q["Quotation"] Q --> SO["Sales Order"] SO --> DN["Delivery Note"] DN --> SI["Sales Invoice"] SI --> PE["Payment Entry"]
| Document | Purpose | Accounting impact |
|---|---|---|
| Lead | Potential customer, raw contact info | None |
| Opportunity | Qualified lead with estimated value | None |
| Quotation | Formal price proposal | None |
| Sales Order | Confirmed order, binding commitment | Stock reservation (v16) |
| Delivery Note | Goods shipped/handed over | Stock reduced |
| Sales Invoice | Bill raised | Revenue booked, receivable created |
| Payment Entry | Money received | Receivable settled |
Not every transaction needs every step. A walk-in POS sale skips Lead through Sales Order entirely; a B2B customer might go through every stage. ERPNext lets you enter the cycle at any point.
Customer master
Section titled “Customer master”The Customer DocType is central to selling. It stores contact details, credit terms, and categorization.
Customer groups and territories
Section titled “Customer groups and territories”Customer Groups create a hierarchy for reporting and pricing:
All Customer Groups +-- Retail | +-- Walk-in | +-- Online +-- Wholesale | +-- Distributors | +-- Event Caterers +-- Franchise +-- Owned Outlets +-- Partner OutletsTerritories work similarly for geographic segmentation:
All Territories +-- North India | +-- Delhi NCR | +-- Punjab +-- South India +-- Bangalore +-- ChennaiCredit limits
Section titled “Credit limits”Set credit limits per customer to prevent over-extension:
# Setting credit limit programmaticallycustomer = frappe.get_doc("Customer", "CUST-0042")customer.append("credit_limits", { "company": "ScoopJoy Ice Creams Pvt Ltd", "credit_limit": 500000, # INR 5,00,000 "bypass_credit_limit_check_at_sales_order": 0})customer.save()When a Sales Order or Sales Invoice would push the customer beyond their limit, ERPNext blocks submission unless an authorized user overrides it.
Creating a customer via API
Section titled “Creating a customer via API”import frappe
def create_franchise_customer(outlet_name, territory, credit_limit=100000): """Create a customer for a new franchise outlet.""" customer = frappe.new_doc("Customer") customer.customer_name = outlet_name customer.customer_type = "Company" customer.customer_group = "Franchise - Partner Outlets" customer.territory = territory customer.default_currency = "INR" customer.default_price_list = "Franchise Price List" customer.payment_terms_template = "Net 30"
customer.append("credit_limits", { "company": "ScoopJoy Ice Creams Pvt Ltd", "credit_limit": credit_limit })
customer.insert(ignore_permissions=True) frappe.db.commit() return customer.namePricing rules and promotional schemes
Section titled “Pricing rules and promotional schemes”Pricing Rules let you define automatic discounts, rate changes, or free items based on conditions.
Types of pricing rules
Section titled “Types of pricing rules”| Type | Use case |
|---|---|
| Price Discount | Flat or percentage discount on rate |
| Product Discount | Free item(s) with purchase |
| Rate | Override the selling rate entirely |
Pricing rule priorities
Section titled “Pricing rule priorities”When multiple rules apply, ERPNext uses priority, then specificity (Item Code > Item Group > Brand), then the rule with the higher minimum quantity.
For complex multi-slab promotions, use Promotional Schemes instead of individual Pricing Rules. They auto-generate the underlying Pricing Rules for you.
CRM: lead and opportunity management
Section titled “CRM: lead and opportunity management”This is the front of the funnel — capturing interest before any money moves. In
Express you might wire a contact form to a leads table; in Frappe the Lead
DocType already gives you the form, the API, and the conversion workflow.
Lead lifecycle
Section titled “Lead lifecycle”stateDiagram-v2 [*] --> Open Open --> Replied Replied --> Opportunity Replied --> DoNotContact["Do Not Contact"] Opportunity --> Converted Converted --> Customer["Customer created"] Customer --> [*]
Leads can be created manually, via web forms, or programmatically from external sources:
def create_lead_from_enquiry(name, email, source="Website"): """Create a lead from an external enquiry form.""" lead = frappe.new_doc("Lead") lead.lead_name = name lead.email_id = email lead.source = source lead.company = "ScoopJoy Ice Creams Pvt Ltd" lead.insert(ignore_permissions=True) frappe.db.commit() return lead.nameOpportunity tracking
Section titled “Opportunity tracking”When a Lead becomes serious, convert it to an Opportunity:
# Convert Lead to Opportunity (programmatic approach)opportunity = frappe.new_doc("Opportunity")opportunity.opportunity_from = "Lead"opportunity.party_name = lead.nameopportunity.opportunity_type = "Sales"opportunity.source = lead.sourceopportunity.opportunity_amount = 250000opportunity.expected_closing = "2026-04-15"opportunity.save()frappe.db.commit()Campaign management
Section titled “Campaign management”Campaigns group marketing efforts and track their effectiveness:
campaign = frappe.new_doc("Campaign")campaign.campaign_name = "Summer 2026 Mango Madness"campaign.insert()
# Assign campaign to a leadlead.campaign_name = "Summer 2026 Mango Madness"lead.save()Track campaign performance through the Campaign report, which shows leads generated, opportunities created, and revenue closed per campaign.
Selling settings
Section titled “Selling settings”Navigate to Selling Settings to configure defaults:
| Setting | Recommended value | Purpose |
|---|---|---|
| Customer Naming By | Naming Series | Auto-generate IDs like CUST-0001 |
| Default Customer Group | Retail | For walk-in customers |
| Default Territory | All Territories | Catch-all |
| Default Price List | Standard Selling | Base price list |
| Sales Order Required | Yes | Enforce SO before Delivery Note |
| Delivery Note Required | Yes | Enforce DN before Sales Invoice |
| Allow Multiple Sales Orders Against a Customer’s PO | No | Prevent duplicate orders |
| Hide Customer’s Tax ID from Sales Transactions | No | Show GSTIN on invoices |
Point of Sale (POS)
Section titled “Point of Sale (POS)”The POS module in ERPNext v16 received significant improvements: faster billing flows, improved offline stability, smoother item scanning, partial payment support, and a new list-style item selector.
POS architecture
Section titled “POS architecture”There are two POS modes in v16. The default merges every shift’s sales into one consolidated Sales Invoice; the new mode writes a Sales Invoice per sale for real-time accounting.
flowchart TB P["POS Profile"] --> OE["POS Opening Entry"] OE --> PI["POS Invoice<br/>(each sale)"] PI --> CE["POS Closing Entry<br/>(end of shift)"] CE --> ML["POS Invoice Merge Log<br/>(consolidates)"] ML --> SI["Sales Invoice<br/>(one per closing)"]
Enable “Use Sales Invoice in POS” in Accounts Settings.
flowchart TB P["POS Profile"] --> OE["POS Opening Entry"] OE --> SI["Sales Invoice<br/>(each sale, directly)"]
POS Profile setup
Section titled “POS Profile setup”A POS Profile defines the configuration for an outlet’s POS terminal:
def setup_pos_profile(outlet_name, warehouse, cost_center): """Set up a POS Profile for a ScoopJoy outlet.""" pos_profile = frappe.new_doc("POS Profile") pos_profile.name = f"POS - {outlet_name}" pos_profile.company = "ScoopJoy Ice Creams Pvt Ltd" pos_profile.warehouse = warehouse pos_profile.write_off_account = "Write Off - SJ" pos_profile.write_off_cost_center = cost_center pos_profile.selling_price_list = "Standard Selling" pos_profile.currency = "INR" pos_profile.customer = "Walk-in Customer"
# Item Group filter -- show only retail items pos_profile.append("item_groups", { "item_group": "Ice Cream - Retail" })
# Payment methods pos_profile.append("payments", { "mode_of_payment": "Cash", "default": 1 }) pos_profile.append("payments", { "mode_of_payment": "UPI" }) pos_profile.append("payments", { "mode_of_payment": "Credit Card" })
# POS-specific settings pos_profile.apply_discount_on = "Grand Total" pos_profile.update_stock = 1 pos_profile.ignore_pricing_rule = 0 pos_profile.hide_unavailable_items = 1 pos_profile.auto_add_item_to_cart = 1
pos_profile.insert() return pos_profile.namePOS Opening Entry (shift start)
Section titled “POS Opening Entry (shift start)”Before using POS, a cashier opens their shift:
opening = frappe.new_doc("POS Opening Entry")opening.pos_profile = "POS - ScoopJoy Outlet 1"opening.user = "cashier1@scoopjoy.com"opening.company = "ScoopJoy Ice Creams Pvt Ltd"opening.append("balance_details", { "mode_of_payment": "Cash", "opening_amount": 5000 # Starting cash in register})opening.submit()POS Invoice (transaction)
Section titled “POS Invoice (transaction)”Each sale at the counter creates a POS Invoice:
# Programmatic POS Invoice (typically done through UI)pos_invoice = frappe.new_doc("POS Invoice")pos_invoice.pos_profile = "POS - ScoopJoy Outlet 1"pos_invoice.customer = "Walk-in Customer"pos_invoice.company = "ScoopJoy Ice Creams Pvt Ltd"pos_invoice.update_stock = 1
# Add items (normally via barcode scanning in UI)pos_invoice.append("items", { "item_code": "ICE-VAN-500", "item_name": "Vanilla Ice Cream 500ml", "qty": 2, "rate": 250, "warehouse": "Outlet 1 - SJ"})pos_invoice.append("items", { "item_code": "ICE-CHOC-500", "item_name": "Chocolate Ice Cream 500ml", "qty": 1, "rate": 280, "warehouse": "Outlet 1 - SJ"})
# Payment -- split across modespos_invoice.append("payments", { "mode_of_payment": "Cash", "amount": 500})pos_invoice.append("payments", { "mode_of_payment": "UPI", "amount": 280})
pos_invoice.submit()POS Closing Entry (shift end)
Section titled “POS Closing Entry (shift end)”At the end of a shift, the cashier reconciles:
closing = frappe.new_doc("POS Closing Entry")closing.pos_profile = "POS - ScoopJoy Outlet 1"closing.user = "cashier1@scoopjoy.com"closing.pos_opening_entry = opening.nameclosing.posting_date = frappe.utils.today()
# System calculates expected amounts from POS Invoices# Cashier enters actual counted amounts for reconciliationclosing.append("payment_reconciliation", { "mode_of_payment": "Cash", "expected_amount": 12500, "closing_amount": 12450 # Difference = Rs 50 short})closing.append("payment_reconciliation", { "mode_of_payment": "UPI", "expected_amount": 8700, "closing_amount": 8700})
closing.submit()Offline POS
Section titled “Offline POS”ERPNext POS works offline through browser local storage. Invoices created offline are synced when connectivity resumes. In v16, offline reliability has been significantly improved — billing remains stable in low-connectivity environments with consistent inventory updates.
Configure offline POS by ensuring:
- The POS page is loaded at least once while online (to cache assets).
- Items and pricing data are cached locally.
- When back online, invoices auto-sync in sequence.
v16 POS improvements summary
Section titled “v16 POS improvements summary”| Feature | Description |
|---|---|
| Faster billing flows | Optimized checkout speed and item scanning |
| Partial payments | Accept and track partial payments with outstanding |
| List-style item selector | Better visibility for long item names |
| Sales Invoice in POS | Direct Sales Invoice creation for real-time accounting |
| Improved offline | Stable billing in low-connectivity environments |
| Multi-counter support | Stable high-speed interface for multi-location retail |
| Consolidated invoices | POS Invoice Merge Log for end-of-day consolidation |
Loyalty program
Section titled “Loyalty program”ERPNext’s Loyalty Program lets customers earn points on purchases and redeem them later.
Setting up a loyalty program
Section titled “Setting up a loyalty program”def setup_scoopjoy_loyalty(): """Create the ScoopJoy loyalty program.""" program = frappe.new_doc("Loyalty Program") program.loyalty_program_name = "ScoopJoy Rewards" program.loyalty_program_type = "Multiple Tier" program.company = "ScoopJoy Ice Creams Pvt Ltd" program.auto_opt_in = 1 program.customer_group = "Retail" program.customer_territory = "All Territories" program.conversion_factor = 1 # 1 point = INR 1 program.expiry_duration = 365 # Points expire after 1 year program.expense_account = "Loyalty Expense - SJ" program.cost_center = "Main - SJ"
# Silver tier: spend INR 300 to earn 1 point program.append("collection_rules", { "tier_name": "Silver", "collection_factor": 300, "min_spent": 0 })
# Gold tier: spend INR 200 to earn 1 point (better rate) program.append("collection_rules", { "tier_name": "Gold", "collection_factor": 200, "min_spent": 10000 # After spending INR 10,000 total })
# Platinum: spend INR 100 to earn 1 point program.append("collection_rules", { "tier_name": "Platinum", "collection_factor": 100, "min_spent": 50000 # After spending INR 50,000 total })
program.insert() return program.nameThe tiers above translate to a simple earning matrix:
| Tier | Collection factor | Unlocked after | Effective rate |
|---|---|---|---|
| Silver | 300 | INR 0 spent | 1 point per INR 300 |
| Gold | 200 | INR 10,000 spent | 1 point per INR 200 |
| Platinum | 100 | INR 50,000 spent | 1 point per INR 100 |
Earning points
Section titled “Earning points”Points are automatically calculated when a Sales Invoice (or POS Invoice) is submitted for an enrolled customer. The system checks the customer’s total spend to determine their tier, then applies the collection factor.
Redeeming points
Section titled “Redeeming points”During a new Sales Invoice, enable “Redeem Loyalty Points” to apply accrued points as a discount:
# Check customer's loyalty pointspoints = frappe.db.sql(""" SELECT SUM(loyalty_points) as total_points FROM `tabLoyalty Point Entry` WHERE customer = %s AND expiry_date >= CURDATE()""", "CUST-0042", as_dict=True)
print(f"Available points: {points[0].total_points}")# With conversion_factor = 1, 150 points = INR 150 discountLoyalty in POS
Section titled “Loyalty in POS”Loyalty points can be redeemed directly in the POS interface. The cashier enables the “Redeem Loyalty Points” checkbox, and the system auto-calculates the discount based on available points and the conversion factor.
Practical examples
Section titled “Practical examples”Example 1: POS for “ScoopJoy Outlet 1” with barcode scanning
Section titled “Example 1: POS for “ScoopJoy Outlet 1” with barcode scanning”-
Configure items with barcodes.
# Add barcode to an existing itemitem = frappe.get_doc("Item", "ICE-VAN-500")item.append("barcodes", {"barcode": "8901234567890","barcode_type": "EAN"})item.save() -
Create the POS Profile via Accounts > POS Profile > New:
Field Value Company ScoopJoy Ice Creams Pvt Ltd Warehouse Outlet 1 - SJ Customer Walk-in Customer Currency INR Update Stock Yes Hide Unavailable Items Yes Auto Add Filtered Item To Cart Yes Add payment methods: Cash (default), UPI, Credit Card. Add applicable item groups: “Ice Cream - Retail”, “Beverages”, “Toppings”.
-
Assign users to restrict access:
pos_profile = frappe.get_doc("POS Profile", "POS - ScoopJoy Outlet 1")pos_profile.append("applicable_for_users", {"user": "cashier1@scoopjoy.com","default": 1})pos_profile.save() -
Use POS. Navigate to POS in the sidebar. The cashier scans barcodes with a USB/Bluetooth scanner; each scan adds the item to the cart. The cashier selects payment mode, collects payment, and submits. The receipt prints automatically if a print format is configured.
Example 2: “Buy 2 Get 1 Free” pricing rule on mango ice cream
Section titled “Example 2: “Buy 2 Get 1 Free” pricing rule on mango ice cream”Create a Pricing Rule via the UI (Accounts > Pricing Rule > New) or programmatically:
def create_buy2_get1_free(): """Buy 2 Mango Ice Cream 500ml, get 1 free.""" rule = frappe.new_doc("Pricing Rule") rule.title = "Mango Madness - Buy 2 Get 1 Free" rule.apply_on = "Item Code" rule.price_or_product_discount = "Product" rule.selling = 1 rule.buying = 0 rule.applicable_for = "Customer Group" rule.customer_group = "Retail" rule.valid_from = "2026-04-01" rule.valid_upto = "2026-06-30" # Summer season only
# Condition: minimum 2 of Mango Ice Cream rule.min_qty = 2 rule.append("items", { "item_code": "ICE-MAN-500" })
# Free item rule.free_item = "ICE-MAN-500" rule.free_qty = 1 rule.free_item_rate = 0 rule.same_item = 1
rule.insert() return rule.nameFor tiered discounts, use a Promotional Scheme — it auto-generates the underlying Pricing Rules for each slab:
def create_summer_promo(): """Tiered promotional scheme for summer.""" scheme = frappe.new_doc("Promotional Scheme") scheme.name = "Summer 2026 Promo" scheme.apply_on = "Item Group" scheme.selling = 1 scheme.applicable_for = "Customer Group" scheme.customer_group = "Retail" scheme.valid_from = "2026-04-01" scheme.valid_upto = "2026-06-30"
scheme.append("promotional_scheme_product_discount", { "rule_description": "Buy 2 get 1 free", "min_qty": 2, "max_qty": 5, "free_item": "ICE-MAN-500", "free_qty": 1, "free_item_rate": 0, "same_item": 1 }) scheme.append("promotional_scheme_product_discount", { "rule_description": "Buy 6 get 3 free", "min_qty": 6, "max_qty": 10, "free_item": "ICE-MAN-500", "free_qty": 3, "free_item_rate": 0, "same_item": 1 })
scheme.append("item_groups", { "item_group": "Ice Cream - Retail" })
scheme.insert() return scheme.nameExample 3: creating a Sales Order from an external app via API
Section titled “Example 3: creating a Sales Order from an external app via API”An online ordering app or franchise management system can push orders to ERPNext. The first approach uses the REST API over HTTP — the way an external Node.js or Python service would talk to ERPNext from outside the bench.
import requests
ERPNEXT_URL = "https://erp.scoopjoy.com"API_KEY = "your_api_key"API_SECRET = "your_api_secret"
def create_sales_order(customer, items, delivery_date): """Create a Sales Order in ERPNext from an external app.""" headers = { "Authorization": f"token {API_KEY}:{API_SECRET}", "Content-Type": "application/json" }
so_items = [] for item in items: so_items.append({ "item_code": item["sku"], "qty": item["quantity"], "rate": item["price"], "delivery_date": delivery_date, "warehouse": "Central Warehouse - SJ" })
payload = { "doctype": "Sales Order", "customer": customer, "company": "ScoopJoy Ice Creams Pvt Ltd", "transaction_date": "2026-03-20", "delivery_date": delivery_date, "order_type": "Sales", "currency": "INR", "selling_price_list": "Standard Selling", "items": so_items }
response = requests.post( f"{ERPNEXT_URL}/api/resource/Sales Order", json=payload, headers=headers )
if response.status_code == 200: so_name = response.json()["data"]["name"] print(f"Sales Order created: {so_name}") return so_name else: raise Exception(f"Failed: {response.text}")
# Usage from an online ordering systemcreate_sales_order( customer="CUST-0042", items=[ {"sku": "ICE-VAN-500", "quantity": 10, "price": 250}, {"sku": "ICE-CHOC-500", "quantity": 5, "price": 280}, {"sku": "ICE-MAN-500", "quantity": 8, "price": 300} ], delivery_date="2026-03-25")If your integration lives inside a custom Frappe app or a server script, skip HTTP entirely and use the ORM directly — cleaner, transactional, and permission-aware:
import frappe
# Inside a custom Frappe app or server scriptdef sync_external_order(order_data): """Sync an order from the franchise ordering portal.""" so = frappe.new_doc("Sales Order") so.customer = order_data["customer"] so.company = "ScoopJoy Ice Creams Pvt Ltd" so.delivery_date = order_data["delivery_date"] so.po_no = order_data.get("external_ref") # External reference
for item in order_data["items"]: so.append("items", { "item_code": item["sku"], "qty": item["qty"], "rate": item["rate"], "warehouse": "Central Warehouse - SJ", "delivery_date": order_data["delivery_date"] })
so.insert() so.submit() frappe.db.commit()
return so.nameExample 4: loyalty program for franchise customers
Section titled “Example 4: loyalty program for franchise customers”Building on the loyalty program from above, assign it to a customer, then earn and redeem points:
# Assign loyalty program to a specific customercustomer = frappe.get_doc("Customer", "CUST-0042")customer.loyalty_program = "ScoopJoy Rewards"customer.save()
# Simulate a purchase and check points# After a Sales Invoice of INR 3,000 is submitted:# Silver tier (collection_factor = 300): 3000 / 300 = 10 points earned
# Query earned pointspoints = frappe.get_all( "Loyalty Point Entry", filters={ "customer": "CUST-0042", "expiry_date": [">=", frappe.utils.today()] }, fields=["SUM(loyalty_points) as total"])print(f"Total active points: {points[0].total}")
# Redeem points on next invoiceinvoice = frappe.new_doc("Sales Invoice")invoice.customer = "CUST-0042"invoice.redeem_loyalty_points = 1invoice.loyalty_points = 10 # Redeem 10 points = INR 10 discountinvoice.loyalty_program = "ScoopJoy Rewards"invoice.loyalty_redemption_account = "Loyalty Expense - SJ"invoice.loyalty_redemption_cost_center = "Main - SJ"# ... add items and submit