From d3e817d8bad81ede3dffe829ce0b209a8305c3d3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 7 Jun 2026 18:21:27 +0700 Subject: [PATCH] x --- apps/backend/src/index.ts | 3 +- apps/backend/src/routes/payments.ts | 173 +++++++++ apps/spa/index.html | 1 + apps/spa/src/pages/OrderSuccessPage.tsx | 449 ++++++++++++++++++++---- 4 files changed, 556 insertions(+), 70 deletions(-) create mode 100644 apps/backend/src/routes/payments.ts 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 @@ website +
diff --git a/apps/spa/src/pages/OrderSuccessPage.tsx b/apps/spa/src/pages/OrderSuccessPage.tsx index ed74953..0c7fbf3 100644 --- a/apps/spa/src/pages/OrderSuccessPage.tsx +++ b/apps/spa/src/pages/OrderSuccessPage.tsx @@ -4,7 +4,7 @@ import Navbar from '../components/Navbar' import Footer from '../components/Footer' import DynamicPageRenderer from '../components/DynamicPageRenderer' import { API_BASE, formatPrice, type Order, type StoreSettings, type Category, type PageSection } from '../types' -import { CheckCircle2, UploadCloud, Loader2, Copy } from 'lucide-react' +import { CheckCircle2, UploadCloud, Loader2, Copy, CreditCard, QrCode, Landmark } from 'lucide-react' export default function OrderSuccessPage() { const { id } = useParams<{ id: string }>() @@ -16,6 +16,135 @@ export default function OrderSuccessPage() { const [uploading, setUploading] = useState(false) const [copied, setCopied] = useState(false) const [cmsSections, setCmsSections] = useState([]) + + const [paymentMethod, setPaymentMethod] = useState<'bank' | 'promptpay' | 'card'>('bank') + const [qrCodeUrl, setQrCodeUrl] = useState(null) + const [generatingQr, setGeneratingQr] = useState(false) + const [polling, setPolling] = useState(false) + + // Card Form State + const [cardNumber, setCardNumber] = useState('') + const [cardName, setCardName] = useState('') + const [cardExpiry, setCardExpiry] = useState('') // MM/YY + const [cardCvv, setCardCvv] = useState('') + const [payingCard, setPayingCard] = useState(false) + const [cardError, setCardError] = useState(null) + + useEffect(() => { + let interval: any; + if (polling && order && order.status === 'PENDING_PAYMENT') { + interval = setInterval(() => { + fetch(`${API_BASE}/orders/${order.id}`) + .then(res => res.json()) + .then(updatedOrder => { + if (updatedOrder && updatedOrder.status === 'PAID') { + setOrder(updatedOrder); + setPolling(false); + clearInterval(interval); + } + }) + .catch(console.error); + }, 5000); + } + return () => clearInterval(interval); + }, [polling, order]); + + const handleGeneratePromptPay = async () => { + if (!order) return + setGeneratingQr(true) + try { + const res = await fetch(`${API_BASE}/payments/charge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + orderId: order.id, + amount: order.totalAmount, + type: 'promptpay' + }) + }) + const data = await res.json() + if (res.ok && data.success && data.qrCodeUrl) { + setQrCodeUrl(data.qrCodeUrl) + setPolling(true) + } else { + alert(data.error || 'Failed to generate PromptPay QR') + } + } catch (err) { + console.error(err) + alert('Failed to connect to payment server') + } finally { + setGeneratingQr(false) + } + } + + const handleCardPay = async (e: React.FormEvent) => { + e.preventDefault() + if (!order) return + setPayingCard(true) + setCardError(null) + + try { + const omise = (window as any).Omise + if (!omise) { + throw new Error('Omise library is loading, please try again.') + } + + omise.setPublicKey(import.meta.env.VITE_OMISE_PUBLIC_KEY) + + const parts = cardExpiry.split('/') + if (parts.length !== 2) { + throw new Error('Expiry date must be in MM/YY format') + } + const expMonth = parseInt(parts[0], 10) + let expYear = parseInt(parts[1], 10) + if (expYear < 100) expYear += 2000 + + const cardData = { + name: cardName, + number: cardNumber.replace(/\s+/g, ''), + expiration_month: expMonth, + expiration_year: expYear, + security_code: cardCvv + } + + omise.createToken('card', cardData, async (statusCode: number, response: any) => { + if (statusCode === 200) { + try { + const chargeRes = await fetch(`${API_BASE}/payments/charge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + orderId: order.id, + amount: order.totalAmount, + type: 'card', + token: response.id + }) + }) + const chargeData = await chargeRes.json() + if (chargeRes.ok && chargeData.success) { + if (chargeData.authorizeUri) { + window.location.href = chargeData.authorizeUri + } else { + window.location.reload() + } + } else { + setCardError(chargeData.error || 'Card charge request failed') + setPayingCard(false) + } + } catch (err: any) { + setCardError('Failed to verify card charge on server') + setPayingCard(false) + } + } else { + setCardError(response.message || 'Validation failed for card details') + setPayingCard(false) + } + }) + } catch (err: any) { + setCardError(err.message || 'An error occurred during payment processing') + setPayingCard(false) + } + } useEffect(() => { Promise.all([ @@ -159,83 +288,265 @@ export default function OrderSuccessPage() { - {/* Banking Details */} - {settings && order.status === 'PENDING_PAYMENT' && ( -
-
- -

Payment Required

-
- {formatPrice(order.totalAmount)} + {order.status === 'PENDING_PAYMENT' ? ( +
+ {/* Payment Method Selector */} +
+ + +
-
-
-

Bank

-

{settings.bankName}

-
-
-
-

Account Number

-

{settings.bankAccountNumber}

+ {/* Bank Transfer Layout */} + {paymentMethod === 'bank' && settings && ( +
+
+
+

Payment Required

+
{formatPrice(order.totalAmount)}
+
+
+

Bank

+

{settings.bankName}

+
+
+
+

Account Number

+

{settings.bankAccountNumber}

+
+ +
+
+

Account Name

+

{settings.bankAccountName}

+
+
+

Please transfer the exact amount and upload your slip below.

+
+ +
+

Payment Slip Verification

+ {order.paymentSlipUrl ? ( +
+
+ +
+
+

Slip Uploaded Successfully

+

Status: {order.status}

+

We will verify your payment and process your order shortly.

+
+ Slip +
+ ) : ( +
+ +
+ {uploading ? ( + + ) : ( + + )} +
+

{uploading ? 'Uploading...' : 'Click or drag to upload slip'}

+

Supports JPG, PNG (Max 5MB)

+
+
+
+ )}
-
-
-

Account Name

-

{settings.bankAccountName}

+ )} + + {/* PromptPay Layout */} + {paymentMethod === 'promptpay' && ( +
+
+

PromptPay QR Code

+
{formatPrice(order.totalAmount)}
+ + {!qrCodeUrl ? ( +
+ +
+ ) : ( +
+
+ PromptPay QR Code +
+ +
+

สแกนจ่ายเงินเพื่อเสร็จสิ้นออเดอร์

+

1. กดบันทึกรูปภาพคิวอาร์โค้ดลงในมือถือของคุณ

+

2. เปิดแอป Mobile Banking และเลือกสแกนภาพจากอัลบั้ม

+

+ {polling ? "🔄 กำลังตรวจสอบยอดชำระเงินโดยอัตโนมัติ..." : "รอการจ่ายเงิน..."} +

+
+ + + + ดาวน์โหลดรูปภาพ QR Code + +
+ )}
+ )} + + {/* Credit Card Layout */} + {paymentMethod === 'card' && ( +
+
+
+

Credit / Debit Card

+
{formatPrice(order.totalAmount)}
+
+ +
+ {cardError && ( +
+ {cardError} +
+ )} + +
+ + setCardName(e.target.value)} + className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30" + placeholder="Jane Doe" + /> +
+ +
+ + { + const val = e.target.value.replace(/\D/g, '').replace(/(.{4})/g, '$1 ').trim(); + setCardNumber(val); + }} + className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30" + placeholder="4242 4242 4242 4242" + /> +
+ +
+
+ + { + const val = e.target.value.replace(/\D/g, '').replace(/(\d{2})(\d{1,2})/, '$1/$2').trim(); + setCardExpiry(val); + }} + className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30" + placeholder="MM/YY" + /> +
+
+ + setCardCvv(e.target.value.replace(/\D/g, ''))} + className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest outline-none transition-all placeholder:text-spa-stone/30" + placeholder="123" + /> +
+
+ + +
+
+ )} +
+ ) : ( +
+
+ +
+
+

ชำระเงินเสร็จสิ้น

+

ทางแบรนด์ได้รับยอดชำระของคุณเรียบร้อย ออเดอร์กำลังอยู่ระหว่างจัดเตรียมสินค้า

+

ออเดอร์สถานะปัจจุบัน: {order.status}

-

Please transfer the exact amount and upload your slip below.

)} - {/* Upload Status */} -
-

Payment Slip Verification

- - {order.paymentSlipUrl ? ( -
-
- -
-
-

Slip Uploaded Successfully

-

Status: {order.status}

-

We will verify your payment and process your order shortly.

-
- Slip -
- ) : ( -
- -
- {uploading ? ( - - ) : ( - - )} -
-

{uploading ? 'Uploading...' : 'Click or drag to upload slip'}

-

Supports JPG, PNG (Max 5MB)

-
-
-
- )} -
- {bottomSections.length > 0 && (