const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); const { encryptStringOrThrow } = require("../../../src/services/crypto"); const { PaymentOrderXlsError, buildCustomerSerial, buildPaymentOrderRows, generatePaymentOrderXls, } = require("../../../src/services/paymentOrderXls"); const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); const config = { fieldEncryptionKey: FIELD_ENCRYPTION_KEY }; const encrypt = (value) => encryptStringOrThrow(config, value); function buildBatch(overrides = {}) { return { batch_no: "PB-20260802-002", payment_account_snapshot_json: { bankInstitutionCode: "CCB", accountName: "合成测试公司", bankName: "中国建设银行", accountNumberEncrypted: encrypt("6217000000000012345"), }, items: [ { id: "b", entity_code: "BX-20260802-0002", amount: "2.00", snapshot_json: { payee: { name: "乙", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000056789") } }, }, { id: "a", entity_code: "BX-20260802-0001", amount: "1.20", snapshot_json: { payee: { name: "甲", bankName: "中国建设银行", bankInstitutionCode: "CCB", bankAccountEncrypted: encrypt("6222000000000012345") } }, }, ], ...overrides, }; } describe("payment order BIFF8 generator", () => { it("keeps reimbursement-purpose remarks and separate transfers for the same frozen payee account", () => { const batch = buildBatch(); for (const item of batch.items) { item.snapshot_json.payee.name = " 合成收款人 "; item.snapshot_json.payee.bankAccountEncrypted = batch.items[0].snapshot_json.payee.bankAccountEncrypted; item.snapshot_json.submitter = { name: "合成提交人" }; item.owner = { name: "已改名归属人" }; } const result = buildPaymentOrderRows(config, batch); expect(result.rows.map((row) => row.slice(10, 12))).toEqual([ ["报销款", "报销款 BX-20260802-0001"], ["报销款", "报销款 BX-20260802-0002"], ]); expect(result.rows.map((row) => row[5])).toEqual(["合成收款人", "合成收款人"]); expect(result.rows[0][4]).toBe(result.rows[1][4]); expect(result.rows.map((row) => row[9])).toEqual([1.2, 2]); expect(result.serials).toEqual(["PB-20260802-002-R001", "PB-20260802-002-R002"]); expect(result.totalAmount).toBe("3.20"); }); it("exports principal repayment as one transfer with decrypted account name and repayment purpose", () => { const batch = buildBatch({ entity_type: "loan_repayment" }); batch.items = [batch.items[0]]; batch.items[0].entity_code = "HK-SYNTHETIC"; batch.items[0].snapshot_json.payee.name = "合***"; batch.items[0].snapshot_json.payee.accountNameEncrypted = encrypt("合成出借人"); const row = buildPaymentOrderRows(config, batch).rows[0]; expect(row[5]).toBe("合成出借人"); expect(row.slice(10, 12)).toEqual(["还款", "归还借款本金"]); batch.items[0].snapshot_json.payee.accountNameEncrypted = "invalid"; expect(() => buildPaymentOrderRows(config, batch)).toThrow(); }); it("preserves complete names and document codes without truncation", () => { const batch = buildBatch(); const name = "合成姓名".repeat(20); const code = "BX-20260802-000123456789"; batch.items = [batch.items[0]]; batch.items[0].snapshot_json.payee.name = ` ${name} `; batch.items[0].entity_code = ` ${code} `; const row = buildPaymentOrderRows(config, batch).rows[0]; expect(row[5]).toBe(name); expect(row[11]).toBe(`报销款 ${code}`); }); it.each([ ["payee_name_missing", (item) => { item.snapshot_json.payee.name = " "; }], ["entity_code_missing", (item) => { item.entity_code = " "; }], ])("rejects the entire export for %s", (code, invalidate) => { const batch = buildBatch(); invalidate(batch.items[0]); expect(() => buildPaymentOrderRows(config, batch)).toThrow(expect.objectContaining({ code })); }); it("generates a BIFF8 workbook with stable rows, totals and customer serials", () => { const first = generatePaymentOrderXls(config, buildBatch()); const second = generatePaymentOrderXls(config, buildBatch()); expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1"); expect(first.rowCount).toBe(2); expect(first.serials).toEqual(second.serials); expect(first.totalAmount).toBe("3.20"); }); it("round-trips the bank template with exact text accounts, headers and decimal amounts", () => { const batch = buildBatch(); const payerAccount = "000123456789012345678"; const payeeAccount = "000987654321098765432"; batch.payment_account_snapshot_json.accountNumberEncrypted = encrypt(payerAccount); batch.items[1].snapshot_json.payee.bankAccountEncrypted = encrypt(payeeAccount); const generated = generatePaymentOrderXls(config, batch); const templatePath = path.resolve(__dirname, "../../../src/assets/payment-order/ccb-payment-order-template.xls"); const readback = spawnSync(process.env.PAYMENT_ORDER_PYTHON_BIN ?? "python3", ["-c", [ "import json,sys,xlrd", "book=xlrd.open_workbook(file_contents=sys.stdin.buffer.read(),formatting_info=True)", "template=xlrd.open_workbook(sys.argv[1],formatting_info=True)", "sheet=book.sheet_by_index(0)", "source=template.sheet_by_index(0)", "rows=[row for row in range(1,sheet.nrows) if sheet.cell_value(row,0) != '']", "print(json.dumps({'biff':book.biff_version,'sheets':book.sheet_names(),'columns':sheet.ncols,'headerMatches':sheet.row_values(0)==source.row_values(0),'widthsMatch':all(sheet.computed_column_width(col)==source.computed_column_width(col) for col in range(15)),'rows':[sheet.row_values(row) for row in rows],'textTypes':[[sheet.cell_type(row,col) for col in [1,3,4,8,10,11]] for row in rows],'amountFormats':[book.format_map[book.xf_list[sheet.cell_xf_index(row,9)].format_key].format_str for row in rows]}))", ].join("\n"), templatePath], { input: generated.buffer, maxBuffer: 1024 * 1024 }); expect(readback.stderr.toString("utf8")).toBe(""); expect(readback.status).toBe(0); const parsed = JSON.parse(readback.stdout.toString("utf8")); expect(parsed).toMatchObject({ biff: 80, sheets: ["Sheet1", "Sheet2", "Sheet3"], columns: 15, headerMatches: true, widthsMatch: true, textTypes: [[1, 1, 1, 1, 1, 1], [1, 1, 1, 1, 1, 1]], amountFormats: ["0.00", "0.00"], }); expect(parsed.rows).toHaveLength(2); expect(parsed.rows[0]).toEqual([ "1", payerAccount, "合成测试公司", "01", payeeAccount, "甲", "中国建设银行", "", "PB-20260802-002-R001", 1.2, "报销款", "报销款 BX-20260802-0001", "0", "", "", ]); expect(parsed.rows[1][9]).toBe(2); expect(parsed.rows[1].slice(10, 12)).toEqual(["报销款", "报销款 BX-20260802-0002"]); }); it("fails the whole export and reports every blocking payee issue", () => { const batch = buildBatch(); batch.items[1].snapshot_json.payee.bankInstitutionCode = "OTHER"; batch.items[0].snapshot_json.payee.bankAccountEncrypted = null; try { generatePaymentOrderXls(config, batch); throw new Error("expected payment order generation to fail"); } catch (error) { expect(error).toBeInstanceOf(PaymentOrderXlsError); expect(error.code).toBe("payee_bank_not_supported"); expect(error.details.blockers).toEqual(expect.arrayContaining([ { code: "payee_bank_not_supported", entityCode: "BX-20260802-0001" }, { code: "payee_account_unavailable", entityCode: "BX-20260802-0002" }, ])); } }); it("does not invent a bank display name when the frozen snapshot is incomplete", () => { const batch = buildBatch(); delete batch.items[0].snapshot_json.payee.bankName; expect(() => generatePaymentOrderXls(config, batch)).toThrow(expect.objectContaining({ code: "payee_bank_name_missing", details: { blockers: expect.arrayContaining([ { code: "payee_bank_name_missing", entityCode: "BX-20260802-0002" }, ]), }, })); }); it("validates customer serial length and character set without truncation", () => { expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-R001"); expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError); expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError); }); it("fails closed when the fixed Python runtime is unavailable", () => { let caught; try { generatePaymentOrderXls(config, buildBatch(), { pythonBin: "/definitely-missing/payment-order-python", }); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(PaymentOrderXlsError); expect(caught).toMatchObject({ code: "payment_order_generator_unavailable" }); expect(caught.diagnostic).toContain("server/requirements/payment-order-biff8.txt"); expect(caught.diagnostic).toContain("PAYMENT_ORDER_PYTHON_BIN"); expect(JSON.stringify(caught)).not.toContain("payment-order-biff8.txt"); }); it("fails closed when the fixed Python dependencies are unavailable", () => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-dependency-test-")); try { const fakePython = path.join(temporaryRoot, "python"); fs.writeFileSync( fakePython, "#!/bin/sh\nprintf '%s\\n' '[payment-order-template][ERROR] runtime_dependency_unavailable' >&2\nexit 70\n", { mode: 0o700 }, ); let caught; try { generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython }); } catch (error) { caught = error; } expect(caught).toMatchObject({ code: "payment_order_generator_unavailable" }); expect(caught.diagnostic).toContain("server/requirements/payment-order-biff8.txt"); expect(JSON.stringify(caught)).not.toContain("PAYMENT_ORDER_PYTHON_BIN"); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); it("fails closed when the controlled template hash drifts", () => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-template-test-")); try { const scriptDirectory = path.join(temporaryRoot, "src", "scripts"); const assetDirectory = path.join(temporaryRoot, "src", "assets", "payment-order"); fs.mkdirSync(scriptDirectory, { recursive: true }); fs.mkdirSync(assetDirectory, { recursive: true }); const scriptPath = path.resolve(__dirname, "../../../src/scripts/fill-payment-order-template.py"); const templatePath = path.resolve(__dirname, "../../../src/assets/payment-order/ccb-payment-order-template.xls"); const copiedScriptPath = path.join(scriptDirectory, "fill-payment-order-template.py"); const copiedTemplatePath = path.join(assetDirectory, "ccb-payment-order-template.xls"); fs.copyFileSync(scriptPath, copiedScriptPath); const template = fs.readFileSync(templatePath); template[template.length - 1] ^= 1; fs.writeFileSync(copiedTemplatePath, template); expect(() => generatePaymentOrderXls(config, buildBatch(), { scriptPath: copiedScriptPath, })).toThrow(expect.objectContaining({ code: "payment_order_template_unavailable" })); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); it("does not copy child stderr or account payload into the exposed error", () => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-stderr-test-")); try { const fakePython = path.join(temporaryRoot, "python"); fs.writeFileSync(fakePython, "#!/bin/sh\ntee /dev/stderr >/dev/null\nexit 70\n", { mode: 0o700 }); let caught; try { generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython }); } catch (error) { caught = error; } expect(caught).toBeInstanceOf(PaymentOrderXlsError); expect(caught.code).toBe("payment_order_generation_failed"); expect(JSON.stringify(caught)).not.toContain("6217000000000012345"); expect(JSON.stringify(caught)).not.toContain("6222000000000012345"); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); it.each([ ["timeout", "#!/bin/sh\nwhile :; do :; done\n", { timeout: 20 }], [ "oversized output", "#!/bin/sh\ni=0\nwhile [ \"$i\" -lt 5000 ]; do printf X; i=$((i + 1)); done\n", { maxBuffer: 1024 }, ], ])("fails closed on child %s", (_label, script, options) => { const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "payment-order-process-test-")); try { const fakePython = path.join(temporaryRoot, "python"); fs.writeFileSync(fakePython, script, { mode: 0o700 }); expect(() => generatePaymentOrderXls(config, buildBatch(), { pythonBin: fakePython, ...options })) .toThrow(expect.objectContaining({ code: "payment_order_generation_failed" })); } finally { fs.rmSync(temporaryRoot, { recursive: true, force: true }); } }); }); it("keeps customer serial and business row identity with an annual PB and mixed source formats", () => { const batch = buildBatch({ batch_no: "PB-2026-0001" }); batch.items[0].entity_code = "BX-202609-0001"; const original = structuredClone(batch); const rows = buildPaymentOrderRows(config, batch); expect(rows.serials).toEqual(["PB-2026-0001-R001", "PB-2026-0001-R002"]); const reversed = buildPaymentOrderRows(config, { ...batch, items: [...batch.items].reverse() }); expect(reversed).toEqual(rows); expect(batch).toEqual(original); const workbook = generatePaymentOrderXls(config, batch); expect(workbook.serials).toEqual(rows.serials); expect(workbook.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1"); });