import "server-only"; import { eq } from "drizzle-orm"; import { getDb } from "@/db"; import { commissionCheckouts, commissionPayments, commissionTickets } from "@/db/schema"; 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 { commissionSlipLockKey } from "@/lib/commission/slip-lock"; import { getMediaStorage } from "@/lib/media/storage"; import { inspectImage } from "@/lib/media/inspect"; import { getRedisClient } from "@/lib/redis/client"; import { HttpError } from "@/lib/security/http"; export const MAX_SLIP_BYTES = 6 * 1024 * 1024; export type Checkout = typeof commissionCheckouts.$inferSelect; export async function verifyAndCreateTicket(checkout: Checkout, file: FormDataEntryValue | null) { if (!(file instanceof File) || file.size === 0 || file.size > MAX_SLIP_BYTES || !["image/png", "image/jpeg", "image/webp"].includes(file.type)) throw new HttpError(415, "invalid-slip-image"); const redis = await getRedisClient(); const lockKey = commissionSlipLockKey(checkout.id); const lockId = crypto.randomUUID(); if (await redis.set(lockKey, lockId, "EX", 60, "NX") !== "OK") throw new HttpError(409, "slip-verification-in-progress"); try { 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"); const verified = await verifyCommissionSlip(file, checkout.amountBaht, checkout.createdAt); const objectKey = `commission/slips/${crypto.randomUUID()}`; const storage = await getMediaStorage(); await storage.write(objectKey, bytes, { type: file.type, acl: "public-read" }); let ticketId: string; try { ticketId = await getDb().transaction(async (tx) => { const [ticket] = await tx.insert(commissionTickets).values({ checkoutId: checkout.id, userId: checkout.userId, }).returning({ id: commissionTickets.id }); await tx.insert(commissionPayments).values({ checkoutId: checkout.id, ticketId: ticket.id, slipObjectKey: objectKey, slipMimeType: file.type, ...verified, }); return ticket.id; }); } catch (cause) { await storage.delete(objectKey).catch(() => undefined); const [paid] = await getDb().select({ id: commissionTickets.id }).from(commissionTickets) .where(eq(commissionTickets.checkoutId, checkout.id)).limit(1); if (paid) return { ticketId: paid.id, created: false }; if (cause && typeof cause === "object" && "code" in cause && cause.code === "23505") throw new HttpError(409, "payment-already-used"); throw cause; } await notifyCommission(ticketId, checkout.userId); await notifyPaidTicketDiscord(ticketId, checkout.amountBaht); return { ticketId, created: true }; } finally { await redis.eval("if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0", 1, lockKey, lockId).catch(() => undefined); } }