first commit

This commit is contained in:
Suherdy Yacob 2026-06-04 12:01:05 +07:00
commit 075d5055a7
12 changed files with 480 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*.pyc
*.pyo
__pycache__/
*.swp
.DS_Store

20
README.md Normal file
View File

@ -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.

4
__init__.py Normal file
View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models

27
__manifest__.py Normal file
View File

@ -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',
}

6
models/__init__.py Normal file
View File

@ -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

37
models/loyalty_card.py Normal file
View File

@ -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

51
models/loyalty_program.py Normal file
View File

@ -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)

81
models/pos_order.py Normal file
View File

@ -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

View File

@ -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);
}
});

4
tests/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_subscription

118
tests/test_subscription.py Normal file
View File

@ -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")

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_card_view_form_inherit_subscription" model="ir.ui.view">
<field name="name">loyalty.card.view.form.inherit.subscription</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="program_type" invisible="1"/>
<field name="subscription_start_date" invisible="program_type != 'subscription'"/>
<field name="subscription_end_date" invisible="program_type != 'subscription'"/>
<field name="subscription_usage_count" invisible="program_type != 'subscription'" readonly="1"/>
</xpath>
</field>
</record>
</odoo>