feat : add slipok and switcher to switch between slip2go and slipok
CI / Verify (push) Successful in 1m56s
CI / Build immutable images and deploy (push) Successful in 3m26s

This commit is contained in:
2026-10-10 00:31:12 +07:00 Unverified
parent 4e2de1a89c
commit 7ba513c2bb
18 changed files with 5722 additions and 10 deletions
+4
View File
@@ -1,6 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("./slip-provider", () => ({
getCommissionSlipProvider: vi.fn().mockResolvedValue("slip2go"),
slipProviderConfigured: vi.fn().mockReturnValue(true),
}));
import { promptPayConfig, verifyCommissionSlip } from "./payment";
const names = ["SLIP2GO_VERIFY_URL", "SLIP2GO_API_SECRET", "COMMISSION_PROMPTPAY_TYPE",
+73 -6
View File
@@ -1,6 +1,7 @@
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";
@@ -31,7 +32,7 @@ export function promptPayConfig() {
return { identifier, receiverNumber: required("COMMISSION_RECEIVER_ACCOUNT_NUMBER") };
}
export async function verifyCommissionSlip(file: File, amountBaht: number, checkoutCreatedAt: Date) {
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();
@@ -65,10 +66,76 @@ export async function verifyCommissionSlip(file: File, amountBaht: number, check
throw new HttpError(503, `invalid-slip-verification-response:${String(field)}`);
}
const slip = parsedSlip.data;
if (slip.amount !== amountBaht ||
Date.parse(slip.dateTime) < checkoutCreatedAt.getTime() - 120000 ||
Date.parse(slip.dateTime) > Date.now() + 120000)
throw new HttpError(422, "slip-amount-or-date-mismatch");
return { referenceId: slip.referenceId, transRef: slip.transRef, amountBaht,
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 };
}
+31
View File
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { limit } = vi.hoisted(() => ({ limit: vi.fn() }));
vi.mock("server-only", () => ({}));
vi.mock("@/db", () => ({ getDb: () => ({ select: () => ({ from: () => ({ where: () => ({ limit }) }) }) }) }));
import { getCommissionSlipProvider, slipProviderConfigured } from "./slip-provider";
afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); });
describe("commission provider configuration", () => {
it("reads fresh settings after switching", async () => {
limit.mockResolvedValueOnce([{ slipProvider: "slip2go" }]).mockResolvedValueOnce([{ slipProvider: "slipok" }]);
expect(await getCommissionSlipProvider()).toBe("slip2go");
expect(await getCommissionSlipProvider()).toBe("slipok");
expect(limit).toHaveBeenCalledTimes(2);
});
it.each([{ rows: [] }, { rows: [{ slipProvider: "invalid" }] }])("rejects missing or invalid settings", async ({ rows }) => {
limit.mockResolvedValueOnce(rows);
await expect(getCommissionSlipProvider()).rejects.toMatchObject({ status: 503 });
});
it("requires an HTTPS Slip2Go endpoint and key", () => {
vi.stubEnv("SLIP2GO_API_SECRET", "secret");
vi.stubEnv("SLIP2GO_VERIFY_URL", "http://slip.test");
expect(slipProviderConfigured("slip2go")).toBe(false);
vi.stubEnv("SLIP2GO_VERIFY_URL", "invalid");
expect(slipProviderConfigured("slip2go")).toBe(false);
vi.stubEnv("SLIP2GO_VERIFY_URL", "https://slip.test");
expect(slipProviderConfigured("slip2go")).toBe(true);
vi.stubEnv("SLIP2GO_API_SECRET", "");
expect(slipProviderConfigured("slip2go")).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
import "server-only";
import { eq } from "drizzle-orm";
import { getDb } from "@/db";
import { commissionSettings } from "@/db/schema";
import { HttpError } from "@/lib/security/http";
export type SlipProvider = "slip2go" | "slipok";
export function isSlipProvider(value: unknown): value is SlipProvider {
return value === "slip2go" || value === "slipok";
}
export function slipProviderConfigured(provider: SlipProvider): boolean {
if (provider === "slipok")
return /^\d+$/.test(process.env.SLIPOK_BRANCH_ID ?? "") && Boolean(process.env.SLIPOK_API_KEY?.trim());
try {
return new URL(process.env.SLIP2GO_VERIFY_URL ?? "").protocol === "https:" &&
Boolean(process.env.SLIP2GO_API_SECRET?.trim());
} catch { return false; }
}
export async function getCommissionSlipProvider(): Promise<SlipProvider> {
const [settings] = await getDb().select({ slipProvider: commissionSettings.slipProvider })
.from(commissionSettings).where(eq(commissionSettings.id, 1)).limit(1);
if (!settings || !isSlipProvider(settings.slipProvider))
throw new HttpError(503, "slip-provider-not-configured");
return settings.slipProvider;
}
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
limit: vi.fn(), verifyCommissionSlip: vi.fn(), write: vi.fn(), delete: vi.fn(),
paymentValues: vi.fn(), ticketReturning: vi.fn(), notify: vi.fn(), discord: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/redis/client", () => ({ getRedisClient: async () => ({ set: async () => "OK", eval: async () => 0 }) }));
vi.mock("@/lib/media/inspect", () => ({ inspectImage: vi.fn() }));
vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => ({ write: mocks.write, delete: mocks.delete }) }));
vi.mock("@/lib/commission/payment", () => ({ verifyCommissionSlip: mocks.verifyCommissionSlip }));
vi.mock("@/lib/commission/server", () => ({ notifyCommission: mocks.notify }));
vi.mock("@/lib/commission/discord", () => ({ notifyPaidTicketDiscord: mocks.discord }));
vi.mock("@/db", () => ({ getDb: () => ({
select: () => ({ from: () => ({ where: () => ({ limit: mocks.limit }) }) }),
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
let inserts = 0;
return fn({ insert: () => ++inserts === 1
? { values: () => ({ returning: mocks.ticketReturning }) }
: { values: mocks.paymentValues } });
},
}) }));
import { verifyAndCreateTicket } from "./slip-upload";
import type { Checkout } from "./slip-upload";
const checkout = { id: "checkout", userId: "customer", amountBaht: 100, createdAt: new Date() } as Checkout;
const file = new File(["image"], "slip.png", { type: "image/png" });
beforeEach(() => {
vi.clearAllMocks();
mocks.limit.mockResolvedValue([]);
mocks.ticketReturning.mockResolvedValue([{ id: "ticket" }]);
mocks.paymentValues.mockResolvedValue(undefined);
mocks.write.mockResolvedValue(undefined);
mocks.delete.mockResolvedValue(undefined);
});
describe("verified commission payment persistence", () => {
it.each(["slip2go", "slipok"])("persists the %s provider without changing the bank transaction key", async (provider) => {
mocks.verifyCommissionSlip.mockResolvedValue({ provider, transRef: "bank-ref", referenceId: `${provider}:ref`,
amountBaht: 100, transferredAt: new Date(), providerData: { success: true } });
expect(await verifyAndCreateTicket(checkout, file)).toEqual({ ticketId: "ticket", created: true });
expect(mocks.paymentValues).toHaveBeenCalledWith(expect.objectContaining({ provider, transRef: "bank-ref",
checkoutId: "checkout", ticketId: "ticket" }));
});
it("rejects a bank transaction already paid through another provider and cleans up the upload", async () => {
mocks.verifyCommissionSlip.mockResolvedValue({ provider: "slipok", transRef: "bank-ref",
referenceId: "slipok:bank-ref", amountBaht: 100, transferredAt: new Date(), providerData: {} });
mocks.paymentValues.mockRejectedValueOnce(Object.assign(new Error("duplicate bank reference"), { code: "23505" }));
await expect(verifyAndCreateTicket(checkout, file)).rejects.toMatchObject({ status: 409, message: "payment-already-used" });
expect(mocks.delete).toHaveBeenCalledOnce();
expect(mocks.notify).not.toHaveBeenCalled();
expect(mocks.discord).not.toHaveBeenCalled();
});
});
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
vi.mock("./slip-provider", () => ({
getCommissionSlipProvider: vi.fn(), slipProviderConfigured: vi.fn().mockReturnValue(true),
}));
import { getCommissionSlipProvider, slipProviderConfigured } from "./slip-provider";
import { verifyCommissionSlip } from "./payment";
import { verificationError } from "./verification-error";
const checkoutDate = new Date("2026-10-10T10:00:00Z");
const validData = { success: true, data: { success: true, transRef: "bank-ref", amount: 100,
transTimestamp: "2026-10-10T10:01:00.000Z" } };
const file = new File(["image"], "slip.png", { type: "image/png" });
const fetchMock = vi.fn();
const verify = () => verifyCommissionSlip(file, 100, checkoutDate);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-10T10:02:00Z"));
vi.stubEnv("SLIPOK_BRANCH_ID", "123");
vi.stubEnv("SLIPOK_API_KEY", "secret");
vi.mocked(getCommissionSlipProvider).mockResolvedValue("slipok");
vi.mocked(slipProviderConfigured).mockReturnValue(true);
fetchMock.mockResolvedValue(Response.json(validData));
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
describe("SlipOK verification", () => {
it("sends the image, receiver logging and expected amount and normalizes the transfer", async () => {
const result = await verify();
expect(result).toMatchObject({ provider: "slipok", referenceId: "slipok:bank-ref",
transRef: "bank-ref", amountBaht: 100, transferredAt: new Date("2026-10-10T10:01:00Z"),
providerData: validData });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("https://api.slipok.com/api/line/apikey/123");
expect(init).toMatchObject({ method: "POST", headers: { "x-authorization": "secret" }, cache: "no-store" });
expect(init.body.get("files")).toBeInstanceOf(File);
expect(init.body.get("log")).toBe("true");
expect(init.body.get("amount")).toBe("100");
expect(init.body.get("file")).toBeNull();
});
it("interprets legacy date and time as Bangkok time", async () => {
fetchMock.mockResolvedValue(Response.json({ success: true, data: { success: true,
transRef: "bank-ref", amount: 100, transDate: "20261010", transTime: "17:01:00" } }));
expect((await verify()).transferredAt).toEqual(new Date("2026-10-10T10:01:00Z"));
});
it.each(["invalid", "2026-02-30T10:01:00Z", "2026-10-10T25:01:00Z", "2026-10-10T10:01:00"])
("rejects an invalid timestamp %s", async (transTimestamp) => {
fetchMock.mockResolvedValue(Response.json({ ...validData, data: { ...validData.data, transTimestamp } }));
await expect(verify()).rejects.toMatchObject({ status: 503 });
});
it.each([{}, { success: true, data: {} }, { ...validData, success: false },
{ ...validData, data: { ...validData.data, success: false } }])("rejects malformed or unverified responses", async (body) => {
fetchMock.mockResolvedValue(Response.json(body));
await expect(verify()).rejects.toMatchObject({ status: 503 });
});
it.each([1000, 1001, 1002, 1003, 1004, 1009, 1010, 1015])("classifies service error %s", async (code) => {
fetchMock.mockResolvedValue(Response.json({ code }, { status: 400 }));
await expect(verify()).rejects.toMatchObject({ status: 503, message: `slipok-service:${code}` });
});
it.each([1005, 1006, 1007, 1008, 1011, 1012, 1013, 1014])("rejects slip error %s, including accompanying data", async (code) => {
fetchMock.mockResolvedValue(Response.json({ code, data: validData.data }, { status: 400 }));
await expect(verify()).rejects.toMatchObject({ status: 422, message: `slipok-rejected:${code}` });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each([401, 429, 500])("handles HTTP %s without valid JSON", async (status) => {
fetchMock.mockResolvedValue(new Response("upstream failure", { status }));
await expect(verify()).rejects.toMatchObject({ status: 503 });
});
it("does not fall back on timeout", async () => {
fetchMock.mockRejectedValue(new DOMException("Timed out", "TimeoutError"));
await expect(verify()).rejects.toMatchObject({ status: 503, message: "slip-verification-unavailable" });
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each([{ amount: 80 }, { transTimestamp: "2026-10-10T09:57:59Z" },
{ transTimestamp: "2026-10-10T10:04:01Z" }])("rejects mismatched amount or transfer time", async (fields) => {
fetchMock.mockResolvedValue(Response.json({ ...validData, data: { ...validData.data, ...fields } }));
await expect(verify()).rejects.toMatchObject({ status: 422, message: "slip-amount-or-date-mismatch" });
});
it.each(["2026-10-10T09:58:00Z", "2026-10-10T10:04:00Z"])("accepts the date boundary %s", async (transTimestamp) => {
fetchMock.mockResolvedValue(Response.json({ ...validData, data: { ...validData.data, transTimestamp } }));
await expect(verify()).resolves.toMatchObject({ transferredAt: new Date(transTimestamp) });
});
it("reads the provider once on every attempt and keeps bank references across a switch", async () => {
expect((await verify()).transRef).toBe("bank-ref");
vi.mocked(getCommissionSlipProvider).mockResolvedValue("slip2go");
vi.stubEnv("SLIP2GO_VERIFY_URL", "https://slip2go.test/verify");
vi.stubEnv("SLIP2GO_API_SECRET", "secret");
vi.stubEnv("COMMISSION_PROMPTPAY_TYPE", "mobile");
vi.stubEnv("COMMISSION_PROMPTPAY_VALUE", "0812345678");
vi.stubEnv("COMMISSION_RECEIVER_ACCOUNT_NUMBER", "0812345678");
fetchMock.mockResolvedValue(Response.json({ code: "200200", data: { referenceId: "provider-ref",
transRef: "bank-ref", amount: 100, dateTime: "2026-10-10T10:01:00Z" } }));
expect(await verify()).toMatchObject({ provider: "slip2go", transRef: "bank-ref", referenceId: "provider-ref" });
expect(getCommissionSlipProvider).toHaveBeenCalledTimes(2);
});
it("does not call an unconfigured provider", async () => {
vi.mocked(slipProviderConfigured).mockReturnValue(false);
await expect(verify()).rejects.toMatchObject({ status: 503, message: "slip-provider-not-configured" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("warns against paying again after a provider duplicate rejection", () => {
expect(verificationError("slipok-rejected:1012")).toContain("ก่อนโอนซ้ำ");
expect(verificationError("slipok-service:1002")).toContain("API Key");
expect(verificationError("slipok-service:1010")).toContain("รอสักครู่");
});
});
+23
View File
@@ -1,4 +1,26 @@
export function verificationError(code: string | undefined): string {
if (code?.startsWith("slipok-service:") || code?.startsWith("slipok-rejected:")) {
const reason = code.slice(code.indexOf(":") + 1);
switch (reason) {
case "1000": return "ตั้งค่าคำขอตรวจสลิป SlipOK ไม่ถูกต้อง กรุณาแจ้งผู้ดูแลระบบ (1000)";
case "1001": return "ไม่พบสาขา SlipOK ที่ตั้งไว้ กรุณาแจ้งผู้ดูแลระบบ (1001)";
case "1002": return "SlipOK ไม่ยอมรับ API Key ที่ตั้งไว้ กรุณาแจ้งผู้ดูแลระบบ (1002)";
case "1003": return "แพ็กเกจ SlipOK หมดอายุ กรุณาแจ้งผู้ดูแลระบบ (1003)";
case "1004": return "โควต้า SlipOK ไม่เพียงพอ กรุณาแจ้งผู้ดูแลระบบ (1004)";
case "1005":
case "1006": return `รูปสลิปไม่ถูกต้อง กรุณาอัปโหลดรูปจากแอปธนาคาร (${reason})`;
case "1007":
case "1008": return `อ่าน QR สำหรับตรวจสอบการชำระเงินไม่ได้ กรุณาอัปโหลดรูปสลิปจากแอปธนาคาร (${reason})`;
case "1009": return "ธนาคารขัดข้องชั่วคราว กรุณาตรวจสลิปอีกครั้งใน 15 นาที (1009)";
case "1010": return "ธนาคารยังไม่พร้อมยืนยันสลิป กรุณารอสักครู่แล้วลองอีกครั้ง (1010)";
case "1011": return "QR หมดอายุหรือไม่พบรายการโอน กรุณาตรวจสอบสลิป (1011)";
case "1012": return "สลิปนี้เคยถูกตรวจสอบแล้ว กรุณาติดต่อผู้ดูแลก่อนโอนซ้ำ (1012)";
case "1013": return "ยอดเงินในสลิปไม่ตรงกับยอดคำขอ (1013)";
case "1014": return "บัญชีผู้รับในสลิปไม่ตรงกับบัญชีที่ตั้งไว้ใน SlipOK (1014)";
case "1015": return "ไม่พบแพ็กเกจ SlipOK กรุณาแจ้งผู้ดูแลระบบ (1015)";
default: return "SlipOK ตรวจสอบสลิปไม่สำเร็จ กรุณาลองอีกครั้งหรือติดต่อผู้ดูแลระบบ";
}
}
const reason = code?.startsWith("slip-rejected:") ? code.slice("slip-rejected:".length)
: code?.startsWith("slip2go-service:") ? code.slice("slip2go-service:".length) : code;
switch (reason) {
@@ -15,6 +37,7 @@ export function verificationError(code: string | undefined): string {
case "400400": return "ตั้งค่าเงื่อนไขตรวจสอบสลิปไม่ถูกต้อง กรุณาแจ้งผู้ดูแลระบบ (400400)";
case "401001": return "Slip2Go ไม่ยอมรับ API Secret ที่ตั้งไว้ กรุณาแจ้งผู้ดูแลระบบ (401001)";
case "401005": return "โทเคน Slip2Go หมด กรุณาแจ้งผู้ดูแลระบบ (401005)";
case "slip-provider-not-configured": return "ยังไม่ได้ตั้งค่าผู้ให้บริการตรวจสลิป กรุณาแจ้งผู้ดูแลระบบ";
case "slip-amount-or-date-mismatch": return "ยอดเงินหรือวันเวลาในสลิปไม่ตรงกับคำขอนี้ กรุณาใช้สลิปจากการชำระเงินครั้งนี้";
case "payment-already-used": return "สลิปนี้ถูกใช้กับคำขออื่นแล้ว";
case "slip-verification-in-progress": return "กำลังตรวจสอบสลิปอยู่ กรุณารอสักครู่";