feat : limit each session to 1 hours when checkout
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}) กรุณาแจ้งผู้ดูแลระบบ`
|
||||||
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";
|
: "ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้ง";
|
||||||
|
|||||||
Reference in New Issue
Block a user