✨ feat: impl accounts and channels
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { accounts, channels, type Account } from "@/lib/db/schema";
|
||||
|
||||
async function requireUser() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("请先登录");
|
||||
}
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
const accountSchema = z.object({
|
||||
name: z.string().min(1, "请输入账户名称").max(100),
|
||||
accountType: z.enum(["BANK", "E_WALLET", "CASH"] as const),
|
||||
balanceType: z.enum(["ASSET", "LIABILITY", "EQUITY"] as const),
|
||||
primaryCurrency: z.string().trim().max(10).optional().nullable(),
|
||||
supportedCurrencies: z.array(z.string().trim()).optional().nullable(),
|
||||
remark: z.string().max(500).optional().nullable(),
|
||||
ext: z.string().optional().nullable(),
|
||||
|
||||
// 银行账户字段
|
||||
issuerName: z.string().max(100).optional().nullable(),
|
||||
accountNumber: z.string().max(100).optional().nullable(),
|
||||
|
||||
// 电子钱包字段
|
||||
platform: z.string().max(64).optional().nullable(),
|
||||
accountId: z.string().max(100).optional().nullable(),
|
||||
|
||||
// 现金账户字段
|
||||
location: z.string().max(150).optional().nullable(),
|
||||
});
|
||||
|
||||
export type AccountInput = z.infer<typeof accountSchema>;
|
||||
|
||||
export type AccountWithChannels = Account & {
|
||||
channelCount: number;
|
||||
};
|
||||
|
||||
export async function getAccountsAction(): Promise<{
|
||||
success: boolean;
|
||||
data?: AccountWithChannels[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
|
||||
const userAccounts = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)))
|
||||
.orderBy(desc(accounts.createdAt));
|
||||
|
||||
const userChannels = await db
|
||||
.select({ id: channels.id, refAccounts: channels.refAccounts })
|
||||
.from(channels)
|
||||
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)));
|
||||
|
||||
const result: AccountWithChannels[] = userAccounts.map((acc) => {
|
||||
const count = userChannels.filter((ch) =>
|
||||
Array.isArray(ch.refAccounts) && ch.refAccounts.includes(acc.id)
|
||||
).length;
|
||||
return {
|
||||
...acc,
|
||||
channelCount: count,
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (err) {
|
||||
console.error("getAccountsAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "获取账户失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAccountAction(
|
||||
data: AccountInput
|
||||
): Promise<{ success: boolean; data?: Account; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const parsed = accountSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
|
||||
}
|
||||
|
||||
const val = parsed.data;
|
||||
const [newAccount] = await db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
userId,
|
||||
name: val.name.trim(),
|
||||
accountType: val.accountType,
|
||||
balanceType: val.balanceType,
|
||||
primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null,
|
||||
supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null,
|
||||
remark: val.remark?.trim() || null,
|
||||
ext: val.ext?.trim() || null,
|
||||
issuerName: val.issuerName?.trim() || null,
|
||||
accountNumber: val.accountNumber?.trim() || null,
|
||||
platform: val.platform?.trim() || null,
|
||||
accountId: val.accountId?.trim() || null,
|
||||
location: val.location?.trim() || null,
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/");
|
||||
return { success: true, data: newAccount };
|
||||
} catch (err) {
|
||||
console.error("createAccountAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "创建账户失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAccountAction(
|
||||
id: string,
|
||||
data: AccountInput
|
||||
): Promise<{ success: boolean; data?: Account; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const parsed = accountSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
|
||||
}
|
||||
|
||||
const val = parsed.data;
|
||||
const [updated] = await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
name: val.name.trim(),
|
||||
accountType: val.accountType,
|
||||
balanceType: val.balanceType,
|
||||
primaryCurrency: val.primaryCurrency ? val.primaryCurrency.toUpperCase() : null,
|
||||
supportedCurrencies: val.supportedCurrencies && val.supportedCurrencies.length > 0 ? val.supportedCurrencies : null,
|
||||
remark: val.remark?.trim() || null,
|
||||
ext: val.ext?.trim() || null,
|
||||
issuerName: val.issuerName?.trim() || null,
|
||||
accountNumber: val.accountNumber?.trim() || null,
|
||||
platform: val.platform?.trim() || null,
|
||||
accountId: val.accountId?.trim() || null,
|
||||
location: val.location?.trim() || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return { success: false, error: "未找到该账户或无权修改" };
|
||||
}
|
||||
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/");
|
||||
return { success: true, data: updated };
|
||||
} catch (err) {
|
||||
console.error("updateAccountAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "更新账户失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAccountActiveAction(
|
||||
id: string,
|
||||
isActive: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)));
|
||||
|
||||
revalidatePath("/accounts");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("toggleAccountActiveAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccountAction(
|
||||
id: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
// 软删除
|
||||
const [deleted] = await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(accounts.id, id), eq(accounts.userId, userId), isNull(accounts.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
return { success: false, error: "账户不存在或已删除" };
|
||||
}
|
||||
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("deleteAccountAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "删除账户失败" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/lib/db/schema";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "请输入姓名/昵称").max(100),
|
||||
email: z.string().email("请输入有效的邮箱地址").max(255),
|
||||
password: z.string().min(8, "密码长度至少需要 8 个字符"),
|
||||
confirmPassword: z.string().min(1, "请确认密码"),
|
||||
})
|
||||
.refine((data: { password: string; confirmPassword: string }) => data.password === data.confirmPassword, {
|
||||
message: "两次输入的密码不一致",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export type RegisterState = {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export async function registerAction(
|
||||
prevState: RegisterState | null,
|
||||
formData: FormData
|
||||
): Promise<RegisterState> {
|
||||
const rawData = {
|
||||
name: formData.get("name"),
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
};
|
||||
|
||||
const parsed = registerSchema.safeParse(rawData);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
fieldErrors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const { name, email, password } = parsed.data;
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
// 检查邮箱是否已被注册
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, normalizedEmail))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
error: "该邮箱已被注册,请直接登录",
|
||||
};
|
||||
}
|
||||
|
||||
// 使用 Argon2 哈希密码
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// 插入新用户
|
||||
await db.insert(users).values({
|
||||
name: name.trim(),
|
||||
email: normalizedEmail,
|
||||
passwordHash,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Registration error:", err);
|
||||
return {
|
||||
success: false,
|
||||
error: "注册失败,请稍后重试",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
accounts,
|
||||
channels,
|
||||
type Channel,
|
||||
} from "@/lib/db/schema";
|
||||
|
||||
async function requireUser() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
throw new Error("请先登录");
|
||||
}
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
const channelSchema = z.object({
|
||||
channelType: z.enum(["PAYMENT_CARD", "E_WALLET", "CASH", "TRANSFER"] as const),
|
||||
refAccounts: z.array(z.string().uuid("无效的账户标识")).min(1, "请至少关联一个资金账户"),
|
||||
desc: z.string().max(500).optional().nullable(),
|
||||
ext: z.string().optional().nullable(),
|
||||
|
||||
// 支付卡特定字段
|
||||
region: z.string().max(10).optional().nullable(),
|
||||
issuerName: z.string().max(100).optional().nullable(),
|
||||
cardType: z.enum(["CREDIT", "DEBIT"] as const).optional().nullable(),
|
||||
cardNumberFull: z.string().max(100).optional().nullable(),
|
||||
cardNumberSuffix: z.string().max(10).optional().nullable(),
|
||||
cardBrand: z.string().max(32).optional().nullable(),
|
||||
|
||||
// 电子钱包特定字段
|
||||
platform: z.string().max(64).optional().nullable(),
|
||||
platformAccountId: z.string().max(100).optional().nullable(),
|
||||
subChannel: z.string().max(64).optional().nullable(),
|
||||
subChannelType: z.enum(["CREDIT", "DEBIT"] as const).optional().nullable(),
|
||||
});
|
||||
|
||||
export type ChannelInput = z.infer<typeof channelSchema>;
|
||||
|
||||
export type ChannelWithAccountNames = Channel & {
|
||||
linkedAccounts: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
export async function getChannelsAction(): Promise<{
|
||||
success: boolean;
|
||||
data?: ChannelWithAccountNames[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
|
||||
const userChannels = await db
|
||||
.select()
|
||||
.from(channels)
|
||||
.where(and(eq(channels.userId, userId), isNull(channels.deletedAt)))
|
||||
.orderBy(desc(channels.createdAt));
|
||||
|
||||
const userAccounts = await db
|
||||
.select({ id: accounts.id, name: accounts.name })
|
||||
.from(accounts)
|
||||
.where(and(eq(accounts.userId, userId), isNull(accounts.deletedAt)));
|
||||
|
||||
const accountMap = new Map(userAccounts.map((a) => [a.id, a.name]));
|
||||
|
||||
const result: ChannelWithAccountNames[] = userChannels.map((ch) => {
|
||||
const linked: { id: string; name: string }[] = [];
|
||||
if (Array.isArray(ch.refAccounts)) {
|
||||
for (const accId of ch.refAccounts) {
|
||||
const name = accountMap.get(accId);
|
||||
if (name) {
|
||||
linked.push({ id: accId, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...ch,
|
||||
linkedAccounts: linked,
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, data: result };
|
||||
} catch (err) {
|
||||
console.error("getChannelsAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "获取渠道失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createChannelAction(
|
||||
data: ChannelInput
|
||||
): Promise<{ success: boolean; data?: Channel; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const parsed = channelSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
|
||||
}
|
||||
|
||||
const val = parsed.data;
|
||||
|
||||
// 校验关联的所有账户必须属于当前登录用户
|
||||
const userOwnedAccounts = await db
|
||||
.select({ id: accounts.id })
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, userId),
|
||||
inArray(accounts.id, val.refAccounts),
|
||||
isNull(accounts.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
if (userOwnedAccounts.length !== val.refAccounts.length) {
|
||||
return { success: false, error: "关联的部分账户不存在或已被删除" };
|
||||
}
|
||||
|
||||
// 自动提取或补全卡号后4位
|
||||
let suffix = val.cardNumberSuffix?.trim() || null;
|
||||
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
|
||||
suffix = val.cardNumberFull.slice(-4);
|
||||
}
|
||||
|
||||
const [newChannel] = await db
|
||||
.insert(channels)
|
||||
.values({
|
||||
userId,
|
||||
channelType: val.channelType,
|
||||
refAccounts: val.refAccounts,
|
||||
isActive: true,
|
||||
desc: val.desc?.trim() || null,
|
||||
ext: val.ext?.trim() || null,
|
||||
region: val.region ? val.region.toUpperCase().trim() : null,
|
||||
issuerName: val.issuerName?.trim() || null,
|
||||
cardType: val.cardType || null,
|
||||
cardNumberFull: val.cardNumberFull?.trim() || null,
|
||||
cardNumberSuffix: suffix,
|
||||
cardBrand: val.cardBrand?.trim() || null,
|
||||
platform: val.platform?.trim() || null,
|
||||
platformAccountId: val.platformAccountId?.trim() || null,
|
||||
subChannel: val.subChannel?.trim() || null,
|
||||
subChannelType: val.subChannelType || null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/");
|
||||
return { success: true, data: newChannel };
|
||||
} catch (err) {
|
||||
console.error("createChannelAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "创建渠道失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateChannelAction(
|
||||
id: string,
|
||||
data: ChannelInput
|
||||
): Promise<{ success: boolean; data?: Channel; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const parsed = channelSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: parsed.error.issues[0]?.message || "参数错误" };
|
||||
}
|
||||
|
||||
const val = parsed.data;
|
||||
|
||||
const userOwnedAccounts = await db
|
||||
.select({ id: accounts.id })
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.userId, userId),
|
||||
inArray(accounts.id, val.refAccounts),
|
||||
isNull(accounts.deletedAt)
|
||||
)
|
||||
);
|
||||
|
||||
if (userOwnedAccounts.length !== val.refAccounts.length) {
|
||||
return { success: false, error: "关联的部分账户不存在或已被删除" };
|
||||
}
|
||||
|
||||
let suffix = val.cardNumberSuffix?.trim() || null;
|
||||
if (!suffix && val.cardNumberFull && val.cardNumberFull.length >= 4) {
|
||||
suffix = val.cardNumberFull.slice(-4);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(channels)
|
||||
.set({
|
||||
channelType: val.channelType,
|
||||
refAccounts: val.refAccounts,
|
||||
desc: val.desc?.trim() || null,
|
||||
ext: val.ext?.trim() || null,
|
||||
region: val.region ? val.region.toUpperCase().trim() : null,
|
||||
issuerName: val.issuerName?.trim() || null,
|
||||
cardType: val.cardType || null,
|
||||
cardNumberFull: val.cardNumberFull?.trim() || null,
|
||||
cardNumberSuffix: suffix,
|
||||
cardBrand: val.cardBrand?.trim() || null,
|
||||
platform: val.platform?.trim() || null,
|
||||
platformAccountId: val.platformAccountId?.trim() || null,
|
||||
subChannel: val.subChannel?.trim() || null,
|
||||
subChannelType: val.subChannelType || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return { success: false, error: "渠道不存在或无权修改" };
|
||||
}
|
||||
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/");
|
||||
return { success: true, data: updated };
|
||||
} catch (err) {
|
||||
console.error("updateChannelAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "更新渠道失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleChannelActiveAction(
|
||||
id: string,
|
||||
isActive: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
await db
|
||||
.update(channels)
|
||||
.set({
|
||||
isActive,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)));
|
||||
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("toggleChannelActiveAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "状态切换失败" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteChannelAction(
|
||||
id: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const userId = await requireUser();
|
||||
const [deleted] = await db
|
||||
.update(channels)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(channels.id, id), eq(channels.userId, userId), isNull(channels.deletedAt)))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
return { success: false, error: "渠道不存在或已删除" };
|
||||
}
|
||||
|
||||
revalidatePath("/channels");
|
||||
revalidatePath("/accounts");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error("deleteChannelAction error:", err);
|
||||
return { success: false, error: err instanceof Error ? err.message : "删除渠道失败" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
|
||||
export const authConfig = {
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
error: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user;
|
||||
const pathname = nextUrl.pathname;
|
||||
const publicPaths = ["/login", "/register", "/api/auth"];
|
||||
const isPublic = publicPaths.some(
|
||||
(path) => pathname === path || pathname.startsWith("/api/auth/")
|
||||
);
|
||||
|
||||
// 已登录用户在登录/注册页时重定向到首页
|
||||
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
|
||||
return Response.redirect(new URL("/", nextUrl));
|
||||
}
|
||||
|
||||
// 访问受保护页面必须已登录
|
||||
if (!isLoggedIn && !isPublic) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
jwt({ token, user }) {
|
||||
if (user?.id) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
providers: [],
|
||||
} satisfies NextAuthConfig;
|
||||
@@ -0,0 +1,147 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { users, userAccounts } from "@/lib/db/schema";
|
||||
import { verifyPassword } from "./password";
|
||||
import { authConfig } from "./config";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
|
||||
// 动态构建 Providers 列表
|
||||
const providers: Provider[] = [
|
||||
Credentials({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const email = String(credentials.email).toLowerCase().trim();
|
||||
const password = String(credentials.password);
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!user || !user.passwordHash || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.avatar,
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// 如果配置了 OIDC,且 AUTH_OIDC_ENABLED 为 true,则动态注册通用 OIDC 提供商
|
||||
const isOidcEnabled =
|
||||
process.env.AUTH_OIDC_ENABLED === "true" &&
|
||||
Boolean(process.env.AUTH_OIDC_ISSUER) &&
|
||||
Boolean(process.env.AUTH_OIDC_CLIENT_ID);
|
||||
|
||||
if (isOidcEnabled) {
|
||||
providers.push({
|
||||
id: "oidc",
|
||||
name: process.env.AUTH_OIDC_NAME || "SSO 统一身份认证",
|
||||
// Laysense currently returns nonce: "" even when no nonce was requested,
|
||||
// causing Auth.js to reject the ID Token. Keep the standard OIDC flow here
|
||||
// and leave it disabled in .env.local until the identity provider is fixed.
|
||||
type: "oidc",
|
||||
issuer: process.env.AUTH_OIDC_ISSUER!.replace(/\/$/, ""),
|
||||
clientId: process.env.AUTH_OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET,
|
||||
// Laysense requires an OAuth state parameter to correlate the callback.
|
||||
checks: ["state"],
|
||||
authorization: {
|
||||
params: {
|
||||
scope: process.env.AUTH_OIDC_SCOPES || "openid profile email",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
...authConfig,
|
||||
providers,
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 天
|
||||
},
|
||||
callbacks: {
|
||||
...authConfig.callbacks,
|
||||
async signIn({ user, account }) {
|
||||
if (!account || account.type === "credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理 OIDC / OAuth 登录与本地用户的关联或新建
|
||||
const email = user.email?.toLowerCase().trim();
|
||||
if (!email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. 查询用户是否已存在
|
||||
let [existingUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
// 创建新用户
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: user.name || email.split("@")[0],
|
||||
email: email,
|
||||
avatar: user.image || null,
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
existingUser = newUser;
|
||||
}
|
||||
|
||||
user.id = existingUser.id;
|
||||
|
||||
// 2. 查询是否已记录该 provider 的账户绑定
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(userAccounts)
|
||||
.where(
|
||||
eq(userAccounts.providerAccountId, account.providerAccountId)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingAccount) {
|
||||
await db.insert(userAccounts).values({
|
||||
userId: existingUser.id,
|
||||
provider: account.provider,
|
||||
providerAccountId: account.providerAccountId,
|
||||
refreshToken: account.refresh_token,
|
||||
accessToken: account.access_token,
|
||||
expiresAt: account.expires_at ? new Date(account.expires_at * 1000) : null,
|
||||
tokenType: account.token_type,
|
||||
scope: account.scope,
|
||||
idToken: account.id_token,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
|
||||
// 遵循 OWASP 密码哈希安全推荐配置
|
||||
const ARGON2_OPTIONS = {
|
||||
memoryCost: 19456, // 19 MiB
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await hash(password, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
||||
try {
|
||||
return await verify(passwordHash, password);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || "postgresql://postgres:Aa110011@localhost:5432/fluxent";
|
||||
|
||||
// 在 Next.js 开发环境下避免热重载创建重复连接池
|
||||
const globalForDb = globalThis as unknown as {
|
||||
conn: postgres.Sql | undefined;
|
||||
};
|
||||
|
||||
const client = globalForDb.conn ?? postgres(connectionString, { max: 10 });
|
||||
if (process.env.NODE_ENV !== "production") globalForDb.conn = client;
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as dotenv from "dotenv"
|
||||
import postgres from "postgres"
|
||||
import { normalizeCardBrand } from "../payment/card-brand"
|
||||
|
||||
dotenv.config({ path: ".env.local" })
|
||||
|
||||
const sql = postgres(process.env.DATABASE_URL!)
|
||||
|
||||
async function main() {
|
||||
const rows = await sql<{ id: string; card_brand: string | null }[]>`
|
||||
SELECT id, card_brand
|
||||
FROM channels
|
||||
WHERE card_brand IS NOT NULL
|
||||
`
|
||||
|
||||
const unsupported = rows.filter((row) => !normalizeCardBrand(row.card_brand))
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`Unsupported card brands: ${unsupported.map((row) => row.card_brand).join(", ")}`
|
||||
)
|
||||
}
|
||||
|
||||
let updated = 0
|
||||
for (const row of rows) {
|
||||
const cardBrand = normalizeCardBrand(row.card_brand)
|
||||
if (cardBrand && cardBrand !== row.card_brand) {
|
||||
await sql`
|
||||
UPDATE channels
|
||||
SET card_brand = ${cardBrand}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ${row.id}
|
||||
`
|
||||
updated += 1
|
||||
}
|
||||
}
|
||||
|
||||
const brands = await sql<{ card_brand: string }[]>`
|
||||
SELECT DISTINCT card_brand
|
||||
FROM channels
|
||||
WHERE card_brand IS NOT NULL
|
||||
ORDER BY card_brand
|
||||
`
|
||||
|
||||
console.log(`Normalized ${updated} channel card brands.`)
|
||||
console.log("Stored card brands:", brands.map((row) => row.card_brand))
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => sql.end())
|
||||
@@ -0,0 +1,22 @@
|
||||
import postgres from "postgres";
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
dotenv.config({ path: ".env.local" });
|
||||
|
||||
const sql = postgres(process.env.DATABASE_URL!);
|
||||
|
||||
async function resetAndInit() {
|
||||
console.log("Dropping old tables...");
|
||||
await sql`DROP TABLE IF EXISTS transactions CASCADE`;
|
||||
await sql`DROP TABLE IF EXISTS channels CASCADE`;
|
||||
await sql`DROP TABLE IF EXISTS accounts CASCADE`;
|
||||
await sql`DROP TABLE IF EXISTS user_accounts CASCADE`;
|
||||
await sql`DROP TABLE IF EXISTS users CASCADE`;
|
||||
console.log("Old tables dropped.");
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
resetAndInit().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
varchar,
|
||||
uuid,
|
||||
boolean,
|
||||
timestamp,
|
||||
jsonb,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 基础时间戳辅助
|
||||
// ---------------------------------------------------------------------------
|
||||
export const timestamps = {
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. 用户与认证表
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 用户表:本地密码用户与 OIDC 用户统一归属于此表
|
||||
*/
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
passwordHash: text("password_hash"), // OIDC 注册的用户可无密码
|
||||
avatar: text("avatar"),
|
||||
isActive: boolean("is_active").default(true).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("users_email_unique").on(t.email),
|
||||
]
|
||||
);
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
/**
|
||||
* 用户认证授权表 (支持 OIDC / OAuth 账号关联)
|
||||
*/
|
||||
export const userAccounts = pgTable(
|
||||
"user_accounts",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
provider: varchar("provider", { length: 64 }).notNull(), // 如 'oidc'
|
||||
providerAccountId: varchar("provider_account_id", { length: 255 }).notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessToken: text("access_token"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
tokenType: varchar("token_type", { length: 64 }),
|
||||
scope: text("scope"),
|
||||
idToken: text("id_token"),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("user_accounts_provider_account_id_unique").on(
|
||||
t.provider,
|
||||
t.providerAccountId
|
||||
),
|
||||
index("user_accounts_user_id_idx").on(t.userId),
|
||||
]
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. 资金账户表 (Accounts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AccountType = "BANK" | "E_WALLET" | "CASH";
|
||||
export type BalanceType = "ASSET" | "LIABILITY" | "EQUITY";
|
||||
|
||||
export const accounts = pgTable(
|
||||
"accounts",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
name: varchar("name", { length: 150 }).notNull(),
|
||||
accountType: varchar("account_type", { length: 32 }).$type<AccountType>().notNull(),
|
||||
balanceType: varchar("balance_type", { length: 32 })
|
||||
.$type<BalanceType>()
|
||||
.default("ASSET")
|
||||
.notNull(),
|
||||
primaryCurrency: varchar("primary_currency", { length: 10 }),
|
||||
supportedCurrencies: jsonb("supported_currencies").$type<string[] | null>(),
|
||||
isActive: boolean("is_active").default(true).notNull(),
|
||||
remark: text("remark"),
|
||||
ext: text("ext"),
|
||||
|
||||
// 银行账户特定字段
|
||||
issuerName: varchar("issuer_name", { length: 100 }),
|
||||
accountNumber: varchar("account_number", { length: 100 }),
|
||||
subAccounts: jsonb("sub_accounts").$type<string[]>(),
|
||||
|
||||
// 电子钱包特定字段
|
||||
platform: varchar("platform", { length: 64 }),
|
||||
accountId: varchar("account_id", { length: 100 }),
|
||||
|
||||
// 现金账户特定字段
|
||||
location: varchar("location", { length: 150 }),
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index("accounts_user_id_idx").on(t.userId),
|
||||
]
|
||||
);
|
||||
|
||||
export type Account = typeof accounts.$inferSelect;
|
||||
export type NewAccount = typeof accounts.$inferInsert;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. 渠道表 (Channels)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ChannelType = "PAYMENT_CARD" | "E_WALLET" | "CASH" | "TRANSFER";
|
||||
export type PaymentInstrumentType = "CREDIT" | "DEBIT";
|
||||
|
||||
export const channels = pgTable(
|
||||
"channels",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
channelType: varchar("channel_type", { length: 32 }).$type<ChannelType>().notNull(),
|
||||
refAccounts: jsonb("ref_accounts").$type<string[]>().notNull(), // 关联的账户 UUID 列表
|
||||
isActive: boolean("is_active").default(true).notNull(),
|
||||
desc: text("desc"),
|
||||
ext: text("ext"),
|
||||
|
||||
// 支付卡渠道字段
|
||||
region: varchar("region", { length: 10 }), // 发卡地如 HK, CN
|
||||
issuerName: varchar("issuer_name", { length: 100 }),
|
||||
cardType: varchar("card_type", { length: 32 }).$type<PaymentInstrumentType>(),
|
||||
cardNumberFull: varchar("card_number_full", { length: 100 }),
|
||||
cardNumberSuffix: varchar("card_number_suffix", { length: 10 }),
|
||||
cardBrand: varchar("card_brand", { length: 32 }),
|
||||
|
||||
// 电子钱包渠道字段
|
||||
platform: varchar("platform", { length: 64 }),
|
||||
platformAccountId: varchar("platform_account_id", { length: 100 }),
|
||||
subChannel: varchar("sub_channel", { length: 64 }),
|
||||
subChannelType: varchar("sub_channel_type", { length: 32 }).$type<PaymentInstrumentType>(),
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index("channels_user_id_idx").on(t.userId),
|
||||
]
|
||||
);
|
||||
|
||||
export type Channel = typeof channels.$inferSelect;
|
||||
export type NewChannel = typeof channels.$inferInsert;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. 交易记账表 (Transactions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TransactionStatus = "PENDING" | "COMPLETED" | "FAILED" | "REFUNDED";
|
||||
export type DcFlag = "DEBIT" | "CREDIT";
|
||||
|
||||
export interface FxRateItem {
|
||||
fromCcy: string;
|
||||
toCcy: string;
|
||||
rate: string;
|
||||
}
|
||||
|
||||
export const transactions = pgTable(
|
||||
"transactions",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: uuid("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
version: varchar("version", { length: 16 }).default("0").notNull(),
|
||||
refTransactions: jsonb("ref_transactions").$type<string[]>(), // 关联交易 UUID 列表
|
||||
txnDate: timestamp("txn_date", { withTimezone: true }).notNull(),
|
||||
clearingDate: timestamp("clearing_date", { withTimezone: true }),
|
||||
postingDate: timestamp("posting_date", { withTimezone: true }),
|
||||
|
||||
// 金额与币种
|
||||
txnAmt: varchar("txn_amt", { length: 32 }).notNull(),
|
||||
txnCcy: varchar("txn_ccy", { length: 10 }).notNull(),
|
||||
postingAmt: varchar("posting_amt", { length: 32 }),
|
||||
postingCcy: varchar("posting_ccy", { length: 10 }),
|
||||
commAmt: varchar("comm_amt", { length: 32 }),
|
||||
commCcy: varchar("comm_ccy", { length: 10 }),
|
||||
surchargeAmt: varchar("surcharge_amt", { length: 32 }),
|
||||
surchargeCcy: varchar("surcharge_ccy", { length: 10 }),
|
||||
discAmt: varchar("disc_amt", { length: 32 }),
|
||||
discCcy: varchar("disc_ccy", { length: 10 }),
|
||||
fxRates: jsonb("fx_rates").$type<FxRateItem[]>(),
|
||||
|
||||
dcFlag: varchar("dc_flag", { length: 16 }).$type<DcFlag>().notNull(),
|
||||
refChannels: jsonb("ref_channels").$type<string[]>().notNull(), // 渠道 UUID 列表
|
||||
cp: varchar("cp", { length: 255 }), // 对手方
|
||||
acqInst: varchar("acq_inst", { length: 150 }),
|
||||
clearingNetwork: varchar("clearing_network", { length: 100 }),
|
||||
txnSts: varchar("txn_sts", { length: 32 })
|
||||
.$type<TransactionStatus>()
|
||||
.default("COMPLETED")
|
||||
.notNull(),
|
||||
|
||||
description: text("description"),
|
||||
memo: text("memo"),
|
||||
ext: text("ext"),
|
||||
rawDescription: text("raw_description"),
|
||||
rawData: jsonb("raw_data"),
|
||||
|
||||
// 场景类型
|
||||
txnScene: varchar("txn_scene", { length: 64 }).notNull(), // MISC_IN, PAYMENT, ECOM_PAYMENT, POS_PAYMENT, ATM, TRANSFER 等
|
||||
merchantName: varchar("merchant_name", { length: 255 }),
|
||||
orderId: varchar("order_id", { length: 150 }),
|
||||
geo: varchar("geo", { length: 64 }),
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index("transactions_user_id_idx").on(t.userId),
|
||||
index("transactions_txn_date_idx").on(t.txnDate),
|
||||
]
|
||||
);
|
||||
|
||||
export type Transaction = typeof transactions.$inferSelect;
|
||||
export type NewTransaction = typeof transactions.$inferInsert;
|
||||
@@ -0,0 +1,105 @@
|
||||
export const CARD_BRANDS = [
|
||||
"visa",
|
||||
"mastercard",
|
||||
"unionpay",
|
||||
"amex",
|
||||
"diners",
|
||||
"discover",
|
||||
"jcb",
|
||||
] as const
|
||||
|
||||
export type CardBrand = (typeof CARD_BRANDS)[number]
|
||||
|
||||
export const CARD_BRAND_LABELS: Record<CardBrand, string> = {
|
||||
visa: "Visa",
|
||||
mastercard: "Mastercard",
|
||||
unionpay: "UnionPay",
|
||||
amex: "American Express",
|
||||
diners: "Diners Club",
|
||||
discover: "Discover",
|
||||
jcb: "JCB",
|
||||
}
|
||||
|
||||
export const CARD_BRAND_LOGOS: Record<CardBrand, string> = {
|
||||
visa: "/payment-logos/visa.svg",
|
||||
mastercard: "/payment-logos/mastercard.svg",
|
||||
unionpay: "/payment-logos/unionpay.svg",
|
||||
amex: "/payment-logos/amex.svg",
|
||||
diners: "/payment-logos/diners.svg",
|
||||
discover: "/payment-logos/discover.svg",
|
||||
jcb: "/payment-logos/jcb.svg",
|
||||
}
|
||||
|
||||
const CARD_BRAND_ALIASES: Record<string, CardBrand> = {
|
||||
visa: "visa",
|
||||
mastercard: "mastercard",
|
||||
unionpay: "unionpay",
|
||||
amex: "amex",
|
||||
americanexpress: "amex",
|
||||
diners: "diners",
|
||||
dinersclub: "diners",
|
||||
discover: "discover",
|
||||
jcb: "jcb",
|
||||
}
|
||||
|
||||
export function normalizeCardBrand(
|
||||
input: string | null | undefined
|
||||
): CardBrand | null {
|
||||
if (typeof input !== "string") {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_-]+/g, "")
|
||||
return CARD_BRAND_ALIASES[key] ?? null
|
||||
}
|
||||
|
||||
export function getCardBrandLogoUrl(
|
||||
input: string | null | undefined
|
||||
): string | null {
|
||||
const brand = normalizeCardBrand(input)
|
||||
return brand ? CARD_BRAND_LOGOS[brand] : null
|
||||
}
|
||||
|
||||
type CardNumberResult = {
|
||||
card: { type?: string } | null
|
||||
isPotentiallyValid: boolean
|
||||
}
|
||||
|
||||
type CardValidatorApi = {
|
||||
number?: (value: string) => CardNumberResult
|
||||
}
|
||||
|
||||
type CardValidatorModule = CardValidatorApi & {
|
||||
default?: CardValidatorApi
|
||||
}
|
||||
|
||||
export async function detectCardBrand(
|
||||
cardNumber: string
|
||||
): Promise<CardBrand | null> {
|
||||
const digits = cardNumber.replace(/\D/g, "")
|
||||
if (!digits) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const loadedValidator =
|
||||
(await import("card-validator")) as unknown as CardValidatorModule
|
||||
const validator = loadedValidator.default ?? loadedValidator
|
||||
|
||||
if (typeof validator.number !== "function") {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = validator.number(digits)
|
||||
if (!result.card || !result.isPotentiallyValid) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.card.type ? normalizeCardBrand(result.card.type) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user