实现企业银行账户与建行制单(corporate-bank-account-payment-order)
这个提交包含在:
@@ -2,6 +2,9 @@ const Fastify = require('fastify');
|
||||
|
||||
const meRoutes = require('../../../src/routes/me');
|
||||
const { AppError } = require('../../../src/utils/AppError');
|
||||
const { encryptStringOrThrow } = require('../../../src/services/crypto');
|
||||
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString('base64');
|
||||
|
||||
function createPrismaState(overrides = {}) {
|
||||
const auditEntries = [];
|
||||
@@ -18,6 +21,7 @@ function createPrismaState(overrides = {}) {
|
||||
emergency_contact_phone: null,
|
||||
bank_account_encrypted: '',
|
||||
bank_name: '',
|
||||
bank_institution: null,
|
||||
avatar_path: null,
|
||||
id_number_encrypted: null,
|
||||
notification_push_enabled: true,
|
||||
@@ -53,6 +57,7 @@ function createPrismaState(overrides = {}) {
|
||||
async function buildApp(prisma) {
|
||||
const app = Fastify();
|
||||
app.decorate('prisma', prisma);
|
||||
app.decorate('config', { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
|
||||
app.decorate('authenticate', async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({
|
||||
@@ -130,8 +135,12 @@ describe('me routes', () => {
|
||||
const { prisma } = createPrismaState({
|
||||
emergency_contact_name: '联系人',
|
||||
emergency_contact_phone: '13900000000',
|
||||
bank_account_encrypted: 'ENC',
|
||||
bank_name: '招商银行',
|
||||
bank_account_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
|
||||
'6222000012345678',
|
||||
),
|
||||
bank_name: '中国建设银行',
|
||||
bank_institution: { id: 'bank-1', code: 'CCB', name: '中国建设银行' },
|
||||
avatar_path: null,
|
||||
id_number_encrypted: null,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const paymentAccountsRoutes = require("../../../src/routes/paymentAccounts");
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const ACCOUNT_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const LEGAL_ENTITY_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const BANK_ID = "44444444-4444-4444-8444-444444444444";
|
||||
const FIELD_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
||||
const SYNTHETIC_ACCOUNT = "6217000000000012345";
|
||||
|
||||
function buildRecord(overrides = {}) {
|
||||
return {
|
||||
id: ACCOUNT_ID,
|
||||
legal_entity_id: LEGAL_ENTITY_ID,
|
||||
bank_institution_id: BANK_ID,
|
||||
account_name: "合成测试公司",
|
||||
account_number_encrypted: encryptStringOrThrow({ fieldEncryptionKey: FIELD_ENCRYPTION_KEY }, SYNTHETIC_ACCOUNT),
|
||||
enabled: true,
|
||||
archived_at: null,
|
||||
created_at: new Date("2026-08-02T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-08-02T00:00:00.000Z"),
|
||||
legal_entity: { id: LEGAL_ENTITY_ID, code: "TEST", name: "合成测试公司" },
|
||||
bank_institution: { id: BANK_ID, code: "CCB", name: "中国建设银行" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(permissionCodes) {
|
||||
const app = Fastify();
|
||||
const actor = { id: ACTOR_ID, roleCodes: [], permissionCodes };
|
||||
const record = buildRecord();
|
||||
const auditCreate = vi.fn(async () => ({ id: "audit" }));
|
||||
const notificationCreateMany = vi.fn(async ({ data }) => ({ count: data.length }));
|
||||
const paymentAccountCreate = vi.fn(async ({ data }) => buildRecord({ ...data }));
|
||||
const tx = {
|
||||
legalEntity: { findFirst: vi.fn(async () => ({ id: LEGAL_ENTITY_ID, name: "合成测试公司" })) },
|
||||
bankInstitution: { findFirst: vi.fn(async () => ({ id: BANK_ID, code: "CCB", name: "中国建设银行" })) },
|
||||
paymentAccount: {
|
||||
create: paymentAccountCreate,
|
||||
update: vi.fn(),
|
||||
findFirst: vi.fn(async () => record),
|
||||
},
|
||||
auditLog: { create: auditCreate },
|
||||
employeeRole: { findMany: vi.fn(async () => [{ employee_id: ACTOR_ID }]) },
|
||||
notification: { createMany: notificationCreateMany },
|
||||
};
|
||||
app.decorate("config", { fieldEncryptionKey: FIELD_ENCRYPTION_KEY });
|
||||
app.decorate("prisma", {
|
||||
bankInstitution: { findMany: vi.fn(async () => [{ id: BANK_ID, code: "CCB", name: "中国建设银行" }]) },
|
||||
paymentAccount: {
|
||||
findMany: vi.fn(async () => [record]),
|
||||
findUnique: vi.fn(async () => ({ id: ACCOUNT_ID, account_number_encrypted: record.account_number_encrypted })),
|
||||
},
|
||||
auditLog: { create: auditCreate },
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
});
|
||||
app.decorate("authenticate", async (request) => { request.employee = actor; });
|
||||
app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes("*") || employee.permissionCodes.includes(permission));
|
||||
app.decorate("requirePermission", (permission) => async (request) => {
|
||||
request.employee = actor;
|
||||
if (!app.hasPermission(actor, permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ requestId: request.id, error: { code: error.code ?? "SYS_001", message: error.message, details: error.details ?? {} } }));
|
||||
await app.register(paymentAccountsRoutes, { prefix: "/api" });
|
||||
return { app, auditCreate, notificationCreateMany, paymentAccountCreate };
|
||||
}
|
||||
|
||||
describe("payment account routes", () => {
|
||||
it("returns only a masked account on normal reads", async () => {
|
||||
const testApp = await buildApp(["payment.account.read"]);
|
||||
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.items[0].accountNumberMasked).toBe("6217***********2345");
|
||||
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_ACCOUNT);
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("requires a reason before revealing the full account and audits the successful view", async () => {
|
||||
const testApp = await buildApp(["payment.account.read_full"]);
|
||||
const missingReason = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: {} });
|
||||
expect(missingReason.statusCode).toBe(400);
|
||||
const response = await testApp.app.inject({ method: "POST", url: `/api/payment-accounts/${ACCOUNT_ID}/reveal`, payload: { reason: "银行制单核对" } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.accountNumber).toBe(SYNTHETIC_ACCOUNT);
|
||||
expect(testApp.auditCreate).toHaveBeenCalledWith({ data: expect.objectContaining({ action: "PAYMENT_ACCOUNT_SENSITIVE_VIEWED", extra: { reason: "银行制单核对" } }) });
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("creates an encrypted account with masked audit and notifies administrators", async () => {
|
||||
const testApp = await buildApp(["payment.account.manage"]);
|
||||
const response = await testApp.app.inject({ method: "POST", url: "/api/payment-accounts", payload: { legalEntityId: LEGAL_ENTITY_ID, bankInstitutionId: BANK_ID, accountName: "合成测试公司", accountNumber: SYNTHETIC_ACCOUNT } });
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(testApp.paymentAccountCreate.mock.calls[0][0].data.account_number_encrypted).toMatch(/^v1:/);
|
||||
expect(JSON.stringify(testApp.auditCreate.mock.calls)).not.toContain(SYNTHETIC_ACCOUNT);
|
||||
expect(testApp.notificationCreateMany).toHaveBeenCalledOnce();
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("does not grant payment-account access to a system administrator by role implication", async () => {
|
||||
const testApp = await buildApp(["system.settings", "system.accounts.manage"]);
|
||||
const response = await testApp.app.inject({ method: "GET", url: "/api/payment-accounts" });
|
||||
expect(response.statusCode).toBe(403);
|
||||
await testApp.app.close();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,15 +85,16 @@ describe("user sensitive field permissions", () => {
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("returns only bank fields to finance and audits the authorized scope", async () => {
|
||||
it("returns only masked bank fields to finance without a full-account view audit", async () => {
|
||||
const testApp = await buildApp(["employee.bank.read"]);
|
||||
|
||||
const response = await testApp.app.inject({ method: "GET", url: `/api/users/${EMPLOYEE_ID}/sensitive` });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
bankInstitution: null,
|
||||
bankName: "合成银行",
|
||||
bankAccount: "6222000012345678",
|
||||
bankAccountMasked: "6222********5678",
|
||||
});
|
||||
expect(response.json().data).not.toHaveProperty("idNumber");
|
||||
expect(testApp.employeeFindUnique).toHaveBeenCalledWith({
|
||||
@@ -101,15 +102,12 @@ describe("user sensitive field permissions", () => {
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_institution_id: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_institution: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
});
|
||||
expect(testApp.auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
||||
extra: { scopes: ["bank"] },
|
||||
}),
|
||||
});
|
||||
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
@@ -119,7 +117,7 @@ describe("user sensitive field permissions", () => {
|
||||
const response = await testApp.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
||||
payload: { bankName: "新银行" },
|
||||
payload: { bankAccount: "6222000099995678" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
@@ -134,7 +132,7 @@ describe("user sensitive field permissions", () => {
|
||||
const response = await testApp.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive`,
|
||||
payload: { bankName: "新银行", idNumber: "110101199001011234" },
|
||||
payload: { bankAccount: "6222000099995678", idNumber: "110101199001011234" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
@@ -143,6 +141,33 @@ describe("user sensitive field permissions", () => {
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("reveals a full bank account only after a reason and records the view audit", async () => {
|
||||
const testApp = await buildApp(["employee.bank.read"]);
|
||||
|
||||
const missingReason = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
|
||||
payload: {},
|
||||
});
|
||||
expect(missingReason.statusCode).toBe(400);
|
||||
expect(testApp.auditCreate).not.toHaveBeenCalled();
|
||||
|
||||
const response = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: `/api/users/${EMPLOYEE_ID}/sensitive/bank/reveal`,
|
||||
payload: { reason: "付款核对" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({ bankAccount: "6222000012345678" });
|
||||
expect(testApp.auditCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: "SYSTEM_USER_BANK_ACCOUNT_REVEALED",
|
||||
extra: { reason: "付款核对" },
|
||||
}),
|
||||
});
|
||||
await testApp.app.close();
|
||||
});
|
||||
|
||||
it("writes only authorized fields and keeps plaintext out of the audit", async () => {
|
||||
const testApp = await buildApp(["employee.bank.manage", "employee.identity.manage"]);
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const XLSX = require("xlsx");
|
||||
|
||||
const { encryptStringOrThrow } = require("../../../src/services/crypto");
|
||||
const {
|
||||
HEADERS,
|
||||
PaymentOrderXlsError,
|
||||
buildCustomerSerial,
|
||||
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: "合成测试公司",
|
||||
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("writes the fixed workbook contract with typed string identifiers and stable ordering", () => {
|
||||
const first = generatePaymentOrderXls(config, buildBatch());
|
||||
const second = generatePaymentOrderXls(config, buildBatch());
|
||||
const workbook = XLSX.read(first.buffer, { type: "buffer", cellNF: true });
|
||||
const sheet = workbook.Sheets.Sheet1;
|
||||
|
||||
expect(first.buffer.subarray(0, 8).toString("hex")).toBe("d0cf11e0a1b11ae1");
|
||||
expect(workbook.SheetNames).toEqual(["Sheet1", "Sheet2", "Sheet3"]);
|
||||
expect(XLSX.utils.sheet_to_json(sheet, { header: 1 })[0]).toEqual(HEADERS);
|
||||
expect(sheet.B2).toMatchObject({ t: "s", v: "6217000000000012345" });
|
||||
expect(sheet.D2).toMatchObject({ t: "s", v: "01" });
|
||||
expect(sheet.E2).toMatchObject({ t: "s", v: "6222000000000012345" });
|
||||
expect(sheet.I2).toMatchObject({ t: "s", v: "PB-20260802-002-001" });
|
||||
expect(sheet.J2).toMatchObject({ t: "n", v: 1.2 });
|
||||
expect(sheet.M2).toMatchObject({ t: "s", v: "0" });
|
||||
expect(first.serials).toEqual(second.serials);
|
||||
expect(first.totalAmount).toBe("3.20");
|
||||
});
|
||||
|
||||
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("validates customer serial length and character set without truncation", () => {
|
||||
expect(buildCustomerSerial("PB-20260802-002", 1)).toBe("PB-20260802-002-001");
|
||||
expect(() => buildCustomerSerial("pb invalid", 1)).toThrow(PaymentOrderXlsError);
|
||||
expect(() => buildCustomerSerial("PB-123456789012345678901234567890", 1)).toThrow(PaymentOrderXlsError);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户