From c0dab8b6dc58cc539db3840199ccbd7ed82162db Mon Sep 17 00:00:00 2001 From: MT Date: Fri, 27 Mar 2026 23:55:15 +0200 Subject: [PATCH 1/2] fix: submit customer credit redemption payload --- POS/src/composables/useInvoice.js | 93 +++++++++++++++++++++++++++---- POS/src/pages/POSSale.vue | 1 + 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js index 66c7c58f..c802e920 100644 --- a/POS/src/composables/useInvoice.js +++ b/POS/src/composables/useInvoice.js @@ -820,6 +820,76 @@ export function useInvoice() { } } + function serializeInvoicePayments(rawPayments) { + return rawPayments + .filter((payment) => !payment?.is_customer_credit) + .map((payment) => ({ + mode_of_payment: payment.mode_of_payment, + amount: payment.amount, + type: payment.type, + })) + } + + function buildCustomerCreditPayload(rawPayments) { + const creditPayments = rawPayments.filter((payment) => payment?.is_customer_credit) + + if (!creditPayments.length) { + return { + invoicePayments: serializeInvoicePayments(rawPayments), + redeemedCustomerCredit: 0, + customerCreditDict: [], + } + } + + const creditSources = new Map() + for (const payment of creditPayments) { + for (const credit of payment.credit_details || []) { + if (!credit?.type || !credit?.credit_origin) continue + const key = `${credit.type}:${credit.credit_origin}` + if (!creditSources.has(key)) { + creditSources.set(key, credit) + } + } + } + + const redeemedCustomerCredit = roundCurrency( + creditPayments.reduce((sum, payment) => sum + Number(payment.amount || 0), 0), + ) + + let remainingCreditToAllocate = redeemedCustomerCredit + const customerCreditDict = [] + + for (const credit of creditSources.values()) { + if (remainingCreditToAllocate <= 0) break + + const availableCredit = roundCurrency( + Number(credit.available_credit ?? credit.total_credit ?? 0), + ) + if (availableCredit <= 0) continue + + const creditToRedeem = Math.min(availableCredit, remainingCreditToAllocate) + if (creditToRedeem <= 0) continue + + customerCreditDict.push({ + ...credit, + credit_to_redeem: roundCurrency(creditToRedeem), + }) + remainingCreditToAllocate = roundCurrency( + remainingCreditToAllocate - creditToRedeem, + ) + } + + if (remainingCreditToAllocate > 0.01) { + throw new Error("Unable to allocate the selected customer credit") + } + + return { + invoicePayments: serializeInvoicePayments(rawPayments), + redeemedCustomerCredit, + customerCreditDict, + } + } + async function saveDraft(targetDoctype = "Sales Invoice") { /** * Save invoice as draft (Step 1) @@ -828,6 +898,7 @@ export function useInvoice() { // Use toRaw() to ensure we get current, non-reactive values (prevents stale cached quantities) const rawItems = toRaw(invoiceItems.value) const rawPayments = toRaw(payments.value) + const { invoicePayments } = buildCustomerCreditPayload(rawPayments) const invoiceData = { doctype: targetDoctype, @@ -835,11 +906,7 @@ export function useInvoice() { posa_pos_opening_shift: posOpeningShift.value, customer: customer.value?.name || customer.value, items: formatItemsForSubmission(rawItems), - payments: rawPayments.map((p) => ({ - mode_of_payment: p.mode_of_payment, - amount: p.amount, - type: p.type, - })), + payments: invoicePayments, discount_amount: additionalDiscount.value || 0, coupon_code: couponCode.value, is_pos: 1, @@ -892,6 +959,11 @@ export function useInvoice() { const rawItems = toRaw(invoiceItems.value) const rawPayments = toRaw(payments.value) const rawSalesTeam = toRaw(salesTeam.value) + const { + invoicePayments, + redeemedCustomerCredit, + customerCreditDict, + } = buildCustomerCreditPayload(rawPayments) const invoiceData = { doctype: targetDoctype, @@ -899,11 +971,7 @@ export function useInvoice() { posa_pos_opening_shift: posOpeningShift.value, customer: customer.value?.name || customer.value, items: formatItemsForSubmission(rawItems), - payments: rawPayments.map((p) => ({ - mode_of_payment: p.mode_of_payment, - amount: p.amount, - type: p.type, - })), + payments: invoicePayments, discount_amount: additionalDiscount.value || 0, coupon_code: couponCode.value, is_pos: 1, @@ -947,6 +1015,11 @@ export function useInvoice() { write_off_amount: writeOffAmount || 0, } + if (redeemedCustomerCredit > 0 && customerCreditDict.length > 0) { + submitData.redeemed_customer_credit = redeemedCustomerCredit + submitData.customer_credit_dict = customerCreditDict + } + try { const result = await submitInvoiceResource.submit({ invoice: invoiceDoc, diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue index 20a7664b..e911bd29 100644 --- a/POS/src/pages/POSSale.vue +++ b/POS/src/pages/POSSale.vue @@ -1956,6 +1956,7 @@ async function handlePaymentCompleted(paymentData) { if (paymentData.payments && Array.isArray(paymentData.payments)) { paymentData.payments.forEach((p) => { cartStore.payments.push({ + ...p, mode_of_payment: p.mode_of_payment, amount: p.amount, type: p.type, From 6dab803113daa43d0de397bdf1c410daa663e632 Mon Sep 17 00:00:00 2001 From: MT Date: Sat, 28 Mar 2026 14:06:36 +0200 Subject: [PATCH 2/2] fix: allow pure customer credit pos sales --- pos_next/api/invoices.py | 9 ++++++--- pos_next/overrides/sales_invoice.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 5f96d3e4..fc00c984 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -1350,6 +1350,12 @@ def submit_invoice(invoice=None, data=None): # (global Stock Settings, POS Settings, and POS Profile flags) _validate_stock_on_invoice(invoice_doc) + # Allow pure customer-credit POS sales to submit without a payment row. + customer_credit_dict = data.get("customer_credit_dict") or invoice.get("customer_credit_dict") + redeemed_customer_credit = data.get("redeemed_customer_credit") or invoice.get("redeemed_customer_credit") + if redeemed_customer_credit and not invoice_doc.payments: + invoice_doc.flags.pos_next_redeemed_customer_credit = flt(redeemed_customer_credit) + # Save before submit invoice_doc.flags.ignore_permissions = True frappe.flags.ignore_account_permission = True @@ -1414,9 +1420,6 @@ def submit_invoice(invoice=None, data=None): _complete_offline_sync(sync_record_name, invoice_doc.name) # Handle credit redemption after successful submission - customer_credit_dict = data.get("customer_credit_dict") or invoice.get("customer_credit_dict") - redeemed_customer_credit = data.get("redeemed_customer_credit") or invoice.get("redeemed_customer_credit") - if redeemed_customer_credit and customer_credit_dict: try: from pos_next.api.credit_sales import redeem_customer_credit diff --git a/pos_next/overrides/sales_invoice.py b/pos_next/overrides/sales_invoice.py index 99220bb3..069d5047 100644 --- a/pos_next/overrides/sales_invoice.py +++ b/pos_next/overrides/sales_invoice.py @@ -129,6 +129,21 @@ def make_pos_gl_entries(self, gl_entries): # and appends change amount entries directly to it self.make_gle_for_change_amount(gl_entries) + def validate_pos_paid_amount(self): + """ + Allow pure customer-credit POS sales to submit without a payment row. + + POSNext redeems customer credit after submit through Journal Entries / + Payment Entry allocation, so there is no real Mode of Payment row to send. + Only bypass the core POS payment-row check when submit_invoice has explicitly + marked the document for customer-credit redemption. + """ + if getattr(self.flags, "pos_next_redeemed_customer_credit", 0): + if len(self.payments) == 0 and cint(self.is_pos) and flt(self.grand_total) > 0: + return + + super().validate_pos_paid_amount() + def get_party_and_party_type_for_pos_gl_entry(self, mode_of_payment, account): """ Get party type and party for wallet payment GL entries.