feat: add switchable SlipOK and Slip2Go verification
Build and Push Image / build-backend (push) Successful in 2m27s
Build and Push Image / build-frontend (push) Successful in 4m4s
Build and Push Image / push (push) Successful in 1m1s

This commit is contained in:
2026-10-15 02:17:35 +07:00 Unverified
parent c3022f4d37
commit beb0cfaf3d
12 changed files with 792 additions and 68 deletions
+10 -2
View File
@@ -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"
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="openssl rand -base64 32"
+17 -5
View File
@@ -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` |
+38 -8
View File
@@ -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
+3 -1
View File
@@ -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 (
<div className="grid h-full w-full gap-8 p-2 pl-0">
@@ -26,6 +27,7 @@ export default async function () {
enka={enka}
donPp={donatePromptpay}
donTmn={donateTruemoney}
slipProvider={slipCheckProvider}
/>
</TabsContent>
<TabsContent value="account">
+51
View File
@@ -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<boolean>(enkaInitial);
@@ -31,6 +38,31 @@ export function SettingsServicesSection({
const [donTmn, setDonTmn] = useState<boolean>(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
</ActionButton>
</div>
<Field data-disabled={savingSlipProvider}>
<FieldTitle id="slip-provider-label">บริการตรวจสอบสลิป</FieldTitle>
<ToggleGroup
type="single"
variant="outline"
value={slipProvider}
onValueChange={changeSlipProvider}
disabled={savingSlipProvider}
aria-labelledby="slip-provider-label"
aria-busy={savingSlipProvider}
className="w-fit"
>
<ToggleGroupItem value="slipok">SlipOK</ToggleGroupItem>
<ToggleGroupItem value="slip2go">Slip2Go</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
ใช้สำหรับรับกรรมและโดเนท มีผลกับการตรวจสอบสลิปครั้งถัดไป
</FieldDescription>
</Field>
<span>Donate Payment Method</span>
<div className="flex items-center gap-2">
<Switch
+33 -24
View File
@@ -3,39 +3,53 @@ import { env } from "bun";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { redis as redisShared } from "@/lib/db/redis";
import { getSlipProvider } from "@/lib/payment";
import { getSlipConfiguration } from "@/lib/slip-checker";
const { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record<string, string>;
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(),
+5 -3
View File
@@ -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;
+5 -1
View File
@@ -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<SlipokResponse>().notNull(),
data: jsonb().$type<SlipokResponse | SlipCheckResult>().notNull(),
});
export const endgameSettings = endgame.table("settings", {
+221
View File
@@ -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<string, string>();
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<typeof spyOn<typeof globalThis, "fetch">> | 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<Response>((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");
});
});
+17 -24
View File
@@ -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<ArrayBuffer>,
type: string,
amount: number,
): Promise<SlipokResponse> {
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);
}
+239
View File
@@ -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<typeof spyOn<typeof globalThis, "fetch">> | 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("<html>error</html>"),
);
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();
});
});
+153
View File
@@ -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<string, string | undefined>;
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<ArrayBuffer>,
type: string,
amount: number,
env: SlipEnvironment = process.env,
): Promise<SlipCheckResult> {
const failure = (
code: string | number,
message: string,
raw?: unknown,
): SlipCheckResult => ({ success: false, provider, code, message, raw });
let config: ReturnType<typeof getSlipConfiguration>;
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<string, string> = {};
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 };
}