From 075d5055a7c09c2a86290ae1b526bf61bf4fb839 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Thu, 4 Jun 2026 12:01:05 +0700 Subject: [PATCH] first commit --- .gitignore | 5 ++ README.md | 20 +++++ __init__.py | 4 + __manifest__.py | 27 +++++++ models/__init__.py | 6 ++ models/loyalty_card.py | 37 +++++++++ models/loyalty_program.py | 51 +++++++++++++ models/pos_order.py | 81 ++++++++++++++++++++ static/src/app/models/pos_order.js | 111 +++++++++++++++++++++++++++ tests/__init__.py | 4 + tests/test_subscription.py | 118 +++++++++++++++++++++++++++++ views/loyalty_card_views.xml | 16 ++++ 12 files changed, 480 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 __manifest__.py create mode 100644 models/__init__.py create mode 100644 models/loyalty_card.py create mode 100644 models/loyalty_program.py create mode 100644 models/pos_order.py create mode 100644 static/src/app/models/pos_order.js create mode 100644 tests/__init__.py create mode 100644 tests/test_subscription.py create mode 100644 views/loyalty_card_views.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae04fa5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.pyc +*.pyo +__pycache__/ +*.swp +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..72fb39b --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# POS Loyalty Subscription + +This module introduces a new "Subscription" loyalty program type in Odoo. + +## Features + +- New "Subscription" loyalty program type. +- Support for subscription start and end dates on loyalty cards. +- Enforces a limit of one claim per day for subscription rewards. +- Automatic integration with POS loyalty. + +## Configuration + +1. Install the module. +2. In the Odoo backend, go to Point of Sale -> Products -> Loyalty Programs. +3. Create a new loyalty program and select "Subscription" as the program type. +4. Set up the reward (for example, a free product). +5. Go to Loyalty Cards and create a card for your customer. +6. Set the "Subscription Start Date" and "Subscription End Date" on the customer's card. +7. Open the POS, select the customer, and add the product. The reward will be automatically applied for free. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..dc5e6b6 --- /dev/null +++ b/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..1568414 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'POS Loyalty Subscription', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'summary': 'Allows subscription-based loyalty programs (e.g. daily claim limits) with validity dates.', + 'author': 'Suherdy Yacob', + 'description': """ +POS Loyalty Subscription +======================== +Introduces a new "Subscription" loyalty program type in Odoo. +Allows customers to join subscription programs where they can claim a free product +once per day within a specified subscription start and end date. + """, + 'depends': ['point_of_sale', 'pos_loyalty', 'pos_loyalty_multi_level'], + 'data': [ + 'views/loyalty_card_views.xml', + ], + 'assets': { + 'point_of_sale._assets_pos': [ + 'pos_loyalty_subscription/static/src/app/**/*', + ], + }, + 'installable': True, + 'application': False, + 'license': 'LGPL-3', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..1a64f45 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import loyalty_program +from . import loyalty_card +from . import pos_order diff --git a/models/loyalty_card.py b/models/loyalty_card.py new file mode 100644 index 0000000..8c19660 --- /dev/null +++ b/models/loyalty_card.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import pytz +from odoo import api, fields, models + +class LoyaltyCard(models.Model): + _inherit = 'loyalty.card' + + subscription_start_date = fields.Date(string="Subscription Start Date") + subscription_end_date = fields.Date(string="Subscription End Date") + subscription_usage_count = fields.Integer(compute='_compute_subscription_usage_count', string="Subscription Usage Today") + + def _compute_subscription_usage_count(self): + for card in self: + if card.program_id.program_type != 'subscription': + card.subscription_usage_count = 0 + continue + + domain = [('card_id', '=', card.id), ('used', '>', 0)] + + user_tz = pytz.timezone(self.env.user.tz or 'UTC') + now_utc = fields.Datetime.now() + now_local = pytz.utc.localize(now_utc).astimezone(user_tz) + + midnight_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + start_date = midnight_local.astimezone(pytz.utc).replace(tzinfo=None) + domain.append(('create_date', '>=', start_date)) + + # Use sudo() to bypass potential multi-company record access rules on loyalty.history + card.subscription_usage_count = self.env['loyalty.history'].sudo().search_count(domain) + + @api.model + def _load_pos_data_fields(self, config): + fields_list = super()._load_pos_data_fields(config) + fields_list.extend(['subscription_start_date', 'subscription_end_date', 'subscription_usage_count']) + return fields_list diff --git a/models/loyalty_program.py b/models/loyalty_program.py new file mode 100644 index 0000000..13719cb --- /dev/null +++ b/models/loyalty_program.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ + +class LoyaltyProgram(models.Model): + _inherit = 'loyalty.program' + + program_type = fields.Selection( + selection_add=[('subscription', 'Subscription')], + ondelete={'subscription': 'set default'} + ) + + @api.model + def _program_items_name(self): + res = super()._program_items_name() + res['subscription'] = _("Subscriptions") + return res + + @api.model + def _program_type_default_values(self): + res = super()._program_type_default_values() + res['subscription'] = { + 'applies_on': 'both', + 'trigger': 'auto', + 'portal_visible': True, + 'portal_point_name': _("Subscription claim(s)"), + 'manual_membership': True, + 'rule_ids': [(5, 0, 0)], + 'reward_ids': [(5, 0, 0)], + 'communication_plan_ids': [(5, 0, 0)], + } + return res + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('program_type') == 'subscription': + vals['manual_membership'] = True + vals['trigger'] = 'auto' + vals['applies_on'] = 'both' + vals['portal_visible'] = True + return super().create(vals_list) + + def write(self, vals): + if vals.get('program_type') == 'subscription' or (not vals.get('program_type') and any(p.program_type == 'subscription' for p in self)): + vals['manual_membership'] = True + vals['trigger'] = 'auto' + vals['applies_on'] = 'both' + vals['portal_visible'] = True + return super().write(vals) diff --git a/models/pos_order.py b/models/pos_order.py new file mode 100644 index 0000000..b6df420 --- /dev/null +++ b/models/pos_order.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import float_compare +from datetime import datetime, time +import pytz + +class PosOrder(models.Model): + _inherit = 'pos.order' + + def validate_coupon_programs(self, point_changes, new_codes): + point_changes_int = {int(k): v for k, v in point_changes.items()} + subscription_cards = self.env['loyalty.card'].browse(point_changes_int.keys()).exists().filtered( + lambda c: c.program_id.program_type == 'subscription' + ) + + # Validate dates and limits for subscription cards + for card in subscription_cards: + today = fields.Date.today() + if card.subscription_start_date and today < card.subscription_start_date: + return { + 'successful': False, + 'payload': { + 'message': _('The subscription for %s is not active yet (Starts on %s).', card.partner_id.name, card.subscription_start_date), + } + } + if card.subscription_end_date and today > card.subscription_end_date: + return { + 'successful': False, + 'payload': { + 'message': _('The subscription for %s has expired (Expired on %s).', card.partner_id.name, card.subscription_end_date), + } + } + + # Check usage limit today in user local timezone + user_tz = pytz.timezone(self.env.user.tz or 'UTC') + now_utc = fields.Datetime.now() + now_local = pytz.utc.localize(now_utc).astimezone(user_tz) + midnight_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + start_date = midnight_local.astimezone(pytz.utc).replace(tzinfo=None) + + history_count = self.env['loyalty.history'].sudo().search_count([ + ('card_id', '=', card.id), + ('create_date', '>=', start_date), + ('used', '>', 0), + ]) + if history_count >= 1: + return { + 'successful': False, + 'payload': { + 'message': _('Customer %s has already claimed their subscription free product today.', card.partner_id.name), + } + } + + # Bypass the points validation by temporarily mocking subscription cards points to a high value. + original_points = {card: card.points for card in subscription_cards} + for card in subscription_cards: + card.points = 9999.0 + + try: + res = super().validate_coupon_programs(point_changes, new_codes) + finally: + for card, points in original_points.items(): + card.points = points + + return res + + def confirm_coupon_programs(self, coupon_data): + # Run super to process normal workflow + res = super().confirm_coupon_programs(coupon_data) + + # After points calculations/deductions are processed, reset the points back to 0.0 for subscription cards + coupon_data_int = {int(k): v for k, v in coupon_data.items()} + for card_id in coupon_data_int.keys(): + card = self.env['loyalty.card'].browse(card_id).exists() + if card and card.program_id.program_type == 'subscription': + card.points = 0.0 + + return res diff --git a/static/src/app/models/pos_order.js b/static/src/app/models/pos_order.js new file mode 100644 index 0000000..7ebf33a --- /dev/null +++ b/static/src/app/models/pos_order.js @@ -0,0 +1,111 @@ +/** @odoo-module **/ + +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { patch } from "@web/core/utils/patch"; + +const { DateTime } = luxon; + +function resolveManyToOneId(value) { + if (!value && value !== 0) { + return null; + } + if (Array.isArray(value)) { + return parseInt(value[0], 10); + } + if (typeof value === 'object') { + return parseInt(value.id, 10); + } + return parseInt(value, 10); +} + +patch(PosOrder.prototype, { + _programIsApplicable(program) { + const isApplicable = super._programIsApplicable(...arguments); + if (!isApplicable) { + return false; + } + + if (program.program_type === 'subscription') { + const partner = this.getPartner(); + if (!partner) { + return false; + } + + const allCards = this.models['loyalty.card']?.getAll() || []; + const card = allCards.find((c) => { + const cardPartnerId = resolveManyToOneId(c.partner_id); + const cardProgramId = resolveManyToOneId(c.program_id); + return cardPartnerId === partner.id && cardProgramId === program.id; + }); + + if (!card || !card.active) { + return false; + } + + const today = DateTime.now().startOf('day'); + if (card.subscription_start_date) { + const startDate = DateTime.fromISO(card.subscription_start_date).startOf('day'); + if (today < startDate) { + return false; + } + } + if (card.subscription_end_date) { + const endDate = DateTime.fromISO(card.subscription_end_date).startOf('day'); + if (today > endDate) { + return false; + } + } + } + + return isApplicable; + }, + + _getRealCouponPoints(coupon_id) { + const dbCoupon = this.models['loyalty.card'].get(coupon_id); + const programId = dbCoupon ? resolveManyToOneId(dbCoupon.program_id) : null; + const program = programId ? this.models['loyalty.program'].get(programId) : null; + + if (program && program.program_type === 'subscription') { + // Subscription program rewards cost points, but they are free for the customer. + // We return enough points to cover the reward cost, limited by 1 claim per day. + const rewardPoints = (program.reward_ids || []).reduce((max, r) => Math.max(max, r.required_points || 0), 1); + + let todayClaims = 0; + + // 1. Count claims in the current order + for (const line of this.getOrderlines()) { + if (line.is_reward_line && line.coupon_id?.id === coupon_id) { + todayClaims++; + } + } + + // 2. Count claims in other orders in this POS session (that are paid or finalized) + const orders = this.models['pos.order'].getAll(); + for (const order of orders) { + if (order === this) { + continue; + } + if (!order.is_paid && order.state !== 'paid' && order.state !== 'done' && order.state !== 'invoiced' && !order.finalized) { + continue; + } + for (const line of order._get_reward_lines() || []) { + if (line.coupon_id && line.coupon_id.id === coupon_id) { + todayClaims++; + } + } + } + + // 3. Count claims already recorded in the backend today + const backendClaims = dbCoupon.subscription_usage_count || 0; + const totalClaimsToday = backendClaims + todayClaims; + + if (totalClaimsToday >= 1) { + return 0; // Exceeded limit of 1 claim per day + } else { + return rewardPoints; // Enough points to claim + } + } + + return super._getRealCouponPoints(...arguments); + } +}); diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b8b725e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_subscription diff --git a/tests/test_subscription.py b/tests/test_subscription.py new file mode 100644 index 0000000..55bfe9d --- /dev/null +++ b/tests/test_subscription.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase +from odoo.fields import Date, Datetime +import pytz + +class TestPOSLoyaltySubscription(TransactionCase): + + def setUp(self): + super().setUp() + + # Create a product for the reward + self.reward_product = self.env['product.product'].create({ + 'name': 'Test Free Coffee', + 'type': 'consu', + }) + + # Create a subscription program + self.program = self.env['loyalty.program'].create({ + 'name': 'Makan Pagi Gratis', + 'program_type': 'subscription', + }) + + # Create a reward for the program + self.reward = self.env['loyalty.reward'].create({ + 'program_id': self.program.id, + 'reward_type': 'product', + 'required_points': 1, + 'reward_product_id': self.reward_product.id, + }) + + # Create a partner + self.partner = self.env['res.partner'].create({ + 'name': 'John Doe', + }) + + # Create a subscription card + self.card = self.env['loyalty.card'].create({ + 'program_id': self.program.id, + 'partner_id': self.partner.id, + 'subscription_start_date': Date.today(), + 'subscription_end_date': Date.today(), + }) + + # Find or create a POS config and session + pos_config = self.env['pos.config'].search([], limit=1) + if not pos_config: + pos_config = self.env['pos.config'].create({ + 'name': 'Test POS Config', + }) + self.pos_session = self.env['pos.session'].create({ + 'config_id': pos_config.id, + 'user_id': self.env.uid, + }) + + def test_program_default_values(self): + """Verify that default values for subscription program are correctly applied""" + self.assertTrue(self.program.manual_membership, "Subscription program should default to manual membership") + self.assertEqual(self.program.trigger, 'auto', "Subscription program should trigger automatically") + + def test_validation_date_ranges(self): + """Verify date validity checks in validate_coupon_programs""" + pos_order = self.env['pos.order'].create({ + 'name': 'Test POS Order', + 'partner_id': self.partner.id, + 'session_id': self.pos_session.id, + 'amount_tax': 0.0, + 'amount_total': 0.0, + 'amount_paid': 0.0, + 'amount_return': 0.0, + }) + + # 1. Valid case (today is within range) + point_changes = {str(self.card.id): -1} + res = pos_order.validate_coupon_programs(point_changes, []) + self.assertTrue(res.get('successful', False), "Validation should succeed within valid date ranges") + + # 2. Start date in future + self.card.subscription_start_date = Date.add(Date.today(), days=1) + res = pos_order.validate_coupon_programs(point_changes, []) + self.assertFalse(res.get('successful', True), "Validation should fail if subscription starts in future") + + # Reset start date + self.card.subscription_start_date = Date.today() + + # 3. End date in past + self.card.subscription_end_date = Date.subtract(Date.today(), days=1) + res = pos_order.validate_coupon_programs(point_changes, []) + self.assertFalse(res.get('successful', True), "Validation should fail if subscription has expired") + + def test_validation_daily_limit(self): + """Verify that daily claim limit is enforced""" + pos_order = self.env['pos.order'].create({ + 'name': 'Test POS Order', + 'partner_id': self.partner.id, + 'session_id': self.pos_session.id, + 'amount_tax': 0.0, + 'amount_total': 0.0, + 'amount_paid': 0.0, + 'amount_return': 0.0, + }) + + point_changes = {str(self.card.id): -1} + + # Succeeds initially + res = pos_order.validate_coupon_programs(point_changes, []) + self.assertTrue(res.get('successful', False)) + + # Create history entry today + self.env['loyalty.history'].create({ + 'card_id': self.card.id, + 'used': 1, + 'description': 'Test Claim', + 'create_date': Datetime.now(), + }) + + # Should fail now + res = pos_order.validate_coupon_programs(point_changes, []) + self.assertFalse(res.get('successful', True), "Validation should fail if already claimed today") diff --git a/views/loyalty_card_views.xml b/views/loyalty_card_views.xml new file mode 100644 index 0000000..033348c --- /dev/null +++ b/views/loyalty_card_views.xml @@ -0,0 +1,16 @@ + + + + loyalty.card.view.form.inherit.subscription + loyalty.card + + + + + + + + + + +