借款登记支持上传原借入回单识别预填(09-17-company-loan-principal-repayment)
- 新增识别与保存接口:可读 PDF 本地解析,必要时有界调用现有回单 OCR;预填上下文加密绑定经办人、文件和 30 分钟 - 适配建行单位客户专用回单表格版式:按位置归行排序、竖排栏头、顶行无标签日期、大小写金额 - 拦截非最终、失败/处理中、多笔交易(含跨页无关联、编号冲突)、非人民币、公司支出和其他收款主体 - 金额、日期、编号整段校验;借款与原件同事务保存,失败回滚并清理对象;同一出借人原件唯一 - 保存绑定所选出借人版本;前端冻结核对时版本,迟到结果与切换账号不覆盖、不重发 - 测试:单测、隔离 HTTP/浏览器、变异;对账浏览器场景固定 UTC;五轮独立复核通过 - 无新增迁移 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -0,0 +1,353 @@
|
||||
const incoming = require("../../../src/services/loanIncomingReceipt");
|
||||
const { syntheticReceiptText, syntheticBankReceiptPdf, syntheticCcbIncomingPdf } = require("../../../../scripts/qa/synthetic-bank-receipt.cjs");
|
||||
|
||||
const config = { fieldEncryptionKey: Buffer.alloc(32, 9).toString("base64") };
|
||||
const credit = (options = {}) => syntheticReceiptText({ direction: "credit", status: "booked", amount: "50,000.00",
|
||||
reference: "SYN-IN-0001", bookedOn: "2025-03-14", extraLines: ["付款人名称 合成出借人", "收款人名称 合成公司"], ...options });
|
||||
const parse = (...texts) => incoming.finalizeResult(incoming.parseIncomingTexts(texts));
|
||||
|
||||
describe("incoming receipt field extraction", () => {
|
||||
test("reads amount, booking date, reference and parties from labelled lines", () => {
|
||||
const result = parse(credit());
|
||||
expect(result.blocks).toEqual([]);
|
||||
expect(result.direction).toBe("credit");
|
||||
expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001",
|
||||
referenceKind: "voucher", currency: "CNY", payerName: "合成出借人", payeeName: "合成公司" });
|
||||
expect(result.sources).toMatchObject({ amount: "pdf_text", receivedDate: "pdf_text", reference: "pdf_text" });
|
||||
expect(result.fieldIssues).toEqual({});
|
||||
});
|
||||
test("a stable serial number is preferred over a voucher number, kept as text", () => {
|
||||
const result = parse(credit({ extraLines: ["交易流水号 0012345678"] }));
|
||||
expect(result.fields.reference).toBe("0012345678");
|
||||
expect(result.fields.referenceKind).toBe("serial");
|
||||
});
|
||||
test("conflicting dates are left empty rather than choosing one", () => {
|
||||
const result = parse(credit({ extraLines: ["交易日期 2025-03-15"] }));
|
||||
expect(result.fields.receivedDate).toBeUndefined();
|
||||
expect(result.fieldIssues.receivedDate).toBe("ambiguous");
|
||||
});
|
||||
test("print stamps and capital amounts are never read as business values", () => {
|
||||
const result = parse(credit({ extraLines: ["打印日期 2026-10-09", "大写金额 伍万元整"] }));
|
||||
expect(result.fields.receivedDate).toBe("2025-03-14");
|
||||
expect(result.fields.amount).toBe("5000000");
|
||||
});
|
||||
test("pages repeating one transaction are merged; different transactions are rejected", () => {
|
||||
expect(parse(credit(), credit()).blocks).toEqual([]);
|
||||
const result = parse(credit(), credit({ amount: "20.00", reference: "SYN-IN-0002" }));
|
||||
expect(result.blocks).toContain("multiple_transactions");
|
||||
expect(result.fields.amount).toBeUndefined();
|
||||
});
|
||||
test.each([
|
||||
[{ final: false }, "non_final"], [{ status: "failed" }, "transaction_failed"],
|
||||
[{ status: "pending" }, "transaction_pending"], [{ currency: "USD" }, "non_cny"],
|
||||
])("restricted document %j is blocked", (options, block) => {
|
||||
expect(parse(credit(options)).blocks).toContain(block);
|
||||
});
|
||||
test("two receipts on one page are blocked", () => {
|
||||
expect(parse(credit() + "\n" + credit({ amount: "1.00" })).blocks).toContain("multiple_transactions");
|
||||
});
|
||||
test("missing required fields are reported, not invented", () => {
|
||||
const result = parse("中国建设银行单位客户专用回单\n贷\n摘要 借款");
|
||||
expect(result.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" });
|
||||
expect(result.fields).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("review counterexamples (2026-10-10)", () => {
|
||||
test("same amount with different voucher numbers is several transactions, not a missing reference", () => {
|
||||
const result = parse(credit({ reference: "SYNTHETIC-VOUCHER-A" }), credit({ reference: "SYNTHETIC-VOUCHER-B" }));
|
||||
expect(result.blocks).toContain("multiple_transactions");
|
||||
});
|
||||
test("a provider reporting different serial numbers with one amount is several transactions", () => {
|
||||
const result = incoming.parseIncomingTexts([""]);
|
||||
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-A" }],
|
||||
[{ Name: "金额", Value: "10.00" }, { Name: "流水号", Value: "OCR-B" }]]);
|
||||
expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions");
|
||||
});
|
||||
test("one transaction repeated across pages remains allowed", () => {
|
||||
expect(parse(credit(), credit()).blocks).toEqual([]);
|
||||
});
|
||||
test("a provider currency never clears a locally known one", () => {
|
||||
const local = incoming.parseIncomingTexts([credit()]);
|
||||
incoming.mergeProviderFields(local, [[{ Name: "币别", Value: "美元" }, { Name: "币种", Value: "USD" }]]);
|
||||
expect(incoming.finalizeResult(local).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"]));
|
||||
expect(local.fields.currency).toBe("CNY");
|
||||
});
|
||||
test("a locally known foreign currency blocks before any provider call", async () => {
|
||||
const createClient = vi.fn();
|
||||
const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked",
|
||||
currency: "USD", bookedOn: "not-a-date" }), { config, createClient });
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result.blocks).toContain("non_cny");
|
||||
});
|
||||
test("conflicting currencies within the document are a known contradiction", () => {
|
||||
expect(parse(credit({ extraLines: ["币种 USD"] })).blocks).toEqual(expect.arrayContaining(["non_cny", "currency_conflict"]));
|
||||
});
|
||||
test.each([["1,23.45"], ["1234.567"], ["50000.00USD"], ["1.2.3"], ["¥"]])("malformed amount %s is invalid, never truncated", (amount) => {
|
||||
const result = parse(credit({ amount }));
|
||||
expect(result.fields.amount).toBeUndefined();
|
||||
expect(result.fieldIssues.amount).toBe("invalid");
|
||||
});
|
||||
test.each([["¥1,234.50元", "123450"], ["人民币1234.5", "123450"], ["0.01", "1"], ["999,999,999,999.99", "99999999999999"]])(
|
||||
"well-formed amount %s is read whole", (amount, minor) => {
|
||||
expect(parse(credit({ amount })).fields.amount).toBe(minor);
|
||||
});
|
||||
test("the provider deadline stops later pages, not just the wait", async () => {
|
||||
let calls = 0;
|
||||
const BankSlipOCR = vi.fn(() => { calls += 1; return new Promise((resolve) => setTimeout(() => resolve({ BankSlipInfos: [] }), 30)); });
|
||||
const pass = await incoming.providerPass(config, Buffer.from("%PDF-synthetic"), 3, () => ({ BankSlipOCR }), { totalMs: 10 });
|
||||
expect(pass.status).toBe("failed");
|
||||
const atReturn = calls;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
expect(calls).toBe(atReturn);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CCB table layout (synthetic replica of the real borrow-in receipt)", () => {
|
||||
const inspect = async (options) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient: vi.fn() });
|
||||
test("reads every field from out-of-order table items without any provider call", async () => {
|
||||
const createClient = vi.fn();
|
||||
const result = await incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(), { config, createClient });
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ ocr: "not_needed", blocks: [], direction: "credit", documentKind: "bank_receipt", fieldIssues: {} });
|
||||
expect(result.fields).toMatchObject({ amount: "5000000", receivedDate: "2025-03-14", reference: "123456789AB12CDEFG3", referenceKind: "serial",
|
||||
currency: "CNY", payerName: "合成甲", payerAccount: "6222000011112222", payeeName: "合成公司", payeeAccount: "33050161000000000001" });
|
||||
});
|
||||
test("a vertical copy note level with the amount row neither pollutes the amount nor becomes a column header", async () => {
|
||||
const result = await inspect({ copyNote: "(收款人回单)(第一联)" });
|
||||
expect(result.fields.amount).toBe("5000000");
|
||||
expect(result.fields.payeeName).toBe("合成公司");
|
||||
});
|
||||
test("baseline jitter within one row still reads the row left to right", async () => {
|
||||
expect((await inspect({ dateDy: 1 })).fields.receivedDate).toBe("2025-03-14");
|
||||
expect((await inspect({ dateDy: -2 })).fields.receivedDate).toBe("2025-03-14");
|
||||
});
|
||||
test("two different unlabelled header dates are ambiguous", async () => {
|
||||
const result = await inspect({ extraItems: [["2025年03月15日", 150, 785]] });
|
||||
expect(result.fields.receivedDate).toBeUndefined();
|
||||
expect(result.fieldIssues.receivedDate).toBe("ambiguous");
|
||||
});
|
||||
test("an impossible header date is invalid, not shifted", async () => {
|
||||
const result = await inspect({ date: ["2025", "02", "30"] });
|
||||
expect(result.fieldIssues.receivedDate).toBe("invalid");
|
||||
});
|
||||
test("parties are only read when the payer column is left of the payee column", async () => {
|
||||
const swapped = await inspect({ headers: ["收款人", "付款人"] });
|
||||
expect(swapped.fields.payerName).toBeUndefined();
|
||||
expect(swapped.fields.payeeName).toBeUndefined();
|
||||
expect(swapped.fields.amount).toBe("5000000");
|
||||
});
|
||||
test("a malformed lower-case amount is invalid even in the table layout", async () => {
|
||||
const result = await inspect({ amount: "50,00.00" });
|
||||
expect(result.fields.amount).toBeUndefined();
|
||||
expect(result.fieldIssues.amount).toBe("invalid");
|
||||
});
|
||||
test("a non-final notice still blocks the table layout", async () => {
|
||||
const result = await inspect({ extraItems: [["本回执不作为收、付款方交易的最终依据。", 25, 575]] });
|
||||
expect(result.blocks).toContain("non_final");
|
||||
});
|
||||
});
|
||||
|
||||
describe("second review counterexamples (2026-10-10)", () => {
|
||||
const inspect = async (options, createClient = vi.fn()) => incoming.inspectIncomingPdf(await syntheticCcbIncomingPdf(options), { config, createClient });
|
||||
test.each([[["交易失败", 140, 650], ["交易状态", 37, 650]], [["处理中", 140, 650], ["交易状态", 37, 650]],
|
||||
[["交易的最终依据。", 140, 575], ["本回执不作为收、付款方", 25, 575]]])(
|
||||
"restrictions stored value-first are still read in reading order: %j", async (...extraItems) => {
|
||||
const result = await inspect({ extraItems });
|
||||
expect(result.blocks.some((b) => ["transaction_failed", "transaction_pending", "non_final"].includes(b))).toBe(true);
|
||||
});
|
||||
test("a labelled print date on the header row is never the booking date", async () => {
|
||||
const result = await inspect({ date: null, extraItems: [["打印日期:2026年10月08日", 140, 785]] });
|
||||
expect(result.fields.receivedDate).toBeUndefined();
|
||||
expect(result.fieldIssues.receivedDate).toBe("missing");
|
||||
});
|
||||
test("a labelled print date beside the unlabelled booking date leaves the booking date", async () => {
|
||||
expect((await inspect({ extraItems: [["打印日期:2026年10月08日", 450, 785]] })).fields.receivedDate).toBe("2025-03-14");
|
||||
});
|
||||
test.each([["2025-03-149"], ["2025-03-14/2025-04-14"]])("labelled date %s must be one whole date", (date) => {
|
||||
expect(parse(credit({ bookedOn: date })).fieldIssues.receivedDate).toBe("invalid");
|
||||
});
|
||||
async function readablePlusScanned() {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
const source = await PDFDocument.load(await syntheticCcbIncomingPdf());
|
||||
doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage();
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
test("a scanned page of another transaction after a readable page is read and blocked", async () => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "9.00" }, { Name: "流水号", Value: "OTHER-2" }] }));
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(BankSlipOCR).toHaveBeenCalledTimes(1);
|
||||
expect(BankSlipOCR.mock.calls[0][0].PdfPageNumber).toBe(2);
|
||||
expect(result.blocks).toContain("multiple_transactions");
|
||||
});
|
||||
test("an unverifiable extra page blocks when no provider is available", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a scanned repeat of the same transaction is accepted", async () => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" }, { Name: "流水号", Value: "123456789AB12CDEFG3" }] }));
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.blocks).toEqual([]);
|
||||
});
|
||||
test.each([
|
||||
["empty provider page", []],
|
||||
["unparseable serial", [{ Name: "流水号", Value: "?" }]],
|
||||
["unparseable amount", [{ Name: "金额", Value: "无法识别" }]],
|
||||
["same amount without any identifier", [{ Name: "金额", Value: "50,000.00" }]],
|
||||
])("a scanned page is not verified by %s", async (_label, fields) => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: fields }));
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a readable page with only the same amount and no identifier is not verified", async () => {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
for (const options of [{}, { serial: null }]) {
|
||||
const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options));
|
||||
doc.addPage((await doc.copyPages(source, [0]))[0]);
|
||||
}
|
||||
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a different secondary voucher number is another transaction even with the same serial", async () => {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
const source = await PDFDocument.load(await syntheticCcbIncomingPdf({ extraItems: [["凭证号 LOCAL-VOUCHER", 293, 702]] }));
|
||||
doc.addPage((await doc.copyPages(source, [0]))[0]); doc.addPage();
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "金额", Value: "50,000.00" },
|
||||
{ Name: "流水号", Value: "123456789AB12CDEFG3" }, { Name: "凭证号", Value: "OTHER-VOUCHER" }] }));
|
||||
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.fields.referenceKind).toBe("serial");
|
||||
expect(result.blocks).toContain("multiple_transactions");
|
||||
});
|
||||
async function twoCcbPages(first, second) {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create();
|
||||
for (const options of [first, second]) {
|
||||
const source = await PDFDocument.load(await syntheticCcbIncomingPdf(options));
|
||||
doc.addPage((await doc.copyPages(source, [0]))[0]);
|
||||
}
|
||||
return Buffer.from(await doc.save());
|
||||
}
|
||||
const voucherOnly = (voucher) => ({ serial: null, extraItems: [[`凭证号 ${voucher}`, 293, 702]] });
|
||||
test("a serial on one page and an unrelated voucher on the other are not linked", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A" }, voucherOnly("VOUCHER-B")), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("the same text under different identifier kinds is not a link", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SAME-0001" }, voucherOnly("SAME-0001")), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a page carrying both numbers links a later page that repeats one of them", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIAL-A", extraItems: [["凭证号 VOUCHER-B", 293, 702]] },
|
||||
voucherOnly("VOUCHER-B")), { config, createClient: () => null });
|
||||
expect(result.blocks).toEqual([]);
|
||||
});
|
||||
test.each([["SERIAL-A/001"], ["SERIAL-A?"], ["A".repeat(70)]])("identifier %s is invalid as a whole, never cut to a prefix", (reference) => {
|
||||
const result = parse(credit({ reference }));
|
||||
expect(result.fields.reference).toBeUndefined();
|
||||
expect(result.fieldIssues.reference).toBe("invalid");
|
||||
});
|
||||
test("same prefix with different suffixes cannot collapse into one identifier", async () => {
|
||||
const result = await incoming.inspectIncomingPdf(await twoCcbPages({ serial: "SERIALA/001" }, { serial: "SERIALA/002" }), { config, createClient: () => null });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
expect(result.fields.reference).toBeUndefined();
|
||||
});
|
||||
test("a provider identifier with an invalid suffix does not verify a page", async () => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: [{ Name: "流水号", Value: "123456789AB12CDEFG3?" }] }));
|
||||
const result = await incoming.inspectIncomingPdf(await readablePlusScanned(), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(result.blocks).toContain("pages_unverified");
|
||||
});
|
||||
test("a provider transaction different from the text layer is several transactions", () => {
|
||||
const result = incoming.parseIncomingTexts([credit({ reference: "LOCAL-A", amount: "10.00" })]);
|
||||
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "20.00" }, { Name: "凭证号", Value: "OCR-B" }]]);
|
||||
expect(incoming.finalizeResult(result).blocks).toContain("multiple_transactions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded provider fallback", () => {
|
||||
const providerFields = [{ Name: "金额", Value: "1,234.50" }, { Name: "记账日期", Value: "2025年1月2日" },
|
||||
{ Name: "流水号", Value: "OCR-77" }, { Name: "币别", Value: "人民币" }];
|
||||
test("a readable PDF with all required fields makes no provider call", async () => {
|
||||
const createClient = vi.fn();
|
||||
const result = await incoming.inspectIncomingPdf(await syntheticBankReceiptPdf({ direction: "credit", status: "booked",
|
||||
reference: "SYN-PDF-1", bookedOn: "2025-02-03", amount: "88.00" }), { config, createClient });
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(result.ocr).toBe("not_needed");
|
||||
expect(result.fields).toMatchObject({ amount: "8800", receivedDate: "2025-02-03", reference: "SYN-PDF-1" });
|
||||
});
|
||||
test("an unreadable PDF uses each page at most once and only fills gaps", async () => {
|
||||
const BankSlipOCR = vi.fn(async () => ({ BankSlipInfos: providerFields }));
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create(); doc.addPage(); doc.addPage();
|
||||
const result = await incoming.inspectIncomingPdf(Buffer.from(await doc.save()), { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(BankSlipOCR).toHaveBeenCalledTimes(2);
|
||||
expect(result.ocr).toBe("used");
|
||||
expect(result.fields).toMatchObject({ amount: "123450", receivedDate: "2025-01-02", reference: "OCR-77" });
|
||||
expect(result.sources.amount).toBe("ocr");
|
||||
});
|
||||
test("provider unavailable or failing leaves manual completion without retries", async () => {
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const doc = await PDFDocument.create(); doc.addPage();
|
||||
const pdf = Buffer.from(await doc.save());
|
||||
expect((await incoming.inspectIncomingPdf(pdf, { config, createClient: () => null })).ocr).toBe("unavailable");
|
||||
const BankSlipOCR = vi.fn(async () => { throw new Error("quota"); });
|
||||
const failed = await incoming.inspectIncomingPdf(pdf, { config, createClient: () => ({ BankSlipOCR }) });
|
||||
expect(BankSlipOCR).toHaveBeenCalledTimes(1);
|
||||
expect(failed.ocr).toBe("failed");
|
||||
expect(failed.fieldIssues).toMatchObject({ amount: "missing", receivedDate: "missing", reference: "missing" });
|
||||
});
|
||||
test("provider text can add restrictions but never overwrite local values", () => {
|
||||
const result = incoming.parseIncomingTexts([credit()]);
|
||||
incoming.mergeProviderFields(result, [[{ Name: "金额", Value: "1.00" }, { Name: "备注", Value: "本回执不作为收付款方交易的最终依据" }]]);
|
||||
incoming.finalizeResult(result);
|
||||
expect(result.blocks).toContain("non_final");
|
||||
expect(result.fields.amount).toBeUndefined();
|
||||
expect(result.fieldIssues.amount).toBe("ambiguous");
|
||||
});
|
||||
test.each(["not a pdf", ""])("rejects non-PDF input %j", async (text) => {
|
||||
await expect(incoming.inspectIncomingPdf(Buffer.from(text), { config })).rejects.toMatchObject({ details: { reasonCode: "loan.incoming_receipt_invalid" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sealed prefill context", () => {
|
||||
const base = { v: 1, purpose: incoming.PURPOSE, parserVersion: incoming.PARSER_VERSION, actorId: "actor-1", fileSha256: "a".repeat(64),
|
||||
issuedAt: 1000, expiresAt: 1000 + incoming.CONTEXT_TTL_MS, fields: {}, blocks: [] };
|
||||
test("opens only for the same actor, file and lifetime", () => {
|
||||
const token = incoming.sealContext(config, base);
|
||||
expect(incoming.openContext(config, token, { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 }).actorId).toBe("actor-1");
|
||||
for (const binding of [{ actorId: "actor-2", fileSha256: "a".repeat(64), now: 2000 },
|
||||
{ actorId: "actor-1", fileSha256: "b".repeat(64), now: 2000 },
|
||||
{ actorId: "actor-1", fileSha256: "a".repeat(64), now: base.expiresAt + 1 }]) {
|
||||
expect(() => incoming.openContext(config, token, binding)).toThrow();
|
||||
}
|
||||
});
|
||||
test("tampered, foreign-purpose or plain tokens are rejected", () => {
|
||||
const token = incoming.sealContext(config, base);
|
||||
const binding = { actorId: "actor-1", fileSha256: "a".repeat(64), now: 2000 };
|
||||
expect(() => incoming.openContext(config, `${token.slice(0, -4)}AAAA`, binding)).toThrow();
|
||||
expect(() => incoming.openContext(config, incoming.sealContext(config, { ...base, purpose: "other" }), binding)).toThrow();
|
||||
expect(() => incoming.openContext(config, Buffer.from(JSON.stringify(base)).toString("base64"), binding)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("save guards", () => {
|
||||
const lender = { legal_entity: { name: "合成公司" } };
|
||||
test("company payment out or money to another entity is not a borrow-in", () => {
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成公司" } }, lender)).toThrow();
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "另一公司" } }, lender)).toThrow();
|
||||
expect(() => incoming.assertSavable({ blocks: ["non_final"], direction: "credit", fields: {} }, lender)).toThrow();
|
||||
});
|
||||
test("a credit to the company, or a lender's own debit slip, can be saved", () => {
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: "credit", fields: { payeeName: "合成公司" } }, lender)).not.toThrow();
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: "debit", fields: { payerName: "合成出借人" } }, lender)).not.toThrow();
|
||||
expect(() => incoming.assertSavable({ blocks: [], direction: null, fields: {} }, lender)).not.toThrow();
|
||||
});
|
||||
test("records corrected field names only", () => {
|
||||
const context = { fields: { amount: "5000000", receivedDate: "2025-03-14", reference: "SYN-IN-0001" } };
|
||||
expect(incoming.corrections(context, { originalPrincipal: "50000.00", receivedDate: "2025-03-15", originReference: "SYN-IN-0001" }))
|
||||
.toEqual(["receivedDate"]);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户