收紧付款复核资料与阶段编辑(08-01-reimbursement-notification-progress-clarity)
这个提交包含在:
+146
-38
@@ -17,6 +17,7 @@ const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { flushNotificationDispatchTasks } = require("../services/notificationDispatcher");
|
||||
const { enqueueReimbursementJournalExport } = require("../services/integrations/erpJournal");
|
||||
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
|
||||
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
|
||||
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
|
||||
const {
|
||||
buildAttachmentContentDisposition,
|
||||
@@ -61,7 +62,6 @@ const CREATE_SCHEMA = z.object({
|
||||
const PATCH_ITEM_SCHEMA = z.object({
|
||||
paymentVoucherNoOverride: z.string().trim().max(50).nullable().optional(),
|
||||
paymentDateOverride: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||
paymentChannelOverride: PAYMENT_CHANNEL_SCHEMA.nullable().optional(),
|
||||
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||
|
||||
const REVIEW_SCHEMA = z.object({
|
||||
@@ -492,7 +492,22 @@ async function lockPaymentEntities(tx, entityType, entityIds) {
|
||||
`);
|
||||
}
|
||||
|
||||
async function loadEntitySnapshots(tx, entityType, entityIds) {
|
||||
function buildPayeeSnapshot(config, owner) {
|
||||
const encryptedAccount = owner?.bank_account_encrypted;
|
||||
const bankAccount = encryptedAccount
|
||||
? decryptStringOrThrow(config, encryptedAccount)
|
||||
: null;
|
||||
if (encryptedAccount && !bankAccount) {
|
||||
throw new Error("Payee bank account could not be decrypted");
|
||||
}
|
||||
return {
|
||||
name: owner?.name ?? null,
|
||||
bankName: owner?.bank_name ?? null,
|
||||
bankAccountMasked: maskBankAccount(bankAccount),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadEntitySnapshots(tx, entityType, entityIds, config) {
|
||||
if (entityType === "reimbursement") {
|
||||
const items = await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: entityIds } },
|
||||
@@ -507,6 +522,13 @@ async function loadEntitySnapshots(tx, entityType, entityIds) {
|
||||
payment_date: true,
|
||||
payment_channel: true,
|
||||
paid_at: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return items.map((item) => ({
|
||||
@@ -525,6 +547,7 @@ async function loadEntitySnapshots(tx, entityType, entityIds) {
|
||||
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannel: item.payment_channel ?? null,
|
||||
paidAt: item.paid_at?.toISOString?.() ?? null,
|
||||
payee: buildPayeeSnapshot(config, item.owner),
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -577,7 +600,77 @@ function ensureBatchEntityStatus(entityType, snapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBatch(batch, openRiskFlags = []) {
|
||||
function normalizeSnapshotText(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
const compactDigits = normalized.replace(/[\s-]/g, "");
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length > 100 ||
|
||||
normalized.startsWith("v1:") ||
|
||||
/^\d{9,32}$/.test(compactDigits)
|
||||
) return null;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeMaskedBankAccount(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
if (!normalized || normalized.length > 64) return null;
|
||||
if (/^\*{1,8}$/.test(normalized)) return normalized;
|
||||
return /^[0-9A-Za-z]{4}\*+[0-9A-Za-z]{4}$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function readSnapshotPayee(snapshotJson) {
|
||||
const payee = snapshotJson?.payee;
|
||||
if (!payee || typeof payee !== "object" || Array.isArray(payee)) {
|
||||
return null;
|
||||
}
|
||||
const bankAccountMasked = normalizeMaskedBankAccount(payee.bankAccountMasked);
|
||||
const name = normalizeSnapshotText(payee.name);
|
||||
const bankName = normalizeSnapshotText(payee.bankName);
|
||||
if (
|
||||
(payee.name != null && !name) ||
|
||||
(payee.bankName != null && !bankName) ||
|
||||
(payee.bankAccountMasked != null && !bankAccountMasked)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
bankName,
|
||||
bankAccountMasked,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadLegacyPayees(tx, batch, config) {
|
||||
if (batch.entity_type !== "reimbursement") {
|
||||
return new Map();
|
||||
}
|
||||
const entityIds = (batch.items ?? [])
|
||||
.filter((item) => !readSnapshotPayee(item.snapshot_json))
|
||||
.map((item) => item.entity_id);
|
||||
if (!entityIds.length) {
|
||||
return new Map();
|
||||
}
|
||||
const reimbursements = await tx.reimbursementForm.findMany({
|
||||
where: { id: { in: Array.from(new Set(entityIds)) } },
|
||||
select: {
|
||||
id: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return new Map(reimbursements.map((item) => [item.id, buildPayeeSnapshot(config, item.owner)]));
|
||||
}
|
||||
|
||||
function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options = {}) {
|
||||
const includePayee = options.includePayee === true;
|
||||
const batchRisk = summarizeBatchRisk(batch);
|
||||
const riskMap = new Map();
|
||||
for (const item of openRiskFlags) {
|
||||
@@ -615,26 +708,38 @@ function mapBatch(batch, openRiskFlags = []) {
|
||||
cancelledAt: batch.cancelled_at?.toISOString?.() ?? null,
|
||||
cancelledBy: batch.cancelled_by ?? null,
|
||||
cancelledReason: batch.cancelled_reason ?? null,
|
||||
items: (batch.items ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
entityId: item.entity_id,
|
||||
entityCode: item.entity_code,
|
||||
entityTitle: item.entity_title,
|
||||
amount: toMoneyString(item.amount),
|
||||
currencyCode: item.currency_code,
|
||||
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: item.payment_channel_override ?? null,
|
||||
status: item.status,
|
||||
failureCode: item.failure_code ?? null,
|
||||
failureMessage: item.failure_message ?? null,
|
||||
snapshotJson: item.snapshot_json,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
openRiskFlags: riskMap.get(item.entity_id) ?? [],
|
||||
})),
|
||||
items: (batch.items ?? []).map((item) => {
|
||||
const payee = includePayee
|
||||
? readSnapshotPayee(item.snapshot_json) ?? legacyPayees.get(item.entity_id) ?? {}
|
||||
: {};
|
||||
return {
|
||||
id: item.id,
|
||||
entityId: item.entity_id,
|
||||
entityCode: item.entity_code,
|
||||
entityTitle: item.entity_title,
|
||||
amount: toMoneyString(item.amount),
|
||||
currencyCode: item.currency_code,
|
||||
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: item.payment_channel_override ?? null,
|
||||
payeeName: payee.name ?? null,
|
||||
bankName: payee.bankName ?? null,
|
||||
bankAccountMasked: payee.bankAccountMasked ?? null,
|
||||
status: item.status,
|
||||
failureCode: item.failure_code ?? null,
|
||||
failureMessage: item.failure_message ?? null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
openRiskFlags: riskMap.get(item.entity_id) ?? [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
|
||||
const legacyPayees = await loadLegacyPayees(tx, batch, config);
|
||||
return mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true });
|
||||
}
|
||||
|
||||
async function listOpenRiskFlagsForBatch(tx, batch) {
|
||||
const entityIds = batch.items.map((item) => item.entity_id);
|
||||
if (!entityIds.length) {
|
||||
@@ -1217,7 +1322,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
const { batch: created, notificationTask } = await fastify.prisma.$transaction(async (tx) => {
|
||||
await lockPaymentEntities(tx, body.entityType, entityIds);
|
||||
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds);
|
||||
const snapshots = await loadEntitySnapshots(tx, body.entityType, entityIds, fastify.config);
|
||||
if (snapshots.length !== entityIds.length) {
|
||||
throw buildValidationError({ entityIds: "contains_not_found" }, 404);
|
||||
}
|
||||
@@ -1312,7 +1417,8 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
|
||||
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mapBatch(created, []) } });
|
||||
const mappedCreated = await mapBatchForResponse(fastify.prisma, created, [], fastify.config);
|
||||
return reply.code(201).send({ requestId: request.id, data: { item: mappedCreated } });
|
||||
});
|
||||
|
||||
fastify.get("/payment-batches/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -1322,7 +1428,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
|
||||
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, batch);
|
||||
return sendData(reply, request, { item: mapBatch(batch, openRiskFlags) });
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, batch, openRiskFlags, fastify.config),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/payment-batches/:id/receipt-attachments", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -1371,7 +1479,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const batchId = parseId(request.params?.id);
|
||||
const batch = await loadBatchWithItems(fastify.prisma, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, fastify.prisma, batch);
|
||||
if (batch.status === "cancelled") {
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
|
||||
}
|
||||
|
||||
@@ -1397,7 +1505,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
createdItems = await fastify.prisma.$transaction(async (tx) => {
|
||||
const lockedBatch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, lockedBatch);
|
||||
if (lockedBatch.status === "cancelled") {
|
||||
if (!["reviewed", "failed"].includes(lockedBatch.status)) {
|
||||
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
|
||||
}
|
||||
|
||||
@@ -1653,7 +1761,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const result = await fastify.prisma.$transaction(async (tx) => {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
if (batch.status === "cancelled") {
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "receipt_attachment_not_editable" }, 409);
|
||||
}
|
||||
const attachment = await tx.paymentBatchReceiptAttachment.findFirst({
|
||||
@@ -1705,8 +1813,8 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const batch = await lockBatchWithItems(tx, batchId);
|
||||
await ensureBatchSourceDepartmentsEnabled(fastify, tx, batch);
|
||||
if (!["draft", "reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "only_open_batch_editable" }, 409);
|
||||
if (!["reviewed", "failed"].includes(batch.status)) {
|
||||
throw buildValidationError({ status: "payment_metadata_not_editable" }, 409);
|
||||
}
|
||||
const item = batch.items.find((entry) => entry.id === itemId);
|
||||
if (!item) {
|
||||
@@ -1719,7 +1827,6 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const before = {
|
||||
paymentVoucherNoOverride: item.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: item.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: item.payment_channel_override ?? null,
|
||||
};
|
||||
const next = await tx.paymentBatchItem.update({
|
||||
where: { id: itemId },
|
||||
@@ -1730,17 +1837,13 @@ async function paymentBatchesRoutes(fastify) {
|
||||
...(body.paymentDateOverride !== undefined
|
||||
? { payment_date_override: body.paymentDateOverride ? parseDateOnlyString(body.paymentDateOverride) : null }
|
||||
: {}),
|
||||
...(body.paymentChannelOverride !== undefined
|
||||
? { payment_channel_override: body.paymentChannelOverride ?? null }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const after = {
|
||||
paymentVoucherNoOverride: next.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: next.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: next.payment_channel_override ?? null,
|
||||
};
|
||||
if (batch.status !== "draft" && JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
@@ -1758,7 +1861,6 @@ async function paymentBatchesRoutes(fastify) {
|
||||
id: updated.id,
|
||||
paymentVoucherNoOverride: updated.payment_voucher_no_override ?? null,
|
||||
paymentDateOverride: updated.payment_date_override?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
paymentChannelOverride: updated.payment_channel_override ?? null,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1859,7 +1961,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
|
||||
await dispatchPaymentResponsibilityNotification(fastify, request, notificationTask);
|
||||
|
||||
return sendData(reply, request, { item: mapBatch(result, []) });
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, result, [], fastify.config),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/execute", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -2027,7 +2131,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
}
|
||||
|
||||
const openRiskFlags = await listOpenRiskFlagsForBatch(fastify.prisma, result);
|
||||
return sendData(reply, request, { item: mapBatch(result, openRiskFlags) });
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, result, openRiskFlags, fastify.config),
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/payment-batches/:id/cancel", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
@@ -2078,7 +2184,9 @@ async function paymentBatchesRoutes(fastify) {
|
||||
});
|
||||
return loadBatchWithItems(tx, batchId);
|
||||
});
|
||||
return sendData(reply, request, { item: mapBatch(cancelled, []) });
|
||||
return sendData(reply, request, {
|
||||
item: await mapBatchForResponse(fastify.prisma, cancelled, [], fastify.config),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const { Client } = require("pg");
|
||||
const { buildApp } = require("../app");
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { ADVISORY_LOCK_NAMESPACES } = require("../constants/advisoryLocks");
|
||||
const { encryptStringOrThrow } = require("../services/crypto");
|
||||
const { currentDateString } = require("../utils/date");
|
||||
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
||||
@@ -93,9 +94,9 @@ function validatePaymentConcurrencyEnvironment(env = process.env) {
|
||||
fail("database_confirmation_mismatch");
|
||||
}
|
||||
requireValue(env, "JWT_SECRET");
|
||||
requireValue(env, "FIELD_ENCRYPTION_KEY");
|
||||
const fieldEncryptionKey = requireValue(env, "FIELD_ENCRYPTION_KEY");
|
||||
|
||||
return { databaseName, databaseUrl };
|
||||
return { databaseName, databaseUrl, fieldEncryptionKey };
|
||||
}
|
||||
|
||||
function responseJson(response) {
|
||||
@@ -200,7 +201,7 @@ async function runBlockedRace({
|
||||
}
|
||||
}
|
||||
|
||||
function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }) {
|
||||
function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId, fieldEncryptionKey }) {
|
||||
return {
|
||||
id,
|
||||
employee_no: `QA-${runKey}-${kind}`,
|
||||
@@ -211,7 +212,10 @@ function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }
|
||||
legal_entity_id: legalEntityId,
|
||||
title: "并发验收",
|
||||
hire_date: new Date("2026-01-01T00:00:00.000Z"),
|
||||
bank_account_encrypted: `synthetic-${runKey}`,
|
||||
bank_account_encrypted: encryptStringOrThrow(
|
||||
{ fieldEncryptionKey },
|
||||
`6222${runKey.slice(0, 8)}5678`,
|
||||
),
|
||||
bank_name: "合成银行",
|
||||
password_hash: "unused-by-token-based-concurrency-check",
|
||||
token_version: 0,
|
||||
@@ -219,7 +223,7 @@ function syntheticEmployeeData({ id, runKey, kind, departmentId, legalEntityId }
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSyntheticFixture(prisma) {
|
||||
async function seedSyntheticFixture(prisma, fieldEncryptionKey) {
|
||||
const runKey = crypto.randomBytes(6).toString("hex");
|
||||
const department = await prisma.department.create({
|
||||
data: { name: `合成部门-${runKey}` },
|
||||
@@ -244,6 +248,7 @@ async function seedSyntheticFixture(prisma) {
|
||||
kind,
|
||||
departmentId: department.id,
|
||||
legalEntityId: legalEntity.id,
|
||||
fieldEncryptionKey,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -772,7 +777,7 @@ async function runPaymentPostgresConcurrencyCheck(env = process.env) {
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
let app = null;
|
||||
try {
|
||||
const fixture = await seedSyntheticFixture(prisma);
|
||||
const fixture = await seedSyntheticFixture(prisma, context.fieldEncryptionKey);
|
||||
app = await buildApp({ startJobs: async () => undefined });
|
||||
await app.ready();
|
||||
const tokens = {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户