Compare commits
+10
-2
@@ -23,9 +23,17 @@ DISCORD_REDIRECT_URI=https://YOUR_BASE_URL/rubgram/callback
|
|||||||
DISCORD_WEBHOOK_URL=YOUR_DISCORD_WEBHOOK_URL # For Rubgram calling
|
DISCORD_WEBHOOK_URL=YOUR_DISCORD_WEBHOOK_URL # For Rubgram calling
|
||||||
WEBHOOK_RUBGRAM_SUBMIT=YOUR_WEBHOOK_RUBGRAM_SUBMIT # For Rubgram submit
|
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_URL=YOUR_SLIPOK_API_URL
|
||||||
SLIPOK_API_KEY=YOUR_SLIPOK_API_KEY
|
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
|
# Donate-related
|
||||||
# Gemini TTS API
|
# Gemini TTS API
|
||||||
@@ -40,4 +48,4 @@ DONATE_WIDGET_KEY=RANDOM_GENERATED
|
|||||||
# Environment
|
# Environment
|
||||||
ENVIRONMENT=production
|
ENVIRONMENT=production
|
||||||
|
|
||||||
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="openssl rand -base64 32"
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="openssl rand -base64 32"
|
||||||
|
|||||||
@@ -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:
|
Payment-processing and stream-overlay system with two payment methods:
|
||||||
|
|
||||||
- **TrueMoney (TMN):** Voucher gift link redemption via Sastify API
|
- **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:
|
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
|
- **Service selector:** Multiple configurable service types with real-time price estimation; "all services" discount
|
||||||
- **Free queue counter:** Admin-configurable free slots (price = 0)
|
- **Free queue counter:** Admin-configurable free slots (price = 0)
|
||||||
- **20-minute payment window** with PromptPay QR code and slip upload
|
- **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
|
- **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
|
- **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
|
- **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
|
## Payment Processing
|
||||||
|
|
||||||
- **Rubgram:** PromptPay only — SlipOK API verifies slip images, deduplicated by `transRef`. 20-minute payment window with QR code.
|
- **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 (SlipOK). Transactions stored in dedicated `endgame.slips` table with full SlipOK response.
|
- **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
|
## 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 |
|
| Enka Network | Character showcase auto-fetch | enka.network |
|
||||||
| Astral/Enka Embed | Character card rendering | git.dgnr.us/astral/api |
|
| Astral/Enka Embed | Character card rendering | git.dgnr.us/astral/api |
|
||||||
| SlipOK | PromptPay slip verification | configurable |
|
| SlipOK | PromptPay slip verification | configurable |
|
||||||
|
| Slip2Go | PromptPay slip verification | configurable |
|
||||||
| Sastify | TrueMoney voucher redemption | api.sastify.xyz |
|
| Sastify | TrueMoney voucher redemption | api.sastify.xyz |
|
||||||
| Google Gemini | TTS for donation announcements | gemini-2.5-flash-preview-tts |
|
| Google Gemini | TTS for donation announcements | gemini-2.5-flash-preview-tts |
|
||||||
| Discord API | OAuth2, guild member list, webhook pings | discord.com |
|
| 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` |
|
| Database | `DATABASE_URL`, `REDIS_URL` |
|
||||||
| Auth | `BETTER_AUTH_SECRET` |
|
| 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` |
|
| Donation payments | `TMN_DEST_PHONE_NUM`, `SASTIFY_API_PRIVKEY` |
|
||||||
| TTS | `GEMINI_TTS_API_KEY` |
|
| TTS | `GEMINI_TTS_API_KEY` |
|
||||||
| Widget | `DONATE_WIDGET_KEY` |
|
| Widget | `DONATE_WIDGET_KEY` |
|
||||||
|
|||||||
@@ -1,22 +1,52 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
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 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() {
|
export async function getSettongs() {
|
||||||
if (!(await adminCheck())) throw "Unauthorized";
|
if (!(await adminCheck())) throw "Unauthorized";
|
||||||
const [
|
const [
|
||||||
settangs = { enka: true, donatePromptpay: true, donateTruemoney: true },
|
settangs = {
|
||||||
] = await db.select().from(settings).limit(1);
|
enka: true,
|
||||||
|
donatePromptpay: true,
|
||||||
|
donateTruemoney: true,
|
||||||
|
slipCheckProvider: "slipok" as const,
|
||||||
|
},
|
||||||
|
] = await primaryDb.select().from(settings).limit(1);
|
||||||
return settangs;
|
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) {
|
export async function toggleEnka(state: boolean) {
|
||||||
if (!(await adminCheck())) throw "Unauthorized";
|
if (!(await adminCheck())) throw "Unauthorized";
|
||||||
await db
|
await db
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { getSettongs } from "./api";
|
|||||||
import { SettingsServicesSection } from "./services";
|
import { SettingsServicesSection } from "./services";
|
||||||
|
|
||||||
export default async function () {
|
export default async function () {
|
||||||
const { enka, donatePromptpay, donateTruemoney } = await getSettongs();
|
const { enka, donatePromptpay, donateTruemoney, slipCheckProvider } =
|
||||||
|
await getSettongs();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full w-full gap-8 p-2 pl-0">
|
<div className="grid h-full w-full gap-8 p-2 pl-0">
|
||||||
@@ -26,6 +27,7 @@ export default async function () {
|
|||||||
enka={enka}
|
enka={enka}
|
||||||
donPp={donatePromptpay}
|
donPp={donatePromptpay}
|
||||||
donTmn={donateTruemoney}
|
donTmn={donateTruemoney}
|
||||||
|
slipProvider={slipCheckProvider}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="account">
|
<TabsContent value="account">
|
||||||
|
|||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
import { Bomb, FolderSync } from "lucide-react";
|
import { Bomb, FolderSync } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ActionButton } from "@/components/action-button";
|
import { ActionButton } from "@/components/action-button";
|
||||||
import { CdnChooser } from "@/components/chooser";
|
import { CdnChooser } from "@/components/chooser";
|
||||||
|
import { Field, FieldDescription, FieldTitle } from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||||
import { shared } from "@/lib/comms";
|
import { shared } from "@/lib/comms";
|
||||||
|
import type { SlipProvider } from "@/lib/slip-checker";
|
||||||
import {
|
import {
|
||||||
forceRefresh,
|
forceRefresh,
|
||||||
|
setSlipCheckProvider,
|
||||||
syncAmber,
|
syncAmber,
|
||||||
toggleDonatePaymentMethod,
|
toggleDonatePaymentMethod,
|
||||||
toggleEnka,
|
toggleEnka,
|
||||||
@@ -20,10 +25,12 @@ export function SettingsServicesSection({
|
|||||||
enka: enkaInitial,
|
enka: enkaInitial,
|
||||||
donPp: initialDonPp,
|
donPp: initialDonPp,
|
||||||
donTmn: initialDonTmn,
|
donTmn: initialDonTmn,
|
||||||
|
slipProvider: initialSlipProvider,
|
||||||
}: {
|
}: {
|
||||||
enka: boolean;
|
enka: boolean;
|
||||||
donPp: boolean;
|
donPp: boolean;
|
||||||
donTmn: boolean;
|
donTmn: boolean;
|
||||||
|
slipProvider: SlipProvider;
|
||||||
}) {
|
}) {
|
||||||
const [syncResult, setSyncResult] = useState("");
|
const [syncResult, setSyncResult] = useState("");
|
||||||
const [enka, setEnka] = useState<boolean>(enkaInitial);
|
const [enka, setEnka] = useState<boolean>(enkaInitial);
|
||||||
@@ -31,6 +38,31 @@ export function SettingsServicesSection({
|
|||||||
const [donTmn, setDonTmn] = useState<boolean>(initialDonTmn);
|
const [donTmn, setDonTmn] = useState<boolean>(initialDonTmn);
|
||||||
const [pathToReload, setPathToReload] = useState("");
|
const [pathToReload, setPathToReload] = useState("");
|
||||||
const [debug] = shared.state("debug");
|
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() {
|
async function sync() {
|
||||||
return syncAmber().then(setSyncResult);
|
return syncAmber().then(setSyncResult);
|
||||||
@@ -73,6 +105,25 @@ export function SettingsServicesSection({
|
|||||||
Reload
|
Reload
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</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>
|
<span>Donate Payment Method</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import DonateLogo from "#/logos/donate.webp";
|
|||||||
import GuideLogo from "#/logos/guide.webp";
|
import GuideLogo from "#/logos/guide.webp";
|
||||||
import RubgramLogo from "#/logos/rubgram.webp";
|
import RubgramLogo from "#/logos/rubgram.webp";
|
||||||
import TierlistLogo from "#/logos/tierlist.webp";
|
import TierlistLogo from "#/logos/tierlist.webp";
|
||||||
|
import CommissionLogo from "#/logos/commission.webp";
|
||||||
import Image from "@/components/image";
|
import Image from "@/components/image";
|
||||||
import { SimpleTooltip } from "@/components/tooltip";
|
import { SimpleTooltip } from "@/components/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -66,6 +67,18 @@ export default function Home() {
|
|||||||
href="/donate"
|
href="/donate"
|
||||||
logo={DonateLogo}
|
logo={DonateLogo}
|
||||||
/>
|
/>
|
||||||
|
{/* <HomeLink
|
||||||
|
name="ซ่อมไอดีชาวบ้าน"
|
||||||
|
desc="ซ่อมไอดีชาวบ้าน"
|
||||||
|
href="/repair"
|
||||||
|
logo={DonateLogo}
|
||||||
|
/> */}
|
||||||
|
<HomeLink
|
||||||
|
name="คอมมิชชั่น"
|
||||||
|
desc="คอมมิชชั่นจ้างแคลทีมดาเมท เปรียบเทียบดาเมท กลุ่มดาว"
|
||||||
|
href="https://guide.sudloh.com/commission"
|
||||||
|
logo={CommissionLogo}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<footer className="flex h-full flex-col justify-end pb-3 text-muted-foreground md:justify-center">
|
<footer className="flex h-full flex-col justify-end pb-3 text-muted-foreground md:justify-center">
|
||||||
<div className="flex flex-col justify-between gap-2 text-center sm:flex-row sm:text-left">
|
<div className="flex flex-col justify-between gap-2 text-center sm:flex-row sm:text-left">
|
||||||
|
|||||||
+33
-24
@@ -3,39 +3,53 @@ import { env } from "bun";
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { redis as redisShared } from "@/lib/db/redis";
|
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 { DISCORD_BOT_TOKEN, GEMINI_TTS_API_KEY } = env as Record<string, string>;
|
||||||
|
|
||||||
const redis = redisShared!;
|
const redis = redisShared!;
|
||||||
|
|
||||||
export const revalidate = 60;
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
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
|
const red = await redis
|
||||||
.ping()
|
.ping()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false);
|
.catch(() => false);
|
||||||
const res = Object.fromEntries(
|
const res = Object.fromEntries(
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.entries(checks).map(
|
Object.entries(healthChecks).map(async ([k, v]) => {
|
||||||
async ([k, v]) =>
|
const cacheKey = `health:${k === "slip" ? `slip:${slipProvider}` : k}`;
|
||||||
[
|
return [
|
||||||
k,
|
k,
|
||||||
red
|
red
|
||||||
? (await redis.exists(`health:${k}`))
|
? (await redis.exists(cacheKey))
|
||||||
? (await redis.get(`health:${k}`)) === "ok"
|
? (await redis.get(cacheKey)) === "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)
|
|
||||||
: await v()
|
: await v()
|
||||||
.then((r) => (r instanceof Response ? r.ok : !!r))
|
.then(async (r) => {
|
||||||
.catch(() => false),
|
const v = r instanceof Response ? r.ok : !!r;
|
||||||
] as const,
|
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(
|
return Response.json(
|
||||||
@@ -73,11 +87,6 @@ const checks = {
|
|||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async slip() {
|
|
||||||
return fetch("https://api.slipok.com/", {
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async tts() {
|
async tts() {
|
||||||
const client = new GoogleGenAI({
|
const client = new GoogleGenAI({
|
||||||
apiKey: GEMINI_TTS_API_KEY.split(",").pop(),
|
apiKey: GEMINI_TTS_API_KEY.split(",").pop(),
|
||||||
|
|||||||
+5
-3
@@ -1,10 +1,12 @@
|
|||||||
import { drizzle } from "drizzle-orm/bun-sql";
|
import { drizzle } from "drizzle-orm/bun-sql";
|
||||||
import * as schema from "./schema";
|
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
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
|
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.REPLICA_DATABASE_URL!, { schema }),
|
drizzle(process.env.REPLICA_DATABASE_URL!, { schema }),
|
||||||
])
|
])
|
||||||
: drizzle(process.env.DATABASE_URL!, { schema });
|
: primaryDb;
|
||||||
|
|||||||
+5
-1
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { uuidv7 } from "uuidv7";
|
import { uuidv7 } from "uuidv7";
|
||||||
import type { SlipokResponse } from "@/app/(ui)/rubgram/api";
|
import type { SlipokResponse } from "@/app/(ui)/rubgram/api";
|
||||||
|
import type { SlipCheckResult } from "@/lib/slip-checker";
|
||||||
import { bytea } from "./custom";
|
import { bytea } from "./custom";
|
||||||
|
|
||||||
export const element = pgEnum("character_element", [
|
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
|
enka: boolean().notNull().default(true), // Turn this off when Enka is on maintenance
|
||||||
donatePromptpay: boolean().notNull().default(true),
|
donatePromptpay: boolean().notNull().default(true),
|
||||||
donateTruemoney: boolean().notNull().default(true),
|
donateTruemoney: boolean().notNull().default(true),
|
||||||
|
slipCheckProvider: text({ enum: ["slipok", "slip2go"] })
|
||||||
|
.notNull()
|
||||||
|
.default("slipok"),
|
||||||
donateGoal: numeric({ mode: "number" }),
|
donateGoal: numeric({ mode: "number" }),
|
||||||
donateGoalStarting: timestamp(),
|
donateGoalStarting: timestamp(),
|
||||||
});
|
});
|
||||||
@@ -194,7 +198,7 @@ export const endgameSlips = endgame.table("slips", {
|
|||||||
slip: bytea().notNull(),
|
slip: bytea().notNull(),
|
||||||
ref: text().unique().notNull(),
|
ref: text().unique().notNull(),
|
||||||
amount: numeric().notNull(),
|
amount: numeric().notNull(),
|
||||||
data: jsonb().$type<SlipokResponse>().notNull(),
|
data: jsonb().$type<SlipokResponse | SlipCheckResult>().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const endgameSettings = endgame.table("settings", {
|
export const endgameSettings = endgame.table("settings", {
|
||||||
|
|||||||
@@ -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
@@ -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<
|
export async function getSlipProvider() {
|
||||||
string,
|
const [config] = await primaryDb
|
||||||
string
|
.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(
|
export async function checkSlip(
|
||||||
buffer: Buffer<ArrayBuffer>,
|
buffer: Buffer<ArrayBuffer>,
|
||||||
type: string,
|
type: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
): Promise<SlipokResponse> {
|
) {
|
||||||
const response = await fetch(SLIPOK_API_URL, {
|
const provider = await getSlipProvider();
|
||||||
method: "POST",
|
return verifySlip(provider, buffer, type, amount);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
Reference in New Issue
Block a user