142 lines
6.5 KiB
TypeScript
142 lines
6.5 KiB
TypeScript
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<string, unknown> };
|
|
}
|
|
|
|
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<typeof slipOKResponse>["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<string, unknown> };
|
|
}
|
|
|
|
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 };
|
|
}
|