feat : big update
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import "server-only";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import { catalogArtifacts, catalogCharacters, catalogWeapons } from "@/db/schema";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { publicMediaUrl } from "@/lib/media/storage";
|
||||
import type { CommissionRequest } from "./request";
|
||||
|
||||
export async function validateCommissionCatalog(request: CommissionRequest): Promise<boolean> {
|
||||
const [characters, weapons, artifacts] = await Promise.all([
|
||||
getDb().select({ key: catalogCharacters.key, weaponType: catalogCharacters.weaponType,
|
||||
constellations: catalogCharacters.constellations }).from(catalogCharacters),
|
||||
getDb().select({ key: catalogWeapons.key, weaponType: catalogWeapons.weaponType }).from(catalogWeapons),
|
||||
getDb().select({ key: catalogArtifacts.key }).from(catalogArtifacts),
|
||||
]);
|
||||
const characterByKey = new Map(characters.map((item) => [item.key, item]));
|
||||
const weaponByKey = new Map(weapons.map((item) => [item.key, item]));
|
||||
const artifactKeys = new Set(artifacts.map((item) => item.key));
|
||||
const compatible = (characterKey: string, weaponKey: string) => {
|
||||
const character = characterByKey.get(characterKey);
|
||||
const weapon = weaponByKey.get(weaponKey);
|
||||
return Boolean(character?.weaponType && weapon?.weaponType === character.weaponType);
|
||||
};
|
||||
return request.teams.every(({ members }) => members.every((member) =>
|
||||
compatible(member.characterKey, member.weaponKey) && artifactKeys.has(member.artifactKey))) &&
|
||||
request.weapons.every(({ characterKey, weaponKeys }) =>
|
||||
weaponKeys.every((weaponKey) => compatible(characterKey, weaponKey))) &&
|
||||
request.constellations.every(({ characterKey, levels }) => {
|
||||
const character = characterByKey.get(characterKey);
|
||||
return Boolean(character && levels.every((level) => level === 0 ||
|
||||
character.constellations.some((item) => item.level === level)));
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCommissionLabels(request: CommissionRequest) {
|
||||
const characterKeys = [...new Set([
|
||||
...request.teams.flatMap(({ members }) => members.map(({ characterKey }) => characterKey)),
|
||||
...request.weapons.map(({ characterKey }) => characterKey),
|
||||
...request.constellations.map(({ characterKey }) => characterKey),
|
||||
])];
|
||||
const weaponKeys = [...new Set([
|
||||
...request.teams.flatMap(({ members }) => members.map(({ weaponKey }) => weaponKey)),
|
||||
...request.weapons.flatMap(({ weaponKeys }) => weaponKeys),
|
||||
])];
|
||||
const artifactKeys = [...new Set(request.teams.flatMap(({ members }) => members.map(({ artifactKey }) => artifactKey)))];
|
||||
const [characters, weapons, artifacts] = await Promise.all([
|
||||
characterKeys.length ? getDb().select({ key: catalogCharacters.key, name: catalogCharacters.name,
|
||||
imageKey: catalogCharacters.imageKey, element: catalogCharacters.element, rarity: catalogCharacters.rarity })
|
||||
.from(catalogCharacters).where(inArray(catalogCharacters.key, characterKeys)) : Promise.resolve([]),
|
||||
weaponKeys.length ? getDb().select({ key: catalogWeapons.key, name: catalogWeapons.name,
|
||||
imageKey: catalogWeapons.imageKey, rarity: catalogWeapons.rarity })
|
||||
.from(catalogWeapons).where(inArray(catalogWeapons.key, weaponKeys)) : Promise.resolve([]),
|
||||
artifactKeys.length ? getDb().select({ key: catalogArtifacts.key, name: catalogArtifacts.name,
|
||||
imageKey: catalogArtifacts.imageKey, rarity: catalogArtifacts.rarity })
|
||||
.from(catalogArtifacts).where(inArray(catalogArtifacts.key, artifactKeys)) : Promise.resolve([]),
|
||||
]);
|
||||
return {
|
||||
characters: characters.map(({ imageKey, ...item }) => ({ ...item, imageUrl: publicMediaUrl(imageKey) })),
|
||||
weapons: weapons.map(({ imageKey, ...item }) => ({ ...item, imageUrl: publicMediaUrl(imageKey) })),
|
||||
artifacts: artifacts.map(({ imageKey, ...item }) => ({ ...item, imageUrl: publicMediaUrl(imageKey) })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const { notifyPaidTicketDiscord } = await import("./discord");
|
||||
const ticketId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("paid commission Discord notification", () => {
|
||||
const fetchMock = vi.fn();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWebhookUrl = process.env.COMMISSION_DISCORD_WEBHOOK_URL;
|
||||
const originalAuthUrl = process.env.BETTER_AUTH_URL;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.COMMISSION_DISCORD_WEBHOOK_URL = "https://discord.example.test/webhook";
|
||||
process.env.BETTER_AUTH_URL = "https://guide.sudloh.com";
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
fetchMock.mockReset().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWebhookUrl === undefined) delete process.env.COMMISSION_DISCORD_WEBHOOK_URL;
|
||||
else process.env.COMMISSION_DISCORD_WEBHOOK_URL = originalWebhookUrl;
|
||||
if (originalAuthUrl === undefined) delete process.env.BETTER_AUTH_URL;
|
||||
else process.env.BETTER_AUTH_URL = originalAuthUrl;
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sends an embed linking to the admin ticket chat", async () => {
|
||||
await notifyPaidTicketDiscord(ticketId, 140);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe("https://discord.example.test/webhook");
|
||||
expect(options.method).toBe("POST");
|
||||
const payload = JSON.parse(options.body as string);
|
||||
expect(payload.allowed_mentions).toEqual({ parse: [] });
|
||||
expect(payload.embeds[0]).toMatchObject({
|
||||
url: `https://guide.sudloh.com/admin/commission/${ticketId}`,
|
||||
description: expect.stringContaining("฿140"),
|
||||
});
|
||||
});
|
||||
|
||||
it("skips the request when no webhook is configured", async () => {
|
||||
delete process.env.COMMISSION_DISCORD_WEBHOOK_URL;
|
||||
await notifyPaidTicketDiscord(ticketId, 140);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not turn a paid ticket into an error when Discord fails", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
fetchMock.mockRejectedValueOnce(new Error("network unavailable"));
|
||||
await expect(notifyPaidTicketDiscord(ticketId, 140)).resolves.toBeUndefined();
|
||||
expect(console.error).toHaveBeenCalledWith("Commission Discord notification failed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import "server-only";
|
||||
|
||||
import { siteUrl } from "@/lib/site-url";
|
||||
|
||||
export async function notifyPaidTicketDiscord(ticketId: string, amountBaht: number) {
|
||||
const webhookUrl = process.env.COMMISSION_DISCORD_WEBHOOK_URL;
|
||||
if (!webhookUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
allowed_mentions: { parse: [] },
|
||||
embeds: [{
|
||||
title: "มี Ticket คอมมิชชันใหม่ที่ชำระเงินแล้ว",
|
||||
url: new URL(`/admin/commission/${ticketId}`, process.env.BETTER_AUTH_URL ?? siteUrl).toString(),
|
||||
description: `Ticket #${ticketId.slice(0, 8)} · ฿${amountBaht}`,
|
||||
color: 0x57f287,
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok) console.error(`Commission Discord notification failed (${response.status})`);
|
||||
} catch {
|
||||
console.error("Commission Discord notification failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
import { promptPayConfig, verifyCommissionSlip } from "./payment";
|
||||
|
||||
const names = ["SLIP2GO_VERIFY_URL", "SLIP2GO_API_SECRET", "COMMISSION_PROMPTPAY_TYPE",
|
||||
"COMMISSION_PROMPTPAY_VALUE", "COMMISSION_RECEIVER_ACCOUNT_NUMBER"] as const;
|
||||
const original = names.map((name) => process.env[name]);
|
||||
const checkoutDate = new Date("2026-09-01T12:00:00.000Z");
|
||||
const validData = { code: "200200", data: { referenceId: "provider-ref", transRef: "bank-ref",
|
||||
amount: 100, dateTime: "2026-09-01T12:01:00.000Z" } };
|
||||
|
||||
afterEach(() => {
|
||||
names.forEach((name, index) => {
|
||||
if (original[index] === undefined) delete process.env[name];
|
||||
else process.env[name] = original[index];
|
||||
});
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("commission slip verification", () => {
|
||||
it("rejects a non-mobile PromptPay value before checkout", () => {
|
||||
process.env.COMMISSION_PROMPTPAY_TYPE = "mobile";
|
||||
process.env.COMMISSION_PROMPTPAY_VALUE = "123456789012345";
|
||||
process.env.COMMISSION_RECEIVER_ACCOUNT_NUMBER = "0812345678";
|
||||
expect(() => promptPayConfig()).toThrow("invalid-promptpay-value");
|
||||
process.env.COMMISSION_PROMPTPAY_VALUE = "0812345678";
|
||||
expect(promptPayConfig().identifier).toEqual({ type: "mobile", value: "0066812345678" });
|
||||
});
|
||||
async function check(response: unknown) {
|
||||
process.env.SLIP2GO_VERIFY_URL = "https://example.test/api/verify-slip/qr-image/info";
|
||||
process.env.SLIP2GO_API_SECRET = "secret";
|
||||
process.env.COMMISSION_PROMPTPAY_TYPE = "mobile";
|
||||
process.env.COMMISSION_PROMPTPAY_VALUE = "0812345678";
|
||||
process.env.COMMISSION_RECEIVER_ACCOUNT_NUMBER = "0812345678";
|
||||
const fetch = vi.fn(async (_url: URL, init: RequestInit) => {
|
||||
expect(init.headers).toEqual({ Authorization: "Bearer secret" });
|
||||
const payload = JSON.parse((init.body as FormData).get("payload") as string);
|
||||
expect(payload).toMatchObject({ checkDuplicate: true,
|
||||
checkReceiver: [{ accountType: "02001", accountNumber: "0812345678" }],
|
||||
checkAmount: { type: "eq", amount: "100.00" } });
|
||||
return Response.json(response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
return verifyCommissionSlip(new File(["image"], "slip.png", { type: "image/png" }), 100, checkoutDate);
|
||||
}
|
||||
it("accepts a matching verified transfer", async () => {
|
||||
await expect(check(validData)).resolves.toMatchObject({ referenceId: "provider-ref",
|
||||
transRef: "bank-ref", amountBaht: 100 });
|
||||
});
|
||||
it("rejects duplicate and amount mismatch results", async () => {
|
||||
await expect(check({ code: "200501" })).rejects.toMatchObject({ status: 422 });
|
||||
await expect(check({ code: "200401", data: { referenceId: "provider-ref" } }))
|
||||
.rejects.toMatchObject({ status: 422, message: "slip-rejected:200401" });
|
||||
await expect(check({ ...validData, code: "200000" })).rejects.toMatchObject({ status: 422 });
|
||||
await expect(check({ ...validData, data: { ...validData.data, amount: 80 } }))
|
||||
.rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
it("treats a rejected API secret as a service error, not an invalid slip", async () => {
|
||||
await expect(check({ code: "401001", message: "Authentication Token Mismatch",
|
||||
data: { referenceId: "provider-ref" } }))
|
||||
.rejects.toMatchObject({ status: 503, message: "slip2go-service:401001" });
|
||||
});
|
||||
it("reports which required field is missing from a successful provider response", async () => {
|
||||
await expect(check({ code: "200200", data: { referenceId: "provider-ref" } }))
|
||||
.rejects.toMatchObject({ status: 503, message: "invalid-slip-verification-response:transRef" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import "server-only";
|
||||
|
||||
import * as z from "zod";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
import { normalizePromptPayIdentifier } from "@/lib/commission/promptpay";
|
||||
|
||||
const slipResponse = z.object({ code: z.string() }).passthrough();
|
||||
const verifiedSlip = z.object({
|
||||
referenceId: z.string().min(1),
|
||||
transRef: z.string().min(1),
|
||||
amount: z.number(),
|
||||
dateTime: z.string().refine((value) => !Number.isNaN(Date.parse(value))),
|
||||
}).passthrough();
|
||||
|
||||
function required(name: string) {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new HttpError(503, `${name}-not-configured`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function promptPayConfig() {
|
||||
const type = required("COMMISSION_PROMPTPAY_TYPE");
|
||||
if (type !== "mobile" && type !== "nationalId" && type !== "ewallet")
|
||||
throw new HttpError(503, "invalid-promptpay-type");
|
||||
let identifier;
|
||||
try {
|
||||
identifier = normalizePromptPayIdentifier({ type, value: required("COMMISSION_PROMPTPAY_VALUE") });
|
||||
} catch {
|
||||
throw new HttpError(503, "invalid-promptpay-value");
|
||||
}
|
||||
return { identifier, receiverNumber: required("COMMISSION_RECEIVER_ACCOUNT_NUMBER") };
|
||||
}
|
||||
|
||||
export async function verifyCommissionSlip(file: File, amountBaht: number, checkoutCreatedAt: Date) {
|
||||
const url = new URL(required("SLIP2GO_VERIFY_URL"));
|
||||
if (url.protocol !== "https:") throw new HttpError(503, "invalid-slip2go-url");
|
||||
const { identifier, receiverNumber } = promptPayConfig();
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
form.set("payload", JSON.stringify({
|
||||
checkDuplicate: true,
|
||||
checkReceiver: [{
|
||||
accountType: { mobile: "02001", nationalId: "02003", ewallet: "02004" }[identifier.type],
|
||||
accountNumber: receiverNumber,
|
||||
}],
|
||||
checkAmount: { type: "eq", amount: amountBaht.toFixed(2) },
|
||||
}));
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${required("SLIP2GO_API_SECRET")}` },
|
||||
body: form, signal: AbortSignal.timeout(15000), cache: "no-store" });
|
||||
} catch {
|
||||
throw new HttpError(503, "slip-verification-unavailable");
|
||||
}
|
||||
const body = slipResponse.safeParse(await response.json().catch(() => null));
|
||||
if (!body.success) throw new HttpError(503, "invalid-slip-verification-response");
|
||||
const code = body.data.code;
|
||||
if (response.status >= 500 || response.status === 401 || code.startsWith("401") ||
|
||||
code === "400400" || code === "200502")
|
||||
throw new HttpError(503, `slip2go-service:${code}`);
|
||||
if (!response.ok || code !== "200200")
|
||||
throw new HttpError(422, `slip-rejected:${code}`);
|
||||
const parsedSlip = verifiedSlip.safeParse(body.data.data);
|
||||
if (!parsedSlip.success) {
|
||||
const field = parsedSlip.error.issues[0]?.path[0] ?? "data";
|
||||
throw new HttpError(503, `invalid-slip-verification-response:${String(field)}`);
|
||||
}
|
||||
const slip = parsedSlip.data;
|
||||
if (slip.amount !== amountBaht ||
|
||||
Date.parse(slip.dateTime) < checkoutCreatedAt.getTime() - 120000 ||
|
||||
Date.parse(slip.dateTime) > Date.now() + 120000)
|
||||
throw new HttpError(422, "slip-amount-or-date-mismatch");
|
||||
return { referenceId: slip.referenceId, transRef: slip.transRef, amountBaht,
|
||||
transferredAt: new Date(slip.dateTime), providerData: body.data as Record<string, unknown> };
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import * as z from "zod";
|
||||
|
||||
// #region Public types
|
||||
export type DataObject =
|
||||
| {
|
||||
type: "primitive";
|
||||
tag: number;
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
type: "template";
|
||||
tag: number;
|
||||
value: DataObject[];
|
||||
};
|
||||
|
||||
export type PaymentNetworkSpecific = {
|
||||
tag: number;
|
||||
value: DataObject[];
|
||||
};
|
||||
|
||||
export type EMVCoData = {
|
||||
payloadFormatIndicator: "01";
|
||||
pointOfInitiationMethod?: "11" | "12";
|
||||
paymentNetworkSpecific: PaymentNetworkSpecific[];
|
||||
merchantCategoryCode?: string;
|
||||
transactionCurrency?: string;
|
||||
transactionAmount?: string;
|
||||
countryCode: string;
|
||||
additionalData?: DataObject[];
|
||||
};
|
||||
|
||||
export type PromptPayIdentifier =
|
||||
| { type: "mobile"; value: string }
|
||||
| { type: "nationalId"; value: string }
|
||||
| { type: "ewallet"; value: string }
|
||||
| { type: "bankAccount"; value: string };
|
||||
|
||||
export type EncodablePromptPayIdentifier = Exclude<
|
||||
PromptPayIdentifier,
|
||||
{ type: "bankAccount" }
|
||||
>;
|
||||
|
||||
export type PromptPayData = {
|
||||
aid: "A000000677010111";
|
||||
identifier: PromptPayIdentifier;
|
||||
};
|
||||
|
||||
export type GeneratePromptPayOptions = {
|
||||
identifier: EncodablePromptPayIdentifier;
|
||||
amount?: number | string;
|
||||
merchantCategoryCode?: string;
|
||||
};
|
||||
|
||||
export type ParsedPromptPayPayload = {
|
||||
payload: string;
|
||||
dataObjects: DataObject[];
|
||||
emvco: EMVCoData;
|
||||
promptPay: PromptPayData;
|
||||
};
|
||||
|
||||
// #region TLV
|
||||
function serializeTLV(objects: DataObject[]): string {
|
||||
return [...objects]
|
||||
.sort((left, right) => left.tag - right.tag)
|
||||
.map((object, index) => {
|
||||
if (!Number.isInteger(object.tag) || object.tag < 0 || object.tag > 99)
|
||||
throw new Error(`Tag ${index} must be 00-99`);
|
||||
|
||||
const value =
|
||||
object.type === "primitive" ? object.value : serializeTLV(object.value);
|
||||
|
||||
if (value.length > 99)
|
||||
throw new Error(`Value ${index} exceeds TLV length limit`);
|
||||
|
||||
const tag = object.tag.toString().padStart(2, "0");
|
||||
const length = value.length.toString().padStart(2, "0");
|
||||
return `${tag}${length}${value}`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function parseTLV(payload: string): DataObject[] {
|
||||
const objects: DataObject[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < payload.length) {
|
||||
if (index + 4 > payload.length)
|
||||
throw new Error(
|
||||
`Malformed TLV payload: incomplete headers at index ${index}`,
|
||||
);
|
||||
|
||||
const tagString = payload.slice(index, index + 2);
|
||||
if (!/^\d{2}$/.test(tagString))
|
||||
throw new Error(
|
||||
`Malformed TLV payload: invalid tag "${tagString}" at index ${index}`,
|
||||
);
|
||||
const tag = Number.parseInt(tagString, 10);
|
||||
index += 2;
|
||||
|
||||
const lengthString = payload.slice(index, index + 2);
|
||||
if (!/^\d{2}$/.test(lengthString))
|
||||
throw new Error(
|
||||
`Malformed TLV payload: invalid length "${lengthString}" at index ${index}`,
|
||||
);
|
||||
const length = Number.parseInt(lengthString, 10);
|
||||
index += 2;
|
||||
|
||||
if (index + length > payload.length)
|
||||
throw new Error(
|
||||
`Malformed TLV payload: expected value length ${length} at index ${index}, but reached end of string`,
|
||||
);
|
||||
|
||||
const value = payload.slice(index, index + length);
|
||||
index += length;
|
||||
objects.push({ type: "primitive", tag, value });
|
||||
}
|
||||
|
||||
return objects;
|
||||
}
|
||||
|
||||
// #region Utilities
|
||||
function primitive(
|
||||
data: DataObject[] | undefined,
|
||||
tag: number,
|
||||
): string | undefined {
|
||||
return data?.find(
|
||||
(object): object is Extract<DataObject, { type: "primitive" }> =>
|
||||
object.tag === tag && object.type === "primitive",
|
||||
)?.value;
|
||||
}
|
||||
|
||||
function optionalProperty<T extends string>(key: string, value: T | undefined) {
|
||||
return value === undefined ? {} : { [key]: value };
|
||||
}
|
||||
|
||||
// #region EMVCo
|
||||
const TEMPLATE_TAGS = new Set([
|
||||
...Array.from({ length: 50 }, (_, index) => index + 2),
|
||||
62,
|
||||
64,
|
||||
...Array.from({ length: 20 }, (_, index) => index + 80),
|
||||
]);
|
||||
|
||||
function parseEMVCo(payload: string): DataObject[] {
|
||||
return parseTLV(payload).map((object) =>
|
||||
TEMPLATE_TAGS.has(object.tag) && object.type === "primitive"
|
||||
? {
|
||||
type: "template",
|
||||
tag: object.tag,
|
||||
value: parseTLV(object.value),
|
||||
}
|
||||
: object,
|
||||
);
|
||||
}
|
||||
|
||||
const emvCoSchema = z.object({
|
||||
payloadFormatIndicator: z.literal("01"),
|
||||
pointOfInitiationMethod: z.enum(["11", "12"]).optional(),
|
||||
paymentNetworkSpecific: z.array(
|
||||
z.object({
|
||||
tag: z.number().int().min(2).max(51),
|
||||
value: z.custom<DataObject[]>((value) => Array.isArray(value)),
|
||||
}),
|
||||
),
|
||||
merchantCategoryCode: z.string().length(4).optional(),
|
||||
transactionCurrency: z.string().length(3).optional(),
|
||||
transactionAmount: z.string().optional(),
|
||||
countryCode: z.string().length(2),
|
||||
additionalData: z.array(z.custom<DataObject>()).optional(),
|
||||
});
|
||||
|
||||
const MODELED_EMVCO_TAGS = new Set([0, 1, 52, 53, 54, 58]);
|
||||
|
||||
function decodeEMVCo(objects: DataObject[]): EMVCoData {
|
||||
const additionalData = objects.filter(
|
||||
(object) =>
|
||||
!MODELED_EMVCO_TAGS.has(object.tag) &&
|
||||
!(object.tag >= 2 && object.tag <= 51),
|
||||
);
|
||||
|
||||
return emvCoSchema.parse({
|
||||
payloadFormatIndicator: primitive(objects, 0),
|
||||
...optionalProperty("pointOfInitiationMethod", primitive(objects, 1)),
|
||||
paymentNetworkSpecific: objects
|
||||
.filter(
|
||||
(object): object is Extract<DataObject, { type: "template" }> =>
|
||||
object.type === "template" && object.tag >= 2 && object.tag <= 51,
|
||||
)
|
||||
.map(({ tag, value }) => ({ tag, value })),
|
||||
...optionalProperty("merchantCategoryCode", primitive(objects, 52)),
|
||||
...optionalProperty("transactionCurrency", primitive(objects, 53)),
|
||||
...optionalProperty("transactionAmount", primitive(objects, 54)),
|
||||
countryCode: primitive(objects, 58),
|
||||
...(additionalData.length === 0 ? {} : { additionalData }),
|
||||
});
|
||||
}
|
||||
|
||||
function encodeEMVCo(data: EMVCoData): DataObject[] {
|
||||
const parsed = emvCoSchema.parse(data);
|
||||
const objects: DataObject[] = [
|
||||
{ type: "primitive", tag: 0, value: parsed.payloadFormatIndicator },
|
||||
];
|
||||
|
||||
if (parsed.pointOfInitiationMethod !== undefined)
|
||||
objects.push({
|
||||
type: "primitive",
|
||||
tag: 1,
|
||||
value: parsed.pointOfInitiationMethod,
|
||||
});
|
||||
|
||||
objects.push(
|
||||
...parsed.paymentNetworkSpecific.map(
|
||||
({ tag, value }): DataObject => ({ type: "template", tag, value }),
|
||||
),
|
||||
);
|
||||
|
||||
for (const [tag, value] of [
|
||||
[52, parsed.merchantCategoryCode],
|
||||
[53, parsed.transactionCurrency],
|
||||
[54, parsed.transactionAmount],
|
||||
[58, parsed.countryCode],
|
||||
] as const) {
|
||||
if (value !== undefined) objects.push({ type: "primitive", tag, value });
|
||||
}
|
||||
|
||||
objects.push(...(parsed.additionalData ?? []));
|
||||
return objects;
|
||||
}
|
||||
|
||||
// #region CRC
|
||||
function crc16(data: Uint8Array): number {
|
||||
let crc = 0xffff;
|
||||
const polynomial = 0x1021;
|
||||
|
||||
for (const byte of data) {
|
||||
crc ^= byte << 8;
|
||||
for (let bit = 0; bit < 8; bit++)
|
||||
crc =
|
||||
(crc & 0x8000) !== 0
|
||||
? ((crc << 1) ^ polynomial) & 0xffff
|
||||
: (crc << 1) & 0xffff;
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
|
||||
function formatCRC(crc: number): string {
|
||||
return crc.toString(16).toUpperCase().padStart(4, "0");
|
||||
}
|
||||
|
||||
function verifyCRC(payload: string): boolean {
|
||||
if (payload.length < 8 || payload.slice(-8, -4) !== "6304") return false;
|
||||
|
||||
const expected = payload.slice(-4);
|
||||
const calculated = formatCRC(
|
||||
crc16(new TextEncoder().encode(payload.slice(0, -4))),
|
||||
);
|
||||
return calculated === expected;
|
||||
}
|
||||
|
||||
function appendCRC(payload: string): string {
|
||||
const body = `${payload}6304`;
|
||||
return `${body}${formatCRC(crc16(new TextEncoder().encode(body)))}`;
|
||||
}
|
||||
|
||||
// #region PromptPay
|
||||
const PROMPTPAY_AID = "A000000677010111" as const;
|
||||
|
||||
const promptPaySchema = z.object({
|
||||
aid: z.literal(PROMPTPAY_AID),
|
||||
identifier: z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("mobile"),
|
||||
value: z.string().regex(/^\d{13}$/),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("nationalId"),
|
||||
value: z.string().regex(/^\d{13}$/),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("ewallet"),
|
||||
value: z.string().regex(/^\d{15}$/),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("bankAccount"),
|
||||
value: z.string().regex(/^\d{1,43}$/),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const IDENTIFIER_TYPES = {
|
||||
1: "mobile",
|
||||
2: "nationalId",
|
||||
3: "ewallet",
|
||||
4: "bankAccount",
|
||||
} as const;
|
||||
|
||||
const IDENTIFIER_TAGS = {
|
||||
mobile: 1,
|
||||
nationalId: 2,
|
||||
ewallet: 3,
|
||||
} as const;
|
||||
|
||||
function decodePromptPay(merchantAccount: DataObject[]): PromptPayData {
|
||||
const identifiers = merchantAccount.filter(
|
||||
(object): object is Extract<DataObject, { type: "primitive" }> =>
|
||||
object.type === "primitive" && object.tag in IDENTIFIER_TYPES,
|
||||
);
|
||||
|
||||
if (identifiers.length !== 1)
|
||||
throw new Error("Expected exactly one PromptPay identifier.");
|
||||
|
||||
const identifier = identifiers[0];
|
||||
return promptPaySchema.parse({
|
||||
aid: primitive(merchantAccount, 0),
|
||||
identifier: {
|
||||
type: IDENTIFIER_TYPES[identifier.tag as keyof typeof IDENTIFIER_TYPES],
|
||||
value: identifier.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function encodePromptPay(
|
||||
identifier: EncodablePromptPayIdentifier,
|
||||
): DataObject[] {
|
||||
const parsed = promptPaySchema.shape.identifier.parse(identifier);
|
||||
if (parsed.type === "bankAccount")
|
||||
throw new Error(
|
||||
"PromptPay bank account encoding is reserved for future use.",
|
||||
);
|
||||
|
||||
return [
|
||||
{ type: "primitive", tag: 0, value: PROMPTPAY_AID },
|
||||
{
|
||||
type: "primitive",
|
||||
tag: IDENTIFIER_TAGS[parsed.type],
|
||||
value: parsed.value,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function normalizePromptPayIdentifier(
|
||||
identifier: EncodablePromptPayIdentifier,
|
||||
): EncodablePromptPayIdentifier {
|
||||
const value = identifier.value.trim();
|
||||
|
||||
if (identifier.type === "mobile") {
|
||||
if (!/^\+?[\d\s()-]+$/.test(value))
|
||||
throw new Error("PromptPay mobile number contains invalid characters.");
|
||||
|
||||
const digits = value.replace(/\D/g, "");
|
||||
const canonical = /^0\d{9}$/.test(digits)
|
||||
? `0066${digits.slice(1)}`
|
||||
: /^66\d{9}$/.test(digits)
|
||||
? `00${digits}`
|
||||
: digits;
|
||||
|
||||
if (!/^0066\d{9}$/.test(canonical))
|
||||
throw new Error("PromptPay mobile number must be a 10-digit Thai number beginning with 0.");
|
||||
|
||||
return promptPaySchema.shape.identifier.parse({
|
||||
type: "mobile",
|
||||
value: canonical,
|
||||
}) as EncodablePromptPayIdentifier;
|
||||
}
|
||||
|
||||
if (!/^[\d\s-]+$/.test(value))
|
||||
throw new Error(
|
||||
`PromptPay ${identifier.type} contains invalid characters.`,
|
||||
);
|
||||
|
||||
return promptPaySchema.shape.identifier.parse({
|
||||
type: identifier.type,
|
||||
value: value.replace(/[\s-]/g, ""),
|
||||
}) as EncodablePromptPayIdentifier;
|
||||
}
|
||||
|
||||
function formatAmount(amount: number | string): string {
|
||||
let formatted = "";
|
||||
if (typeof amount === "number") {
|
||||
if (Number.isFinite(amount)) formatted = amount.toFixed(2);
|
||||
} else {
|
||||
const value = amount.trim();
|
||||
if (/^\d+(?:\.\d{1,2})?$/.test(value)) {
|
||||
const [integer, fraction = ""] = value.split(".");
|
||||
formatted = `${integer}.${fraction.padEnd(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!/^\d+\.\d{2}$/.test(formatted) || Number(formatted) <= 0)
|
||||
throw new Error(
|
||||
"PromptPay amount must be a positive number with at most 2 decimals.",
|
||||
);
|
||||
if (formatted.length > 13)
|
||||
throw new Error("PromptPay amount exceeds the EMVCo length limit.");
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
function generatePromptPayPayload(options: GeneratePromptPayOptions): string {
|
||||
const identifier = normalizePromptPayIdentifier(options.identifier);
|
||||
const amount =
|
||||
options.amount === undefined ? undefined : formatAmount(options.amount);
|
||||
|
||||
if (
|
||||
options.merchantCategoryCode !== undefined &&
|
||||
!/^\d{4}$/.test(options.merchantCategoryCode)
|
||||
)
|
||||
throw new Error("PromptPay merchant category code must be 4 digits.");
|
||||
|
||||
const objects: DataObject[] = [
|
||||
{ type: "primitive", tag: 0, value: "01" },
|
||||
{
|
||||
type: "primitive",
|
||||
tag: 1,
|
||||
value: amount === undefined ? "11" : "12",
|
||||
},
|
||||
{ type: "template", tag: 29, value: encodePromptPay(identifier) },
|
||||
];
|
||||
|
||||
if (options.merchantCategoryCode !== undefined)
|
||||
objects.push({
|
||||
type: "primitive",
|
||||
tag: 52,
|
||||
value: options.merchantCategoryCode,
|
||||
});
|
||||
|
||||
objects.push(
|
||||
{ type: "primitive", tag: 53, value: "764" },
|
||||
{ type: "primitive", tag: 58, value: "TH" },
|
||||
);
|
||||
if (amount !== undefined)
|
||||
objects.push({ type: "primitive", tag: 54, value: amount });
|
||||
|
||||
return appendCRC(serializeTLV(objects));
|
||||
}
|
||||
|
||||
function parsePromptPayPayload(payload: string): ParsedPromptPayPayload {
|
||||
if (!verifyCRC(payload)) throw new Error("Invalid PromptPay payload CRC.");
|
||||
|
||||
const dataObjects = parseEMVCo(payload);
|
||||
const crc = dataObjects.at(-1);
|
||||
if (crc?.type !== "primitive" || crc.tag !== 63)
|
||||
throw new Error("PromptPay payload is missing its CRC data object.");
|
||||
|
||||
const emvco = decodeEMVCo(dataObjects.slice(0, -1));
|
||||
if (emvco.transactionCurrency !== "764")
|
||||
throw new Error("PromptPay transaction currency must be THB (764).");
|
||||
if (emvco.countryCode !== "TH")
|
||||
throw new Error("Domestic PromptPay country code must be TH.");
|
||||
|
||||
const promptPayTemplates = emvco.paymentNetworkSpecific.filter(
|
||||
({ tag }) => tag === 29,
|
||||
);
|
||||
if (promptPayTemplates.length !== 1)
|
||||
throw new Error(
|
||||
"Expected exactly one PromptPay merchant account template.",
|
||||
);
|
||||
|
||||
return {
|
||||
payload,
|
||||
dataObjects,
|
||||
emvco,
|
||||
promptPay: decodePromptPay(promptPayTemplates[0].value),
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
appendCRC,
|
||||
crc16,
|
||||
decodeEMVCo,
|
||||
decodePromptPay,
|
||||
encodeEMVCo,
|
||||
encodePromptPay,
|
||||
formatCRC,
|
||||
generatePromptPayPayload,
|
||||
normalizePromptPayIdentifier,
|
||||
parseEMVCo,
|
||||
parsePromptPayPayload,
|
||||
parseTLV,
|
||||
serializeTLV,
|
||||
verifyCRC,
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { commissionPrice, commissionRequestSchema } from "./request";
|
||||
|
||||
const request = {
|
||||
teams: [{ members: ["a", "b", "c", "d"].map((characterKey) => ({
|
||||
characterKey, weaponKey: `weapon-${characterKey}`, artifactKey: "artifact",
|
||||
})) }],
|
||||
weapons: [{ characterKey: "a", weaponKeys: ["weapon-a", "weapon-b", "weapon-c"] }],
|
||||
constellations: [{ characterKey: "a", levels: [0, 2] }],
|
||||
};
|
||||
|
||||
describe("commission request pricing and shape", () => {
|
||||
it("accepts one complete team without either comparison type", () => {
|
||||
const teamOnly = { teams: request.teams, weapons: [], constellations: [] };
|
||||
const parsed = commissionRequestSchema.safeParse(teamOnly);
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(commissionPrice(parsed.data)).toBe(100);
|
||||
expect(parsed.data.teams[0].members[0]).toMatchObject({ constellation: "0", refinement: "1" });
|
||||
}
|
||||
});
|
||||
it("keeps selected team constellation and refinement ranges", () => {
|
||||
const selected = { ...request, teams: [{ members: request.teams[0].members.map((member, index) =>
|
||||
index === 0 ? { ...member, constellation: "0-6", refinement: "1-5" } : member) }] };
|
||||
expect(commissionRequestSchema.parse(selected).teams[0].members[0]).toMatchObject({
|
||||
constellation: "0-6", refinement: "1-5",
|
||||
});
|
||||
expect(commissionRequestSchema.safeParse({ ...selected, teams: [{ members: selected.teams[0].members.map((member, index) =>
|
||||
index === 0 ? { ...member, refinement: "6" } : member) }] }).success).toBe(false);
|
||||
});
|
||||
it("prices independent request types together", () => {
|
||||
expect(commissionPrice(commissionRequestSchema.parse(request))).toBe(200);
|
||||
expect(commissionPrice(commissionRequestSchema.parse({ teams: [], weapons: [],
|
||||
constellations: [{ characterKey: "a", levels: [0, 1] }] }))).toBe(140);
|
||||
expect(commissionPrice(commissionRequestSchema.parse({ teams: [], weapons: [],
|
||||
constellations: [{ characterKey: "a", levels: [0] }] }))).toBe(120);
|
||||
expect(commissionPrice(commissionRequestSchema.parse({ teams: [], weapons: [
|
||||
{ characterKey: "a", weaponKeys: ["weapon-a", "weapon-b"] },
|
||||
], constellations: [] }))).toBe(140);
|
||||
expect(commissionPrice({ teams: [], weapons: [{ characterKey: "a", weaponKeys: ["weapon-a", "", ""] }],
|
||||
constellations: [] })).toBe(120);
|
||||
expect(commissionPrice(commissionRequestSchema.parse({ teams: request.teams.concat(request.teams),
|
||||
weapons: [], constellations: [] }))).toBe(200);
|
||||
expect(commissionPrice({ teams: [], weapons: [], constellations: [] })).toBe(0);
|
||||
});
|
||||
it("requires four distinct team characters and valid comparison choices", () => {
|
||||
expect(commissionRequestSchema.safeParse({ ...request, teams: [{ members: [
|
||||
...request.teams[0].members.slice(0, 3), request.teams[0].members[0],
|
||||
] }] }).success).toBe(false);
|
||||
expect(commissionRequestSchema.safeParse({ ...request,
|
||||
weapons: [{ characterKey: "a", weaponKeys: ["weapon-a"] }] }).success).toBe(false);
|
||||
expect(commissionRequestSchema.safeParse({ ...request,
|
||||
constellations: [{ characterKey: "a", levels: [0, 0] }] }).success).toBe(false);
|
||||
expect(commissionRequestSchema.safeParse({ ...request,
|
||||
constellations: [{ characterKey: "a", levels: [] }] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as z from "zod";
|
||||
|
||||
const key = z.string().min(1).max(64);
|
||||
const member = z.object({
|
||||
characterKey: key,
|
||||
weaponKey: key,
|
||||
artifactKey: key,
|
||||
constellation: z.enum(["0", "1", "2", "3", "4", "5", "6", "0-6"]).default("0"),
|
||||
refinement: z.enum(["1", "2", "3", "4", "5", "1-5"]).default("1"),
|
||||
});
|
||||
const team = z.object({ members: z.array(member).length(4) }).refine(
|
||||
({ members }) => new Set(members.map((item) => item.characterKey)).size === 4,
|
||||
"A team needs four different characters",
|
||||
);
|
||||
const weaponComparison = z.object({ characterKey: key, weaponKeys: z.array(key).min(2).max(100) }).refine(
|
||||
({ weaponKeys }) => new Set(weaponKeys).size === weaponKeys.length,
|
||||
"Choose different weapons",
|
||||
);
|
||||
const constellationComparison = z.object({
|
||||
characterKey: key,
|
||||
levels: z.array(z.number().int().min(0).max(6)).min(1).max(7),
|
||||
}).refine(({ levels }) => new Set(levels).size === levels.length, "Choose different constellation levels");
|
||||
|
||||
export const commissionRequestSchema = z.object({
|
||||
teams: z.array(team).max(20),
|
||||
weapons: z.array(weaponComparison).max(20),
|
||||
constellations: z.array(constellationComparison).max(20),
|
||||
}).refine((value) => value.teams.length + value.weapons.length + value.constellations.length > 0,
|
||||
"Choose at least one commission item");
|
||||
|
||||
export type CommissionRequest = z.infer<typeof commissionRequestSchema>;
|
||||
|
||||
export function commissionPrice(request: CommissionRequest): number {
|
||||
const hasItems = request.teams.length + request.weapons.length + request.constellations.length > 0;
|
||||
return (hasItems ? Math.max(1, request.teams.length) * 100 : 0) +
|
||||
request.weapons.reduce((sum, item) => sum + item.weaponKeys.filter(Boolean).length * 20, 0) +
|
||||
request.constellations.reduce((sum, item) => sum + item.levels.length * 20, 0);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import "server-only";
|
||||
|
||||
import { headers } from "next/headers";
|
||||
import { getAuth } from "@/lib/auth/server";
|
||||
import { getDb } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
import { getRedisClient, redisEventChannel } from "@/lib/redis/client";
|
||||
|
||||
export async function requireCommissionUser() {
|
||||
const session = await getAuth().api.getSession({ headers: await headers() });
|
||||
if (!session?.user) throw new HttpError(401, "unauthorized");
|
||||
const [user] = await getDb().select().from(users).where(eq(users.id, session.user.id)).limit(1);
|
||||
if (!user || user.banned) throw new HttpError(401, "unauthorized");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function notifyCommission(ticketId: string, userId: string) {
|
||||
try {
|
||||
const client = await getRedisClient();
|
||||
await Promise.all([
|
||||
client.publish(redisEventChannel(`commission:ticket:${ticketId}`), "changed"),
|
||||
client.publish(redisEventChannel(`commission:user:${userId}`), "changed"),
|
||||
client.publish(redisEventChannel("commission:admin"), "changed"),
|
||||
]);
|
||||
} catch {
|
||||
// The database is authoritative; a reconnect or page refresh catches up.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import "server-only";
|
||||
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { getDb } from "@/db";
|
||||
import { commissionCheckouts, commissionMessages, commissionPayments, commissionReactions, commissionTickets, users } from "@/db/schema";
|
||||
import { requireCommissionUser } from "./server";
|
||||
import { HttpError } from "@/lib/security/http";
|
||||
|
||||
export async function getCommissionTicket(id: string) {
|
||||
const user = await requireCommissionUser();
|
||||
const [row] = await getDb().select({ ticket: commissionTickets, checkout: commissionCheckouts,
|
||||
customerName: users.name, payment: commissionPayments })
|
||||
.from(commissionTickets)
|
||||
.innerJoin(commissionCheckouts, eq(commissionTickets.checkoutId, commissionCheckouts.id))
|
||||
.innerJoin(users, eq(commissionTickets.userId, users.id))
|
||||
.innerJoin(commissionPayments, eq(commissionPayments.ticketId, commissionTickets.id))
|
||||
.where(eq(commissionTickets.id, id)).limit(1);
|
||||
if (!row || (user.role !== "admin" || !user.emailVerified) && row.ticket.userId !== user.id)
|
||||
throw new HttpError(404, "ticket-not-found");
|
||||
const [messages, reactions] = await Promise.all([
|
||||
getDb().select({ message: commissionMessages, authorName: users.name }).from(commissionMessages)
|
||||
.innerJoin(users, eq(commissionMessages.authorId, users.id))
|
||||
.where(eq(commissionMessages.ticketId, id)).orderBy(asc(commissionMessages.createdAt)),
|
||||
getDb().select({ reaction: commissionReactions, messageId: commissionMessages.id })
|
||||
.from(commissionReactions)
|
||||
.innerJoin(commissionMessages, eq(commissionReactions.messageId, commissionMessages.id))
|
||||
.where(eq(commissionMessages.ticketId, id)),
|
||||
]);
|
||||
return { ...row, messages: messages.map(({ message, authorName }) => ({ ...message, authorName,
|
||||
reactions: reactions.filter(({ messageId }) => messageId === message.id).map(({ reaction }) => reaction) })),
|
||||
currentUserId: user.id };
|
||||
}
|
||||
|
||||
export async function authorizeTicket(id: string) {
|
||||
const user = await requireCommissionUser();
|
||||
const [ticket] = await getDb().select().from(commissionTickets).where(eq(commissionTickets.id, id)).limit(1);
|
||||
if (!ticket || ((user.role !== "admin" || !user.emailVerified) && ticket.userId !== user.id))
|
||||
throw new HttpError(404, "ticket-not-found");
|
||||
return { ticket, user };
|
||||
}
|
||||
Reference in New Issue
Block a user