import "server-only"; import * as z from "zod"; import { getCommissionSlipProvider, slipProviderConfigured } from "./slip-provider"; import { HttpError } from "@/lib/security/http"; import { normalizePromptPayIdentifier } from "@/lib/commission/promptpay"; const slipResponse = z.object({ code: z.string() }).passthrough(); const verifiedSlip = z.object({ referenceId: z.string().min(1), transRef: z.string().min(1), amount: z.number(), dateTime: z.string().refine((value) => !Number.isNaN(Date.parse(value))), }).passthrough(); function required(name: string) { const value = process.env[name]; if (!value) throw new HttpError(503, `${name}-not-configured`); return value; } export function promptPayConfig() { const type = required("COMMISSION_PROMPTPAY_TYPE"); if (type !== "mobile" && type !== "nationalId" && type !== "ewallet") throw new HttpError(503, "invalid-promptpay-type"); let identifier; try { identifier = normalizePromptPayIdentifier({ type, value: required("COMMISSION_PROMPTPAY_VALUE") }); } catch { throw new HttpError(503, "invalid-promptpay-value"); } return { identifier, receiverNumber: required("COMMISSION_RECEIVER_ACCOUNT_NUMBER") }; } async function verifySlip2Go(file: File, amountBaht: number) { const url = new URL(required("SLIP2GO_VERIFY_URL")); if (url.protocol !== "https:") throw new HttpError(503, "invalid-slip2go-url"); const { identifier, receiverNumber } = promptPayConfig(); const form = new FormData(); form.set("file", file); form.set("payload", JSON.stringify({ checkReceiver: [{ accountType: { mobile: "02001", nationalId: "02003", ewallet: "02004" }[identifier.type], accountNumber: receiverNumber, }], checkAmount: { type: "eq", amount: amountBaht.toFixed(2) }, })); let response: Response; try { response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${required("SLIP2GO_API_SECRET")}` }, body: form, signal: AbortSignal.timeout(15000), cache: "no-store" }); } catch { throw new HttpError(503, "slip-verification-unavailable"); } const body = slipResponse.safeParse(await response.json().catch(() => null)); if (!body.success) throw new HttpError(503, "invalid-slip-verification-response"); const code = body.data.code; if (response.status >= 500 || response.status === 401 || code.startsWith("401") || code === "400400" || code === "200502") throw new HttpError(503, `slip2go-service:${code}`); if (!response.ok || code !== "200200") throw new HttpError(422, `slip-rejected:${code}`); const parsedSlip = verifiedSlip.safeParse(body.data.data); if (!parsedSlip.success) { const field = parsedSlip.error.issues[0]?.path[0] ?? "data"; throw new HttpError(503, `invalid-slip-verification-response:${String(field)}`); } const slip = parsedSlip.data; return { referenceId: slip.referenceId, transRef: slip.transRef, amountBaht: slip.amount, transferredAt: new Date(slip.dateTime), providerData: body.data as Record }; } const slipOKResponse = z.object({ success: z.literal(true), data: z.object({ success: z.literal(true), transRef: z.string().min(1), amount: z.number().finite(), transTimestamp: z.string().optional(), transDate: z.string().optional(), transTime: z.string().optional(), }).passthrough(), }).passthrough(); function slipOKTransferTime(data: z.infer["data"]): Date { let timestamp = data.transTimestamp; if (timestamp === undefined) { if (!/^\d{8}$/.test(data.transDate ?? "") || !/^\d{2}:\d{2}:\d{2}$/.test(data.transTime ?? "")) throw new HttpError(503, "invalid-slip-verification-response:transTimestamp"); const date = data.transDate!; timestamp = `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}T${data.transTime}+07:00`; } // Require an explicit timezone and reject dates that JavaScript silently normalizes. const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(timestamp); const millis = Date.parse(timestamp); if (!match || !Number.isFinite(millis)) throw new HttpError(503, "invalid-slip-verification-response:transTimestamp"); const dateCheck = new Date(`${match[1]}T${match[2]}Z`); if (!Number.isFinite(dateCheck.getTime()) || dateCheck.toISOString().slice(0, 19) !== `${match[1]}T${match[2]}`) throw new HttpError(503, "invalid-slip-verification-response:transTimestamp"); return new Date(millis); } async function verifySlipOK(file: File, amountBaht: number) { const form = new FormData(); form.set("files", file); form.set("log", "true"); form.set("amount", String(amountBaht)); let response: Response; try { response = await fetch(`https://api.slipok.com/api/line/apikey/${required("SLIPOK_BRANCH_ID")}`, { method: "POST", headers: { "x-authorization": required("SLIPOK_API_KEY") }, body: form, signal: AbortSignal.timeout(15000), cache: "no-store", }); } catch { throw new HttpError(503, "slip-verification-unavailable"); } const body: unknown = await response.json().catch(() => null); const error = z.object({ code: z.number().int() }).safeParse(body); if (error.success) { const code = error.data.code; const unavailable = response.status >= 500 || response.status === 401 || response.status === 429 || [1000, 1001, 1002, 1003, 1004, 1009, 1010, 1015].includes(code); throw new HttpError(unavailable ? 503 : 422, `slipok-${unavailable ? "service" : "rejected"}:${code}`); } if (!response.ok) throw new HttpError(503, "slip-verification-unavailable"); const parsed = slipOKResponse.safeParse(body); if (!parsed.success) throw new HttpError(503, "invalid-slip-verification-response"); const slip = parsed.data.data; return { referenceId: `slipok:${slip.transRef}`, transRef: slip.transRef, amountBaht: slip.amount, transferredAt: slipOKTransferTime(slip), providerData: parsed.data as Record }; } export async function verifyCommissionSlip(file: File, amountBaht: number, checkoutCreatedAt: Date) { const provider = await getCommissionSlipProvider(); if (!slipProviderConfigured(provider)) throw new HttpError(503, "slip-provider-not-configured"); const slip = provider === "slipok" ? await verifySlipOK(file, amountBaht) : await verifySlip2Go(file, amountBaht); if (slip.amountBaht !== amountBaht || slip.transferredAt.getTime() < checkoutCreatedAt.getTime() - 120000 || slip.transferredAt.getTime() > Date.now() + 120000) throw new HttpError(422, "slip-amount-or-date-mismatch"); return { ...slip, provider }; }