From d85687c33272d2f330b4c8decf5db751cac4fe78 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 1 Oct 2026 22:37:52 +0700 Subject: [PATCH] feat : limit each session to 1 hours when checkout --- README.md | 3 +- app/commission/pay/[id]/page.tsx | 21 ++++++----- app/commission/tickets/page.tsx | 6 ++- components/commission/payment-form.tsx | 44 +++++++++++++++++++--- lib/commission/checkout-expiration.test.ts | 12 ++++++ lib/commission/checkout-expiration.ts | 13 +++++++ lib/commission/mobile-slip.test.ts | 44 ++++++++++++++++++++-- lib/commission/mobile-slip.ts | 16 +++++--- lib/commission/slip-upload.test.ts | 28 ++++++++++++++ lib/commission/slip-upload.ts | 2 + lib/commission/verification-error.ts | 1 + 11 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 lib/commission/checkout-expiration.test.ts create mode 100644 lib/commission/checkout-expiration.ts diff --git a/README.md b/README.md index d41b3ff..e339b9b 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,8 @@ image-verification endpoint from your Slip2Go API Connect account. Set `SLIP2GO_API_SECRET` to the raw secret; the server adds the `Bearer` authorization prefix when calling Slip2Go. Payment slips and ticket images use the configured S3 bucket and are served from `S3_PUBLIC_URL`. Anyone with an attachment URL can -view it. Apply the commission database migration before enabling checkout. +view it. Each checkout accepts payment for one hour after creation; paid tickets +remain available afterward. Apply the commission database migration before enabling checkout. Traefik must overwrite `X-Real-Ip` for every request, and the web pods must be reachable only through Traefik before enabling the configured client IP based diff --git a/app/commission/pay/[id]/page.tsx b/app/commission/pay/[id]/page.tsx index 7b8c155..0e7187a 100644 --- a/app/commission/pay/[id]/page.tsx +++ b/app/commission/pay/[id]/page.tsx @@ -13,6 +13,7 @@ import { getCustomerSession } from "@/lib/auth/server"; import { promptPayConfig } from "@/lib/commission/payment"; import { generatePromptPayPayload } from "@/lib/commission/promptpay"; import { getCommissionLabels } from "@/lib/commission/catalog"; +import { checkoutExpiresAt, checkoutNow } from "@/lib/commission/checkout-expiration"; export const instant = false; @@ -27,21 +28,21 @@ export default async function CommissionPayPage({ params }: PageProps<"/commissi const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) .where(eq(commissionTickets.checkoutId, id)).limit(1); if (ticket) redirect(`/commission/tickets/${ticket.id}`); - const { identifier } = promptPayConfig(); - const payload = generatePromptPayPayload({ identifier, amount: checkout.amountBaht }); - const qr = await QRCode.toDataURL(payload, { margin: 2, width: 300 }); + const expiresAt = checkoutExpiresAt(checkout.createdAt); + const serverNow = checkoutNow(); + let qr: string | null = null; + if (serverNow < expiresAt) { + const { identifier } = promptPayConfig(); + const payload = generatePromptPayPayload({ identifier, amount: checkout.amountBaht }); + qr = await QRCode.toDataURL(payload, { margin: 2, width: 300 }); + } const catalog = await getCommissionLabels(checkout.request); return
กลับไป Commission

ชำระเงิน

รายการคำขอ - ฿{checkout.amountBaht} {catalog && } - สแกน PromptPay - {/* Generated locally from the fixed checkout amount and configured recipient. */} - {/* eslint-disable-next-line @next/next/no-img-element */} - {`PromptPay - ฿{checkout.amountBaht}

โอนเงินแล้วอัปโหลดรูปสลิปเพื่อยืนยัน

-
- +
; } diff --git a/app/commission/tickets/page.tsx b/app/commission/tickets/page.tsx index 1248860..e5f4645 100644 --- a/app/commission/tickets/page.tsx +++ b/app/commission/tickets/page.tsx @@ -1,5 +1,5 @@ import Link from "next/link"; -import { and, desc, eq, isNull } from "drizzle-orm"; +import { and, desc, eq, gt, isNull } from "drizzle-orm"; import { redirect } from "next/navigation"; import { connection } from "next/server"; import { getDb } from "@/db"; @@ -10,6 +10,7 @@ import { SiteHeader } from "@/components/public/site-header"; import { Badge } from "@/components/ui/badge"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { getCustomerSession } from "@/lib/auth/server"; +import { CHECKOUT_DURATION_MS, checkoutNow } from "@/lib/commission/checkout-expiration"; export const instant = false; export default async function CommissionTicketsPage() { @@ -23,7 +24,8 @@ export default async function CommissionTicketsPage() { getDb().select({ id: commissionCheckouts.id, amount: commissionCheckouts.amountBaht, createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts) .leftJoin(commissionTickets, eq(commissionTickets.checkoutId, commissionCheckouts.id)) - .where(and(eq(commissionCheckouts.userId, session.user.id), isNull(commissionTickets.id))) + .where(and(eq(commissionCheckouts.userId, session.user.id), isNull(commissionTickets.id), + gt(commissionCheckouts.createdAt, new Date(checkoutNow() - CHECKOUT_DURATION_MS)))) .orderBy(desc(commissionCheckouts.createdAt)).limit(20), ]); return
diff --git a/components/commission/payment-form.tsx b/components/commission/payment-form.tsx index 6bcd24d..8cd0609 100644 --- a/components/commission/payment-form.tsx +++ b/components/commission/payment-form.tsx @@ -1,34 +1,66 @@ "use client"; -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import { MobileSlipHandoff } from "@/components/commission/mobile-slip-handoff"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { verificationError } from "@/lib/commission/verification-error"; -export function CommissionPaymentForm({ checkoutId }: { checkoutId: string }) { +export function CommissionPaymentForm({ checkoutId, amountBaht, qr, expiresAt, serverNow }: { + checkoutId: string; amountBaht: number; qr: string | null; expiresAt: number; serverNow: number; +}) { const router = useRouter(); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [now, setNow] = useState(serverNow); + const expired = now >= expiresAt; + const secondsLeft = Math.max(0, Math.ceil((expiresAt - now) / 1000)); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + async function submit(event: FormEvent) { - event.preventDefault(); setBusy(true); setError(""); + event.preventDefault(); + if (Date.now() >= expiresAt) { setNow(Date.now()); return; } + setBusy(true); setError(""); try { const response = await fetch(`/api/commission/checkouts/${checkoutId}/verify`, { method: "POST", body: new FormData(event.currentTarget), }); const result = await response.json(); - if (!response.ok) throw new Error(verificationError(result.error)); + if (!response.ok) { + if (result.error === "checkout-expired") setNow(Date.now()); + throw new Error(verificationError(result.error)); + } router.push(`/commission/tickets/${result.ticketId}`); } catch (cause) { setError(cause instanceof Error ? cause.message : "เกิดข้อผิดพลาด"); } finally { setBusy(false); } } - return
+ if (expired || !qr) return หมดเวลาชำระเงิน +

คำขอนี้หมดอายุแล้ว กรุณาสร้างคำขอใหม่

+ สร้างคำขอใหม่ +
; + + return <>สแกน PromptPay + + {/* Generated locally from the fixed checkout amount and configured recipient. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {`PromptPay + ฿{amountBaht} +

โอนเงินแล้วอัปโหลดรูปสลิปเพื่อยืนยัน

+

เวลาชำระเงินที่เหลือ {String(Math.floor(secondsLeft / 60)).padStart(2, "0")}:{String(secondsLeft % 60).padStart(2, "0")}

+
+ อัปโหลดสลิปที่ชำระเงินแล้ว - {error && {error}}; +
{error && {error}}; } diff --git a/lib/commission/checkout-expiration.test.ts b/lib/commission/checkout-expiration.test.ts new file mode 100644 index 0000000..77d97d5 --- /dev/null +++ b/lib/commission/checkout-expiration.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { checkoutExpired, checkoutExpiresAt } from "./checkout-expiration"; + +describe("commission checkout window", () => { + it("expires exactly one hour after each checkout was created", () => { + const createdAt = new Date("2026-10-01T12:00:00.000Z"); + const expiresAt = checkoutExpiresAt(createdAt); + expect(expiresAt).toBe(new Date("2026-10-01T13:00:00.000Z").getTime()); + expect(checkoutExpired(createdAt, expiresAt - 1)).toBe(false); + expect(checkoutExpired(createdAt, expiresAt)).toBe(true); + }); +}); diff --git a/lib/commission/checkout-expiration.ts b/lib/commission/checkout-expiration.ts new file mode 100644 index 0000000..0cb5355 --- /dev/null +++ b/lib/commission/checkout-expiration.ts @@ -0,0 +1,13 @@ +export const CHECKOUT_DURATION_MS = 60 * 60 * 1000; + +export function checkoutNow(): number { + return Date.now(); +} + +export function checkoutExpiresAt(createdAt: Date): number { + return createdAt.getTime() + CHECKOUT_DURATION_MS; +} + +export function checkoutExpired(createdAt: Date, now = Date.now()): boolean { + return now >= checkoutExpiresAt(createdAt); +} diff --git a/lib/commission/mobile-slip.test.ts b/lib/commission/mobile-slip.test.ts index acc400c..2fc2149 100644 --- a/lib/commission/mobile-slip.test.ts +++ b/lib/commission/mobile-slip.test.ts @@ -13,8 +13,9 @@ 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 }; +const { authorizeMobileSlip, createMobileSlipLink, mobileSlipStatus } = await import("./mobile-slip"); +const checkout = { id: "checkout-1", userId: "customer-1", amountBaht: 150, + createdAt: new Date(Date.now() - 60_000) }; describe("commission mobile slip links", () => { beforeEach(() => { vi.clearAllMocks(); set.mockResolvedValue("OK"); }); @@ -26,7 +27,7 @@ describe("commission mobile slip links", () => { }); it("issues a ten-minute link and rejects an expired or replaced token", async () => { - limit.mockResolvedValueOnce([{ id: checkout.id }]).mockResolvedValueOnce([]); + limit.mockResolvedValueOnce([checkout]).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")); @@ -49,4 +50,41 @@ describe("commission mobile slip links", () => { limit.mockResolvedValueOnce([checkout]).mockResolvedValueOnce([]); expect(await authorizeMobileSlip(token)).toEqual({ checkout, ticketId: null, digest }); }); + + it("does not issue a link for an expired checkout", async () => { + limit.mockResolvedValueOnce([{ ...checkout, createdAt: new Date(Date.now() - 3_600_000) }]) + .mockResolvedValueOnce([]); + await expect(createMobileSlipLink(checkout.id, checkout.userId)) + .rejects.toMatchObject({ status: 410, message: "checkout-expired" }); + expect(set).not.toHaveBeenCalled(); + }); + + it("caps a mobile link at the checkout deadline", async () => { + limit.mockResolvedValueOnce([{ ...checkout, createdAt: new Date(Date.now() - 3_570_000) }]) + .mockResolvedValueOnce([]); + const { expiresAt } = await createMobileSlipLink(checkout.id, checkout.userId); + expect(expiresAt).toBeLessThanOrEqual(Date.now() + 30_000); + expect(set).toHaveBeenCalledWith(expect.stringContaining(":token:"), expect.any(String), "EX", 30); + }); + + it("rejects an active phone link once its checkout expires", 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, createdAt: new Date(Date.now() - 3_600_000) }]) + .mockResolvedValueOnce([]); + await expect(authorizeMobileSlip(token)) + .rejects.toMatchObject({ status: 410, message: "checkout-expired" }); + }); + + it("reports expired unpaid checkouts while preserving completed tickets", async () => { + const expired = { ...checkout, createdAt: new Date(Date.now() - 3_600_000) }; + limit.mockResolvedValueOnce([expired]).mockResolvedValueOnce([]); + expect(await mobileSlipStatus(checkout.id, checkout.userId, "a".repeat(64))) + .toEqual({ state: "expired" }); + limit.mockResolvedValueOnce([expired]).mockResolvedValueOnce([{ id: "ticket-1" }]); + expect(await mobileSlipStatus(checkout.id, checkout.userId, "a".repeat(64))) + .toEqual({ state: "complete", ticketId: "ticket-1" }); + }); }); diff --git a/lib/commission/mobile-slip.ts b/lib/commission/mobile-slip.ts index 1a635f7..27ecefc 100644 --- a/lib/commission/mobile-slip.ts +++ b/lib/commission/mobile-slip.ts @@ -4,6 +4,7 @@ import { createHash, randomBytes } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { getDb } from "@/db"; import { commissionCheckouts, commissionTickets } from "@/db/schema"; +import { checkoutExpired, checkoutExpiresAt } from "@/lib/commission/checkout-expiration"; import { getRedisClient, redisEventChannel } from "@/lib/redis/client"; import { HttpError } from "@/lib/security/http"; @@ -15,19 +16,22 @@ 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) + const [checkout] = await getDb().select({ id: commissionCheckouts.id, createdAt: commissionCheckouts.createdAt }).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"); + if (checkoutExpired(checkout.createdAt)) throw new HttpError(410, "checkout-expired"); 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 }; + const expiresAt = Math.min(Date.now() + TTL_SECONDS * 1000, checkoutExpiresAt(checkout.createdAt)); + const ttl = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)); + await redis.set(tokenKey(digest), JSON.stringify({ checkoutId, userId }), "EX", ttl); + await redis.set(activeKey(checkoutId), digest, "EX", ttl); + return { token, digest, expiresAt }; } export async function authorizeMobileSlip(token: string) { @@ -44,16 +48,18 @@ export async function authorizeMobileSlip(token: string) { 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 && checkoutExpired(checkout.createdAt)) throw new HttpError(410, "checkout-expired"); 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) + const [checkout] = await getDb().select({ id: commissionCheckouts.id, createdAt: commissionCheckouts.createdAt }).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 (checkoutExpired(checkout.createdAt)) return { state: "expired" }; 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" }; diff --git a/lib/commission/slip-upload.test.ts b/lib/commission/slip-upload.test.ts index d6af744..3221b0f 100644 --- a/lib/commission/slip-upload.test.ts +++ b/lib/commission/slip-upload.test.ts @@ -4,9 +4,12 @@ const set = vi.fn().mockResolvedValue(null); const getRedisClient = vi.fn().mockResolvedValue({ set }); const inspectImage = vi.fn(); const verifyCommissionSlip = vi.fn(); +const limit = vi.fn(); +const evalRedis = vi.fn().mockResolvedValue(0); vi.mock("server-only", () => ({})); vi.mock("@/lib/redis/client", () => ({ getRedisClient })); +vi.mock("@/db", () => ({ getDb: () => ({ select: () => ({ from: () => ({ where: () => ({ limit }) }) }) }) })); vi.mock("@/lib/media/inspect", () => ({ inspectImage })); vi.mock("@/lib/commission/payment", () => ({ verifyCommissionSlip })); @@ -23,4 +26,29 @@ describe("commission slip submission lock", () => { expect(inspectImage).not.toHaveBeenCalled(); expect(verifyCommissionSlip).not.toHaveBeenCalled(); }); + + it("rejects an unpaid expired checkout before verifying its slip", async () => { + set.mockResolvedValueOnce("OK"); + getRedisClient.mockResolvedValueOnce({ set, eval: evalRedis }); + limit.mockResolvedValueOnce([]); + const checkout = { id: "checkout-2", userId: "customer-1", amountBaht: 150, + createdAt: new Date(Date.now() - 3_600_000) }; + const file = new File(["image"], "slip.png", { type: "image/png" }); + await expect(verifyAndCreateTicket(checkout as Parameters[0], file)) + .rejects.toMatchObject({ status: 410, message: "checkout-expired" }); + expect(inspectImage).not.toHaveBeenCalled(); + expect(verifyCommissionSlip).not.toHaveBeenCalled(); + }); + + it("still returns an existing ticket after the checkout deadline", async () => { + set.mockResolvedValueOnce("OK"); + getRedisClient.mockResolvedValueOnce({ set, eval: evalRedis }); + limit.mockResolvedValueOnce([{ id: "ticket-1" }]); + const checkout = { id: "checkout-3", userId: "customer-1", amountBaht: 150, + createdAt: new Date(Date.now() - 3_600_000) }; + const file = new File(["image"], "slip.png", { type: "image/png" }); + expect(await verifyAndCreateTicket(checkout as Parameters[0], file)) + .toEqual({ ticketId: "ticket-1", created: false }); + expect(verifyCommissionSlip).not.toHaveBeenCalled(); + }); }); diff --git a/lib/commission/slip-upload.ts b/lib/commission/slip-upload.ts index 1f181d8..9532f24 100644 --- a/lib/commission/slip-upload.ts +++ b/lib/commission/slip-upload.ts @@ -6,6 +6,7 @@ import { commissionCheckouts, commissionPayments, commissionTickets } from "@/db import { notifyPaidTicketDiscord } from "@/lib/commission/discord"; import { verifyCommissionSlip } from "@/lib/commission/payment"; import { notifyCommission } from "@/lib/commission/server"; +import { checkoutExpired } from "@/lib/commission/checkout-expiration"; import { getMediaStorage } from "@/lib/media/storage"; import { inspectImage } from "@/lib/media/inspect"; import { getRedisClient } from "@/lib/redis/client"; @@ -28,6 +29,7 @@ export async function verifyAndCreateTicket(checkout: Checkout, file: FormDataEn 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 }; + if (checkoutExpired(checkout.createdAt)) throw new HttpError(410, "checkout-expired"); const bytes = new Uint8Array(await file.arrayBuffer()); await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp"); diff --git a/lib/commission/verification-error.ts b/lib/commission/verification-error.ts index b5d626b..5a770f4 100644 --- a/lib/commission/verification-error.ts +++ b/lib/commission/verification-error.ts @@ -18,6 +18,7 @@ export function verificationError(code: string | undefined): string { case "slip-amount-or-date-mismatch": return "ยอดเงินหรือวันเวลาในสลิปไม่ตรงกับคำขอนี้ กรุณาใช้สลิปจากการชำระเงินครั้งนี้"; case "payment-already-used": return "สลิปนี้ถูกใช้กับคำขออื่นแล้ว"; case "slip-verification-in-progress": return "กำลังตรวจสอบสลิปอยู่ กรุณารอสักครู่"; + case "checkout-expired": return "หมดเวลาชำระเงินสำหรับคำขอนี้แล้ว กรุณาสร้างคำขอใหม่"; default: return reason && /^\d{6}$/.test(reason) ? `ตรวจสอบสลิปไม่ผ่าน (รหัส ${reason}) กรุณาแจ้งผู้ดูแลระบบ` : "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";