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 `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 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 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 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 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 { promptPayConfig } from "@/lib/commission/payment";
import { generatePromptPayPayload } from "@/lib/commission/promptpay"; import { generatePromptPayPayload } from "@/lib/commission/promptpay";
import { getCommissionLabels } from "@/lib/commission/catalog"; import { getCommissionLabels } from "@/lib/commission/catalog";
import { checkoutExpiresAt, checkoutNow } from "@/lib/commission/checkout-expiration";
export const instant = false; 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) const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, id)).limit(1); .where(eq(commissionTickets.checkoutId, id)).limit(1);
if (ticket) redirect(`/commission/tickets/${ticket.id}`); if (ticket) redirect(`/commission/tickets/${ticket.id}`);
const { identifier } = promptPayConfig(); const expiresAt = checkoutExpiresAt(checkout.createdAt);
const payload = generatePromptPayPayload({ identifier, amount: checkout.amountBaht }); const serverNow = checkoutNow();
const qr = await QRCode.toDataURL(payload, { margin: 2, width: 300 }); 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); 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"> 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> <Link href="/commission" className="text-sm underline">กลับไป Commission</Link>
<h1 className="font-heading text-3xl font-semibold">ชำระเงิน</h1> <h1 className="font-heading text-3xl font-semibold">ชำระเงิน</h1>
<Card><CardHeader><CardTitle>รายการคำขอ - ฿{checkout.amountBaht}</CardTitle></CardHeader> <Card><CardHeader><CardTitle>รายการคำขอ - ฿{checkout.amountBaht}</CardTitle></CardHeader>
<CardContent>{catalog && <CommissionRequestSummary request={checkout.request} catalog={catalog} amountBaht={checkout.amountBaht} />}</CardContent></Card> <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"> <CommissionPaymentForm checkoutId={id} amountBaht={checkout.amountBaht} qr={qr}
{/* Generated locally from the fixed checkout amount and configured recipient. */} expiresAt={expiresAt} serverNow={serverNow} />
{/* 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} />
</main></div>; </main></div>;
} }
+4 -2
View File
@@ -1,5 +1,5 @@
import Link from "next/link"; 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 { redirect } from "next/navigation";
import { connection } from "next/server"; import { connection } from "next/server";
import { getDb } from "@/db"; import { getDb } from "@/db";
@@ -10,6 +10,7 @@ import { SiteHeader } from "@/components/public/site-header";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getCustomerSession } from "@/lib/auth/server"; import { getCustomerSession } from "@/lib/auth/server";
import { CHECKOUT_DURATION_MS, checkoutNow } from "@/lib/commission/checkout-expiration";
export const instant = false; export const instant = false;
export default async function CommissionTicketsPage() { export default async function CommissionTicketsPage() {
@@ -23,7 +24,8 @@ export default async function CommissionTicketsPage() {
getDb().select({ id: commissionCheckouts.id, amount: commissionCheckouts.amountBaht, getDb().select({ id: commissionCheckouts.id, amount: commissionCheckouts.amountBaht,
createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts) createdAt: commissionCheckouts.createdAt }).from(commissionCheckouts)
.leftJoin(commissionTickets, eq(commissionTickets.checkoutId, commissionCheckouts.id)) .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), .orderBy(desc(commissionCheckouts.createdAt)).limit(20),
]); ]);
return <div className="min-h-svh"><SiteHeader /><CommissionLiveRefresh endpoint="/api/commission/events" /> return <div className="min-h-svh"><SiteHeader /><CommissionLiveRefresh endpoint="/api/commission/events" />
+38 -6
View File
@@ -1,34 +1,66 @@
"use client"; "use client";
import { useState, type FormEvent } from "react"; import { useEffect, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { MobileSlipHandoff } from "@/components/commission/mobile-slip-handoff"; import { MobileSlipHandoff } from "@/components/commission/mobile-slip-handoff";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { verificationError } from "@/lib/commission/verification-error"; 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 router = useRouter();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); 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>) { 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 { try {
const response = await fetch(`/api/commission/checkouts/${checkoutId}/verify`, { const response = await fetch(`/api/commission/checkouts/${checkoutId}/verify`, {
method: "POST", body: new FormData(event.currentTarget), method: "POST", body: new FormData(event.currentTarget),
}); });
const result = await response.json(); 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}`); router.push(`/commission/tickets/${result.ticketId}`);
} catch (cause) { setError(cause instanceof Error ? cause.message : "เกิดข้อผิดพลาด"); } } catch (cause) { setError(cause instanceof Error ? cause.message : "เกิดข้อผิดพลาด"); }
finally { setBusy(false); } 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> <Field><FieldLabel htmlFor="slip">อัปโหลดสลิปที่ชำระเงินแล้ว</FieldLabel>
<Input id="slip" name="file" type="file" accept="image/png,image/jpeg,image/webp" required /> <Input id="slip" name="file" type="file" accept="image/png,image/jpeg,image/webp" required />
<MobileSlipHandoff checkoutId={checkoutId} /></Field> <MobileSlipHandoff checkoutId={checkoutId} /></Field>
<Button type="submit" disabled={busy}>{busy ? "กำลังตรวจสอบ..." : "ตรวจสอบสลิปและเปิด Ticket"}</Button> <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("@/db", () => ({ getDb: () => ({ select }) }));
vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => redis })); vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => redis }));
const { authorizeMobileSlip, createMobileSlipLink } = await import("./mobile-slip"); const { authorizeMobileSlip, createMobileSlipLink, mobileSlipStatus } = await import("./mobile-slip");
const checkout = { id: "checkout-1", userId: "customer-1", amountBaht: 150 }; const checkout = { id: "checkout-1", userId: "customer-1", amountBaht: 150,
createdAt: new Date(Date.now() - 60_000) };
describe("commission mobile slip links", () => { describe("commission mobile slip links", () => {
beforeEach(() => { vi.clearAllMocks(); set.mockResolvedValue("OK"); }); 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 () => { 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); const { token, digest } = await createMobileSlipLink(checkout.id, checkout.userId);
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(digest).toBe(createHash("sha256").update(token).digest("hex")); expect(digest).toBe(createHash("sha256").update(token).digest("hex"));
@@ -49,4 +50,41 @@ describe("commission mobile slip links", () => {
limit.mockResolvedValueOnce([checkout]).mockResolvedValueOnce([]); limit.mockResolvedValueOnce([checkout]).mockResolvedValueOnce([]);
expect(await authorizeMobileSlip(token)).toEqual({ checkout, ticketId: null, digest }); 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 { and, eq } from "drizzle-orm";
import { getDb } from "@/db"; import { getDb } from "@/db";
import { commissionCheckouts, commissionTickets } from "@/db/schema"; import { commissionCheckouts, commissionTickets } from "@/db/schema";
import { checkoutExpired, checkoutExpiresAt } from "@/lib/commission/checkout-expiration";
import { getRedisClient, redisEventChannel } from "@/lib/redis/client"; import { getRedisClient, redisEventChannel } from "@/lib/redis/client";
import { HttpError } from "@/lib/security/http"; 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"); const digestToken = (token: string) => createHash("sha256").update(token).digest("hex");
export async function createMobileSlipLink(checkoutId: string, userId: string) { 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); .where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1);
if (!checkout) throw new HttpError(404, "checkout-not-found"); if (!checkout) throw new HttpError(404, "checkout-not-found");
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1); .where(eq(commissionTickets.checkoutId, checkoutId)).limit(1);
if (ticket) throw new HttpError(409, "checkout-already-paid"); 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 token = randomBytes(32).toString("base64url");
const digest = digestToken(token); const digest = digestToken(token);
const redis = await getRedisClient(); const redis = await getRedisClient();
await redis.set(tokenKey(digest), JSON.stringify({ checkoutId, userId }), "EX", TTL_SECONDS); const expiresAt = Math.min(Date.now() + TTL_SECONDS * 1000, checkoutExpiresAt(checkout.createdAt));
await redis.set(activeKey(checkoutId), digest, "EX", TTL_SECONDS); const ttl = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000));
return { token, digest, expiresAt: Date.now() + TTL_SECONDS * 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) { 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"); if (!checkout) throw new HttpError(404, "checkout-not-found");
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1); .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 }; return { checkout, ticketId: ticket?.id ?? null, digest };
} }
export async function mobileSlipStatus(checkoutId: string, userId: string, digest: string) { 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); .where(and(eq(commissionCheckouts.id, checkoutId), eq(commissionCheckouts.userId, userId))).limit(1);
if (!checkout) throw new HttpError(404, "checkout-not-found"); if (!checkout) throw new HttpError(404, "checkout-not-found");
const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) const [ticket] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, checkoutId)).limit(1); .where(eq(commissionTickets.checkoutId, checkoutId)).limit(1);
if (ticket) return { state: "complete", ticketId: ticket.id }; 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"); if (!/^[a-f0-9]{64}$/.test(digest)) throw new HttpError(400, "invalid-upload-link");
const redis = await getRedisClient(); const redis = await getRedisClient();
if (await redis.get(activeKey(checkoutId)) !== digest) return { state: "expired" }; 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 getRedisClient = vi.fn().mockResolvedValue({ set });
const inspectImage = vi.fn(); const inspectImage = vi.fn();
const verifyCommissionSlip = vi.fn(); const verifyCommissionSlip = vi.fn();
const limit = vi.fn();
const evalRedis = vi.fn().mockResolvedValue(0);
vi.mock("server-only", () => ({})); vi.mock("server-only", () => ({}));
vi.mock("@/lib/redis/client", () => ({ getRedisClient })); vi.mock("@/lib/redis/client", () => ({ getRedisClient }));
vi.mock("@/db", () => ({ getDb: () => ({ select: () => ({ from: () => ({ where: () => ({ limit }) }) }) }) }));
vi.mock("@/lib/media/inspect", () => ({ inspectImage })); vi.mock("@/lib/media/inspect", () => ({ inspectImage }));
vi.mock("@/lib/commission/payment", () => ({ verifyCommissionSlip })); vi.mock("@/lib/commission/payment", () => ({ verifyCommissionSlip }));
@@ -23,4 +26,29 @@ describe("commission slip submission lock", () => {
expect(inspectImage).not.toHaveBeenCalled(); expect(inspectImage).not.toHaveBeenCalled();
expect(verifyCommissionSlip).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 { notifyPaidTicketDiscord } from "@/lib/commission/discord";
import { verifyCommissionSlip } from "@/lib/commission/payment"; import { verifyCommissionSlip } from "@/lib/commission/payment";
import { notifyCommission } from "@/lib/commission/server"; import { notifyCommission } from "@/lib/commission/server";
import { checkoutExpired } from "@/lib/commission/checkout-expiration";
import { getMediaStorage } from "@/lib/media/storage"; import { getMediaStorage } from "@/lib/media/storage";
import { inspectImage } from "@/lib/media/inspect"; import { inspectImage } from "@/lib/media/inspect";
import { getRedisClient } from "@/lib/redis/client"; 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) const [existing] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets)
.where(eq(commissionTickets.checkoutId, checkout.id)).limit(1); .where(eq(commissionTickets.checkoutId, checkout.id)).limit(1);
if (existing) return { ticketId: existing.id, created: false }; 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()); const bytes = new Uint8Array(await file.arrayBuffer());
await inspectImage(bytes, file.type as "image/png" | "image/jpeg" | "image/webp"); 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 "slip-amount-or-date-mismatch": return "ยอดเงินหรือวันเวลาในสลิปไม่ตรงกับคำขอนี้ กรุณาใช้สลิปจากการชำระเงินครั้งนี้";
case "payment-already-used": return "สลิปนี้ถูกใช้กับคำขออื่นแล้ว"; case "payment-already-used": return "สลิปนี้ถูกใช้กับคำขออื่นแล้ว";
case "slip-verification-in-progress": return "กำลังตรวจสอบสลิปอยู่ กรุณารอสักครู่"; case "slip-verification-in-progress": return "กำลังตรวจสอบสลิปอยู่ กรุณารอสักครู่";
case "checkout-expired": return "หมดเวลาชำระเงินสำหรับคำขอนี้แล้ว กรุณาสร้างคำขอใหม่";
default: return reason && /^\d{6}$/.test(reason) default: return reason && /^\d{6}$/.test(reason)
? `ตรวจสอบสลิปไม่ผ่าน (รหัส ${reason}) กรุณาแจ้งผู้ดูแลระบบ` ? `ตรวจสอบสลิปไม่ผ่าน (รหัส ${reason}) กรุณาแจ้งผู้ดูแลระบบ`
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง"; : "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";