实现企业银行账户与建行制单(corporate-bank-account-payment-order)
这个提交包含在:
@@ -51,6 +51,9 @@ const EXECUTED_REIMBURSEMENT_ID = "bbbbbbbb-2222-4222-8222-222222222222";
|
||||
const OWNER_ID = "77777777-7777-4777-8777-777777777777";
|
||||
const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
|
||||
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
|
||||
const LEGAL_ENTITY_ID = "12121212-1212-4121-8121-121212121212";
|
||||
const BANK_INSTITUTION_ID = "13131313-1313-4131-8131-131313131313";
|
||||
const PAYMENT_ACCOUNT_ID = "14141414-1414-4141-8141-141414141414";
|
||||
const TRAVEL_ORDER_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const SYNTHETIC_RECEIPT_PDF = Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF\n", "ascii");
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
||||
@@ -174,7 +177,13 @@ function buildReimbursement(overrides = {}) {
|
||||
id: OWNER_ID,
|
||||
employee_no: "EMP001",
|
||||
name: "测试员工",
|
||||
bank_name: "测试银行",
|
||||
legal_entity_id: LEGAL_ENTITY_ID,
|
||||
bank_name: "中国建设银行",
|
||||
bank_institution: {
|
||||
id: BANK_INSTITUTION_ID,
|
||||
code: "CCB",
|
||||
name: "中国建设银行",
|
||||
},
|
||||
bank_account_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
@@ -238,6 +247,21 @@ function buildPrisma(options = {}) {
|
||||
},
|
||||
receiptAttachments: options.receiptAttachments ?? defaultReceiptAttachments,
|
||||
reservedEntityIds: options.reservedEntityIds ?? [],
|
||||
paymentAccount: {
|
||||
id: PAYMENT_ACCOUNT_ID,
|
||||
legal_entity_id: LEGAL_ENTITY_ID,
|
||||
bank_institution_id: BANK_INSTITUTION_ID,
|
||||
account_name: "测试公司",
|
||||
account_number_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
`6217${"0".repeat(8)}1234`,
|
||||
),
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
legal_entity: { id: LEGAL_ENTITY_ID, code: "HYB", name: "测试公司" },
|
||||
bank_institution: { id: BANK_INSTITUTION_ID, code: "CCB", name: "中国建设银行" },
|
||||
...(options.paymentAccount ?? {}),
|
||||
},
|
||||
employees: options.employees ?? [
|
||||
{ id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] },
|
||||
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] },
|
||||
@@ -254,6 +278,9 @@ function buildPrisma(options = {}) {
|
||||
};
|
||||
|
||||
const tx = {
|
||||
paymentAccount: {
|
||||
findFirst: vi.fn(async () => ({ ...state.paymentAccount })),
|
||||
},
|
||||
paymentBatch: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
if (where.id !== state.batch.id) {
|
||||
@@ -967,9 +994,13 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(detailResponse.json().data.item.id).toBe(BATCH_ID);
|
||||
expect(detailResponse.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(detailResponse.json().data.item.paymentOrderExportBlockers).toEqual(expect.arrayContaining([
|
||||
{ code: "legacy_payment_account_snapshot_missing" },
|
||||
{ code: "legacy_payee_snapshot_missing", entityCode: "BX-20260429-0001" },
|
||||
]));
|
||||
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
|
||||
|
||||
await app.close();
|
||||
@@ -1002,7 +1033,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(response.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
|
||||
@@ -1150,17 +1181,17 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(tx.paymentBatchItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
snapshot_json: expect.objectContaining({
|
||||
payee: {
|
||||
payee: expect.objectContaining({
|
||||
name: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(response.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
|
||||
@@ -1210,7 +1241,8 @@ describe("payment batch reimbursement routes", () => {
|
||||
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.payeeBankAccount).toBe("decrypt_failed");
|
||||
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
|
||||
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled();
|
||||
@@ -1517,7 +1549,7 @@ describe("payment batch reimbursement routes", () => {
|
||||
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
|
||||
expect(detail.json().data.item.items[0]).toMatchObject({
|
||||
payeeName: "测试员工",
|
||||
bankName: "测试银行",
|
||||
bankName: "中国建设银行",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(state.batch.items[0].snapshot_json).toEqual({});
|
||||
@@ -2293,4 +2325,116 @@ describe("payment batch reimbursement routes", () => {
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("exports a reviewed CCB batch as BIFF8 and records the first export", async () => {
|
||||
const payerEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
`6217${"0".repeat(8)}1234`,
|
||||
);
|
||||
const payeeEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
);
|
||||
const { app, tx, state } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: {
|
||||
status: "reviewed",
|
||||
payment_account_snapshot_json: {
|
||||
accountName: "合成测试公司",
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
accountNumberMasked: "6217********1234",
|
||||
accountNumberEncrypted: payerEncrypted,
|
||||
},
|
||||
payment_order_export_count: 0,
|
||||
items: [buildPaymentBatch().items[0],].map((item) => ({
|
||||
...item,
|
||||
snapshot_json: {
|
||||
payee: {
|
||||
name: "测试员工",
|
||||
bankName: "中国建设银行",
|
||||
bankInstitutionCode: "CCB",
|
||||
bankAccountMasked: "6222********5678",
|
||||
bankAccountEncrypted: payeeEncrypted,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "导入企业网银" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.rawPayload.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(state.batch.payment_order_export_count).toBe(1);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "PAYMENT_ORDER_EXPORTED", extra: expect.objectContaining({ reason: "导入企业网银", exportCount: 1 }) }),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("permanently rejects payment-order export for a legacy batch without frozen accounts", async () => {
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "payment.order.export"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: { status: "reviewed", payment_account_snapshot_json: null },
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/payment-order`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "旧批次尝试" },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details.reason).toBe("payment_account_snapshot_missing");
|
||||
expect(tx.paymentBatch.update).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("reveals the frozen payee account to finance only after a reason and audits the view", async () => {
|
||||
const payeeEncrypted = encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
SYNTHETIC_BANK_ACCOUNT,
|
||||
);
|
||||
const { app, tx } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: "dept-1",
|
||||
permissionCodes: ["payment.batch.manage", "employee.bank.read"],
|
||||
roleCodes: ["finance"],
|
||||
},
|
||||
batch: {
|
||||
status: "reviewed",
|
||||
items: buildPaymentBatch().items.map((item) => ({
|
||||
...item,
|
||||
snapshot_json: { payee: { bankAccountEncrypted: payeeEncrypted } },
|
||||
})),
|
||||
},
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}/payee-bank/reveal`,
|
||||
headers: { authorization: "Bearer test" },
|
||||
payload: { reason: "人工转账核对" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.bankAccount).toBe(SYNTHETIC_BANK_ACCOUNT);
|
||||
expect(tx.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: "PAYMENT_BATCH_PAYEE_BANK_REVEALED", extra: expect.objectContaining({ reason: "人工转账核对", paymentBatchItemId: ITEM_ID }) }),
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户