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 */}
-
- ฿{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 >;
}
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}) กรุณาแจ้งผู้ดูแลระบบ`
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";