diff --git a/.env.example b/.env.example
index f12929b..a66ea33 100644
--- a/.env.example
+++ b/.env.example
@@ -23,9 +23,17 @@ DISCORD_REDIRECT_URI=https://YOUR_BASE_URL/rubgram/callback
DISCORD_WEBHOOK_URL=YOUR_DISCORD_WEBHOOK_URL # For Rubgram calling
WEBHOOK_RUBGRAM_SUBMIT=YOUR_WEBHOOK_RUBGRAM_SUBMIT # For Rubgram submit
-# Payment (Rubgram) https://slipok.com/
+# Slip verification (Rubgram + donations): select provider in /admin/settings
+# SlipOK (default) https://slipok.com/
SLIPOK_API_URL=YOUR_SLIPOK_API_URL
SLIPOK_API_KEY=YOUR_SLIPOK_API_KEY
+# Slip2Go: full URL ending in /api/verify-slip/qr-image/info
+SLIP2GO_VERIFY_URL=YOUR_SLIP2GO_IMAGE_API_URL
+SLIP2GO_API_SECRET=YOUR_SLIP2GO_SECRET_KEY
+# Both payment flows must receive into this PromptPay account when using Slip2Go.
+# mobile, nationalId, or ewallet
+PROMPTPAY_TYPE=mobile
+PROMPTPAY_IDENTIFIER=YOUR_PROMPTPAY_IDENTIFIER
# Donate-related
# Gemini TTS API
@@ -40,4 +48,4 @@ DONATE_WIDGET_KEY=RANDOM_GENERATED
# Environment
ENVIRONMENT=production
-NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="openssl rand -base64 32"
\ No newline at end of file
+NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="openssl rand -base64 32"
diff --git a/README.md b/README.md
index a286e3f..514fbb9 100755
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ Next.js 16 application running multiple community events for YouTuber Buzz. Buil
Payment-processing and stream-overlay system with two payment methods:
- **TrueMoney (TMN):** Voucher gift link redemption via Sastify API
-- **PromptPay (SlipOK):** Slip image upload verified by SlipOK API
+- **PromptPay:** Slip image upload verified by SlipOK or Slip2Go, selected in admin settings
Key features:
@@ -41,7 +41,7 @@ Paid endgame content service where Buzz plays on your account:
- **Service selector:** Multiple configurable service types with real-time price estimation; "all services" discount
- **Free queue counter:** Admin-configurable free slots (price = 0)
- **20-minute payment window** with PromptPay QR code and slip upload
-- **SlipOK verification** with deduplication by `transRef`
+- **SlipOK / Slip2Go verification** with deduplication by `transRef`
- **Expiration system:** Unpaid submissions auto-expire with queue rebalancing; expired users can restore and pay
- **Admin panel:** Lock/limit/free controls, manual submission creation, Discord ping ("ถึงคิวแล้ว"), slip archive viewer by round, monthly archive
- **Widget:** `/widget/rubgram-count` — OBS overlay displaying count/limit
@@ -118,8 +118,18 @@ All widgets are SSE-driven, auto-updating, and designed for streaming overlays.
## Payment Processing
-- **Rubgram:** PromptPay only — SlipOK API verifies slip images, deduplicated by `transRef`. 20-minute payment window with QR code.
-- **Donation:** TrueMoney (Sastify API voucher redemption) + PromptPay (SlipOK). Transactions stored in dedicated `endgame.slips` table with full SlipOK response.
+- **Rubgram:** PromptPay only — the selected slip provider verifies images, deduplicated by `transRef`. 20-minute payment window with QR code.
+- **Donation:** TrueMoney (Sastify API voucher redemption) + PromptPay (the same selected provider). Transactions stored in `endgame.slips` with provider identity, verified amount/reference, and the full original response. Historical SlipOK records remain supported.
+
+Switch between **SlipOK** (default) and **Slip2Go** in `/admin/settings` → external services → slip verification. The choice is saved globally and read from the primary database for each new check; no restart is needed. Checks already running finish with their original provider. The selector requires valid configuration before saving; there is no automatic fallback or retry.
+
+- SlipOK uses the existing `SLIPOK_API_URL` and `SLIPOK_API_KEY`.
+- Slip2Go uses `SLIP2GO_VERIFY_URL` (the full image endpoint URL ending in `/api/verify-slip/qr-image/info`) and `SLIP2GO_API_SECRET` (the exact secret value sent in `Authorization`). See the [image verification guide](https://slip2go.com/guide/rest-api/image) and [authentication guide](https://slip2go.com/guide/authentication).
+- Slip2Go validates the exact payment amount and the account from `PROMPTPAY_TYPE` (`mobile`, `nationalId`, or `ewallet`) and `PROMPTPAY_IDENTIFIER`. Rubgram's static QR must receive into that same account. Provider duplicate checking is omitted; the shared database unique reference prevents reuse across both flows and providers while allowing failed submissions to be retried.
+
+Before deploying this change, run `bun dr push` against the deployment database to add `settings.slipCheckProvider` with default `slipok`. No existing slip JSON needs migration. Then configure Slip2Go credentials and PromptPay variables on each app instance before selecting Slip2Go. Revert to SlipOK using the same selector.
+
+Verification has a 30-second timeout and returns payment errors for provider failures, invalid responses, or unavailable services. `/api/health` checks the selected provider's origin without uploading a slip or spending verification credits; its cache is separate per provider. This checks reachability, not bank verification or account credits.
## Authentication & Authorization
@@ -136,6 +146,7 @@ All widgets are SSE-driven, auto-updating, and designed for streaming overlays.
| Enka Network | Character showcase auto-fetch | enka.network |
| Astral/Enka Embed | Character card rendering | git.dgnr.us/astral/api |
| SlipOK | PromptPay slip verification | configurable |
+| Slip2Go | PromptPay slip verification | configurable |
| Sastify | TrueMoney voucher redemption | api.sastify.xyz |
| Google Gemini | TTS for donation announcements | gemini-2.5-flash-preview-tts |
| Discord API | OAuth2, guild member list, webhook pings | discord.com |
@@ -234,7 +245,8 @@ Required variables (~30 total, see `.env.example`):
| ----------------- | ---------------------------------------------------------------------------------- |
| Database | `DATABASE_URL`, `REDIS_URL` |
| Auth | `BETTER_AUTH_SECRET` |
-| Rubgram payments | `SLIPOK_API_URL`, `SLIPOK_API_KEY` |
+| Slip verification | `SLIPOK_API_URL`, `SLIPOK_API_KEY`; optional `SLIP2GO_VERIFY_URL`, `SLIP2GO_API_SECRET` |
+| PromptPay account | `PROMPTPAY_TYPE`, `PROMPTPAY_IDENTIFIER` (required for Slip2Go and donation QR) |
| Donation payments | `TMN_DEST_PHONE_NUM`, `SASTIFY_API_PRIVKEY` |
| TTS | `GEMINI_TTS_API_KEY` |
| Widget | `DONATE_WIDGET_KEY` |
diff --git a/app/(ui)/admin/settings/api.ts b/app/(ui)/admin/settings/api.ts
index 3f7f555..51bef5e 100755
--- a/app/(ui)/admin/settings/api.ts
+++ b/app/(ui)/admin/settings/api.ts
@@ -1,22 +1,52 @@
"use server";
import { revalidatePath } from "next/cache";
-import { actionLog } from "@/lib/api";
-import { db } from "@/lib/db";
-import { settings } from "@/lib/db/schema";
-import { adminCheck } from "@/lib/auth";
-import { sse } from "@/lib/db/sse-endpoints";
-import { syncAmber as syncAmberRaw } from "@/util/sync";
import type { ToastType } from "@/components/fake-toast";
+import { actionLog } from "@/lib/api";
+import { adminCheck } from "@/lib/auth";
+import { db, primaryDb } from "@/lib/db";
+import { settings } from "@/lib/db/schema";
+import { sse } from "@/lib/db/sse-endpoints";
+import { getSlipConfiguration, isSlipProvider } from "@/lib/slip-checker";
+import { syncAmber as syncAmberRaw } from "@/util/sync";
export async function getSettongs() {
if (!(await adminCheck())) throw "Unauthorized";
const [
- settangs = { enka: true, donatePromptpay: true, donateTruemoney: true },
- ] = await db.select().from(settings).limit(1);
+ settangs = {
+ enka: true,
+ donatePromptpay: true,
+ donateTruemoney: true,
+ slipCheckProvider: "slipok" as const,
+ },
+ ] = await primaryDb.select().from(settings).limit(1);
return settangs;
}
+export async function setSlipCheckProvider(provider: string) {
+ if (!(await adminCheck())) throw "Unauthorized";
+ if (!isSlipProvider(provider))
+ return { success: false as const, message: "บริการตรวจสอบสลิปไม่ถูกต้อง" };
+ try {
+ getSlipConfiguration(provider);
+ } catch (error) {
+ return {
+ success: false as const,
+ message: error instanceof Error ? error.message : "การตั้งค่าไม่ถูกต้อง",
+ };
+ }
+ await primaryDb
+ .insert(settings)
+ .values({ slipCheckProvider: provider })
+ .onConflictDoUpdate({
+ target: settings.id,
+ set: { slipCheckProvider: provider },
+ });
+ await actionLog("Changed slip verification provider", { provider });
+ revalidatePath("/admin/settings");
+ return { success: true as const, provider };
+}
+
export async function toggleEnka(state: boolean) {
if (!(await adminCheck())) throw "Unauthorized";
await db
diff --git a/app/(ui)/admin/settings/page.tsx b/app/(ui)/admin/settings/page.tsx
index 0956a5e..6edfc03 100755
--- a/app/(ui)/admin/settings/page.tsx
+++ b/app/(ui)/admin/settings/page.tsx
@@ -12,7 +12,8 @@ import { getSettongs } from "./api";
import { SettingsServicesSection } from "./services";
export default async function () {
- const { enka, donatePromptpay, donateTruemoney } = await getSettongs();
+ const { enka, donatePromptpay, donateTruemoney, slipCheckProvider } =
+ await getSettongs();
return (
@@ -26,6 +27,7 @@ export default async function () {
enka={enka}
donPp={donatePromptpay}
donTmn={donateTruemoney}
+ slipProvider={slipCheckProvider}
/>
diff --git a/app/(ui)/admin/settings/services.tsx b/app/(ui)/admin/settings/services.tsx
index 02bd51a..f411b7b 100755
--- a/app/(ui)/admin/settings/services.tsx
+++ b/app/(ui)/admin/settings/services.tsx
@@ -2,13 +2,18 @@
import { Bomb, FolderSync } from "lucide-react";
import { useState } from "react";
+import { toast } from "sonner";
import { ActionButton } from "@/components/action-button";
import { CdnChooser } from "@/components/chooser";
+import { Field, FieldDescription, FieldTitle } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { shared } from "@/lib/comms";
+import type { SlipProvider } from "@/lib/slip-checker";
import {
forceRefresh,
+ setSlipCheckProvider,
syncAmber,
toggleDonatePaymentMethod,
toggleEnka,
@@ -20,10 +25,12 @@ export function SettingsServicesSection({
enka: enkaInitial,
donPp: initialDonPp,
donTmn: initialDonTmn,
+ slipProvider: initialSlipProvider,
}: {
enka: boolean;
donPp: boolean;
donTmn: boolean;
+ slipProvider: SlipProvider;
}) {
const [syncResult, setSyncResult] = useState("");
const [enka, setEnka] = useState(enkaInitial);
@@ -31,6 +38,31 @@ export function SettingsServicesSection({
const [donTmn, setDonTmn] = useState(initialDonTmn);
const [pathToReload, setPathToReload] = useState("");
const [debug] = shared.state("debug");
+ const [slipProvider, setSlipProvider] = useState(initialSlipProvider);
+ const [savingSlipProvider, setSavingSlipProvider] = useState(false);
+
+ async function changeSlipProvider(value: string) {
+ if (
+ (value !== "slipok" && value !== "slip2go") ||
+ value === slipProvider ||
+ savingSlipProvider
+ )
+ return;
+ setSavingSlipProvider(true);
+ try {
+ const result = await setSlipCheckProvider(value);
+ if (result.success) {
+ setSlipProvider(result.provider);
+ toast.success("เปลี่ยนบริการตรวจสอบสลิปแล้ว");
+ } else {
+ toast.error(result.message);
+ }
+ } catch {
+ toast.error("ไม่สามารถเปลี่ยนบริการตรวจสอบสลิปได้ กรุณาลองใหม่");
+ } finally {
+ setSavingSlipProvider(false);
+ }
+ }
async function sync() {
return syncAmber().then(setSyncResult);
@@ -73,6 +105,25 @@ export function SettingsServicesSection({
Reload
+
+ บริการตรวจสอบสลิป
+
+ SlipOK
+ Slip2Go
+
+
+ ใช้สำหรับรับกรรมและโดเนท มีผลกับการตรวจสอบสลิปครั้งถัดไป
+
+
Donate Payment Method
;
const redis = redisShared!;
-export const revalidate = 60;
+export const dynamic = "force-dynamic";
export async function GET() {
+ const slipProvider = await getSlipProvider().catch(() => null);
+ const healthChecks = {
+ ...checks,
+ async slip() {
+ if (!slipProvider) return false;
+ const { url } = getSlipConfiguration(slipProvider);
+ // API origins may return 404/401 without a route or credentials; they are reachable.
+ return fetch(new URL(url).origin, {
+ signal: AbortSignal.timeout(5000),
+ }).then((response) => response.status < 500);
+ },
+ };
const red = await redis
.ping()
.then(() => true)
.catch(() => false);
const res = Object.fromEntries(
await Promise.all(
- Object.entries(checks).map(
- async ([k, v]) =>
- [
- k,
- red
- ? (await redis.exists(`health:${k}`))
- ? (await redis.get(`health:${k}`)) === "ok"
- : await v()
- .then(async (r) => {
- const v = r instanceof Response ? r.ok : !!r;
- if (v) await redis.setex(`health:${k}`, 900, "ok");
- return v;
- })
- .catch(() => false)
+ Object.entries(healthChecks).map(async ([k, v]) => {
+ const cacheKey = `health:${k === "slip" ? `slip:${slipProvider}` : k}`;
+ return [
+ k,
+ red
+ ? (await redis.exists(cacheKey))
+ ? (await redis.get(cacheKey)) === "ok"
: await v()
- .then((r) => (r instanceof Response ? r.ok : !!r))
- .catch(() => false),
- ] as const,
- ),
+ .then(async (r) => {
+ const v = r instanceof Response ? r.ok : !!r;
+ if (v) await redis.setex(cacheKey, 900, "ok");
+ return v;
+ })
+ .catch(() => false)
+ : await v()
+ .then((r) => (r instanceof Response ? r.ok : !!r))
+ .catch(() => false),
+ ] as const;
+ }),
),
);
return Response.json(
@@ -73,11 +87,6 @@ const checks = {
signal: AbortSignal.timeout(5000),
});
},
- async slip() {
- return fetch("https://api.slipok.com/", {
- signal: AbortSignal.timeout(5000),
- });
- },
async tts() {
const client = new GoogleGenAI({
apiKey: GEMINI_TTS_API_KEY.split(",").pop(),
diff --git a/lib/db/index.ts b/lib/db/index.ts
index 2f251b2..47ffeda 100755
--- a/lib/db/index.ts
+++ b/lib/db/index.ts
@@ -1,10 +1,12 @@
import { drizzle } from "drizzle-orm/bun-sql";
-import * as schema from "./schema";
import { withReplicas } from "drizzle-orm/pg-core";
+import * as schema from "./schema";
+
+export const primaryDb = drizzle(process.env.DATABASE_URL!, { schema });
export const db = process.env.REPLICA_DATABASE_URL
- ? withReplicas(drizzle(process.env.DATABASE_URL!, { schema }), [
+ ? withReplicas(primaryDb, [
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
])
- : drizzle(process.env.DATABASE_URL!, { schema });
+ : primaryDb;
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 76a16cd..536bc58 100755
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -14,6 +14,7 @@ import {
} from "drizzle-orm/pg-core";
import { uuidv7 } from "uuidv7";
import type { SlipokResponse } from "@/app/(ui)/rubgram/api";
+import type { SlipCheckResult } from "@/lib/slip-checker";
import { bytea } from "./custom";
export const element = pgEnum("character_element", [
@@ -77,6 +78,9 @@ export const settings = pgTable("settings", {
enka: boolean().notNull().default(true), // Turn this off when Enka is on maintenance
donatePromptpay: boolean().notNull().default(true),
donateTruemoney: boolean().notNull().default(true),
+ slipCheckProvider: text({ enum: ["slipok", "slip2go"] })
+ .notNull()
+ .default("slipok"),
donateGoal: numeric({ mode: "number" }),
donateGoalStarting: timestamp(),
});
@@ -194,7 +198,7 @@ export const endgameSlips = endgame.table("slips", {
slip: bytea().notNull(),
ref: text().unique().notNull(),
amount: numeric().notNull(),
- data: jsonb().$type().notNull(),
+ data: jsonb().$type().notNull(),
});
export const endgameSettings = endgame.table("settings", {
diff --git a/lib/payment.test.ts b/lib/payment.test.ts
new file mode 100644
index 0000000..a5ca3cd
--- /dev/null
+++ b/lib/payment.test.ts
@@ -0,0 +1,221 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ mock,
+ spyOn,
+ test,
+} from "bun:test";
+
+// Keep these module mocks in this focused test process: no external DB/auth/services.
+let storedProvider: string | undefined;
+let authorized = true;
+const saved = mock((values: { slipCheckProvider: string }) => {
+ storedProvider = values.slipCheckProvider;
+});
+const primarySelect = mock(() => ({
+ from: () => ({
+ where: async () =>
+ storedProvider === undefined ? [] : [{ provider: storedProvider }],
+ limit: async () =>
+ storedProvider === undefined
+ ? []
+ : [{ slipCheckProvider: storedProvider }],
+ }),
+}));
+const replicaSelect = mock(() => {
+ throw new Error("Provider must not be read from replica");
+});
+mock.module("@/lib/db", () => ({
+ db: { select: replicaSelect },
+ primaryDb: {
+ select: primarySelect,
+ insert: () => ({
+ values: (values: { slipCheckProvider: string }) => ({
+ onConflictDoUpdate: async () => saved(values),
+ }),
+ }),
+ },
+}));
+mock.module("@/lib/auth", () => ({ adminCheck: async () => authorized }));
+const actionLog = mock(async () => {});
+mock.module("@/lib/api", () => ({ actionLog }));
+const revalidatePath = mock(() => {});
+mock.module("next/cache", () => ({ revalidatePath }));
+mock.module("@/lib/db/sse-endpoints", () => ({ sse: {} }));
+mock.module("@/util/sync", () => ({ syncAmber: async () => {} }));
+const healthCache = new Map();
+mock.module("@/lib/db/redis", () => ({
+ redis: {
+ ping: async () => "PONG",
+ exists: async (key: string) => Number(healthCache.has(key)),
+ get: async (key: string) => healthCache.get(key),
+ setex: async (key: string, _seconds: number, value: string) =>
+ healthCache.set(key, value),
+ },
+}));
+
+const { checkSlip, getSlipProvider } = await import("./payment");
+const { getSettongs, setSlipCheckProvider } = await import(
+ "@/app/(ui)/admin/settings/api"
+);
+const { GET: getHealth } = await import("@/app/api/health/route");
+const testEnv = {
+ SLIPOK_API_URL: "https://slipok.example/verify",
+ SLIPOK_API_KEY: "test-slipok-key",
+ SLIP2GO_VERIFY_URL: "https://slip2go.example/api/verify-slip/qr-image/info",
+ SLIP2GO_API_SECRET: "test-slip2go-key",
+ PROMPTPAY_TYPE: "mobile",
+ PROMPTPAY_IDENTIFIER: "0812345678",
+};
+const previousEnv = Object.fromEntries(
+ Object.keys(testEnv).map((key) => [key, process.env[key]]),
+);
+let fetchSpy: ReturnType> | undefined;
+beforeEach(() => {
+ Object.assign(process.env, testEnv);
+ storedProvider = undefined;
+ authorized = true;
+ saved.mockClear();
+ actionLog.mockClear();
+ revalidatePath.mockClear();
+ primarySelect.mockClear();
+ replicaSelect.mockClear();
+ healthCache.clear();
+});
+afterEach(() => {
+ fetchSpy?.mockRestore();
+ for (const [key, value] of Object.entries(previousEnv)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+});
+
+describe("saved slip provider", () => {
+ test("defaults to SlipOK without a settings row", async () => {
+ expect(await getSlipProvider()).toBe("slipok");
+ expect((await getSettongs()).slipCheckProvider).toBe("slipok");
+ expect(replicaSelect).not.toHaveBeenCalled();
+ });
+
+ test("only admins can change or read admin settings", async () => {
+ authorized = false;
+ await expect(setSlipCheckProvider("slip2go")).rejects.toBe("Unauthorized");
+ await expect(getSettongs()).rejects.toBe("Unauthorized");
+ expect(saved).not.toHaveBeenCalled();
+ });
+
+ test("rejects unsupported providers before saving", async () => {
+ expect((await setSlipCheckProvider("unknown")).success).toBe(false);
+ expect(saved).not.toHaveBeenCalled();
+ });
+
+ test("does not save a provider with missing credentials", async () => {
+ storedProvider = "slipok";
+ delete process.env.SLIP2GO_API_SECRET;
+ expect((await setSlipCheckProvider("slip2go")).success).toBe(false);
+ expect(await getSlipProvider()).toBe("slipok");
+ expect(saved).not.toHaveBeenCalled();
+ });
+
+ test("requires a valid recipient before switching to Slip2Go", async () => {
+ delete process.env.PROMPTPAY_IDENTIFIER;
+ expect((await setSlipCheckProvider("slip2go")).success).toBe(false);
+ expect(saved).not.toHaveBeenCalled();
+ });
+
+ test("saves, audits, refreshes, and takes effect on the next check", async () => {
+ const transaction = { transRef: "SAME-BANK-REF", amount: 10 };
+ fetchSpy = spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(
+ Response.json({ success: true, data: transaction }),
+ )
+ .mockResolvedValueOnce(
+ Response.json({ code: "200200", data: transaction }),
+ )
+ .mockResolvedValueOnce(
+ Response.json({ success: true, data: transaction }),
+ );
+ const buffer = Buffer.from("slip");
+ const first = await checkSlip(buffer, "image/png", 10);
+ expect(first.provider).toBe("slipok");
+ expect(await setSlipCheckProvider("slip2go")).toEqual({
+ success: true,
+ provider: "slip2go",
+ });
+ expect((await getSettongs()).slipCheckProvider).toBe("slip2go");
+ expect(actionLog).toHaveBeenCalledWith(
+ "Changed slip verification provider",
+ { provider: "slip2go" },
+ );
+ expect(revalidatePath).toHaveBeenCalledWith("/admin/settings");
+ const second = await checkSlip(buffer, "image/png", 10);
+ expect(second.provider).toBe("slip2go");
+ if (!first.success || !second.success)
+ throw new Error("Expected verified transactions");
+ // Both providers keep the same bank reference for the existing unique DB constraint.
+ expect(first.data.transRef).toBe(second.data.transRef);
+ await setSlipCheckProvider("slipok");
+ expect((await checkSlip(buffer, "image/png", 10)).provider).toBe("slipok");
+ expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
+ testEnv.SLIPOK_API_URL,
+ testEnv.SLIP2GO_VERIFY_URL,
+ testEnv.SLIPOK_API_URL,
+ ]);
+ expect(replicaSelect).not.toHaveBeenCalled();
+ });
+
+ test("an in-progress check keeps its starting provider", async () => {
+ let complete!: (response: Response) => void;
+ const request = new Promise((resolve) => {
+ complete = resolve;
+ });
+ fetchSpy = spyOn(globalThis, "fetch").mockReturnValue(request);
+ const pending = checkSlip(Buffer.from("slip"), "image/png", 10);
+ // Wait until the first request is sent, then change the setting.
+ await Bun.sleep(0);
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ await setSlipCheckProvider("slip2go");
+ complete(
+ Response.json({ success: true, data: { transRef: "REF", amount: 10 } }),
+ );
+ expect((await pending).provider).toBe("slipok");
+ expect(await getSlipProvider()).toBe("slip2go");
+ });
+});
+
+describe("selected provider health", () => {
+ test("checks origins without uploading slips and keeps caches separate", async () => {
+ fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(null, { status: 200 }),
+ );
+ expect((await (await getHealth()).json()).slip).toBe(true);
+ expect(healthCache.get("health:slip:slipok")).toBe("ok");
+ await setSlipCheckProvider("slip2go");
+ fetchSpy.mockResolvedValue(new Response(null, { status: 503 }));
+ expect((await (await getHealth()).json()).slip).toBe(false);
+ expect(healthCache.has("health:slip:slip2go")).toBe(false);
+ const providerCalls = fetchSpy.mock.calls.filter(([url]) =>
+ String(url).includes("slip"),
+ );
+ expect(providerCalls.map(([url]) => String(url))).toEqual([
+ "https://slipok.example",
+ "https://slip2go.example",
+ ]);
+ for (const [, init] of providerCalls) {
+ expect(init?.method).toBeUndefined();
+ expect(init?.body).toBeUndefined();
+ expect(init?.headers).toBeUndefined();
+ }
+ });
+
+ test("an API origin returning 404 is reachable", async () => {
+ storedProvider = "slip2go";
+ fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(null, { status: 404 }),
+ );
+ expect((await (await getHealth()).json()).slip).toBe(true);
+ expect(healthCache.get("health:slip:slip2go")).toBe("ok");
+ });
+});
diff --git a/lib/payment.ts b/lib/payment.ts
index 2ee7e05..9a6ec55 100644
--- a/lib/payment.ts
+++ b/lib/payment.ts
@@ -1,31 +1,24 @@
-import type { SlipokResponse } from "@/app/(ui)/rubgram/api";
+import { eq } from "drizzle-orm";
+import { primaryDb } from "@/lib/db";
+import { settings } from "@/lib/db/schema";
+import { isSlipProvider, verifySlip } from "@/lib/slip-checker";
-const { SLIPOK_API_URL, SLIPOK_API_KEY } = process.env as Record<
- string,
- string
->;
+export async function getSlipProvider() {
+ const [config] = await primaryDb
+ .select({ provider: settings.slipCheckProvider })
+ .from(settings)
+ .where(eq(settings.id, true));
+ const provider = config?.provider ?? "slipok";
+ if (!isSlipProvider(provider))
+ throw new Error("Invalid slip provider setting.");
+ return provider;
+}
export async function checkSlip(
buffer: Buffer,
type: string,
amount: number,
-): Promise {
- const response = await fetch(SLIPOK_API_URL, {
- method: "POST",
- headers: {
- "x-authorization": SLIPOK_API_KEY,
- },
- body: (() => {
- const formData = new FormData();
- formData.append("files", new Blob([buffer], { type }), "image");
- formData.append("amount", amount.toString());
- // formData.append("log", "true"); // no log cause im logging myself
- return formData;
- })(),
- });
-
- const data = await response.json();
-
- // if (!response.ok) throw data;
- return data;
+) {
+ const provider = await getSlipProvider();
+ return verifySlip(provider, buffer, type, amount);
}
diff --git a/lib/slip-checker.test.ts b/lib/slip-checker.test.ts
new file mode 100644
index 0000000..2cabe48
--- /dev/null
+++ b/lib/slip-checker.test.ts
@@ -0,0 +1,239 @@
+import { afterEach, describe, expect, spyOn, test } from "bun:test";
+import {
+ getSlipConfiguration,
+ isSlipProvider,
+ type SlipProvider,
+ verifySlip,
+} from "./slip-checker";
+
+const env = {
+ SLIPOK_API_URL: "https://slipok.example/verify",
+ SLIPOK_API_KEY: "slipok-test-key",
+ SLIP2GO_VERIFY_URL: "https://slip2go.example/api/verify-slip/qr-image/info",
+ SLIP2GO_API_SECRET: "slip2go-test-key",
+ PROMPTPAY_TYPE: "mobile",
+ PROMPTPAY_IDENTIFIER: "+66-81-234-5678",
+};
+const buffer = Buffer.from("test slip image");
+const transaction = { transRef: "BANK-REF-1", amount: 12.5 };
+let fetchSpy: ReturnType> | undefined;
+afterEach(() => fetchSpy?.mockRestore());
+
+function respond(raw: unknown, status = 200) {
+ fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+ Response.json(raw, { status }),
+ );
+}
+
+describe("slip provider configuration", () => {
+ test("allows only the two supported providers", () => {
+ expect(isSlipProvider("slipok")).toBe(true);
+ expect(isSlipProvider("slip2go")).toBe(true);
+ expect(isSlipProvider("other")).toBe(false);
+ });
+
+ test.each([
+ ["mobile", "+66-81-234-5678", "02001", "0812345678"],
+ ["mobile", "0066812345678", "02001", "0812345678"],
+ ["nationalId", "1-2345-67890-12-3", "02003", "1234567890123"],
+ ["ewallet", "012 345 678 901 234", "02004", "012345678901234"],
+ ])("normalizes %s %s", (type, value, accountType, accountNumber) => {
+ expect(
+ getSlipConfiguration("slip2go", {
+ ...env,
+ PROMPTPAY_TYPE: type,
+ PROMPTPAY_IDENTIFIER: value,
+ }).receiver,
+ ).toEqual({ accountType, accountNumber });
+ });
+
+ test.each([
+ { SLIP2GO_API_SECRET: undefined },
+ { SLIP2GO_VERIFY_URL: undefined },
+ { SLIP2GO_VERIFY_URL: "invalid" },
+ { SLIP2GO_VERIFY_URL: "file:///tmp/slip" },
+ { PROMPTPAY_TYPE: "bankAccount" },
+ { PROMPTPAY_IDENTIFIER: undefined },
+ { PROMPTPAY_IDENTIFIER: "0812abc" },
+ ])("rejects invalid Slip2Go configuration %p", (override) => {
+ expect(() =>
+ getSlipConfiguration("slip2go", { ...env, ...override }),
+ ).toThrow();
+ });
+
+ test("SlipOK does not require PromptPay configuration", () => {
+ expect(
+ getSlipConfiguration("slipok", {
+ SLIPOK_API_URL: env.SLIPOK_API_URL,
+ SLIPOK_API_KEY: env.SLIPOK_API_KEY,
+ }).receiver,
+ ).toBeUndefined();
+ });
+});
+
+describe("slip verification requests", () => {
+ test("preserves SlipOK upload format and retains the full response", async () => {
+ const raw = {
+ success: true,
+ data: { ...transaction, sender: { name: "Sender" } },
+ };
+ respond(raw);
+ expect(await verifySlip("slipok", buffer, "image/png", 12.5, env)).toEqual({
+ success: true,
+ provider: "slipok",
+ data: transaction,
+ raw,
+ });
+ const [url, init] = fetchSpy!.mock.calls[0];
+ expect(url).toBe(env.SLIPOK_API_URL);
+ expect(init!.headers).toEqual({ "x-authorization": env.SLIPOK_API_KEY });
+ expect(init!.method).toBe("POST");
+ const form = init!.body as FormData;
+ expect(Array.from(form.keys())).toEqual(["files", "amount"]);
+ expect(form.get("amount")).toBe("12.5");
+ const file = form.get("files") as File;
+ expect(file.type).toBe("image/png");
+ expect(await file.text()).toBe("test slip image");
+ expect(init!.signal).toBeInstanceOf(AbortSignal);
+ });
+
+ test.each([
+ "200000",
+ "200200",
+ ])("Slip2Go success %s checks receiver and amount", async (code) => {
+ const raw = { code, message: "Slip found.", data: transaction };
+ respond(raw);
+ expect(
+ await verifySlip("slip2go", buffer, "image/jpeg", 12.5, env),
+ ).toEqual({ success: true, provider: "slip2go", data: transaction, raw });
+ const [url, init] = fetchSpy!.mock.calls[0];
+ expect(url).toBe(env.SLIP2GO_VERIFY_URL);
+ expect(init!.headers).toEqual({ Authorization: env.SLIP2GO_API_SECRET });
+ const form = init!.body as FormData;
+ expect(Array.from(form.keys())).toEqual(["file", "payload"]);
+ expect((form.get("file") as File).type).toBe("image/jpeg");
+ expect(JSON.parse(form.get("payload") as string)).toEqual({
+ checkAmount: { type: "eq", amount: "12.5" },
+ checkReceiver: [{ accountType: "02001", accountNumber: "0812345678" }],
+ });
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("failed verification", () => {
+ test.each([
+ "200401",
+ "200402",
+ "200403",
+ "200404",
+ "200500",
+ "200501",
+ "200502",
+ "200202",
+ "200001",
+ "unexpected",
+ ])("rejects Slip2Go code %s even with HTTP 200 and transaction data", async (code) => {
+ const raw = { code, message: "Rejected", data: transaction };
+ respond(raw);
+ expect(
+ await verifySlip("slip2go", buffer, "image/png", 12.5, env),
+ ).toMatchObject({ success: false, code, message: "Rejected", raw });
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test("preserves SlipOK failure code and message", async () => {
+ respond({ success: false, code: 1010, message: "Invalid slip" });
+ expect(
+ await verifySlip("slipok", buffer, "image/png", 12.5, env),
+ ).toMatchObject({ success: false, code: 1010, message: "Invalid slip" });
+ });
+
+ for (const provider of ["slipok", "slip2go"] satisfies SlipProvider[]) {
+ const success =
+ provider === "slipok" ? { success: true } : { code: "200200" };
+ test.each([
+ {},
+ { transRef: "", amount: 12.5 },
+ { transRef: "REF" },
+ { transRef: "REF", amount: "12.5" },
+ { transRef: "REF", amount: 0 },
+ ])(`${provider} rejects incomplete transaction %p`, async (data) => {
+ respond({ ...success, data });
+ expect(
+ await verifySlip(provider, buffer, "image/png", 12.5, env),
+ ).toMatchObject({ success: false, code: "INVALID_RESPONSE" });
+ });
+
+ test(`${provider} rejects amount mismatches`, async () => {
+ respond({ ...success, data: { ...transaction, amount: 10 } });
+ expect(
+ await verifySlip(provider, buffer, "image/png", 12.5, env),
+ ).toMatchObject({ success: false, code: "AMOUNT_MISMATCH" });
+ });
+
+ test(`${provider} rejects HTTP errors despite a success body`, async () => {
+ respond({ ...success, data: transaction }, 500);
+ expect(
+ (await verifySlip(provider, buffer, "image/png", 12.5, env)).success,
+ ).toBe(false);
+ });
+ }
+
+ test.each([
+ [null],
+ [[]],
+ ["invalid"],
+ ])("rejects malformed envelopes %p", async (raw) => {
+ respond(raw);
+ expect(
+ await verifySlip("slip2go", buffer, "image/png", 12.5, env),
+ ).toMatchObject({ success: false, code: "INVALID_RESPONSE" });
+ });
+
+ test("rejects non-JSON responses", async () => {
+ fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("error"),
+ );
+ expect(
+ await verifySlip("slip2go", buffer, "image/png", 12.5, env),
+ ).toMatchObject({ success: false, code: "INVALID_RESPONSE" });
+ });
+
+ test.each([
+ 401, 429, 503,
+ ])("preserves provider failure on HTTP %s", async (status) => {
+ respond({ code: "401005", message: "Insufficient Token" }, status);
+ expect(
+ await verifySlip("slip2go", buffer, "image/png", 12.5, env),
+ ).toMatchObject({
+ success: false,
+ code: "401005",
+ message: "Insufficient Token",
+ });
+ });
+
+ test.each([
+ "TimeoutError",
+ "AbortError",
+ "TypeError",
+ ])("handles %s without retry", async (name) => {
+ fetchSpy = spyOn(globalThis, "fetch").mockRejectedValue(
+ new DOMException("Failed", name),
+ );
+ expect(
+ await verifySlip("slip2go", buffer, "image/png", 12.5, env),
+ ).toMatchObject({
+ success: false,
+ code: name === "TypeError" ? "NETWORK_ERROR" : "TIMEOUT",
+ });
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test("missing configuration never contacts a provider", async () => {
+ respond({});
+ expect(
+ await verifySlip("slip2go", buffer, "image/png", 12.5, {}),
+ ).toMatchObject({ success: false, code: "CONFIGURATION_ERROR" });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/slip-checker.ts b/lib/slip-checker.ts
new file mode 100644
index 0000000..eb9fddd
--- /dev/null
+++ b/lib/slip-checker.ts
@@ -0,0 +1,153 @@
+import { z } from "zod";
+import { normalizePromptPayIdentifier } from "@/lib/promptpay";
+
+export type SlipProvider = "slipok" | "slip2go";
+export type SlipCheckResult = { provider: SlipProvider; raw?: unknown } & (
+ | { success: true; data: { transRef: string; amount: number } }
+ | { success: false; code: string | number; message: string }
+);
+
+type SlipEnvironment = Record;
+
+export function isSlipProvider(value: unknown): value is SlipProvider {
+ return value === "slipok" || value === "slip2go";
+}
+
+export function getSlipConfiguration(
+ provider: SlipProvider,
+ env: SlipEnvironment = process.env,
+) {
+ const urlVariable =
+ provider === "slipok" ? "SLIPOK_API_URL" : "SLIP2GO_VERIFY_URL";
+ const keyVariable =
+ provider === "slipok" ? "SLIPOK_API_KEY" : "SLIP2GO_API_SECRET";
+ const url = env[urlVariable];
+ const key = env[keyVariable];
+ if (!url || !key)
+ throw new Error(`Missing ${urlVariable} or ${keyVariable}.`);
+ const parsedUrl = new URL(url);
+ if (!["http:", "https:"].includes(parsedUrl.protocol))
+ throw new Error(`Invalid ${urlVariable}.`);
+
+ let receiver: { accountType: string; accountNumber: string } | undefined;
+ if (provider === "slip2go") {
+ const type = env.PROMPTPAY_TYPE;
+ const value = env.PROMPTPAY_IDENTIFIER;
+ if (
+ !value ||
+ (type !== "mobile" && type !== "nationalId" && type !== "ewallet")
+ )
+ throw new Error(
+ "Slip2Go requires valid PROMPTPAY_TYPE and PROMPTPAY_IDENTIFIER.",
+ );
+ const identifier = normalizePromptPayIdentifier({ type, value });
+ receiver = {
+ accountType: { mobile: "02001", nationalId: "02003", ewallet: "02004" }[
+ type
+ ],
+ accountNumber:
+ type === "mobile" ? `0${identifier.value.slice(4)}` : identifier.value,
+ };
+ }
+ return { url, key, receiver };
+}
+
+const transactionSchema = z.object({
+ transRef: z.string().trim().min(1),
+ amount: z.number().finite().positive(),
+});
+const envelopeSchema = z.object({
+ success: z.boolean().optional(),
+ code: z.union([z.string(), z.number()]).optional(),
+ message: z.string().optional(),
+ data: z.unknown().optional(),
+});
+
+export async function verifySlip(
+ provider: SlipProvider,
+ buffer: Buffer,
+ type: string,
+ amount: number,
+ env: SlipEnvironment = process.env,
+): Promise {
+ const failure = (
+ code: string | number,
+ message: string,
+ raw?: unknown,
+ ): SlipCheckResult => ({ success: false, provider, code, message, raw });
+ let config: ReturnType;
+ try {
+ config = getSlipConfiguration(provider, env);
+ } catch {
+ return failure("CONFIGURATION_ERROR", "ยังไม่ได้ตั้งค่าบริการตรวจสอบสลิปให้ครบถ้วน");
+ }
+ if (!Number.isFinite(amount) || amount <= 0)
+ return failure("INVALID_AMOUNT", "ยอดเงินไม่ถูกต้อง");
+
+ const formData = new FormData();
+ const headers: Record = {};
+ if (provider === "slipok") {
+ headers["x-authorization"] = config.key;
+ formData.append("files", new Blob([buffer], { type }), "image");
+ formData.append("amount", amount.toString());
+ } else {
+ headers.Authorization = config.key;
+ formData.append("file", new Blob([buffer], { type }), "image");
+ formData.append(
+ "payload",
+ JSON.stringify({
+ checkAmount: { type: "eq", amount: amount.toString() },
+ checkReceiver: [config.receiver],
+ }),
+ );
+ }
+
+ let response: Response;
+ let raw: unknown;
+ try {
+ response = await fetch(config.url, {
+ method: "POST",
+ headers,
+ body: formData,
+ signal: AbortSignal.timeout(30_000),
+ });
+ raw = await response.json();
+ } catch (error) {
+ if (error instanceof SyntaxError)
+ return failure("INVALID_RESPONSE", "บริการตรวจสอบสลิปส่งข้อมูลไม่ถูกต้อง");
+ const timeout =
+ error instanceof Error &&
+ (error.name === "TimeoutError" || error.name === "AbortError");
+ return failure(
+ timeout ? "TIMEOUT" : "NETWORK_ERROR",
+ "ไม่สามารถติดต่อบริการตรวจสอบสลิปได้ กรุณาลองใหม่",
+ );
+ }
+
+ const parsed = envelopeSchema.safeParse(raw);
+ if (!parsed.success)
+ return failure("INVALID_RESPONSE", "บริการตรวจสอบสลิปส่งข้อมูลไม่ถูกต้อง", raw);
+ const envelope = parsed.data;
+ const success =
+ provider === "slipok"
+ ? envelope.success === true
+ : envelope.code === "200000" || envelope.code === "200200";
+ if (!response.ok || !success)
+ return failure(
+ envelope.code ??
+ (response.ok ? "VERIFICATION_FAILED" : `HTTP_${response.status}`),
+ envelope.message ?? "ตรวจสอบสลิปไม่สำเร็จ",
+ raw,
+ );
+
+ const transaction = transactionSchema.safeParse(envelope.data);
+ if (!transaction.success)
+ return failure(
+ "INVALID_RESPONSE",
+ "บริการตรวจสอบสลิปส่งข้อมูลธุรกรรมไม่ครบถ้วน",
+ raw,
+ );
+ if (transaction.data.amount !== amount)
+ return failure("AMOUNT_MISMATCH", "ยอดเงินในสลิปไม่ตรงกับยอดที่ต้องชำระ", raw);
+ return { success: true, provider, data: transaction.data, raw };
+}