initial commit

This commit is contained in:
Abdul Aziz Amrullah 2026-05-04 09:40:52 +07:00
commit f9b7c799b1
7 changed files with 322 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
__pycache__

112
README.md Normal file
View File

@ -0,0 +1,112 @@
# POS Loyalty Auto Level Update
Automatically updates a customer's loyalty membership level after every POS transaction (sale or refund).
## Overview
This module extends Odoo 19's Point of Sale to support **automatic multi-level loyalty membership management**. When a customer completes a purchase or receives a refund at the POS, the module recalculates their total spending and assigns the appropriate loyalty tier — no manual intervention required.
## How It Works
```
Customer pays / gets refund at POS
Sum all paid pos.order amounts for this customer
Fetch loyalty.program records where
multi_level_membership = True
(sorted by minimum_spend ASC)
Find the highest tier where
minimum_spend ≤ total purchases
Compare with current res.partner.membership_level_id
├── Different → Update to new level
└── Same → Do nothing
```
### Step-by-Step
1. **Trigger** — The module hooks into `action_pos_order_paid()`, which is called every time a POS order is finalized (both regular sales and refunds).
2. **Calculate Total Purchases** — Sums the `amount_total` field from all `pos.order` records with state `paid` or `done` for the customer. Refund orders naturally have a negative `amount_total`, so they reduce the total automatically.
3. **Fetch Loyalty Levels** — Retrieves all `loyalty.program` records where the `multi_level_membership` field (Boolean) is `True`, sorted by `minimum_spend` in ascending order.
4. **Determine Level** — Walks through the sorted programs and selects the **highest tier** where the customer's total purchases meet or exceed `minimum_spend`.
5. **Transfer Points** (If upgraded/downgraded) — Finds the customer's loyalty card for the old membership program. If it has points, transfers those points to the new program's loyalty card (creating one if it doesn't already exist), then resets the old card's points to 0.
6. **Compare & Update** — Checks the customer's current `membership_level_id` (Many2one to `loyalty.program`) on `res.partner`:
- If the level has **changed** → updates the field with the new program.
- If the level is the **same** → does nothing.
- If the customer doesn't meet **any** tier → clears the membership level.
## Dependencies
| Module | Purpose |
|--------|---------|
| `point_of_sale` | Provides `pos.order` model and POS transaction flow |
| `pos_loyalty` | Provides `loyalty.program` model |
## Required Studio Fields
This module relies on custom fields created via Odoo Studio:
| Model | Field | Type | Description |
|-------|-------|------|-------------|
| `loyalty.program` | `multi_level_membership` | Boolean | Marks this program as part of the multi-level membership system |
| `loyalty.program` | `minimum_spend` | Float | Minimum total spend required to qualify for this level |
| `res.partner` | `membership_level_id` | Many2one → `loyalty.program` | The customer's current loyalty membership level |
## Example
Suppose you have three loyalty tiers configured:
| Loyalty Program | minimum_spend |
|----------------|----------------------|
| Bronze | 0 |
| Silver | 500,000 |
| Gold | 1,500,000 |
**Scenario — Upgrade:**
- Customer has accumulated **600,000** in total POS purchases.
- Current level: **Bronze**
- After a new purchase, total reaches **600,000** which is ≥ 500,000 (Silver) but < 1,500,000 (Gold).
- Result: Customer is upgraded to **Silver**.
**Scenario — Downgrade after refund:**
- Customer's total was **520,000** (Silver level).
- A refund of **50,000** brings the total down to **470,000**.
- 470,000 < 500,000 (Silver threshold), so customer drops back to **Bronze**.
## Module Structure
```
pos_loyalty_auto_level/
├── __init__.py
├── __manifest__.py
├── README.md
└── models/
├── __init__.py
└── pos_order.py # Core logic: overrides action_pos_order_paid()
```
## Installation
1. Place the `pos_loyalty_auto_level` folder in your Odoo `custom/` addons directory.
2. Update the apps list: **Settings → Apps → Update Apps List**.
3. Search for **"POS Loyalty Auto Level Update"** and click **Install**.
## Logging
The module logs membership level changes at `INFO` level and unchanged checks at `DEBUG` level. Check your Odoo server logs for entries from `odoo.addons.pos_loyalty_auto_level.models.pos_order`.
## License
LGPL-3

1
__init__.py Normal file
View File

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

30
__manifest__.py Normal file
View File

@ -0,0 +1,30 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'POS Loyalty Auto Level Update',
'version': '1.0',
'category': 'Sales/Point of Sale',
'summary': 'Automatically update customer membership level after POS transactions',
'author': 'Abdul Aziz Amrullah',
'description': """
POS Loyalty Auto Level Update
================================
This module automates the management of customer loyalty membership tiers directly within the Point of Sale system.
Key Features:
1. **Automated Lifetime Spend Tracking**: After every POS sale or refund, the module calculates the customer's cumulative purchase amount from all `paid` or `done` `pos.order` records.
2. **Tier Evaluation**: Compares the customer's total spend against the `minimum_spend` thresholds defined on `loyalty.program` records (specifically those marked as `multi_level_membership`).
3. **Seamless Upgrades/Downgrades**: Automatically determines the highest qualifying membership level and updates the customer's `membership_level_id` on their `res.partner` profile.
4. **Intelligent Point Consolidation**: If a customer changes tiers, the module seamlessly sweeps loyalty points from any of their older tier cards and consolidates them into the new membership card. It hooks directly into the POS frontend's point confirmation (`confirm_coupon_programs`) to ensure that even points earned in the current transaction are properly swept over.
5. **Detailed Logging**: Provides developer-level console logs in the browser to trace the loyalty processing and synchronization steps during checkout.
""",
'depends': ['point_of_sale', 'pos_loyalty'],
'data': [],
'assets': {
'point_of_sale._assets_pos': [
'pos_loyalty_auto_level/static/src/app/**/*',
],
},
'installable': True,
'application': False,
'license': 'LGPL-3',
}

