Loyalty Programs Are No Longer a Retail Luxury—They're a Revenue Engine
Every e-commerce store and point-of-sale terminal competes for repeat purchases. Acquiring a new customer costs 5–7x more than retaining an existing one, yet most Odoo implementations leave the Loyalty module untouched. The default installation ships with the feature disabled, buried under the Sales → Configuration menu where it quietly waits for someone to flip the switch.
The business impact of not using it is measurable. Stores without loyalty programs see lower average order values, higher churn rates, and zero insight into which customers are one purchase away from becoming high-value accounts. Meanwhile, competitors hand out points, gift cards, and coupons that create switching costs and emotional attachment.
Odoo 19's loyalty engine is surprisingly powerful—it supports four distinct program types, flexible reward structures, conditional triggers, and native integration with both the Website and POS apps. This guide covers everything: how each program type works, how to configure rewards and trigger rules, how loyalty surfaces in e-commerce and POS, how to track performance, and the mistakes that silently break programs in production.
The Four Loyalty Program Types in Odoo 19
Odoo 19 organizes loyalty under four distinct program types. Each serves a different commercial purpose, and choosing the wrong type for your use case creates configuration headaches later. Here's what each one does and when to use it.
| Program Type | How It Works | Best For | Key Setting |
|---|---|---|---|
| Loyalty Cards | Customers earn points per order or per currency spent. Points accumulate across sessions and can be redeemed for rewards. | Repeat purchase incentives, tiered VIP programs | program_type = 'loyalty' |
| Coupons | Single-use or multi-use codes generated manually or in bulk. Customer enters the code at checkout to unlock a reward. | Targeted campaigns, influencer codes, win-back emails | program_type = 'coupons' |
| Promotions | Automatic discounts applied when order conditions are met. No code required—the system applies the reward if the cart qualifies. | Flash sales, buy-X-get-Y, minimum spend thresholds | program_type = 'promotion' |
| Gift Cards | Prepaid value cards sold as products. Buyers purchase a gift card; recipients redeem the balance at checkout. | Holiday campaigns, B2B incentives, customer credits | program_type = 'gift_card' |
Enabling the Loyalty Module
The loyalty engine lives in the loyalty module, which is auto-installed when you enable loyalty features via Sales → Configuration → Settings → Pricing. For e-commerce, also ensure website_sale_loyalty is installed. For POS, you need pos_loyalty.
# In the Odoo shell, verify which loyalty modules are installed
env['ir.module.module'].search([
('name', 'like', '%loyalty%'),
('state', '=', 'installed'),
]).mapped('name')
# Expected output for full coverage:
# ['loyalty', 'website_sale_loyalty', 'pos_loyalty']Creating a Loyalty Program via Code
While most users configure programs through the UI, understanding the model structure helps when automating program creation or debugging issues:
program = env['loyalty.program'].create({
'name': 'VIP Rewards Club',
'program_type': 'loyalty',
'applies_on': 'both', # 'current' | 'future' | 'both'
'trigger': 'auto', # 'auto' | 'with_code'
'portal_visible': True,
'rule_ids': [(0, 0, {
'reward_point_mode': 'money', # earn points per $ spent
'reward_point_amount': 1.0, # 1 point per $1
'minimum_amount': 25.00, # min order to earn
})],
'reward_ids': [(0, 0, {
'reward_type': 'discount',
'discount': 10.0, # 10% off
'discount_mode': 'percent',
'discount_applicability': 'order',
'required_points': 100, # costs 100 points
})],
})The most common configuration mistake is using a Promotion when you need a Loyalty Card. Promotions are stateless—they fire every time the cart meets the conditions. Loyalty cards are stateful—they track accumulated points per customer across orders. If you want customers to "build up" rewards over time, you need Loyalty Cards, not Promotions.
Configuring Rewards, Trigger Rules, and Point Earning Logic
Every loyalty program has two halves: how customers earn (trigger rules) and what they get (rewards). Odoo 19 separates these into distinct models—loyalty.rule and loyalty.reward—linked to the parent loyalty.program. Understanding this separation is critical because one program can have multiple rules and multiple rewards.
Trigger Rules: How Points Are Earned
Trigger rules define the conditions under which a customer earns points (or qualifies for a reward in the case of promotions). Key fields on loyalty.rule:
| Field | Values | Effect |
|---|---|---|
reward_point_mode | 'order', 'money', 'unit' | Earn flat points per order, per currency spent, or per product unit |
reward_point_amount | Float | Number of points earned per trigger (e.g., 1.5 points per $1) |
minimum_amount | Float | Minimum order total to qualify |
minimum_qty | Integer | Minimum product quantity to qualify |
product_ids | Many2many | Restrict earning to specific products |
product_category_id | Many2one | Restrict earning to a product category |
Reward Types: What Customers Get
Odoo 19 supports three reward types, each with its own configuration surface:
# 1. Discount reward: percentage or fixed amount off
discount_reward = {
'reward_type': 'discount',
'discount': 15.0,
'discount_mode': 'percent', # 'percent' | 'per_point' | 'per_order'
'discount_applicability': 'order', # 'order' | 'cheapest' | 'specific'
'discount_max_amount': 50.00, # cap the discount at $50
'required_points': 150,
}
# 2. Free product reward: get a product for free
free_product_reward = {
'reward_type': 'product',
'reward_product_id': product.id, # the free product
'reward_product_qty': 1,
'required_points': 200,
}
# 3. Free shipping reward (e-commerce only)
free_shipping_reward = {
'reward_type': 'shipping',
'required_points': 75,
}Advanced: Buy X Get Y with Promotions
The classic "Buy 2 Get 1 Free" is configured using a Promotion program with a quantity-based trigger and a free product reward:
promo = env['loyalty.program'].create({
'name': 'Buy 2 Get 1 Free - Summer T-Shirts',
'program_type': 'promotion',
'trigger': 'auto',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'reward_point_mode': 'unit',
'reward_point_amount': 1,
'minimum_qty': 3, # must have 3+ in cart
'product_ids': [(6, 0, tshirt_products.ids)],
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': tshirt_products[0].id,
'reward_product_qty': 1,
'required_points': 1,
})],
}) By default, multiple promotions can stack on the same order. If you run "10% off orders over $100" alongside "Free shipping on orders over $75," a $100 order gets both. To prevent stacking, set pricelist_ids on the program to restrict it to specific pricelists, or use the applies_on = 'current' setting so the discount applies only once per qualifying order.
E-commerce Integration: How Loyalty Programs Appear on Your Odoo Website
Installing website_sale_loyalty connects the loyalty engine to the shopping cart, checkout flow, and customer portal. But "installed" doesn't mean "visible"—several configuration steps determine what your customers actually see.
Cart-Level Coupon and Promo Code Entry
When a Coupon or Promotion program has trigger = 'with_code', a promo code input field appears on the cart page. Customers enter their code, the system validates it against the program's rules, and if valid, the reward is applied as a negative line on the order.
<!-- The promo code input is injected by website_sale_loyalty
into the cart template via this xpath: -->
<template id="reduction_code_form"
inherit_id="website_sale.cart">
<xpath expr="//div[@id='cart_total']" position="before">
<div class="o_reward_code_form mt-3">
<form method="post"
action="/shop/cart/coupon">
<input type="hidden" name="csrf_token"
t-att-value="request.csrf_token()"/>
<div class="input-group">
<input type="text" name="promo"
class="form-control"
placeholder="Enter promo code"/>
<button type="submit"
class="btn btn-secondary">
Apply
</button>
</div>
</form>
</div>
</xpath>
</template>Loyalty Points Display in Customer Portal
When portal_visible = True on a loyalty program, customers can see their points balance in the portal under My Account → Loyalty Cards. This is critical for engagement—customers can't redeem points they don't know they have.
# Find all loyalty cards for a specific customer
cards = env['loyalty.card'].search([
('partner_id', '=', customer.id),
('program_id.program_type', '=', 'loyalty'),
])
for card in cards:
print(f"Program: {{card.program_id.name}}")
print(f" Points: {{card.points}}")
print(f" Code: {{card.code}}")Gift Card Purchase Flow
Gift cards are sold as regular products on the website. The key is linking the product to the gift card program:
# The gift card product must be a service with specific settings
gift_product = env['product.product'].create({
'name': '$50 Gift Card',
'type': 'service',
'list_price': 50.00,
'sale_ok': True,
'purchase_ok': False,
'taxes_id': [(5, 0, 0)], # Gift cards are typically tax-exempt
})
# Link it to the gift card program
gift_program = env['loyalty.program'].create({
'name': 'Gift Cards',
'program_type': 'gift_card',
'trigger': 'with_code',
'applies_on': 'future',
'rule_ids': [(0, 0, {
'reward_point_mode': 'money',
'reward_point_amount': 1,
'product_ids': [(6, 0, [gift_product.id])],
})],
'reward_ids': [(0, 0, {
'reward_type': 'discount',
'discount_mode': 'per_point',
'discount': 1.0, # $1 discount per point
'discount_applicability': 'order',
'required_points': 1,
})],
}) When a gift card order is confirmed, Odoo automatically generates a loyalty.card record with a unique code and sends it to the buyer via email. Make sure your email template for gift cards is customized—the default template is minimal. Navigate to Settings → Technical → Email Templates and search for "Gift Card" to customize the design.
POS Integration: Loyalty Programs at the Point of Sale
The pos_loyalty module extends the POS interface with loyalty features. Cashiers can scan loyalty cards, apply coupon codes, redeem points, and sell gift cards—all within the POS session. The integration is real-time: points earned in-store are immediately available online, and vice versa.
POS Configuration
Each POS configuration can enable or disable specific loyalty programs. This allows you to run different promotions in different stores:
# Point of Sale → Configuration → Point of Sale
# Select your POS config, then under the "Pricing" tab:
# 1. Enable "Loyalty, Coupons & Gift Cards"
# 2. Select which programs are available in this POS
# 3. Programs with trigger='auto' apply automatically
# 4. Programs with trigger='with_code' require manual entry
# The POS will show:
# - A "Loyalty" button on the order screen
# - A "Promo Code" button for code-based programs
# - Earned points on the receiptCashier Workflow: Earning and Redeeming
The POS loyalty flow works in three steps:
- Customer identification — The cashier sets the customer on the order. This links the order to the customer's loyalty card. Without a customer, points-based programs won't track anything.
- Automatic earning — As products are scanned, the POS calculates earned points in real time based on the program's rules. The cashier sees a notification: "Customer will earn X points."
- Redemption — The cashier clicks "Rewards" to see available rewards. If the customer has enough points, they can apply the reward as a discount line on the order. The points are deducted from the loyalty card.
// Inside the POS JS, the loyalty card is fetched when
// a customer is set on the order:
async _onSetCustomer(partner) {
const loyaltyCards = await this.orm.searchRead(
'loyalty.card',
[
['partner_id', '=', partner.id],
['program_id.pos_ok', '=', true],
['points', '>', 0],
],
['program_id', 'points', 'code'],
);
this.currentOrder.setLoyaltyCards(loyaltyCards);
}Receipt Customization
By default, the POS receipt shows earned points and remaining balance. You can customize the receipt template to display the loyalty information more prominently:
<!-- Loyalty info on POS receipt (pos_loyalty module) -->
<t t-if="loyaltyPoints">
<br/>
<div style="text-align:center; font-weight:bold;">
--- LOYALTY REWARDS ---
</div>
<t t-foreach="loyaltyPoints" t-as="program">
<div>
<t t-out="program.program_name"/>
</div>
<div>
Points earned: +<t t-out="program.points_won"/>
</div>
<div>
Total balance: <t t-out="program.points_total"/>
</div>
</t>
</t>The POS loyalty module works offline, but with a caveat: loyalty card balances are cached locally. If a customer redeems points at one terminal while another terminal has a stale cache, double-redemption is possible. Points are reconciled when the POS session is closed and synced. For high-volume stores, consider shortening the sync interval or requiring online mode for redemptions above a threshold.
Loyalty Program Reporting and Analytics
Running a loyalty program without tracking its performance is like running ads without conversion metrics. Odoo 19 provides several reporting angles, but you need to know where to look—and what to build yourself.
Built-in Reports
| Report | Location | What It Shows |
|---|---|---|
| Loyalty Cards List | Sales → Loyalty Cards | All issued cards with points balance, customer, and program |
| Coupon Usage | Sales → Coupon Programs → Coupons | Generated codes, usage count, remaining uses |
| Gift Card Balances | Sales → Gift Cards | Outstanding gift card value (a liability on your books) |
Custom Analytics: Measuring Program ROI
The built-in reports show operational data but not business intelligence. Here's a SQL query pattern for measuring the actual ROI of your loyalty program:
-- Compare average order value: loyalty members vs. non-members
WITH loyalty_customers AS (
SELECT DISTINCT partner_id
FROM loyalty_card
WHERE program_id IN (
SELECT id FROM loyalty_program
WHERE program_type = 'loyalty'
)
AND points > 0
)
SELECT
CASE
WHEN lc.partner_id IS NOT NULL THEN 'Loyalty Member'
ELSE 'Non-Member'
END AS segment,
COUNT(DISTINCT so.id) AS total_orders,
ROUND(AVG(so.amount_total), 2) AS avg_order_value,
ROUND(SUM(so.amount_total), 2) AS total_revenue,
COUNT(DISTINCT so.partner_id) AS unique_customers
FROM sale_order so
LEFT JOIN loyalty_customers lc
ON so.partner_id = lc.partner_id
WHERE so.state IN ('sale', 'done')
AND so.date_order >= NOW() - INTERVAL '90 days'
GROUP BY
CASE
WHEN lc.partner_id IS NOT NULL THEN 'Loyalty Member'
ELSE 'Non-Member'
END;Tracking Outstanding Liability
Gift cards and unredeemed loyalty points represent a financial liability. For accounting compliance, you should track the total outstanding value:
# Total unredeemed gift card value
gift_cards = env['loyalty.card'].search([
('program_id.program_type', '=', 'gift_card'),
('points', '>', 0),
])
total_liability = sum(card.points for card in gift_cards)
print(f"Outstanding gift card liability: ${{total_liability:,.2f}}")"Breakage" is the industry term for gift cards and points that are never redeemed. Typical breakage rates are 10–15% for gift cards and 20–30% for loyalty points. Accounting standards (ASC 606 / IFRS 15) require you to recognize breakage revenue proportionally as redemptions occur. Make sure your finance team understands this before launching a large gift card program.
3 Loyalty Program Mistakes That Silently Cost You Revenue
Points Earned But Never Visible to Customers
You configure a beautiful loyalty program, customers earn points on every order, but nobody redeems. The reason: portal_visible is set to False (the default). Customers have no idea they have points. They never see a balance, never get prompted to redeem, and the program generates zero incremental revenue. You've created a cost center (you're giving discounts to the few who discover it by accident) with none of the retention benefits.
Always set portal_visible = True on loyalty programs. Then go further: customize the order confirmation email template to include the points earned and current balance. Add a banner on the cart page showing available rewards. The goal is to make points feel tangible. A points balance that customers can see is 3x more likely to drive a repeat purchase than one they can't.
Promotion Stacking That Erodes Your Margins
You launch three promotions: "10% off first order," "Free shipping over $50," and "Buy 2 Get 1 Free on accessories." A savvy customer adds 3 accessories ($30 each = $90), gets one free ($30 off), gets 10% off the remaining $60 ($6 off), and gets free shipping ($8 saved). Your effective discount is 49% on a $90 cart. The margin on those accessories just vanished. This happens because Odoo applies all qualifying promotions by default—there's no built-in stacking limit.
Use the rule_ids to set mutually exclusive conditions. Assign promotions to different pricelists so they can't co-exist on the same order. For code-based promotions, set maximum_use_number on the coupon. Most importantly: model the worst-case stacking scenario before launching. Calculate what happens when a customer qualifies for every active promotion simultaneously. If the combined discount exceeds your target margin, restructure the programs.
Gift Cards Not Generating loyalty.card Records After Purchase
A customer buys a $100 gift card. The order is confirmed, payment is received, but the recipient never gets a gift card code. The loyalty.card record was never created. This happens when the gift card product isn't correctly linked to the gift card program—either the product isn't in the program's rule product_ids, or the program's applies_on is set to 'current' instead of 'future'. You've taken the customer's money and delivered nothing.
After creating a gift card program, always test the full flow: purchase the gift card product, confirm the order, check that a loyalty.card record was created with the correct points balance, and verify the email was sent with the code. Automate this test in your CI pipeline. The three critical settings: the product must be in the rule's product_ids, the program must have applies_on = 'future', and the program trigger must be 'with_code'.
What a Well-Configured Loyalty Program Adds to Your Bottom Line
A loyalty program isn't a cost—it's an investment in customer lifetime value:
Customers with an active points balance are significantly more likely to return. The unredeemed balance creates a psychological switching cost that keeps them in your ecosystem.
Customers spend more to hit earning thresholds. A "Earn double points on orders over $75" rule nudges a $60 cart to $75—a 25% increase on that transaction at almost zero cost.
Gift card buyers spend an average of 20% above the card's face value. Combined with 10–15% breakage, gift cards generate net-positive revenue before accounting for the new customer acquisition they drive.
For a mid-size e-commerce store doing $500,000/month in revenue, a loyalty program that increases repeat purchase rate by 30% and AOV by 15% adds approximately $1.35M in annual incremental revenue. The cost of configuring and maintaining the program in Odoo is a fraction of a single month's uplift.
Optimization Metadata
Complete guide to Odoo 19 loyalty programs. Configure points, coupons, promotions, and gift cards for e-commerce and POS with trigger rules, rewards, and reporting.
1. "The Four Loyalty Program Types in Odoo 19"
2. "Configuring Rewards, Trigger Rules, and Point Earning Logic"
3. "E-commerce Integration: How Loyalty Programs Appear on Your Odoo Website"
4. "POS Integration: Loyalty Programs at the Point of Sale"
5. "3 Loyalty Program Mistakes That Silently Cost You Revenue"