diff --git a/app/api/commission/checkouts/[id]/upload-link/route.ts b/app/api/commission/checkouts/[id]/upload-link/route.ts new file mode 100644 index 0000000..1f2516e --- /dev/null +++ b/app/api/commission/checkouts/[id]/upload-link/route.ts @@ -0,0 +1,27 @@ +import QRCode from "qrcode"; +import { createMobileSlipLink, mobileSlipStatus } from "@/lib/commission/mobile-slip"; +import { requireCommissionUser } from "@/lib/commission/server"; +import { errorResponse, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +export async function POST(request: Request, context: RouteContext<"/api/commission/checkouts/[id]/upload-link">) { + try { + requireSameOrigin(request); + const user = await requireCommissionUser(); + await limitRequest("commission-upload-link", user.id, 10); + const { id } = await context.params; + const { token, digest, expiresAt } = await createMobileSlipLink(id, user.id); + const url = new URL(`/commission/upload-slip#${token}`, process.env.BETTER_AUTH_URL).toString(); + const qr = await QRCode.toDataURL(url, { margin: 2, width: 240 }); + return Response.json({ url, qr, digest, expiresAt }, { headers: { "Cache-Control": "no-store" } }); + } catch (cause) { return errorResponse(cause); } +} + +export async function GET(request: Request, context: RouteContext<"/api/commission/checkouts/[id]/upload-link">) { + try { + const user = await requireCommissionUser(); + const { id } = await context.params; + const digest = new URL(request.url).searchParams.get("digest") ?? ""; + return Response.json(await mobileSlipStatus(id, user.id, digest), { headers: { "Cache-Control": "no-store" } }); + } catch (cause) { return errorResponse(cause); } +} diff --git a/app/api/commission/checkouts/[id]/verify/route.ts b/app/api/commission/checkouts/[id]/verify/route.ts index 09d95ab..37a93df 100644 --- a/app/api/commission/checkouts/[id]/verify/route.ts +++ b/app/api/commission/checkouts/[id]/verify/route.ts @@ -1,16 +1,12 @@ import { and, eq } from "drizzle-orm"; import { getDb } from "@/db"; -import { commissionCheckouts, commissionPayments, commissionTickets } from "@/db/schema"; -import { notifyCommission, requireCommissionUser } from "@/lib/commission/server"; -import { notifyPaidTicketDiscord } from "@/lib/commission/discord"; -import { verifyCommissionSlip } from "@/lib/commission/payment"; -import { getMediaStorage } from "@/lib/media/storage"; -import { inspectImage } from "@/lib/media/inspect"; +import { commissionCheckouts } from "@/db/schema"; +import { requireCommissionUser } from "@/lib/commission/server"; +import { invalidateMobileSlipLink } from "@/lib/commission/mobile-slip"; +import { MAX_SLIP_BYTES, verifyAndCreateTicket } from "@/lib/commission/slip-upload"; import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http"; import { limitRequest } from "@/lib/security/rate-limit"; -const MAX_SLIP_BYTES = 6 * 1024 * 1024; - export async function POST(request: Request, context: RouteContext<"/api/commission/checkouts/[id]/verify">) { try { requireSameOrigin(request); @@ -20,47 +16,15 @@ export async function POST(request: Request, context: RouteContext<"/api/commiss const [checkout] = await getDb().select().from(commissionCheckouts) .where(and(eq(commissionCheckouts.id, id), eq(commissionCheckouts.userId, user.id))).limit(1); if (!checkout) throw new HttpError(404, "checkout-not-found"); - const [existing] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) - .where(eq(commissionTickets.checkoutId, id)).limit(1); - if (existing) return Response.json({ ticketId: existing.id }); if (!request.headers.get("content-type")?.startsWith("multipart/form-data;")) throw new HttpError(415, "expected-multipart"); return await withUploadSlot(async () => { const form = await boundedBody(request, MAX_SLIP_BYTES + 64 * 1024).formData(); - const file = form.get("file"); - if (!(file instanceof File) || file.size === 0 || file.size > MAX_SLIP_BYTES || - !["image/png", "image/jpeg", "image/webp"].includes(file.type)) - throw new HttpError(415, "invalid-slip-image"); - const bytes = new Uint8Array(await file.arrayBuffer()); - await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp"); - const verified = await verifyCommissionSlip(file, checkout.amountBaht, checkout.createdAt); - const objectKey = `commission/slips/${crypto.randomUUID()}`; - const storage = await getMediaStorage(); - await storage.write(objectKey, bytes, { type: file.type, acl: "private" }); - let ticketId: string; - try { - ticketId = await getDb().transaction(async (tx) => { - const [ticket] = await tx.insert(commissionTickets).values({ - checkoutId: checkout.id, userId: user.id, - }).returning({ id: commissionTickets.id }); - await tx.insert(commissionPayments).values({ - checkoutId: checkout.id, ticketId: ticket.id, slipObjectKey: objectKey, - slipMimeType: file.type, ...verified, - }); - return ticket.id; - }); - } catch (cause) { - await storage.delete(objectKey).catch(() => undefined); - const [paid] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) - .where(eq(commissionTickets.checkoutId, checkout.id)).limit(1); - if (paid) return Response.json({ ticketId: paid.id }); - if (cause && typeof cause === "object" && "code" in cause && cause.code === "23505") - throw new HttpError(409, "payment-already-used"); - throw cause; - } - await notifyCommission(ticketId, user.id); - await notifyPaidTicketDiscord(ticketId, checkout.amountBaht); - return Response.json({ ticketId }, { status: 201, headers: { "Cache-Control": "no-store" } }); + const result = await verifyAndCreateTicket(checkout, form.get("file")); + await invalidateMobileSlipLink(checkout.id).catch(() => undefined); + return Response.json({ ticketId: result.ticketId }, { + status: result.created ? 201 : 200, headers: { "Cache-Control": "no-store" }, + }); }); } catch (cause) { return errorResponse(cause); } } diff --git a/app/api/commission/mobile-slip/route.test.ts b/app/api/commission/mobile-slip/route.test.ts new file mode 100644 index 0000000..77e4977 --- /dev/null +++ b/app/api/commission/mobile-slip/route.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { HttpError } from "@/lib/security/http"; + +const authorizeMobileSlip = vi.fn(); +const consumeMobileSlipLink = vi.fn(); +const recordMobileSlipError = vi.fn(); +const verifyAndCreateTicket = vi.fn(); +const limitRequest = vi.fn(); + +vi.mock("@/lib/commission/mobile-slip", () => ({ + authorizeMobileSlip, consumeMobileSlipLink, recordMobileSlipError, +})); +vi.mock("@/lib/commission/slip-upload", () => ({ MAX_SLIP_BYTES: 6 * 1024 * 1024, verifyAndCreateTicket })); +vi.mock("@/lib/security/rate-limit", () => ({ limitRequest })); + +const { GET, POST } = await import("./route"); +const token = "x".repeat(43); +const checkout = { id: "checkout-1", userId: "customer-1", amountBaht: 150 }; + +function upload(origin = "https://guide.sudloh.com") { + const form = new FormData(); + form.set("file", new File(["image"], "slip.png", { type: "image/png" })); + return new Request("https://guide.sudloh.com/api/commission/mobile-slip", { + method: "POST", headers: { Origin: origin, Authorization: `Bearer ${token}` }, body: form, + }); +} + +describe("commission phone slip upload", () => { + beforeEach(() => { + process.env.BETTER_AUTH_URL = "https://guide.sudloh.com"; + vi.clearAllMocks(); + authorizeMobileSlip.mockResolvedValue({ checkout, ticketId: null, digest: "digest-1" }); + verifyAndCreateTicket.mockResolvedValue({ ticketId: "ticket-1", created: true }); + consumeMobileSlipLink.mockResolvedValue(undefined); + recordMobileSlipError.mockResolvedValue(undefined); + }); + + it("checks the bearer link without requiring a login", async () => { + const response = await GET(new Request("https://guide.sudloh.com/api/commission/mobile-slip", { + headers: { Authorization: `Bearer ${token}` }, + })); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ amountBaht: 150, complete: false }); + expect(authorizeMobileSlip).toHaveBeenCalledWith(token); + }); + + it("verifies a phone slip and consumes the link", async () => { + const response = await POST(upload()); + expect(response.status).toBe(201); + expect(verifyAndCreateTicket).toHaveBeenCalledWith(checkout, expect.any(File)); + expect(consumeMobileSlipLink).toHaveBeenCalledWith("checkout-1", "digest-1"); + }); + + it("rejects cross-origin uploads before using the link", async () => { + expect((await POST(upload("https://example.com"))).status).toBe(403); + expect(authorizeMobileSlip).not.toHaveBeenCalled(); + }); + + it("reports verification failures for the desktop and permits retry", async () => { + const failure = new HttpError(422, "slip-rejected:200402"); + verifyAndCreateTicket.mockRejectedValueOnce(failure); + const response = await POST(upload()); + expect(response.status).toBe(422); + expect(recordMobileSlipError).toHaveBeenCalledWith("digest-1", "customer-1", failure); + expect(consumeMobileSlipLink).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/commission/mobile-slip/route.ts b/app/api/commission/mobile-slip/route.ts new file mode 100644 index 0000000..b0ec46f --- /dev/null +++ b/app/api/commission/mobile-slip/route.ts @@ -0,0 +1,42 @@ +import { authorizeMobileSlip, consumeMobileSlipLink, recordMobileSlipError } from "@/lib/commission/mobile-slip"; +import { MAX_SLIP_BYTES, verifyAndCreateTicket } from "@/lib/commission/slip-upload"; +import { boundedBody, errorResponse, HttpError, requireSameOrigin, withUploadSlot } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +function bearerToken(request: Request) { + const value = request.headers.get("authorization") ?? ""; + if (!value.startsWith("Bearer ")) throw new HttpError(401, "upload-link-required"); + return value.slice(7); +} + +export async function GET(request: Request) { + try { + const { checkout, ticketId } = await authorizeMobileSlip(bearerToken(request)); + return Response.json({ amountBaht: checkout.amountBaht, complete: Boolean(ticketId) }, + { headers: { "Cache-Control": "no-store" } }); + } catch (cause) { return errorResponse(cause); } +} + +export async function POST(request: Request) { + let handoff: Awaited> | undefined; + try { + requireSameOrigin(request); + handoff = await authorizeMobileSlip(bearerToken(request)); + await limitRequest("commission-mobile-slip-token", handoff.digest, 10); + if (handoff.ticketId) return Response.json({ complete: true }, { headers: { "Cache-Control": "no-store" } }); + if (!request.headers.get("content-type")?.startsWith("multipart/form-data;")) + throw new HttpError(415, "expected-multipart"); + const { checkout, digest } = handoff; + const result = await withUploadSlot(async () => { + const form = await boundedBody(request, MAX_SLIP_BYTES + 64 * 1024).formData(); + return verifyAndCreateTicket(checkout, form.get("file")); + }); + await consumeMobileSlipLink(checkout.id, digest).catch(() => undefined); + return Response.json({ complete: true }, { + status: result.created ? 201 : 200, headers: { "Cache-Control": "no-store" }, + }); + } catch (cause) { + if (handoff) await recordMobileSlipError(handoff.digest, handoff.checkout.userId, cause).catch(() => undefined); + return errorResponse(cause); + } +} diff --git a/app/commission/page.tsx b/app/commission/page.tsx index db51961..db391fa 100644 --- a/app/commission/page.tsx +++ b/app/commission/page.tsx @@ -14,7 +14,7 @@ async function CommissionContent() { await connection(); const session = await getCustomerSession(); if (!session) return
-

เข้าสู่ระบบหรือสมัครสมาชิกก่อนเริ่มคำขอคอมมิชชัน

+

เข้าสู่ระบบหรือสมัครสมาชิกก่อนเริ่มคำขอ

diff --git a/app/commission/upload-slip/page.tsx b/app/commission/upload-slip/page.tsx new file mode 100644 index 0000000..d5fc44e --- /dev/null +++ b/app/commission/upload-slip/page.tsx @@ -0,0 +1,9 @@ +import { MobileSlipForm } from "@/components/commission/mobile-slip-form"; +import { SiteHeader } from "@/components/public/site-header"; + +export default function UploadSlipPage() { + return
+

อัปโหลดสลิป

+ +
; +} diff --git a/components/commission/mobile-slip-form.tsx b/components/commission/mobile-slip-form.tsx new file mode 100644 index 0000000..0cc90eb --- /dev/null +++ b/components/commission/mobile-slip-form.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useState, type FormEvent } from "react"; +import { verificationError } from "@/lib/commission/verification-error"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; + +export function MobileSlipForm() { + const [amount, setAmount] = useState(null); + const [ready, setReady] = useState(false); + const [complete, setComplete] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const value = window.location.hash.slice(1); + Promise.resolve().then(() => { + if (!value) throw new Error("missing upload link"); + return fetch("/api/commission/mobile-slip", { headers: { Authorization: `Bearer ${value}` }, cache: "no-store" }); + }) + .then(async (response) => { + const result = await response.json(); + if (!response.ok) throw new Error(result.error); + setAmount(result.amountBaht); + setComplete(result.complete); + setReady(true); + }) + .catch(() => setError("ลิงก์อัปโหลดหมดอายุหรือไม่ถูกต้อง กรุณาสร้าง QR ใหม่บนคอมพิวเตอร์")); + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); setBusy(true); setError(""); + try { + const response = await fetch("/api/commission/mobile-slip", { + method: "POST", headers: { Authorization: `Bearer ${window.location.hash.slice(1)}` }, + body: new FormData(event.currentTarget), + }); + const result = await response.json(); + if (!response.ok) throw new Error(verificationError(result.error)); + setComplete(true); + } catch (cause) { setError(cause instanceof Error ? cause.message : "ตรวจสอบสลิปไม่สำเร็จ"); } + finally { setBusy(false); } + } + + if (complete) return ตรวจสอบสลิปสำเร็จแล้ว กลับไปดู Ticket บนคอมพิวเตอร์ได้เลย; + return
+ {ready && <>

ยอดชำระ ฿{amount}

+ เลือกรูปสลิปจากแอปธนาคาร + + +
} + {error && {error}} +
; +} diff --git a/components/commission/mobile-slip-handoff.tsx b/components/commission/mobile-slip-handoff.tsx new file mode 100644 index 0000000..90c4d6e --- /dev/null +++ b/components/commission/mobile-slip-handoff.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { verificationError } from "@/lib/commission/verification-error"; + +type LinkDetails = { qr: string; digest: string; expiresAt: number }; + +export function MobileSlipHandoff({ checkoutId }: { checkoutId: string }) { + const router = useRouter(); + const [link, setLink] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [expired, setExpired] = useState(false); + const [error, setError] = useState(""); + const [now, setNow] = useState(Date.now); + const secondsLeft = link ? Math.max(0, Math.ceil((link.expiresAt - now) / 1000)) : 0; + const timeLeft = `${String(Math.floor(secondsLeft / 60)).padStart(2, "0")}:${String(secondsLeft % 60).padStart(2, "0")}`; + + async function createLink() { + setBusy(true); setError(""); + try { + const response = await fetch(`/api/commission/checkouts/${checkoutId}/upload-link`, { method: "POST" }); + const result = await response.json(); + if (!response.ok) throw new Error(verificationError(result.error)); + setLink(result as LinkDetails); + setNow(Date.now()); + setExpired(false); + setOpen(true); + } catch (cause) { setError(cause instanceof Error ? cause.message : "สร้าง QR ไม่สำเร็จ"); } + finally { setBusy(false); } + } + + useEffect(() => { + if (!link) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [link]); + + useEffect(() => { + if (!link) return; + let active = true; + const endpoint = `/api/commission/checkouts/${checkoutId}/upload-link?digest=${link.digest}`; + const check = async () => { + try { + const response = await fetch(endpoint, { cache: "no-store" }); + if (!response.ok) return; + const result = await response.json(); + if (!active) return; + if (result.state === "complete") router.push(`/commission/tickets/${result.ticketId}`); + else if (result.state === "expired") setExpired(true); + else setError(result.error ? verificationError(result.error) : ""); + } catch { /* The next event or reconnect checks again. */ } + }; + const source = new EventSource("/api/commission/events"); + source.addEventListener("open", check); + source.addEventListener("changed", check); + const timer = window.setTimeout(() => void check(), Math.max(0, link.expiresAt - Date.now() + 500)); + void check(); + return () => { + active = false; + source.removeEventListener("open", check); + source.removeEventListener("changed", check); + source.close(); + window.clearTimeout(timer); + }; + }, [checkoutId, link, router]); + + return
+ + + + + อัปโหลดสลิปจากมือถือ + สแกน QR ด้วยมือถือ แล้วเลือกรูปสลิปจากแอปธนาคาร + + {link &&
+ {!expired && <> + {/* eslint-disable-next-line @next/next/no-img-element */} + QR สำหรับเปิดหน้าอัปโหลดสลิปบนมือถือ +

เวลาที่เหลือ {timeLeft}

+ } + {expired &&

ลิงก์หมดอายุแล้ว ปิดหน้าต่างนี้แล้วกดอัปโหลดจากมือถืออีกครั้ง

} +
} + {error && {error}} +
+
+ {error && !open && {error}} +
; +} diff --git a/components/commission/payment-form.tsx b/components/commission/payment-form.tsx index 44641cc..6bcd24d 100644 --- a/components/commission/payment-form.tsx +++ b/components/commission/payment-form.tsx @@ -2,35 +2,12 @@ import { useState, type FormEvent } from "react"; import { useRouter } from "next/navigation"; +import { MobileSlipHandoff } from "@/components/commission/mobile-slip-handoff"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; - -function verificationError(code: string | undefined): string { - const reason = code?.startsWith("slip-rejected:") ? code.slice("slip-rejected:".length) - : code?.startsWith("slip2go-service:") ? code.slice("slip2go-service:".length) : code; - switch (reason) { - case "200000": return "พบสลิป แต่ Slip2Go ยังไม่ได้ยืนยันเงื่อนไขผู้รับและยอดเงิน กรุณาแจ้งผู้ดูแลระบบ (200000)"; - case "200401": return "บัญชีผู้รับในสลิปไม่ตรงกับบัญชีที่ตั้งไว้ (200401)"; - case "200402": return "ยอดเงินในสลิปไม่ตรงกับยอดคำขอ (200402)"; - case "200403": return "วันที่โอนในสลิปไม่ตรงเงื่อนไข (200403)"; - case "200404": return "Slip2Go ไม่พบข้อมูลสลิปในระบบธนาคาร (200404)"; - case "200500": return "Slip2Go ระบุว่าสลิปไม่ถูกต้อง (200500)"; - case "200501": return "สลิปนี้เคยถูกตรวจสอบแล้ว หากชำระเงินแล้ว กรุณาติดต่อผู้ดูแลก่อนโอนซ้ำ (200501)"; - case "200502": return "ธนาคารไม่สามารถตรวจสอบสลิปได้ในขณะนี้ กรุณาลองอีกครั้ง (200502)"; - case "400001": - case "400002": return `อ่าน QR ในรูปสลิปไม่ได้ กรุณาอัปโหลดรูปสลิปจากแอปธนาคาร (${reason})`; - case "400400": return "ตั้งค่าเงื่อนไขตรวจสอบสลิปไม่ถูกต้อง กรุณาแจ้งผู้ดูแลระบบ (400400)"; - case "401001": return "Slip2Go ไม่ยอมรับ API Secret ที่ตั้งไว้ กรุณาแจ้งผู้ดูแลระบบ (401001)"; - case "401005": return "โทเคน Slip2Go หมด กรุณาแจ้งผู้ดูแลระบบ (401005)"; - case "slip-amount-or-date-mismatch": return "ยอดเงินหรือวันเวลาในสลิปไม่ตรงกับคำขอนี้ กรุณาใช้สลิปจากการชำระเงินครั้งนี้"; - case "payment-already-used": return "สลิปนี้ถูกใช้กับคำขออื่นแล้ว"; - default: return reason && /^\d{6}$/.test(reason) - ? `ตรวจสอบสลิปไม่ผ่าน (รหัส ${reason}) กรุณาแจ้งผู้ดูแลระบบ` - : "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง"; - } -} +import { verificationError } from "@/lib/commission/verification-error"; export function CommissionPaymentForm({ checkoutId }: { checkoutId: string }) { const router = useRouter(); @@ -50,7 +27,8 @@ export function CommissionPaymentForm({ checkoutId }: { checkoutId: string }) { } return
อัปโหลดสลิปที่ชำระเงินแล้ว - + + {error && {error}}
; } diff --git a/components/commission/request-form.tsx b/components/commission/request-form.tsx index 169d1c4..7935f57 100644 --- a/components/commission/request-form.tsx +++ b/components/commission/request-form.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { XIcon } from "lucide-react"; +import { toast } from "sonner"; import { ArtifactSelect, CharacterSelect, WeaponSelect } from "@/components/admin/ui/catalog-select"; import { ConstellationSelect, RefinementSelect } from "@/components/admin/ui/option-select"; import { Button } from "@/components/ui/button"; @@ -62,7 +63,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { async function submit() { const validationError = requestError(request); - if (validationError) { setError(validationError); return; } + if (validationError) { setError(validationError); toast.error(validationError); return; } setBusy(true); setError(""); try { const response = await fetch("/api/commission/checkouts", { method: "POST", @@ -73,7 +74,11 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { } const { checkoutId } = await response.json(); router.push(`/commission/pay/${checkoutId}`); - } catch (cause) { setError(cause instanceof Error ? cause.message : "สร้างคำขอไม่สำเร็จ"); } + } catch (cause) { + const message = cause instanceof Error ? cause.message : "สร้างคำขอไม่สำเร็จ"; + setError(message); + toast.error(message); + } finally { setBusy(false); } } @@ -142,12 +147,25 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { value={weaponKey} label={`เลือกอาวุธเปรียบเทียบชิ้นที่ ${weaponIndex + 1}`} placeholder={`อาวุธชิ้นที่ ${weaponIndex + 1}`} emptyLabel={set.characterKey ? "ไม่พบอาวุธ" : "เลือกตัวละครก่อน"} onValueChange={(next) => { - const weapons = structuredClone(request.weapons); weapons[index].weaponKeys[weaponIndex] = next; patch({ weapons }); + const weapons = structuredClone(request.weapons); + delete weapons[index].refinements?.[weaponKey]; + weapons[index].weaponKeys[weaponIndex] = next; + if (next) weapons[index].refinements = { ...weapons[index].refinements, [next]: "1" }; + patch({ weapons }); }} />
+ { + const weapons = structuredClone(request.weapons); + weapons[index].refinements = { ...weapons[index].refinements, + [weaponKey]: refinement as "1" | "2" | "3" | "4" | "5" }; + patch({ weapons }); + }} triggerProps={{ size: "sm", className: "w-16 shrink-0 px-1 text-xs" }} /> : null}

item.key === set.characterKey)?.name}> {catalog.characters.find((item) => item.key === set.characterKey)?.name ?? "เลือกตัวละคร"}

