initial commit

This commit is contained in:
Abdul Aziz Amrullah 2026-05-04 09:51:19 +07:00
commit ad46c2d8fd
11 changed files with 200 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# POS Loyalty Expiration Custom
## Overview
This module enhances the Odoo 19 Point of Sale (POS) loyalty capabilities by introducing a strict expiration control mechanism. It provides an isolated, custom expiration date field (`custom_end_date`) that overrides default point earning and reward claiming logic in the Point of Sale UI and backend.
When a Loyalty Card passes this `custom_end_date`, the system securely locks the card out of POS transactions, preventing both the accumulation of new reward points and the claiming of previously earned rewards.
## Key Features
* **Dedicated Custom End Date:** Formally declares and injects the `custom_end_date` directly into the `loyalty.card` model and UI (no Odoo Studio required).
* **Earn Point Blocking:** Halts any calculation or recording of points for an order when an expired loyalty card is used.
* **Reward Claim Blocking:** Filters and completely hides any claimable rewards associated with an expired loyalty card from the POS frontend interface.
* **Strict Backend Validation:** Expands on Odoo's internal coupon code checks to raise descriptive validation errors if an expired code is manually inputted.
## Installation
1. Place the `pos_loyalty_expiration_custom` directory inside your Odoo `addons` or `custom` folder.
2. Restart the Odoo service.
3. Log in as an Administrator, activate Developer Mode, and click **Update Apps List**.
4. Search for `POS Loyalty Expiration Custom` and click **Install**.
## Usage
1. Navigate to **Point of Sale > Products > Loyalty Programs**.
2. Select a program and view its related **Loyalty Cards** (or go directly to **Loyalty Cards**).
3. On the card form, locate the **End Date** field (placed next to the standard `expiration_date`).
4. Set an expiration date.
5. In an active POS Session:
* Selecting a customer or scanning a code linked to an expired card will yield no points for their purchase.
* Selecting the "Rewards" button will not present any options related to the expired card.
## Technical Notes
* Extending Python model `loyalty.card` to inject `custom_end_date` into `_load_pos_data_fields`.
* Patching `LoyaltyCard.prototype.isExpired()` in `pos_order.js` to parse and check the custom date constraint.
* Patching `Order.prototype.getClaimableRewards` to proactively filter out expired cards.
* Patching `PosStore.prototype._updatePrograms` to gracefully ignore and purge any points calculated against expired cards.

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 Expiration Custom',
'version': '1.0',
'category': 'Sales/Point of Sale',
'summary': 'Enforce custom expiration dates on POS Loyalty Cards',
'author': 'Abdul Aziz Amrullah',
'description': """
POS Loyalty Expiration Custom
=============================
This module extends the core Point of Sale loyalty functionality to enforce a strict expiration logic based on a `custom_end_date` field.
Key Features:
-------------
* **Custom End Date Field:** Adds a custom "End Date" (`custom_end_date`) to the Loyalty Card view (backend).
* **Strict POS Validation:** When a loyalty card's custom end date has passed, the Point of Sale system will immediately block the card.
* **Halt Point Earning:** Expired cards are blocked from accumulating new reward points for any purchases made.
* **Block Reward Claims:** Rewards linked to expired loyalty cards are completely hidden and cannot be claimed by the user.
This ensures seamless and autonomous handling of promotional periods or membership validity strictly through an isolated date field without affecting the core functionality of regular expiration parameters.
""",
'depends': ['pos_loyalty'],
'data': [
'views/loyalty_card_views.xml',
],
'assets': {
'point_of_sale._assets_pos': [
'pos_loyalty_expiration_custom/static/src/app/models/loyalty_card.js',
'pos_loyalty_expiration_custom/static/src/app/models/pos_order.js',
'pos_loyalty_expiration_custom/static/src/app/services/pos_store.js',
],
},
'installable': True,
'application': False,
'auto_install': False,
'license': 'LGPL-3',
}

2
models/__init__.py Normal file
View File

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

