initial commit

This commit is contained in:
Abdul Aziz Amrullah 2026-05-04 09:48:00 +07:00
commit 1a38a52a05
9 changed files with 217 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

24
README.md Normal file
View File

@ -0,0 +1,24 @@
# POS Loyalty Usage Limit
## Overview
This custom Odoo 19 module enhances the Point of Sale (POS) Loyalty and Promotions system by introducing redemption usage limits on individual loyalty cards. It allows administrators to restrict how many times a customer can use their loyalty card to redeem rewards within a specific time period (per day, per month, or per year).
## Features
* **Flexible Limits**: Set usage limits to be enforced per day, per month, or per year.
* **Card-Level Configuration**: The limit configuration is set individually on each `loyalty.card`, allowing some VIP cards to be unlimited while standard cards are restricted.
* **Smart POS UI Filtering**: In the POS interface, if a card has reached its limit, the "Reward" button is automatically hidden, preventing cashiers from attempting invalid redemptions.
* **Offline/Online Synchronization**: The frontend logic seamlessly blends backend historical usage data with current un-synced POS session data to calculate real-time usage.
* **Strict Backend Validation**: A secure RPC validation check runs at checkout, preventing users from bypassing the limits by using the same card concurrently across multiple active POS sessions.
## Configuration
1. Navigate to **Sales** > **Products** > **Discount & Loyalty** and open a loyalty program.
2. Click on the **Cards** smart button or directly navigate to a specific Loyalty Card.
3. Check the **Limit** boolean checkbox to enable the restriction.
4. Specify the **Limit Count** (e.g., `1`).
5. Choose the **Limit Period** (`Per Day`, `Per Month`, or `Per Year`).
6. If the **Limit** checkbox is left unchecked, the loyalty card can be used indefinitely.
## Technical Details
* **Backend Validation**: Overrides `validate_coupon_programs` in `pos.order` to ensure real-time limit adherence when processing payments.
* **Frontend Filtering**: Patches `getClaimableRewards` in `PosOrder.prototype` to hide unavailable rewards proactively.
* **Usage Tracking**: Computes period usage counts by scanning the `loyalty.history` records dynamically.

1
__init__.py Normal file
View File

@ -0,0 +1 @@
from . import models

38
__manifest__.py Normal file
View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
{
'name': 'POS Loyalty Usage Limit',
'version': '1.0',
'category': 'Sales/Point of Sale',
'summary': 'Add usage limit to loyalty cards per day, month, or year',
'author': 'Abdul Aziz Amrullah',
'depends': ['pos_loyalty'],
'description': """
POS Loyalty Usage Limit
=======================
This custom Odoo 19 module enhances the Point of Sale (POS) Loyalty system by introducing redemption usage limits on individual loyalty cards.
Key Features:
-------------
* **Flexible Limits**: Configure limits to be enforced per day, per month, or per year.
* **Card-Level Control**: Limits are configured individually on each `loyalty.card`, allowing specific rules per customer.
* **Smart POS Filtering**: The "Reward" button is proactively hidden in the POS interface if the customer has reached their limit, reducing cashier confusion.
* **Offline Synchronization**: Correctly balances backend historical usage data with live un-synced offline session data for accurate enforcement.
* **Strict Backend Validation**: Employs real-time RPC validation checks during payment processing to prevent exploitation via concurrent POS sessions.
How it works:
-------------
If a card has a limit of '1 per day', the customer can only redeem a reward once per day. If they attempt a second redemption in the same day (even on a different POS register), the transaction is blocked, or the reward is hidden. If the limit is turned off (unchecked), the card operates with unlimited usages as per standard Odoo behavior.
""",
'data': [
'views/loyalty_card_views.xml',
],
'assets': {
'point_of_sale._assets_pos': [
'pos_loyalty_usage_limit_custom/static/src/app/models/pos_order.js',
],
},
'installable': True,
'application': False,
'license': 'LGPL-3',
}

2
models/__init__.py Normal file
View File

@ -0,0 +1,2 @@
from . import loyalty_card
from . import pos_order

