Advertisement
🛠️ 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

🏋️ Practical Exercise

  1. Add a loyalty-points field to res.partner.
  2. Load it into POS.
  3. Show points on the POS UI.
  4. Award points on order validation.
  5. 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.

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
Advertisement

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

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?

FAQ

Why override 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.

What is the UNIQUE constraint on 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.

Why override 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.