diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 6f050f4..408c702 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -21,6 +21,7 @@ import { couponsRoute } from "./routes/coupons"; import { reviewsRoute } from "./routes/reviews"; import { promotionsRoute } from "./routes/promotions"; import { pagesRoute } from "./routes/pages"; +import { paymentsRoute } from "./routes/payments"; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent"; @@ -84,7 +85,7 @@ const app = new Elysia() return file; }) .group("/api", (app) => - app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute) + app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute) ) .listen(PORT); diff --git a/apps/backend/src/routes/payments.ts b/apps/backend/src/routes/payments.ts new file mode 100644 index 0000000..70fca43 --- /dev/null +++ b/apps/backend/src/routes/payments.ts @@ -0,0 +1,173 @@ +import { Elysia, t } from "elysia"; +import prisma from "../lib/prisma"; + +const OMISE_SECRET_KEY = process.env.OMISE_SECRET_KEY || ""; + +export const paymentsRoute = new Elysia({ prefix: "/payments" }) + .post("/charge", async ({ body, set }) => { + try { + const { orderId, amount, type, token } = body as { + orderId: number; + amount: number; + type: "card" | "promptpay"; + token?: string; + }; + + if (!OMISE_SECRET_KEY) { + set.status = 500; + return { error: "Omise secret key is not configured" }; + } + + // 1. Check if order exists + const order = await prisma.order.findUnique({ + where: { id: orderId } + }); + if (!order) { + set.status = 404; + return { error: "Order not found" }; + } + + // Convert amount to satang (integer) + const amountInSatang = Math.round(amount * 100); + + // Setup Basic Auth header for Omise + const authHeader = "Basic " + Buffer.from(OMISE_SECRET_KEY + ":").toString("base64"); + + if (type === "card") { + if (!token) { + set.status = 400; + return { error: "Card token is required for card payments" }; + } + + // Create a card charge directly + const chargeRes = await fetch("https://api.omise.co/charges", { + method: "POST", + headers: { + "Authorization": authHeader, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + amount: amountInSatang, + currency: "THB", + card: token, + metadata: { + orderId: String(orderId) + } + }) + }); + + const charge = await chargeRes.json() as any; + + if (charge.error) { + set.status = 400; + return { error: charge.message || "Failed to create credit card charge" }; + } + + // If card payment is immediately successful (some cards don't require 3DS) + if (charge.status === "successful") { + await prisma.order.update({ + where: { id: orderId }, + data: { status: "PAID" } + }); + } + + return { + success: true, + status: charge.status, + chargeId: charge.id, + authorizeUri: charge.authorize_uri || null // For 3DS card validation redirect + }; + } else { + // PromptPay: + // 1. Create source + const sourceRes = await fetch("https://api.omise.co/sources", { + method: "POST", + headers: { + "Authorization": authHeader, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + type: "promptpay", + amount: amountInSatang, + currency: "THB" + }) + }); + const source = await sourceRes.json() as any; + if (source.error) { + set.status = 400; + return { error: source.message || "Failed to create PromptPay source" }; + } + + // 2. Create charge + const chargeRes = await fetch("https://api.omise.co/charges", { + method: "POST", + headers: { + "Authorization": authHeader, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + amount: amountInSatang, + currency: "THB", + source: source.id, + metadata: { + orderId: String(orderId) + } + }) + }); + const charge = await chargeRes.json() as any; + if (charge.error) { + set.status = 400; + return { error: charge.message || "Failed to create PromptPay charge" }; + } + + // Get QR Code Image URL from the charge's source references + const qrCodeUrl = charge.source?.references?.barcode || null; + + return { + success: true, + status: charge.status, + chargeId: charge.id, + qrCodeUrl: qrCodeUrl + }; + } + } catch (err: any) { + console.error("[Payment Charge] Error:", err); + set.status = 500; + return { error: err.message || "Failed to process payment request" }; + } + }, { + body: t.Object({ + orderId: t.Number(), + amount: t.Number(), + type: t.Union([t.Literal("card"), t.Literal("promptpay")]), + token: t.Optional(t.String()) + }) + }) + .post("/webhook", async ({ body, set }) => { + try { + const event = body as any; + console.log(`[Omise Webhook] Received event: ${event.key}`); + + if (event.key === "charge.complete") { + const charge = event.data; + const status = charge.status; + const orderIdStr = charge.metadata?.orderId; + + if (orderIdStr && status === "successful") { + const orderId = parseInt(orderIdStr, 10); + console.log(`[Omise Webhook] Payment successful for Order #${orderId}`); + + await prisma.order.update({ + where: { id: orderId }, + data: { status: "PAID" } + }); + } + } + + return { received: true }; + } catch (err: any) { + console.error("[Omise Webhook] Error processing event:", err); + set.status = 500; + return { error: "Failed to process webhook" }; + } + }); diff --git a/apps/spa/index.html b/apps/spa/index.html index 5bb28f0..5c8c238 100644 --- a/apps/spa/index.html +++ b/apps/spa/index.html @@ -5,6 +5,7 @@
Bank
-{settings.bankName}
-Account Number
-{settings.bankAccountNumber}
+ {/* Bank Transfer Layout */} + {paymentMethod === 'bank' && settings && ( +Bank
+{settings.bankName}
+Account Number
+{settings.bankAccountNumber}
+Account Name
+{settings.bankAccountName}
+Please transfer the exact amount and upload your slip below.
+Slip Uploaded Successfully
+Status: {order.status}
+We will verify your payment and process your order shortly.
+{uploading ? 'Uploading...' : 'Click or drag to upload slip'}
+Supports JPG, PNG (Max 5MB)
+Account Name
-{settings.bankAccountName}
+ )} + + {/* PromptPay Layout */} + {paymentMethod === 'promptpay' && ( +สแกนจ่ายเงินเพื่อเสร็จสิ้นออเดอร์
+1. กดบันทึกรูปภาพคิวอาร์โค้ดลงในมือถือของคุณ
+2. เปิดแอป Mobile Banking และเลือกสแกนภาพจากอัลบั้ม
++ {polling ? "🔄 กำลังตรวจสอบยอดชำระเงินโดยอัตโนมัติ..." : "รอการจ่ายเงิน..."} +
+ชำระเงินเสร็จสิ้น
+ทางแบรนด์ได้รับยอดชำระของคุณเรียบร้อย ออเดอร์กำลังอยู่ระหว่างจัดเตรียมสินค้า
+ออเดอร์สถานะปัจจุบัน: {order.status}
Please transfer the exact amount and upload your slip below.
Slip Uploaded Successfully
-Status: {order.status}
-We will verify your payment and process your order shortly.
-{uploading ? 'Uploading...' : 'Click or drag to upload slip'}
-Supports JPG, PNG (Max 5MB)
-