commit ad46c2d8fd61f19ba0694eeaae319779038a8c96 Author: Abdul Aziz Amrullah Date: Mon May 4 09:51:19 2026 +0700 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ed8ebf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d55daa5 --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..e585d46 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..85f250c --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,2 @@ +from . import loyalty_card +from . import pos_config diff --git a/models/loyalty_card.py b/models/loyalty_card.py new file mode 100644 index 0000000..e58c389 --- /dev/null +++ b/models/loyalty_card.py @@ -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 diff --git a/models/pos_config.py b/models/pos_config.py new file mode 100644 index 0000000..cb22317 --- /dev/null +++ b/models/pos_config.py @@ -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 diff --git a/static/src/app/models/loyalty_card.js b/static/src/app/models/loyalty_card.js new file mode 100644 index 0000000..5545534 --- /dev/null +++ b/static/src/app/models/loyalty_card.js @@ -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; + } +}); diff --git a/static/src/app/models/pos_order.js b/static/src/app/models/pos_order.js new file mode 100644 index 0000000..f894cd5 --- /dev/null +++ b/static/src/app/models/pos_order.js @@ -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; + }); + } +}); diff --git a/static/src/app/services/pos_store.js b/static/src/app/services/pos_store.js new file mode 100644 index 0000000..3c28e46 --- /dev/null +++ b/static/src/app/services/pos_store.js @@ -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]; + } + } + } + } +}); diff --git a/views/loyalty_card_views.xml b/views/loyalty_card_views.xml new file mode 100644 index 0000000..ec8dc8f --- /dev/null +++ b/views/loyalty_card_views.xml @@ -0,0 +1,24 @@ + + + + loyalty.card.view.form.inherit.custom.end.date + loyalty.card + + + + + + + + + + loyalty.card.view.list.inherit.custom.end.date + loyalty.card + + + + + + + +