Ad – 728×90
🛠️ Projects

POS Loyalty Module Project – Custom Loyalty Program in Odoo POS

This project builds a full-stack POS loyalty system: a Python model for loyalty cards, a backend to earn and redeem points, a POS frontend button that opens a card lookup popup, and receipt integration. It covers the complete Python-to-JavaScript roundtrip that most POS customizations require.

⏱️ 5-7 hours 🎯 Advanced 📅 Updated 2026
What you'll build:
  • pos.loyalty.card model 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