From f9b7c799b10b55519734d6c145a00c2baaea9463 Mon Sep 17 00:00:00 2001 From: Abdul Aziz Amrullah Date: Mon, 4 May 2026 09:40:52 +0700 Subject: [PATCH] initial commit --- .gitignore | 1 + README.md | 112 ++++++++++++++++++++++ __init__.py | 1 + __manifest__.py | 30 ++++++ models/__init__.py | 1 + models/pos_order.py | 153 ++++++++++++++++++++++++++++++ static/src/app/pos_store_patch.js | 24 +++++ 7 files changed, 322 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/pos_order.py create mode 100644 static/src/app/pos_store_patch.js 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..aecdce1 --- /dev/null +++ b/README.md @@ -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 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..dd7e64e --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e9ab911 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1 @@ +from . import pos_order diff --git a/models/pos_order.py b/models/pos_order.py new file mode 100644 index 0000000..c256977 --- /dev/null +++ b/models/pos_order.py @@ -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')) diff --git a/static/src/app/pos_store_patch.js b/static/src/app/pos_store_patch.js new file mode 100644 index 0000000..3826dba --- /dev/null +++ b/static/src/app/pos_store_patch.js @@ -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(); + } + } +});