- ${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 ? `
diff --git a/docs/GIFT_CARD_REFACTORING_PLAN.md b/docs/GIFT_CARD_REFACTORING_PLAN.md
new file mode 100644
index 00000000..43295efb
--- /dev/null
+++ b/docs/GIFT_CARD_REFACTORING_PLAN.md
@@ -0,0 +1,410 @@
+# Gift Card Refactoring Plan - ERPNext Native Integration
+
+## 📊 Progression
+
+| Phase | Description | Status |
+|-------|-------------|--------|
+| 1 | Custom Fields ERPNext Coupon Code | âś… Done |
+| 2 | Refactoring gift_cards.py | âś… Done |
+| 3 | Refactoring offers.py | âś… Done |
+| 4 | Patch de Migration | âś… Done |
+| 5 | Bouton de Création Rapide | ✅ Done |
+| 6 | Adaptation Frontend | âś… Done |
+| 7 | Nettoyage | âś… Done |
+| 8 | Referral Code Migration | âś… Done |
+| 9 | Tests Backend | ✅ Done (53 tests passés) |
+| 10 | Tests Frontend (Chrome) | âś… Done |
+
+### Détails Phase 9 - Tests Backend (Complété 2026-01-14)
+
+**53 tests passés** couvrant:
+- Gift card code generation (format GC-XXXX-XXXX, unicité)
+- Création manuelle de gift cards
+- Pricing Rule création avec/sans validity
+- Application de gift cards (montant partiel, complet)
+- Mise à jour du solde après utilisation
+- Validation coupons (expirés, restriction client, dates)
+- Récupération des gift cards actifs
+- CRUD coupons promotionnels
+- Referral Code (création, application, génération coupons)
+
+---
+
+## 🎯 Objectif
+
+Supprimer la dépendance à `POS Coupon` et utiliser directement `ERPNext Coupon Code` pour:
+- Simplifier l'architecture (une seule source de vérité)
+- Éliminer la synchronisation complexe
+- Compatibilité native avec Webshop et autres modules ERPNext
+- Meilleure intégration comptable
+
+---
+
+## 📊 État Actuel
+
+### Ce qui existe dans POS Coupon (Ă migrer)
+
+| Champ | Description | Équivalent ERPNext |
+|-------|-------------|-------------------|
+| `coupon_code` | Code du coupon | `coupon_code` âś… |
+| `coupon_type` | Gift Card, Promotional | `coupon_type` âś… |
+| `customer` | Client assigné | `customer` (à ajouter) |
+| `discount_amount` | Montant de réduction | Via Pricing Rule |
+| `gift_card_amount` | Solde gift card | `gift_card_amount` (custom) âś… |
+| `original_amount` | Montant original | `original_gift_card_amount` (custom) âś… |
+| `valid_from/upto` | Validité | `valid_from/upto` ✅ |
+| `used` | Compteur utilisation | `used` âś… |
+| `maximum_use` | Limite utilisation | `maximum_use` âś… |
+| `company` | Société | Via Pricing Rule |
+| `source_invoice` | Facture source | `source_pos_invoice` (custom) âś… |
+
+### Fichiers impactés
+
+```
+Backend (pos_next/):
+├── api/gift_cards.py # À refactorer (principal)
+├── api/offers.py # À refactorer (get_active_coupons, validate_coupon)
+├── api/invoices.py # À adapter (coupon_code handling)
+├── api/promotions.py # À vérifier
+├── hooks.py # À nettoyer (retirer hooks POS Coupon)
+├── fixtures/custom_field.json # À compléter
+└── pos_next/doctype/
+ └── pos_coupon/ # À SUPPRIMER (après migration)
+
+Frontend (POS/src/):
+├── composables/useGiftCard.js # À adapter
+├── composables/usePermissions.js # À vérifier
+└── components/sale/CouponDialog.vue # À adapter
+```
+
+---
+
+## 🚀 Plan de Refactoring
+
+### Phase 1: Custom Fields ERPNext Coupon Code
+
+**Statut: âś… Partiellement fait**
+
+Champs déjà créés dans `fixtures/custom_field.json`:
+- `gift_card_amount` - Solde actuel
+- `original_gift_card_amount` - Montant original
+- `coupon_code_residual` - Référence au coupon original (split)
+- `pos_coupon` - Lien vers POS Coupon (à retirer après migration)
+- `source_pos_invoice` - Facture d'origine
+
+**Ă€ ajouter:**
+```json
+{
+ "dt": "Coupon Code",
+ "fieldname": "pos_next_gift_card",
+ "fieldtype": "Check",
+ "label": "POS Next Gift Card",
+ "description": "Gift card managed by POS Next"
+},
+{
+ "dt": "Coupon Code",
+ "fieldname": "customer",
+ "fieldtype": "Link",
+ "options": "Customer",
+ "label": "Customer",
+ "description": "Customer assigned to this coupon (optional for gift cards)"
+}
+```
+
+---
+
+### Phase 2: Refactoring gift_cards.py
+
+**Statut: 🔄 À faire**
+
+#### 2.1 Créer Gift Card directement dans ERPNext
+
+```python
+def create_gift_card_from_invoice(doc, method=None):
+ """
+ Crée un Coupon Code ERPNext + Pricing Rule quand on vend un item gift card.
+
+ Flow:
+ 1. Détecte l'item gift card dans la facture
+ 2. Crée le Pricing Rule avec le montant
+ 3. Crée le Coupon Code ERPNext directement
+ 4. Retourne les infos du gift card créé
+ """
+ # Plus de POS Coupon intermédiaire!
+```
+
+#### 2.2 Simplifier apply_gift_card
+
+```python
+def apply_gift_card(coupon_code, invoice_total, customer=None, company=None):
+ """
+ Applique un gift card (Coupon Code ERPNext) Ă une facture.
+
+ - Vérifie le solde dans gift_card_amount
+ - Calcule le discount
+ - Retourne les infos pour l'UI
+ """
+```
+
+#### 2.3 Simplifier process_gift_card_on_submit
+
+```python
+def process_gift_card_on_submit(doc, method=None):
+ """
+ Met à jour le solde du Coupon Code ERPNext après utilisation.
+
+ - Réduit gift_card_amount
+ - Met à jour le Pricing Rule associé
+ - Gère le splitting si nécessaire
+ """
+```
+
+---
+
+### Phase 3: Refactoring offers.py
+
+**Statut: 🔄 À faire**
+
+#### 3.1 get_active_coupons
+
+Actuellement utilise `POS Coupon`. À refactorer pour:
+- Récupérer les `Coupon Code` ERPNext avec `pos_next_gift_card = 1`
+- Inclure les coupons standard ERPNext aussi
+- Retourner le format attendu par le frontend
+
+#### 3.2 validate_coupon
+
+Actuellement utilise `POS Coupon`. À refactorer pour:
+- Valider contre `Coupon Code` ERPNext
+- Vérifier le solde `gift_card_amount` pour les gift cards
+- Vérifier le Pricing Rule associé
+
+---
+
+### Phase 4: Patch de Migration
+
+**Statut: ⏳ À créer**
+
+Fichier: `pos_next/patches/v1_x/migrate_pos_coupons_to_erpnext.py`
+
+```python
+def execute():
+ """
+ Migre tous les POS Coupon vers ERPNext Coupon Code.
+
+ Pour chaque POS Coupon:
+ 1. Crée le Pricing Rule si nécessaire
+ 2. Crée le Coupon Code ERPNext
+ 3. Copie tous les champs
+ 4. Met à jour les références dans les factures
+ 5. Garde POS Coupon en lecture seule (ne pas supprimer immédiatement)
+ """
+```
+
+---
+
+### Phase 5: Bouton de Création Rapide
+
+**Statut: ⏳ À créer**
+
+#### 5.1 Client Script pour Coupon Code List
+
+Fichier: `pos_next/public/js/coupon_code_list.js`
+
+```javascript
+frappe.listview_settings['Coupon Code'] = {
+ onload: function(listview) {
+ listview.page.add_inner_button(__('Create Gift Card'), function() {
+ // Dialog pour créer rapidement un gift card
+ let d = new frappe.ui.Dialog({
+ title: __('Create Gift Card'),
+ fields: [
+ { fieldname: 'amount', fieldtype: 'Currency', label: __('Amount'), reqd: 1 },
+ { fieldname: 'customer', fieldtype: 'Link', options: 'Customer', label: __('Customer (Optional)') },
+ { fieldname: 'company', fieldtype: 'Link', options: 'Company', label: __('Company'), reqd: 1 },
+ { fieldname: 'validity_months', fieldtype: 'Int', label: __('Validity (Months)'), default: 12 }
+ ],
+ primary_action: function() {
+ // Appel API pour créer le gift card
+ }
+ });
+ d.show();
+ });
+ }
+};
+```
+
+#### 5.2 API de création manuelle
+
+```python
+@frappe.whitelist()
+def create_gift_card_manual(amount, company, customer=None, validity_months=12):
+ """
+ Crée un gift card manuellement (depuis le bouton ERPNext).
+
+ Returns:
+ dict: Infos du gift card créé (code, montant, validité)
+ """
+```
+
+---
+
+### Phase 6: Adaptation Frontend
+
+**Statut: 🔄 À faire**
+
+#### 6.1 useGiftCard.js
+
+- Retirer les références à `POS Coupon`
+- Appeler directement les APIs qui utilisent `Coupon Code`
+- Garder la mĂŞme interface pour les composants
+
+#### 6.2 CouponDialog.vue
+
+- Aucun changement majeur nécessaire (utilise déjà l'API)
+- Vérifier l'affichage du solde gift card
+
+---
+
+### Phase 7: Nettoyage
+
+**Statut: ⏳ À faire (après migration réussie)**
+
+1. Retirer le doctype `POS Coupon` du module
+2. Retirer les fixtures liés à POS Coupon
+3. Nettoyer les hooks
+4. Retirer les logs de debug
+
+---
+
+## đź“‹ Checklist de Tests
+
+### Tests Backend (Phase 9) ✅ Complété
+
+- [x] **Génération Code Gift Card**
+ - [x] Code au format GC-XXXX-XXXX
+ - [x] Codes uniques
+ - [x] Caractères valides (uppercase + digits)
+
+- [x] **Création Manuelle Gift Card**
+ - [x] Création basique avec montant et company
+ - [x] Création avec customer assigné
+ - [x] Création avec validité 0 (illimité)
+ - [x] Pricing Rule créé correctement
+
+- [x] **Application Gift Card**
+ - [x] Appliquer gift card montant < total facture
+ - [x] Appliquer gift card montant > total facture (splitting logic)
+ - [x] Mise à jour solde après utilisation partielle
+ - [x] Mise à jour solde à zéro (exhausted)
+
+- [x] **Validation Coupon**
+ - [x] Coupon valide accepté
+ - [x] Coupon invalide rejeté
+ - [x] Coupon expiré rejeté
+ - [x] Coupon pas encore valide rejeté
+ - [x] Restriction customer respectée
+ - [x] Gift card solde zéro rejeté
+
+- [x] **Coupons Promotionnels**
+ - [x] Création avec pourcentage
+ - [x] Création avec montant fixe
+ - [x] Mise Ă jour discount
+ - [x] Mise à jour validité
+ - [x] Suppression coupon + pricing rule
+
+- [x] **Referral Code**
+ - [x] Création avec pourcentage/montant
+ - [x] Génération coupon referrer
+ - [x] Génération coupon referee
+ - [x] Application referral code
+ - [x] Validation des champs requis
+
+### Tests Frontend (Phase 10) ✅ Complété (2026-01-14)
+
+- [x] **Bouton Création Manuelle ERPNext** ✅
+ - [x] Bouton visible dans liste Coupon Code
+ - [x] Dialog de création fonctionne (Amount, Company, Customer, Validity)
+ - [x] Gift card créé correctement avec Pricing Rule
+ - [x] Code affiché dans dialog de confirmation
+
+- [x] **Application Gift Card dans POS** âś…
+ - [x] Dialog de coupon fonctionne
+ - [x] Gift cards disponibles affichés avec solde
+ - [x] Discount s'applique correctement au total
+ - [x] Grand_total final correct (CHF 0.00 quand couvert)
+ - [x] Checkout avec gift card fonctionne
+ - [x] Solde gift card réduit après utilisation
+ - [x] Compteur "used" incrémenté
+
+- [x] **Création Gift Card via Vente** ✅
+ - [x] Vendre item gift card → Coupon Code ERPNext créé
+ - [x] Dialog notification (GiftCardCreatedDialog.vue)
+ - [x] API get_gift_cards_from_invoice
+
+- [x] **Flow Complet de Splitting** âś…
+ - [x] Gift card 75 CHF sur facture 29.90 CHF → solde 45.10 CHF
+ - [x] Peut réutiliser le même code pour le solde restant
+
+- [x] **Annulation** âś…
+ - [x] Annuler facture FA-2026-00042 → solde restauré (45.10 → 75 CHF)
+
+### Tests Intégration (Optionnel)
+
+- [ ] **Webshop**
+ - [ ] Gift card utilisable sur Webshop
+ - [ ] Solde réduit après commande Webshop
+
+- [ ] **Migration**
+ - [x] Patch de migration existe
+ - [ ] Migration testée sur prod avec données réelles
+
+---
+
+## 📅 Ordre d'Exécution Recommandé
+
+1. **Phase 1** - Compléter les custom fields (30 min)
+2. **Phase 2** - Refactorer gift_cards.py (2-3h)
+3. **Phase 3** - Refactorer offers.py (1h)
+4. **Phase 6** - Adapter frontend (30 min)
+5. **Tests** - Tester le flow complet (1h)
+6. **Phase 4** - Créer patch migration (1h)
+7. **Phase 5** - Bouton création rapide (1h)
+8. **Phase 7** - Nettoyage final (30 min)
+
+**Temps estimé total: 7-9 heures**
+
+---
+
+## 🔗 Compatibilité
+
+### Webshop ERPNext
+Le Webshop utilise nativement `Coupon Code` + `Pricing Rule`, donc:
+- âś… Compatible automatiquement
+- âś… Gift cards utilisables sur le web
+- ✅ Solde partagé entre POS et Web
+
+### API Standard ERPNext
+- âś… `apply_pricing_rule` fonctionne
+- âś… Rapports Coupon Code incluent les gift cards
+- âś… Workflow standard de validation
+
+---
+
+## ⚠️ Points d'Attention
+
+1. **Ne pas supprimer POS Coupon immédiatement**
+ - Garder en lecture seule après migration
+ - Supprimer dans une version ultérieure
+
+2. **Pricing Rule par Company**
+ - Un Pricing Rule par société
+ - Vérifier la company dans les validations
+
+3. **Mode Offline**
+ - Cacher les Coupon Code localement
+ - Sync au retour online
+
+4. **Performance**
+ - Indexer `gift_card_amount` si beaucoup de coupons
+ - Cacher les settings
diff --git a/docs/GIFT_CARD_SYNC_PLAN.md b/docs/GIFT_CARD_SYNC_PLAN.md
new file mode 100644
index 00000000..64ea445c
--- /dev/null
+++ b/docs/GIFT_CARD_SYNC_PLAN.md
@@ -0,0 +1,751 @@
+# 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 | âś… Done | 2025-01-12 |
+| 5 | Gift Card splitting logic | âś… Done | 2025-01-12 |
+| 6 | Frontend Gift Card components | âś… Done | 2025-01-12 |
+| 7 | Hooks & Events | âś… Done | 2025-01-12 |
+| 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
+
+**Phase 4-7 (2025-01-12):**
+- Created `pos_next/api/gift_cards.py` with full gift card API:
+ - `generate_gift_card_code()` - Creates XXXX-XXXX-XXXX format codes
+ - `get_gift_card_settings()` - Gets settings from POS Settings
+ - `is_gift_card_item()` - Checks if item is the gift card item
+ - `create_gift_card_from_invoice()` - Creates gift cards when selling gift card items
+ - `_sync_to_erpnext_coupon()` - Syncs to ERPNext Coupon Code + Pricing Rule
+ - `_create_pricing_rule_for_gift_card()` - Creates Pricing Rule for discount
+ - `apply_gift_card()` - Applies gift card to invoice
+ - `get_gift_cards_with_balance()` - Gets available gift cards
+ - `process_gift_card_on_submit()` - Handles splitting on invoice submit
+ - `_split_gift_card()` - Updates balance on original card
+ - `process_gift_card_on_cancel()` - Restores balance on cancel
+- Updated `pos_next/hooks.py` with doc_events for POS Invoice:
+ - `on_submit`: create_gift_card_from_invoice, process_gift_card_on_submit
+ - `on_cancel`: process_gift_card_on_cancel
+- Updated `pos_next/api/offers.py`:
+ - Enhanced `get_active_coupons()` to support anonymous gift cards and balance field
+ - Enhanced `validate_coupon()` to check gift card balance and add `is_gift_card` flag
+- Created `POS/src/composables/useGiftCard.js`:
+ - `loadGiftCards()` - Fetch available gift cards
+ - `applyGiftCard()` - Apply gift card to invoice
+ - `calculateGiftCardDiscount()` - Calculate discount with splitting info
+ - `formatGiftCard()` - Format gift card for display
+ - Computed: `groupedGiftCards`, `totalAvailableBalance`, `hasGiftCards`
+- Updated `POS/src/components/sale/CouponDialog.vue`:
+ - Added balance display for gift cards in the list
+ - Added "Anonymous" label for cards without customer
+ - Enhanced applied coupon preview to show gift card balance info
+ - Added remaining balance display for splitting scenarios
+
+---
+
+## 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
+
+
${__("Gift Card Code")}
+
+ ${gift_card.coupon_code}
+
+
+ ${__("Value")}: ${gift_card.formatted_amount}
+
+
+ ${__("Valid until")}: ${frappe.datetime.str_to_user(gift_card.valid_upto)}
+
+
+ `,
+ },
+ ],
+ primary_action_label: __("Copy Code"),
+ primary_action: function () {
+ frappe.utils.copy_to_clipboard(gift_card.coupon_code);
+ frappe.show_alert({
+ message: __("Code copied to clipboard"),
+ indicator: "green",
+ });
+ },
+ secondary_action_label: __("Close"),
+ secondary_action: function () {
+ d.hide();
+ },
+ });
+ d.show();
+}
diff --git a/pos_next/tests/README.md b/pos_next/tests/README.md
new file mode 100644
index 00000000..dff5e3be
--- /dev/null
+++ b/pos_next/tests/README.md
@@ -0,0 +1,85 @@
+# POS Next Test Suite - ERPNext Coupon Code Integration
+
+This directory contains the test suite for the gift card and coupon refactoring that migrates from `POS Coupon` to native `ERPNext Coupon Code`.
+
+## Test Modules
+
+### 1. `test_gift_cards.py`
+Tests for `pos_next/api/gift_cards.py`:
+- **TestGiftCardCodeGeneration** - Gift card code format and uniqueness
+- **TestManualGiftCardCreation** - Manual gift card creation from ERPNext UI
+- **TestGiftCardApplication** - Applying gift cards to invoices
+- **TestGiftCardBalanceUpdate** - Balance updates after usage
+- **TestGetGiftCardsWithBalance** - Retrieving available gift cards
+- **TestPricingRuleCreation** - Pricing Rule creation for gift cards
+
+### 2. `test_coupon_validation.py`
+Tests for `pos_next/api/offers.py`:
+- **TestGetActiveCoupons** - Retrieve available gift cards
+- **TestValidateCoupon** - Validate coupon codes
+- **TestCouponValidityDates** - Expiry and validity date handling
+
+### 3. `test_promotions_coupon.py`
+Tests for `pos_next/api/promotions.py`:
+- **TestCreateCoupon** - Create promotional coupons
+- **TestUpdateCoupon** - Update existing coupons
+- **TestDeleteCoupon** - Delete coupons
+- **TestGetReferralDetails** - Get referral code details
+
+### 4. `test_referral_code.py`
+Tests for `pos_next/pos_next/doctype/referral_code/referral_code.py`:
+- **TestReferralCodeCreation** - Create referral codes
+- **TestReferralCouponGeneration** - Generate coupons for referrer/referee
+- **TestApplyReferralCode** - Apply referral codes
+- **TestReferralCodeValidation** - Validation rules
+
+## Running Tests
+
+### Run All Tests
+```bash
+bench --site [site] execute pos_next.tests.run_all_tests.run_all_tests
+```
+
+### Run Individual Test Modules
+```bash
+# Gift Card Tests
+bench --site [site] run-tests --app pos_next --module pos_next.tests.test_gift_cards
+
+# Coupon Validation Tests
+bench --site [site] run-tests --app pos_next --module pos_next.tests.test_coupon_validation
+
+# Promotions Tests
+bench --site [site] run-tests --app pos_next --module pos_next.tests.test_promotions_coupon
+
+# Referral Code Tests
+bench --site [site] run-tests --app pos_next --module pos_next.pos_next.doctype.referral_code.test_referral_code
+```
+
+### Run Specific Test Class
+```bash
+bench --site [site] run-tests --app pos_next --module pos_next.tests.test_gift_cards --test pos_next.tests.test_gift_cards.TestGiftCardCodeGeneration
+```
+
+## Test Requirements
+
+- At least one Company must exist
+- At least one Customer must exist (for customer-specific tests)
+- POS Profile with gift card settings (for invoice-based tests)
+
+## Test Coverage
+
+| Feature | Test File | Coverage |
+|---------|-----------|----------|
+| Gift Card Creation | test_gift_cards.py | âś“ Manual creation, From invoice |
+| Gift Card Application | test_gift_cards.py | âś“ Partial, Full, Exceeds balance |
+| Gift Card Splitting | test_gift_cards.py | âś“ Balance updates |
+| Coupon Validation | test_coupon_validation.py | âś“ Valid, Invalid, Expired |
+| Pricing Rule | test_gift_cards.py | âś“ Creation, Updates |
+| Referral Coupons | test_referral_code.py | âś“ Referrer, Referee generation |
+| Coupon CRUD | test_promotions_coupon.py | âś“ Create, Update, Delete |
+
+## Notes
+
+- Tests use `frappe.db.rollback()` for cleanup where possible
+- Test data is cleaned up in `tearDownClass` methods
+- Some tests may be skipped if prerequisites (customers, etc.) are not available
diff --git a/pos_next/tests/__init__.py b/pos_next/tests/__init__.py
new file mode 100644
index 00000000..78a32a3f
--- /dev/null
+++ b/pos_next/tests/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, BrainWise and contributors
+# For license information, please see license.txt
diff --git a/pos_next/tests/run_all_tests.py b/pos_next/tests/run_all_tests.py
new file mode 100644
index 00000000..e95c67eb
--- /dev/null
+++ b/pos_next/tests/run_all_tests.py
@@ -0,0 +1,156 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, BrainWise and contributors
+# For license information, please see license.txt
+
+"""
+Test Runner for POS Next - ERPNext Coupon Code Integration
+
+This script runs all tests related to the gift card and coupon refactoring.
+
+Usage:
+ From bench:
+ bench --site [site] execute pos_next.tests.run_all_tests.run_all_tests
+
+ Or run individual test modules:
+ bench --site [site] run-tests --app pos_next --module pos_next.tests.test_gift_cards
+ bench --site [site] run-tests --app pos_next --module pos_next.tests.test_coupon_validation
+ bench --site [site] run-tests --app pos_next --module pos_next.tests.test_promotions_coupon
+ bench --site [site] run-tests --app pos_next --module pos_next.pos_next.doctype.referral_code.test_referral_code
+"""
+
+import frappe
+import unittest
+import sys
+
+
+def run_all_tests():
+ """
+ Run all POS Next coupon-related tests.
+
+ Returns:
+ bool: True if all tests passed, False otherwise
+ """
+ print("\n" + "="*70)
+ print("POS NEXT - ERPNext Coupon Code Integration Tests")
+ print("="*70 + "\n")
+
+ # Import test modules
+ from pos_next.tests.test_gift_cards import (
+ TestGiftCardCodeGeneration,
+ TestManualGiftCardCreation,
+ TestGiftCardApplication,
+ TestGiftCardBalanceUpdate,
+ TestGetGiftCardsWithBalance,
+ TestPricingRuleCreation,
+ )
+ from pos_next.tests.test_coupon_validation import (
+ TestGetActiveCoupons,
+ TestValidateCoupon,
+ TestCouponValidityDates,
+ )
+ from pos_next.tests.test_promotions_coupon import (
+ TestCreateCoupon,
+ TestUpdateCoupon,
+ TestDeleteCoupon,
+ TestGetReferralDetails,
+ )
+ from pos_next.pos_next.doctype.referral_code.test_referral_code import (
+ TestReferralCodeCreation,
+ TestReferralCouponGeneration,
+ TestApplyReferralCode,
+ TestReferralCodeValidation,
+ )
+
+ # Build test suite
+ loader = unittest.TestLoader()
+ suite = unittest.TestSuite()
+
+ # Gift Card Tests
+ print("Loading Gift Card Tests...")
+ suite.addTests(loader.loadTestsFromTestCase(TestGiftCardCodeGeneration))
+ suite.addTests(loader.loadTestsFromTestCase(TestManualGiftCardCreation))
+ suite.addTests(loader.loadTestsFromTestCase(TestGiftCardApplication))
+ suite.addTests(loader.loadTestsFromTestCase(TestGiftCardBalanceUpdate))
+ suite.addTests(loader.loadTestsFromTestCase(TestGetGiftCardsWithBalance))
+ suite.addTests(loader.loadTestsFromTestCase(TestPricingRuleCreation))
+
+ # Coupon Validation Tests
+ print("Loading Coupon Validation Tests...")
+ suite.addTests(loader.loadTestsFromTestCase(TestGetActiveCoupons))
+ suite.addTests(loader.loadTestsFromTestCase(TestValidateCoupon))
+ suite.addTests(loader.loadTestsFromTestCase(TestCouponValidityDates))
+
+ # Promotions Coupon Tests
+ print("Loading Promotions Coupon Tests...")
+ suite.addTests(loader.loadTestsFromTestCase(TestCreateCoupon))
+ suite.addTests(loader.loadTestsFromTestCase(TestUpdateCoupon))
+ suite.addTests(loader.loadTestsFromTestCase(TestDeleteCoupon))
+ suite.addTests(loader.loadTestsFromTestCase(TestGetReferralDetails))
+
+ # Referral Code Tests
+ print("Loading Referral Code Tests...")
+ suite.addTests(loader.loadTestsFromTestCase(TestReferralCodeCreation))
+ suite.addTests(loader.loadTestsFromTestCase(TestReferralCouponGeneration))
+ suite.addTests(loader.loadTestsFromTestCase(TestApplyReferralCode))
+ suite.addTests(loader.loadTestsFromTestCase(TestReferralCodeValidation))
+
+ print(f"\nTotal tests loaded: {suite.countTestCases()}")
+ print("\n" + "-"*70)
+ print("Running Tests...")
+ print("-"*70 + "\n")
+
+ # Run tests
+ runner = unittest.TextTestRunner(verbosity=2)
+ result = runner.run(suite)
+
+ # Print summary
+ print("\n" + "="*70)
+ print("TEST SUMMARY")
+ print("="*70)
+ print(f"Tests Run: {result.testsRun}")
+ print(f"Failures: {len(result.failures)}")
+ print(f"Errors: {len(result.errors)}")
+ print(f"Skipped: {len(result.skipped)}")
+ print("="*70)
+
+ if result.failures:
+ print("\nFailed Tests:")
+ for test, traceback in result.failures:
+ print(f" - {test}")
+
+ if result.errors:
+ print("\nTests with Errors:")
+ for test, traceback in result.errors:
+ print(f" - {test}")
+
+ all_passed = len(result.failures) == 0 and len(result.errors) == 0
+ print(f"\nOverall Result: {'PASSED âś“' if all_passed else 'FAILED âś—'}")
+ print("="*70 + "\n")
+
+ return all_passed
+
+
+def run_quick_test():
+ """
+ Run a quick subset of tests for fast validation.
+ """
+ print("\n" + "="*70)
+ print("POS NEXT - Quick Test (Code Generation & Basic Validation)")
+ print("="*70 + "\n")
+
+ from pos_next.tests.test_gift_cards import TestGiftCardCodeGeneration
+ from pos_next.tests.test_coupon_validation import TestValidateCoupon
+
+ loader = unittest.TestLoader()
+ suite = unittest.TestSuite()
+
+ suite.addTests(loader.loadTestsFromTestCase(TestGiftCardCodeGeneration))
+
+ runner = unittest.TextTestRunner(verbosity=2)
+ result = runner.run(suite)
+
+ return len(result.failures) == 0 and len(result.errors) == 0
+
+
+if __name__ == "__main__":
+ run_all_tests()
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()
diff --git a/pos_next/tests/test_coupon_validation.py b/pos_next/tests/test_coupon_validation.py
new file mode 100644
index 00000000..47fdc0e4
--- /dev/null
+++ b/pos_next/tests/test_coupon_validation.py
@@ -0,0 +1,393 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, BrainWise and contributors
+# For license information, please see license.txt
+
+"""
+Test Suite for Coupon Validation (offers.py)
+
+This module tests the coupon validation functionality including:
+- get_active_coupons - Retrieve available gift cards
+- validate_coupon - Validate and retrieve coupon details
+
+Run with: bench --site [site] run-tests --app pos_next --module pos_next.tests.test_coupon_validation
+"""
+
+import frappe
+import unittest
+from frappe.utils import nowdate, add_months, add_days, flt
+from pos_next.api.offers import get_active_coupons, validate_coupon
+from pos_next.api.gift_cards import create_gift_card_manual, _update_gift_card_balance
+
+
+class TestGetActiveCoupons(unittest.TestCase):
+ """Test get_active_coupons function"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ # Get or create a test customer
+ customers = frappe.get_all("Customer", limit=1)
+ if customers:
+ cls.test_customer = customers[0].name
+ else:
+ cls.test_customer = None
+
+ # Create test gift cards
+ # 1. Gift card with balance, no customer (anonymous)
+ result1 = create_gift_card_manual(
+ amount=100,
+ company=cls.test_company,
+ validity_months=12
+ )
+ cls.created_coupons.append(result1.get("name"))
+ cls.gc_anonymous = result1.get("coupon_code")
+
+ # 2. Gift card with balance, assigned to customer
+ result2 = create_gift_card_manual(
+ amount=75,
+ company=cls.test_company,
+ customer=cls.test_customer,
+ validity_months=12
+ )
+ cls.created_coupons.append(result2.get("name"))
+ cls.gc_customer_assigned = result2.get("coupon_code")
+
+ # 3. Gift card with zero balance
+ result3 = create_gift_card_manual(
+ amount=50,
+ company=cls.test_company,
+ validity_months=12
+ )
+ cls.created_coupons.append(result3.get("name"))
+ cls.gc_zero_balance = result3.get("coupon_code")
+
+ # Set balance to zero
+ coupon3 = frappe.get_doc("Coupon Code", result3.get("name"))
+ _update_gift_card_balance(coupon3.name, 0, coupon3.pricing_rule)
+
+ # Track pricing rules for cleanup
+ for name in cls.created_coupons:
+ coupon = frappe.get_doc("Coupon Code", name)
+ if coupon.pricing_rule:
+ cls.created_pricing_rules.append(coupon.pricing_rule)
+
+ frappe.db.commit()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ for coupon_name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", coupon_name, force=True)
+ except Exception:
+ pass
+
+ 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 test_get_anonymous_gift_cards(self):
+ """Test that anonymous gift cards are returned"""
+ cards = get_active_coupons(customer=None, company=self.test_company)
+ codes = [c["coupon_code"] for c in cards]
+
+ self.assertIn(self.gc_anonymous, codes)
+
+ def test_get_customer_gift_cards(self):
+ """Test that customer-specific gift cards are returned"""
+ if not self.test_customer:
+ self.skipTest("No test customer available")
+
+ cards = get_active_coupons(customer=self.test_customer, company=self.test_company)
+ codes = [c["coupon_code"] for c in cards]
+
+ # Should include both anonymous and customer-assigned
+ self.assertIn(self.gc_anonymous, codes)
+ self.assertIn(self.gc_customer_assigned, codes)
+
+ def test_exclude_zero_balance(self):
+ """Test that gift cards with zero balance are excluded"""
+ cards = get_active_coupons(customer=None, company=self.test_company)
+ codes = [c["coupon_code"] for c in cards]
+
+ self.assertNotIn(self.gc_zero_balance, codes)
+
+ def test_returned_fields(self):
+ """Test that all expected fields are returned"""
+ cards = get_active_coupons(company=self.test_company)
+
+ if not cards:
+ self.skipTest("No gift cards returned")
+
+ card = cards[0]
+ expected_fields = [
+ "name", "coupon_code", "coupon_name", "customer",
+ "gift_card_amount", "balance", "valid_from", "valid_upto"
+ ]
+
+ for field in expected_fields:
+ self.assertIn(field, card, f"Missing field: {field}")
+
+
+class TestValidateCoupon(unittest.TestCase):
+ """Test validate_coupon function"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ # Get test customer
+ customers = frappe.get_all("Customer", limit=1)
+ cls.test_customer = customers[0].name if customers else None
+
+ # 1. Valid gift card with balance
+ result1 = create_gift_card_manual(
+ amount=100,
+ company=cls.test_company,
+ validity_months=12
+ )
+ cls.created_coupons.append(result1.get("name"))
+ cls.gc_valid = result1.get("coupon_code")
+
+ # 2. Gift card with zero balance
+ result2 = create_gift_card_manual(
+ amount=50,
+ company=cls.test_company,
+ validity_months=12
+ )
+ cls.created_coupons.append(result2.get("name"))
+ cls.gc_exhausted = result2.get("coupon_code")
+
+ coupon2 = frappe.get_doc("Coupon Code", result2.get("name"))
+ _update_gift_card_balance(coupon2.name, 0, coupon2.pricing_rule)
+
+ # 3. Gift card assigned to specific customer
+ if cls.test_customer:
+ result3 = create_gift_card_manual(
+ amount=75,
+ company=cls.test_company,
+ customer=cls.test_customer,
+ validity_months=12
+ )
+ cls.created_coupons.append(result3.get("name"))
+ cls.gc_customer_specific = result3.get("coupon_code")
+ cls.gc_customer_specific_name = result3.get("name")
+
+ # Track pricing rules
+ for name in cls.created_coupons:
+ coupon = frappe.get_doc("Coupon Code", name)
+ if coupon.pricing_rule:
+ cls.created_pricing_rules.append(coupon.pricing_rule)
+
+ frappe.db.commit()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ for coupon_name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", coupon_name, force=True)
+ except Exception:
+ pass
+
+ 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 test_validate_valid_coupon(self):
+ """Test validating a valid gift card"""
+ result = validate_coupon(
+ coupon_code=self.gc_valid,
+ company=self.test_company
+ )
+
+ self.assertTrue(result.get("valid"))
+ self.assertIn("coupon", result)
+ self.assertEqual(result["coupon"]["coupon_code"], self.gc_valid)
+
+ def test_validate_case_insensitive(self):
+ """Test that validation is case insensitive"""
+ result = validate_coupon(
+ coupon_code=self.gc_valid.lower(),
+ company=self.test_company
+ )
+
+ self.assertTrue(result.get("valid"))
+
+ def test_validate_invalid_code(self):
+ """Test validating an invalid coupon code"""
+ result = validate_coupon(
+ coupon_code="INVALID-CODE-9999",
+ company=self.test_company
+ )
+
+ self.assertFalse(result.get("valid"))
+ self.assertIn("message", result)
+
+ def test_validate_exhausted_gift_card(self):
+ """Test validating a gift card with zero balance"""
+ result = validate_coupon(
+ coupon_code=self.gc_exhausted,
+ company=self.test_company
+ )
+
+ self.assertFalse(result.get("valid"))
+ self.assertIn("balance", result.get("message", "").lower())
+
+ def test_validate_customer_restriction(self):
+ """Test that customer-specific coupons reject other customers"""
+ if not self.test_customer or not hasattr(self, 'gc_customer_specific'):
+ self.skipTest("No customer-specific gift card available")
+
+ # Should fail for different customer
+ result = validate_coupon(
+ coupon_code=self.gc_customer_specific,
+ customer="Some-Other-Customer",
+ company=self.test_company
+ )
+
+ self.assertFalse(result.get("valid"))
+
+ def test_validate_customer_restriction_correct_customer(self):
+ """Test that customer-specific coupons accept correct customer"""
+ if not self.test_customer or not hasattr(self, 'gc_customer_specific'):
+ self.skipTest("No customer-specific gift card available")
+
+ result = validate_coupon(
+ coupon_code=self.gc_customer_specific,
+ customer=self.test_customer,
+ company=self.test_company
+ )
+
+ self.assertTrue(result.get("valid"))
+
+ def test_validate_returns_balance(self):
+ """Test that validation returns balance for gift cards"""
+ result = validate_coupon(
+ coupon_code=self.gc_valid,
+ company=self.test_company
+ )
+
+ self.assertTrue(result.get("valid"))
+ coupon = result.get("coupon", {})
+ self.assertIn("gift_card_amount", coupon)
+ self.assertGreater(flt(coupon.get("gift_card_amount")), 0)
+
+
+class TestCouponValidityDates(unittest.TestCase):
+ """Test coupon validity date handling"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ def tearDown(self):
+ """Roll back after each test"""
+ frappe.db.rollback()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ for coupon_name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", coupon_name, force=True)
+ except Exception:
+ pass
+
+ 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 test_expired_coupon(self):
+ """Test that expired coupons are rejected"""
+ # Create a gift card that's already expired
+ result = create_gift_card_manual(
+ amount=100,
+ company=self.test_company,
+ validity_months=1
+ )
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ # Manually set expiry to past date
+ past_date = add_days(nowdate(), -30)
+ frappe.db.set_value("Coupon Code", coupon.name, "valid_upto", past_date)
+ frappe.db.commit()
+
+ # Validate should fail
+ validation_result = validate_coupon(
+ coupon_code=result.get("coupon_code"),
+ company=self.test_company
+ )
+
+ self.assertFalse(validation_result.get("valid"))
+ self.assertIn("expired", validation_result.get("message", "").lower())
+
+ def test_future_valid_from(self):
+ """Test that coupons with future valid_from are rejected"""
+ result = create_gift_card_manual(
+ amount=100,
+ company=self.test_company,
+ validity_months=12
+ )
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ # Set valid_from to future date
+ future_date = add_days(nowdate(), 30)
+ frappe.db.set_value("Coupon Code", coupon.name, "valid_from", future_date)
+ frappe.db.commit()
+
+ # Validate should fail
+ validation_result = validate_coupon(
+ coupon_code=result.get("coupon_code"),
+ company=self.test_company
+ )
+
+ self.assertFalse(validation_result.get("valid"))
+ self.assertIn("not yet valid", validation_result.get("message", "").lower())
+
+
+def run_coupon_validation_tests():
+ """Run all coupon validation tests and return results"""
+ loader = unittest.TestLoader()
+ suite = unittest.TestSuite()
+
+ suite.addTests(loader.loadTestsFromTestCase(TestGetActiveCoupons))
+ suite.addTests(loader.loadTestsFromTestCase(TestValidateCoupon))
+ suite.addTests(loader.loadTestsFromTestCase(TestCouponValidityDates))
+
+ runner = unittest.TextTestRunner(verbosity=2)
+ return runner.run(suite)
+
+
+if __name__ == "__main__":
+ run_coupon_validation_tests()
diff --git a/pos_next/tests/test_gift_cards.py b/pos_next/tests/test_gift_cards.py
new file mode 100644
index 00000000..6f8871c7
--- /dev/null
+++ b/pos_next/tests/test_gift_cards.py
@@ -0,0 +1,561 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, BrainWise and contributors
+# For license information, please see license.txt
+
+"""
+Test Suite for Gift Cards API
+
+This module tests the gift card functionality including:
+- Gift card code generation
+- Gift card creation (manual and from invoice)
+- Gift card application
+- Gift card balance updates (splitting)
+- Gift card cancellation/refund handling
+
+Run with: bench --site [site] run-tests --app pos_next --module pos_next.tests.test_gift_cards
+"""
+
+import frappe
+import unittest
+from frappe.utils import nowdate, add_months, flt, getdate
+from pos_next.api.gift_cards import (
+ generate_gift_card_code,
+ create_gift_card_manual,
+ apply_gift_card,
+ get_gift_cards_with_balance,
+ _create_gift_card,
+ _create_pricing_rule_for_gift_card,
+ _update_gift_card_balance,
+)
+
+
+class TestGiftCardCodeGeneration(unittest.TestCase):
+ """Test gift card code generation"""
+
+ def test_code_format(self):
+ """Test that generated code matches expected format GC-XXXX-XXXX"""
+ code = generate_gift_card_code()
+ self.assertIsNotNone(code)
+ self.assertTrue(code.startswith("GC-"))
+ parts = code.split("-")
+ self.assertEqual(len(parts), 3)
+ self.assertEqual(len(parts[1]), 4)
+ self.assertEqual(len(parts[2]), 4)
+
+ def test_code_uniqueness(self):
+ """Test that generated codes are unique"""
+ codes = set()
+ for _ in range(50):
+ code = generate_gift_card_code()
+ self.assertNotIn(code, codes, "Duplicate code generated")
+ codes.add(code)
+
+ def test_code_characters(self):
+ """Test that code contains only uppercase letters and digits"""
+ code = generate_gift_card_code()
+ # Remove the prefix and hyphens
+ clean_code = code.replace("GC-", "").replace("-", "")
+ for char in clean_code:
+ self.assertTrue(
+ char.isupper() or char.isdigit(),
+ f"Invalid character '{char}' in code"
+ )
+
+
+class TestManualGiftCardCreation(unittest.TestCase):
+ """Test manual gift card creation from ERPNext UI"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ def tearDown(self):
+ """Clean up after each test"""
+ frappe.db.rollback()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ # Delete test coupons
+ for coupon_name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", coupon_name, force=True)
+ except Exception:
+ pass
+
+ # Delete test 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 test_create_gift_card_basic(self):
+ """Test basic gift card creation"""
+ result = create_gift_card_manual(
+ amount=100,
+ company=self.test_company,
+ validity_months=12
+ )
+
+ self.assertTrue(result.get("success"))
+ self.assertIn("coupon_code", result)
+ self.assertEqual(result.get("amount"), 100)
+
+ # Track for cleanup
+ self.created_coupons.append(result.get("name"))
+
+ # Verify coupon was created in database
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ self.assertEqual(coupon.coupon_type, "Promotional")
+ self.assertEqual(coupon.pos_next_gift_card, 1)
+ self.assertEqual(flt(coupon.gift_card_amount), 100)
+ self.assertEqual(flt(coupon.original_gift_card_amount), 100)
+
+ # Track pricing rule for cleanup
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ def test_create_gift_card_with_customer(self):
+ """Test gift card creation with customer assignment"""
+ # Get a test customer
+ customer = frappe.get_all("Customer", limit=1)
+ if not customer:
+ self.skipTest("No customer found for testing")
+
+ result = create_gift_card_manual(
+ amount=50,
+ company=self.test_company,
+ customer=customer[0].name,
+ validity_months=6
+ )
+
+ self.assertTrue(result.get("success"))
+ self.created_coupons.append(result.get("name"))
+
+ # Verify validity period
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ expected_upto = add_months(nowdate(), 6)
+ self.assertEqual(str(coupon.valid_upto), str(expected_upto))
+
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ def test_create_gift_card_pricing_rule(self):
+ """Test that pricing rule is created correctly"""
+ result = create_gift_card_manual(
+ amount=75,
+ company=self.test_company,
+ validity_months=12
+ )
+
+ self.assertTrue(result.get("success"))
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ self.assertIsNotNone(coupon.pricing_rule)
+
+ # Verify pricing rule
+ pr = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
+ self.assertEqual(pr.rate_or_discount, "Discount Amount")
+ self.assertEqual(flt(pr.discount_amount), 75)
+ self.assertEqual(pr.company, self.test_company)
+ self.assertEqual(pr.coupon_code_based, 1)
+
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ def test_create_gift_card_zero_validity(self):
+ """Test gift card with unlimited validity (0 months)"""
+ result = create_gift_card_manual(
+ amount=200,
+ company=self.test_company,
+ validity_months=0
+ )
+
+ self.assertTrue(result.get("success"))
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ # With 0 validity months, valid_upto should be None or empty
+ self.assertFalse(coupon.valid_upto)
+
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+
+class TestGiftCardApplication(unittest.TestCase):
+ """Test gift card application to invoices"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ # Create a test gift card
+ result = create_gift_card_manual(
+ amount=100,
+ company=cls.test_company,
+ validity_months=12
+ )
+ cls.test_gift_card_code = result.get("coupon_code")
+ cls.test_gift_card_name = result.get("name")
+ cls.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ if coupon.pricing_rule:
+ cls.created_pricing_rules.append(coupon.pricing_rule)
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ for coupon_name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", coupon_name, force=True)
+ except Exception:
+ pass
+
+ 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 test_apply_gift_card_partial(self):
+ """Test applying gift card with amount less than balance"""
+ result = apply_gift_card(
+ coupon_code=self.test_gift_card_code,
+ invoice_total=60,
+ company=self.test_company
+ )
+
+ self.assertTrue(result.get("success"))
+ self.assertEqual(result.get("discount_amount"), 60)
+ self.assertEqual(result.get("available_balance"), 100)
+ self.assertTrue(result.get("will_split"))
+ self.assertEqual(result.get("remaining_balance"), 40)
+
+ def test_apply_gift_card_full(self):
+ """Test applying gift card with amount equal to balance"""
+ result = apply_gift_card(
+ coupon_code=self.test_gift_card_code,
+ invoice_total=100,
+ company=self.test_company
+ )
+
+ self.assertTrue(result.get("success"))
+ self.assertEqual(result.get("discount_amount"), 100)
+ self.assertFalse(result.get("will_split"))
+ self.assertEqual(result.get("remaining_balance"), 0)
+
+ def test_apply_gift_card_exceeds_balance(self):
+ """Test applying gift card with invoice greater than balance"""
+ result = apply_gift_card(
+ coupon_code=self.test_gift_card_code,
+ invoice_total=150,
+ company=self.test_company
+ )
+
+ self.assertTrue(result.get("success"))
+ self.assertEqual(result.get("discount_amount"), 100) # Only balance amount
+ self.assertFalse(result.get("will_split"))
+
+ def test_apply_nonexistent_gift_card(self):
+ """Test applying non-existent gift card"""
+ result = apply_gift_card(
+ coupon_code="INVALID-CODE-9999",
+ invoice_total=50,
+ company=self.test_company
+ )
+
+ self.assertFalse(result.get("success"))
+ self.assertIn("not found", result.get("message", "").lower())
+
+ def test_apply_gift_card_case_insensitive(self):
+ """Test that gift card codes are case insensitive"""
+ result = apply_gift_card(
+ coupon_code=self.test_gift_card_code.lower(),
+ invoice_total=50,
+ company=self.test_company
+ )
+
+ self.assertTrue(result.get("success"))
+
+
+class TestGiftCardBalanceUpdate(unittest.TestCase):
+ """Test gift card balance updates"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ def tearDown(self):
+ """Roll back after each test"""
+ frappe.db.rollback()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ for coupon_name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", coupon_name, force=True)
+ except Exception:
+ pass
+
+ 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 test_update_balance_partial_usage(self):
+ """Test updating balance after partial usage"""
+ # Create a test gift card
+ result = create_gift_card_manual(
+ amount=100,
+ company=self.test_company,
+ validity_months=12
+ )
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ # Update balance
+ _update_gift_card_balance(
+ coupon_name=coupon.name,
+ new_balance=40,
+ pricing_rule=coupon.pricing_rule
+ )
+
+ # Verify balance was updated
+ updated_coupon = frappe.get_doc("Coupon Code", coupon.name)
+ self.assertEqual(flt(updated_coupon.gift_card_amount), 40)
+
+ # Verify pricing rule was updated
+ if coupon.pricing_rule:
+ updated_pr = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
+ self.assertEqual(flt(updated_pr.discount_amount), 40)
+
+ def test_update_balance_exhausted(self):
+ """Test updating balance to zero (fully used)"""
+ result = create_gift_card_manual(
+ amount=50,
+ company=self.test_company,
+ validity_months=12
+ )
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ # Update balance to zero
+ _update_gift_card_balance(
+ coupon_name=coupon.name,
+ new_balance=0,
+ pricing_rule=coupon.pricing_rule
+ )
+
+ # Verify balance is zero
+ updated_coupon = frappe.get_doc("Coupon Code", coupon.name)
+ self.assertEqual(flt(updated_coupon.gift_card_amount), 0)
+ self.assertEqual(updated_coupon.used, 1)
+
+
+class TestGetGiftCardsWithBalance(unittest.TestCase):
+ """Test retrieving gift cards with balance"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ # Create gift cards with different states
+ # 1. Gift card with full balance
+ result1 = create_gift_card_manual(
+ amount=100,
+ company=cls.test_company,
+ validity_months=12
+ )
+ cls.created_coupons.append(result1.get("name"))
+ cls.gc_full_balance = result1.get("coupon_code")
+
+ # 2. Gift card with partial balance
+ result2 = create_gift_card_manual(
+ amount=75,
+ company=cls.test_company,
+ validity_months=12
+ )
+ cls.created_coupons.append(result2.get("name"))
+ cls.gc_partial_balance = result2.get("coupon_code")
+
+ # Reduce balance to partial
+ coupon2 = frappe.get_doc("Coupon Code", result2.get("name"))
+ _update_gift_card_balance(coupon2.name, 25, coupon2.pricing_rule)
+
+ # 3. Exhausted gift card (balance = 0)
+ result3 = create_gift_card_manual(
+ amount=50,
+ company=cls.test_company,
+ validity_months=12
+ )
+ cls.created_coupons.append(result3.get("name"))
+ cls.gc_exhausted = result3.get("coupon_code")
+
+ coupon3 = frappe.get_doc("Coupon Code", result3.get("name"))
+ _update_gift_card_balance(coupon3.name, 0, coupon3.pricing_rule)
+
+ # Track pricing rules
+ for name in cls.created_coupons:
+ coupon = frappe.get_doc("Coupon Code", name)
+ if coupon.pricing_rule:
+ cls.created_pricing_rules.append(coupon.pricing_rule)
+
+ frappe.db.commit()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ for coupon_name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", coupon_name, force=True)
+ except Exception:
+ pass
+
+ 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 test_get_cards_with_balance(self):
+ """Test that only cards with balance > 0 are returned"""
+ cards = get_gift_cards_with_balance(company=self.test_company)
+
+ # Get codes of returned cards
+ returned_codes = [c.coupon_code for c in cards]
+
+ # Full balance should be included
+ self.assertIn(self.gc_full_balance, returned_codes)
+
+ # Partial balance should be included
+ self.assertIn(self.gc_partial_balance, returned_codes)
+
+ # Exhausted should NOT be included
+ self.assertNotIn(self.gc_exhausted, returned_codes)
+
+ def test_returned_balance_field(self):
+ """Test that balance field is correctly populated"""
+ cards = get_gift_cards_with_balance(company=self.test_company)
+
+ for card in cards:
+ self.assertIn("balance", card)
+ self.assertGreater(card.balance, 0)
+
+
+class TestPricingRuleCreation(unittest.TestCase):
+ """Test pricing rule creation for gift cards"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_pricing_rules = []
+
+ def tearDown(self):
+ """Roll back after each test"""
+ frappe.db.rollback()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ 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 test_pricing_rule_basic(self):
+ """Test basic pricing rule creation"""
+ code = f"TEST-{frappe.generate_hash()[:8].upper()}"
+ pr_name = _create_pricing_rule_for_gift_card(
+ amount=100,
+ coupon_code=code,
+ company=self.test_company
+ )
+
+ self.assertIsNotNone(pr_name)
+ self.created_pricing_rules.append(pr_name)
+
+ pr = frappe.get_doc("Pricing Rule", pr_name)
+ self.assertEqual(pr.apply_on, "Transaction")
+ self.assertEqual(pr.price_or_product_discount, "Price")
+ self.assertEqual(pr.rate_or_discount, "Discount Amount")
+ self.assertEqual(flt(pr.discount_amount), 100)
+ self.assertTrue(pr.selling)
+ self.assertFalse(pr.buying)
+ self.assertTrue(pr.coupon_code_based)
+
+ def test_pricing_rule_with_validity(self):
+ """Test pricing rule with validity dates"""
+ code = f"TEST-{frappe.generate_hash()[:8].upper()}"
+ valid_from = nowdate()
+ valid_upto = add_months(valid_from, 6)
+
+ pr_name = _create_pricing_rule_for_gift_card(
+ amount=50,
+ coupon_code=code,
+ company=self.test_company,
+ valid_from=valid_from,
+ valid_upto=valid_upto
+ )
+
+ self.assertIsNotNone(pr_name)
+ self.created_pricing_rules.append(pr_name)
+
+ pr = frappe.get_doc("Pricing Rule", pr_name)
+ self.assertEqual(str(pr.valid_from), str(valid_from))
+ self.assertEqual(str(pr.valid_upto), str(valid_upto))
+
+
+def run_gift_card_tests():
+ """Run all gift card tests and return results"""
+ loader = unittest.TestLoader()
+ suite = unittest.TestSuite()
+
+ # Add test classes
+ suite.addTests(loader.loadTestsFromTestCase(TestGiftCardCodeGeneration))
+ suite.addTests(loader.loadTestsFromTestCase(TestManualGiftCardCreation))
+ suite.addTests(loader.loadTestsFromTestCase(TestGiftCardApplication))
+ suite.addTests(loader.loadTestsFromTestCase(TestGiftCardBalanceUpdate))
+ suite.addTests(loader.loadTestsFromTestCase(TestGetGiftCardsWithBalance))
+ suite.addTests(loader.loadTestsFromTestCase(TestPricingRuleCreation))
+
+ # Run tests
+ runner = unittest.TextTestRunner(verbosity=2)
+ return runner.run(suite)
+
+
+if __name__ == "__main__":
+ run_gift_card_tests()
diff --git a/pos_next/tests/test_promotions_coupon.py b/pos_next/tests/test_promotions_coupon.py
new file mode 100644
index 00000000..ecf35dbd
--- /dev/null
+++ b/pos_next/tests/test_promotions_coupon.py
@@ -0,0 +1,427 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025, BrainWise and contributors
+# For license information, please see license.txt
+
+"""
+Test Suite for Promotions API Coupon Functions
+
+This module tests the coupon CRUD operations in promotions.py including:
+- create_coupon - Create new coupons (ERPNext Coupon Code)
+- update_coupon - Update existing coupons
+- delete_coupon - Delete coupons
+- get_referral_details - Get referral code details
+
+Run with: bench --site [site] run-tests --app pos_next --module pos_next.tests.test_promotions_coupon
+"""
+
+import frappe
+import unittest
+from frappe.utils import nowdate, add_months, flt
+import json
+from pos_next.api.promotions import (
+ create_coupon,
+ update_coupon,
+ delete_coupon,
+ get_referral_details,
+)
+from pos_next.pos_next.doctype.referral_code.referral_code import create_referral_code
+
+
+class TestCreateCoupon(unittest.TestCase):
+ """Test create_coupon function"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ # Get test customer
+ customers = frappe.get_all("Customer", limit=1)
+ cls.test_customer = customers[0].name if customers else None
+
+ def tearDown(self):
+ """Roll back after each test"""
+ frappe.db.rollback()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ for name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", name, force=True)
+ except Exception:
+ pass
+
+ for name in cls.created_pricing_rules:
+ try:
+ frappe.delete_doc("Pricing Rule", name, force=True)
+ except Exception:
+ pass
+
+ frappe.db.commit()
+
+ def test_create_promotional_coupon_percentage(self):
+ """Test creating a promotional coupon with percentage discount"""
+ data = {
+ "coupon_name": f"Test Promo {frappe.generate_hash()[:6]}",
+ "coupon_type": "Promotional",
+ "discount_type": "Percentage",
+ "discount_percentage": 10,
+ "company": self.test_company,
+ "valid_from": nowdate(),
+ "valid_upto": add_months(nowdate(), 3),
+ "maximum_use": 100,
+ }
+
+ result = create_coupon(json.dumps(data))
+
+ self.assertTrue(result.get("success"))
+ self.assertIn("coupon_code", result)
+ self.created_coupons.append(result.get("name"))
+
+ # Verify coupon
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ self.assertEqual(coupon.coupon_type, "Promotional")
+
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+ pr = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
+ self.assertEqual(pr.rate_or_discount, "Discount Percentage")
+ self.assertEqual(flt(pr.discount_percentage), 10)
+
+ def test_create_promotional_coupon_amount(self):
+ """Test creating a promotional coupon with fixed amount discount"""
+ data = {
+ "coupon_name": f"Test Amount Promo {frappe.generate_hash()[:6]}",
+ "coupon_type": "Promotional",
+ "discount_type": "Amount",
+ "discount_amount": 50,
+ "company": self.test_company,
+ "valid_from": nowdate(),
+ "valid_upto": add_months(nowdate(), 3),
+ "maximum_use": 50,
+ }
+
+ result = create_coupon(json.dumps(data))
+
+ self.assertTrue(result.get("success"))
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+ pr = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
+ self.assertEqual(pr.rate_or_discount, "Discount Amount")
+ self.assertEqual(flt(pr.discount_amount), 50)
+
+ def test_create_coupon_with_customer(self):
+ """Test creating a coupon assigned to specific customer"""
+ if not self.test_customer:
+ self.skipTest("No test customer available")
+
+ data = {
+ "coupon_name": f"Customer Coupon {frappe.generate_hash()[:6]}",
+ "coupon_type": "Promotional",
+ "discount_type": "Percentage",
+ "discount_percentage": 15,
+ "company": self.test_company,
+ "customer": self.test_customer,
+ "valid_from": nowdate(),
+ "valid_upto": add_months(nowdate(), 1),
+ "maximum_use": 1,
+ }
+
+ result = create_coupon(json.dumps(data))
+
+ self.assertTrue(result.get("success"))
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ self.assertEqual(coupon.customer, self.test_customer)
+
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+
+class TestUpdateCoupon(unittest.TestCase):
+ """Test update_coupon function"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_coupons = []
+ cls.created_pricing_rules = []
+
+ def tearDown(self):
+ """Roll back after each test"""
+ frappe.db.rollback()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ for name in cls.created_coupons:
+ try:
+ frappe.delete_doc("Coupon Code", name, force=True)
+ except Exception:
+ pass
+
+ for name in cls.created_pricing_rules:
+ try:
+ frappe.delete_doc("Pricing Rule", name, force=True)
+ except Exception:
+ pass
+
+ frappe.db.commit()
+
+ def test_update_coupon_discount(self):
+ """Test updating coupon discount amount"""
+ # Create coupon first
+ data = {
+ "coupon_name": f"Update Test {frappe.generate_hash()[:6]}",
+ "coupon_type": "Promotional",
+ "discount_type": "Percentage",
+ "discount_percentage": 10,
+ "company": self.test_company,
+ "valid_from": nowdate(),
+ "valid_upto": add_months(nowdate(), 3),
+ "maximum_use": 100,
+ }
+
+ result = create_coupon(json.dumps(data))
+ self.assertTrue(result.get("success"))
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ # Update discount
+ update_data = {
+ "name": result.get("name"),
+ "discount_type": "Percentage",
+ "discount_percentage": 20, # Changed from 10 to 20
+ }
+
+ update_result = update_coupon(json.dumps(update_data))
+ self.assertTrue(update_result.get("success"))
+
+ # Verify update
+ if coupon.pricing_rule:
+ updated_pr = frappe.get_doc("Pricing Rule", coupon.pricing_rule)
+ self.assertEqual(flt(updated_pr.discount_percentage), 20)
+
+ def test_update_coupon_validity(self):
+ """Test updating coupon validity dates"""
+ data = {
+ "coupon_name": f"Validity Test {frappe.generate_hash()[:6]}",
+ "coupon_type": "Promotional",
+ "discount_type": "Amount",
+ "discount_amount": 30,
+ "company": self.test_company,
+ "valid_from": nowdate(),
+ "valid_upto": add_months(nowdate(), 1),
+ "maximum_use": 50,
+ }
+
+ result = create_coupon(json.dumps(data))
+ self.assertTrue(result.get("success"))
+ self.created_coupons.append(result.get("name"))
+
+ coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ if coupon.pricing_rule:
+ self.created_pricing_rules.append(coupon.pricing_rule)
+
+ # Update validity
+ new_valid_upto = add_months(nowdate(), 6)
+ update_data = {
+ "name": result.get("name"),
+ "valid_upto": str(new_valid_upto),
+ }
+
+ update_result = update_coupon(json.dumps(update_data))
+ self.assertTrue(update_result.get("success"))
+
+ # Verify update
+ updated_coupon = frappe.get_doc("Coupon Code", result.get("name"))
+ self.assertEqual(str(updated_coupon.valid_upto), str(new_valid_upto))
+
+
+class TestDeleteCoupon(unittest.TestCase):
+ """Test delete_coupon function"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+
+ def test_delete_coupon(self):
+ """Test deleting a coupon"""
+ # Create coupon first
+ data = {
+ "coupon_name": f"Delete Test {frappe.generate_hash()[:6]}",
+ "coupon_type": "Promotional",
+ "discount_type": "Percentage",
+ "discount_percentage": 5,
+ "company": self.test_company,
+ "valid_from": nowdate(),
+ "valid_upto": add_months(nowdate(), 1),
+ "maximum_use": 10,
+ }
+
+ result = create_coupon(json.dumps(data))
+ self.assertTrue(result.get("success"))
+
+ coupon_name = result.get("name")
+ coupon = frappe.get_doc("Coupon Code", coupon_name)
+ pricing_rule_name = coupon.pricing_rule
+
+ # Delete coupon
+ delete_result = delete_coupon(coupon_name)
+ self.assertTrue(delete_result.get("success"))
+
+ # Verify deletion
+ self.assertFalse(frappe.db.exists("Coupon Code", coupon_name))
+
+ # Pricing rule should also be deleted
+ if pricing_rule_name:
+ self.assertFalse(frappe.db.exists("Pricing Rule", pricing_rule_name))
+
+
+class TestGetReferralDetails(unittest.TestCase):
+ """Test get_referral_details function"""
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up test fixtures"""
+ cls.test_company = frappe.get_all("Company", limit=1)[0].name
+ cls.created_referral_codes = []
+ cls.created_coupons = []
+
+ # Get test customers
+ customers = frappe.get_all("Customer", limit=2)
+ if len(customers) >= 2:
+ cls.referrer_customer = customers[0].name
+ cls.referee_customer = customers[1].name
+ else:
+ cls.referrer_customer = None
+ cls.referee_customer = None
+
+ @classmethod
+ def tearDownClass(cls):
+ """Clean up test data"""
+ # Clean up coupons first
+ for name in cls.created_coupons:
+ try:
+ coupon = frappe.get_doc("Coupon Code", name)
+ if coupon.pricing_rule:
+ try:
+ frappe.delete_doc("Pricing Rule", coupon.pricing_rule, force=True)
+ except Exception:
+ pass
+ frappe.delete_doc("Coupon Code", name, force=True)
+ except Exception:
+ pass
+
+ # Clean up referral codes
+ for name in cls.created_referral_codes:
+ try:
+ frappe.delete_doc("Referral Code", name, force=True)
+ except Exception:
+ pass
+
+ frappe.db.commit()
+
+ def test_get_referral_details_basic(self):
+ """Test getting referral details"""
+ if not self.referrer_customer:
+ self.skipTest("No test customer available")
+
+ # Create referral code
+ referral = create_referral_code(
+ company=self.test_company,
+ customer=self.referrer_customer,
+ referrer_discount_type="Percentage",
+ referrer_discount_percentage=10,
+ referee_discount_type="Percentage",
+ referee_discount_percentage=15
+ )
+ self.created_referral_codes.append(referral.name)
+ frappe.db.commit()
+
+ # Get details
+ details = get_referral_details(referral.name)
+
+ self.assertIsNotNone(details)
+ self.assertEqual(details.get("name"), referral.name)
+ self.assertEqual(details.get("customer"), self.referrer_customer)
+ self.assertIn("generated_coupons", details)
+ self.assertIn("total_coupons_generated", details)
+
+ def test_get_referral_details_with_coupons(self):
+ """Test getting referral details after coupons are generated"""
+ if not self.referrer_customer or not self.referee_customer:
+ self.skipTest("No test customers available")
+
+ from pos_next.pos_next.doctype.referral_code.referral_code import apply_referral_code
+
+ # Create referral code
+ referral = create_referral_code(
+ company=self.test_company,
+ customer=self.referrer_customer,
+ referrer_discount_type="Amount",
+ referrer_discount_amount=20,
+ referee_discount_type="Amount",
+ referee_discount_amount=20
+ )
+ self.created_referral_codes.append(referral.name)
+ frappe.db.commit()
+
+ # Apply referral code (generates coupons)
+ result = apply_referral_code(
+ referral_code=referral.referral_code,
+ referee_customer=self.referee_customer
+ )
+
+ if result.get("referrer_coupon"):
+ self.created_coupons.append(result["referrer_coupon"]["name"])
+ if result.get("referee_coupon"):
+ self.created_coupons.append(result["referee_coupon"]["name"])
+
+ frappe.db.commit()
+
+ # Get details
+ details = get_referral_details(referral.name)
+
+ self.assertGreater(details.get("total_coupons_generated"), 0)
+ self.assertIsInstance(details.get("generated_coupons"), list)
+
+ # Verify coupon details are populated
+ for coupon in details.get("generated_coupons", []):
+ self.assertIn("coupon_code", coupon)
+ self.assertIn("coupon_type", coupon)
+
+ def test_get_referral_details_invalid(self):
+ """Test getting details for invalid referral code"""
+ with self.assertRaises(frappe.exceptions.ValidationError):
+ get_referral_details("INVALID-REFERRAL-CODE")
+
+
+def run_promotions_coupon_tests():
+ """Run all promotions coupon tests and return results"""
+ loader = unittest.TestLoader()
+ suite = unittest.TestSuite()
+
+ suite.addTests(loader.loadTestsFromTestCase(TestCreateCoupon))
+ suite.addTests(loader.loadTestsFromTestCase(TestUpdateCoupon))
+ suite.addTests(loader.loadTestsFromTestCase(TestDeleteCoupon))
+ suite.addTests(loader.loadTestsFromTestCase(TestGetReferralDetails))
+
+ runner = unittest.TextTestRunner(verbosity=2)
+ return runner.run(suite)
+
+
+if __name__ == "__main__":
+ run_promotions_coupon_tests()