diff --git a/.gitignore b/.gitignore index 67f20bb7..43094442 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ dev-dist/ TODO.md .claude/settings.json WhatsApp Image 2026-02-18 at 11.09.47 AM.jpeg +CLAUDE.md 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/src/components/sale/CouponDialog.vue b/POS/src/components/sale/CouponDialog.vue index 3fb4f8bf..38afb214 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) }} +
+
@@ -160,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, @@ -282,6 +309,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,22 +318,36 @@ 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 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.netTotal) + remainingBalance = availableBalance - discountAmount + } else { + // 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, + } + 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 - 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, @@ -315,6 +357,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/components/sale/CouponManagement.vue b/POS/src/components/sale/CouponManagement.vue index 678a714d..bac1367c 100644 --- a/POS/src/components/sale/CouponManagement.vue +++ b/POS/src/components/sale/CouponManagement.vue @@ -107,7 +107,7 @@ {{ coupon.coupon_code }}

{ } // 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 diff --git a/POS/src/components/sale/EditItemDialog.vue b/POS/src/components/sale/EditItemDialog.vue index 480d2a7d..8ada225f 100644 --- a/POS/src/components/sale/EditItemDialog.vue +++ b/POS/src/components/sale/EditItemDialog.vue @@ -348,10 +348,18 @@ const hasPricingRules = computed(() => { return Boolean(localItem.value.pricing_rules) && localItem.value.pricing_rules.length > 0 }) -// Rate editing is allowed only if: -// 1. POS Settings allows rate editing AND -// 2. Item does NOT have pricing rules (promotional offers) applied +// Zero-price items (e.g., gift cards) always allow rate editing +const isZeroPriceItem = computed(() => { + if (!localItem.value) return false + const originalRate = localItem.value.price_list_rate || localItem.value.rate || 0 + return originalRate === 0 +}) + +// Rate editing is allowed if: +// 1. Item has zero price (gift cards with custom value), OR +// 2. POS Settings allows rate editing AND item has no pricing rules applied const canEditRate = computed(() => { + if (isZeroPriceItem.value) return true return settingsStore.allowUserToEditRate && !hasPricingRules.value }) diff --git a/POS/src/components/sale/GiftCardCreatedDialog.vue b/POS/src/components/sale/GiftCardCreatedDialog.vue new file mode 100644 index 00000000..77f8597e --- /dev/null +++ b/POS/src/components/sale/GiftCardCreatedDialog.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/POS/src/components/sale/InvoiceCart.vue b/POS/src/components/sale/InvoiceCart.vue index b6bdee75..fa66c1ea 100644 --- a/POS/src/components/sale/InvoiceCart.vue +++ b/POS/src/components/sale/InvoiceCart.vue @@ -2000,5 +2000,13 @@ onBeforeUnmount(() => { if (typeof document === "undefined") return; document.removeEventListener("mousedown", handleOutsideClick); }); + +/** + * Expose methods to parent component. + * Allows POSSale to trigger edit dialog for zero-price items (e.g., gift cards). + */ +defineExpose({ + openEditDialog, +}); ``` diff --git a/POS/src/components/sale/PaymentDialog.vue b/POS/src/components/sale/PaymentDialog.vue index 35379999..9b50fe13 100644 --- a/POS/src/components/sale/PaymentDialog.vue +++ b/POS/src/components/sale/PaymentDialog.vue @@ -1066,6 +1066,18 @@ const walletInfo = ref({ const loadingWallet = ref(false) const walletPaymentMethods = ref(new Set()) // Set of mode_of_payment names that are wallet payments +// Wallee Terminal Payment state +const showWalleeDialog = ref(false) +const walleeDialogAmount = ref(0) +const walleeSelectedTerminal = ref(null) +const walleePaymentStatus = ref('') +const walleePaymentError = ref(false) +const walleePaymentInProgress = ref(false) +const walleeCurrentTransaction = ref(null) +const walleeLockedPayments = ref([]) +const walleePaymentMode = ref(null) // The Mode of Payment configured for Wallee in POS Profile +const walleeCurrentMethod = ref(null) // The payment method being used + // Delivery date for Sales Orders const deliveryDate = ref("") const today = new Date().toISOString().split("T")[0] @@ -1770,6 +1782,10 @@ const canComplete = computed(() => { return false } + // If grand total is 0 (fully covered by discount/gift card), can complete without payment entries + if (props.grandTotal === 0) { + return true + } // If partial payment is allowed, can complete with any amount > 0 if (props.allowPartialPayment) { return totalPaid.value > 0 && paymentEntries.value.length > 0 @@ -2378,6 +2394,12 @@ watch( if (isOpen) { // Only sync when dialog opens, not continuously localAdditionalDiscount.value = props.additionalDiscount || 0 + + // If there's already a discount applied (e.g., from gift card/coupon), + // set the mode to 'amount' since coupon discounts are always amounts + if (props.additionalDiscount > 0) { + additionalDiscountType.value = 'amount' + } } }, ) diff --git a/POS/src/composables/useGiftCard.js b/POS/src/composables/useGiftCard.js new file mode 100644 index 00000000..bc4566c2 --- /dev/null +++ b/POS/src/composables/useGiftCard.js @@ -0,0 +1,235 @@ +/** + * 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, + }) + + /** + * Resource to get gift cards created from an invoice + */ + const giftCardsFromInvoiceResource = createResource({ + url: "pos_next.api.gift_cards.get_gift_cards_from_invoice", + 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} - Application result with discount amount + */ + async function applyGiftCard({ couponCode, invoiceTotal, customer, company }) { + try { + const result = await applyGiftCardResource.fetch({ + coupon_code: couponCode, + invoice_total: invoiceTotal, + customer: customer || null, + company, + }) + + const data = result?.message || result + return data + } catch (err) { + console.error("Failed to apply gift card:", err) + return { + success: false, + message: err.message || __("Failed to apply gift card"), + } + } + } + + /** + * Get gift cards created from a specific invoice + * Called after invoice submission to check if gift cards were created + * + * @param {string} invoiceName - Name of the invoice + * @returns {Promise} - List of gift cards created from this invoice + */ + async function getGiftCardsFromInvoice(invoiceName) { + if (!invoiceName) { + return [] + } + + try { + const result = await giftCardsFromInvoiceResource.fetch({ + invoice_name: invoiceName, + }) + + const data = result?.message || result || [] + return Array.isArray(data) ? data : [] + } catch (err) { + console.error("Failed to get gift cards from invoice:", err) + return [] + } + } + + /** + * Calculate discount amount for a gift card + * + * @param {Object} giftCard - Gift card object + * @param {number} invoiceTotal - Invoice total amount + * @returns {Object} - Discount calculation result + */ + function calculateGiftCardDiscount(giftCard, invoiceTotal) { + if (!giftCard) { + return { discount: 0, willSplit: false, remainingBalance: 0 } + } + + const balance = giftCard.balance || giftCard.gift_card_amount || giftCard.discount_amount || 0 + const discount = Math.min(balance, invoiceTotal) + const willSplit = balance > invoiceTotal + const remainingBalance = willSplit ? balance - invoiceTotal : 0 + + return { + discount, + willSplit, + remainingBalance, + availableBalance: balance, + } + } + + /** + * Format gift card for display + * + * @param {Object} giftCard - Gift card object + * @returns {Object} - Formatted gift card info + */ + function formatGiftCard(giftCard) { + return { + code: giftCard.coupon_code, + name: giftCard.coupon_name || giftCard.name, + balance: giftCard.balance || giftCard.gift_card_amount || giftCard.discount_amount || 0, + originalAmount: giftCard.original_amount || giftCard.balance, + customer: giftCard.customer, + customerName: giftCard.customer_name, + validUpto: giftCard.valid_upto, + isAnonymous: !giftCard.customer, + } + } + + /** + * Gift cards grouped by type (customer-specific vs anonymous) + */ + const groupedGiftCards = computed(() => { + const customerCards = giftCards.value.filter((gc) => gc.customer) + const anonymousCards = giftCards.value.filter((gc) => !gc.customer) + + return { + customerCards, + anonymousCards, + hasCustomerCards: customerCards.length > 0, + hasAnonymousCards: anonymousCards.length > 0, + } + }) + + /** + * Total available balance across all gift cards + */ + const totalAvailableBalance = computed(() => { + return giftCards.value.reduce((sum, gc) => { + const balance = gc.balance || gc.gift_card_amount || gc.discount_amount || 0 + return sum + balance + }, 0) + }) + + /** + * Check if there are any gift cards available + */ + const hasGiftCards = computed(() => giftCards.value.length > 0) + + return { + // State + giftCards, + loading, + error, + + // Computed + groupedGiftCards, + totalAvailableBalance, + hasGiftCards, + + // Methods + loadGiftCards, + applyGiftCard, + getGiftCardsFromInvoice, + calculateGiftCardDiscount, + formatGiftCard, + + // Resources (for advanced usage) + giftCardsResource, + applyGiftCardResource, + giftCardsFromInvoiceResource, + } +} diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js index 5be26be9..ad9df962 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) @@ -834,8 +839,11 @@ 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, + posa_gift_card_amount_used: additionalDiscount.value || 0, is_pos: 1, update_stock: 1, } @@ -898,8 +906,11 @@ 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, + posa_gift_card_amount_used: additionalDiscount.value || 0, is_pos: 1, update_stock: 1, // Critical: Ensures stock is updated } @@ -967,31 +978,9 @@ export function useInvoice() { resetInvoice() return result } catch (error) { - // Preserve original error object with all its properties - console.error("Submit invoice error:", error) - console.log( - "submitInvoiceResource.error:", - submitInvoiceResource.error, - ) - // If resource has error data, extract and attach it if (submitInvoiceResource.error) { const resourceError = submitInvoiceResource.error - console.log("Resource error details:", { - exc_type: resourceError.exc_type, - _server_messages: resourceError._server_messages, - httpStatus: resourceError.httpStatus, - messages: resourceError.messages, - messagesContent: JSON.stringify(resourceError.messages), - data: resourceError.data, - exception: resourceError.exception, - keys: Object.keys(resourceError), - }) - - // The messages array likely contains the detailed error info - if (resourceError.messages && resourceError.messages.length > 0) { - console.log("First message:", resourceError.messages[0]) - } // Attach all resource error properties to the error error.exc_type = resourceError.exc_type || error.exc_type @@ -1000,15 +989,11 @@ export function useInvoice() { error.messages = resourceError.messages error.exception = resourceError.exception error.data = resourceError.data - - console.log("After attaching, error.messages:", error.messages) } throw error } } catch (error) { - // Outer catch to ensure error propagates - console.error("Submit invoice outer error:", error) throw error } finally { isSubmitting.value = false @@ -1046,7 +1031,6 @@ export function useInvoice() { } } catch (error) { // Silently fail - default customer is optional - console.log("No default customer set in POS Profile") } } @@ -1173,6 +1157,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 3dca4077..0b2f2b92 100644 --- a/POS/src/pages/POSSale.vue +++ b/POS/src/pages/POSSale.vue @@ -314,6 +314,7 @@ style="min-width: 300px; contain: layout style paint" > + + + { + const addedItem = cartStore.invoiceItems.find( + (i) => i.item_code === item.item_code + ); + if (addedItem && invoiceCartRef.value) { + invoiceCartRef.value.openEditDialog(addedItem); + } + }); + return; + } + // Add to cart try { cartStore.addItem(item, 1, false, shiftStore.currentProfile); @@ -1929,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); @@ -1977,6 +2031,17 @@ async function handlePaymentCompleted(paymentData) { log.debug("Background invoice cache refresh failed:", err) ); + // Check if gift cards were created from this invoice + try { + const giftCards = await getGiftCardsFromInvoice(invoiceName); + if (giftCards && giftCards.length > 0) { + createdGiftCards.value = giftCards; + showGiftCardCreatedDialog.value = true; + } + } catch (err) { + log.warn("Failed to check for created gift cards:", err); + } + if (shiftStore.autoPrintEnabled || posSettingsStore.silentPrint) { try { await handlePrintInvoice({ name: invoiceName }); diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index 639fe9b4..a6476e78 100644 --- a/POS/src/stores/posCart.js +++ b/POS/src/stores/posCart.js @@ -86,6 +86,7 @@ export const usePOSCartStore = defineStore("posCart", () => { totalTax, totalDiscount, grandTotal, + netTotalBeforeAdditionalDiscount, posProfile, posOpeningShift, payments, @@ -307,6 +308,24 @@ export const usePOSCartStore = defineStore("posCart", () => { showSuccess(__("Discount has been removed from cart")) } + // Watch appliedCoupon to ensure additionalDiscount stays in sync + // This handles cases where appliedCoupon is restored from state but additionalDiscount is lost + // Using flush: 'post' to ensure the watcher runs after Vue updates and store is fully initialized + watch(appliedCoupon, (newCoupon) => { + if (newCoupon && newCoupon.amount > 0) { + // Always sync the discount to ensure consistency + // Use nextTick to ensure the store and composable are ready + nextTick(() => { + if (additionalDiscount.value !== newCoupon.amount) { + applyDiscount(newCoupon) + } + }) + } else if (!newCoupon && additionalDiscount.value > 0) { + // Clear the discount if coupon is removed + removeDiscount() + } + }, { immediate: true, flush: 'post' }) + function buildOfferEvaluationPayload(currentProfile) { // Use toRaw() to ensure we get current, non-reactive values (prevents stale cached quantities) const rawItems = toRaw(invoiceItems.value) @@ -1225,6 +1244,14 @@ export const usePOSCartStore = defineStore("posCart", () => { if (updates.discount_amount !== undefined) cartItem.discount_amount = updates.discount_amount if (updates.rate !== undefined) cartItem.rate = updates.rate if (updates.price_list_rate !== undefined) cartItem.price_list_rate = updates.price_list_rate + if (updates.rate !== undefined) { + // Update rate (for zero-price items like gift cards) + cartItem.rate = updates.rate + // Also update price_list_rate to keep consistency + if (cartItem.price_list_rate === 0) { + cartItem.price_list_rate = updates.rate + } + } if (updates.serial_no !== undefined) cartItem.serial_no = updates.serial_no // Track manual rate edits for audit purposes if (updates.is_rate_manually_edited !== undefined) cartItem.is_rate_manually_edited = updates.is_rate_manually_edited @@ -1649,6 +1676,7 @@ export const usePOSCartStore = defineStore("posCart", () => { totalTax, totalDiscount, grandTotal, + netTotalBeforeAdditionalDiscount, posProfile, posOpeningShift, payments, 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 ? ` 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 + +``` + +**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 diff --git a/pos_next/api/gift_cards.py b/pos_next/api/gift_cards.py new file mode 100644 index 00000000..6d991b50 --- /dev/null +++ b/pos_next/api/gift_cards.py @@ -0,0 +1,892 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025, BrainWise and contributors +# For license information, please see license.txt + +""" +Gift Card API for POS Next + +Handles: +- Gift card creation from Invoice (when selling gift card items) +- Gift card validation and application +- Gift card splitting (when amount > invoice total) +- Direct ERPNext Coupon Code integration (no POS Coupon) +""" + +import frappe +from frappe import _ +from frappe.utils import flt, nowdate, add_months, getdate +import random +import string + + +# ========================================== +# Gift Card Code Generation +# ========================================== + +def generate_gift_card_code(): + """ + Generate unique gift card code in format GC-XXXX-XXXX + + Returns: + str: Unique gift card code + """ + def segment(): + return ''.join(random.choices(string.ascii_uppercase + string.digits, k=4)) + + max_attempts = 100 + for _ in range(max_attempts): + code = f"GC-{segment()}-{segment()}" + + # Check uniqueness in ERPNext Coupon Code only + if not frappe.db.exists("Coupon Code", {"coupon_code": code}): + return code + + # Fallback: use hash-based code + return f"GC-{frappe.generate_hash()[:8].upper()}" + + +# ========================================== +# Gift Card Settings +# ========================================== + +def get_gift_card_settings(pos_profile): + """ + Get gift card settings for a POS Profile. + + Args: + pos_profile: Name of the POS Profile + + Returns: + dict: Gift card settings or None if not enabled + """ + if not pos_profile: + return None + + # Get POS Settings for this profile + pos_settings = frappe.db.get_value( + "POS Settings", + {"pos_profile": pos_profile, "enabled": 1}, + [ + "enable_gift_cards", + "gift_card_item", + "enable_gift_card_splitting", + "gift_card_validity_months", + "gift_card_notification" + ], + as_dict=True + ) + + if not pos_settings or not pos_settings.get("enable_gift_cards"): + return None + + return pos_settings + + +def is_gift_card_item(item_code, pos_profile): + """ + Check if an item is the designated gift card item. + + Args: + item_code: Item code to check + pos_profile: POS Profile name + + Returns: + bool: True if item is the gift card item + """ + settings = get_gift_card_settings(pos_profile) + if not settings: + return False + + return settings.get("gift_card_item") == item_code + + +# ========================================== +# Gift Card Creation (Direct to ERPNext Coupon Code) +# ========================================== + +@frappe.whitelist() +def create_gift_card_from_invoice(doc, method=None): + """ + Create gift card(s) when a gift card item is sold. + Called after POS Invoice or Sales Invoice submission. + Creates ERPNext Coupon Code directly (no POS Coupon). + + Args: + doc: Invoice document or invoice name + method: Hook method name (optional) + + Returns: + dict: Created gift card details or None + """ + if not doc: + return None + + if isinstance(doc, str): + # Try Sales Invoice first, then POS Invoice + if frappe.db.exists("Sales Invoice", doc): + invoice = frappe.get_doc("Sales Invoice", doc) + else: + invoice = frappe.get_doc("POS Invoice", doc) + else: + invoice = doc + + # Check if invoice is submitted + if invoice.docstatus != 1: + return None + + # Get POS profile + pos_profile = getattr(invoice, 'pos_profile', None) + if not pos_profile and invoice.doctype == "Sales Invoice": + pos_profile = frappe.db.get_value( + "POS Opening Entry", + {"name": invoice.get("posa_pos_opening_shift")}, + "pos_profile" + ) if invoice.get("posa_pos_opening_shift") else None + + # Get gift card settings + settings = get_gift_card_settings(pos_profile) + if not settings: + return None + + gift_card_item = settings.get("gift_card_item") + if not gift_card_item: + return None + + created_gift_cards = [] + + # Find gift card items in the invoice + for item in invoice.items: + if item.item_code != gift_card_item: + continue + + # Create gift card for each quantity + qty = int(item.qty) + for i in range(qty): + gift_card = _create_gift_card( + amount=flt(item.rate), + customer=invoice.customer, + company=invoice.company, + source_invoice=invoice.name, + settings=settings + ) + if gift_card: + created_gift_cards.append(gift_card) + + # Send notifications if configured + if created_gift_cards and settings.get("gift_card_notification"): + for gc in created_gift_cards: + _send_gift_card_notification(gc, settings.get("gift_card_notification")) + + return { + "success": True, + "gift_cards": created_gift_cards + } if created_gift_cards else None + + +def _create_gift_card(amount, customer, company, source_invoice, settings): + """ + Create a gift card directly as ERPNext Coupon Code + Pricing Rule. + + Args: + amount: Gift card value + customer: Customer name (can be None for anonymous) + company: Company name + source_invoice: Source Invoice name + settings: Gift card settings dict + + Returns: + dict: Created gift card info + """ + try: + code = generate_gift_card_code() + validity_months = settings.get("gift_card_validity_months") or 12 + + # Calculate validity dates + valid_from = nowdate() + valid_upto = None + if validity_months > 0: + valid_upto = add_months(valid_from, validity_months) + + # Create Pricing Rule first + pricing_rule = _create_pricing_rule_for_gift_card( + amount=flt(amount), + coupon_code=code, + company=company, + valid_from=valid_from, + valid_upto=valid_upto + ) + + if not pricing_rule: + frappe.log_error( + "Gift Card Creation Failed", + f"Failed to create pricing rule for gift card {code}" + ) + return None + + # Create ERPNext Coupon Code directly + coupon = frappe.get_doc({ + "doctype": "Coupon Code", + "coupon_name": f"Gift Card {code}", + "coupon_type": "Promotional", + "coupon_code": code, + "pricing_rule": pricing_rule, + "valid_from": valid_from, + "valid_upto": valid_upto, + "maximum_use": 0, # Unlimited uses until balance is exhausted + "used": 0, + # Custom fields for POS Next + "pos_next_gift_card": 1, + "gift_card_amount": flt(amount), + "original_gift_card_amount": flt(amount), + "source_invoice": source_invoice + }) + coupon.insert(ignore_permissions=True) + + return { + "name": coupon.name, + "coupon_code": code, + "amount": flt(amount), + "valid_from": valid_from, + "valid_upto": valid_upto, + "customer": customer + } + + except Exception as e: + frappe.log_error( + "Gift Card Creation Failed", + f"Failed to create gift card for invoice {source_invoice}: {str(e)}" + ) + return None + + +def _create_pricing_rule_for_gift_card(amount, coupon_code, company, valid_from=None, valid_upto=None): + """ + Create Pricing Rule for gift card discount. + + Args: + amount: Discount amount + coupon_code: Coupon code string + company: Company name + valid_from: Start date + valid_upto: End date + + Returns: + str: Name of created Pricing Rule or None + """ + try: + pricing_rule_data = { + "doctype": "Pricing Rule", + "title": f"Gift Card {coupon_code}", + "apply_on": "Transaction", + "price_or_product_discount": "Price", + "rate_or_discount": "Discount Amount", + "discount_amount": flt(amount), + "selling": 1, + "buying": 0, + "applicable_for": "", + "company": company, + "currency": frappe.get_cached_value("Company", company, "default_currency"), + "valid_from": valid_from or nowdate(), + "coupon_code_based": 1, + "priority": "1" + } + + # Only set valid_upto and is_cumulative if we have an end date + # ERPNext requires valid_upto when is_cumulative=1 + if valid_upto: + pricing_rule_data["valid_upto"] = valid_upto + pricing_rule_data["is_cumulative"] = 1 + + pricing_rule = frappe.get_doc(pricing_rule_data) + pricing_rule.insert(ignore_permissions=True) + + return pricing_rule.name + + except Exception as e: + frappe.log_error( + "Pricing Rule Creation Failed", + f"Failed to create pricing rule for gift card {coupon_code}: {str(e)}" + ) + return None + + +def _send_gift_card_notification(gift_card_info, notification_name): + """ + Send notification for a created gift card. + + Args: + gift_card_info: Dict with gift card details + notification_name: Name of the Notification template + """ + try: + if not frappe.db.exists("Notification", notification_name): + return + + coupon = frappe.get_doc("Coupon Code", gift_card_info.get("name")) + + from frappe.email.doctype.notification.notification import evaluate_alert + notification = frappe.get_doc("Notification", notification_name) + evaluate_alert(coupon, notification.event, notification.name) + + except Exception as e: + frappe.log_error( + "Gift Card Notification Failed", + f"Failed to send notification for gift card {gift_card_info.get('coupon_code')}: {str(e)}" + ) + + +# ========================================== +# Manual Gift Card Creation (for ERPNext UI button) +# ========================================== + +@frappe.whitelist() +def create_gift_card_manual(amount, company, customer=None, validity_months=12): + """ + Create a gift card manually (from ERPNext Coupon Code list button). + + Args: + amount: Gift card value + company: Company name + customer: Optional customer assignment + validity_months: Validity period in months + + Returns: + dict: Created gift card info + """ + try: + code = generate_gift_card_code() + + valid_from = nowdate() + valid_upto = None + if int(validity_months) > 0: + valid_upto = add_months(valid_from, int(validity_months)) + + # Create Pricing Rule + pricing_rule = _create_pricing_rule_for_gift_card( + amount=flt(amount), + coupon_code=code, + company=company, + valid_from=valid_from, + valid_upto=valid_upto + ) + + if not pricing_rule: + return {"success": False, "message": _("Failed to create pricing rule")} + + # Create Coupon Code + coupon = frappe.get_doc({ + "doctype": "Coupon Code", + "coupon_name": f"Gift Card {code}", + "coupon_type": "Promotional", + "coupon_code": code, + "pricing_rule": pricing_rule, + "valid_from": valid_from, + "valid_upto": valid_upto, + "maximum_use": 0, + "used": 0, + "pos_next_gift_card": 1, + "gift_card_amount": flt(amount), + "original_gift_card_amount": flt(amount), + "customer": customer + }) + coupon.insert(ignore_permissions=True) + + return { + "success": True, + "name": coupon.name, + "coupon_code": code, + "amount": flt(amount), + "valid_from": valid_from, + "valid_upto": valid_upto + } + + except Exception as e: + frappe.log_error("Manual Gift Card Creation Failed", str(e)) + return {"success": False, "message": str(e)} + + +# ========================================== +# Gift Card Application +# ========================================== + +@frappe.whitelist() +def apply_gift_card(coupon_code, invoice_total, customer=None, company=None): + """ + Apply a gift card to an invoice. + + Args: + coupon_code: Gift card code + invoice_total: Total invoice amount + customer: Optional customer for validation + company: Company for validation + + Returns: + dict: Discount amount and gift card info + """ + coupon_code = (coupon_code or "").strip().upper() + + if not coupon_code: + return {"success": False, "message": _("Please enter a gift card code")} + + # Get the Coupon Code from ERPNext + coupon = frappe.db.get_value( + "Coupon Code", + {"coupon_code": coupon_code}, + [ + "name", "coupon_code", "coupon_type", "pricing_rule", + "valid_from", "valid_upto", "maximum_use", "used", + "pos_next_gift_card", "gift_card_amount", "original_gift_card_amount" + ], + as_dict=True + ) + + if not coupon: + return {"success": False, "message": _("Gift card not found")} + + # Check if it's a POS Next gift card + if not coupon.get("pos_next_gift_card"): + return {"success": False, "message": _("This is not a POS Next gift card")} + + # Check validity dates + today = getdate(nowdate()) + if coupon.valid_from and getdate(coupon.valid_from) > today: + return {"success": False, "message": _("Gift card is not yet valid")} + if coupon.valid_upto and getdate(coupon.valid_upto) < today: + return {"success": False, "message": _("Gift card has expired")} + + # Get available balance + available_balance = flt(coupon.gift_card_amount) + + if available_balance <= 0: + return {"success": False, "message": _("Gift card has no remaining balance")} + + # Calculate discount (minimum of balance and invoice total) + discount_amount = min(available_balance, flt(invoice_total)) + + # Check if splitting will be needed + will_split = available_balance > flt(invoice_total) + remaining_balance = available_balance - discount_amount if will_split else 0 + + return { + "success": True, + "coupon_code": coupon.coupon_code, + "coupon_name": coupon.name, + "discount_amount": discount_amount, + "available_balance": available_balance, + "will_split": will_split, + "remaining_balance": remaining_balance, + "valid_upto": coupon.valid_upto + } + + +@frappe.whitelist() +def get_gift_cards_with_balance(customer=None, company=None): + """ + Get all POS Next gift cards with available balance. + + Args: + customer: Optional customer filter + company: Company filter + + Returns: + list: Gift cards with balance > 0 + """ + filters = { + "coupon_type": "Promotional", + "pos_next_gift_card": 1 + } + + # Get all POS Next gift cards + gift_cards = frappe.get_all( + "Coupon Code", + filters=filters, + fields=[ + "name", "coupon_code", "coupon_name", + "gift_card_amount", "original_gift_card_amount", + "valid_from", "valid_upto", "used", "maximum_use", + "source_invoice" + ], + order_by="creation desc" + ) + + # Filter by balance and validity + today = getdate(nowdate()) + result = [] + + for gc in gift_cards: + # Check validity dates + if gc.valid_from and getdate(gc.valid_from) > today: + continue + if gc.valid_upto and getdate(gc.valid_upto) < today: + continue + + # Check balance + balance = flt(gc.gift_card_amount) + if balance <= 0: + continue + + gc["balance"] = balance + result.append(gc) + + 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 +# ========================================== + +@frappe.whitelist() +def process_gift_card_on_submit(doc, method=None): + """ + Process gift card after invoice submission. + Updates the ERPNext Coupon Code balance. + Handles splitting if gift card amount > invoice total. + Also handles returns (Credit Notes) to restore gift card balance. + + Args: + doc: Invoice document or invoice name + method: Hook method name (optional) + """ + if not doc: + return + + if isinstance(doc, str): + if frappe.db.exists("Sales Invoice", doc): + invoice = frappe.get_doc("Sales Invoice", doc) + else: + invoice = frappe.get_doc("POS Invoice", doc) + else: + invoice = doc + + # Handle returns (Credit Notes) - restore gift card balance + if getattr(invoice, 'is_return', 0) and getattr(invoice, 'return_against', None): + _process_gift_card_return(invoice) + return + + # Get coupon code from invoice + coupon_code = getattr(invoice, 'posa_coupon_code', None) or getattr(invoice, 'coupon_code', None) + + if not coupon_code: + return + + coupon_code = coupon_code.strip() + + # 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: + return + + # Only process POS Next gift cards + if not coupon.get("pos_next_gift_card"): + return + + # Get gift card settings for splitting option + pos_profile = getattr(invoice, 'pos_profile', None) + if not pos_profile and invoice.doctype == "Sales Invoice": + pos_profile = frappe.db.get_value( + "POS Opening Entry", + {"name": invoice.get("posa_pos_opening_shift")}, + "pos_profile" + ) if invoice.get("posa_pos_opening_shift") else None + + settings = get_gift_card_settings(pos_profile) + enable_splitting = settings.get("enable_gift_card_splitting") if settings else True + + # Calculate amounts + gift_card_balance = flt(coupon.gift_card_amount) + + # Use posa_gift_card_amount_used if available (persisted field that ERPNext doesn't clear) + # Fall back to discount_amount, then to gift_card_balance as last resort + used_amount = flt(getattr(invoice, 'posa_gift_card_amount_used', 0)) + if not used_amount: + used_amount = flt(invoice.discount_amount) if invoice.discount_amount else gift_card_balance + + if gift_card_balance <= 0: + return + + # Process based on splitting + if gift_card_balance > used_amount and enable_splitting: + # Partial usage - update balance + remaining_amount = gift_card_balance - used_amount + _update_gift_card_balance(coupon.name, remaining_amount, coupon.pricing_rule) + else: + # Full usage - mark as exhausted + _update_gift_card_balance(coupon.name, 0, coupon.pricing_rule) + + +def _update_gift_card_balance(coupon_name, new_balance, pricing_rule=None): + """ + Update gift card balance in Coupon Code and Pricing Rule. + + Args: + coupon_name: Coupon Code name + new_balance: New balance amount + pricing_rule: Associated Pricing Rule name + """ + try: + # Get current used count and increment it + current_used = frappe.db.get_value("Coupon Code", coupon_name, "used") or 0 + + # Update Coupon Code + frappe.db.set_value( + "Coupon Code", + coupon_name, + { + "gift_card_amount": flt(new_balance), + "used": current_used + 1 + } + ) + + # Update Pricing Rule + if pricing_rule: + frappe.db.set_value( + "Pricing Rule", + pricing_rule, + "discount_amount", + flt(new_balance) + ) + + except Exception as e: + frappe.log_error( + "Gift Card Balance Update Failed", + f"Failed to update gift card {coupon_name}: {str(e)}" + ) + + +# ========================================== +# Gift Card Return/Cancel Handling +# ========================================== + +def _process_gift_card_return(return_invoice): + """ + Process gift card balance restoration when a return (Credit Note) is submitted. + Gets the gift card info from the original invoice and restores the balance. + + Args: + return_invoice: The return invoice document (with is_return=1) + """ + try: + original_invoice_name = return_invoice.return_against + if not original_invoice_name: + return + + # Get the original invoice to find the gift card used + original_invoice = frappe.get_doc(return_invoice.doctype, original_invoice_name) + + # Get coupon code from original invoice + coupon_code = getattr(original_invoice, 'posa_coupon_code', None) or getattr(original_invoice, 'coupon_code', None) + + if not coupon_code: + return + + coupon_code = coupon_code.strip() + + # 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"] + ) + + if not coupon: + return + + # Only process POS Next gift cards + if not coupon.get("pos_next_gift_card"): + return + + # Calculate the refund amount from the return invoice + # Use absolute value since return invoices have negative amounts + # Get the gift card amount used from the original invoice + refund_amount = flt(getattr(original_invoice, 'posa_gift_card_amount_used', 0)) + if not refund_amount: + refund_amount = flt(original_invoice.discount_amount) + + # For partial returns, calculate proportionally + # Compare return net total vs original net total (both are after discount) + original_net = abs(flt(original_invoice.grand_total)) + return_net = abs(flt(return_invoice.grand_total)) + + if original_net > 0 and return_net < original_net: + # Partial return - calculate proportional refund + return_ratio = return_net / original_net + refund_amount = flt(refund_amount * return_ratio) + + if refund_amount <= 0: + return + + current_balance = flt(coupon.gift_card_amount) + original_amount = flt(coupon.original_gift_card_amount) + + new_balance = current_balance + refund_amount + + # Cap at original amount + if original_amount and new_balance > original_amount: + new_balance = original_amount + + # Update balance (don't increment used counter for returns) + frappe.db.set_value( + "Coupon Code", + coupon.name, + "gift_card_amount", + flt(new_balance) + ) + + # Update Pricing Rule + if coupon.pricing_rule: + frappe.db.set_value( + "Pricing Rule", + coupon.pricing_rule, + "discount_amount", + flt(new_balance) + ) + + except Exception as e: + frappe.log_error( + "Gift Card Return Processing Failed", + f"Failed to process gift card return for invoice {return_invoice.name}: {str(e)}" + ) + + +@frappe.whitelist() +def get_gift_cards_from_invoice(invoice_name): + """ + Get gift cards created from a specific invoice. + + Args: + invoice_name: Name of the source invoice + + Returns: + list: Gift cards created from this invoice + """ + if not invoice_name: + return [] + + gift_cards = frappe.get_all( + "Coupon Code", + filters={ + "pos_next_gift_card": 1, + "source_invoice": invoice_name + }, + fields=[ + "name", "coupon_code", "coupon_name", + "gift_card_amount", "original_gift_card_amount", + "valid_from", "valid_upto" + ], + order_by="creation asc" + ) + + return gift_cards + + +@frappe.whitelist() +def process_gift_card_on_cancel(doc, method=None): + """ + Process gift card when invoice is cancelled. + Restores gift card balance. + + Args: + doc: Invoice document or invoice name + method: Hook method name (optional) + """ + if not doc: + return + + if isinstance(doc, str): + if frappe.db.exists("Sales Invoice", doc): + invoice = frappe.get_doc("Sales Invoice", doc) + else: + invoice = frappe.get_doc("POS Invoice", doc) + else: + invoice = doc + + # Get coupon code from invoice + coupon_code = getattr(invoice, 'posa_coupon_code', None) or getattr(invoice, 'coupon_code', None) + + if not coupon_code: + return + + coupon_code = coupon_code.strip() + + # 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"] + ) + + if not coupon: + return + + # Only process POS Next gift cards + if not coupon.get("pos_next_gift_card"): + return + + try: + # Calculate restored balance + # Use posa_gift_card_amount_used if available (persisted field that ERPNext doesn't clear) + refund_amount = flt(getattr(invoice, 'posa_gift_card_amount_used', 0)) + if not refund_amount: + refund_amount = flt(invoice.discount_amount) + + current_balance = flt(coupon.gift_card_amount) + original_amount = flt(coupon.original_gift_card_amount) + + new_balance = current_balance + refund_amount + + # Cap at original amount + if original_amount and new_balance > original_amount: + new_balance = original_amount + + # Update balance + _update_gift_card_balance(coupon.name, new_balance, coupon.pricing_rule) + + except Exception as e: + frappe.log_error( + "Gift Card Cancel Processing Failed", + f"Failed to process gift card cancel for invoice {invoice.name}: {str(e)}" + ) diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 8f5c564c..f72092e5 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -816,8 +816,18 @@ 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() + if invoice_doc.grand_total is None: invoice_doc.grand_total = 0.0 if invoice_doc.base_grand_total is None: @@ -853,25 +863,31 @@ def update_invoice(data): sum(p.base_amount or 0 for p in invoice_doc.payments) ) - # Validate and track POS Coupon if coupon_code is provided + # Validate and track coupon if coupon_code is provided coupon_code = data.get("coupon_code") if coupon_code: - # Validate POS Coupon exists and is valid - if frappe.db.table_exists("POS Coupon"): - from pos_next.pos_next.doctype.pos_coupon.pos_coupon import check_coupon_code - - coupon_result = check_coupon_code( - coupon_code, - customer=invoice_doc.customer, - company=invoice_doc.company - ) + # Validate coupon exists using ERPNext Coupon Code + from pos_next.api.offers import validate_coupon + + coupon_result = validate_coupon( + coupon_code, + customer=invoice_doc.customer, + company=invoice_doc.company + ) + + if not coupon_result or not coupon_result.get("valid"): + error_msg = coupon_result.get("message", _("Invalid coupon code")) if coupon_result else _("Invalid coupon code") + frappe.throw(error_msg) - if not coupon_result or not coupon_result.get("valid"): - error_msg = coupon_result.get("msg", "Invalid coupon code") if coupon_result else "Invalid coupon code" - frappe.throw(_(error_msg)) + # 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 coupon code on invoice for tracking - invoice_doc.coupon_code = 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 @@ -1246,19 +1262,7 @@ def submit_invoice(invoice=None, data=None): "allocated_percentage": member.get("allocated_percentage", 0), }) - # Handle POS Coupon if coupon_code is provided - coupon_code = invoice.get("coupon_code") or data.get("coupon_code") - if coupon_code: - # Increment usage counter for POS Coupon - if frappe.db.table_exists("POS Coupon"): - try: - from pos_next.pos_next.doctype.pos_coupon.pos_coupon import increment_coupon_usage - increment_coupon_usage(coupon_code) - except Exception as e: - frappe.log_error( - title="Failed to increment coupon usage", - message=f"Coupon: {coupon_code}, Error: {str(e)}" - ) + # Note: Coupon usage tracking is handled by the gift_cards.process_gift_card_on_submit hook # Auto-set batch numbers for returns _auto_set_return_batches(invoice_doc) @@ -1310,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() diff --git a/pos_next/api/offers.py b/pos_next/api/offers.py index cd7a3b94..d5f9e742 100644 --- a/pos_next/api/offers.py +++ b/pos_next/api/offers.py @@ -523,56 +523,121 @@ def _get_standalone_pricing_rule_offers(company: str, date: str) -> List[Offer]: # ============================================================================ @frappe.whitelist() -def get_active_coupons(customer: str, company: str) -> List[Dict]: - """Get active gift card coupons for a customer""" - if not frappe.db.table_exists("POS Coupon"): - return [] +def get_active_coupons(customer: str = None, company: str = None) -> List[Dict]: + """ + Get active gift card coupons available for use. + + Returns gift cards (ERPNext Coupon Code with pos_next_gift_card=1) that are: + - Assigned to the customer, OR + - Anonymous (no customer assigned) + - Have remaining balance > 0 + - Are within validity dates + """ + today = getdate(nowdate()) + + # Build SQL query for ERPNext Coupon Code with gift card custom fields + # Get both customer-specific and anonymous gift cards + coupons = frappe.db.sql(""" + SELECT + cc.name, + cc.coupon_code, + cc.coupon_name, + cc.customer, + cc.valid_from, + cc.valid_upto, + cc.used, + cc.maximum_use, + cc.gift_card_amount, + cc.original_gift_card_amount, + cc.source_invoice, + pr.company + FROM `tabCoupon Code` cc + LEFT JOIN `tabPricing Rule` pr ON cc.pricing_rule = pr.name + WHERE + cc.pos_next_gift_card = 1 + AND (pr.company = %(company)s OR pr.company IS NULL) + AND (cc.customer = %(customer)s OR cc.customer IS NULL OR cc.customer = '') + AND (cc.valid_from IS NULL OR cc.valid_from <= %(today)s) + AND (cc.valid_upto IS NULL OR cc.valid_upto >= %(today)s) + """, {"company": company, "customer": customer or "", "today": today}, as_dict=1) + + valid_cards = [] + for card in coupons: + # Check usage limits + if card.used and card.maximum_use and card.used >= card.maximum_use: + continue - coupons = frappe.get_all( - "POS Coupon", - filters={ - "company": company, - "coupon_type": "Gift Card", - "customer": customer, - "used": 0, - }, - fields=["name", "coupon_code", "coupon_name", "valid_from", "valid_upto"], - ) + # Check balance + balance = flt(card.gift_card_amount) + if balance <= 0: + continue - return coupons + # Get customer name if customer is set + customer_name = None + if card.customer: + customer_name = frappe.db.get_value("Customer", card.customer, "customer_name") + + # Add balance and format response for frontend compatibility + valid_cards.append({ + "name": card.name, + "coupon_code": card.coupon_code, + "coupon_name": card.coupon_name or card.coupon_code, + "customer": card.customer, + "customer_name": customer_name, + "gift_card_amount": card.gift_card_amount, + "original_amount": card.original_gift_card_amount, + "balance": balance, + "valid_from": card.valid_from, + "valid_upto": card.valid_upto, + "used": card.used, + "maximum_use": card.maximum_use, + "source_invoice": card.source_invoice, + "company": card.company, + }) + + return valid_cards @frappe.whitelist() -def validate_coupon(coupon_code: str, customer: str, company: str) -> Dict: - """Validate a coupon code and return its details""" - if not frappe.db.table_exists("POS Coupon"): - return {"valid": False, "message": _("Coupons are not enabled")} +def validate_coupon(coupon_code: str, customer: str = None, company: str = None) -> Dict: + """ + Validate a coupon code and return its details. + Works with ERPNext Coupon Code directly. + For gift cards (pos_next_gift_card=1), also checks balance and supports splitting. + """ date = getdate() - # Fetch coupon with case-insensitive code matching - # Note: coupon_code field is unique, so we can fetch directly - coupon = frappe.db.get_value( - "POS Coupon", - {"coupon_code": coupon_code, "company": company}, - ["*"], - as_dict=1 - ) + # Fetch ERPNext Coupon Code with case-insensitive code matching + coupon = frappe.db.sql(""" + SELECT + cc.name, + cc.coupon_code, + cc.coupon_name, + cc.coupon_type, + cc.customer, + cc.valid_from, + cc.valid_upto, + cc.used, + cc.maximum_use, + cc.pricing_rule, + cc.pos_next_gift_card, + cc.gift_card_amount, + cc.original_gift_card_amount, + cc.source_invoice, + pr.company, + pr.discount_amount as pricing_rule_discount + FROM `tabCoupon Code` cc + LEFT JOIN `tabPricing Rule` pr ON cc.pricing_rule = pr.name + WHERE + UPPER(cc.coupon_code) = %(coupon_code)s + AND (pr.company = %(company)s OR pr.company IS NULL) + """, {"coupon_code": coupon_code.upper(), "company": company}, as_dict=1) if not coupon: return {"valid": False, "message": _("Invalid coupon code")} - if coupon.disabled: - return {"valid": False, "message": _("This coupon is disabled")} - - # Check usage limits - if coupon.coupon_type == "Gift Card": - if coupon.used: - return {"valid": False, "message": _("This gift card has already been used")} - else: - # Promotional coupons - if coupon.maximum_use > 0 and coupon.used >= coupon.maximum_use: - return {"valid": False, "message": _("This coupon has reached its usage limit")} + coupon = coupon[0] # Check validity dates if coupon.valid_from and coupon.valid_from > date: @@ -581,11 +646,92 @@ def validate_coupon(coupon_code: str, customer: str, company: str) -> Dict: if coupon.valid_upto and coupon.valid_upto < date: return {"valid": False, "message": _("This coupon has expired")} - # Check customer restriction + # Check customer restriction - gift cards with no customer can be used by anyone if coupon.customer and coupon.customer != customer: return {"valid": False, "message": _("This coupon is not valid for this customer")} - return { - "valid": True, - "coupon": coupon - } + # POS Next Gift Card specific validations + if coupon.pos_next_gift_card: + # Check balance + balance = flt(coupon.gift_card_amount) + if balance <= 0: + return {"valid": False, "message": _("This gift card has no remaining balance")} + + # Get customer name if customer is set + customer_name = None + if coupon.customer: + customer_name = frappe.db.get_value("Customer", coupon.customer, "customer_name") + + # Format response for frontend compatibility + return { + "valid": True, + "coupon": { + "name": coupon.name, + "coupon_code": coupon.coupon_code, + "coupon_name": coupon.coupon_name or coupon.coupon_code, + "coupon_type": "Gift Card", # Keep as Gift Card for frontend display + "customer": coupon.customer, + "customer_name": customer_name, + "gift_card_amount": coupon.gift_card_amount, + "original_amount": coupon.original_gift_card_amount, + "balance": balance, + "discount_amount": balance, + "valid_from": coupon.valid_from, + "valid_upto": coupon.valid_upto, + "used": coupon.used, + "maximum_use": coupon.maximum_use, + "is_gift_card": True, + "pricing_rule": coupon.pricing_rule, + "company": coupon.company, + } + } + else: + # Standard promotional coupons - check usage limits + if coupon.maximum_use and coupon.maximum_use > 0 and coupon.used >= coupon.maximum_use: + return {"valid": False, "message": _("This coupon has reached its usage limit")} + + # Fetch discount details from the linked Pricing Rule + discount_type = None + discount_percentage = 0 + discount_amount = 0 + + if coupon.pricing_rule: + pr = frappe.db.get_value( + "Pricing Rule", + coupon.pricing_rule, + ["rate_or_discount", "discount_percentage", "discount_amount"], + as_dict=True + ) + if pr: + # Map Pricing Rule values to frontend expected values + # "Discount Percentage" -> "Percentage", "Discount Amount" -> "Amount" + rate_or_discount = pr.rate_or_discount or "" + if "Percentage" in rate_or_discount: + discount_type = "Percentage" + elif "Amount" in rate_or_discount: + discount_type = "Amount" + else: + discount_type = rate_or_discount + discount_percentage = flt(pr.discount_percentage) + discount_amount = flt(pr.discount_amount) + + return { + "valid": True, + "coupon": { + "name": coupon.name, + "coupon_code": coupon.coupon_code, + "coupon_name": coupon.coupon_name, + "coupon_type": coupon.coupon_type, + "customer": coupon.customer, + "valid_from": coupon.valid_from, + "valid_upto": coupon.valid_upto, + "used": coupon.used, + "maximum_use": coupon.maximum_use, + "is_gift_card": False, + "pricing_rule": coupon.pricing_rule, + "company": coupon.company, + "discount_type": discount_type, + "discount_percentage": discount_percentage, + "discount_amount": discount_amount, + } + } diff --git a/pos_next/api/promotions.py b/pos_next/api/promotions.py index d286a17c..2a27e55a 100644 --- a/pos_next/api/promotions.py +++ b/pos_next/api/promotions.py @@ -549,52 +549,57 @@ def search_items(search_term, pos_profile=None, limit=20): # ==================== COUPON MANAGEMENT ==================== +# Uses ERPNext Coupon Code directly for native integration @frappe.whitelist() def get_coupons(company=None, include_disabled=False, coupon_type=None): - """Get all coupons for the company with enhanced filtering.""" - check_promotion_permissions("read") - - filters = {} - - if company: - filters["company"] = company - - # Check if disabled field exists before filtering - has_disabled_field = frappe.db.has_column("POS Coupon", "disabled") + """Get all coupons for the company with enhanced filtering. - if not include_disabled and has_disabled_field: - filters["disabled"] = 0 - - if coupon_type: - filters["coupon_type"] = coupon_type - - # Build field list - only include fields that exist - fields = [ - "name", "coupon_name", "coupon_code", "coupon_type", - "customer", "customer_name", - "valid_from", "valid_upto", "maximum_use", "used", - "one_use", "company", "campaign" - ] - - # Check for optional fields - if has_disabled_field: - fields.append("disabled") - - coupons = frappe.get_all( - "POS Coupon", - filters=filters, - fields=fields, - order_by="modified desc" - ) + Uses ERPNext Coupon Code doctype directly for native integration. + """ + check_promotion_permissions("read") - # Enrich with status today = getdate(nowdate()) + # Query ERPNext Coupon Code with Pricing Rule join for company filter + coupons = frappe.db.sql(""" + SELECT + cc.name, + cc.coupon_name, + cc.coupon_code, + cc.coupon_type, + cc.customer, + cc.valid_from, + cc.valid_upto, + cc.maximum_use, + cc.used, + cc.pos_next_gift_card, + cc.gift_card_amount, + pr.company, + pr.disable as disabled + FROM `tabCoupon Code` cc + LEFT JOIN `tabPricing Rule` pr ON cc.pricing_rule = pr.name + WHERE + (pr.company = %(company)s OR pr.company IS NULL OR %(company)s IS NULL) + AND (%(include_disabled)s = 1 OR pr.disable = 0 OR pr.disable IS NULL) + AND (%(coupon_type)s IS NULL OR cc.coupon_type = %(coupon_type)s) + ORDER BY cc.modified DESC + """, { + "company": company, + "include_disabled": 1 if include_disabled else 0, + "coupon_type": coupon_type + }, as_dict=True) + + # Enrich with status and customer name for coupon in coupons: - # Set disabled to 0 if field doesn't exist - if not has_disabled_field: - coupon["disabled"] = 0 + # Get customer name + if coupon.customer: + coupon["customer_name"] = frappe.db.get_value("Customer", coupon.customer, "customer_name") + else: + coupon["customer_name"] = None + + # Set disabled default + coupon["disabled"] = coupon.get("disabled") or 0 # Calculate status - disabled takes precedence if coupon.get("disabled"): @@ -619,22 +624,49 @@ def get_coupons(company=None, include_disabled=False, coupon_type=None): @frappe.whitelist() def get_coupon_details(coupon_name): - """Get detailed information about a specific coupon.""" + """Get detailed information about a specific coupon. + + Uses ERPNext Coupon Code doctype directly. + """ check_promotion_permissions("read") - if not frappe.db.exists("POS Coupon", coupon_name): + if not frappe.db.exists("Coupon Code", coupon_name): frappe.throw(_("Coupon {0} not found").format(coupon_name)) - coupon = frappe.get_doc("POS Coupon", coupon_name) + coupon = frappe.get_doc("Coupon Code", coupon_name) data = coupon.as_dict() + # Add company from linked Pricing Rule + if coupon.pricing_rule: + data["company"] = frappe.db.get_value("Pricing Rule", coupon.pricing_rule, "company") + + # Add customer name + if coupon.customer: + data["customer_name"] = frappe.db.get_value("Customer", coupon.customer, "customer_name") + + # Add discount info from Pricing Rule + if coupon.pricing_rule: + pr = frappe.db.get_value( + "Pricing Rule", + coupon.pricing_rule, + ["rate_or_discount", "discount_percentage", "discount_amount", "min_amt", "max_amt"], + as_dict=True + ) + if pr: + data["discount_type"] = "Percentage" if pr.rate_or_discount == "Discount Percentage" else "Amount" + data["discount_percentage"] = pr.discount_percentage or 0 + data["discount_amount"] = pr.discount_amount or 0 + data["min_amount"] = pr.min_amt or 0 + data["max_amount"] = pr.max_amt or 0 + data["apply_on"] = "Grand Total" # Default for POS + return data @frappe.whitelist() def create_coupon(data): """ - Create a new coupon. + Create a new coupon using ERPNext Coupon Code + Pricing Rule. Input format: { @@ -648,7 +680,7 @@ def create_coupon(data): "max_amount": 200, # Optional - Maximum discount cap "apply_on": "Grand Total", # Grand Total or Net Total "company": "Company Name", - "customer": "CUST-001", # Required for Gift Card + "customer": "CUST-001", # Optional for Gift Card "valid_from": "2025-01-01", "valid_upto": "2025-12-31", "maximum_use": 100, # Optional @@ -684,55 +716,79 @@ def create_coupon(data): if flt(data.get("discount_amount")) <= 0: frappe.throw(_("Discount amount must be greater than 0")) - # Validate Gift Card requires customer - if data.get("coupon_type") == "Gift Card" and not data.get("customer"): - frappe.throw(_("Customer is required for Gift Card coupons")) - try: - # Create coupon - coupon = frappe.new_doc("POS Coupon") - coupon.update({ - "coupon_name": data.get("coupon_name"), - "coupon_type": data.get("coupon_type"), - "coupon_code": data.get("coupon_code"), # Will auto-generate if empty - "discount_type": data.get("discount_type"), - "discount_percentage": flt(data.get("discount_percentage")) if data.get("discount_type") == "Percentage" else None, - "discount_amount": flt(data.get("discount_amount")) if data.get("discount_type") == "Amount" else None, - "min_amount": flt(data.get("min_amount")) if data.get("min_amount") else None, - "max_amount": flt(data.get("max_amount")) if data.get("max_amount") else None, - "apply_on": data.get("apply_on", "Grand Total"), + # Generate coupon code if not provided + coupon_code = data.get("coupon_code") + if not coupon_code: + import random + import string + coupon_code = "".join(random.choices(string.ascii_uppercase + string.digits, k=8)) + + # Create Pricing Rule first + pricing_rule = frappe.get_doc({ + "doctype": "Pricing Rule", + "title": f"Coupon - {coupon_code}", + "apply_on": "Transaction", + "price_or_product_discount": "Price", + "rate_or_discount": "Discount Percentage" if data.get("discount_type") == "Percentage" else "Discount Amount", + "discount_percentage": flt(data.get("discount_percentage")) if data.get("discount_type") == "Percentage" else 0, + "discount_amount": flt(data.get("discount_amount")) if data.get("discount_type") == "Amount" else 0, + "min_amt": flt(data.get("min_amount")) if data.get("min_amount") else 0, + "max_amt": flt(data.get("max_amount")) if data.get("max_amount") else 0, + "selling": 1, + "buying": 0, "company": data.get("company"), - "customer": data.get("customer"), - "valid_from": data.get("valid_from"), + "coupon_code_based": 1, + "valid_from": data.get("valid_from") or nowdate(), "valid_upto": data.get("valid_upto"), - "maximum_use": cint(data.get("maximum_use", 0)) or None, - "one_use": cint(data.get("one_use", 0)), - "campaign": data.get("campaign"), + "priority": 1, + "disable": 0, }) + pricing_rule.insert(ignore_permissions=True) - coupon.insert() + # Create Coupon Code + coupon = frappe.get_doc({ + "doctype": "Coupon Code", + "coupon_name": data.get("coupon_name"), + "coupon_code": coupon_code, + "coupon_type": data.get("coupon_type"), + "pricing_rule": pricing_rule.name, + "valid_from": data.get("valid_from") or nowdate(), + "valid_upto": data.get("valid_upto"), + "maximum_use": cint(data.get("maximum_use", 0)) or 0, + "used": 0, + "customer": data.get("customer"), + # Gift card custom fields + "pos_next_gift_card": 1 if data.get("coupon_type") == "Gift Card" else 0, + "gift_card_amount": flt(data.get("discount_amount")) if data.get("coupon_type") == "Gift Card" else 0, + "original_gift_card_amount": flt(data.get("discount_amount")) if data.get("coupon_type") == "Gift Card" else 0, + }) + coupon.insert(ignore_permissions=True) return { "success": True, "message": _("Coupon {0} created successfully").format(coupon.coupon_code), - "coupon_name": coupon.name, + "name": coupon.name, "coupon_code": coupon.coupon_code } except Exception as e: frappe.db.rollback() frappe.log_error( - title=_("Coupon Creation Failed"), - message=frappe.get_traceback() + "Coupon Creation Failed", + frappe.get_traceback() ) frappe.throw(_("Failed to create coupon: {0}").format(str(e))) @frappe.whitelist() -def update_coupon(coupon_name, data): +def update_coupon(data): """ - Update an existing coupon. + Update an existing coupon (ERPNext Coupon Code + Pricing Rule). Can update validity dates, usage limits, disabled status, and discount configuration. + + Args: + data: JSON string or dict containing 'name' and fields to update """ check_promotion_permissions("write") @@ -740,41 +796,46 @@ def update_coupon(coupon_name, data): if isinstance(data, str): data = json.loads(data) - if not frappe.db.exists("POS Coupon", coupon_name): + coupon_name = data.get("name") + if not coupon_name: + frappe.throw(_("Coupon name is required")) + + if not frappe.db.exists("Coupon Code", coupon_name): frappe.throw(_("Coupon {0} not found").format(coupon_name)) try: - coupon = frappe.get_doc("POS Coupon", coupon_name) - - # Update discount fields - if "discount_type" in data: - coupon.discount_type = data["discount_type"] - if "discount_percentage" in data: - coupon.discount_percentage = flt(data["discount_percentage"]) if data["discount_percentage"] else None - if "discount_amount" in data: - coupon.discount_amount = flt(data["discount_amount"]) if data["discount_amount"] else None - if "min_amount" in data: - coupon.min_amount = flt(data["min_amount"]) if data["min_amount"] else None - if "max_amount" in data: - coupon.max_amount = flt(data["max_amount"]) if data["max_amount"] else None - if "apply_on" in data: - coupon.apply_on = data["apply_on"] - - # Update validity and usage fields + coupon = frappe.get_doc("Coupon Code", coupon_name) + + # Update Coupon Code fields if "valid_from" in data: coupon.valid_from = data["valid_from"] if "valid_upto" in data: coupon.valid_upto = data["valid_upto"] if "maximum_use" in data: - coupon.maximum_use = cint(data["maximum_use"]) or None - if "one_use" in data: - coupon.one_use = cint(data["one_use"]) - if "disabled" in data: - coupon.disabled = cint(data["disabled"]) - if "description" in data: - coupon.description = data["description"] - - coupon.save() + coupon.maximum_use = cint(data["maximum_use"]) or 0 + + coupon.save(ignore_permissions=True) + + # Update linked Pricing Rule for discount fields + if coupon.pricing_rule: + pr = frappe.get_doc("Pricing Rule", coupon.pricing_rule) + + if "discount_type" in data: + pr.rate_or_discount = "Discount Percentage" if data["discount_type"] == "Percentage" else "Discount Amount" + if "discount_percentage" in data: + pr.discount_percentage = flt(data["discount_percentage"]) if data["discount_percentage"] else 0 + if "discount_amount" in data: + pr.discount_amount = flt(data["discount_amount"]) if data["discount_amount"] else 0 + if "min_amount" in data: + pr.min_amt = flt(data["min_amount"]) if data["min_amount"] else 0 + if "max_amount" in data: + pr.max_amt = flt(data["max_amount"]) if data["max_amount"] else 0 + if "valid_from" in data: + pr.valid_from = data["valid_from"] + if "valid_upto" in data: + pr.valid_upto = data["valid_upto"] + + pr.save(ignore_permissions=True) return { "success": True, @@ -784,64 +845,78 @@ def update_coupon(coupon_name, data): except Exception as e: frappe.db.rollback() frappe.log_error( - title=_("Coupon Update Failed"), - message=frappe.get_traceback() + "Coupon Update Failed", + frappe.get_traceback() ) frappe.throw(_("Failed to update coupon: {0}").format(str(e))) @frappe.whitelist() def toggle_coupon(coupon_name, disabled=None): - """Enable or disable a coupon.""" + """Enable or disable a coupon by toggling its linked Pricing Rule.""" check_promotion_permissions("write") - if not frappe.db.exists("POS Coupon", coupon_name): + if not frappe.db.exists("Coupon Code", coupon_name): frappe.throw(_("Coupon {0} not found").format(coupon_name)) try: - coupon = frappe.get_doc("POS Coupon", coupon_name) + coupon = frappe.get_doc("Coupon Code", coupon_name) + + if not coupon.pricing_rule: + frappe.throw(_("Coupon {0} has no linked Pricing Rule").format(coupon_name)) + + # Toggle the Pricing Rule disable status + pr = frappe.get_doc("Pricing Rule", coupon.pricing_rule) if disabled is not None: - coupon.disabled = cint(disabled) + pr.disable = cint(disabled) else: # Toggle current state - coupon.disabled = 0 if coupon.disabled else 1 + pr.disable = 0 if pr.disable else 1 - coupon.save() + pr.save(ignore_permissions=True) - status = "disabled" if coupon.disabled else "enabled" + status = "disabled" if pr.disable else "enabled" return { "success": True, "message": _("Coupon {0} {1}").format(coupon.coupon_code, status), - "disabled": coupon.disabled + "disabled": pr.disable } except Exception as e: frappe.db.rollback() frappe.log_error( - title=_("Coupon Toggle Failed"), - message=frappe.get_traceback() + "Coupon Toggle Failed", + frappe.get_traceback() ) frappe.throw(_("Failed to toggle coupon: {0}").format(str(e))) @frappe.whitelist() def delete_coupon(coupon_name): - """Delete a coupon.""" + """Delete a coupon (ERPNext Coupon Code and its linked Pricing Rule).""" check_promotion_permissions("delete") - if not frappe.db.exists("POS Coupon", coupon_name): + if not frappe.db.exists("Coupon Code", coupon_name): frappe.throw(_("Coupon {0} not found").format(coupon_name)) try: # Check if coupon has been used - coupon = frappe.get_doc("POS Coupon", coupon_name) + coupon = frappe.get_doc("Coupon Code", coupon_name) if coupon.used > 0: frappe.throw(_("Cannot delete coupon {0} as it has been used {1} times").format( coupon.coupon_code, coupon.used )) - frappe.delete_doc("POS Coupon", coupon_name) + # Store pricing rule name before deletion + pricing_rule_name = coupon.pricing_rule + + # Delete Coupon Code first + frappe.delete_doc("Coupon Code", coupon_name, ignore_permissions=True) + + # Delete linked Pricing Rule + if pricing_rule_name and frappe.db.exists("Pricing Rule", pricing_rule_name): + frappe.delete_doc("Pricing Rule", pricing_rule_name, ignore_permissions=True) return { "success": True, @@ -851,8 +926,8 @@ def delete_coupon(coupon_name): except Exception as e: frappe.db.rollback() frappe.log_error( - title=_("Coupon Deletion Failed"), - message=frappe.get_traceback() + "Coupon Deletion Failed", + frappe.get_traceback() ) frappe.throw(_("Failed to delete coupon: {0}").format(str(e))) @@ -929,17 +1004,26 @@ def get_referral_details(referral_name): referral = frappe.get_doc("Referral Code", referral_name) data = referral.as_dict() - # Get generated coupons for this referral + # Get generated coupons for this referral (now using ERPNext Coupon Code) coupons = frappe.get_all( - "POS Coupon", + "Coupon Code", filters={"referral_code": referral_name}, fields=[ - "name", "coupon_code", "coupon_type", "customer", "customer_name", - "used", "valid_from", "valid_upto", "disabled" + "name", "coupon_code", "coupon_type", "customer", + "used", "valid_from", "valid_upto", "maximum_use" ], order_by="creation desc" ) + # Add customer_name and used status for each coupon + for coupon in coupons: + if coupon.customer: + coupon["customer_name"] = frappe.db.get_value("Customer", coupon.customer, "customer_name") + else: + coupon["customer_name"] = None + # Coupon is disabled/used if used >= maximum_use + coupon["disabled"] = coupon.used >= (coupon.maximum_use or 99999999) + data["generated_coupons"] = coupons data["total_coupons_generated"] = len(coupons) 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/api/wallet.py b/pos_next/api/wallet.py index 419ee135..8a11dbe7 100644 --- a/pos_next/api/wallet.py +++ b/pos_next/api/wallet.py @@ -43,47 +43,58 @@ def process_loyalty_to_wallet(doc, method=None): Convert earned loyalty points to wallet balance after invoice submission. Called during on_submit hook. """ - if not doc.is_pos or doc.is_return: - return + try: + if not doc.is_pos or doc.is_return: + return - # Check if loyalty to wallet is enabled - pos_settings = get_pos_settings(doc.pos_profile) - if not pos_settings: - return + # Check if loyalty to wallet is enabled + pos_settings = get_pos_settings(doc.pos_profile) + if not pos_settings: + return - if not cint(pos_settings.get("enable_loyalty_program")) or not cint(pos_settings.get("loyalty_to_wallet")): - return + if not cint(pos_settings.get("enable_loyalty_program")) or not cint(pos_settings.get("loyalty_to_wallet")): + return - # Check if customer has loyalty program - loyalty_program = frappe.db.get_value("Customer", doc.customer, "loyalty_program") - if not loyalty_program: - return + # Check if wallet account is properly configured before proceeding + wallet_account = pos_settings.get("wallet_account") + if wallet_account: + account_type = frappe.db.get_value("Account", wallet_account, "account_type") + if account_type != "Receivable": + frappe.log_error( + "Wallet Account Configuration Error", + f"Wallet account {wallet_account} is not a Receivable type. Skipping loyalty to wallet conversion." + ) + return + + # Check if customer has loyalty program + loyalty_program = frappe.db.get_value("Customer", doc.customer, "loyalty_program") + if not loyalty_program: + return - # Get the loyalty points earned from this invoice - loyalty_entry = frappe.db.get_value( - "Loyalty Point Entry", - { - "invoice_type": "Sales Invoice", - "invoice": doc.name, - "loyalty_points": [">", 0] - }, - ["loyalty_points", "name"], - as_dict=True - ) + # Get the loyalty points earned from this invoice + loyalty_entry = frappe.db.get_value( + "Loyalty Point Entry", + { + "invoice_type": "Sales Invoice", + "invoice": doc.name, + "loyalty_points": [">", 0] + }, + ["loyalty_points", "name"], + as_dict=True + ) - if not loyalty_entry or loyalty_entry.loyalty_points <= 0: - return + if not loyalty_entry or loyalty_entry.loyalty_points <= 0: + return - # Get conversion rate from Loyalty Program (standard ERPNext field) - conversion_rate = flt(frappe.db.get_value("Loyalty Program", loyalty_program, "conversion_factor")) or 1.0 + # Get conversion rate from Loyalty Program (standard ERPNext field) + conversion_rate = flt(frappe.db.get_value("Loyalty Program", loyalty_program, "conversion_factor")) or 1.0 - # Calculate wallet credit amount - credit_amount = flt(loyalty_entry.loyalty_points) * conversion_rate + # Calculate wallet credit amount + credit_amount = flt(loyalty_entry.loyalty_points) * conversion_rate - if credit_amount <= 0: - return + if credit_amount <= 0: + return - try: # Get or create customer wallet wallet = get_or_create_wallet(doc.customer, doc.company, pos_settings) diff --git a/pos_next/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json index c0a8fb93..34241cf8 100644 --- a/pos_next/fixtures/custom_field.json +++ b/pos_next/fixtures/custom_field.json @@ -170,6 +170,120 @@ "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": "Legacy coupon code field for gift card tracking (deprecated, use coupon_code instead)", + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "posa_coupon_code", + "fieldtype": "Data", + "hidden": 1, + "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": "posa_is_printed", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Coupon Code (Legacy)", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-02-05 10:00:00", + "module": "POS Next", + "name": "Sales Invoice-posa_coupon_code", + "no_copy": 1, + "non_negative": 0, + "options": null, + "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, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": "Amount used from gift card for this invoice (persisted for balance tracking)", + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "posa_gift_card_amount_used", + "fieldtype": "Currency", + "hidden": 1, + "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": "posa_coupon_code", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Gift Card Amount Used", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-01-14 16:00:00", + "module": "POS Next", + "name": "Sales Invoice-posa_gift_card_amount_used", + "no_copy": 1, + "non_negative": 0, + "options": null, + "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, @@ -511,5 +625,404 @@ "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.pos_next_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": "2026-01-13 15: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": "0", + "depends_on": null, + "description": "This gift card is managed by POS Next", + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Coupon Code", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "pos_next_gift_card", + "fieldtype": "Check", + "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": 1, + "insert_after": "pos_next_section", + "is_system_generated": 0, + "is_virtual": 0, + "label": "POS Next Gift Card", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-01-13 15:00:00.000000", + "module": "POS Next", + "name": "Coupon Code-pos_next_gift_card", + "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.pos_next_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_gift_card", + "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.pos_next_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": "Invoice that created this gift card (Sales Invoice or POS Invoice)", + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Coupon Code", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "source_invoice", + "fieldtype": "Data", + "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": "Source Invoice", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-01-13 15:00:00.000000", + "module": "POS Next", + "name": "Coupon Code-source_invoice", + "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": 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": "Referral code that generated this coupon", + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Coupon Code", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "referral_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": 1, + "insert_after": "source_invoice", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Referral Code", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-01-14 12:00:00.000000", + "module": "POS Next", + "name": "Coupon Code-referral_code", + "no_copy": 0, + "non_negative": 0, + "options": "Referral 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 } ] diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 20013ef7..3fea097a 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -1,5 +1,26 @@ 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 + import importlib + erpnext_mod = importlib.import_module("erpnext") + erpnext_dir = os.path.dirname(erpnext_mod.__file__) + si_json_path = os.path.join( + erpnext_dir, "accounts", "doctype", "sales_invoice", "sales_invoice.json" + ) + 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" @@ -49,7 +70,7 @@ # include js in doctype views # doctype_js = {"doctype" : "public/js/doctype.js"} -# doctype_list_js = {"doctype" : "public/js/doctype_list.js"} +doctype_list_js = {"Coupon Code": "public/js/coupon_code_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} @@ -86,6 +107,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,15 +136,7 @@ [ "name", "in", - [ - "Sales Invoice-posa_pos_opening_shift", - "Sales Invoice-posa_is_printed", - "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" - ] + _custom_field_names, ] ] }, @@ -212,16 +247,31 @@ "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.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.wallet.process_loyalty_to_wallet" + "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": { + "on_submit": [ + "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.gift_cards.process_gift_card_on_cancel" + }, "POS Profile": { "on_update": "pos_next.realtime_events.emit_pos_profile_updated_event" } 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. diff --git a/pos_next/patches.txt b/pos_next/patches.txt index 6c51d983..89c04070 100644 --- a/pos_next/patches.txt +++ b/pos_next/patches.txt @@ -4,4 +4,5 @@ [post_model_sync] # Patches added in this section will be executed after doctypes are migrated -pos_next.patches.v1_7_0.reinstall_workspace \ No newline at end of file +pos_next.patches.v1_7_0.reinstall_workspace +pos_next.patches.v2_0_0.migrate_pos_coupons_to_erpnext \ No newline at end of file diff --git a/pos_next/patches/v2_0_0/migrate_pos_coupons_to_erpnext.py b/pos_next/patches/v2_0_0/migrate_pos_coupons_to_erpnext.py new file mode 100644 index 00000000..f2326304 --- /dev/null +++ b/pos_next/patches/v2_0_0/migrate_pos_coupons_to_erpnext.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025, POS Next and contributors +# For license information, please see license.txt + +""" +Migration patch: POS Coupon → ERPNext Coupon Code + +This patch migrates all POS Coupon gift cards to native ERPNext Coupon Code +with custom fields for gift card tracking. After migration, POS Coupon doctype +is no longer required for gift card functionality. +""" + +import frappe +from frappe import _ +from frappe.utils import nowdate, add_months, getdate, flt + + +def execute(): + """Migrate POS Coupons to ERPNext Coupon Code with Pricing Rules.""" + + # Check if POS Coupon table exists + if not frappe.db.table_exists("POS Coupon"): + frappe.logger().info("POS Coupon table does not exist, skipping migration") + return + + # Get all POS Coupons that are gift cards + pos_coupons = frappe.db.sql(""" + SELECT * + FROM `tabPOS Coupon` + WHERE coupon_type = 'Gift Card' + AND disabled = 0 + """, as_dict=True) + + if not pos_coupons: + frappe.logger().info("No POS Coupon gift cards found to migrate") + return + + migrated_count = 0 + skipped_count = 0 + + for pos_coupon in pos_coupons: + try: + # Check if already migrated (Coupon Code with same code exists) + existing_coupon = frappe.db.exists( + "Coupon Code", + {"coupon_code": pos_coupon.coupon_code} + ) + + if existing_coupon: + # Update existing coupon with gift card fields if not set + _update_existing_coupon(existing_coupon, pos_coupon) + skipped_count += 1 + continue + + # Create new Pricing Rule and Coupon Code + _create_erpnext_coupon(pos_coupon) + migrated_count += 1 + + except Exception as e: + frappe.log_error( + f"Migration failed for {pos_coupon.coupon_code}", + f"Error migrating POS Coupon {pos_coupon.name}: {str(e)}\n\n{frappe.get_traceback()}" + ) + + frappe.logger().info( + f"POS Coupon migration complete: {migrated_count} migrated, {skipped_count} already existed" + ) + + +def _update_existing_coupon(coupon_name: str, pos_coupon: dict): + """Update existing Coupon Code with gift card custom fields. + + Args: + coupon_name: Name of existing Coupon Code + pos_coupon: Original POS Coupon data + """ + # Get current values + current = frappe.db.get_value( + "Coupon Code", + coupon_name, + ["pos_next_gift_card", "gift_card_amount"], + as_dict=True + ) + + # Only update if not already marked as POS Next gift card + if current and not current.pos_next_gift_card: + balance = flt(pos_coupon.gift_card_amount) if pos_coupon.gift_card_amount else flt(pos_coupon.discount_amount) + + frappe.db.set_value( + "Coupon Code", + coupon_name, + { + "pos_next_gift_card": 1, + "gift_card_amount": balance, + "original_gift_card_amount": flt(pos_coupon.original_amount) or balance, + "source_invoice": pos_coupon.source_invoice, + }, + update_modified=False + ) + + +def _create_erpnext_coupon(pos_coupon: dict): + """Create ERPNext Coupon Code + Pricing Rule from POS Coupon. + + Args: + pos_coupon: POS Coupon data to migrate + """ + # Determine the balance + balance = flt(pos_coupon.gift_card_amount) if pos_coupon.gift_card_amount else flt(pos_coupon.discount_amount) + original_amount = flt(pos_coupon.original_amount) or balance + + # Create Pricing Rule + pricing_rule_name = f"Gift Card - {pos_coupon.coupon_code}" + + pricing_rule = frappe.get_doc({ + "doctype": "Pricing Rule", + "title": pricing_rule_name, + "apply_on": "Transaction", + "price_or_product_discount": "Price", + "rate_or_discount": "Discount Amount", + "discount_amount": balance, + "selling": 1, + "buying": 0, + "applicable_for": "", + "company": pos_coupon.company, + "currency": frappe.db.get_default("currency") or "CHF", + "coupon_code_based": 1, + "valid_from": pos_coupon.valid_from or getdate(nowdate()), + "valid_upto": pos_coupon.valid_upto or add_months(getdate(nowdate()), 12), + "priority": 1, + "disable": 0, + }) + pricing_rule.insert(ignore_permissions=True) + + # Create Coupon Code + coupon_code = frappe.get_doc({ + "doctype": "Coupon Code", + "coupon_name": pos_coupon.coupon_name or f"Gift Card {pos_coupon.coupon_code}", + "coupon_code": pos_coupon.coupon_code, + "coupon_type": "Promotional", + "pricing_rule": pricing_rule.name, + "valid_from": pos_coupon.valid_from or getdate(nowdate()), + "valid_upto": pos_coupon.valid_upto or add_months(getdate(nowdate()), 12), + "maximum_use": 0, # Unlimited for gift cards with balance tracking + "used": pos_coupon.used or 0, + "customer": pos_coupon.customer, + # Custom fields + "pos_next_gift_card": 1, + "gift_card_amount": balance, + "original_gift_card_amount": original_amount, + "source_invoice": pos_coupon.source_invoice, + }) + coupon_code.insert(ignore_permissions=True) + + frappe.logger().info(f"Migrated POS Coupon {pos_coupon.coupon_code} to ERPNext Coupon Code") 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..139c438c 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,13 @@ "column_break_wallet", "auto_create_wallet", "loyalty_to_wallet", + "section_break_gift_card", + "enable_gift_cards", + "gift_card_item", + "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 +160,56 @@ "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." + }, + { + "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 +575,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2026-01-11 16:52:08.009217", + "modified": "2026-01-16 14:00:00.000000", "modified_by": "Administrator", "module": "POS Next", "name": "POS Settings", diff --git a/pos_next/pos_next/doctype/referral_code/referral_code.py b/pos_next/pos_next/doctype/referral_code/referral_code.py index 465bada1..6926844a 100644 --- a/pos_next/pos_next/doctype/referral_code/referral_code.py +++ b/pos_next/pos_next/doctype/referral_code/referral_code.py @@ -109,8 +109,8 @@ def apply_referral_code(referral_code, referee_customer): if referral.disabled: frappe.throw(_("This referral code has been disabled")) - # Check if referee has already used this referral code - existing_coupon = frappe.db.exists("POS Coupon", { + # Check if referee has already used this referral code (now using ERPNext Coupon Code) + existing_coupon = frappe.db.exists("Coupon Code", { "referral_code": referral.name, "customer": referee_customer, "coupon_type": "Promotional" @@ -124,18 +124,18 @@ def apply_referral_code(referral_code, referee_customer): "referee_coupon": None } - # Generate Gift Card coupon for referrer (primary customer) + # Generate coupon for referrer (primary customer) try: referrer_coupon = generate_referrer_coupon(referral) result["referrer_coupon"] = { "name": referrer_coupon.name, "coupon_code": referrer_coupon.coupon_code, - "customer": referrer_coupon.customer + "customer": referral.customer } except Exception as e: frappe.log_error( - title="Referrer Coupon Generation Failed", - message=f"Failed to generate referrer coupon: {str(e)}" + "Referrer Coupon Generation Failed", + f"Failed to generate referrer coupon: {str(e)}" ) # Generate Promotional coupon for referee (new customer) @@ -148,8 +148,8 @@ def apply_referral_code(referral_code, referee_customer): } except Exception as e: frappe.log_error( - title="Referee Coupon Generation Failed", - message=f"Failed to generate referee coupon: {str(e)}" + "Referee Coupon Generation Failed", + f"Failed to generate referee coupon: {str(e)}" ) frappe.throw(_("Failed to generate your welcome coupon")) @@ -162,72 +162,134 @@ def apply_referral_code(referral_code, referee_customer): def generate_referrer_coupon(referral): - """Generate a Gift Card coupon for the referrer""" - coupon = frappe.new_doc("POS Coupon") - + """Generate a coupon for the referrer using ERPNext Coupon Code + Pricing Rule""" # Calculate validity dates valid_from = today() valid_days = referral.referrer_coupon_valid_days or 30 valid_upto = add_days(valid_from, valid_days) - coupon.update({ - "coupon_name": f"Referral Reward - {referral.customer} - {frappe.utils.now_datetime().strftime('%Y%m%d%H%M%S')}", - "coupon_type": "Gift Card", + # Generate unique coupon code and name + unique_hash = frappe.generate_hash()[:8].upper() + coupon_code = f"REF-{unique_hash}" + coupon_name = f"Referral Reward - {referral.customer} - {unique_hash}" + + # Create Pricing Rule first + pricing_rule_data = { + "doctype": "Pricing Rule", + "title": coupon_name, + "apply_on": "Transaction", + "price_or_product_discount": "Price", + "selling": 1, + "buying": 0, + "applicable_for": "Customer", "customer": referral.customer, "company": referral.company, - "campaign": referral.campaign, - "referral_code": referral.name, - - # Discount configuration - "discount_type": referral.referrer_discount_type, - "discount_percentage": flt(referral.referrer_discount_percentage) if referral.referrer_discount_type == "Percentage" else None, - "discount_amount": flt(referral.referrer_discount_amount) if referral.referrer_discount_type == "Amount" else None, - "min_amount": flt(referral.referrer_min_amount) if referral.referrer_min_amount else None, - "max_amount": flt(referral.referrer_max_amount) if referral.referrer_max_amount else None, - "apply_on": "Grand Total", + "valid_from": valid_from, + "valid_upto": valid_upto, + "coupon_code_based": 1, + "disable": 0, + } - # Validity + # Set discount type + if referral.referrer_discount_type == "Percentage": + pricing_rule_data["rate_or_discount"] = "Discount Percentage" + pricing_rule_data["discount_percentage"] = flt(referral.referrer_discount_percentage) + else: # Amount + pricing_rule_data["rate_or_discount"] = "Discount Amount" + pricing_rule_data["discount_amount"] = flt(referral.referrer_discount_amount) + + # Set min/max conditions if specified + if referral.referrer_min_amount: + pricing_rule_data["min_amt"] = flt(referral.referrer_min_amount) + if referral.referrer_max_amount: + pricing_rule_data["max_amt"] = flt(referral.referrer_max_amount) + + pricing_rule = frappe.get_doc(pricing_rule_data) + pricing_rule.insert(ignore_permissions=True) + + # Create Coupon Code linked to Pricing Rule + coupon = frappe.get_doc({ + "doctype": "Coupon Code", + "coupon_name": coupon_name, + "coupon_code": coupon_code, + "coupon_type": "Promotional", + "pricing_rule": pricing_rule.name, "valid_from": valid_from, "valid_upto": valid_upto, - "maximum_use": 1, # Gift cards are single-use - "one_use": 1, + "maximum_use": 1, + "used": 0, + # Custom fields for POS Next + "pos_next_gift_card": 0, # Not a gift card balance, just a referral reward + "referral_code": referral.name, + "customer": referral.customer, }) + coupon.insert(ignore_permissions=True) - coupon.insert() return coupon def generate_referee_coupon(referral, referee_customer): - """Generate a Promotional coupon for the referee (new customer)""" - coupon = frappe.new_doc("POS Coupon") - + """Generate a Promotional coupon for the referee (new customer) using ERPNext Coupon Code + Pricing Rule""" # Calculate validity dates valid_from = today() valid_days = referral.referee_coupon_valid_days or 30 valid_upto = add_days(valid_from, valid_days) - coupon.update({ - "coupon_name": f"Welcome Referral - {referee_customer} - {frappe.utils.now_datetime().strftime('%Y%m%d%H%M%S')}", - "coupon_type": "Promotional", + # Generate unique coupon code and name + unique_hash = frappe.generate_hash()[:8].upper() + coupon_code = f"WELCOME-{unique_hash}" + coupon_name = f"Welcome Referral - {referee_customer} - {unique_hash}" + + # Create Pricing Rule first + pricing_rule_data = { + "doctype": "Pricing Rule", + "title": coupon_name, + "apply_on": "Transaction", + "price_or_product_discount": "Price", + "selling": 1, + "buying": 0, + "applicable_for": "Customer", "customer": referee_customer, "company": referral.company, - "campaign": referral.campaign, - "referral_code": referral.name, - - # Discount configuration - "discount_type": referral.referee_discount_type, - "discount_percentage": flt(referral.referee_discount_percentage) if referral.referee_discount_type == "Percentage" else None, - "discount_amount": flt(referral.referee_discount_amount) if referral.referee_discount_type == "Amount" else None, - "min_amount": flt(referral.referee_min_amount) if referral.referee_min_amount else None, - "max_amount": flt(referral.referee_max_amount) if referral.referee_max_amount else None, - "apply_on": "Grand Total", + "valid_from": valid_from, + "valid_upto": valid_upto, + "coupon_code_based": 1, + "disable": 0, + } - # Validity + # Set discount type + if referral.referee_discount_type == "Percentage": + pricing_rule_data["rate_or_discount"] = "Discount Percentage" + pricing_rule_data["discount_percentage"] = flt(referral.referee_discount_percentage) + else: # Amount + pricing_rule_data["rate_or_discount"] = "Discount Amount" + pricing_rule_data["discount_amount"] = flt(referral.referee_discount_amount) + + # Set min/max conditions if specified + if referral.referee_min_amount: + pricing_rule_data["min_amt"] = flt(referral.referee_min_amount) + if referral.referee_max_amount: + pricing_rule_data["max_amt"] = flt(referral.referee_max_amount) + + pricing_rule = frappe.get_doc(pricing_rule_data) + pricing_rule.insert(ignore_permissions=True) + + # Create Coupon Code linked to Pricing Rule + coupon = frappe.get_doc({ + "doctype": "Coupon Code", + "coupon_name": coupon_name, + "coupon_code": coupon_code, + "coupon_type": "Promotional", + "pricing_rule": pricing_rule.name, "valid_from": valid_from, "valid_upto": valid_upto, - "maximum_use": 1, # One-time use for referee - "one_use": 1, + "maximum_use": 1, + "used": 0, + # Custom fields for POS Next + "pos_next_gift_card": 0, + "referral_code": referral.name, + "customer": referee_customer, }) + coupon.insert(ignore_permissions=True) - coupon.insert() return coupon diff --git a/pos_next/pos_next/doctype/referral_code/test_referral_code.py b/pos_next/pos_next/doctype/referral_code/test_referral_code.py index 27cf673b..0c151672 100644 --- a/pos_next/pos_next/doctype/referral_code/test_referral_code.py +++ b/pos_next/pos_next/doctype/referral_code/test_referral_code.py @@ -1,9 +1,529 @@ -# Copyright (c) 2021, Youssef Restom and Contributors -# See license.txt +# -*- coding: utf-8 -*- +# Copyright (c) 2025, BrainWise and contributors +# For license information, please see license.txt -# import frappe +""" +Test Suite for Referral Code functionality + +This module tests the referral code system including: +- Referral code creation +- Coupon generation for referrer and referee (using ERPNext Coupon Code) +- Application of referral codes +- Duplicate usage prevention + +Run with: bench --site [site] run-tests --app pos_next --module pos_next.pos_next.doctype.referral_code.test_referral_code +""" + +import frappe import unittest +from frappe.utils import nowdate, add_days, flt +from pos_next.pos_next.doctype.referral_code.referral_code import ( + create_referral_code, + apply_referral_code, + generate_referrer_coupon, + generate_referee_coupon, +) + + +class TestReferralCodeCreation(unittest.TestCase): + """Test referral code document creation""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures""" + cls.test_company = frappe.get_all("Company", limit=1)[0].name + cls.created_referral_codes = [] + + # Get or create 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 + + def tearDown(self): + """Roll back after each test""" + frappe.db.rollback() + + @classmethod + def tearDownClass(cls): + """Clean up test data""" + for name in cls.created_referral_codes: + try: + # Delete associated coupons + coupons = frappe.get_all("Coupon Code", filters={"referral_code": name}) + for coupon in coupons: + coupon_doc = frappe.get_doc("Coupon Code", coupon.name) + if coupon_doc.pricing_rule: + try: + frappe.delete_doc("Pricing Rule", coupon_doc.pricing_rule, force=True) + except Exception: + pass + frappe.delete_doc("Coupon Code", coupon.name, force=True) + + # Delete referral code + frappe.delete_doc("Referral Code", name, force=True) + except Exception: + pass + + frappe.db.commit() + + def test_create_referral_code_percentage(self): + """Test creating a referral code with percentage discount""" + if not self.referrer_customer: + self.skipTest("No test customer available") + + 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.assertIsNotNone(referral) + self.created_referral_codes.append(referral.name) + + self.assertEqual(referral.company, self.test_company) + self.assertEqual(referral.customer, self.referrer_customer) + self.assertEqual(referral.referrer_discount_type, "Percentage") + self.assertEqual(flt(referral.referrer_discount_percentage), 10) + + def test_create_referral_code_amount(self): + """Test creating a referral code with fixed amount discount""" + if not self.referrer_customer: + self.skipTest("No test customer available") + + 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=25 + ) + + self.assertIsNotNone(referral) + self.created_referral_codes.append(referral.name) + + self.assertEqual(referral.referrer_discount_type, "Amount") + self.assertEqual(flt(referral.referrer_discount_amount), 20) + + def test_referral_code_auto_generated(self): + """Test that referral code is auto-generated""" + if not self.referrer_customer: + self.skipTest("No test customer available") + + 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=10 + ) + + self.assertIsNotNone(referral.referral_code) + self.assertGreater(len(referral.referral_code), 0) + self.created_referral_codes.append(referral.name) + + +class TestReferralCouponGeneration(unittest.TestCase): + """Test coupon generation from referral codes""" + + @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 = [] + cls.created_pricing_rules = [] + + # 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 + + def tearDown(self): + """Roll back after each test""" + frappe.db.rollback() + + @classmethod + def tearDownClass(cls): + """Clean up test data""" + # Clean up coupons + for name in cls.created_coupons: + try: + frappe.delete_doc("Coupon Code", name, force=True) + except Exception: + pass + + # Clean up pricing rules + for name in cls.created_pricing_rules: + try: + frappe.delete_doc("Pricing Rule", 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_generate_referrer_coupon(self): + """Test generating a coupon for the referrer""" + 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="Amount", + referrer_discount_amount=50, + referee_discount_type="Percentage", + referee_discount_percentage=10 + ) + self.created_referral_codes.append(referral.name) + + # Generate referrer coupon + coupon = generate_referrer_coupon(referral) + + self.assertIsNotNone(coupon) + self.created_coupons.append(coupon.name) + + # Verify coupon properties + self.assertEqual(coupon.doctype, "Coupon Code") + self.assertTrue(coupon.coupon_code.startswith("REF-")) + self.assertEqual(coupon.referral_code, referral.name) + self.assertEqual(coupon.customer, self.referrer_customer) + + # Verify pricing rule was created + self.assertIsNotNone(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_generate_referee_coupon(self): + """Test generating a coupon for the referee""" + if not self.referrer_customer or not self.referee_customer: + self.skipTest("No test customers 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=20 + ) + self.created_referral_codes.append(referral.name) + + # Generate referee coupon + coupon = generate_referee_coupon(referral, self.referee_customer) + + self.assertIsNotNone(coupon) + self.created_coupons.append(coupon.name) + + # Verify coupon properties + self.assertTrue(coupon.coupon_code.startswith("WELCOME-")) + self.assertEqual(coupon.coupon_type, "Promotional") + self.assertEqual(coupon.referral_code, referral.name) + self.assertEqual(coupon.customer, self.referee_customer) + + # Verify pricing rule + self.assertIsNotNone(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), 20) + + def test_coupon_validity_period(self): + """Test that coupon has correct validity period""" + if not self.referrer_customer: + self.skipTest("No test customer available") + + # Create referral with 60 day validity + referral = frappe.new_doc("Referral Code") + referral.company = self.test_company + referral.customer = self.referrer_customer + referral.referrer_discount_type = "Amount" + referral.referrer_discount_amount = 30 + referral.referrer_coupon_valid_days = 60 + referral.referee_discount_type = "Amount" + referral.referee_discount_amount = 30 + referral.insert() + self.created_referral_codes.append(referral.name) + + # Generate coupon + coupon = generate_referrer_coupon(referral) + self.created_coupons.append(coupon.name) + + if coupon.pricing_rule: + self.created_pricing_rules.append(coupon.pricing_rule) + + # Check validity + expected_upto = add_days(nowdate(), 60) + self.assertEqual(str(coupon.valid_upto), str(expected_upto)) + + +class TestApplyReferralCode(unittest.TestCase): + """Test applying referral codes""" + + @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 = [] + cls.created_pricing_rules = [] + + # Get test customers + customers = frappe.get_all("Customer", limit=3) + if len(customers) >= 3: + cls.referrer_customer = customers[0].name + cls.referee_customer1 = customers[1].name + cls.referee_customer2 = customers[2].name + else: + cls.referrer_customer = None + cls.referee_customer1 = None + cls.referee_customer2 = 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: + 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 + + for name in cls.created_pricing_rules: + try: + frappe.delete_doc("Pricing Rule", name, force=True) + except Exception: + pass + + for name in cls.created_referral_codes: + try: + frappe.delete_doc("Referral Code", name, force=True) + except Exception: + pass + + frappe.db.commit() + + def test_apply_referral_code_success(self): + """Test successfully applying a referral code""" + if not self.referrer_customer or not self.referee_customer1: + self.skipTest("No test customers available") + + # Create referral code + referral = create_referral_code( + company=self.test_company, + customer=self.referrer_customer, + referrer_discount_type="Amount", + referrer_discount_amount=25, + referee_discount_type="Amount", + referee_discount_amount=25 + ) + self.created_referral_codes.append(referral.name) + frappe.db.commit() + + # Apply referral code + result = apply_referral_code( + referral_code=referral.referral_code, + referee_customer=self.referee_customer1 + ) + + self.assertIsNotNone(result) + self.assertIn("referrer_coupon", result) + self.assertIn("referee_coupon", result) + + # Track created coupons for cleanup + 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"]) + + # Verify referrer coupon + self.assertIsNotNone(result.get("referrer_coupon")) + self.assertEqual(result["referrer_coupon"]["customer"], self.referrer_customer) + + # Verify referee coupon + self.assertIsNotNone(result.get("referee_coupon")) + self.assertEqual(result["referee_coupon"]["customer"], self.referee_customer1) + + def test_apply_invalid_referral_code(self): + """Test applying an invalid referral code""" + if not self.referee_customer1: + self.skipTest("No test customer available") + + with self.assertRaises(frappe.exceptions.ValidationError): + apply_referral_code( + referral_code="INVALID-CODE-12345", + referee_customer=self.referee_customer1 + ) + + def test_apply_referral_code_case_insensitive(self): + """Test that referral code is case insensitive""" + if not self.referrer_customer or not self.referee_customer1: + self.skipTest("No test customers available") + + 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=10 + ) + self.created_referral_codes.append(referral.name) + frappe.db.commit() + + # Apply with lowercase + result = apply_referral_code( + referral_code=referral.referral_code.lower(), + referee_customer=self.referee_customer1 + ) + + self.assertIsNotNone(result) + + 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"]) + + def test_referral_code_increments_count(self): + """Test that applying a referral increments the usage count""" + if not self.referrer_customer or not self.referee_customer1: + self.skipTest("No test customers available") + + 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) + + initial_count = referral.referrals_count or 0 + frappe.db.commit() + + result = apply_referral_code( + referral_code=referral.referral_code, + referee_customer=self.referee_customer1 + ) + + 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"]) + + # Reload and check count + referral.reload() + self.assertEqual(referral.referrals_count, initial_count + 1) + + +class TestReferralCodeValidation(unittest.TestCase): + """Test referral code validation rules""" + + @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_validate_referrer_percentage_required(self): + """Test that referrer percentage is required when type is Percentage""" + if not self.test_customer: + self.skipTest("No test customer available") + + referral = frappe.new_doc("Referral Code") + referral.company = self.test_company + referral.customer = self.test_customer + referral.referrer_discount_type = "Percentage" + referral.referrer_discount_percentage = None # Missing + referral.referee_discount_type = "Percentage" + referral.referee_discount_percentage = 10 + + with self.assertRaises(frappe.exceptions.ValidationError): + referral.validate() + + def test_validate_referrer_amount_required(self): + """Test that referrer amount is required when type is Amount""" + if not self.test_customer: + self.skipTest("No test customer available") + + referral = frappe.new_doc("Referral Code") + referral.company = self.test_company + referral.customer = self.test_customer + referral.referrer_discount_type = "Amount" + referral.referrer_discount_amount = None # Missing + referral.referee_discount_type = "Amount" + referral.referee_discount_amount = 10 + + with self.assertRaises(frappe.exceptions.ValidationError): + referral.validate() + + def test_validate_percentage_range(self): + """Test that percentage must be between 0 and 100""" + if not self.test_customer: + self.skipTest("No test customer available") + + referral = frappe.new_doc("Referral Code") + referral.company = self.test_company + referral.customer = self.test_customer + referral.referrer_discount_type = "Percentage" + referral.referrer_discount_percentage = 150 # Invalid + referral.referee_discount_type = "Percentage" + referral.referee_discount_percentage = 10 + + with self.assertRaises(frappe.exceptions.ValidationError): + referral.validate() + + +def run_referral_code_tests(): + """Run all referral code tests and return results""" + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + suite.addTests(loader.loadTestsFromTestCase(TestReferralCodeCreation)) + suite.addTests(loader.loadTestsFromTestCase(TestReferralCouponGeneration)) + suite.addTests(loader.loadTestsFromTestCase(TestApplyReferralCode)) + suite.addTests(loader.loadTestsFromTestCase(TestReferralCodeValidation)) + + runner = unittest.TextTestRunner(verbosity=2) + return runner.run(suite) -class TestReferralCode(unittest.TestCase): - pass +if __name__ == "__main__": + run_referral_code_tests() diff --git a/pos_next/public/js/coupon_code_list.js b/pos_next/public/js/coupon_code_list.js new file mode 100644 index 00000000..6e190a8b --- /dev/null +++ b/pos_next/public/js/coupon_code_list.js @@ -0,0 +1,146 @@ +// -*- coding: utf-8 -*- +// Copyright (c) 2025, POS Next and contributors +// For license information, please see license.txt + +/** + * Coupon Code List customization for POS Next + * + * Adds a "Create Gift Card" button to quickly create gift cards + * managed by POS Next directly from the Coupon Code list. + */ + +frappe.listview_settings["Coupon Code"] = { + onload: function (listview) { + // Add "Create Gift Card" button + listview.page.add_inner_button(__("Create Gift Card"), function () { + _show_create_gift_card_dialog(); + }); + }, + + // Color coding for gift cards + get_indicator: function (doc) { + if (doc.pos_next_gift_card && doc.gift_card_amount > 0) { + return [__("Gift Card Active"), "green", "pos_next_gift_card,=,1"]; + } else if (doc.pos_next_gift_card && doc.gift_card_amount <= 0) { + return [__("Gift Card Depleted"), "gray", "pos_next_gift_card,=,1"]; + } + }, +}; + +/** + * Show dialog to create a new gift card + */ +function _show_create_gift_card_dialog() { + let d = new frappe.ui.Dialog({ + title: __("Create Gift Card"), + fields: [ + { + fieldname: "amount", + fieldtype: "Currency", + label: __("Amount"), + reqd: 1, + description: __("Gift card value"), + }, + { + fieldname: "company", + fieldtype: "Link", + options: "Company", + label: __("Company"), + reqd: 1, + default: frappe.defaults.get_user_default("Company"), + }, + { + fieldname: "customer", + fieldtype: "Link", + options: "Customer", + label: __("Customer"), + description: __("Optional - leave empty for anonymous gift card"), + }, + { + fieldname: "validity_months", + fieldtype: "Int", + label: __("Validity (Months)"), + default: 12, + description: __("How long the gift card is valid"), + }, + ], + primary_action_label: __("Create"), + primary_action: function (values) { + frappe.call({ + method: "pos_next.api.gift_cards.create_gift_card_manual", + args: { + amount: values.amount, + company: values.company, + customer: values.customer || null, + validity_months: values.validity_months || 12, + }, + callback: function (r) { + if (r.message && r.message.success) { + d.hide(); + frappe.show_alert( + { + message: __( + "Gift Card {0} created with balance {1}", + [r.message.coupon_code, r.message.formatted_amount] + ), + indicator: "green", + }, + 10 + ); + + // Refresh list view + cur_list.refresh(); + + // Show the created coupon code prominently + _show_gift_card_created_dialog(r.message); + } + }, + }); + }, + }); + d.show(); +} + +/** + * Show dialog with created gift card details (for printing/sharing) + */ +function _show_gift_card_created_dialog(gift_card) { + let d = new frappe.ui.Dialog({ + title: __("Gift Card Created"), + fields: [ + { + fieldtype: "HTML", + fieldname: "gift_card_info", + options: ` +
+

${__("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()