What you'll build:
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
Python
# 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
Python
# 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
Ad – 728×90
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):
JavaScript
/** @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
XML (OWL template)
<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>
Extension challenges:
- 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