checkout-api.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import { postRemittance } from "@/lib/remittance-client";
  2. export type RemittanceChannel = {
  3. id: string;
  4. code: string;
  5. requestUrl: string;
  6. icon: string;
  7. name: string;
  8. description: string;
  9. amountRange: string;
  10. processingTime: string;
  11. fee: string;
  12. groupName: string;
  13. groupOrder: number;
  14. channelType: number | null;
  15. bankValid: number;
  16. /** 支付金额倍率;与标价相乘。为 null 或非有限数时按 1 处理。 */
  17. rate: number | null;
  18. };
  19. export type BankChannelOption = {
  20. value: string;
  21. label: string;
  22. currency: string;
  23. /** 与外层通道 `rate` 同语义;为 null 或非有限数时乘价按 1。 */
  24. rate: number | null;
  25. };
  26. function toNumber(v: unknown): number | null {
  27. if (typeof v === "number" && Number.isFinite(v)) return v;
  28. if (typeof v === "string" && v.trim()) {
  29. const n = Number(v);
  30. if (Number.isFinite(n)) return n;
  31. }
  32. return null;
  33. }
  34. function pickString(record: Record<string, unknown>, keys: string[]): string {
  35. for (const key of keys) {
  36. const value = record[key];
  37. if (typeof value === "string" && value.trim()) return value.trim();
  38. }
  39. return "";
  40. }
  41. function pickAmountRange(record: Record<string, unknown>): string {
  42. const min = toNumber(
  43. record.minAmount ?? record.amountMin ?? record.lowerAmount ?? record.min,
  44. );
  45. const max = toNumber(
  46. record.maxAmount ?? record.amountMax ?? record.upperAmount ?? record.max,
  47. );
  48. const unit = pickString(record, ["currency", "currencyCode", "coin", "unit"]).toUpperCase();
  49. if (min !== null && max !== null) {
  50. return `$${min} - $${max}${unit ? ` ${unit}` : ""}`;
  51. }
  52. const rangeText = pickString(record, ["amountRange", "limitRange", "amountDesc"]);
  53. return rangeText || "-";
  54. }
  55. function pickFee(record: Record<string, unknown>): string {
  56. const feeRate = toNumber(record.feeRate);
  57. if (feeRate !== null) {
  58. if (feeRate <= 1) return `${(feeRate * 100).toFixed(2).replace(/\.00$/, "")}%`;
  59. return `${feeRate.toFixed(2).replace(/\.00$/, "")}%`;
  60. }
  61. const fee = pickString(record, ["fee", "charge", "feeDesc"]);
  62. return fee || "0%";
  63. }
  64. const CHANNEL_GROUP_NAME_MAP: Record<number, string> = {
  65. 1: "国际转账支付",
  66. 2: "中国网银支付",
  67. 3: "数字货币",
  68. 4: "电子钱包",
  69. 5: "CWG 电子卡",
  70. };
  71. const CHANNEL_GROUP_DISPLAY_ORDER: Record<number, number> = {
  72. 3: 1, // 数字货币
  73. 2: 2, // 中国网银支付
  74. 1: 3, // 国际转账支付
  75. 4: 4, // 电子钱包
  76. 5: 5, // CWG 电子卡
  77. };
  78. function pickGroupId(record: Record<string, unknown>): number | null {
  79. const id = toNumber(
  80. record.type ??
  81. record.channelType ??
  82. record.payTypeType ??
  83. record.groupId ??
  84. record.channelGroupId ??
  85. record.channelClassify ??
  86. record.channelCategory ??
  87. record.categoryId ??
  88. record.payTypeGroup ??
  89. record.payTypeClassify,
  90. );
  91. if (id === null) return null;
  92. if (id >= 1 && id <= 5) return id;
  93. return null;
  94. }
  95. function normalizeChannelList(raw: unknown): RemittanceChannel[] {
  96. if (!raw || typeof raw !== "object") return [];
  97. const container = raw as Record<string, unknown>;
  98. const inner =
  99. container.data ??
  100. container.list ??
  101. container.rows ??
  102. container.records ??
  103. container.channels;
  104. const source = Array.isArray(inner) ? inner : [];
  105. const output: RemittanceChannel[] = [];
  106. for (const item of source) {
  107. if (!item || typeof item !== "object") continue;
  108. const row = item as Record<string, unknown>;
  109. const id = String(
  110. row.id ??
  111. row.channelId ??
  112. row.payTypeId ??
  113. row.code ??
  114. row.channelCode ??
  115. output.length + 1,
  116. );
  117. const name =
  118. pickString(row, ["name", "channelName", "payType", "channelCode", "code"]) || "-";
  119. const code = pickString(row, ["code", "channelCode", "payCode", "channelNo"]) || id;
  120. const requestUrl = pickString(row, ["requestUrl", "payUrl", "url"]) || "/xfgpay/pay";
  121. const icon = pickString(row, ["icon", "logo", "img", "image", "iconUrl"]);
  122. const processingTime =
  123. pickString(row, ["processingTime", "processTime", "arrivalTime", "timeDesc"]) || "1 hours";
  124. const groupId = pickGroupId(row);
  125. const fallbackGroupName =
  126. pickString(row, [
  127. "groupName",
  128. "categoryName",
  129. "channelGroup",
  130. "payScene",
  131. "group",
  132. ]) || "支付通道";
  133. const groupName = groupId ? CHANNEL_GROUP_NAME_MAP[groupId] : fallbackGroupName;
  134. const groupOrder = groupId ? (CHANNEL_GROUP_DISPLAY_ORDER[groupId] ?? 999) : 999;
  135. output.push({
  136. id,
  137. code,
  138. requestUrl,
  139. icon,
  140. name,
  141. description: name || "-",
  142. amountRange: pickAmountRange(row),
  143. processingTime,
  144. fee: pickFee(row),
  145. groupName,
  146. groupOrder,
  147. channelType: toNumber(row.type ?? row.channelType ?? row.payTypeType),
  148. bankValid: toNumber(row.bankValid) ?? 0,
  149. rate: toNumber(row.rate),
  150. });
  151. }
  152. return output;
  153. }
  154. /** 支付展示/提交金额 = 标价 × 通道 `rate`;`rate` 为 null 或非有限数时按 1。金额最多保留两位小数。 */
  155. export function payAmountWithChannelRate(basePrice: number, rate: number | null): number {
  156. if (!Number.isFinite(basePrice) || basePrice < 0) return 0;
  157. const mult = rate != null && Number.isFinite(rate) ? rate : 1;
  158. const product = basePrice * mult;
  159. if (!Number.isFinite(product)) return 0;
  160. return Math.round(product * 100) / 100;
  161. }
  162. /**
  163. * 有二级「选择付款方式」且已选中时,用该项的 `rate`;
  164. * 否则用 `/remittance/channel/list` 外层通道的 `rate`。
  165. */
  166. export function resolvePayRateMultiplier(input: {
  167. bankSelectorVisible: boolean;
  168. selectedBankCode: string;
  169. bankOptions: BankChannelOption[];
  170. channelRate: number | null;
  171. }): number | null {
  172. if (input.bankSelectorVisible && input.selectedBankCode.trim()) {
  173. const bank = input.bankOptions.find((b) => b.value === input.selectedBankCode);
  174. if (bank) return bank.rate;
  175. }
  176. return input.channelRate;
  177. }
  178. export async function fetchRemittanceChannels(): Promise<RemittanceChannel[]> {
  179. // 存款通道固定走 remittance 路径,避免误取到提款通道(/remit/channel/list)。
  180. const raw = await postRemittance<unknown>("/remittance/channel/list", {});
  181. return normalizeChannelList(raw);
  182. }
  183. function normalizeBankChannelList(raw: unknown): BankChannelOption[] {
  184. if (!raw || typeof raw !== "object") return [];
  185. const container = raw as Record<string, unknown>;
  186. const inner =
  187. container.data ??
  188. container.list ??
  189. container.rows ??
  190. container.records ??
  191. container.channels;
  192. const source = Array.isArray(inner) ? inner : [];
  193. const output: BankChannelOption[] = [];
  194. for (const item of source) {
  195. if (!item || typeof item !== "object") continue;
  196. const row = item as Record<string, unknown>;
  197. const valueFromCodeKeys: unknown[] = [row.code, row.channelCode, row.payCode];
  198. let value = "";
  199. for (const v of valueFromCodeKeys) {
  200. if (typeof v === "number" && Number.isFinite(v)) {
  201. value = String(v);
  202. break;
  203. }
  204. if (typeof v === "string" && v.trim()) {
  205. value = v.trim();
  206. break;
  207. }
  208. }
  209. if (!value) value = pickString(row, ["name", "currency", "enName"]);
  210. if (!value) continue;
  211. const label = pickString(row, ["name", "enName", "currency", "code"]) || value;
  212. const currency = pickString(row, ["currency", "code", "name"]) || "USDT";
  213. output.push({ value, label, currency, rate: toNumber(row.rate) });
  214. }
  215. return output;
  216. }
  217. export async function fetchBankChannelOptions(channelCode?: string): Promise<BankChannelOption[]> {
  218. const body = channelCode ? { channelCode } : {};
  219. const raw = await postRemittance<unknown>("/channel/bank/list", body);
  220. return normalizeBankChannelList(raw);
  221. }
  222. /**
  223. * 银联 / 电汇等走 `/telegraphic/pay` 的通道:金额、银行等参数放在请求体,不拼在 `.../pay/...` 路径后面。
  224. */
  225. export function isTelegraphicStylePayRequestUrl(requestUrl: string): boolean {
  226. const n = `/${requestUrl.replace(/^\/+|\/+$/g, "")}`.toLowerCase();
  227. return n.includes("telegraphic");
  228. }
  229. export type PayGoodsDetail = {
  230. goodsId: string;
  231. goodsNum: number;
  232. };
  233. export async function submitXfgPayOrder(input: {
  234. requestUrl: string;
  235. amount: number;
  236. bankCode?: string;
  237. code?: string;
  238. goodsDetails: PayGoodsDetail[];
  239. payName: string;
  240. payPhone: string;
  241. }): Promise<{ raw: unknown; resultUrl: string | null }> {
  242. const normalizedRequestUrl = `/${input.requestUrl.replace(/^\/+|\/+$/g, "")}`;
  243. // 仍以后端下发 requestUrl 为准(动态);仅当明显是提款路径时兜底,避免误打提款申请。
  244. const payRequestPath = /^\/withdraw(\/|$)/i.test(normalizedRequestUrl)
  245. ? "/xfgpay/pay"
  246. : normalizedRequestUrl;
  247. const amount = String(input.amount);
  248. const useBodyForPayParams = isTelegraphicStylePayRequestUrl(payRequestPath);
  249. const path = useBodyForPayParams
  250. ? payRequestPath
  251. : input.bankCode
  252. ? `${payRequestPath}/1/${encodeURIComponent(amount)}/${encodeURIComponent(input.bankCode)}/0`
  253. : `${payRequestPath}/1/${encodeURIComponent(amount)}/0`;
  254. const body = useBodyForPayParams
  255. ? {
  256. goodsDetails: input.goodsDetails,
  257. payName: input.payName,
  258. payPhone: input.payPhone,
  259. amount: input.amount,
  260. ...(input.code ? { code: input.code } : {}),
  261. ...(input.bankCode ? { bankCode: input.bankCode } : {}),
  262. }
  263. : {
  264. goodsDetails: input.goodsDetails,
  265. payName: input.payName,
  266. payPhone: input.payPhone,
  267. /** 与路径中的金额一致:标价×通道 rate 后的实付额;避免后端只读 body 时落到商品原价。 */
  268. amount: input.amount,
  269. };
  270. const data = await postRemittance<unknown>(path, body);
  271. return { raw: data, resultUrl: pickResultUrl(data) };
  272. }
  273. function pickResultUrl(raw: unknown): string | null {
  274. if (!raw || typeof raw !== "object") return null;
  275. const o = raw as Record<string, unknown>;
  276. const candidates: unknown[] = [
  277. o.result,
  278. o.url,
  279. o.payUrl,
  280. o.redirectUrl,
  281. ];
  282. if (o.data && typeof o.data === "object" && o.data !== null) {
  283. const d = o.data as Record<string, unknown>;
  284. candidates.push(d.result, d.url, d.payUrl, d.redirectUrl);
  285. }
  286. for (const item of candidates) {
  287. if (typeof item === "string" && item.trim()) return item.trim();
  288. }
  289. return null;
  290. }