const FIELD_ALIASES = { amount: ["小写金额", "交易金额", "转账金额", "付款金额", "金额"], currency: ["币别", "币种"], payerAccount: ["付款人账号", "付款账号"], payeeAccount: ["收款人账号", "收款账号"], transactionStatus: ["单据状态", "交易状态"], reference: ["流水号", "交易流水号", "凭证号", "凭证号码"], }; function normalizeText(value) { return typeof value === "string" ? value.normalize("NFKC").trim() : ""; } function parseReceiptMinorUnits(value) { const text = normalizeText(value).replace(/^[¥¥]\s*/, "").replace(/\s*元$/, ""); if (!/^(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d{1,2})?$/.test(text)) return null; const [whole, fraction = ""] = text.replace(/,/g, "").split("."); if (whole.length > 12) return null; return (BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"))).toString(); } function normalizeAccount(value) { const text = normalizeText(value).replace(/\s/g, ""); return /^\d{6,40}$/.test(text) ? text : null; } function normalizeCurrency(value) { const text = normalizeText(value).toUpperCase(); if (["人民币", "人民币元", "RMB", "CNY"].includes(text)) return "CNY"; return /^[A-Z]{3}$/.test(text) ? text : null; } function normalizeStatus(value) { const text = normalizeText(value); if (["交易成功", "转账成功", "付款成功", "支付成功"].includes(text)) return "success"; if (["交易失败", "转账失败", "付款失败", "支付失败", "已撤销", "已冲正", "已退汇"].includes(text)) return "failed"; if (["已受理", "受理成功", "处理中", "待处理"].includes(text)) return "pending"; return null; } function normalizeBankSlip(fields) { const result = { amount: null, currency: null, payerAccount: null, payeeAccount: null, transactionStatus: null, transactionStatusRaw: null, reference: null, issues: [], }; if (!Array.isArray(fields) || fields.length === 0 || fields.length > 200) { result.issues.push("response_invalid"); return result; } const parsers = { amount: parseReceiptMinorUnits, currency: normalizeCurrency, payerAccount: normalizeAccount, payeeAccount: normalizeAccount, transactionStatus: normalizeStatus, reference: (value) => normalizeText(value) || null, }; for (const [key, aliases] of Object.entries(FIELD_ALIASES)) { let entries = fields.filter((field) => aliases.includes(normalizeText(field?.Name)) && normalizeText(field?.Value)); if (key === "reference" && entries.length) { const preferred = aliases.find((alias) => entries.some((field) => normalizeText(field.Name) === alias)); entries = entries.filter((field) => normalizeText(field.Name) === preferred); } const names = entries.map((field) => normalizeText(field.Name)); const values = entries.map((field) => parsers[key](field.Value)); if (key === "transactionStatus") { if (values.includes("failed")) result.issues.push("transaction_failed"); if (values.includes("pending")) result.issues.push("transaction_pending"); } if (new Set(names).size !== names.length || new Set(values).size > 1) { result.issues.push(`${key}_ambiguous`); } else if (values.length && values[0] !== null) { result[key] = values[0]; // The exact bank wording is kept: several distinct bank states share the // normalized "failed", but only some prove that no money left the account. // Several bank wordings normalize to the same value (e.g. 交易失败 and 已撤销 // both become "failed"), so the raw wording is kept only when every status // field carries the identical text; otherwise it stays null (fail closed). if (key === "transactionStatus") { const raws = new Set(entries.map((field) => normalizeText(field.Value))); result.transactionStatusRaw = raws.size === 1 ? [...raws][0] : null; } } else { result.issues.push(`${key}_unresolved`); } } return result; } function matchBankSlip(receipt, candidates) { const finish = (status, reasons, itemId = null) => ({ status, itemId, reasons, bankVerified: false }); if (receipt.transactionStatus === "failed") return finish("mismatch", ["transaction_failed"]); const unresolved = [...receipt.issues]; if (receipt.transactionStatus !== "success") unresolved.push("transaction_not_confirmed"); if (unresolved.length) return finish("needs_review", [...new Set(unresolved)]); if (!Array.isArray(candidates) || !candidates.length) return finish("needs_review", ["candidates_missing"]); const comparisons = candidates.map((candidate) => { const expected = { amount: parseReceiptMinorUnits(candidate.amount), currency: normalizeCurrency(candidate.currency), payerAccount: normalizeAccount(candidate.payerAccount), payeeAccount: normalizeAccount(candidate.payeeAccount), }; const invalid = !candidate.id || Object.values(expected).some((value) => value === null); const reasons = []; if (expected.amount !== receipt.amount) reasons.push("amount_mismatch"); if (expected.currency !== receipt.currency) reasons.push("currency_mismatch"); if (expected.payerAccount !== receipt.payerAccount) reasons.push("payer_account_mismatch"); if (expected.payeeAccount !== receipt.payeeAccount) reasons.push("payee_account_mismatch"); return { id: candidate.id, invalid, reasons }; }); if (comparisons.some((comparison) => comparison.invalid)) return finish("needs_review", ["candidate_invalid"]); const matches = comparisons.filter((comparison) => comparison.reasons.length === 0); if (matches.length === 1) return finish("matched", [], matches[0].id); if (matches.length > 1) return finish("needs_review", ["multiple_candidates"]); return finish("mismatch", [...new Set(comparisons.flatMap((comparison) => comparison.reasons))]); } module.exports = { normalizeBankSlip, matchBankSlip, parseReceiptMinorUnits };