25
models/loyalty_card.py Normal file
View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class LoyaltyCard(models.Model):
_inherit = 'loyalty.card'
# Declare the field so the ORM is aware of it and it can be loaded in _load_pos_data_fields.
custom_end_date = fields.Date(string="End Date", help="Custom End Date for Loyalty Card")
@api.model
def _load_pos_data_fields(self, config):
fields_list = super()._load_pos_data_fields(config)
if 'custom_end_date' not in fields_list:
fields_list.append('custom_end_date')
return fields_list
@api.model
def get_gift_card_status(self, gift_code, config_id):
res = super().get_gift_card_status(gift_code, config_id)
if res.get('status') and res.get('data') and res['data'].get('loyalty.card'):
card_data = res['data']['loyalty.card'][0]
end_date = card_data.get('custom_end_date')
if end_date and fields.Date.from_string(end_date) < fields.Date.today():
res['status'] = False
return res

22
models/pos_config.py Normal file
View File

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, _
class PosConfig(models.Model):
_inherit = 'pos.config'
def use_coupon_code(self, code, creation_date, partner_id, pricelist_id):
res = super().use_coupon_code(code, creation_date, partner_id, pricelist_id)
if res.get('successful'):
# Fetch the coupon to check custom_end_date
coupon_id = res['payload'].get('coupon_id')
if coupon_id:
coupon = self.env['loyalty.card'].browse(coupon_id)
check_date = fields.Date.from_string(creation_date[:11])
if coupon.custom_end_date and coupon.custom_end_date < check_date:
return {
'successful': False,
'payload': {
'error_message': _("This coupon's custom end date is expired (%s).", code),
},
}
return res

View File

@ -0,0 +1,21 @@
/** @odoo-module **/
import { LoyaltyCard } from "@pos_loyalty/app/models/loyalty_card";
import { patch } from "@web/core/utils/patch";
const { DateTime } = luxon;
patch(LoyaltyCard.prototype, {
isExpired() {
const isSuperExpired = super.isExpired(...arguments);
if (isSuperExpired) {
return true;
}
if (this.custom_end_date) {
return DateTime.fromISO(this.custom_end_date).toMillis() < DateTime.now().toMillis();
}
return false;
}
});

View File

@ -0,0 +1,14 @@
/** @odoo-module **/
import { PosOrder } from "@point_of_sale/app/models/pos_order";
import { patch } from "@web/core/utils/patch";
patch(PosOrder.prototype, {
getClaimableRewards() {
const claimableRewards = super.getClaimableRewards(...arguments);
return claimableRewards.filter(cr => {
const coupon = this.models['loyalty.card'].get(cr.coupon_id);
return coupon ? !coupon.isExpired() : true;
});
}
});

View File

@ -0,0 +1,19 @@
/** @odoo-module **/
import { PosStore } from "@point_of_sale/app/services/pos_store";
import { patch } from "@web/core/utils/patch";
patch(PosStore.prototype, {
async _updatePrograms() {
await super._updatePrograms(...arguments);
const order = this.getOrder();
if (order && order.uiState && order.uiState.couponPointChanges) {
for (const [key, pe] of Object.entries(order.uiState.couponPointChanges)) {
const coupon = this.models['loyalty.card'].get(pe.coupon_id);
if (coupon && coupon.isExpired()) {
delete order.uiState.couponPointChanges[key];
}
}
}
}
});

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_card_view_form_inherit_custom_end_date" model="ir.ui.view">
<field name="name">loyalty.card.view.form.inherit.custom.end.date</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="custom_end_date"/>
</xpath>
</field>
</record>
<record id="loyalty_card_view_tree_inherit_custom_end_date" model="ir.ui.view">
<field name="name">loyalty.card.view.list.inherit.custom.end.date</field>
<field name="model">loyalty.card</field>
<field name="inherit_id" ref="loyalty.loyalty_card_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='expiration_date']" position="after">
<field name="custom_end_date" optional="show"/>
</xpath>
</field>
</record>
</odoo>