@@ -175,7 +193,7 @@ export function CommissionRequestForm({ catalog }: { catalog: Catalog }) { }}>เพิ่มอาวุธ )} - + เปรียบเทียบกลุ่มดาว - ฿20 ต่อระดับเลือกตัวละครและกลุ่มดาวอย่างน้อย 1 ระดับ หากไม่มีทีมจะมีค่าบริการพื้นฐาน ฿100 {request.constellations.map((set, index) => diff --git a/components/commission/request-summary.tsx b/components/commission/request-summary.tsx index 5539416..1ada932 100644 --- a/components/commission/request-summary.tsx +++ b/components/commission/request-summary.tsx @@ -71,7 +71,9 @@ export function CommissionRequestSummary({ request, catalog, amountBaht }: {
{set.weaponKeys.map((key) => { const weapon = weapons.get(key); - return weapon ?

{weapon.name}

+ return weapon ?
+
R{set.refinements?.[key] ?? "1"}
+

{weapon.name}

:

{key}

; })}
diff --git a/lib/commission/mobile-slip.test.ts b/lib/commission/mobile-slip.test.ts new file mode 100644 index 0000000..acc400c --- /dev/null +++ b/lib/commission/mobile-slip.test.ts @@ -0,0 +1,52 @@ +import { createHash } from "node:crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const limit = vi.fn(); +const where = vi.fn(() => ({ limit })); +const from = vi.fn(() => ({ where })); +const select = vi.fn(() => ({ from })); +const get = vi.fn(); +const set = vi.fn(); +const redis = { get, set }; + +vi.mock("server-only", () => ({})); +vi.mock("@/db", () => ({ getDb: () => ({ select }) })); +vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => redis })); + +const { authorizeMobileSlip, createMobileSlipLink } = await import("./mobile-slip"); +const checkout = { id: "checkout-1", userId: "customer-1", amountBaht: 150 }; + +describe("commission mobile slip links", () => { + beforeEach(() => { vi.clearAllMocks(); set.mockResolvedValue("OK"); }); + + it("requires checkout ownership before issuing a link", async () => { + limit.mockResolvedValueOnce([]); + await expect(createMobileSlipLink("checkout-1", "wrong-user")).rejects.toMatchObject({ status: 404 }); + expect(set).not.toHaveBeenCalled(); + }); + + it("issues a ten-minute link and rejects an expired or replaced token", async () => { + limit.mockResolvedValueOnce([{ id: checkout.id }]).mockResolvedValueOnce([]); + const { token, digest } = await createMobileSlipLink(checkout.id, checkout.userId); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(digest).toBe(createHash("sha256").update(token).digest("hex")); + expect(set).toHaveBeenCalledWith(expect.stringContaining(":token:"), + JSON.stringify({ checkoutId: checkout.id, userId: checkout.userId }), "EX", 600); + expect(set).toHaveBeenCalledWith(expect.stringContaining(":active:"), digest, "EX", 600); + + get.mockResolvedValueOnce(null); + await expect(authorizeMobileSlip(token)).rejects.toMatchObject({ status: 404 }); + get.mockResolvedValueOnce(JSON.stringify({ checkoutId: checkout.id, userId: checkout.userId })) + .mockResolvedValueOnce("another-digest"); + await expect(authorizeMobileSlip(token)).rejects.toMatchObject({ status: 404 }); + }); + + it("accepts the active token only for its stored checkout", async () => { + const token = "x".repeat(43); + const digest = createHash("sha256").update(token).digest("hex"); + get.mockResolvedValueOnce(JSON.stringify({ checkoutId: checkout.id, userId: checkout.userId })) + .mockResolvedValueOnce(digest); + limit.mockResolvedValueOnce([checkout]).mockResolvedValueOnce([]); + expect(await authorizeMobileSlip(token)).toEqual({ checkout, ticketId: null, digest }); + }); +}); diff --git a/lib/commission/mobile-slip.ts b/lib/commission/mobile-slip.ts new file mode 100644 index 0000000..1a635f7 --- /dev/null +++ b/lib/commission/mobile-slip.ts @@ -0,0 +1,81 @@ +import "server-only"; + +import { createHash, randomBytes } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { commissionCheckouts, commissionTickets } from "@/db/schema"; +import { getRedisClient, redisEventChannel } from "@/lib/redis/client"; +import { HttpError } from "@/lib/security/http"; + +const TTL_SECONDS = 600; +const prefix = () => `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-mobile-slip`; +const activeKey = (checkoutId: string) => `${prefix()}:active:${checkoutId}`; +const tokenKey = (digest: string) => `${prefix()}:token:${digest}`; +const errorKey = (digest: string) => `${prefix()}:error:${digest}`; +const digestToken = (token: string) => createHash("sha256").update(token).digest("hex"); + +export async function createMobileSlipLink(checkoutId: string, userId: string) { + const [checkout] = await getDb().select({ id: commissionCheckouts.id }).from(commissionCheckouts) + .where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1); + if (!checkout) throw new HttpError(404, "checkout-not-found"); + const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, checkoutId)).limit(1); + if (ticket) throw new HttpError(409, "checkout-already-paid"); + + const token = randomBytes(32).toString("base64url"); + const digest = digestToken(token); + const redis = await getRedisClient(); + await redis.set(tokenKey(digest), JSON.stringify({ checkoutId, userId }), "EX", TTL_SECONDS); + await redis.set(activeKey(checkoutId), digest, "EX", TTL_SECONDS); + return { token, digest, expiresAt: Date.now() + TTL_SECONDS * 1000 }; +} + +export async function authorizeMobileSlip(token: string) { + if (!/^[A-Za-z0-9_-]{43}$/.test(token)) throw new HttpError(404, "upload-link-invalid"); + const digest = digestToken(token); + const redis = await getRedisClient(); + const raw = await redis.get(tokenKey(digest)); + if (!raw) throw new HttpError(404, "upload-link-expired"); + const { checkoutId, userId } = JSON.parse(raw) as { checkoutId: string; userId: string }; + if (await redis.get(activeKey(checkoutId)) !== digest) + throw new HttpError(404, "upload-link-expired"); + const [checkout] = await getDb().select().from(commissionCheckouts) + .where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1); + if (!checkout) throw new HttpError(404, "checkout-not-found"); + const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, checkoutId)).limit(1); + return { checkout, ticketId: ticket?.id ?? null, digest }; +} + +export async function mobileSlipStatus(checkoutId: string, userId: string, digest: string) { + const [checkout] = await getDb().select({ id: commissionCheckouts.id }).from(commissionCheckouts) + .where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1); + if (!checkout) throw new HttpError(404, "checkout-not-found"); + const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, checkoutId)).limit(1); + if (ticket) return { state: "complete", ticketId: ticket.id }; + if (!/^[a-f0-9]{64}$/.test(digest)) throw new HttpError(400, "invalid-upload-link"); + const redis = await getRedisClient(); + if (await redis.get(activeKey(checkoutId)) !== digest) return { state: "expired" }; + return { state: "pending", error: await redis.get(errorKey(digest)) }; +} + +export async function recordMobileSlipError(digest: string, userId: string, cause: unknown) { + const code = cause instanceof HttpError ? cause.message : "service-unavailable"; + const redis = await getRedisClient(); + const ttl = await redis.ttl(tokenKey(digest)); + if (ttl > 0) { + await redis.set(errorKey(digest), code, "EX", ttl); + await redis.publish(redisEventChannel(`commission:user:${userId}`), "changed"); + } +} + +export async function consumeMobileSlipLink(checkoutId: string, digest: string) { + const redis = await getRedisClient(); + await redis.eval("if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0", 1, + activeKey(checkoutId), digest); +} + +export async function invalidateMobileSlipLink(checkoutId: string) { + await (await getRedisClient()).del(activeKey(checkoutId)); +} diff --git a/lib/commission/request.test.ts b/lib/commission/request.test.ts index 60afae4..7734e8a 100644 --- a/lib/commission/request.test.ts +++ b/lib/commission/request.test.ts @@ -28,6 +28,19 @@ describe("commission request pricing and shape", () => { expect(commissionRequestSchema.safeParse({ ...selected, teams: [{ members: selected.teams[0].members.map((member, index) => index === 0 ? { ...member, refinement: "6" } : member) }] }).success).toBe(false); }); + it("keeps each compared weapon's refinement and defaults older requests to R1", () => { + const selected = { ...request, weapons: [{ characterKey: "a", weaponKeys: ["weapon-a", "weapon-b"], + refinements: { "weapon-a": "5", "weapon-b": "2" } }] }; + expect(commissionRequestSchema.parse(selected).weapons[0].refinements).toEqual({ + "weapon-a": "5", "weapon-b": "2", + }); + expect(commissionPrice(commissionRequestSchema.parse(selected))).toBe(180); + expect(commissionRequestSchema.parse(request).weapons[0].refinements).toBeUndefined(); + expect(commissionRequestSchema.safeParse({ ...selected, weapons: [{ ...selected.weapons[0], + refinements: { "weapon-a": "6" } }] }).success).toBe(false); + expect(commissionRequestSchema.safeParse({ ...selected, weapons: [{ ...selected.weapons[0], + refinements: { "weapon-c": "3" } }] }).success).toBe(false); + }); it("prices independent request types together", () => { expect(commissionPrice(commissionRequestSchema.parse(request))).toBe(200); expect(commissionPrice(commissionRequestSchema.parse({ teams: [], weapons: [], diff --git a/lib/commission/request.ts b/lib/commission/request.ts index 18217ad..de8f110 100644 --- a/lib/commission/request.ts +++ b/lib/commission/request.ts @@ -12,10 +12,14 @@ const team = z.object({ members: z.array(member).length(4) }).refine( ({ members }) => new Set(members.map((item) => item.characterKey)).size === 4, "A team needs four different characters", ); -const weaponComparison = z.object({ characterKey: key, weaponKeys: z.array(key).min(2).max(100) }).refine( - ({ weaponKeys }) => new Set(weaponKeys).size === weaponKeys.length, - "Choose different weapons", -); +const weaponComparison = z.object({ + characterKey: key, + weaponKeys: z.array(key).min(2).max(100), + refinements: z.record(key, z.enum(["1", "2", "3", "4", "5"])).optional(), +}).refine(({ weaponKeys }) => new Set(weaponKeys).size === weaponKeys.length, "Choose different weapons") + .refine(({ weaponKeys, refinements }) => + Object.keys(refinements ?? {}).every((weaponKey) => weaponKeys.includes(weaponKey)), + "Refinements must belong to selected weapons"); const constellationComparison = z.object({ characterKey: key, levels: z.array(z.number().int().min(0).max(6)).min(1).max(7), diff --git a/lib/commission/slip-upload.test.ts b/lib/commission/slip-upload.test.ts new file mode 100644 index 0000000..d6af744 --- /dev/null +++ b/lib/commission/slip-upload.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; + +const set = vi.fn().mockResolvedValue(null); +const getRedisClient = vi.fn().mockResolvedValue({ set }); +const inspectImage = vi.fn(); +const verifyCommissionSlip = vi.fn(); + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/redis/client", () => ({ getRedisClient })); +vi.mock("@/lib/media/inspect", () => ({ inspectImage })); +vi.mock("@/lib/commission/payment", () => ({ verifyCommissionSlip })); + +const { verifyAndCreateTicket } = await import("./slip-upload"); + +describe("commission slip submission lock", () => { + it("rejects a second verification for the same checkout before calling Slip2Go", async () => { + const checkout = { id: "checkout-1", userId: "customer-1", amountBaht: 150 }; + const file = new File(["image"], "slip.png", { type: "image/png" }); + await expect(verifyAndCreateTicket(checkout as Parameters[0], file)) + .rejects.toMatchObject({ status: 409, message: "slip-verification-in-progress" }); + expect(set).toHaveBeenCalledWith(expect.stringContaining(":commission-slip-lock:checkout-1"), + expect.any(String), "EX", 60, "NX"); + expect(inspectImage).not.toHaveBeenCalled(); + expect(verifyCommissionSlip).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/commission/slip-upload.ts b/lib/commission/slip-upload.ts new file mode 100644 index 0000000..b53f23a --- /dev/null +++ b/lib/commission/slip-upload.ts @@ -0,0 +1,66 @@ +import "server-only"; + +import { eq } from "drizzle-orm"; +import { getDb } from "@/db"; +import { commissionCheckouts, commissionPayments, commissionTickets } from "@/db/schema"; +import { notifyPaidTicketDiscord } from "@/lib/commission/discord"; +import { verifyCommissionSlip } from "@/lib/commission/payment"; +import { notifyCommission } from "@/lib/commission/server"; +import { getMediaStorage } from "@/lib/media/storage"; +import { inspectImage } from "@/lib/media/inspect"; +import { getRedisClient } from "@/lib/redis/client"; +import { HttpError } from "@/lib/security/http"; + +export const MAX_SLIP_BYTES = 6 * 1024 * 1024; +export type Checkout = typeof commissionCheckouts.$inferSelect; + +export async function verifyAndCreateTicket(checkout: Checkout, file: FormDataEntryValue | null) { + if (!(file instanceof File) || file.size === 0 || file.size > MAX_SLIP_BYTES || + !["image/png", "image/jpeg", "image/webp"].includes(file.type)) + throw new HttpError(415, "invalid-slip-image"); + + const redis = await getRedisClient(); + const lockKey = `${process.env.REDIS_SECURITY_PREFIX || "buzz:security"}:commission-slip-lock:${checkout.id}`; + const lockId = crypto.randomUUID(); + if (await redis.set(lockKey, lockId, "EX", 60, "NX") !== "OK") + throw new HttpError(409, "slip-verification-in-progress"); + try { + const [existing] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, checkout.id)).limit(1); + if (existing) return { ticketId: existing.id, created: false }; + + const bytes = new Uint8Array(await file.arrayBuffer()); + await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp"); + const verified = await verifyCommissionSlip(file, checkout.amountBaht, checkout.createdAt); + const objectKey = `commission/slips/${crypto.randomUUID()}`; + const storage = await getMediaStorage(); + await storage.write(objectKey, bytes, { type: file.type, acl: "private" }); + let ticketId: string; + try { + ticketId = await getDb().transaction(async (tx) => { + const [ticket] = await tx.insert(commissionTickets).values({ + checkoutId: checkout.id, userId: checkout.userId, + }).returning({ id: commissionTickets.id }); + await tx.insert(commissionPayments).values({ + checkoutId: checkout.id, ticketId: ticket.id, slipObjectKey: objectKey, + slipMimeType: file.type, ...verified, + }); + return ticket.id; + }); + } catch (cause) { + await storage.delete(objectKey).catch(() => undefined); + const [paid] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) + .where(eq(commissionTickets.checkoutId, checkout.id)).limit(1); + if (paid) return { ticketId: paid.id, created: false }; + if (cause && typeof cause === "object" && "code" in cause && cause.code === "23505") + throw new HttpError(409, "payment-already-used"); + throw cause; + } + await notifyCommission(ticketId, checkout.userId); + await notifyPaidTicketDiscord(ticketId, checkout.amountBaht); + return { ticketId, created: true }; + } finally { + await redis.eval("if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0", 1, + lockKey, lockId).catch(() => undefined); + } +} diff --git a/lib/commission/verification-error.ts b/lib/commission/verification-error.ts new file mode 100644 index 0000000..b5d626b --- /dev/null +++ b/lib/commission/verification-error.ts @@ -0,0 +1,25 @@ +export function verificationError(code: string | undefined): string { + const reason = code?.startsWith("slip-rejected:") ? code.slice("slip-rejected:".length) + : code?.startsWith("slip2go-service:") ? code.slice("slip2go-service:".length) : code; + switch (reason) { + case "200000": return "พบสลิป แต่ Slip2Go ยังไม่ได้ยืนยันเงื่อนไขผู้รับและยอดเงิน กรุณาแจ้งผู้ดูแลระบบ (200000)"; + case "200401": return "บัญชีผู้รับในสลิปไม่ตรงกับบัญชีที่ตั้งไว้ (200401)"; + case "200402": return "ยอดเงินในสลิปไม่ตรงกับยอดคำขอ (200402)"; + case "200403": return "วันที่โอนในสลิปไม่ตรงเงื่อนไข (200403)"; + case "200404": return "Slip2Go ไม่พบข้อมูลสลิปในระบบธนาคาร (200404)"; + case "200500": return "Slip2Go ระบุว่าสลิปไม่ถูกต้อง (200500)"; + case "200501": return "สลิปนี้เคยถูกตรวจสอบแล้ว หากชำระเงินแล้ว กรุณาติดต่อผู้ดูแลก่อนโอนซ้ำ (200501)"; + case "200502": return "ธนาคารไม่สามารถตรวจสอบสลิปได้ในขณะนี้ กรุณาลองอีกครั้ง (200502)"; + case "400001": + case "400002": return `อ่าน QR ในรูปสลิปไม่ได้ กรุณาอัปโหลดรูปสลิปจากแอปธนาคาร (${reason})`; + case "400400": return "ตั้งค่าเงื่อนไขตรวจสอบสลิปไม่ถูกต้อง กรุณาแจ้งผู้ดูแลระบบ (400400)"; + case "401001": return "Slip2Go ไม่ยอมรับ API Secret ที่ตั้งไว้ กรุณาแจ้งผู้ดูแลระบบ (401001)"; + case "401005": return "โทเคน Slip2Go หมด กรุณาแจ้งผู้ดูแลระบบ (401005)"; + case "slip-amount-or-date-mismatch": return "ยอดเงินหรือวันเวลาในสลิปไม่ตรงกับคำขอนี้ กรุณาใช้สลิปจากการชำระเงินครั้งนี้"; + case "payment-already-used": return "สลิปนี้ถูกใช้กับคำขออื่นแล้ว"; + case "slip-verification-in-progress": return "กำลังตรวจสอบสลิปอยู่ กรุณารอสักครู่"; + default: return reason && /^\d{6}$/.test(reason) + ? `ตรวจสอบสลิปไม่ผ่าน (รหัส ${reason}) กรุณาแจ้งผู้ดูแลระบบ` + : "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง"; + } +}