feat(P20T2-1,P20T2-2,P20T2-3): 卡授权/交易/支付标记/发票绑定/ERP出站后端

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 539c2cc4dd
当前提交 dc74d9e53f
共修改 23 个文件,包含 4265 行新增和 218 行删除
+183 -105
查看文件
@@ -24,10 +24,19 @@ const MIME_ALLOWLIST = new Set([
]);
const OCR_TYPE_ALLOWLIST = new Set(["vat", "train", "flight", "taxi", "general"]);
const bindBodySchema = z.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
});
const bindBodySchema = z.union([
z
.object({
reimbursementId: z.string().uuid(),
linkedItemId: z.string().uuid().optional(),
})
.strict(),
z
.object({
travelOrderId: z.string().uuid(),
})
.strict(),
]);
const invoiceEditBodySchema = z
.object({
@@ -692,6 +701,7 @@ async function invoicePoolRoutes(fastify) {
id: true,
status: true,
linked_reimbursement_id: true,
travel_order_id: true,
owner_employee_id: true,
uploaded_by_employee_id: true,
archived_reason: true,
@@ -718,7 +728,7 @@ async function invoicePoolRoutes(fastify) {
});
}
if (current.linked_reimbursement_id) {
if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -777,7 +787,7 @@ async function invoicePoolRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/unbind",
{
preHandler: fastify.requirePermission("invoice.upload"),
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
@@ -795,7 +805,7 @@ async function invoicePoolRoutes(fastify) {
await fastify.prisma.$transaction(async (tx) => {
const invoiceRows = await tx.$queryRaw`
SELECT id, status, linked_reimbursement_id
SELECT id, status, linked_reimbursement_id, travel_order_id
FROM "invoice_pool"
WHERE id = ${invoiceId}::uuid
FOR UPDATE
@@ -810,7 +820,7 @@ async function invoicePoolRoutes(fastify) {
}
const invoice = invoiceRows[0];
if (invoice.status !== "bound" || !invoice.linked_reimbursement_id) {
if (invoice.status !== "bound" || (!invoice.linked_reimbursement_id && !invoice.travel_order_id)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -819,41 +829,51 @@ async function invoicePoolRoutes(fastify) {
});
}
const reimbursementRows = await tx.$queryRaw`
SELECT id, status, owner_id, submitter_id
FROM "reimbursement_forms"
WHERE id = ${invoice.linked_reimbursement_id}::uuid
FOR UPDATE
`;
if (invoice.linked_reimbursement_id) {
const reimbursementRows = await tx.$queryRaw`
SELECT id, status, owner_id, submitter_id
FROM "reimbursement_forms"
WHERE id = ${invoice.linked_reimbursement_id}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_unbind" },
});
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_unbind" },
});
}
} else {
if (!fastify.hasPermission(request.employee, "travel.payment.manage")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
}
responseInvoice = await tx.invoicePool.update({
@@ -862,6 +882,7 @@ async function invoicePoolRoutes(fastify) {
status: "available",
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: null,
updated_by: request.employee.id,
},
select: buildInvoiceDetailSelect(),
@@ -1064,6 +1085,7 @@ async function invoicePoolRoutes(fastify) {
id: true,
status: true,
linked_reimbursement_id: true,
travel_order_id: true,
file_sha256: true,
invoice_code: true,
invoice_number: true,
@@ -1079,7 +1101,7 @@ async function invoicePoolRoutes(fastify) {
});
}
if (current.linked_reimbursement_id) {
if (current.linked_reimbursement_id || current.travel_order_id) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
@@ -1195,7 +1217,7 @@ async function invoicePoolRoutes(fastify) {
fastify.post(
"/invoice-pool/:id/bind",
{
preHandler: fastify.requirePermission("invoice.upload"),
preHandler: fastify.authenticate,
},
async (request, reply) => {
const parsedInvoiceId = z.string().uuid().safeParse(request.params?.id);
@@ -1219,67 +1241,9 @@ async function invoicePoolRoutes(fastify) {
}
const invoiceId = parsedInvoiceId.data;
const { reimbursementId, linkedItemId } = parsedBody.data;
try {
await fastify.prisma.$transaction(async (tx) => {
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (![
"draft",
"rejected",
].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
}
if (linkedItemId) {
const linkedItem = await tx.reimbursementItem.findFirst({
where: {
id: linkedItemId,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "item_not_in_reimbursement" },
});
}
}
const invoiceRows = await tx.$queryRaw`
SELECT id
FROM "invoice_pool"
@@ -1299,6 +1263,7 @@ async function invoicePoolRoutes(fastify) {
select: {
status: true,
linked_reimbursement_id: true,
travel_order_id: true,
},
});
@@ -1310,10 +1275,10 @@ async function invoicePoolRoutes(fastify) {
});
}
if (invoice.status === "bound" || invoice.linked_reimbursement_id) {
if (invoice.status === "bound" || invoice.linked_reimbursement_id || invoice.travel_order_id) {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
message: "发票已被其他业务单据占用/绑定",
statusCode: 409,
});
}
@@ -1321,18 +1286,129 @@ async function invoicePoolRoutes(fastify) {
if (invoice.status !== "available") {
throw new AppError({
code: "VALID_005",
message: "发票已被其他报销单占用/绑定",
message: "发票已被其他业务单据占用/绑定",
statusCode: 409,
details: { status: invoice.status },
});
}
if ("reimbursementId" in parsedBody.data) {
if (!fastify.hasPermission(request.employee, "invoice.upload")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const { reimbursementId, linkedItemId } = parsedBody.data;
const reimbursementRows = await tx.$queryRaw`
SELECT id, owner_id, submitter_id, status
FROM "reimbursement_forms"
WHERE id = ${reimbursementId}::uuid
FOR UPDATE
`;
if (!Array.isArray(reimbursementRows) || reimbursementRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const reimbursement = reimbursementRows[0];
const isOwn =
reimbursement.owner_id === request.employee.id ||
reimbursement.submitter_id === request.employee.id;
const canViewAll = fastify.hasPermission(request.employee, "expense.view_all");
if (!isOwn && !canViewAll) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
if (!["draft", "rejected"].includes(reimbursement.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { reimbursement: "invalid_status_for_bind" },
});
}
if (linkedItemId) {
const linkedItem = await tx.reimbursementItem.findFirst({
where: {
id: linkedItemId,
reimbursement_id: reimbursementId,
},
select: { id: true },
});
if (!linkedItem) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 400,
details: { linkedItemId: "item_not_in_reimbursement" },
});
}
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: linkedItemId ?? null,
travel_order_id: null,
updated_by: request.employee.id,
},
});
return;
}
if (!fastify.hasPermission(request.employee, "travel.payment.manage")) {
throw new AppError({
code: "PERM_001",
message: "无权限",
statusCode: 403,
});
}
const { travelOrderId } = parsedBody.data;
const travelOrderRows = await tx.$queryRaw`
SELECT id, status
FROM "travel_orders"
WHERE id = ${travelOrderId}::uuid
FOR UPDATE
`;
if (!Array.isArray(travelOrderRows) || travelOrderRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const travelOrder = travelOrderRows[0];
if (!["confirmed", "paid"].includes(travelOrder.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { travelOrder: "invalid_status_for_bind" },
});
}
await tx.invoicePool.update({
where: { id: invoiceId },
data: {
status: "bound",
linked_reimbursement_id: reimbursementId,
linked_item_id: linkedItemId ?? null,
linked_reimbursement_id: null,
linked_item_id: null,
travel_order_id: travelOrderId,
updated_by: request.employee.id,
},
});
@@ -1363,6 +1439,7 @@ async function invoicePoolRoutes(fastify) {
status: true,
linked_reimbursement_id: true,
linked_item_id: true,
travel_order_id: true,
total_amount: true,
},
});
@@ -1372,6 +1449,7 @@ async function invoicePoolRoutes(fastify) {
status: invoice.status,
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
linkedTravelOrderId: invoice.travel_order_id,
totalAmount: toMoneyString(invoice.total_amount),
});
},
+6 -2
查看文件
@@ -47,10 +47,12 @@ function parseInvoiceListQuery(query) {
function ensureInvoiceReadableByUser(request, invoice) {
const roleCodes = request.employee.roleCodes;
const permissionCodes = request.employee.permissionCodes ?? [];
if (
roleCodes.includes("admin") ||
roleCodes.includes("finance") ||
roleCodes.includes("finance_mgr")
roleCodes.includes("finance_mgr") ||
permissionCodes.includes("travel.payment.manage")
) {
return true;
}
@@ -110,7 +112,9 @@ function registerInvoicePoolReadRoutes(fastify) {
const query = parseInvoiceListQuery(request.query);
const canViewOwn = fastify.hasPermission(request.employee, "invoice.view_own");
const canManage = fastify.hasPermission(request.employee, "invoice.manage");
const canManage =
fastify.hasPermission(request.employee, "invoice.manage") ||
fastify.hasPermission(request.employee, "travel.payment.manage");
if (!canViewOwn && !canManage) {
throw new AppError({
@@ -43,6 +43,7 @@ function mapInvoiceSummary(item) {
totalAmount: toMoneyString(item.total_amount),
linkedReimbursementId: item.linked_reimbursement_id,
linkedItemId: item.linked_item_id,
linkedTravelOrderId: item.travel_order_id ?? null,
originalName: item.original_name,
mimeType: item.mime_type,
fileSize: item.file_size,
@@ -99,6 +100,7 @@ function normalizeInvoiceResponse(invoice) {
totalAmount: toMoneyString(invoice.total_amount),
linkedReimbursementId: invoice.linked_reimbursement_id,
linkedItemId: invoice.linked_item_id,
linkedTravelOrderId: invoice.travel_order_id ?? null,
verification: mapVerificationRecord(latestVerification),
isDuplicateOverride: invoice.is_duplicate_override ?? false,
overrideRefFormNumber: invoice.override_ref_form_number ?? null,
@@ -41,6 +41,7 @@ function buildInvoiceSummarySelect() {
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
travel_order_id: true,
original_name: true,
mime_type: true,
file_size: true,
@@ -85,6 +86,7 @@ function buildInvoiceDetailSelect(options = {}) {
total_amount: true,
linked_reimbursement_id: true,
linked_item_id: true,
travel_order_id: true,
is_duplicate_override: true,
override_ref_form_number: true,
override_reason: true,
+314
查看文件
@@ -0,0 +1,314 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { normalizeTravelAuthorizationRows } = require("../services/integrations/travel");
const { authorizeBudgetForEntity } = require("../services/travelPayments");
const UUID_SCHEMA = z.string().uuid();
const LIST_QUERY_SCHEMA = z.object({
travelRequestId: UUID_SCHEMA.optional(),
status: z.string().trim().min(1).max(32).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const IMPORT_SCHEMA = z.object({
authorizations: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
});
const AUTHORIZATION_STATUS_LIST = new Set([
"authorized",
"partially_settled",
"settled",
"reversed",
"expired",
]);
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function normalizeAuthorizationStatus(value) {
const normalized = String(value ?? "authorized").trim().toLowerCase();
return AUTHORIZATION_STATUS_LIST.has(normalized) ? normalized : "authorized";
}
function parseListQuery(query) {
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
travelRequestId: parsed.data.travelRequestId ?? null,
status: parsed.data.status ? normalizeAuthorizationStatus(parsed.data.status) : null,
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
}
function mapAuthorization(item) {
return {
id: item.id,
source: item.source,
externalAuthNo: item.external_auth_no,
employeeId: item.employee_id,
departmentId: item.department_id ?? null,
travelRequestId: item.travel_request_id,
budgetPoolId: item.budget_pool_id ?? null,
legalEntityId: item.legal_entity_id,
costCenterId: item.cost_center_id,
authorizedAmount: item.authorized_amount?.toString?.() ?? "0.00",
settledAmount: item.settled_amount?.toString?.() ?? "0.00",
reversedAmount: item.reversed_amount?.toString?.() ?? "0.00",
currency: item.currency,
occurredAt: item.occurred_at?.toISOString?.() ?? null,
expiresAt: item.expires_at?.toISOString?.() ?? null,
status: item.status,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function hasAuthorizationConflict(existing, row, travelRequest) {
const existingBudgetPoolId = existing.budget_pool_id ?? null;
const nextBudgetPoolId = row.budgetPoolId ?? travelRequest.budget_pool_id ?? null;
return (
existing.employee_id !== row.employeeId ||
existing.travel_request_id !== row.travelRequestId ||
String(existing.authorized_amount ?? "0.00") !== String(row.authorizedAmount ?? "0.00") ||
String(existing.currency ?? "CNY") !== String(row.currency ?? "CNY") ||
existingBudgetPoolId !== nextBudgetPoolId
);
}
async function travelAuthorizationsRoutes(fastify) {
fastify.get(
"/travel/authorizations",
{
preHandler: fastify.requirePermission("travel.payment.manage"),
},
async (request, reply) => {
const query = parseListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.travelRequestId ? { travel_request_id: query.travelRequestId } : {}),
...(query.status ? { status: query.status } : {}),
};
const [items, total] = await Promise.all([
fastify.prisma.cardAuthorization.findMany({
where,
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
skip,
take: query.pageSize,
}),
fastify.prisma.cardAuthorization.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map(mapAuthorization),
});
},
);
fastify.post(
"/travel/authorizations/import",
{
preHandler: fastify.requirePermission("integration.manage"),
},
async (request, reply) => {
if (!fastify.config.travelSyncEnabled) {
throw buildValidationError({ integration: "travel_sync_disabled" }, 409);
}
const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const rows = normalizeTravelAuthorizationRows(parsed.data.authorizations);
if (!rows.length) {
throw buildValidationError({ authorizations: "empty_after_normalization" });
}
const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId)));
const travelRequestIds = Array.from(new Set(rows.map((item) => item.travelRequestId)));
const [employees, travelRequests] = await Promise.all([
fastify.prisma.employee.findMany({
where: { id: { in: employeeIds }, status: "active" },
select: { id: true, department_id: true },
}),
fastify.prisma.travelRequest.findMany({
where: { id: { in: travelRequestIds } },
select: {
id: true,
applicant_employee_id: true,
department_id: true,
budget_pool_id: true,
legal_entity_id: true,
cost_center_id: true,
},
}),
]);
const employeeMap = new Map(employees.map((item) => [item.id, item]));
const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item]));
const imported = [];
const skipped = [];
let authorizedCount = 0;
for (const row of rows) {
const employee = employeeMap.get(row.employeeId);
if (!employee) {
skipped.push({
source: row.source,
externalAuthNo: row.externalAuthNo,
reason: "employee_not_found",
});
continue;
}
const travelRequest = travelRequestMap.get(row.travelRequestId);
if (!travelRequest) {
skipped.push({
source: row.source,
externalAuthNo: row.externalAuthNo,
reason: "travel_request_not_found",
});
continue;
}
if (travelRequest.applicant_employee_id !== row.employeeId) {
skipped.push({
source: row.source,
externalAuthNo: row.externalAuthNo,
reason: "travel_request_employee_mismatch",
});
continue;
}
const existing = await fastify.prisma.cardAuthorization.findUnique({
where: {
source_external_auth_no: {
source: row.source,
external_auth_no: row.externalAuthNo,
},
},
});
if (existing && hasAuthorizationConflict(existing, row, travelRequest)) {
skipped.push({
source: row.source,
externalAuthNo: row.externalAuthNo,
reason: "authorization_conflict",
});
continue;
}
const status = normalizeAuthorizationStatus(row.status);
const nextBudgetPoolId = row.budgetPoolId ?? travelRequest.budget_pool_id ?? null;
const record = await fastify.prisma.$transaction(async (tx) => {
const upserted = await tx.cardAuthorization.upsert({
where: {
source_external_auth_no: {
source: row.source,
external_auth_no: row.externalAuthNo,
},
},
update: {
employee_id: row.employeeId,
department_id: employee.department_id,
travel_request_id: row.travelRequestId,
budget_pool_id: nextBudgetPoolId,
legal_entity_id: travelRequest.legal_entity_id,
cost_center_id: travelRequest.cost_center_id,
authorized_amount: row.authorizedAmount,
settled_amount: row.settledAmount,
reversed_amount: row.reversedAmount,
currency: row.currency,
occurred_at: row.occurredAt,
expires_at: row.expiresAt,
status,
raw_payload: row.rawPayload,
updated_by: request.employee.id,
},
create: {
source: row.source,
external_auth_no: row.externalAuthNo,
employee_id: row.employeeId,
department_id: employee.department_id,
travel_request_id: row.travelRequestId,
budget_pool_id: nextBudgetPoolId,
legal_entity_id: travelRequest.legal_entity_id,
cost_center_id: travelRequest.cost_center_id,
authorized_amount: row.authorizedAmount,
settled_amount: row.settledAmount,
reversed_amount: row.reversedAmount,
currency: row.currency,
occurred_at: row.occurredAt,
expires_at: row.expiresAt,
status,
raw_payload: row.rawPayload,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
const budgetAuthorization = await authorizeBudgetForEntity(tx, {
budgetPoolId: nextBudgetPoolId,
entityId: upserted.id,
amount: row.authorizedAmount,
actorId: request.employee.id,
note: "travel_authorization_import_authorize",
});
if (budgetAuthorization.applied) {
authorizedCount += 1;
}
return upserted;
});
imported.push(record);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_AUTHORIZATIONS_IMPORTED",
targetType: "card_authorization",
extra: {
requested: rows.length,
createdOrUpdated: imported.length,
authorizedCount,
skipped,
},
}),
});
return sendData(reply, request, {
requested: rows.length,
imported: imported.length,
authorizedCount,
skipped,
items: imported.map(mapAuthorization),
});
},
);
}
module.exports = travelAuthorizationsRoutes;
+284 -2
查看文件
@@ -4,11 +4,31 @@ const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { normalizeTravelImportRows } = require("../services/integrations/travel");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
enqueueTravelOrderPaymentJournalExport,
buildTravelOrderPaymentJournalDedupeKey,
} = require("../services/integrations/erpJournal");
const { parseDateOnlyString } = require("../utils/date");
const IMPORT_SCHEMA = z.object({
orders: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
});
const UUID_SCHEMA = z.string().uuid();
const PAYMENT_CHANNEL_SCHEMA = z.enum([
"bank_transfer",
"corporate_card",
"virtual_card",
"manual",
"other",
]);
const MARK_PAID_SCHEMA = z
.object({
paymentVoucherNo: z.string().trim().max(50).nullable().optional(),
paymentDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
paymentChannel: PAYMENT_CHANNEL_SCHEMA.nullable().optional(),
})
.strict();
function mapTravelOrder(item) {
return {
@@ -23,6 +43,11 @@ function mapTravelOrder(item) {
amount: item.amount?.toString?.() ?? null,
currency: item.currency,
status: item.status,
paymentVoucherNo: item.payment_voucher_no ?? null,
paymentDate: item.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: item.payment_channel ?? null,
paidAt: item.paid_at?.toISOString?.() ?? null,
paidBy: item.paid_by ?? null,
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
@@ -37,6 +62,29 @@ function buildValidationError(details, statusCode = 400) {
});
}
function normalizeNullableString(value) {
if (value == null) {
return null;
}
const normalized = String(value).trim();
return normalized || null;
}
function toDateOnlyString(value) {
if (!value) {
return null;
}
try {
return new Date(value).toISOString().slice(0, 10);
} catch {
return null;
}
}
function buildOrderKey(source, externalOrderNo) {
return `${source}::${externalOrderNo}`;
}
async function travelOrdersRoutes(fastify) {
fastify.post(
"/travel/orders/import",
@@ -67,7 +115,12 @@ async function travelOrdersRoutes(fastify) {
),
).filter((item) => UUID_SCHEMA.safeParse(item).success);
const [employees, travelRequests] = await Promise.all([
const existingOrderWhere = rows.map((item) => ({
source: item.source,
external_order_no: item.externalOrderNo,
}));
const [employees, travelRequests, existingOrders] = await Promise.all([
fastify.prisma.employee.findMany({
where: {
id: { in: employeeIds },
@@ -87,10 +140,25 @@ async function travelOrdersRoutes(fastify) {
},
})
: Promise.resolve([]),
existingOrderWhere.length
? fastify.prisma.travelOrder.findMany({
where: { OR: existingOrderWhere },
select: {
id: true,
source: true,
external_order_no: true,
status: true,
travel_request_id: true,
},
})
: Promise.resolve([]),
]);
const employeeMap = new Map(employees.map((item) => [item.id, item]));
const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item]));
const existingOrderMap = new Map(
existingOrders.map((item) => [buildOrderKey(item.source, item.external_order_no), item]),
);
const imported = [];
const skipped = [];
@@ -131,6 +199,7 @@ async function travelOrdersRoutes(fastify) {
nextTravelRequestId = undefined;
}
const existingOrder = existingOrderMap.get(buildOrderKey(row.source, row.externalOrderNo)) ?? null;
const updateData = {
employee_id: row.employeeId,
department_id: employee.department_id,
@@ -138,7 +207,7 @@ async function travelOrdersRoutes(fastify) {
occurred_at: row.occurredAt,
amount: row.amount,
currency: row.currency,
status: "imported",
status: existingOrder?.status ?? "imported",
raw_payload: row.rawPayload,
updated_by: request.employee.id,
};
@@ -202,6 +271,219 @@ async function travelOrdersRoutes(fastify) {
});
},
);
fastify.post(
"/travel-orders/:id/mark-paid",
{
preHandler: fastify.requirePermission("travel.payment.manage"),
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.id);
if (!parsedId.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
const parsedBody = MARK_PAID_SCHEMA.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw buildValidationError(parsedBody.error.flatten());
}
const paymentVoucherNo = normalizeNullableString(parsedBody.data.paymentVoucherNo);
const paymentDate = parsedBody.data.paymentDate ?? null;
const paymentChannel = parsedBody.data.paymentChannel ?? null;
let response = null;
await fastify.prisma.$transaction(async (tx) => {
const orderRows = await tx.$queryRaw`
SELECT id, status, source, external_order_no, employee_id, department_id,
travel_request_id, amount, currency,
payment_voucher_no, payment_date, payment_channel, paid_at, paid_by,
created_at, updated_at
FROM "travel_orders"
WHERE id = ${parsedId.data}::uuid
FOR UPDATE
`;
if (!Array.isArray(orderRows) || orderRows.length === 0) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const current = orderRows[0];
if (!["confirmed", "paid"].includes(current.status)) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { status: "invalid_pay_status" },
});
}
const existingPaymentDate = toDateOnlyString(current.payment_date);
if (current.status === "paid") {
const voucherConflict = paymentVoucherNo !== (current.payment_voucher_no ?? null);
const paymentDateConflict = paymentDate !== existingPaymentDate;
const paymentChannelConflict = paymentChannel !== (current.payment_channel ?? null);
if (voucherConflict || paymentDateConflict || paymentChannelConflict) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { payment: "already_paid_conflict" },
});
}
}
const matchedTransaction = await tx.cardTransaction.findFirst({
where: {
travel_order_id: parsedId.data,
txn_type: "charge",
status: "settled",
amount: current.amount,
currency: current.currency,
},
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
select: { id: true },
});
if (!matchedTransaction) {
throw new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode: 409,
details: { payment: "missing_settled_transaction" },
});
}
const now = new Date();
if (current.status === "confirmed") {
await tx.travelOrder.update({
where: { id: parsedId.data },
data: {
status: "paid",
payment_voucher_no: paymentVoucherNo,
payment_date: paymentDate ? parseDateOnlyString(paymentDate) : null,
payment_channel: paymentChannel,
paid_at: now,
paid_by: request.employee.id,
updated_by: request.employee.id,
},
});
}
const refreshed = await tx.travelOrder.findUnique({
where: { id: parsedId.data },
select: {
id: true,
source: true,
external_order_no: true,
employee_id: true,
department_id: true,
linked_reimbursement_id: true,
travel_request_id: true,
amount: true,
currency: true,
status: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
paid_by: true,
created_at: true,
updated_at: true,
travel_request: {
select: {
id: true,
request_no: true,
department_id: true,
applicant_employee_id: true,
submitter_id: true,
legal_entity: { select: { code: true } },
cost_center: { select: { code: true } },
budget_pool: { select: { code: true } },
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_ORDER_MARKED_PAID",
targetType: "travel_order",
targetId: parsedId.data,
extra: {
paymentVoucherNo,
paymentDate,
paymentChannel,
idempotent: current.status === "paid",
matchedTransactionId: matchedTransaction.id,
},
}),
});
const departmentId = refreshed?.department_id ?? refreshed?.travel_request?.department_id ?? null;
const erpGate = await evaluateFeatureFlag({
prisma: tx,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.erpSync,
departmentId,
strictDepartment: true,
});
let erpJournal = {
eventType: "erp.journal.export",
outboxId: null,
dedupeKey: buildTravelOrderPaymentJournalDedupeKey(parsedId.data),
enqueued: false,
};
if (erpGate.enabled && refreshed) {
const outbox = await enqueueTravelOrderPaymentJournalExport(tx, fastify.config, {
travelOrder: refreshed,
actorId: request.employee.id,
});
erpJournal = {
eventType: "erp.journal.export",
outboxId: outbox.id,
dedupeKey: buildTravelOrderPaymentJournalDedupeKey(parsedId.data),
enqueued: true,
};
} else {
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ERP_SYNC_SKIPPED_BY_FEATURE_FLAG",
targetType: "travel_order",
targetId: parsedId.data,
extra: {
featureFlag: FEATURE_FLAG_KEYS.erpSync,
departmentId,
reason: erpGate.reason,
},
}),
});
}
response = {
travelOrderId: refreshed.id,
travelRequestId: refreshed.travel_request_id ?? null,
status: refreshed.status,
paymentVoucherNo: refreshed.payment_voucher_no ?? null,
paymentDate: refreshed.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: refreshed.payment_channel ?? null,
erpJournal,
};
});
return sendData(reply, request, response);
},
);
}
module.exports = travelOrdersRoutes;
+29
查看文件
@@ -11,6 +11,7 @@ const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow
const { sendNotificationEmailSafe } = require("../services/email");
const { createNotifications, resolveRecipientsForAssignee } = require("../services/notifications");
const { enqueueImNotifications } = require("../services/notifications/imEnqueue");
const { buildTravelPaymentTrack } = require("../services/travelPayments");
const { NOTIFICATION_TYPES } = require("../constants/notificationTypes");
const UUID_SCHEMA = z.string().uuid();
@@ -145,10 +146,16 @@ const TRAVEL_DETAIL_SELECT = {
external_order_no: true,
employee_id: true,
linked_reimbursement_id: true,
travel_request_id: true,
occurred_at: true,
amount: true,
currency: true,
status: true,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: true,
paid_by: true,
created_at: true,
updated_at: true,
},
@@ -356,10 +363,16 @@ function mapTravelOrder(item) {
externalOrderNo: item.external_order_no,
employeeId: item.employee_id,
linkedReimbursementId: item.linked_reimbursement_id ?? null,
travelRequestId: item.travel_request_id ?? null,
occurredAt: toIsoDate(item.occurred_at),
amount: moneyToFixed(item.amount),
currency: item.currency,
status: item.status,
paymentVoucherNo: item.payment_voucher_no ?? null,
paymentDate: toIsoDate(item.payment_date),
paymentChannel: item.payment_channel ?? null,
paidAt: toIsoDateTime(item.paid_at),
paidBy: item.paid_by ?? null,
createdAt: toIsoDateTime(item.created_at),
updatedAt: toIsoDateTime(item.updated_at),
};
@@ -1054,6 +1067,22 @@ async function createTravelRequestsRoutes(fastify) {
},
);
fastify.get(
"/travel-requests/:id/payment-track",
{
preHandler: fastify.authenticate,
},
async (request, reply) => {
ensureTravelReadPermission(fastify, request);
const travelRequestId = parseTravelRequestId(request.params?.id);
await loadReadableTravelRequest(fastify.prisma, travelRequestId, request);
const track = await buildTravelPaymentTrack(fastify.prisma, travelRequestId, {
canManagePayments: fastify.hasPermission(request.employee, "travel.payment.manage"),
});
return sendData(reply, request, track);
},
);
fastify.post(
"/travel-requests/:id/withdraw",
{
+666
查看文件
@@ -0,0 +1,666 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { sendData } = require("../utils/reply");
const { buildAuditLogData } = require("../utils/audit");
const { normalizeTravelTransactionRows } = require("../services/integrations/travel");
const {
settleBudgetForEntity,
reverseBudgetForEntity,
isOrderAmountAndCurrencyMatched,
} = require("../services/travelPayments");
const UUID_SCHEMA = z.string().uuid();
const LIST_QUERY_SCHEMA = z.object({
travelRequestId: UUID_SCHEMA.optional(),
travelOrderId: UUID_SCHEMA.optional(),
status: z.string().trim().min(1).max(32).optional(),
txnType: z.string().trim().min(1).max(32).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
const IMPORT_SCHEMA = z.object({
transactions: z.array(z.record(z.string(), z.unknown())).min(1).max(100),
});
const FREEZE_SUGGESTION_SCHEMA = z.object({
reasonCode: z.enum([
"amount_mismatch",
"currency_mismatch",
"missing_order",
"duplicate",
"manual_review",
]),
note: z.string().trim().max(500).nullable().optional(),
});
const TRANSACTION_STATUS_LIST = new Set(["pending", "settled", "reversed", "failed"]);
const TRANSACTION_TYPE_LIST = new Set(["charge", "refund", "reversal"]);
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function normalizeTransactionStatus(value) {
const normalized = String(value ?? "pending").trim().toLowerCase();
return TRANSACTION_STATUS_LIST.has(normalized) ? normalized : "pending";
}
function normalizeTransactionType(value) {
const normalized = String(value ?? "charge").trim().toLowerCase();
return TRANSACTION_TYPE_LIST.has(normalized) ? normalized : "charge";
}
function parseListQuery(query) {
const parsed = LIST_QUERY_SCHEMA.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return {
travelRequestId: parsed.data.travelRequestId ?? null,
travelOrderId: parsed.data.travelOrderId ?? null,
status: parsed.data.status ? normalizeTransactionStatus(parsed.data.status) : null,
txnType: parsed.data.txnType ? normalizeTransactionType(parsed.data.txnType) : null,
page: parsed.data.page ?? 1,
pageSize: parsed.data.pageSize ?? 20,
};
}
function toMoneyFixed(value) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return "0.00";
}
return numeric.toFixed(2);
}
function mapMatchedOrder(order, transaction) {
if (!order) {
return null;
}
return {
id: order.id,
externalOrderNo: order.external_order_no,
status: order.status,
amount: toMoneyFixed(order.amount),
currency: order.currency,
exactMatch: isOrderAmountAndCurrencyMatched(order, transaction),
};
}
function mapTransaction(item) {
return {
id: item.id,
source: item.source,
externalTxnId: item.external_txn_id,
cardAuthorizationId: item.card_authorization_id ?? null,
employeeId: item.employee_id,
departmentId: item.department_id ?? null,
travelRequestId: item.travel_request_id,
travelOrderId: item.travel_order_id ?? null,
amount: toMoneyFixed(item.amount),
currency: item.currency,
txnType: item.txn_type,
status: item.status,
occurredAt: item.occurred_at?.toISOString?.() ?? null,
freezeSuggestedAt: item.freeze_suggested_at?.toISOString?.() ?? null,
freezeSuggestedBy: item.freeze_suggested_by ?? null,
freezeReasonCode: item.freeze_reason_code ?? null,
freezeReasonNote: item.freeze_reason_note ?? null,
matchedOrder: mapMatchedOrder(item.travel_order ?? null, item),
createdAt: item.created_at?.toISOString?.() ?? null,
updatedAt: item.updated_at?.toISOString?.() ?? null,
};
}
function hasTransactionConflict(existing, row) {
if (
existing.employee_id !== row.employeeId ||
existing.travel_request_id !== row.travelRequestId ||
String(existing.amount ?? "0.00") !== String(row.amount ?? "0.00") ||
String(existing.currency ?? "CNY") !== String(row.currency ?? "CNY") ||
String(existing.txn_type ?? "charge") !== String(row.txnType ?? "charge")
) {
return true;
}
if (
row.hasCardAuthorizationId &&
(existing.card_authorization_id ?? null) !== (row.cardAuthorizationId ?? null)
) {
return true;
}
if (row.hasTravelOrderId && (existing.travel_order_id ?? null) !== (row.travelOrderId ?? null)) {
return true;
}
return false;
}
async function syncAuthorizationSnapshot(tx, authorizationId, actorId) {
const authorization = await tx.cardAuthorization.findUnique({
where: { id: authorizationId },
select: {
id: true,
authorized_amount: true,
card_transactions: {
select: {
amount: true,
txn_type: true,
status: true,
},
},
},
});
if (!authorization) {
return null;
}
const settledAmount = authorization.card_transactions.reduce((total, item) => {
if (item.txn_type === "charge" && item.status === "settled") {
return total + Number(item.amount ?? 0);
}
return total;
}, 0);
const reversedAmount = authorization.card_transactions.reduce((total, item) => {
if (["refund", "reversal"].includes(item.txn_type) || item.status === "reversed") {
return total + Number(item.amount ?? 0);
}
return total;
}, 0);
const authorizedAmount = Number(authorization.authorized_amount ?? 0);
let nextStatus = "authorized";
if (reversedAmount > 0 && reversedAmount >= authorizedAmount && authorizedAmount > 0) {
nextStatus = "reversed";
} else if (settledAmount > 0 && settledAmount >= authorizedAmount && authorizedAmount > 0) {
nextStatus = "settled";
} else if (settledAmount > 0) {
nextStatus = "partially_settled";
}
return tx.cardAuthorization.update({
where: { id: authorizationId },
data: {
settled_amount: settledAmount.toFixed(2),
reversed_amount: reversedAmount.toFixed(2),
status: nextStatus,
updated_by: actorId,
},
});
}
async function loadOrderById(tx, orderId) {
return tx.travelOrder.findUnique({
where: { id: orderId },
select: {
id: true,
external_order_no: true,
employee_id: true,
travel_request_id: true,
amount: true,
currency: true,
status: true,
},
});
}
async function travelTransactionsRoutes(fastify) {
fastify.get(
"/travel/transactions",
{
preHandler: fastify.requirePermission("travel.payment.manage"),
},
async (request, reply) => {
const query = parseListQuery(request.query);
const skip = (query.page - 1) * query.pageSize;
const where = {
...(query.travelRequestId ? { travel_request_id: query.travelRequestId } : {}),
...(query.travelOrderId ? { travel_order_id: query.travelOrderId } : {}),
...(query.status ? { status: query.status } : {}),
...(query.txnType ? { txn_type: query.txnType } : {}),
};
const [items, total] = await Promise.all([
fastify.prisma.cardTransaction.findMany({
where,
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
skip,
take: query.pageSize,
include: {
travel_order: {
select: {
id: true,
external_order_no: true,
status: true,
amount: true,
currency: true,
},
},
},
}),
fastify.prisma.cardTransaction.count({ where }),
]);
return sendData(reply, request, {
page: query.page,
pageSize: query.pageSize,
total,
items: items.map(mapTransaction),
});
},
);
fastify.post(
"/travel/transactions/import",
{
preHandler: fastify.requirePermission("integration.manage"),
},
async (request, reply) => {
if (!fastify.config.travelSyncEnabled) {
throw buildValidationError({ integration: "travel_sync_disabled" }, 409);
}
const parsed = IMPORT_SCHEMA.safeParse(request.body ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const rows = normalizeTravelTransactionRows(parsed.data.transactions);
if (!rows.length) {
throw buildValidationError({ transactions: "empty_after_normalization" });
}
const employeeIds = Array.from(new Set(rows.map((item) => item.employeeId)));
const travelRequestIds = Array.from(new Set(rows.map((item) => item.travelRequestId)));
const authorizationIds = Array.from(
new Set(rows.map((item) => item.cardAuthorizationId).filter(Boolean)),
);
const travelOrderIds = Array.from(new Set(rows.map((item) => item.travelOrderId).filter(Boolean)));
const [employees, travelRequests, authorizations, travelOrders] = await Promise.all([
fastify.prisma.employee.findMany({
where: { id: { in: employeeIds }, status: "active" },
select: { id: true, department_id: true },
}),
fastify.prisma.travelRequest.findMany({
where: { id: { in: travelRequestIds } },
select: {
id: true,
applicant_employee_id: true,
budget_pool_id: true,
},
}),
authorizationIds.length
? fastify.prisma.cardAuthorization.findMany({
where: { id: { in: authorizationIds } },
select: {
id: true,
employee_id: true,
travel_request_id: true,
budget_pool_id: true,
},
})
: Promise.resolve([]),
travelOrderIds.length
? fastify.prisma.travelOrder.findMany({
where: { id: { in: travelOrderIds } },
select: {
id: true,
employee_id: true,
travel_request_id: true,
external_order_no: true,
amount: true,
currency: true,
status: true,
},
})
: Promise.resolve([]),
]);
const employeeMap = new Map(employees.map((item) => [item.id, item]));
const travelRequestMap = new Map(travelRequests.map((item) => [item.id, item]));
const authorizationMap = new Map(authorizations.map((item) => [item.id, item]));
const orderMap = new Map(travelOrders.map((item) => [item.id, item]));
const imported = [];
const skipped = [];
let confirmedCount = 0;
let reversedCount = 0;
for (const row of rows) {
const employee = employeeMap.get(row.employeeId);
if (!employee) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "employee_not_found",
});
continue;
}
const travelRequest = travelRequestMap.get(row.travelRequestId);
if (!travelRequest) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "travel_request_not_found",
});
continue;
}
if (travelRequest.applicant_employee_id !== row.employeeId) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "travel_request_employee_mismatch",
});
continue;
}
const authorization = row.cardAuthorizationId ? authorizationMap.get(row.cardAuthorizationId) ?? null : null;
if (row.cardAuthorizationId && !authorization) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "card_authorization_not_found",
});
continue;
}
if (
authorization &&
(authorization.employee_id !== row.employeeId || authorization.travel_request_id !== row.travelRequestId)
) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "card_authorization_mismatch",
});
continue;
}
const travelOrder = row.travelOrderId ? orderMap.get(row.travelOrderId) ?? null : null;
if (row.travelOrderId && !travelOrder) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "travel_order_not_found",
});
continue;
}
if (travelOrder && travelOrder.employee_id !== row.employeeId) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "travel_order_employee_mismatch",
});
continue;
}
if (
travelOrder &&
travelOrder.travel_request_id &&
travelOrder.travel_request_id !== row.travelRequestId
) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "travel_order_request_mismatch",
});
continue;
}
const existing = await fastify.prisma.cardTransaction.findUnique({
where: {
source_external_txn_id: {
source: row.source,
external_txn_id: row.externalTxnId,
},
},
});
if (existing && hasTransactionConflict(existing, row)) {
skipped.push({
source: row.source,
externalTxnId: row.externalTxnId,
reason: "transaction_conflict",
});
continue;
}
const txnType = normalizeTransactionType(row.txnType);
const status = normalizeTransactionStatus(row.status);
const record = await fastify.prisma.$transaction(async (tx) => {
const nextCardAuthorizationId = row.hasCardAuthorizationId
? row.cardAuthorizationId ?? null
: existing?.card_authorization_id;
const nextTravelOrderId = row.hasTravelOrderId
? row.travelOrderId ?? null
: existing?.travel_order_id;
const upserted = await tx.cardTransaction.upsert({
where: {
source_external_txn_id: {
source: row.source,
external_txn_id: row.externalTxnId,
},
},
update: {
employee_id: row.employeeId,
department_id: employee.department_id,
travel_request_id: row.travelRequestId,
...(row.hasCardAuthorizationId ? { card_authorization_id: nextCardAuthorizationId } : {}),
...(row.hasTravelOrderId ? { travel_order_id: nextTravelOrderId } : {}),
amount: row.amount,
currency: row.currency,
txn_type: txnType,
status,
occurred_at: row.occurredAt,
raw_payload: row.rawPayload,
updated_by: request.employee.id,
},
create: {
source: row.source,
external_txn_id: row.externalTxnId,
card_authorization_id: nextCardAuthorizationId ?? null,
employee_id: row.employeeId,
department_id: employee.department_id,
travel_request_id: row.travelRequestId,
travel_order_id: nextTravelOrderId ?? null,
amount: row.amount,
currency: row.currency,
txn_type: txnType,
status,
occurred_at: row.occurredAt,
raw_payload: row.rawPayload,
created_by: request.employee.id,
updated_by: request.employee.id,
},
});
const budgetPoolId = authorization?.budget_pool_id ?? travelRequest.budget_pool_id ?? null;
if (txnType === "charge" && status === "settled") {
await settleBudgetForEntity(tx, {
budgetPoolId,
entityId: upserted.id,
amount: row.amount,
actorId: request.employee.id,
note: "travel_transaction_import_settle",
});
}
if (["refund", "reversal"].includes(txnType) || status === "reversed") {
const reverseBudget = await reverseBudgetForEntity(tx, {
budgetPoolId,
entityId: upserted.id,
amount: row.amount,
actorId: request.employee.id,
note: "travel_transaction_import_reverse",
});
if (reverseBudget.applied) {
reversedCount += 1;
}
}
if (nextCardAuthorizationId) {
await syncAuthorizationSnapshot(tx, nextCardAuthorizationId, request.employee.id);
}
const nextOrder = nextTravelOrderId ? await loadOrderById(tx, nextTravelOrderId) : null;
if (
nextOrder &&
txnType === "charge" &&
status === "settled" &&
nextOrder.status === "imported" &&
isOrderAmountAndCurrencyMatched(nextOrder, upserted)
) {
await tx.travelOrder.update({
where: { id: nextOrder.id },
data: {
status: "confirmed",
updated_by: request.employee.id,
},
});
confirmedCount += 1;
}
return tx.cardTransaction.findUnique({
where: { id: upserted.id },
include: {
travel_order: {
select: {
id: true,
external_order_no: true,
status: true,
amount: true,
currency: true,
},
},
},
});
});
imported.push(record);
}
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_TRANSACTIONS_IMPORTED",
targetType: "card_transaction",
extra: {
requested: rows.length,
createdOrUpdated: imported.length,
confirmedCount,
reversedCount,
skipped,
},
}),
});
return sendData(reply, request, {
requested: rows.length,
imported: imported.length,
confirmedCount,
reversedCount,
skipped,
items: imported.filter(Boolean).map(mapTransaction),
});
},
);
fastify.post(
"/travel/transactions/:id/freeze-suggestion",
{
preHandler: fastify.requirePermission("travel.payment.manage"),
},
async (request, reply) => {
const parsedId = UUID_SCHEMA.safeParse(request.params?.id);
if (!parsedId.success) {
throw buildValidationError({ id: "invalid_uuid" });
}
const parsedBody = FREEZE_SUGGESTION_SCHEMA.safeParse(request.body ?? {});
if (!parsedBody.success) {
throw buildValidationError(parsedBody.error.flatten());
}
const transaction = await fastify.prisma.$transaction(async (tx) => {
const current = await tx.cardTransaction.findUnique({
where: { id: parsedId.data },
include: {
travel_order: {
select: {
id: true,
external_order_no: true,
status: true,
amount: true,
currency: true,
},
},
},
});
if (!current) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const updated = await tx.cardTransaction.update({
where: { id: parsedId.data },
data: {
freeze_suggested_at: new Date(),
freeze_suggested_by: request.employee.id,
freeze_reason_code: parsedBody.data.reasonCode,
freeze_reason_note: parsedBody.data.note ?? null,
updated_by: request.employee.id,
},
include: {
travel_order: {
select: {
id: true,
external_order_no: true,
status: true,
amount: true,
currency: true,
},
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "TRAVEL_TRANSACTION_FREEZE_SUGGESTED",
targetType: "card_transaction",
targetId: updated.id,
extra: {
reasonCode: parsedBody.data.reasonCode,
note: parsedBody.data.note ?? null,
travelOrderId: updated.travel_order_id ?? null,
},
}),
});
return updated;
});
return sendData(reply, request, mapTransaction(transaction));
},
);
}
module.exports = travelTransactionsRoutes;