From 9e5cd28be061b6e4017ff0bddbf70e4573d36349 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Mon, 12 Jan 2026 18:15:47 +0100
Subject: [PATCH 01/43] docs: add implementation plan for ERPNext Coupon Code
sync with gift cards
---
docs/GIFT_CARD_SYNC_PLAN.md | 683 ++++++++++++++++++++++++++++++++++++
1 file changed, 683 insertions(+)
create mode 100644 docs/GIFT_CARD_SYNC_PLAN.md
diff --git a/docs/GIFT_CARD_SYNC_PLAN.md b/docs/GIFT_CARD_SYNC_PLAN.md
new file mode 100644
index 00000000..7e520b8d
--- /dev/null
+++ b/docs/GIFT_CARD_SYNC_PLAN.md
@@ -0,0 +1,683 @@
+# Gift Card & ERPNext Coupon Code Sync - Implementation Plan
+
+## Overview
+
+This feature adds optional synchronization between POS Next's gift card system and ERPNext's native Coupon Code doctype. It enables:
+
+1. **Gift Card Product Sales** - Selling a designated item automatically creates a gift card
+2. **ERPNext Integration** - Gift cards sync with ERPNext Coupon Code + Pricing Rule
+3. **Gift Card Splitting** - When gift card amount > invoice total, automatically split into used/remaining
+4. **Optional Customer Assignment** - Gift cards can be anonymous or assigned to a customer
+
+---
+
+## Architecture Comparison
+
+### Current POS Next
+```
+POS Coupon (standalone)
+├── coupon_code
+├── discount_amount
+├── customer (required for gift cards)
+└── erpnext_coupon_code (unused)
+```
+
+### Proposed Architecture
+```
+POS Coupon
+├── coupon_code
+├── discount_amount
+├── customer (OPTIONAL)
+├── gift_card_amount (NEW)
+├── coupon_code_residual (NEW - for split tracking)
+└── erpnext_coupon_code (synced)
+ └── ERPNext Coupon Code
+ ├── gift_card_amount (custom field)
+ ├── coupon_code_residual (custom field)
+ └── pricing_rule
+ └── ERPNext Pricing Rule
+```
+
+---
+
+## Phase 1: Custom Fields for ERPNext Coupon Code
+
+### Files to Create/Modify
+
+**1.1 Create Property Setter for Coupon Code custom fields**
+
+File: `pos_next/pos_next/custom/coupon_code.json`
+
+```json
+{
+ "custom_fields": [
+ {
+ "fieldname": "gift_card_amount",
+ "fieldtype": "Currency",
+ "label": "Gift Card Amount",
+ "insert_after": "pricing_rule",
+ "fetch_from": "pricing_rule.discount_amount",
+ "read_only": 1,
+ "description": "Monetary value of the gift card"
+ },
+ {
+ "fieldname": "coupon_code_residual",
+ "fieldtype": "Link",
+ "label": "Original Gift Card",
+ "options": "Coupon Code",
+ "insert_after": "gift_card_amount",
+ "read_only": 1,
+ "description": "Reference to original gift card if this was created from a split"
+ },
+ {
+ "fieldname": "pos_coupon",
+ "fieldtype": "Link",
+ "label": "POS Coupon",
+ "options": "POS Coupon",
+ "insert_after": "coupon_code_residual",
+ "read_only": 1,
+ "description": "Linked POS Coupon document"
+ }
+ ]
+}
+```
+
+**1.2 Create fixtures for custom fields**
+
+File: `pos_next/fixtures/coupon_code_custom_fields.json`
+
+---
+
+## Phase 2: POS Settings - Gift Card Configuration
+
+### Files to Modify
+
+**2.1 Update POS Settings DocType**
+
+File: `pos_next/pos_next/doctype/pos_settings/pos_settings.json`
+
+Add new section "Gift Card Settings":
+
+```json
+{
+ "fieldname": "section_break_gift_card",
+ "fieldtype": "Section Break",
+ "label": "Gift Card Settings"
+},
+{
+ "fieldname": "enable_gift_cards",
+ "fieldtype": "Check",
+ "label": "Enable Gift Cards",
+ "default": "0",
+ "description": "Enable gift card functionality in POS"
+},
+{
+ "fieldname": "gift_card_item",
+ "fieldtype": "Link",
+ "label": "Gift Card Item",
+ "options": "Item",
+ "depends_on": "enable_gift_cards",
+ "description": "Item that represents a gift card purchase. When sold, creates a gift card coupon."
+},
+{
+ "fieldname": "sync_with_erpnext_coupon",
+ "fieldtype": "Check",
+ "label": "Sync with ERPNext Coupon Code",
+ "default": "1",
+ "depends_on": "enable_gift_cards",
+ "description": "Create ERPNext Coupon Code and Pricing Rule for accounting integration"
+},
+{
+ "fieldname": "column_break_gift_card",
+ "fieldtype": "Column Break"
+},
+{
+ "fieldname": "enable_gift_card_splitting",
+ "fieldtype": "Check",
+ "label": "Enable Gift Card Splitting",
+ "default": "1",
+ "depends_on": "enable_gift_cards",
+ "description": "When gift card amount exceeds invoice total, create a new gift card for the remaining balance"
+},
+{
+ "fieldname": "gift_card_validity_months",
+ "fieldtype": "Int",
+ "label": "Gift Card Validity (Months)",
+ "default": "12",
+ "depends_on": "enable_gift_cards",
+ "description": "Number of months a gift card is valid from creation date"
+},
+{
+ "fieldname": "gift_card_notification",
+ "fieldtype": "Link",
+ "label": "Gift Card Notification",
+ "options": "Notification",
+ "depends_on": "enable_gift_cards",
+ "description": "Notification template to send when gift card is created"
+}
+```
+
+---
+
+## Phase 3: Update POS Coupon DocType
+
+### Files to Modify
+
+**3.1 Update pos_coupon.json**
+
+- Make `customer` field NOT mandatory for Gift Cards
+- Add new fields for gift card tracking
+
+```json
+{
+ "fieldname": "customer",
+ "fieldtype": "Link",
+ "label": "Customer",
+ "options": "Customer",
+ "depends_on": "eval: doc.coupon_type == \"Gift Card\"",
+ "description": "Optional: Assign gift card to a specific customer"
+},
+{
+ "fieldname": "gift_card_amount",
+ "fieldtype": "Currency",
+ "label": "Gift Card Balance",
+ "depends_on": "eval: doc.coupon_type == \"Gift Card\"",
+ "read_only": 1,
+ "description": "Current balance of the gift card"
+},
+{
+ "fieldname": "original_amount",
+ "fieldtype": "Currency",
+ "label": "Original Amount",
+ "depends_on": "eval: doc.coupon_type == \"Gift Card\"",
+ "read_only": 1,
+ "description": "Original gift card value"
+},
+{
+ "fieldname": "coupon_code_residual",
+ "fieldtype": "Link",
+ "label": "Original Gift Card",
+ "options": "POS Coupon",
+ "read_only": 1,
+ "description": "Reference to original gift card if created from split"
+},
+{
+ "fieldname": "source_invoice",
+ "fieldtype": "Link",
+ "label": "Source Invoice",
+ "options": "POS Invoice",
+ "read_only": 1,
+ "description": "POS Invoice that created this gift card"
+}
+```
+
+**3.2 Update pos_coupon.py validation**
+
+Remove mandatory customer check for Gift Cards:
+
+```python
+def validate(self):
+ if self.coupon_type == "Gift Card":
+ self.maximum_use = 1
+ # Customer is now OPTIONAL
+ # if not self.customer:
+ # frappe.throw(_("Please select the customer for Gift Card."))
+```
+
+---
+
+## Phase 4: Gift Card API
+
+### Files to Create
+
+**4.1 Create new API file**
+
+File: `pos_next/api/gift_cards.py`
+
+```python
+"""
+Gift Card API for POS Next
+
+Handles:
+- Gift card creation from POS Invoice
+- Gift card validation and application
+- Gift card splitting
+- ERPNext Coupon Code synchronization
+"""
+
+@frappe.whitelist()
+def create_gift_card_from_invoice(invoice_name):
+ """
+ Create gift card(s) when a gift card item is sold.
+ Called after POS Invoice submission.
+
+ Args:
+ invoice_name: Name of the POS Invoice
+
+ Returns:
+ dict: Created gift card details
+ """
+ pass
+
+@frappe.whitelist()
+def apply_gift_card(coupon_code, invoice_total, customer=None):
+ """
+ Apply a gift card to an invoice.
+
+ Args:
+ coupon_code: Gift card code
+ invoice_total: Total invoice amount
+ customer: Optional customer for validation
+
+ Returns:
+ dict: Discount amount and remaining balance info
+ """
+ pass
+
+@frappe.whitelist()
+def process_gift_card_on_submit(invoice_name):
+ """
+ Process gift card after invoice submission.
+ Handles splitting if gift card amount > invoice total.
+
+ Args:
+ invoice_name: Name of the submitted POS Invoice
+ """
+ pass
+
+@frappe.whitelist()
+def get_gift_cards_with_balance(customer=None, company=None):
+ """
+ Get all gift cards with available balance.
+
+ Args:
+ customer: Optional customer filter
+ company: Company filter
+
+ Returns:
+ list: Gift cards with balance > 0
+ """
+ pass
+
+def create_erpnext_coupon_code(pos_coupon):
+ """
+ Create ERPNext Coupon Code linked to POS Coupon.
+ Also creates the Pricing Rule for discount application.
+
+ Args:
+ pos_coupon: POS Coupon document
+
+ Returns:
+ Coupon Code document
+ """
+ pass
+
+def create_pricing_rule_for_gift_card(amount, coupon_code, company):
+ """
+ Create Pricing Rule for gift card discount.
+
+ Args:
+ amount: Discount amount
+ coupon_code: Coupon code string
+ company: Company name
+
+ Returns:
+ Pricing Rule document
+ """
+ pass
+
+def split_gift_card(original_coupon, used_amount, remaining_amount, invoice_name):
+ """
+ Split a gift card into used and remaining portions.
+
+ Args:
+ original_coupon: Original POS Coupon document
+ used_amount: Amount being used in current transaction
+ remaining_amount: Amount to keep for future use
+ invoice_name: Invoice using the gift card
+
+ Returns:
+ dict: New coupon for used amount, updated original for remaining
+ """
+ pass
+
+def generate_gift_card_code():
+ """
+ Generate unique gift card code in format XXXX-XXXX-XXXX
+
+ Returns:
+ str: Unique gift card code
+ """
+ import random
+ import string
+
+ def segment():
+ return ''.join(random.choices(string.ascii_uppercase + string.digits, k=4))
+
+ code = f"{segment()}-{segment()}-{segment()}"
+
+ # Ensure uniqueness
+ while frappe.db.exists("POS Coupon", {"coupon_code": code}):
+ code = f"{segment()}-{segment()}-{segment()}"
+
+ return code
+```
+
+---
+
+## Phase 5: Gift Card Splitting Logic
+
+### Implementation Details
+
+**5.1 Split Process Flow**
+
+```
+Invoice Submission with Gift Card
+│
+├─ gift_card_amount > invoice_total?
+│ │
+│ ├─ YES: Split Required
+│ │ ├─ Create NEW gift card for used_amount (marked as used)
+│ │ ├─ Update ORIGINAL gift card:
+│ │ │ ├─ gift_card_amount = remaining_amount
+│ │ │ ├─ Update linked Pricing Rule discount_amount
+│ │ │ └─ Add description note about split
+│ │ └─ Link new card via coupon_code_residual
+│ │
+│ └─ NO: Full Usage
+│ └─ Mark gift card as used (used = 1)
+│
+└─ Update ERPNext Coupon Code (if sync enabled)
+```
+
+**5.2 Database Operations**
+
+```sql
+-- When splitting a 100 CHF gift card used for 70 CHF invoice:
+
+-- 1. Create new POS Coupon for used amount
+INSERT INTO `tabPOS Coupon` (
+ coupon_name, coupon_type, coupon_code,
+ discount_type, discount_amount, gift_card_amount,
+ coupon_code_residual, used, maximum_use
+) VALUES (
+ 'GC-USED-70-{timestamp}', 'Gift Card', 'XXXX-XXXX-USED',
+ 'Amount', 70, 70,
+ '{original_coupon_name}', 1, 1
+);
+
+-- 2. Update original gift card
+UPDATE `tabPOS Coupon` SET
+ gift_card_amount = 30,
+ discount_amount = 30,
+ description = CONCAT(description, '\nSplit on {date}: 70 used, 30 remaining')
+WHERE name = '{original_coupon_name}';
+
+-- 3. Update Pricing Rule
+UPDATE `tabPricing Rule` SET
+ discount_amount = 30
+WHERE name = '{pricing_rule_name}';
+```
+
+---
+
+## Phase 6: Frontend Components
+
+### Files to Create/Modify
+
+**6.1 Gift Card Selection Dialog**
+
+File: `POS/src/components/sale/GiftCardDialog.vue`
+
+```vue
+
+
+
+```
+
+**6.2 Gift Card Creation on Item Sale**
+
+File: `POS/src/composables/useGiftCard.js`
+
+```javascript
+import { ref, computed } from 'vue'
+import { call } from '@/utils/apiWrapper'
+
+export function useGiftCard() {
+ const giftCardSettings = ref(null)
+
+ /**
+ * Check if an item is the gift card item
+ */
+ function isGiftCardItem(itemCode) {
+ return giftCardSettings.value?.gift_card_item === itemCode
+ }
+
+ /**
+ * Create gift card after invoice submission
+ */
+ async function createGiftCardFromInvoice(invoiceName) {
+ if (!giftCardSettings.value?.enable_gift_cards) return null
+
+ return await call('pos_next.api.gift_cards.create_gift_card_from_invoice', {
+ invoice_name: invoiceName
+ })
+ }
+
+ /**
+ * Apply gift card to current invoice
+ */
+ async function applyGiftCard(couponCode, invoiceTotal, customer) {
+ return await call('pos_next.api.gift_cards.apply_gift_card', {
+ coupon_code: couponCode,
+ invoice_total: invoiceTotal,
+ customer: customer
+ })
+ }
+
+ /**
+ * Get gift cards with available balance
+ */
+ async function getAvailableGiftCards(customer, company) {
+ return await call('pos_next.api.gift_cards.get_gift_cards_with_balance', {
+ customer: customer,
+ company: company
+ })
+ }
+
+ return {
+ giftCardSettings,
+ isGiftCardItem,
+ createGiftCardFromInvoice,
+ applyGiftCard,
+ getAvailableGiftCards
+ }
+}
+```
+
+---
+
+## Phase 7: Hooks & Events
+
+### Files to Modify
+
+**7.1 Update hooks.py**
+
+File: `pos_next/hooks.py`
+
+```python
+doc_events = {
+ "POS Invoice": {
+ "on_submit": [
+ "pos_next.api.gift_cards.process_gift_card_on_submit",
+ "pos_next.api.gift_cards.create_gift_card_from_invoice"
+ ],
+ "on_cancel": "pos_next.api.gift_cards.process_gift_card_on_cancel"
+ },
+ "POS Coupon": {
+ "after_insert": "pos_next.api.gift_cards.sync_to_erpnext_coupon",
+ "on_update": "pos_next.api.gift_cards.update_erpnext_coupon",
+ "on_trash": "pos_next.api.gift_cards.delete_erpnext_coupon"
+ }
+}
+```
+
+---
+
+## Phase 8: Migration & Patches
+
+### Files to Create
+
+**8.1 Add custom fields patch**
+
+File: `pos_next/patches/v1_x/add_gift_card_custom_fields.py`
+
+```python
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+def execute():
+ """Add custom fields to Coupon Code for gift card support"""
+
+ custom_fields = {
+ "Coupon Code": [
+ {
+ "fieldname": "gift_card_amount",
+ "fieldtype": "Currency",
+ "label": "Gift Card Amount",
+ "insert_after": "pricing_rule",
+ "fetch_from": "pricing_rule.discount_amount",
+ "read_only": 1
+ },
+ {
+ "fieldname": "coupon_code_residual",
+ "fieldtype": "Link",
+ "label": "Original Gift Card",
+ "options": "Coupon Code",
+ "insert_after": "gift_card_amount",
+ "read_only": 1
+ },
+ {
+ "fieldname": "pos_coupon",
+ "fieldtype": "Link",
+ "label": "POS Coupon",
+ "options": "POS Coupon",
+ "insert_after": "coupon_code_residual",
+ "read_only": 1
+ }
+ ]
+ }
+
+ create_custom_fields(custom_fields)
+```
+
+**8.2 Update POS Coupon patch**
+
+File: `pos_next/patches/v1_x/update_pos_coupon_for_gift_cards.py`
+
+```python
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+def execute():
+ """Add gift card fields to POS Coupon"""
+
+ # Add new fields via Property Setter or direct update
+ # Update existing Gift Card coupons to set gift_card_amount
+ frappe.db.sql("""
+ UPDATE `tabPOS Coupon`
+ SET gift_card_amount = discount_amount,
+ original_amount = discount_amount
+ WHERE coupon_type = 'Gift Card'
+ AND gift_card_amount IS NULL
+ """)
+```
+
+---
+
+## Summary of Files to Create/Modify
+
+### New Files
+| File | Description |
+|------|-------------|
+| `pos_next/api/gift_cards.py` | Gift card API endpoints |
+| `pos_next/patches/v1_x/add_gift_card_custom_fields.py` | Custom fields migration |
+| `pos_next/patches/v1_x/update_pos_coupon_for_gift_cards.py` | POS Coupon migration |
+| `POS/src/components/sale/GiftCardDialog.vue` | Gift card UI dialog |
+| `POS/src/composables/useGiftCard.js` | Gift card composable |
+
+### Modified Files
+| File | Changes |
+|------|---------|
+| `pos_next/pos_next/doctype/pos_settings/pos_settings.json` | Add gift card settings section |
+| `pos_next/pos_next/doctype/pos_coupon/pos_coupon.json` | Add gift card fields, make customer optional |
+| `pos_next/pos_next/doctype/pos_coupon/pos_coupon.py` | Remove mandatory customer, add sync logic |
+| `pos_next/hooks.py` | Add doc_events for gift card processing |
+| `POS/src/stores/settings.js` | Load gift card settings |
+| `POS/src/pages/POSSale.vue` | Integrate gift card dialog |
+
+---
+
+## Testing Checklist
+
+- [ ] Create gift card by selling gift card item
+- [ ] Apply gift card to invoice (amount < total)
+- [ ] Apply gift card to invoice (amount > total) - test splitting
+- [ ] Apply gift card to invoice (amount = total)
+- [ ] Gift card with customer restriction
+- [ ] Gift card without customer (anonymous)
+- [ ] ERPNext Coupon Code sync verification
+- [ ] Pricing Rule creation verification
+- [ ] Gift card cancellation/return handling
+- [ ] Offline mode compatibility
From d2a64f3016ca08aed392d7f0b9a2fcbfba13fd56 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Mon, 12 Jan 2026 18:26:05 +0100
Subject: [PATCH 02/43] feat(gift-cards): implement phases 1-3 for ERPNext
Coupon Code sync
- Add custom fields to ERPNext Coupon Code doctype (gift_card_amount, coupon_code_residual, etc.)
- Add Gift Card settings section to POS Settings (enable_gift_cards, gift_card_item, splitting, etc.)
- Update POS Coupon: make customer optional, add gift card balance tracking fields
- Update hooks.py to include new custom fields in fixtures
---
docs/GIFT_CARD_SYNC_PLAN.md | 37 ++
pos_next/fixtures/custom_field.json | 342 ++++++++++++++++++
pos_next/hooks.py | 8 +-
.../doctype/pos_coupon/pos_coupon.json | 59 ++-
.../pos_next/doctype/pos_coupon/pos_coupon.py | 17 +-
.../doctype/pos_settings/pos_settings.json | 68 +++-
6 files changed, 525 insertions(+), 6 deletions(-)
diff --git a/docs/GIFT_CARD_SYNC_PLAN.md b/docs/GIFT_CARD_SYNC_PLAN.md
index 7e520b8d..cb1ff57a 100644
--- a/docs/GIFT_CARD_SYNC_PLAN.md
+++ b/docs/GIFT_CARD_SYNC_PLAN.md
@@ -1,5 +1,42 @@
# Gift Card & ERPNext Coupon Code Sync - Implementation Plan
+## Implementation Progress
+
+| Phase | Description | Status | Date |
+|-------|-------------|--------|------|
+| 1 | Custom fields for ERPNext Coupon Code | ✅ Done | 2025-01-12 |
+| 2 | Gift Card settings in POS Settings | ✅ Done | 2025-01-12 |
+| 3 | Update POS Coupon (customer optional) | ✅ Done | 2025-01-12 |
+| 4 | Gift Card sync API | ⏳ Pending | - |
+| 5 | Gift Card splitting logic | ⏳ Pending | - |
+| 6 | Frontend Gift Card components | ⏳ Pending | - |
+| 7 | Hooks & Events | ⏳ Pending | - |
+| 8 | Migration & Patches | ⏳ Pending | - |
+
+### Completed Changes
+
+**Phase 1-3 (2025-01-12):**
+- Added custom fields to `Coupon Code` doctype via fixtures:
+ - `pos_next_section` (Section Break)
+ - `gift_card_amount` (Currency)
+ - `original_gift_card_amount` (Currency)
+ - `coupon_code_residual` (Link to Coupon Code)
+ - `pos_coupon` (Link to POS Coupon)
+ - `source_pos_invoice` (Link to POS Invoice)
+- Added Gift Card settings section to POS Settings:
+ - `enable_gift_cards` (Check)
+ - `gift_card_item` (Link to Item)
+ - `sync_with_erpnext_coupon` (Check)
+ - `enable_gift_card_splitting` (Check)
+ - `gift_card_validity_months` (Int)
+ - `gift_card_notification` (Link to Notification)
+- Updated POS Coupon:
+ - Made `customer` field optional for Gift Cards
+ - Added `gift_card_amount`, `original_amount`, `coupon_code_residual`, `source_invoice` fields
+ - Updated validation to initialize gift card balance
+
+---
+
## Overview
This feature adds optional synchronization between POS Next's gift card system and ERPNext's native Coupon Code doctype. It enables:
diff --git a/pos_next/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json
index c0a8fb93..3930d8d3 100644
--- a/pos_next/fixtures/custom_field.json
+++ b/pos_next/fixtures/custom_field.json
@@ -511,5 +511,347 @@
"translatable": 0,
"unique": 0,
"width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 1,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": "eval:doc.coupon_type=='Gift Card'",
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Coupon Code",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "pos_next_section",
+ "fieldtype": "Section Break",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "pricing_rule",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "POS Next Gift Card",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2025-01-12 12:00:00.000000",
+ "module": "POS Next",
+ "name": "Coupon Code-pos_next_section",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": "eval:doc.coupon_type=='Gift Card'",
+ "description": "Current balance of the gift card",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Coupon Code",
+ "fetch_from": "pricing_rule.discount_amount",
+ "fetch_if_empty": 0,
+ "fieldname": "gift_card_amount",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "pos_next_section",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Gift Card Amount",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2025-01-12 12:00:00.000000",
+ "module": "POS Next",
+ "name": "Coupon Code-gift_card_amount",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "2",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": "eval:doc.coupon_type=='Gift Card'",
+ "description": "Original gift card value (before any usage)",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Coupon Code",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "original_gift_card_amount",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "gift_card_amount",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Original Amount",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2025-01-12 12:00:00.000000",
+ "module": "POS Next",
+ "name": "Coupon Code-original_gift_card_amount",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "2",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "Reference to original gift card if this was created from a split",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Coupon Code",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "coupon_code_residual",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "original_gift_card_amount",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Original Gift Card",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2025-01-12 12:00:00.000000",
+ "module": "POS Next",
+ "name": "Coupon Code-coupon_code_residual",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Coupon Code",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "Linked POS Coupon document",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Coupon Code",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "pos_coupon",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "coupon_code_residual",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "POS Coupon",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2025-01-12 12:00:00.000000",
+ "module": "POS Next",
+ "name": "Coupon Code-pos_coupon",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "POS Coupon",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "POS Invoice that created this gift card",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Coupon Code",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "source_pos_invoice",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "pos_coupon",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Source POS Invoice",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2025-01-12 12:00:00.000000",
+ "module": "POS Next",
+ "name": "Coupon Code-source_pos_invoice",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "POS Invoice",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
}
]
diff --git a/pos_next/hooks.py b/pos_next/hooks.py
index 20013ef7..daf83af9 100644
--- a/pos_next/hooks.py
+++ b/pos_next/hooks.py
@@ -100,7 +100,13 @@
"POS Profile-posa_cash_mode_of_payment",
"POS Profile-posa_allow_delete",
"POS Profile-posa_block_sale_beyond_available_qty",
- "Mode of Payment-is_wallet_payment"
+ "Mode of Payment-is_wallet_payment",
+ "Coupon Code-pos_next_section",
+ "Coupon Code-gift_card_amount",
+ "Coupon Code-original_gift_card_amount",
+ "Coupon Code-coupon_code_residual",
+ "Coupon Code-pos_coupon",
+ "Coupon Code-source_pos_invoice"
]
]
]
diff --git a/pos_next/pos_next/doctype/pos_coupon/pos_coupon.json b/pos_next/pos_next/doctype/pos_coupon/pos_coupon.json
index 089b0ee1..b49dadfe 100644
--- a/pos_next/pos_next/doctype/pos_coupon/pos_coupon.json
+++ b/pos_next/pos_next/doctype/pos_coupon/pos_coupon.json
@@ -19,6 +19,12 @@
"disabled",
"company",
"campaign",
+ "gift_card_section",
+ "gift_card_amount",
+ "original_amount",
+ "column_break_gift_card",
+ "coupon_code_residual",
+ "source_invoice",
"erpnext_integration_section",
"erpnext_coupon_code",
"pricing_rule",
@@ -64,7 +70,8 @@
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Customer",
- "options": "Customer"
+ "options": "Customer",
+ "description": "Optional: Assign gift card to a specific customer. If set, only this customer can use the gift card."
},
{
"fieldname": "column_break_4",
@@ -103,6 +110,54 @@
"label": "Campaign",
"options": "Campaign"
},
+ {
+ "collapsible": 0,
+ "depends_on": "eval: doc.coupon_type == \"Gift Card\"",
+ "fieldname": "gift_card_section",
+ "fieldtype": "Section Break",
+ "label": "Gift Card Balance"
+ },
+ {
+ "depends_on": "eval: doc.coupon_type == \"Gift Card\"",
+ "fieldname": "gift_card_amount",
+ "fieldtype": "Currency",
+ "label": "Current Balance",
+ "options": "Company:company:default_currency",
+ "precision": "2",
+ "read_only": 1,
+ "in_list_view": 1,
+ "description": "Current available balance on this gift card"
+ },
+ {
+ "depends_on": "eval: doc.coupon_type == \"Gift Card\"",
+ "fieldname": "original_amount",
+ "fieldtype": "Currency",
+ "label": "Original Amount",
+ "options": "Company:company:default_currency",
+ "precision": "2",
+ "read_only": 1,
+ "description": "Original gift card value when created"
+ },
+ {
+ "fieldname": "column_break_gift_card",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "coupon_code_residual",
+ "fieldtype": "Link",
+ "label": "Original Gift Card",
+ "options": "POS Coupon",
+ "read_only": 1,
+ "description": "Reference to original gift card if this was created from a split"
+ },
+ {
+ "fieldname": "source_invoice",
+ "fieldtype": "Link",
+ "label": "Source Invoice",
+ "options": "POS Invoice",
+ "read_only": 1,
+ "description": "POS Invoice that created this gift card"
+ },
{
"collapsible": 1,
"fieldname": "erpnext_integration_section",
@@ -263,7 +318,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-07-30 00:17:17.711972",
+ "modified": "2025-01-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "POS Next",
"name": "POS Coupon",
diff --git a/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py b/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py
index e57b832b..604a0515 100644
--- a/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py
+++ b/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py
@@ -24,8 +24,15 @@ def validate(self):
# Gift Card validations
if self.coupon_type == "Gift Card":
self.maximum_use = 1
- if not self.customer:
- frappe.throw(_("Please select the customer for Gift Card."))
+ # Customer is OPTIONAL for gift cards - they can be anonymous
+ # If customer is set, only that customer can use the gift card
+
+ # Initialize gift card balance if not set
+ if self.discount_type == "Amount" and self.discount_amount:
+ if not self.gift_card_amount:
+ self.gift_card_amount = flt(self.discount_amount)
+ if not self.original_amount:
+ self.original_amount = flt(self.discount_amount)
# Discount validations
if not self.discount_type:
@@ -88,6 +95,12 @@ def check_coupon_code(coupon_code, customer=None, company=None):
res["msg"] = _("Sorry, this coupon code has been fully redeemed")
return res
+ # Check gift card balance (for gift cards with splitting enabled)
+ if coupon.coupon_type == "Gift Card" and hasattr(coupon, 'gift_card_amount'):
+ if coupon.gift_card_amount is not None and flt(coupon.gift_card_amount) <= 0:
+ res["msg"] = _("Sorry, this gift card has no remaining balance")
+ return res
+
# Check company
if company and coupon.company != company:
res["msg"] = _("Sorry, this coupon is not valid for this company")
diff --git a/pos_next/pos_next/doctype/pos_settings/pos_settings.json b/pos_next/pos_next/doctype/pos_settings/pos_settings.json
index 81342931..494677f3 100644
--- a/pos_next/pos_next/doctype/pos_settings/pos_settings.json
+++ b/pos_next/pos_next/doctype/pos_settings/pos_settings.json
@@ -15,6 +15,14 @@
"column_break_wallet",
"auto_create_wallet",
"loyalty_to_wallet",
+ "section_break_gift_card",
+ "enable_gift_cards",
+ "gift_card_item",
+ "sync_with_erpnext_coupon",
+ "column_break_gift_card",
+ "enable_gift_card_splitting",
+ "gift_card_validity_months",
+ "gift_card_notification",
"section_break_general",
"allow_user_to_edit_additional_discount",
"allow_user_to_edit_item_discount",
@@ -153,6 +161,64 @@
"read_only_depends_on": "enable_loyalty_program"
},
{
+ "collapsible": 0,
+ "fieldname": "section_break_gift_card",
+ "fieldtype": "Section Break",
+ "label": "Gift Cards"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_gift_cards",
+ "fieldtype": "Check",
+ "label": "Enable Gift Cards",
+ "description": "Enable gift card functionality in POS"
+ },
+ {
+ "fieldname": "gift_card_item",
+ "fieldtype": "Link",
+ "label": "Gift Card Item",
+ "options": "Item",
+ "depends_on": "enable_gift_cards",
+ "description": "Item that represents a gift card purchase. When sold, creates a gift card coupon with the item's price as the gift card value."
+ },
+ {
+ "default": "1",
+ "fieldname": "sync_with_erpnext_coupon",
+ "fieldtype": "Check",
+ "label": "Sync with ERPNext Coupon Code",
+ "depends_on": "enable_gift_cards",
+ "description": "Create ERPNext Coupon Code and Pricing Rule for each gift card. Enables accounting integration and usage in ERPNext."
+ },
+ {
+ "fieldname": "column_break_gift_card",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "1",
+ "fieldname": "enable_gift_card_splitting",
+ "fieldtype": "Check",
+ "label": "Enable Gift Card Splitting",
+ "depends_on": "enable_gift_cards",
+ "description": "When gift card amount exceeds invoice total, keep the remaining balance on the card for future use."
+ },
+ {
+ "default": "12",
+ "fieldname": "gift_card_validity_months",
+ "fieldtype": "Int",
+ "label": "Gift Card Validity (Months)",
+ "depends_on": "enable_gift_cards",
+ "description": "Number of months a gift card is valid from creation date. Set to 0 for no expiration."
+ },
+ {
+ "fieldname": "gift_card_notification",
+ "fieldtype": "Link",
+ "label": "Gift Card Notification",
+ "options": "Notification",
+ "depends_on": "enable_gift_cards",
+ "description": "Notification template to send when a gift card is created (e.g., email with gift card code)."
+ },
+ {
+ "collapsible": 0,
"fieldname": "section_break_general",
"fieldtype": "Section Break",
"label": "General Settings"
@@ -518,7 +584,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2026-01-11 16:52:08.009217",
+ "modified": "2026-01-12 12:00:00.000000",
"modified_by": "Administrator",
"module": "POS Next",
"name": "POS Settings",
From ce505902eb93ac6b748e0587ceddf2a3b8128d05 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Mon, 12 Jan 2026 20:35:22 +0100
Subject: [PATCH 03/43] feat(gift-cards): implement gift card API, splitting
logic, and frontend components
- Add pos_next/api/gift_cards.py with full gift card management:
- Gift card creation from invoice when selling gift card items
- ERPNext Coupon Code + Pricing Rule sync
- Gift card splitting (balance update when amount > invoice total)
- Cancel handling with balance restoration
- Add useGiftCard.js composable for frontend gift card operations
- Enhance CouponDialog.vue to show gift card balance and splitting info
- Update offers.py to support anonymous gift cards and balance tracking
- Add POS Invoice hooks for automatic gift card processing
- Update implementation plan with phases 4-7 progress
---
POS/src/components/sale/CouponDialog.vue | 77 ++-
POS/src/composables/useGiftCard.js | 200 +++++++
docs/GIFT_CARD_SYNC_PLAN.md | 39 +-
pos_next/api/gift_cards.py | 724 +++++++++++++++++++++++
pos_next/api/offers.py | 114 +++-
pos_next/hooks.py | 7 +
6 files changed, 1111 insertions(+), 50 deletions(-)
create mode 100644 POS/src/composables/useGiftCard.js
create mode 100644 pos_next/api/gift_cards.py
diff --git a/POS/src/components/sale/CouponDialog.vue b/POS/src/components/sale/CouponDialog.vue
index 3fb4f8bf..ec9a5411 100644
--- a/POS/src/components/sale/CouponDialog.vue
+++ b/POS/src/components/sale/CouponDialog.vue
@@ -45,7 +45,7 @@
d="M18 9H2v5a2 2 0 002 2h12a2 2 0 002-2V9zM4 13a1 1 0 011-1h1a1 1 0 110 2H5a1 1 0 01-1-1zm5-1a1 1 0 100 2h1a1 1 0 100-2H9z"
clip-rule="evenodd" />
- {{ __('My Gift Cards ({0})', [giftCards.length]) }}
+ {{ __('Available Gift Cards ({0})', [giftCards.length]) }}
@@ -68,15 +68,26 @@
{{ card.coupon_code }}
-
{{ card.coupon_name }}
+
+ {{ card.coupon_name }}
+ ({{ __('Anonymous') }})
+
-
+
+
+
+ {{ formatCurrency(card.balance || card.gift_card_amount || card.discount_amount) }}
+
+
{{ __('Balance') }}
+
+
+
@@ -93,12 +104,12 @@
- {{ __('Coupon Applied Successfully!') }}
+ {{ appliedDiscount.isGiftCard ? __('Gift Card Applied!') : __('Coupon Applied Successfully!') }}
- {{ __('Coupon Code') }}
+ {{ appliedDiscount.isGiftCard ? __('Gift Card Code') : __('Coupon Code') }}
{{ appliedDiscount.code }}
@@ -107,6 +118,17 @@
-{{ formatCurrency(appliedDiscount.amount) }}
+
+
+
+ {{ __('Card Balance') }}
+ {{ formatCurrency(appliedDiscount.availableBalance) }}
+
+
+ {{ __('Remaining after purchase') }}
+ {{ formatCurrency(appliedDiscount.remainingBalance) }}
+
+
@@ -282,6 +304,7 @@ async function applyCoupon() {
}
const coupon = validationData.coupon
+ const isGiftCard = coupon.coupon_type === "Gift Card" || coupon.is_gift_card
// Check minimum amount (on subtotal before tax)
if (coupon.min_amount && props.subtotal < coupon.min_amount) {
@@ -290,18 +313,27 @@ async function applyCoupon() {
return
}
- // Calculate discount on subtotal (before tax) using centralized helper
- // Transform server coupon format to discount object format
- const discountObj = {
- percentage: coupon.discount_type === "Percentage" ? coupon.discount_percentage : 0,
- amount: coupon.discount_type === "Amount" ? coupon.discount_amount : 0,
- }
-
- let discountAmount = calculateDiscountAmount(discountObj, props.subtotal)
-
- // Apply maximum discount limit if specified
- if (coupon.max_amount && discountAmount > coupon.max_amount) {
- discountAmount = coupon.max_amount
+ let discountAmount = 0
+ let availableBalance = 0
+ let remainingBalance = 0
+
+ if (isGiftCard) {
+ // Gift card: use balance as discount, cap at subtotal
+ availableBalance = coupon.balance || coupon.gift_card_amount || coupon.discount_amount || 0
+ discountAmount = Math.min(availableBalance, props.subtotal)
+ remainingBalance = availableBalance - discountAmount
+ } else {
+ // Regular coupon: calculate based on discount type
+ const discountObj = {
+ percentage: coupon.discount_type === "Percentage" ? coupon.discount_percentage : 0,
+ amount: coupon.discount_type === "Amount" ? coupon.discount_amount : 0,
+ }
+ discountAmount = calculateDiscountAmount(discountObj, props.subtotal)
+
+ // Apply maximum discount limit if specified
+ if (coupon.max_amount && discountAmount > coupon.max_amount) {
+ discountAmount = coupon.max_amount
+ }
}
// Clamp discount to subtotal to prevent negative totals
@@ -315,6 +347,9 @@ async function applyCoupon() {
type: coupon.discount_type,
coupon: coupon,
apply_on: coupon.apply_on,
+ isGiftCard: isGiftCard,
+ availableBalance: isGiftCard ? availableBalance : null,
+ remainingBalance: isGiftCard ? remainingBalance : null,
}
emit("discount-applied", appliedDiscount.value)
diff --git a/POS/src/composables/useGiftCard.js b/POS/src/composables/useGiftCard.js
new file mode 100644
index 00000000..af984b9d
--- /dev/null
+++ b/POS/src/composables/useGiftCard.js
@@ -0,0 +1,200 @@
+/**
+ * Gift Card Composable
+ *
+ * Handles gift card operations including:
+ * - Loading available gift cards
+ * - Applying gift cards to invoices
+ * - Gift card balance management
+ * - Gift card splitting logic
+ */
+
+import { createResource } from "frappe-ui"
+import { ref, computed } from "vue"
+
+export function useGiftCard() {
+ const giftCards = ref([])
+ const loading = ref(false)
+ const error = ref(null)
+
+ /**
+ * Resource to load available gift cards
+ */
+ const giftCardsResource = createResource({
+ url: "pos_next.api.offers.get_active_coupons",
+ auto: false,
+ onSuccess(data) {
+ // Handle frappe response wrapper
+ const cards = data?.message || data || []
+ giftCards.value = cards
+ error.value = null
+ },
+ onError(err) {
+ console.error("Error loading gift cards:", err)
+ error.value = err
+ giftCards.value = []
+ },
+ })
+
+ /**
+ * Resource to apply a gift card
+ */
+ const applyGiftCardResource = createResource({
+ url: "pos_next.api.gift_cards.apply_gift_card",
+ auto: false,
+ })
+
+ /**
+ * Load available gift cards for a customer and company
+ *
+ * @param {Object} params - Parameters
+ * @param {string} params.customer - Customer name (optional for anonymous cards)
+ * @param {string} params.company - Company name
+ * @returns {Promise} - List of available gift cards
+ */
+ async function loadGiftCards({ customer, company }) {
+ if (!company) {
+ giftCards.value = []
+ return []
+ }
+
+ loading.value = true
+ try {
+ await giftCardsResource.fetch({
+ customer: customer || null,
+ company,
+ })
+ return giftCards.value
+ } catch (err) {
+ console.error("Failed to load gift cards:", err)
+ return []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ /**
+ * Apply a gift card to an invoice
+ *
+ * @param {Object} params - Parameters
+ * @param {string} params.couponCode - Gift card code
+ * @param {number} params.invoiceTotal - Invoice total amount
+ * @param {string} params.customer - Customer name (optional)
+ * @param {string} params.company - Company name
+ * @returns {Promise
{
}
// Filter by type
+ // Note: POS Next gift cards have coupon_type='Promotional' but pos_next_gift_card=1
if (filterType.value !== "all") {
- filtered = filtered.filter((c) => c.coupon_type === filterType.value)
+ filtered = filtered.filter((c) => {
+ if (filterType.value === "Gift Card") {
+ // Include both ERPNext gift cards (coupon_type) and POS Next gift cards (pos_next_gift_card)
+ return c.coupon_type === "Gift Card" || c.pos_next_gift_card === 1
+ }
+ if (filterType.value === "Promotional") {
+ // Only show promotional coupons that are NOT gift cards
+ return c.coupon_type === "Promotional" && !c.pos_next_gift_card
+ }
+ return c.coupon_type === filterType.value
+ })
}
return filtered
From 310377d32abf477c1790fa39f3ada3700d9e915d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Fri, 16 Jan 2026 13:52:40 +0100
Subject: [PATCH 35/43] refactor(pos-settings): remove obsolete
sync_with_erpnext_coupon field
Gift cards now always create ERPNext Coupon Code directly, making
this option redundant. The sync is always enabled by design.
---
POS/components.d.ts | 1 +
.../pos_next/doctype/pos_settings/pos_settings.json | 11 +----------
2 files changed, 2 insertions(+), 10 deletions(-)
diff --git a/POS/components.d.ts b/POS/components.d.ts
index c50eba95..8385e757 100644
--- a/POS/components.d.ts
+++ b/POS/components.d.ts
@@ -20,6 +20,7 @@ declare module 'vue' {
CustomerDialog: typeof import('./src/components/sale/CustomerDialog.vue')['default']
DraftInvoicesDialog: typeof import('./src/components/sale/DraftInvoicesDialog.vue')['default']
EditItemDialog: typeof import('./src/components/sale/EditItemDialog.vue')['default']
+ GiftCardCreatedDialog: typeof import('./src/components/sale/GiftCardCreatedDialog.vue')['default']
InstallAppBadge: typeof import('./src/components/common/InstallAppBadge.vue')['default']
InvoiceCart: typeof import('./src/components/sale/InvoiceCart.vue')['default']
InvoiceDetailDialog: typeof import('./src/components/invoices/InvoiceDetailDialog.vue')['default']
diff --git a/pos_next/pos_next/doctype/pos_settings/pos_settings.json b/pos_next/pos_next/doctype/pos_settings/pos_settings.json
index 494677f3..139c438c 100644
--- a/pos_next/pos_next/doctype/pos_settings/pos_settings.json
+++ b/pos_next/pos_next/doctype/pos_settings/pos_settings.json
@@ -18,7 +18,6 @@
"section_break_gift_card",
"enable_gift_cards",
"gift_card_item",
- "sync_with_erpnext_coupon",
"column_break_gift_card",
"enable_gift_card_splitting",
"gift_card_validity_months",
@@ -181,14 +180,6 @@
"depends_on": "enable_gift_cards",
"description": "Item that represents a gift card purchase. When sold, creates a gift card coupon with the item's price as the gift card value."
},
- {
- "default": "1",
- "fieldname": "sync_with_erpnext_coupon",
- "fieldtype": "Check",
- "label": "Sync with ERPNext Coupon Code",
- "depends_on": "enable_gift_cards",
- "description": "Create ERPNext Coupon Code and Pricing Rule for each gift card. Enables accounting integration and usage in ERPNext."
- },
{
"fieldname": "column_break_gift_card",
"fieldtype": "Column Break"
@@ -584,7 +575,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2026-01-12 12:00:00.000000",
+ "modified": "2026-01-16 14:00:00.000000",
"modified_by": "Administrator",
"module": "POS Next",
"name": "POS Settings",
From 8e06bb9cfa329bbcb9fff0da4ac527b3f60d5f81 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Fri, 16 Jan 2026 15:42:38 +0100
Subject: [PATCH 36/43] fix(gift-cards): calculate discount on net total after
pricing rules
Gift card discount was incorrectly calculated on the original subtotal
instead of the net total after pricing rules. This caused validation
errors when the gift card balance exceeded the discounted price.
- Add netTotalBeforeAdditionalDiscount computed in useInvoice.js
- Pass netTotal prop to CouponDialog for gift card calculations
- Use netTotal for gift card discount capping instead of subtotal
---
POS/src/components/sale/CouponDialog.vue | 22 ++++++++++++++++------
POS/src/composables/useInvoice.js | 6 ++++++
POS/src/pages/POSSale.vue | 1 +
POS/src/stores/posCart.js | 2 ++
4 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/POS/src/components/sale/CouponDialog.vue b/POS/src/components/sale/CouponDialog.vue
index ec9a5411..38afb214 100644
--- a/POS/src/components/sale/CouponDialog.vue
+++ b/POS/src/components/sale/CouponDialog.vue
@@ -182,7 +182,12 @@ const props = defineProps({
subtotal: {
type: Number,
required: true,
- note: __("Cart subtotal BEFORE tax - used for discount calculations"),
+ note: __("Cart subtotal BEFORE tax - used for regular coupon discount calculations"),
+ },
+ netTotal: {
+ type: Number,
+ required: true,
+ note: __("Net total AFTER pricing rules but BEFORE additional discount - used for gift card calculations"),
},
items: Array,
posProfile: String,
@@ -318,12 +323,14 @@ async function applyCoupon() {
let remainingBalance = 0
if (isGiftCard) {
- // Gift card: use balance as discount, cap at subtotal
+ // Gift card: use balance as discount, cap at netTotal (after pricing rules)
+ // This is critical: gift card discount must be based on the ACTUAL amount to pay
+ // after pricing rules have been applied, not the original subtotal
availableBalance = coupon.balance || coupon.gift_card_amount || coupon.discount_amount || 0
- discountAmount = Math.min(availableBalance, props.subtotal)
+ discountAmount = Math.min(availableBalance, props.netTotal)
remainingBalance = availableBalance - discountAmount
} else {
- // Regular coupon: calculate based on discount type
+ // Regular coupon: calculate based on discount type (uses original subtotal)
const discountObj = {
percentage: coupon.discount_type === "Percentage" ? coupon.discount_percentage : 0,
amount: coupon.discount_type === "Amount" ? coupon.discount_amount : 0,
@@ -336,8 +343,11 @@ async function applyCoupon() {
}
}
- // Clamp discount to subtotal to prevent negative totals
- discountAmount = Math.min(discountAmount, props.subtotal)
+ // Clamp discount to the appropriate total based on coupon type
+ // Gift cards: clamp to netTotal (after pricing rules)
+ // Regular coupons: clamp to subtotal (original prices)
+ const maxDiscount = isGiftCard ? props.netTotal : props.subtotal
+ discountAmount = Math.min(discountAmount, maxDiscount)
appliedDiscount.value = {
name: coupon.coupon_name || coupon.coupon_code,
diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js
index 5419745f..860e256b 100644
--- a/POS/src/composables/useInvoice.js
+++ b/POS/src/composables/useInvoice.js
@@ -189,6 +189,11 @@ export function useInvoice() {
const totalDiscount = computed(() =>
roundCurrency(_cachedTotalDiscount.value + (additionalDiscount.value || 0)),
)
+ // Net total after item-level discounts (pricing rules) but BEFORE additional discount (coupon/gift card)
+ // This is the correct base for gift card calculations
+ const netTotalBeforeAdditionalDiscount = computed(
+ () => _cachedSubtotal.value - _cachedTotalDiscount.value,
+ )
const grandTotal = computed(() => {
const discount =
_cachedTotalDiscount.value + (additionalDiscount.value || 0)
@@ -1150,6 +1155,7 @@ export function useInvoice() {
totalPaid,
remainingAmount,
canSubmit,
+ netTotalBeforeAdditionalDiscount,
// Actions
addItem,
diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue
index 288ac721..953ca074 100644
--- a/POS/src/pages/POSSale.vue
+++ b/POS/src/pages/POSSale.vue
@@ -501,6 +501,7 @@
{
totalTax,
totalDiscount,
grandTotal,
+ netTotalBeforeAdditionalDiscount,
posProfile,
posOpeningShift,
payments,
@@ -1699,6 +1700,7 @@ export const usePOSCartStore = defineStore("posCart", () => {
totalTax,
totalDiscount,
grandTotal,
+ netTotalBeforeAdditionalDiscount,
posProfile,
posOpeningShift,
payments,
From b657e65fd987a391899b7d7f965959cde5b2c115 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Fri, 30 Jan 2026 17:10:32 +0100
Subject: [PATCH 37/43] Fix: use discount_amount instead of
additional_discount_amount for gift card discounts
---
POS/src/composables/useInvoice.js | 2 ++
POS/src/pages/POSSale.vue | 8 +++++++
POS/src/utils/printInvoice.js | 36 +++++++++++++++++++++++++------
3 files changed, 40 insertions(+), 6 deletions(-)
diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js
index 860e256b..ad9df962 100644
--- a/POS/src/composables/useInvoice.js
+++ b/POS/src/composables/useInvoice.js
@@ -839,6 +839,7 @@ export function useInvoice() {
amount: p.amount,
type: p.type,
})),
+ // Document-level discount for coupons and gift cards
discount_amount: additionalDiscount.value || 0,
coupon_code: couponCode.value,
posa_coupon_code: couponCode.value ? couponCode.value.toUpperCase() : null,
@@ -905,6 +906,7 @@ export function useInvoice() {
amount: p.amount,
type: p.type,
})),
+ // Document-level discount for coupons and gift cards
discount_amount: additionalDiscount.value || 0,
coupon_code: couponCode.value,
posa_coupon_code: couponCode.value ? couponCode.value.toUpperCase() : null,
diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue
index 953ca074..0b2f2b92 100644
--- a/POS/src/pages/POSSale.vue
+++ b/POS/src/pages/POSSale.vue
@@ -1975,6 +1975,14 @@ async function handlePaymentCompleted(paymentData) {
total_tax: cartStore.totalTax,
total_discount: cartStore.totalDiscount,
write_off_amount: paymentData.write_off_amount || 0,
+ // Document-level discount for coupons and gift cards
+ discount_amount: cartStore.additionalDiscount || 0,
+ apply_discount_on: cartStore.additionalDiscount > 0 ? "Grand Total" : null,
+ coupon_code: cartStore.couponCode || null,
+ posa_coupon_code: cartStore.couponCode ? cartStore.couponCode.toUpperCase() : null,
+ posa_gift_card_amount_used: cartStore.additionalDiscount || 0,
+ is_pos: 1,
+ update_stock: 1,
};
await offlineStore.saveInvoiceOffline(invoiceData);
diff --git a/POS/src/utils/printInvoice.js b/POS/src/utils/printInvoice.js
index 6a95cc4d..1ec9db7c 100644
--- a/POS/src/utils/printInvoice.js
+++ b/POS/src/utils/printInvoice.js
@@ -251,12 +251,36 @@ export function printInvoiceCustom(invoiceData) {
- ${invoiceData.total_taxes_and_charges && invoiceData.total_taxes_and_charges > 0 ? `
-
${__("Subtotal:")}${formatCurrency((invoiceData.grand_total || 0) - (invoiceData.total_taxes_and_charges || 0))}
-
${__("Tax:")}${formatCurrency(invoiceData.total_taxes_and_charges)}
` : ""}
- ${invoiceData.discount_amount ? `
-
Additional Discount${invoiceData.additional_discount_percentage ? ` (${Number(invoiceData.additional_discount_percentage).toFixed(1)}%)` : ""}:-${formatCurrency(Math.abs(invoiceData.discount_amount))}
` : ""}
-
${__("TOTAL:")}${formatCurrency(invoiceData.grand_total)}
+ ${
+ invoiceData.total_taxes_and_charges &&
+ invoiceData.total_taxes_and_charges > 0
+ ? `
+
+ ${__('Subtotal:')}
+ ${formatCurrency((invoiceData.grand_total || 0) - (invoiceData.total_taxes_and_charges || 0))}
+
+
+ ${__('Tax:')}
+ ${formatCurrency(invoiceData.total_taxes_and_charges)}
+
+ `
+ : ""
+ }
+ ${
+ // Document-level discount (discount_amount is the primary field)
+ invoiceData.discount_amount
+ ? `
+
+ Additional Discount${invoiceData.additional_discount_percentage ? ` (${Number(invoiceData.additional_discount_percentage).toFixed(1)}%)` : ""}:
+ -${formatCurrency(Math.abs(invoiceData.discount_amount))}
+
+ `
+ : ""
+ }
+
+ ${__('TOTAL:')}
+ ${formatCurrency(invoiceData.grand_total)}
+
${invoiceData.payments && invoiceData.payments.length > 0 ? `
From 9bc096dea84b857aac5081e105f2fbe3ec284d2e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Thu, 5 Feb 2026 22:38:32 +0100
Subject: [PATCH 38/43] feat(coupons): use native ERPNext coupon_code field on
Sales Invoice
- Add coupon_code Link field (Custom Field) on Sales Invoice for native ERPNext integration
- Add validate/submit/cancel hooks for coupon usage tracking via ERPNext pricing_rule utils
- Simplify update_invoice: use coupon_code directly instead of post-save DB patching
- Mark posa_coupon_code as legacy, keep for backwards compatibility with gift cards
---
pos_next/api/invoices.py | 50 +--
pos_next/api/sales_invoice_hooks.py | 68 ++++
pos_next/fixtures/custom_field.json | 63 +++-
pos_next/hooks.py | 13 +-
.../tests/test_coupon_invoice_integration.py | 293 ++++++++++++++++++
5 files changed, 440 insertions(+), 47 deletions(-)
create mode 100644 pos_next/tests/test_coupon_invoice_integration.py
diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py
index 19ffccb1..c3f0541a 100644
--- a/pos_next/api/invoices.py
+++ b/pos_next/api/invoices.py
@@ -870,14 +870,15 @@ def update_invoice(data):
error_msg = coupon_result.get("message", _("Invalid coupon code")) if coupon_result else _("Invalid coupon code")
frappe.throw(error_msg)
- # Store coupon code on invoice for tracking (using custom field)
- invoice_doc.posa_coupon_code = coupon_code
+ # Get the actual Coupon Code document name for the Link field
+ # This ensures proper linking even if the user entered the code in different case
+ coupon_doc_name = coupon_result.get("coupon", {}).get("name") or coupon_code.upper()
- # Store discount values before save (ERPNext validation may clear them)
- discount_amount_to_persist = flt(invoice_doc.get("discount_amount"))
- apply_discount_on = invoice_doc.get("apply_discount_on")
- calculated_grand_total = flt(invoice_doc.get("grand_total"))
- coupon_code_to_persist = invoice_doc.get("posa_coupon_code")
+ # Store coupon code on invoice using native ERPNext field (Link to Coupon Code)
+ # This enables native ERPNext coupon tracking (usage counter on submit/cancel)
+ invoice_doc.coupon_code = coupon_doc_name
+ # Also store in legacy field for backwards compatibility with gift cards
+ invoice_doc.posa_coupon_code = coupon_doc_name
# Save as draft
invoice_doc.flags.ignore_permissions = True
@@ -885,41 +886,6 @@ def update_invoice(data):
invoice_doc.docstatus = 0
invoice_doc.save()
- # Restore discount values directly to database if they were cleared by ERPNext validation
- if discount_amount_to_persist > 0:
- # Build update dict
- update_dict = {
- "discount_amount": discount_amount_to_persist,
- "apply_discount_on": apply_discount_on or "Grand Total",
- "grand_total": calculated_grand_total,
- "base_grand_total": calculated_grand_total,
- "rounded_total": calculated_grand_total,
- "base_rounded_total": calculated_grand_total,
- "outstanding_amount": calculated_grand_total
- }
- # Also persist the coupon code if set
- if coupon_code_to_persist:
- update_dict["posa_coupon_code"] = coupon_code_to_persist
-
- # Update database directly to persist the values
- frappe.db.set_value(
- doctype,
- invoice_doc.name,
- update_dict,
- update_modified=False
- )
-
- # Update the in-memory document to reflect the changes
- invoice_doc.discount_amount = discount_amount_to_persist
- invoice_doc.apply_discount_on = apply_discount_on or "Grand Total"
- invoice_doc.grand_total = calculated_grand_total
- invoice_doc.base_grand_total = calculated_grand_total
- invoice_doc.rounded_total = calculated_grand_total
- invoice_doc.base_rounded_total = calculated_grand_total
- invoice_doc.outstanding_amount = calculated_grand_total
- if coupon_code_to_persist:
- invoice_doc.posa_coupon_code = coupon_code_to_persist
-
return invoice_doc.as_dict()
except Exception as e:
frappe.log_error(frappe.get_traceback(), "Update Invoice Error")
diff --git a/pos_next/api/sales_invoice_hooks.py b/pos_next/api/sales_invoice_hooks.py
index 10304f89..f69d64e1 100644
--- a/pos_next/api/sales_invoice_hooks.py
+++ b/pos_next/api/sales_invoice_hooks.py
@@ -141,3 +141,71 @@ def before_cancel(doc, method=None):
alert=True,
indicator="orange"
)
+
+
+def validate_coupon_on_invoice(doc, method=None):
+ """
+ Validate coupon code on Sales Invoice (like Sales Order does).
+ This enables native ERPNext coupon validation for Sales Invoice.
+
+ Args:
+ doc: Sales Invoice document
+ method: Hook method name (unused)
+ """
+ if not doc.coupon_code:
+ return
+
+ try:
+ from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code
+ validate_coupon_code(doc.coupon_code)
+ except Exception as e:
+ frappe.log_error(
+ "Coupon Validation Error",
+ f"Invoice: {doc.name}, Coupon: {doc.coupon_code}, Error: {str(e)}"
+ )
+ raise
+
+
+def update_coupon_usage_on_submit(doc, method=None):
+ """
+ Increment coupon usage counter on submit.
+ This mirrors the behavior in Sales Order for ERPNext coupon tracking.
+
+ Args:
+ doc: Sales Invoice document
+ method: Hook method name (unused)
+ """
+ if not doc.coupon_code:
+ return
+
+ try:
+ from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count
+ update_coupon_code_count(doc.coupon_code, "used")
+ except Exception as e:
+ frappe.log_error(
+ "Coupon Usage Update Error",
+ f"Invoice: {doc.name}, Coupon: {doc.coupon_code}, Action: used, Error: {str(e)}"
+ )
+ # Don't block invoice submission if coupon update fails
+
+
+def update_coupon_usage_on_cancel(doc, method=None):
+ """
+ Decrement coupon usage counter on cancel.
+ This mirrors the behavior in Sales Order for ERPNext coupon tracking.
+
+ Args:
+ doc: Sales Invoice document
+ method: Hook method name (unused)
+ """
+ if not doc.coupon_code:
+ return
+
+ try:
+ from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count
+ update_coupon_code_count(doc.coupon_code, "cancelled")
+ except Exception as e:
+ frappe.log_error(
+ "Coupon Usage Update Error",
+ f"Invoice: {doc.name}, Coupon: {doc.coupon_code}, Action: cancelled, Error: {str(e)}"
+ )
diff --git a/pos_next/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json
index 3025a78a..08aa12d6 100644
--- a/pos_next/fixtures/custom_field.json
+++ b/pos_next/fixtures/custom_field.json
@@ -179,7 +179,7 @@
"columns": 0,
"default": null,
"depends_on": null,
- "description": "Coupon code used for this invoice (for gift card tracking)",
+ "description": "Legacy coupon code field for gift card tracking (deprecated, use coupon_code instead)",
"docstatus": 0,
"doctype": "Custom Field",
"dt": "Sales Invoice",
@@ -200,11 +200,11 @@
"insert_after": "posa_is_printed",
"is_system_generated": 0,
"is_virtual": 0,
- "label": "Coupon Code",
+ "label": "Coupon Code (Legacy)",
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
- "modified": "2026-01-13 13:30:00",
+ "modified": "2026-02-05 10:00:00",
"module": "POS Next",
"name": "Sales Invoice-posa_coupon_code",
"no_copy": 1,
@@ -227,6 +227,63 @@
"unique": 0,
"width": null
},
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "Coupon Code used for this invoice (native ERPNext integration)",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Sales Invoice",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "coupon_code",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "additional_discount_percentage",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Coupon Code",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-02-05 10:00:00",
+ "module": "POS Next",
+ "name": "Sales Invoice-coupon_code",
+ "no_copy": 1,
+ "non_negative": 0,
+ "options": "Coupon Code",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 1,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
diff --git a/pos_next/hooks.py b/pos_next/hooks.py
index a433c701..e9043ccf 100644
--- a/pos_next/hooks.py
+++ b/pos_next/hooks.py
@@ -97,6 +97,7 @@
"Sales Invoice-posa_pos_opening_shift",
"Sales Invoice-posa_is_printed",
"Sales Invoice-posa_coupon_code",
+ "Sales Invoice-coupon_code",
"Item-custom_company",
"POS Profile-posa_cash_mode_of_payment",
"POS Profile-posa_allow_delete",
@@ -220,14 +221,22 @@
"Sales Invoice": {
"validate": [
"pos_next.api.sales_invoice_hooks.validate",
+ "pos_next.api.sales_invoice_hooks.validate_coupon_on_invoice",
"pos_next.api.wallet.validate_wallet_payment"
],
"before_cancel": "pos_next.api.sales_invoice_hooks.before_cancel",
"on_submit": [
+ "pos_next.api.sales_invoice_hooks.update_coupon_usage_on_submit",
"pos_next.realtime_events.emit_stock_update_event",
- "pos_next.api.wallet.process_loyalty_to_wallet"
+ "pos_next.api.wallet.process_loyalty_to_wallet",
+ "pos_next.api.gift_cards.create_gift_card_from_invoice",
+ "pos_next.api.gift_cards.process_gift_card_on_submit"
+ ],
+ "on_cancel": [
+ "pos_next.api.sales_invoice_hooks.update_coupon_usage_on_cancel",
+ "pos_next.realtime_events.emit_stock_update_event",
+ "pos_next.api.gift_cards.process_gift_card_on_cancel"
],
- "on_cancel": "pos_next.realtime_events.emit_stock_update_event",
"after_insert": "pos_next.realtime_events.emit_invoice_created_event"
},
"POS Invoice": {
diff --git a/pos_next/tests/test_coupon_invoice_integration.py b/pos_next/tests/test_coupon_invoice_integration.py
new file mode 100644
index 00000000..b69093bc
--- /dev/null
+++ b/pos_next/tests/test_coupon_invoice_integration.py
@@ -0,0 +1,293 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, BrainWise and contributors
+# For license information, please see license.txt
+
+"""
+Test Suite for Coupon Code Integration with Sales Invoice
+
+This module tests the native ERPNext coupon_code field integration on Sales Invoice:
+- Coupon validation on invoice validate
+- Coupon usage counter increment on submit
+- Coupon usage counter decrement on cancel
+
+Run with: bench --site [site] run-tests --app pos_next --module pos_next.tests.test_coupon_invoice_integration
+"""
+
+import frappe
+import unittest
+from frappe.utils import nowdate, add_months, flt
+
+
+class TestCouponInvoiceIntegration(unittest.TestCase):
+ """Test coupon_code field integration with Sales Invoice"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ # Get test company
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+
+ # Get test customer
+ customers = frappe.get_all("Customer", limit=1)
+ cls.test_customer = customers[0].name if customers else None
+
+ # Get test item
+ items = frappe.get_all("Item", filters={"is_sales_item": 1}, limit=1)
+ cls.test_item = items[0].name if items else None
+
+ # Get default income account
+ cls.income_account = frappe.db.get_value(
+ "Company", cls.test_company, "default_income_account"
+ )
+
+ # Track created docs for cleanup
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+ cls.created_invoices = []
+
+ # Create a test Pricing Rule and Coupon Code
+ cls._create_test_coupon()
+
+ @classmethod
+ def _create_test_coupon(cls):
+ """Create a test coupon for testing"""
+ # Create Pricing Rule
+ pricing_rule = frappe.get_doc({
+ "doctype": "Pricing Rule",
+ "title": "Test Coupon PR",
+ "apply_on": "Transaction",
+ "price_or_product_discount": "Price",
+ "rate_or_discount": "Discount Amount",
+ "discount_amount": 10,
+ "selling": 1,
+ "company": cls.test_company,
+ "currency": frappe.get_cached_value("Company", cls.test_company, "default_currency"),
+ "valid_from": nowdate(),
+ "valid_upto": add_months(nowdate(), 12),
+ "coupon_code_based": 1,
+ })
+ pricing_rule.insert(ignore_permissions=True)
+ cls.created_pricing_rules.append(pricing_rule.name)
+
+ # Create Coupon Code
+ cls.test_coupon_code = "TESTCOUPON2024"
+ coupon = frappe.get_doc({
+ "doctype": "Coupon Code",
+ "coupon_name": "Test Coupon",
+ "coupon_type": "Promotional",
+ "coupon_code": cls.test_coupon_code,
+ "pricing_rule": pricing_rule.name,
+ "valid_from": nowdate(),
+ "valid_upto": add_months(nowdate(), 12),
+ "maximum_use": 100,
+ "used": 0,
+ })
+ coupon.insert(ignore_permissions=True)
+ cls.created_coupons.append(coupon.name)
+ cls.test_coupon_name = coupon.name
+
+ frappe.db.commit()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ # Cancel and delete invoices first
+ for invoice_name in cls.created_invoices:
+ try:
+ invoice = frappe.get_doc("Sales Invoice", invoice_name)
+ if invoice.docstatus == 1:
+ invoice.cancel()
+ frappe.delete_doc("Sales Invoice", invoice_name, force=True)
+ except Exception:
+ pass
+
+ # Delete coupons
+ for coupon_name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", coupon_name, force=True)
+ except Exception:
+ pass
+
+ # Delete pricing rules
+ for pr_name in cls.created_pricing_rules:
+ try:
+ frappe.delete_doc("Pricing Rule", pr_name, force=True)
+ except Exception:
+ pass
+
+ frappe.db.commit()
+
+ def _create_invoice_with_coupon(self, coupon_code, submit=False):
+ """Helper to create a Sales Invoice with coupon_code"""
+ if not self.test_customer or not self.test_item:
+ self.skipTest("Missing test customer or item")
+
+ invoice = frappe.get_doc({
+ "doctype": "Sales Invoice",
+ "customer": self.test_customer,
+ "company": self.test_company,
+ "posting_date": nowdate(),
+ "due_date": nowdate(),
+ "coupon_code": coupon_code,
+ "items": [{
+ "item_code": self.test_item,
+ "qty": 1,
+ "rate": 100,
+ "income_account": self.income_account,
+ }],
+ })
+
+ invoice.insert(ignore_permissions=True)
+ self.created_invoices.append(invoice.name)
+
+ if submit:
+ invoice.submit()
+
+ return invoice
+
+ def test_coupon_code_field_exists(self):
+ """Test that coupon_code custom field exists on Sales Invoice"""
+ # Check if the field exists in the doctype
+ has_field = frappe.db.exists("Custom Field", {
+ "dt": "Sales Invoice",
+ "fieldname": "coupon_code"
+ })
+
+ self.assertTrue(has_field, "coupon_code custom field should exist on Sales Invoice")
+
+ def test_coupon_code_is_link_field(self):
+ """Test that coupon_code is a Link field to Coupon Code"""
+ field = frappe.get_doc("Custom Field", "Sales Invoice-coupon_code")
+
+ self.assertEqual(field.fieldtype, "Link")
+ self.assertEqual(field.options, "Coupon Code")
+
+ def test_validate_coupon_on_invoice(self):
+ """Test that valid coupon passes validation"""
+ # This should not raise an exception
+ invoice = self._create_invoice_with_coupon(self.test_coupon_name)
+
+ self.assertEqual(invoice.coupon_code, self.test_coupon_name)
+
+ def test_coupon_usage_increment_on_submit(self):
+ """Test that coupon usage counter increments on invoice submit"""
+ # Get initial usage count
+ initial_used = frappe.db.get_value("Coupon Code", self.test_coupon_name, "used")
+
+ # Create and submit invoice with coupon
+ invoice = self._create_invoice_with_coupon(self.test_coupon_name, submit=True)
+
+ # Check usage count increased
+ new_used = frappe.db.get_value("Coupon Code", self.test_coupon_name, "used")
+ self.assertEqual(new_used, initial_used + 1, "Coupon usage should increment on submit")
+
+ def test_coupon_usage_decrement_on_cancel(self):
+ """Test that coupon usage counter decrements on invoice cancel"""
+ # Create and submit invoice with coupon
+ invoice = self._create_invoice_with_coupon(self.test_coupon_name, submit=True)
+
+ # Get usage count after submit
+ used_after_submit = frappe.db.get_value("Coupon Code", self.test_coupon_name, "used")
+
+ # Cancel the invoice
+ invoice.cancel()
+
+ # Check usage count decreased
+ used_after_cancel = frappe.db.get_value("Coupon Code", self.test_coupon_name, "used")
+ self.assertEqual(
+ used_after_cancel, used_after_submit - 1,
+ "Coupon usage should decrement on cancel"
+ )
+
+ def test_no_increment_without_coupon(self):
+ """Test that usage doesn't change for invoices without coupon"""
+ # Get initial usage count
+ initial_used = frappe.db.get_value("Coupon Code", self.test_coupon_name, "used")
+
+ # Create invoice WITHOUT coupon
+ if not self.test_customer or not self.test_item:
+ self.skipTest("Missing test customer or item")
+
+ invoice = frappe.get_doc({
+ "doctype": "Sales Invoice",
+ "customer": self.test_customer,
+ "company": self.test_company,
+ "posting_date": nowdate(),
+ "due_date": nowdate(),
+ "items": [{
+ "item_code": self.test_item,
+ "qty": 1,
+ "rate": 100,
+ "income_account": self.income_account,
+ }],
+ })
+ invoice.insert(ignore_permissions=True)
+ self.created_invoices.append(invoice.name)
+ invoice.submit()
+
+ # Check usage count unchanged
+ new_used = frappe.db.get_value("Coupon Code", self.test_coupon_name, "used")
+ self.assertEqual(new_used, initial_used, "Coupon usage should not change for invoices without coupon")
+
+ # Cleanup
+ invoice.cancel()
+
+
+class TestLegacyPosaCouponCodeCompatibility(unittest.TestCase):
+ """Test backwards compatibility with posa_coupon_code field"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ customers = frappe.get_all("Customer", limit=1)
+ cls.test_customer = customers[0].name if customers else None
+
+ def test_posa_coupon_code_field_exists(self):
+ """Test that legacy posa_coupon_code field still exists"""
+ has_field = frappe.db.exists("Custom Field", {
+ "dt": "Sales Invoice",
+ "fieldname": "posa_coupon_code"
+ })
+
+ self.assertTrue(has_field, "posa_coupon_code field should exist for backwards compatibility")
+
+ def test_gift_cards_can_read_both_fields(self):
+ """Test that gift_cards.py logic handles both fields"""
+ # This tests the getattr pattern used in gift_cards.py
+ from pos_next.api.gift_cards import process_gift_card_on_submit
+
+ # Create a mock invoice object
+ class MockInvoice:
+ def __init__(self):
+ self.posa_coupon_code = None
+ self.coupon_code = "TEST123"
+ self.is_return = False
+ self.doctype = "Sales Invoice"
+
+ invoice = MockInvoice()
+
+ # Test the pattern used in gift_cards.py
+ coupon_code = getattr(invoice, 'posa_coupon_code', None) or getattr(invoice, 'coupon_code', None)
+ self.assertEqual(coupon_code, "TEST123")
+
+ # Test with posa_coupon_code set
+ invoice.posa_coupon_code = "LEGACY456"
+ coupon_code = getattr(invoice, 'posa_coupon_code', None) or getattr(invoice, 'coupon_code', None)
+ self.assertEqual(coupon_code, "LEGACY456")
+
+
+def run_coupon_invoice_integration_tests():
+ """Run all coupon invoice integration tests and return results"""
+ loader = unittest.TestLoader()
+ suite = unittest.TestSuite()
+
+ suite.addTests(loader.loadTestsFromTestCase(TestCouponInvoiceIntegration))
+ suite.addTests(loader.loadTestsFromTestCase(TestLegacyPosaCouponCodeCompatibility))
+
+ runner = unittest.TextTestRunner(verbosity=2)
+ return runner.run(suite)
+
+
+if __name__ == "__main__":
+ run_coupon_invoice_integration_tests()
From efe964ae9b908c158c2c5e3c74177971686706f2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Wed, 18 Feb 2026 23:12:06 +0100
Subject: [PATCH 39/43] fix(fixtures): skip coupon_code custom field on ERPNext
v16+
ERPNext v16 has a native coupon_code field on Sales Invoice,
causing a ValidationError during migration when the fixture
tries to create it as a Custom Field. Dynamically detect the
ERPNext version by reading the Sales Invoice DocType JSON and
only include the custom field for v15 and below.
---
pos_next/hooks.py | 61 +++++++++++++++++++++++++++++++++--------------
1 file changed, 43 insertions(+), 18 deletions(-)
diff --git a/pos_next/hooks.py b/pos_next/hooks.py
index e9043ccf..458384cd 100644
--- a/pos_next/hooks.py
+++ b/pos_next/hooks.py
@@ -1,5 +1,25 @@
from pos_next.utils import get_build_version
+
+def _has_native_coupon_code_field():
+ """Check if ERPNext has a native coupon_code field on Sales Invoice (v16+)."""
+ try:
+ import json
+ import os
+ si_json_path = os.path.join(
+ os.path.dirname(__file__), "..", "erpnext",
+ "accounts", "doctype", "sales_invoice", "sales_invoice.json"
+ )
+ si_json_path = os.path.normpath(si_json_path)
+ if os.path.exists(si_json_path):
+ with open(si_json_path) as f:
+ meta = json.load(f)
+ return any(f.get("fieldname") == "coupon_code" for f in meta.get("fields", []))
+ except Exception:
+ pass
+ return False
+
+
app_name = "pos_next"
app_title = "POS Next"
app_publisher = "BrainWise"
@@ -86,6 +106,28 @@
# Fixtures
# --------
+# Build custom field list dynamically: skip coupon_code on Sales Invoice
+# if ERPNext already has it natively (v16+)
+_custom_field_names = [
+ "Sales Invoice-posa_pos_opening_shift",
+ "Sales Invoice-posa_is_printed",
+ "Sales Invoice-posa_coupon_code",
+ "Item-custom_company",
+ "POS Profile-posa_cash_mode_of_payment",
+ "POS Profile-posa_allow_delete",
+ "POS Profile-posa_block_sale_beyond_available_qty",
+ "Mode of Payment-is_wallet_payment",
+ "Coupon Code-pos_next_section",
+ "Coupon Code-pos_next_gift_card",
+ "Coupon Code-gift_card_amount",
+ "Coupon Code-original_gift_card_amount",
+ "Coupon Code-coupon_code_residual",
+ "Coupon Code-source_invoice",
+ "Coupon Code-referral_code",
+]
+if not _has_native_coupon_code_field():
+ _custom_field_names.insert(3, "Sales Invoice-coupon_code")
+
fixtures = [
{
"dt": "Custom Field",
@@ -93,24 +135,7 @@
[
"name",
"in",
- [
- "Sales Invoice-posa_pos_opening_shift",
- "Sales Invoice-posa_is_printed",
- "Sales Invoice-posa_coupon_code",
- "Sales Invoice-coupon_code",
- "Item-custom_company",
- "POS Profile-posa_cash_mode_of_payment",
- "POS Profile-posa_allow_delete",
- "POS Profile-posa_block_sale_beyond_available_qty",
- "Mode of Payment-is_wallet_payment",
- "Coupon Code-pos_next_section",
- "Coupon Code-pos_next_gift_card",
- "Coupon Code-gift_card_amount",
- "Coupon Code-original_gift_card_amount",
- "Coupon Code-coupon_code_residual",
- "Coupon Code-source_invoice",
- "Coupon Code-referral_code"
- ]
+ _custom_field_names,
]
]
},
From 5846bd04f373bfadffb5b7bf17c9d4ca194d6638 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Wed, 18 Feb 2026 23:13:07 +0100
Subject: [PATCH 40/43] fix(fixtures): use importlib to locate ERPNext package
path
The previous relative path calculation was wrong because
__file__ resolves to pos_next/pos_next/hooks.py, not
pos_next/hooks.py. Use importlib.import_module to reliably
find the erpnext package directory regardless of install path.
---
pos_next/hooks.py | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/pos_next/hooks.py b/pos_next/hooks.py
index 458384cd..3fea097a 100644
--- a/pos_next/hooks.py
+++ b/pos_next/hooks.py
@@ -6,11 +6,12 @@ def _has_native_coupon_code_field():
try:
import json
import os
+ import importlib
+ erpnext_mod = importlib.import_module("erpnext")
+ erpnext_dir = os.path.dirname(erpnext_mod.__file__)
si_json_path = os.path.join(
- os.path.dirname(__file__), "..", "erpnext",
- "accounts", "doctype", "sales_invoice", "sales_invoice.json"
+ erpnext_dir, "accounts", "doctype", "sales_invoice", "sales_invoice.json"
)
- si_json_path = os.path.normpath(si_json_path)
if os.path.exists(si_json_path):
with open(si_json_path) as f:
meta = json.load(f)
From 3571c411e23f7c99b0e8775193fa612033bf935d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Wed, 18 Feb 2026 23:15:19 +0100
Subject: [PATCH 41/43] fix(fixtures): remove coupon_code from JSON, create
programmatically for v15
The fixtures JSON is imported directly by Frappe during migrate
(import_doc reads the full file, ignoring hooks.py filters).
This caused a ValidationError on ERPNext v16+ where coupon_code
is a native field on Sales Invoice.
- Remove Sales Invoice-coupon_code entry from custom_field.json
- Add ensure_coupon_code_field() in install.py that creates the
Custom Field only on ERPNext v15 (where it does not exist natively)
- Called from both after_install and after_migrate hooks
---
pos_next/fixtures/custom_field.json | 57 -----------------------------
pos_next/install.py | 39 ++++++++++++++++++++
2 files changed, 39 insertions(+), 57 deletions(-)
diff --git a/pos_next/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json
index 08aa12d6..34241cf8 100644
--- a/pos_next/fixtures/custom_field.json
+++ b/pos_next/fixtures/custom_field.json
@@ -227,63 +227,6 @@
"unique": 0,
"width": null
},
- {
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "collapsible_depends_on": null,
- "columns": 0,
- "default": null,
- "depends_on": null,
- "description": "Coupon Code used for this invoice (native ERPNext integration)",
- "docstatus": 0,
- "doctype": "Custom Field",
- "dt": "Sales Invoice",
- "fetch_from": null,
- "fetch_if_empty": 0,
- "fieldname": "coupon_code",
- "fieldtype": "Link",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "additional_discount_percentage",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Coupon Code",
- "length": 0,
- "link_filters": null,
- "mandatory_depends_on": null,
- "modified": "2026-02-05 10:00:00",
- "module": "POS Next",
- "name": "Sales Invoice-coupon_code",
- "no_copy": 1,
- "non_negative": 0,
- "options": "Coupon Code",
- "permlevel": 0,
- "placeholder": null,
- "precision": "",
- "print_hide": 1,
- "print_hide_if_no_value": 0,
- "print_width": null,
- "read_only": 0,
- "read_only_depends_on": null,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "show_dashboard": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0,
- "width": null
- },
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
diff --git a/pos_next/install.py b/pos_next/install.py
index 9ac51fc2..3df72f00 100644
--- a/pos_next/install.py
+++ b/pos_next/install.py
@@ -12,6 +12,7 @@
"""
import frappe
import logging
+from pos_next.hooks import _has_native_coupon_code_field
# Configure logger
logger = logging.getLogger(__name__)
@@ -22,6 +23,9 @@ def after_install():
try:
log_message("POS Next: Running post-install setup", level="info")
+ # Ensure coupon_code custom field exists on v15 (native on v16+)
+ ensure_coupon_code_field()
+
# Setup default print format for POS Profiles
setup_default_print_format()
@@ -43,6 +47,9 @@ def after_install():
def after_migrate():
"""Hook that runs after bench migrate"""
try:
+ # Ensure coupon_code custom field exists on v15 (native on v16+)
+ ensure_coupon_code_field(quiet=True)
+
# Setup default print format
setup_default_print_format(quiet=True)
@@ -61,6 +68,38 @@ def after_migrate():
raise
+def ensure_coupon_code_field(quiet=False):
+ """Create coupon_code Custom Field on Sales Invoice for ERPNext v15 (not needed on v16+)."""
+ if _has_native_coupon_code_field():
+ if not quiet:
+ log_message("ERPNext has native coupon_code on Sales Invoice, skipping custom field", level="info")
+ return
+
+ if frappe.db.exists("Custom Field", "Sales Invoice-coupon_code"):
+ if not quiet:
+ log_message("Custom Field Sales Invoice-coupon_code already exists", level="info")
+ return
+
+ if not quiet:
+ log_message("Creating coupon_code Custom Field on Sales Invoice (ERPNext v15)", level="info")
+
+ from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+ create_custom_fields({
+ "Sales Invoice": [
+ {
+ "fieldname": "coupon_code",
+ "fieldtype": "Link",
+ "label": "Coupon Code",
+ "options": "Coupon Code",
+ "insert_after": "additional_discount_percentage",
+ "no_copy": 1,
+ "print_hide": 1,
+ "description": "Coupon Code used for this invoice",
+ }
+ ]
+ })
+
+
def setup_default_print_format(quiet=False):
"""
Set POS Next Receipt as default print format for POS Profiles if not already set.
From e75351294b5fd3ea6a73d9841be0b45bb7564991 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Thu, 19 Feb 2026 11:45:36 +0100
Subject: [PATCH 42/43] fix(invoices): re-enforce ignore_pricing_rule after
set_missing_values()
ERPNext's set_pos_fields() (called inside set_missing_values when
for_validate=False) overwrites ignore_pricing_rule with the POS Profile
value (default: 0). This caused apply_pricing_rule_on_transaction() to
run during save/validate and overwrite the capped discount amount sent by
the POS frontend with the full Pricing Rule amount (e.g. 500 CHF gift
card on a 300 CHF invoice), triggering a validation error.
Fix: re-set ignore_pricing_rule=1 after set_missing_values() in both
update_invoice() and submit_invoice() to ensure the POS frontend's
already-computed discounts are preserved.
---
pos_next/api/invoices.py | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py
index c3f0541a..f72092e5 100644
--- a/pos_next/api/invoices.py
+++ b/pos_next/api/invoices.py
@@ -816,6 +816,15 @@ def update_invoice(data):
# Populate missing fields (company, currency, accounts, etc.)
invoice_doc.set_missing_values()
+ # Re-enforce ignore_pricing_rule after set_missing_values().
+ # ERPNext's set_pos_fields() (called inside set_missing_values when
+ # for_validate=False) overwrites ignore_pricing_rule with the POS
+ # Profile value (default: 0). We always want to skip pricing-rule
+ # recalculation because the POS frontend has already computed the
+ # correct discounts.
+ invoice_doc.ignore_pricing_rule = 1
+ invoice_doc.flags.ignore_pricing_rule = True
+
# Calculate totals and apply discounts (with rounding disabled)
invoice_doc.calculate_taxes_and_totals()
@@ -1305,6 +1314,11 @@ def submit_invoice(invoice=None, data=None):
_validate_stock_on_invoice(invoice_doc)
# Save before submit
+ # Re-enforce ignore_pricing_rule so that save() -> validate() does not
+ # call apply_pricing_rule_on_transaction() and overwrite the discount
+ # amount that the POS frontend has already computed and capped.
+ invoice_doc.ignore_pricing_rule = 1
+ invoice_doc.flags.ignore_pricing_rule = True
invoice_doc.flags.ignore_permissions = True
frappe.flags.ignore_account_permission = True
invoice_doc.save()
From da050055f337bb4cc3cf0c5ea4b1dae048d405b8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Christillin?=
Date: Thu, 19 Feb 2026 12:06:14 +0100
Subject: [PATCH 43/43] fix(gift_cards): look up coupon by doc name OR
coupon_code field
Sales Invoice stores the Coupon Code *document name* (e.g. 'Gift Card
GC-MV2S-Y1G9') in coupon_code/posa_coupon_code, but the on_submit/
on_cancel hooks were querying by the coupon_code *field* value (e.g.
'GC-MV2S-Y1G9'), causing a mismatch that silently skipped balance
updates after every gift card payment.
Add _get_gift_card_coupon() helper that tries by document name first,
then falls back to the coupon_code field. Apply it in
process_gift_card_on_submit, _process_gift_card_return, and
process_gift_card_on_cancel.
---
pos_next/api/gift_cards.py | 76 +++++++++++++++++++++++++++-----------
1 file changed, 55 insertions(+), 21 deletions(-)
diff --git a/pos_next/api/gift_cards.py b/pos_next/api/gift_cards.py
index 97a2d968..6d991b50 100644
--- a/pos_next/api/gift_cards.py
+++ b/pos_next/api/gift_cards.py
@@ -531,6 +531,44 @@ def get_gift_cards_with_balance(customer=None, company=None):
return result
+# ==========================================
+# Gift Card Lookup Helper
+# ==========================================
+
+def _get_gift_card_coupon(coupon_ref, fields):
+ """
+ Look up a Coupon Code document by either its document name or its coupon_code field value.
+
+ The `coupon_code` column on Sales Invoice stores the Coupon Code *document name*
+ (e.g. "Gift Card GC-MV2S-Y1G9"), while the `coupon_code` *field* on the Coupon
+ Code doctype holds the short code (e.g. "GC-MV2S-Y1G9"). Both cases must be
+ handled transparently.
+
+ Args:
+ coupon_ref: Document name OR coupon_code field value (case-insensitive).
+ fields: List of fields to return.
+
+ Returns:
+ dict or None
+ """
+ if not coupon_ref:
+ return None
+
+ # 1. Try by document name first (handles "Gift Card GC-…" style names).
+ coupon = frappe.db.get_value("Coupon Code", coupon_ref, fields, as_dict=True)
+ if coupon:
+ return coupon
+
+ # 2. Fall back to filtering by the coupon_code field value.
+ coupon = frappe.db.get_value(
+ "Coupon Code",
+ {"coupon_code": coupon_ref},
+ fields,
+ as_dict=True
+ )
+ return coupon
+
+
# ==========================================
# Gift Card Processing on Invoice Submit
# ==========================================
@@ -569,14 +607,14 @@ def process_gift_card_on_submit(doc, method=None):
if not coupon_code:
return
- coupon_code = coupon_code.strip().upper()
+ coupon_code = coupon_code.strip()
- # Get the Coupon Code from ERPNext
- coupon = frappe.db.get_value(
- "Coupon Code",
- {"coupon_code": coupon_code},
- ["name", "coupon_type", "pos_next_gift_card", "gift_card_amount", "pricing_rule"],
- as_dict=True
+ # Get the Coupon Code from ERPNext.
+ # invoice.coupon_code stores the document *name* (e.g. "Gift Card GC-MV2S-Y1G9"),
+ # so we must look up by name first, then fall back to the coupon_code field value.
+ coupon = _get_gift_card_coupon(
+ coupon_code,
+ ["name", "coupon_type", "pos_next_gift_card", "gift_card_amount", "pricing_rule"]
)
if not coupon:
@@ -685,15 +723,13 @@ def _process_gift_card_return(return_invoice):
if not coupon_code:
return
- coupon_code = coupon_code.strip().upper()
+ coupon_code = coupon_code.strip()
- # Get the Coupon Code
- coupon = frappe.db.get_value(
- "Coupon Code",
- {"coupon_code": coupon_code},
+ # Get the Coupon Code (invoice stores the doc *name*, not just the code value).
+ coupon = _get_gift_card_coupon(
+ coupon_code,
["name", "coupon_type", "pos_next_gift_card", "gift_card_amount",
- "original_gift_card_amount", "pricing_rule"],
- as_dict=True
+ "original_gift_card_amount", "pricing_rule"]
)
if not coupon:
@@ -814,15 +850,13 @@ def process_gift_card_on_cancel(doc, method=None):
if not coupon_code:
return
- coupon_code = coupon_code.strip().upper()
+ coupon_code = coupon_code.strip()
- # Get the Coupon Code
- coupon = frappe.db.get_value(
- "Coupon Code",
- {"coupon_code": coupon_code},
+ # Get the Coupon Code (invoice stores the doc *name*, not just the code value).
+ coupon = _get_gift_card_coupon(
+ coupon_code,
["name", "coupon_type", "pos_next_gift_card", "gift_card_amount",
- "original_gift_card_amount", "pricing_rule"],
- as_dict=True
+ "original_gift_card_amount", "pricing_rule"]
)
if not coupon: