feat : limit each session to 1 hours when checkout

This commit is contained in:
2026-10-01 22:37:52 +07:00 Unverified
parent 61dfc9c9e5
commit d85687c332
11 changed files with 163 additions and 27 deletions
+2 -1
View File
@@ -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
+11 -10
View File
@@ -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 <div className="min-h-svh"><SiteHeader /><main className="mx-auto flex max-w-3xl flex-col gap-6 p-4 py-10 sm:p-8">
<Link href="/commission" className="text-sm underline">กลับไป Commission</Link>
<h1 className="font-heading text-3xl font-semibold">ชำระเงิน</h1>
<Card><CardHeader><CardTitle>รายการคำขอ - ฿{checkout.amountBaht}</CardTitle></CardHeader>
<CardContent>{catalog && <CommissionRequestSummary request={checkout.request} catalog={catalog} amountBaht={checkout.amountBaht} />}</CardContent></Card>
<Card><CardHeader><CardTitle>สแกน PromptPay</CardTitle></CardHeader><CardContent className="flex flex-col items-center gap-4">
{/* Generated locally from the fixed checkout amount and configured recipient. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qr} alt={`PromptPay QR สำหรับชำระเงิน ${checkout.amountBaht} บาท`} width={300} height={300} />
<strong>฿{checkout.amountBaht}</strong><p className="text-sm text-muted-foreground">โอนเงินแล้วอัปโหลดรูปสลิปเพื่อยืนยัน</p>
</CardContent></Card>
<CommissionPaymentForm checkoutId={id} />
<CommissionPaymentForm checkoutId={id} amountBaht={checkout.amountBaht} qr={qr}
expiresAt={expiresAt} serverNow={serverNow} />
</main></div>;
}
+4 -2
View File
@@ -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 <div className="min-h-svh"><SiteHeader /><CommissionLiveRefresh endpoint="/api/commission/events" />
+38 -6
View File
@@ -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<HTMLFormElement>) {
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 <form onSubmit={submit} className="flex flex-col gap-4"><FieldGroup>
if (expired || !qr) return <Card><CardHeader><CardTitle>หมดเวลาชำระเงิน</CardTitle></CardHeader>
<CardContent className="flex flex-col gap-3"><p>คำขอนี้หมดอายุแล้ว กรุณาสร้างคำขอใหม่</p>
<Link href="/commission" className="text-sm underline">สร้างคำขอใหม่</Link>
</CardContent></Card>;
return <><Card><CardHeader><CardTitle>สแกน PromptPay</CardTitle></CardHeader>
<CardContent className="flex flex-col items-center gap-4">
{/* Generated locally from the fixed checkout amount and configured recipient. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qr} alt={`PromptPay QR สำหรับชำระเงิน ${amountBaht} บาท`} width={300} height={300} />
<strong>฿{amountBaht}</strong>
<p className="text-sm text-muted-foreground">โอนเงินแล้วอัปโหลดรูปสลิปเพื่อยืนยัน</p>
<p className="text-sm text-muted-foreground">เวลาชำระเงินที่เหลือ {String(Math.floor(secondsLeft / 60)).padStart(2, "0")}:{String(secondsLeft % 60).padStart(2, "0")}</p>
</CardContent></Card>
<form onSubmit={submit} className="flex flex-col gap-4"><FieldGroup>
<Field><FieldLabel htmlFor="slip">อัปโหลดสลิปที่ชำระเงินแล้ว</FieldLabel>
<Input id="slip" name="file" type="file" accept="image/png,image/jpeg,image/webp" required />
<MobileSlipHandoff checkoutId={checkoutId} /></Field>
<Button type="submit" disabled={busy}>{busy ? "กำลังตรวจสอบ..." : "ตรวจสอบสลิปและเปิด Ticket"}</Button>
</FieldGroup>{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}</form>;
</FieldGroup>{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}</form></>;
}
@@ -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);
});
});
+13
View File
@@ -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);
}
+41 -3
View File
@@ -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" });
});
});
+11 -5
View File
@@ -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" };
+28
View File
@@ -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<typeof verifyAndCreateTicket>[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<typeof verifyAndCreateTicket>[0], file))
.toEqual({ ticketId: "ticket-1", created: false });
expect(verifyCommissionSlip).not.toHaveBeenCalled();
});
});
+2
View File
@@ -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");
+1
View File
@@ -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}) กรุณาแจ้งผู้ดูแลระบบ`
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";