diff --git a/app/assets/styles/utils.scss b/app/assets/styles/utils.scss index 4527068..d9cb434 100644 --- a/app/assets/styles/utils.scss +++ b/app/assets/styles/utils.scss @@ -135,9 +135,9 @@ $class-types: ( ), ); -// ─── Numeric: w-0 → w-96, h-0 → h-96, size-0 → size-96 ─────────────────── +// ─── Numeric: w-0 → w-400, h-0 → h-400, size-0 → size-400 ─────────────────── @each $prefix, $props in $class-types { - @for $i from 0 through 96 { + @for $i from 0 through 400 { .#{$prefix}-#{$i} { @include set-props($props, calc(var(--spacing) * #{$i})); } @@ -361,9 +361,9 @@ $inset-types: ( ), ); -// ─── Numeric: 0 → 96, using the same --spacing scale ────────────────────── +// ─── Numeric: 0 → 400, using the same --spacing scale ────────────────────── @each $prefix, $props in $inset-types { - @for $i from 0 through 96 { + @for $i from 0 through 400 { .#{$prefix}-#{$i} { @include set-props($props, calc(var(--spacing) * #{$i})); } @@ -412,9 +412,9 @@ $minmax-types: ( ), ); -// ─── Numeric: 0 → 96, using --spacing scale ─────────────────────────────── +// ─── Numeric: 0 → 400, using --spacing scale ─────────────────────────────── @each $prefix, $props in $minmax-types { - @for $i from 0 through 96 { + @for $i from 0 through 400 { .#{$prefix}-#{$i} { @include set-props($props, calc(var(--spacing) * #{$i})); } diff --git a/app/components/pos/ConfirmOrder.vue b/app/components/pos/ConfirmOrder.vue index d61aee0..1977382 100644 --- a/app/components/pos/ConfirmOrder.vue +++ b/app/components/pos/ConfirmOrder.vue @@ -1,117 +1,17 @@ diff --git a/app/components/pos/VietQR.vue b/app/components/pos/VietQR.vue new file mode 100644 index 0000000..1a38dec --- /dev/null +++ b/app/components/pos/VietQR.vue @@ -0,0 +1,31 @@ + + diff --git a/app/components/pos/composables/useCustomer.js b/app/components/pos/composables/useCustomer.js index 8f426e0..4b0e07c 100644 --- a/app/components/pos/composables/useCustomer.js +++ b/app/components/pos/composables/useCustomer.js @@ -3,9 +3,10 @@ export default function useCustomer({ activeCart, getCarts }) { const addresses = ref([]); async function getAddresses() { - addresses.value = await $getdata("Customer_Address", { - filter: { customer: activeCart.value.customer }, - }); + addresses.value = + (await $getdata("Customer_Address", { + filter: { customer: activeCart.value.customer }, + })) || []; } const isChangingCus = ref(false); diff --git a/app/components/pos/composables/usePlaceOrder.js b/app/components/pos/composables/usePlaceOrder.js new file mode 100644 index 0000000..d2ccef6 --- /dev/null +++ b/app/components/pos/composables/usePlaceOrder.js @@ -0,0 +1,236 @@ +export default function usePlaceOrder({ activeCart, activeCartItems, orderInfo, getCarts, onSuccess }) { + const { $dayjs, $id, $getdata, $insertapi, $patchapi, $deleteapi, $snackbar } = useNuxtApp(); + const isPending = ref(false); + const paidPaymentStatus = ref(); + const invoice = ref(); + const showVietQRModal = ref(); + + const subtotal = computed(() => { + return activeCartItems.value?.reduce((prev, curr) => prev + curr.imei__variant__price, 0); + }); + + const shipping_address = computed(() => { + if (!orderInfo.value.address) return ""; + return `${orderInfo.value.address.address_detail}, ${orderInfo.value.address.ward}, ${orderInfo.value.address.district}, ${orderInfo.value.address.city}`; + }); + + /** + * Create a record in `invoice` as `pending` + */ + async function createOrder() { + try { + isPending.value = true; + + const invoicePayload = { + customer: activeCart.value.customer, + customer_name: activeCart.value.customer__fullname, + customer_phone: activeCart.value.customer__phone, + customer_email: activeCart.value.customer__email, + shipping_address: shipping_address.value, + product_amount: activeCartItems.value.length, + shipping_fee: 0, + total_amount: subtotal.value, + discount_amount: 0, + final_amount: subtotal.value, + order_type: "pos", + status: "pending", + payment_status: 1, + staff: 1, + ordered_at: new Date(), + paid_at: null, + delivery_method: orderInfo.value.deliveryMethod.id, + }; + + const invoice = await $insertapi("Invoice", { + data: invoicePayload, + notify: false, + }); + + return invoice; + } catch (error) { + console.error(error); + $snackbar("Tạo đơn hàng không thành công", "Error"); + } finally { + isPending.value = false; + } + } + + /** + * Mark sold IMEIs, create invoice details, delivery info, payment records, reset cart. + * Runs when order payment is confirmed. + * @param invoiceId id of created invoice + */ + async function finalizeOrder(invoiceId) { + if (!invoiceId) { + console.error("invoiceId is required"); + return; + } + + const imeisSold = await markImeisSold(); + await createInvoiceDetails(invoiceId, imeisSold); + await createPaymentRecord(invoiceId); + + if (orderInfo.value.deliveryMethod.code === "HOME_DELIVERY") { + await createDeliveryInfo(invoiceId); + } + + // update invoice.payment_status from PENDING to PAID + const patchedInvoice = await $patchapi("Invoice", { id: invoiceId, payment_status: paidPaymentStatus.value.id }); + $snackbar("Khởi tạo đơn hàng thành công", "Success"); + resetCart(); + onSuccess(); + } + + async function markImeisSold() { + const imeisSoldPayload = activeCartItems.value.map((cartItem) => ({ + imei: cartItem.imei__imei, + variant: cartItem.imei__variant, + sold_date: $dayjs().format("YYYY-MM-DD"), + })); + + const imeisSold = await $insertapi("IMEI_Sold", { + data: imeisSoldPayload, + notify: false, + }); + + return imeisSold; + } + + async function createInvoiceDetails(invoiceId, imeisSold) { + const invoiceDetailPayload = activeCartItems.value.map((cartItem) => { + const imeiSold = imeisSold.find((imeiSold) => imeiSold.imei === cartItem.imei__imei); + return { + invoice: invoiceId, + variant: cartItem.imei__variant, + imei_sold: imeiSold.id, + price: cartItem.imei__variant__price, + }; + }); + + const invoiceDetails = await $insertapi("Invoice_Detail", { + data: invoiceDetailPayload, + notify: false, + }); + + return invoiceDetails; + } + + async function createDeliveryInfo(invoiceId) { + const deliveryInfoPayload = { + invoice: invoiceId, + receiver_name: orderInfo.value.receiver_name, + receiver_phone: orderInfo.value.receiver_phone, + city: orderInfo.value.address.city, + district: orderInfo.value.address.district, + ward: orderInfo.value.address.ward, + address: orderInfo.value.address.address_detail, + shipping_fee: 0, + carrier: orderInfo.value.deliveryMethod.id, + tracking_code: $id(), + status: "pending", + staff: 1, + }; + + const deliveryInfo = await $insertapi("Delivery_Info", { + data: deliveryInfoPayload, + notify: false, + }); + + return deliveryInfo; + } + + async function createPaymentRecord(invoiceId) { + const paymentRecord = await $insertapi("Payment_Record", { + data: { + invoice: invoiceId, + payment_method: orderInfo.value.paymentMethod.id, + transfer_amount: orderInfo.value.paymentMethod.code === "CASH" ? undefined : subtotal.value, + cash_amount: orderInfo.value.paymentMethod.code === "CASH" ? subtotal.value : undefined, + }, + notify: false, + }); + + return paymentRecord; + } + + async function resetCart() { + await Promise.all([ + $deleteapi( + "Cart_Item", + activeCartItems.value.map((c) => c.id), + ), + $patchapi("Cart", { + id: activeCartItems.value[0].cart, + customer: null, + }), + ]); + getCarts(); + } + + async function placeOrder() { + invoice.value = await createOrder(); + + if (orderInfo.value.paymentMethod.code === "CASH") { + finalizeOrder(invoice.value.id); + } else if (orderInfo.value.paymentMethod.code === "VIETQR") { + showVietQRModal.value = { + component: "pos/VietQR", + title: "Thanh toán qua VietQR", + width: "700px", + vbind: { invoiceId: invoice.value.id, finalizeOrder }, // verify thủ công? + }; + } else if (orderInfo.value.paymentMethod.code === "VNPAY") { + try { + const { paymentUrl } = await $fetch("/api/vnpay/create-payment", { + method: "POST", + body: { + amount: invoice.value.total_amount, + invoiceCode: invoice.value.code, + }, + }); + + window.open(paymentUrl, "vnpaypopup", "popup=true,width=900,height=700"); + listenToVnpayEvents(); + } catch (error) { + console.error(error); + } + } + } + + function vnpayListener(e) { + console.log("main window received e.data from popup", e.data); + + const { type, message } = e.data; + if (type === "vnpay-success") { + finalizeOrder(invoice.value.id); + } + if (type === "vnpay-error") { + $snackbar(`Thanh toán VNPAY thất bại: ${message}`, "Error"); + } + } + + function listenToVnpayEvents() { + window.removeEventListener("message", vnpayListener); + window.addEventListener("message", vnpayListener); + } + + function testReturnPage() { + listenToVnpayEvents(); + + const vnp_ResponseCode = "01"; + window.open( + `http://localhost:3000/vnpay-return?vnp_Amount=1000000&vnp_BankCode=NCB&vnp_BankTranNo=VNP15593296&vnp_CardType=ATM&vnp_OrderInfo=Thanh+to%C3%A1n+%C4%91%C6%A1n+h%C3%A0ng+HD000001&vnp_PayDate=20260622142046&vnp_ResponseCode=${vnp_ResponseCode}&vnp_TmnCode=SCG4UUS6&vnp_TransactionNo=15593296&vnp_TransactionStatus=00&vnp_TxnRef=HD000001-2026-06-22T07:20:14.735Z&vnp_SecureHash=9726fe2db0da9741fc14fc42ced098cabf204f21ab12f633e8ac1cb7c9567a3b7a379d790f29a1a8b60fa8edb24acb0faca83feca0f7757865b0bf853b55ea01`, + "return", + "popup=true,width=800,height=600", + ); + } + + onMounted(async () => { + paidPaymentStatus.value = await $getdata("Payment_Status", { + filter: { code: "PAID" }, + first: true, + }); + }); + + return { isPending, subtotal, shipping_address, placeOrder, testReturnPage, showVietQRModal }; +} diff --git a/app/pages/vnpay-return.vue b/app/pages/vnpay-return.vue new file mode 100644 index 0000000..600ac3d --- /dev/null +++ b/app/pages/vnpay-return.vue @@ -0,0 +1,50 @@ + + + diff --git a/app/utils/qr.js b/app/utils/qr.js new file mode 100644 index 0000000..6d774b9 --- /dev/null +++ b/app/utils/qr.js @@ -0,0 +1,20 @@ +export default function qr(amount, qrVars = {}) { + const defaultQRVars = { + bankId: "BIDV", + accountNo: "1222198820", + accountName: "Nguyen Viet An", + template: "Rxp4lcv", + description: "Thanh toan ERP", + }; + + const finalVars = { + ...defaultQRVars, + ...qrVars, + }; + + const { bankId, accountNo, template, description, accountName } = finalVars; + + const qrSrc = `https://img.vietqr.io/image/${bankId}-${accountNo}-${template}.png?amount=${amount}&addInfo=${description}&accountName=${accountName}`; + + return qrSrc; +} diff --git a/server/api/vnpay/create-payment.post.ts b/server/api/vnpay/create-payment.post.ts new file mode 100644 index 0000000..cb697b4 --- /dev/null +++ b/server/api/vnpay/create-payment.post.ts @@ -0,0 +1,35 @@ +import { VNPay } from "vnpay"; +import { secureSecret, tmnCode, vnpayHost } from "~~/server/utils/vnpaySettings"; +export default defineEventHandler(async (event) => { + const body = await readBody(event); + + const vnpay = new VNPay({ + // ⚡ Cấu hình bắt buộc + tmnCode, + secureSecret, + vnpayHost, + + // 🔧 Cấu hình tùy chọn + testMode: true, // Chế độ test + // hashAlgorithm: 'SHA512', // Thuật toán mã hóa + enableLog: true, // Bật/tắt log + // loggerFn: ignoreLogger, // Custom logger + + // 🔧 Custom endpoints + endpoints: { + paymentEndpoint: "paymentv2/vpcpay.html", + queryDrRefundEndpoint: "merchant_webapi/api/transaction", + getBankListEndpoint: "qrpayauth/api/merchant/get_bank_list", + }, + }); + + const paymentUrl = vnpay.buildPaymentUrl({ + vnp_Amount: body.amount, + vnp_IpAddr: "192.168.1.1", + vnp_ReturnUrl: "http://localhost:3000/vnpay-return", + vnp_TxnRef: `Test-${body.invoiceCode}-${new Date().toISOString()}`, + vnp_OrderInfo: `Thanh toan don hang ${body.invoiceCode}`, + }); + + return { paymentUrl }; +}); diff --git a/server/api/vnpay/return.get.ts b/server/api/vnpay/return.get.ts new file mode 100644 index 0000000..c9253ec --- /dev/null +++ b/server/api/vnpay/return.get.ts @@ -0,0 +1,36 @@ +import { VNPay } from "vnpay"; + +export default defineEventHandler<{ + query: { + vnp_Amount: string; + vnp_BankCode: string; + vnp_BankTranNo: string; + vnp_CardType: string; + vnp_OrderInfo: string; + vnp_PayDate: string; + vnp_ResponseCode: string; + vnp_TmnCode: string; + vnp_TransactionNo: string; + vnp_TransactionStatus: string; + vnp_TxnRef: string; + vnp_SecureHash: string; + }; +}>(async (event) => { + const query = getQuery(event); + const vnpay = new VNPay({ tmnCode, secureSecret, vnpayHost }); + + try { + const verify = vnpay.verifyReturnUrl(query); + /* + !isVerified -> Xác thực tính toàn vẹn dữ liệu thất bại + !isSuccess -> Đơn hàng thanh toán thất bại + */ + return { + isSuccess: verify.isSuccess, + isVerified: verify.isVerified, + message: verify.message, + }; + } catch (error) { + return { error: "Dữ liệu không hợp lệ" }; + } +}); diff --git a/server/utils/vnpaySettings.ts b/server/utils/vnpaySettings.ts new file mode 100644 index 0000000..20bbc04 --- /dev/null +++ b/server/utils/vnpaySettings.ts @@ -0,0 +1,3 @@ +export const tmnCode = "SCG4UUS6"; +export const secureSecret = "G0O1GV6Z5OS0YDG38O76QSYRR81D8WWY"; +export const vnpayHost = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html";