Skip to content

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.

ERPNext implements the full order-to-cash cycle. Each document links to the next, creating a traceable chain.

Order-to-cash document chain
Rendering diagram…
DocumentPurposeAccounting impact
LeadPotential customer, raw contact infoNone
OpportunityQualified lead with estimated valueNone
QuotationFormal price proposalNone
Sales OrderConfirmed order, binding commitmentStock reservation (v16)
Delivery NoteGoods shipped/handed overStock reduced
Sales InvoiceBill raisedRevenue booked, receivable created
Payment EntryMoney receivedReceivable 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.

The Customer DocType is central to selling. It stores contact details, credit terms, and categorization.

Customer Groups create a hierarchy for reporting and pricing:

All Customer Groups
+-- Retail
| +-- Walk-in
| +-- Online
+-- Wholesale
| +-- Distributors
| +-- Event Caterers
+-- Franchise
+-- Owned Outlets
+-- Partner Outlets

Territories work similarly for geographic segmentation:

All Territories
+-- North India
| +-- Delhi NCR
| +-- Punjab
+-- South India
+-- Bangalore
+-- Chennai

Set credit limits per customer to prevent over-extension:

# Setting credit limit programmatically
customer = 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.

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

Pricing Rules let you define automatic discounts, rate changes, or free items based on conditions.

TypeUse case
Price DiscountFlat or percentage discount on rate
Product DiscountFree item(s) with purchase
RateOverride the selling rate entirely

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.

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
Rendering diagram…

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

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.name
opportunity.opportunity_type = "Sales"
opportunity.source = lead.source
opportunity.opportunity_amount = 250000
opportunity.expected_closing = "2026-04-15"
opportunity.save()
frappe.db.commit()

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

Navigate to Selling Settings to configure defaults:

SettingRecommended valuePurpose
Customer Naming ByNaming SeriesAuto-generate IDs like CUST-0001
Default Customer GroupRetailFor walk-in customers
Default TerritoryAll TerritoriesCatch-all
Default Price ListStandard SellingBase price list
Sales Order RequiredYesEnforce SO before Delivery Note
Delivery Note RequiredYesEnforce DN before Sales Invoice
Allow Multiple Sales Orders Against a Customer’s PONoPrevent duplicate orders
Hide Customer’s Tax ID from Sales TransactionsNoShow GSTIN on invoices

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.

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.

Default POS shift flow
Rendering diagram…

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

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()

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 modes
pos_invoice.append("payments", {
"mode_of_payment": "Cash",
"amount": 500
})
pos_invoice.append("payments", {
"mode_of_payment": "UPI",
"amount": 280
})
pos_invoice.submit()

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.name
closing.posting_date = frappe.utils.today()
# System calculates expected amounts from POS Invoices
# Cashier enters actual counted amounts for reconciliation
closing.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()

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:

  1. The POS page is loaded at least once while online (to cache assets).
  2. Items and pricing data are cached locally.
  3. When back online, invoices auto-sync in sequence.
FeatureDescription
Faster billing flowsOptimized checkout speed and item scanning
Partial paymentsAccept and track partial payments with outstanding
List-style item selectorBetter visibility for long item names
Sales Invoice in POSDirect Sales Invoice creation for real-time accounting
Improved offlineStable billing in low-connectivity environments
Multi-counter supportStable high-speed interface for multi-location retail
Consolidated invoicesPOS Invoice Merge Log for end-of-day consolidation

ERPNext’s Loyalty Program lets customers earn points on purchases and redeem them later.

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

The tiers above translate to a simple earning matrix:

TierCollection factorUnlocked afterEffective rate
Silver300INR 0 spent1 point per INR 300
Gold200INR 10,000 spent1 point per INR 200
Platinum100INR 50,000 spent1 point per INR 100

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.

During a new Sales Invoice, enable “Redeem Loyalty Points” to apply accrued points as a discount:

# Check customer's loyalty points
points = 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 discount

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.

Example 1: POS for “ScoopJoy Outlet 1” with barcode scanning

Section titled “Example 1: POS for “ScoopJoy Outlet 1” with barcode scanning”
  1. Configure items with barcodes.

    # Add barcode to an existing item
    item = frappe.get_doc("Item", "ICE-VAN-500")
    item.append("barcodes", {
    "barcode": "8901234567890",
    "barcode_type": "EAN"
    })
    item.save()
  2. Create the POS Profile via Accounts > POS Profile > New:

    FieldValue
    CompanyScoopJoy Ice Creams Pvt Ltd
    WarehouseOutlet 1 - SJ
    CustomerWalk-in Customer
    CurrencyINR
    Update StockYes
    Hide Unavailable ItemsYes
    Auto Add Filtered Item To CartYes

    Add payment methods: Cash (default), UPI, Credit Card. Add applicable item groups: “Ice Cream - Retail”, “Beverages”, “Toppings”.

  3. 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()
  4. 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.name

For 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.name

Example 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 system
create_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 script
def 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.name

Example 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 customer
customer = 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 points
points = 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 invoice
invoice = frappe.new_doc("Sales Invoice")
invoice.customer = "CUST-0042"
invoice.redeem_loyalty_points = 1
invoice.loyalty_points = 10 # Redeem 10 points = INR 10 discount
invoice.loyalty_program = "ScoopJoy Rewards"
invoice.loyalty_redemption_account = "Loyalty Expense - SJ"
invoice.loyalty_redemption_cost_center = "Main - SJ"
# ... add items and submit