From beb0cfaf3da2f82b24bb7a263602ae0ac886824e Mon Sep 17 00:00:00 2001 From: gunshiz Date: Thu, 15 Oct 2026 02:17:35 +0700 Subject: [PATCH] feat: add switchable SlipOK and Slip2Go verification --- .env.example | 12 +- README.md | 22 ++- app/(ui)/admin/settings/api.ts | 46 +++++- app/(ui)/admin/settings/page.tsx | 4 +- app/(ui)/admin/settings/services.tsx | 51 ++++++ app/api/health/route.ts | 57 ++++--- lib/db/index.ts | 8 +- lib/db/schema.ts | 6 +- lib/payment.test.ts | 221 +++++++++++++++++++++++++ lib/payment.ts | 41 ++--- lib/slip-checker.test.ts | 239 +++++++++++++++++++++++++++ lib/slip-checker.ts | 153 +++++++++++++++++ 12 files changed, 792 insertions(+), 68 deletions(-) create mode 100644 lib/payment.test.ts create mode 100644 lib/slip-checker.test.ts create mode 100644 lib/slip-checker.ts 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 }; +}