🏋️ Practical Exercise
- Add a loyalty-points field to
res.partner. - Load it into POS.
- Show points on the POS UI.
- Award points on order validation.
- Show points on the receipt.
🔥 Challenge Exercise
Build a simple POS loyalty feature: store points on the customer, load them into POS, display and award points when an order is paid, and print the balance on the receipt. Explain how backend model data reaches the POS frontend and how order-validation hooks let you run custom logic.
pos.loyalty.cardmodel with partner, points, and card code- Python RPC method: look up a card by code from the POS
- Override
_order_fields()to save card code on the order - POS frontend: "Loyalty" button in the action pad
- Popup: scan/type card code → display points balance
- After payment: award points based on order total
- Receipt: show points earned and new total
Step 1: Loyalty Card Model
# models/pos_loyalty_card.py
from odoo import models, fields, api
import uuid
class PosLoyaltyCard(models.Model):
_name = 'pos.loyalty.card'
_description = 'POS Loyalty Card'
_rec_name = 'code'
code = fields.Char(
string='Card Code', required=True, index=True,
default=lambda self: str(uuid.uuid4())[:8].upper(),
copy=False,
)
partner_id = fields.Many2one('res.partner', string='Customer')
points = fields.Float(string='Points Balance', default=0.0)
company_id = fields.Many2one(
'res.company', default=lambda self: self.env.company
)
_sql_constraints = [
('unique_code', 'UNIQUE(code, company_id)',
'Loyalty card code must be unique per company.'),
]
@api.model
def lookup_card(self, code):
"""Called from POS frontend — returns card data or error."""
card = self.search([
('code', '=', code),
('company_id', '=', self.env.company.id),
], limit=1)
if not card:
return {'found': False, 'message': 'Card not found.'}
return {
'found': True,
'card_id': card.id,
'code': card.code,
'points': card.points,
'partner_name': card.partner_id.name or 'Guest',
}
@api.model
def award_points(self, card_id, amount, rate=1.0):
"""Award points for a purchase. Called after order payment."""
card = self.browse(card_id)
earned = round(amount * rate, 2)
card.points += earned
return earned
Step 2: Extend pos.order
# models/pos_order.py
from odoo import models, fields, api
class PosOrder(models.Model):
_inherit = 'pos.order'
loyalty_card_id = fields.Many2one('pos.loyalty.card', string='Loyalty Card')
loyalty_points_earned = fields.Float(string='Loyalty Points Earned')
@api.model
def _order_fields(self, ui_order):
fields = super()._order_fields(ui_order)
fields['loyalty_card_id'] = ui_order.get('loyalty_card_id')
fields['loyalty_points_earned'] = ui_order.get('loyalty_points_earned', 0)
return fields
def _process_order(self, order, draft, existing_order):
"""Award points after order is confirmed."""
result = super()._process_order(order, draft, existing_order)
# Find the confirmed order
pos_order = self.browse(result)
if pos_order.loyalty_card_id:
earned = self.env['pos.loyalty.card'].award_points(
pos_order.loyalty_card_id.id,
pos_order.amount_total,
)
pos_order.loyalty_points_earned = earned
return result
Step 3: POS Frontend
Three JavaScript pieces: the button (patch ActionpadWidget), the popup (LoyaltyCardPopup), and the order extension (patch Order to store the card and add to receipt):
/** @odoo-module **/
import { patch } from '@web/core/utils/patch';
import { Order } from '@point_of_sale/app/store/models';
// Store loyalty card on the order
patch(Order.prototype, {
setup(_defaultObj, options) {
super.setup(_defaultObj, options);
this.loyalty_card_id = null;
this.loyalty_points_earned = 0;
},
set_loyalty_card(cardData) {
this.loyalty_card_id = cardData.card_id;
this.loyalty_card_data = cardData;
},
export_as_JSON() {
const json = super.export_as_JSON();
json.loyalty_card_id = this.loyalty_card_id;
json.loyalty_points_earned = this.loyalty_points_earned;
return json;
},
export_for_printing() {
const result = super.export_for_printing();
result.loyalty_card_data = this.loyalty_card_data || null;
return result;
},
});
Step 4: Receipt Integration
<t t-name="my_loyalty.ReceiptExtension"
t-inherit="point_of_sale.receipt"
t-inherit-mode="extension">
<xpath expr="//div[hasclass('pos-receipt-orderlines')]" position="after">
<t t-if="receipt.loyalty_card_data">
<div class="pos-receipt-separator"/>
<div class="pos-receipt-loyalty">
<div>Loyalty Card: <t t-esc="receipt.loyalty_card_data.code"/></div>
<div>Points Earned: <t t-esc="receipt.loyalty_card_data.points_earned or 0"/></div>
<div>New Balance: <t t-esc="receipt.loyalty_card_data.points"/></div>
</div>
</t>
</xpath>
</t>
- Add a "Redeem Points" button that applies a discount based on points balance
- Show loyalty card balance history in the backend (a One2many of transactions)
- Add a QR code to the receipt that links to the customer's loyalty portal page
- Integrate the loyalty card lookup into the customer selection step
- Add tiers: Bronze/Silver/Gold based on total points, with different earn rates
Interview Questions
- How do you add customer data to the POS?
- Where do you hook order validation in POS?
- How do you display custom data on the POS screen?
- How do you persist points back to the backend?
- How do you add data to a POS receipt?
Related Topics
FAQ
export_as_JSON() to include the loyalty card? +export_as_JSON() is the method the POS frontend calls to serialize an order for transmission to the server. Adding loyalty_card_id here ensures the field travels with the order in the same JSON payload — the server-side _order_fields() override then extracts and saves it.
pos.loyalty.card.code for? +The unique constraint prevents two loyalty cards from sharing the same code within a company. Without it, the lookup_card() RPC could return multiple cards for one scan, causing ambiguity. The company_id scope allows the same card code to be reused across different companies in a multi-company setup.
export_for_printing() for receipt data? +The POS receipt template reads from the object returned by export_for_printing(), not directly from the order model. Adding loyalty data to this method's return value makes it available to the QWeb receipt template without needing to change how the template fetches data.