1
models/__init__.py Normal file
View File

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

153
models/pos_order.py Normal file
View File

@ -0,0 +1,153 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, models
_logger = logging.getLogger(__name__)
class PosOrder(models.Model):
_inherit = 'pos.order'
def action_pos_order_paid(self):
"""Override to update the customer's membership level after payment."""
res = super().action_pos_order_paid()
for order in self:
if order.partner_id:
order._update_customer_membership_level()
return res
def confirm_coupon_programs(self, coupon_data):
"""Also update membership after the frontend confirms coupon programs to catch current order's points."""
res = super().confirm_coupon_programs(coupon_data)
for order in self:
if order.partner_id:
order._update_customer_membership_level()
return res
def _update_customer_membership_level(self):
"""Determine and update the customer's loyalty membership level.
1. Sum all paid pos.order amount_total for this partner.
2. Fetch all loyalty.program where multi_level_membership = True,
ordered by minimum_spend ascending.
3. Find the highest level the customer qualifies for
(highest minimum_spend <= total purchases).
4. Update res.partner.membership_level_id if it differs.
5. Consolidate points from other tier cards.
"""
self.ensure_one()
partner = self.partner_id
if not partner:
return
# 1. Calculate total purchases for this customer (only paid/done orders)
total_purchases = self._get_customer_total_purchases(partner)
# 2. Get all multi-level loyalty programs sorted by minimum spend ASC
loyalty_programs = self.env['loyalty.program'].sudo().search(
[('multi_level_membership', '=', True)],
order='minimum_spend asc',
)
if not loyalty_programs:
_logger.info(
'No multi-level loyalty programs found. '
'Skipping membership level update for partner %s (ID: %s).',
partner.name, partner.id,
)
return
# 3. Determine the appropriate level
matched_program = None
for program in loyalty_programs:
min_spend = program.minimum_spend or 0.0
if total_purchases >= min_spend:
matched_program = program
else:
break
if not matched_program:
_logger.info(
'Customer %s (ID: %s) total purchases %.2f do not meet '
'the minimum spend of any multi-level loyalty program. '
'Clearing membership level.',
partner.name, partner.id, total_purchases,
)
if partner.membership_level_id:
partner.sudo().write({'membership_level_id': False})
return
# 4. Compare with current level and update if different
current_level_id = partner.membership_level_id.id if partner.membership_level_id else False
if current_level_id != matched_program.id:
_logger.info(
'Updating membership level for customer %s (ID: %s): '
'%s -> %s (total purchases: %.2f)',
partner.name,
partner.id,
partner.membership_level_id.name if partner.membership_level_id else 'None',
matched_program.name,
total_purchases,
)
partner.sudo().write({'membership_level_id': matched_program.id})
else:
_logger.debug(
'Membership level unchanged for customer %s (ID: %s): '
'%s (total purchases: %.2f)',
partner.name,
partner.id,
matched_program.name,
total_purchases,
)
# 5. Transfer points from old loyalty card(s) to new loyalty card
all_multi_level_program_ids = loyalty_programs.mapped('id')
other_programs = [pid for pid in all_multi_level_program_ids if pid != matched_program.id]
if other_programs:
old_cards = self.env['loyalty.card'].sudo().search([
('partner_id', '=', partner.id),
('program_id', 'in', other_programs),
('points', '!=', 0),
])
if old_cards:
new_card = self.env['loyalty.card'].sudo().search([
('partner_id', '=', partner.id),
('program_id', '=', matched_program.id),
], limit=1)
if not new_card:
new_card = self.env['loyalty.card'].sudo().create({
'partner_id': partner.id,
'program_id': matched_program.id,
'points': 0,
})
for old_card in old_cards:
pts_to_transfer = old_card.points
if abs(pts_to_transfer) > 0.0001: # Simple check to avoid floating point noise transfers
new_card.points += pts_to_transfer
old_card.points = 0
_logger.info(
'Transferred %s points from loyalty program "%s" to "%s" for customer %s (ID: %s)',
pts_to_transfer,
old_card.program_id.name,
new_card.program_id.name,
partner.name,
partner.id,
)
@api.model
def _get_customer_total_purchases(self, partner):
"""Calculate the total amount of paid/done POS orders for a given partner.
:param partner: res.partner record
:returns: float total of amount_total across all qualifying orders
"""
orders = self.sudo().search([
('partner_id', '=', partner.id),
('state', 'in', ('paid', 'done')),
])
return sum(orders.mapped('amount_total'))

View File

@ -0,0 +1,24 @@
/** @odoo-module */
import { PosStore } from "@point_of_sale/app/services/pos_store";
import { patch } from "@web/core/utils/patch";
patch(PosStore.prototype, {
async postProcessLoyalty(order) {
console.group(`[Loyalty Auto Level] Processing Loyalty for Order: ${order.name}`);
const partner = order.getPartner();
console.log("Customer:", partner ? partner.name : "No Customer");
console.log("Coupon Point Changes (Before Sync):", JSON.parse(JSON.stringify(order.uiState.couponPointChanges)));
try {
await super.postProcessLoyalty(order);
console.log("Loyalty processing completed successfully. The backend confirm_coupon_programs should have triggered point consolidation if a membership upgrade/downgrade occurred.");
} catch (error) {
console.error("Error during loyalty processing:", error);
throw error;
} finally {
console.groupEnd();
}
}
});