实现企业银行账户与建行制单(corporate-bank-account-payment-order)

这个提交包含在:
2026-08-02 23:36:40 +08:00
父节点 f946d64539
当前提交 5261d2849b
共修改 46 个文件,包含 2751 行新增和 73 行删除
+11 -2
查看文件
@@ -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);
});
});