收紧付款复核资料与阶段编辑(08-01-reimbursement-notification-progress-clarity)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-02 15:15:01 +08:00
父节点 9afebdb44d
当前提交 185d64dcd6
共修改 14 个文件,包含 777 行新增和 121 行删除
@@ -12,6 +12,7 @@ const notifications = require("../../../src/services/notifications");
const email = require("../../../src/services/email");
const notificationDispatcher = require("../../../src/services/notificationDispatcher");
const erpJournal = require("../../../src/services/integrations/erpJournal");
const { encryptStringOrThrow } = require("../../../src/services/crypto");
const evaluateFeatureFlagMock = vi.fn();
const createNotificationsWithRecordsMock = vi.fn(async () => []);
@@ -52,6 +53,8 @@ const SUBMITTER_ID = "88888888-8888-4888-8888-888888888888";
const RECEIPT_ID = "99999999-9999-4999-8999-999999999999";
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");
const SYNTHETIC_BANK_ACCOUNT = `6222${"0".repeat(8)}5678`;
function buildMultipartRequest({ files, fields = {} }) {
const boundary = "----codex-payment-batch-receipt-boundary";
@@ -85,6 +88,24 @@ function buildMultipartRequest({ files, fields = {} }) {
};
}
async function listStoredReceiptFiles(rootDir) {
const files = [];
const pending = [rootDir];
while (pending.length) {
const current = pending.pop();
const entries = await fs.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(current, entry.name);
if (entry.isDirectory()) {
pending.push(entryPath);
} else if (entry.isFile()) {
files.push(entryPath);
}
}
}
return files;
}
function buildPaymentBatch(overrides = {}) {
return {
id: BATCH_ID,
@@ -153,6 +174,11 @@ function buildReimbursement(overrides = {}) {
id: OWNER_ID,
employee_no: "EMP001",
name: "测试员工",
bank_name: "测试银行",
bank_account_encrypted: encryptStringOrThrow(
{ fieldEncryptionKey: FIELD_ENCRYPTION_KEY },
SYNTHETIC_BANK_ACCOUNT,
),
legal_entity: {
code: "HYB",
},
@@ -366,7 +392,7 @@ function buildPrisma(options = {}) {
options.candidateCount ?? (state.reservedEntityIds.includes(state.reimbursement.id) ? 0 : 1),
),
findMany: vi.fn(async ({ select } = {}) => {
if (select?.owner) {
if (select?.owner?.select?.employee_no) {
return state.reservedEntityIds.includes(state.reimbursement.id) ? [] : [{ ...state.reimbursement }];
}
const reimbursements = [state.reimbursement, ...state.additionalReimbursements];
@@ -518,6 +544,7 @@ async function buildApp(options = {}) {
app.decorate("prisma", prisma);
app.decorate("config", {
appTimezone: options.appTimezone ?? "Asia/Shanghai",
fieldEncryptionKey: FIELD_ENCRYPTION_KEY,
storageDir: tempDir,
maxUploadSizeBytes: 5 * 1024 * 1024,
paymentBatchEnabled: true,
@@ -905,8 +932,8 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("allows a manager with payment review permission to read the batch list and detail", async () => {
const { app } = await buildApp({
it("keeps the batch list compact and loads payee review facts only for detail", async () => {
const { app, tx } = await buildApp({
actor: {
id: REVIEWER_ID,
department_id: "dept-1",
@@ -920,16 +947,68 @@ describe("payment batch reimbursement routes", () => {
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items[0].id).toBe(BATCH_ID);
expect(listResponse.json().data.items[0].items[0]).toMatchObject({
payeeName: null,
bankName: null,
bankAccountMasked: null,
});
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson");
expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1);
const detailResponse = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}`,
headers: { authorization: "Bearer test" },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json().data.items[0].id).toBe(BATCH_ID);
expect(detailResponse.statusCode).toBe(200);
expect(detailResponse.json().data.item.id).toBe(BATCH_ID);
expect(detailResponse.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
});
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
await app.close();
});
it("never exposes an untrusted stored payment snapshot", async () => {
const ciphertextSentinel = "v1:synthetic:encrypted:payload";
const { app } = await buildApp({
batch: {
items: [{
...buildPaymentBatch().items[0],
snapshot_json: {
payee: {
name: SYNTHETIC_BANK_ACCOUNT,
bankName: ciphertextSentinel,
bankAccountMasked: SYNTHETIC_BANK_ACCOUNT,
},
bank_account_encrypted: ciphertextSentinel,
},
}],
},
});
const response = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
});
expect(response.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
expect(response.body).not.toContain(SYNTHETIC_BANK_ACCOUNT);
expect(response.body).not.toContain(ciphertextSentinel);
expect(response.body).not.toContain("bank_account_encrypted");
await app.close();
});
@@ -1068,10 +1147,76 @@ describe("payment batch reimbursement routes", () => {
status: "draft",
}),
});
expect(tx.paymentBatchItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
snapshot_json: expect.objectContaining({
payee: {
name: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
},
}),
}),
});
expect(response.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
});
expect(JSON.stringify(response.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
expect(JSON.stringify(response.json())).not.toContain("bank_account_encrypted");
await app.close();
});
it("persists the payment channel selected at creation as the review fact", async () => {
const { app, tx } = await buildApp();
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: {
entityType: "reimbursement",
entityIds: [REIMBURSEMENT_ID],
defaultPaymentChannel: "alipay",
},
});
expect(response.statusCode).toBe(201);
expect(tx.paymentBatch.create).toHaveBeenCalledWith({
data: expect.objectContaining({ default_payment_channel: "alipay" }),
});
expect(response.json().data.item.defaultPaymentChannel).toBe("alipay");
await app.close();
});
it("fails closed before batch writes when the payee account cannot be decrypted", async () => {
const unreadableAccount = encryptStringOrThrow(
{ fieldEncryptionKey: Buffer.alloc(32, 8).toString("base64") },
SYNTHETIC_BANK_ACCOUNT,
);
const { app, tx } = await buildApp({
reimbursement: {
owner: {
...buildReimbursement().owner,
bank_account_encrypted: unreadableAccount,
},
},
});
const response = await app.inject({
method: "POST",
url: "/api/payment-batches",
headers: { authorization: "Bearer test" },
payload: { entityType: "reimbursement", entityIds: [REIMBURSEMENT_ID] },
});
expect(response.statusCode).toBe(500);
expect(tx.paymentBatch.create).not.toHaveBeenCalled();
expect(tx.paymentBatchItem.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it("notifies one active chief executive before falling back to managers", async () => {
const chiefExecutiveId = "99999999-1111-4111-8111-111111111111";
const { app, tx } = await buildApp({
@@ -1370,6 +1515,13 @@ describe("payment batch reimbursement routes", () => {
});
expect(detail.statusCode).toBe(200);
expect(detail.json().data.item.defaultPaymentChannel).toBe("bank");
expect(detail.json().data.item.items[0]).toMatchObject({
payeeName: "测试员工",
bankName: "测试银行",
bankAccountMasked: "6222********5678",
});
expect(state.batch.items[0].snapshot_json).toEqual({});
expect(JSON.stringify(detail.json())).not.toContain(SYNTHETIC_BANK_ACCOUNT);
expect(state.batch.default_payment_channel).toBeNull();
const execute = await app.inject({
@@ -1513,7 +1665,6 @@ describe("payment batch reimbursement routes", () => {
payload: {
paymentVoucherNoOverride: "BANK-RECEIPT-001",
paymentDateOverride: "2026-04-30",
paymentChannelOverride: "bank",
},
});
@@ -1521,11 +1672,10 @@ describe("payment batch reimbursement routes", () => {
expect(response.json().data.item).toMatchObject({
paymentVoucherNoOverride: "BANK-RECEIPT-001",
paymentDateOverride: "2026-04-30",
paymentChannelOverride: "bank",
});
expect(state.batch.items[0]).toMatchObject({
payment_voucher_no_override: "BANK-RECEIPT-001",
payment_channel_override: "bank",
payment_channel_override: null,
});
expect(state.batch.items[0].payment_date_override).toEqual(new Date("2026-04-30T00:00:00.000Z"));
expect(tx.auditLog.create).toHaveBeenCalledWith(
@@ -1544,8 +1694,83 @@ describe("payment batch reimbursement routes", () => {
await app.close();
});
it("uploads, downloads and voids bank receipt attachments without physical deletion", async () => {
const { app, prisma, state, tempDir } = await buildApp({ receiptAttachments: [] });
it("allows payment metadata updates while retrying a failed batch", async () => {
const { app, state, tx } = await buildApp({
batch: {
status: "failed",
items: [{ ...buildPaymentBatch().items[0], status: "failed" }],
},
});
const response = await app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: { paymentVoucherNoOverride: "RETRY-001" },
});
expect(response.statusCode).toBe(200);
expect(state.batch.items[0].payment_voucher_no_override).toBe("RETRY-001");
expect(tx.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ action: "PAYMENT_BATCH_ITEM_METADATA_UPDATED" }),
}),
);
await app.close();
});
it("keeps draft payment metadata immutable and rejects channel overrides", async () => {
const draft = await buildApp({ batch: { status: "draft", reviewed_at: null, reviewed_by: null } });
const draftResponse = await draft.app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: { paymentVoucherNoOverride: "SHOULD-NOT-WRITE" },
});
expect(draftResponse.statusCode).toBe(409);
expect(draftResponse.json().error.details.status).toBe("payment_metadata_not_editable");
expect(draft.tx.paymentBatchItem.update).not.toHaveBeenCalled();
expect(draft.tx.auditLog.create).not.toHaveBeenCalled();
await draft.app.close();
const reviewed = await buildApp();
const channelResponse = await reviewed.app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: { paymentChannelOverride: "cash" },
});
expect(channelResponse.statusCode).toBe(400);
expect(reviewed.tx.paymentBatchItem.update).not.toHaveBeenCalled();
expect(reviewed.tx.auditLog.create).not.toHaveBeenCalled();
await reviewed.app.close();
});
it.each(["executed", "cancelled"])("keeps %s payment metadata immutable", async (status) => {
const { app, tx } = await buildApp({ batch: { status } });
const response = await app.inject({
method: "PATCH",
url: `/api/payment-batches/${BATCH_ID}/items/${ITEM_ID}`,
headers: { authorization: "Bearer test" },
payload: { paymentDateOverride: "2026-04-30" },
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.status).toBe("payment_metadata_not_editable");
expect(tx.paymentBatchItem.update).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
await app.close();
});
it.each(["reviewed", "failed"])(
"uploads, downloads and voids bank receipt attachments in %s without physical deletion",
async (status) => {
const { app, prisma, state, tempDir } = await buildApp({
batch: { status },
receiptAttachments: [],
});
try {
const requestPayload = buildMultipartRequest({
@@ -1684,6 +1909,90 @@ describe("payment batch reimbursement routes", () => {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
},
);
it.each(["draft", "executed", "cancelled"])(
"rejects receipt upload and void in %s without attachment side effects",
async (status) => {
const { app, tx, state, tempDir } = await buildApp({ batch: { status } });
try {
const requestPayload = buildMultipartRequest({
files: [{
filename: "blocked-receipt.pdf",
contentType: "application/pdf",
content: SYNTHETIC_RECEIPT_PDF,
}],
});
const uploadResponse = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: { authorization: "Bearer test", ...requestPayload.headers },
payload: requestPayload.payload,
});
expect(uploadResponse.statusCode).toBe(409);
expect(uploadResponse.json().error.details.status).toBe("receipt_attachment_not_editable");
expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled();
expect(await listStoredReceiptFiles(tempDir)).toEqual([]);
const originalAttachment = { ...state.receiptAttachments[0] };
const voidResponse = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments/${RECEIPT_ID}/void`,
headers: { authorization: "Bearer test" },
payload: { reason: "状态不允许时不得作废" },
});
expect(voidResponse.statusCode).toBe(409);
expect(voidResponse.json().error.details.status).toBe("receipt_attachment_not_editable");
expect(tx.paymentBatchReceiptAttachment.update).not.toHaveBeenCalled();
expect(state.receiptAttachments[0]).toEqual(originalAttachment);
expect(tx.auditLog.create).not.toHaveBeenCalled();
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
},
);
it("removes uploaded receipt objects when the locked batch becomes read-only", async () => {
const { app, tx, state, tempDir } = await buildApp({ receiptAttachments: [] });
tx.paymentBatch.findUnique
.mockImplementationOnce(async () => ({
...state.batch,
items: state.batch.items.map((item) => ({ ...item })),
_count: { receipt_attachments: 0 },
}))
.mockImplementationOnce(async () => ({
...state.batch,
status: "executed",
items: state.batch.items.map((item) => ({ ...item })),
_count: { receipt_attachments: 0 },
}));
try {
const requestPayload = buildMultipartRequest({
files: [{
filename: "race-receipt.pdf",
contentType: "application/pdf",
content: SYNTHETIC_RECEIPT_PDF,
}],
});
const response = await app.inject({
method: "POST",
url: `/api/payment-batches/${BATCH_ID}/receipt-attachments`,
headers: { authorization: "Bearer test", ...requestPayload.headers },
payload: requestPayload.payload,
});
expect(response.statusCode).toBe(409);
expect(response.json().error.details.status).toBe("receipt_attachment_not_editable");
expect(tx.paymentBatchReceiptAttachment.create).not.toHaveBeenCalled();
expect(tx.auditLog.create).not.toHaveBeenCalled();
expect(await listStoredReceiptFiles(tempDir)).toEqual([]);
} finally {
await app.close();
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("checks receipt upload permission before feature evaluation or multipart consumption", async () => {