40
models/loyalty_card.py Normal file
View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class LoyaltyCard(models.Model):
_inherit = 'loyalty.card'
limit = fields.Boolean(string="Limit")
limit_count = fields.Integer(string="Limit Count", default=1)
limit_period = fields.Selection([
('day', 'Per Day'),
('month', 'Per Month'),
('year', 'Per Year')
], string="Limit Period", default='day')
backend_usage_count = fields.Integer(compute='_compute_backend_usage_count', string="Usage Count in Period")
def _compute_backend_usage_count(self):
for card in self:
if not card.limit:
card.backend_usage_count = 0
continue
domain = [('card_id', '=', card.id), ('used', '>', 0)]
today = fields.Datetime.now()
if card.limit_period == 'day':
domain.append(('create_date', '>=', today.replace(hour=0, minute=0, second=0, microsecond=0)))
elif card.limit_period == 'month':
domain.append(('create_date', '>=', today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)))
elif card.limit_period == 'year':
domain.append(('create_date', '>=', today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)))
card.backend_usage_count = self.env['loyalty.history'].search_count(domain)
@api.model
def _load_pos_data_fields(self, config):
fields_list = super()._load_pos_data_fields(config)
fields_list.extend(['limit', 'limit_count', 'limit_period', 'backend_usage_count'])
return fields_list

47
models/pos_order.py Normal file
View File

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, _
class PosOrder(models.Model):
_inherit = 'pos.order'
def validate_coupon_programs(self, point_changes, new_codes):
res = super().validate_coupon_programs(point_changes, new_codes)
if not res.get('successful'):
return res
point_changes = {int(k): v for k, v in point_changes.items()}
coupon_ids_from_pos = set(point_changes.keys())
coupons = self.env['loyalty.card'].browse(coupon_ids_from_pos).exists()
for coupon in coupons:
if not coupon.limit:
continue
# Check if this coupon is being used for redemption (points are spent, i.e., point_change < 0)
# Or if it's 0 (maybe a reward that doesn't cost points?), but wait, point_changes is negative when spent.
if point_changes.get(coupon.id, 0) >= 0:
continue
# Calculate usages
domain = [('card_id', '=', coupon.id), ('used', '>', 0)]
today = fields.Datetime.now()
if coupon.limit_period == 'day':
domain.append(('create_date', '>=', today.replace(hour=0, minute=0, second=0, microsecond=0)))
elif coupon.limit_period == 'month':
domain.append(('create_date', '>=', today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)))
elif coupon.limit_period == 'year':
domain.append(('create_date', '>=', today.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)))
usage_count = self.env['loyalty.history'].search_count(domain)
if usage_count >= coupon.limit_count:
return {
'successful': False,
'payload': {
'message': _('The loyalty card %s has reached its usage limit of %s per %s.', coupon.code, coupon.limit_count, coupon.limit_period),
}
}
return res

View File

@ -0,0 +1,45 @@
/** @odoo-module **/
import { PosOrder } from "@point_of_sale/app/models/pos_order";
import { patch } from "@web/core/utils/patch";
patch(PosOrder.prototype, {
getClaimableRewards(coupon_id = false, program_id = false, auto = false) {
const claimableRewards = super.getClaimableRewards(...arguments);
// Filter out rewards from cards that have reached their limit
return claimableRewards.filter(reward => {
if (!reward.coupon_id) {
return true; // Not tied to a specific coupon yet, or global
}
const dbCoupon = this.models["loyalty.card"].get(reward.coupon_id);
if (!dbCoupon || !dbCoupon.limit) {
return true;
}
let posSessionUsages = 0;
const orders = this.models['pos.order'].getAll();
for (const order of orders) {
if (order === this) continue; // don't count current order
// only count orders that are validated/finalized in the POS
if (!order.is_paid && order.state !== 'paid' && order.state !== 'done' && order.state !== 'invoiced') {
// depending on odoo 19 state definition, we might also just check if the order has been paid.
// 'is_paid' is usually a getter in pos.order, let's use it as a method or property.
// But in Odoo 19, finalized property indicates it's pushed or paid.
// Let's just check if it's finalized or paid.
if (!order.finalized) {
continue;
}
}
const lines = order._get_reward_lines();
if (lines.some(l => l.coupon_id && l.coupon_id.id === dbCoupon.id)) {
posSessionUsages++;
}
}
return (dbCoupon.backend_usage_count + posSessionUsages) < dbCoupon.limit_count;
});
}
});

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_card_view_form_inherit_usage_limit" model="ir.ui.view">
<field name="name">loyalty.card.view.form.inherit.usage.limit</field>
<field name="model">loyalty.card</field>
<field name="inherit_id" ref="loyalty.loyalty_card_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='expiration_date']" position="after">
<field name="limit"/>
<label for="limit_count" invisible="not limit"/>
<div class="o_row" invisible="not limit">
<field name="limit_count"/>
<span> per </span>
<field name="limit_period"/>
</div>
</xpath>
</field>
</record>
</odoo>