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

这个提交包含在:
2026-03-11 06:41:11 +08:00
父节点 539c2cc4dd
当前提交 dc74d9e53f
共修改 23 个文件,包含 4265 行新增和 218 行删除
@@ -106,6 +106,17 @@ function resolveBindingSpecs(eventType, payload, response) {
),
].filter(Boolean);
}
if (journalType === 'travel_order_payment') {
return [
createBindingSpec(
'travel_order_payment_journal',
payload?.references?.travelOrderId ?? payload?.entityId,
extractResponseValue(response, 'externalId'),
extractResponseValue(response, 'externalCode') ?? payload?.references?.externalOrderNo ?? payload?.documentNo ?? null,
),
].filter(Boolean);
}
}
if (eventType === 'erp.procurement.export') {
+177 -11
查看文件
@@ -2,6 +2,7 @@ const { enqueueIntegrationEvent } = require('./outbox');
const ERP_JOURNAL_EVENT_TYPES = Object.freeze(['erp.journal.export', 'erp.reimbursement.paid']);
const REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX = 'reimbursement';
const TRAVEL_ORDER_JOURNAL_DEDUPE_PREFIX = 'travel_order';
function toDateOnly(value) {
if (!value) {
@@ -25,12 +26,20 @@ function toIsoString(value) {
}
}
function resolveLegalEntityCode(reimbursement) {
return (
reimbursement?.owner?.legal_entity?.code ??
reimbursement?.submitter?.legal_entity?.code ??
null
);
function resolveReimbursementLegalEntityCode(reimbursement) {
return reimbursement?.owner?.legal_entity?.code ?? reimbursement?.submitter?.legal_entity?.code ?? null;
}
function resolveTravelRequestLegalEntityCode(travelOrder) {
return travelOrder?.travel_request?.legal_entity?.code ?? null;
}
function resolveTravelRequestCostCenterCode(travelOrder) {
return travelOrder?.travel_request?.cost_center?.code ?? null;
}
function resolveTravelRequestBudgetPoolCode(travelOrder) {
return travelOrder?.travel_request?.budget_pool?.code ?? null;
}
function buildReimbursementJournalPayload(input) {
@@ -39,7 +48,7 @@ function buildReimbursementJournalPayload(input) {
toIsoString(reimbursement.paid_at) ??
(reimbursement.payment_date ? `${toDateOnly(reimbursement.payment_date)}T00:00:00.000Z` : null);
const payload = {
return {
journalType: 'reimbursement_paid',
sourceEventName: 'reimbursement.paid',
entityType: 'reimbursement_form',
@@ -54,23 +63,65 @@ function buildReimbursementJournalPayload(input) {
departmentId: reimbursement.department_id ?? null,
ownerEmployeeId: reimbursement.owner_id ?? null,
submitterEmployeeId: reimbursement.submitter_id ?? null,
legalEntityCode: resolveLegalEntityCode(reimbursement),
legalEntityCode: resolveReimbursementLegalEntityCode(reimbursement),
costCenterCode: null,
},
reimbursementId: reimbursement.id,
formNumber: reimbursement.form_number ?? null,
};
}
return payload;
function buildTravelOrderPaymentJournalPayload(input) {
const travelOrder = input?.travelOrder ?? {};
const travelRequest = travelOrder?.travel_request ?? {};
const occurredAt =
toIsoString(travelOrder.paid_at) ??
(travelOrder.payment_date ? `${toDateOnly(travelOrder.payment_date)}T00:00:00.000Z` : null) ??
toIsoString(travelOrder.updated_at) ??
toIsoString(travelOrder.created_at);
return {
journalType: 'travel_order_payment',
sourceEventName: 'travel_order.paid',
entityType: 'travel_order',
entityId: travelOrder.id ?? null,
documentNo: travelOrder.external_order_no ?? null,
currencyCode: travelOrder.currency ?? 'CNY',
totalAmount: travelOrder.amount?.toString?.() ?? null,
occurredAt,
paymentVoucherNo: travelOrder.payment_voucher_no ?? null,
paymentDate: toDateOnly(travelOrder.payment_date),
paymentChannel: travelOrder.payment_channel ?? null,
dimensions: {
departmentId: travelOrder.department_id ?? travelRequest.department_id ?? null,
employeeId: travelOrder.employee_id ?? null,
applicantEmployeeId: travelRequest.applicant_employee_id ?? null,
submitterEmployeeId: travelRequest.submitter_id ?? null,
legalEntityCode: resolveTravelRequestLegalEntityCode(travelOrder),
costCenterCode: resolveTravelRequestCostCenterCode(travelOrder),
budgetPoolCode: resolveTravelRequestBudgetPoolCode(travelOrder),
},
references: {
travelOrderId: travelOrder.id ?? null,
travelRequestId: travelOrder.travel_request_id ?? null,
requestNo: travelRequest.request_no ?? null,
externalOrderNo: travelOrder.external_order_no ?? null,
source: travelOrder.source ?? null,
linkedReimbursementId: travelOrder.linked_reimbursement_id ?? null,
},
};
}
function buildReimbursementJournalDedupeKey(reimbursementId) {
return `${REIMBURSEMENT_JOURNAL_DEDUPE_PREFIX}:${reimbursementId}:paid`;
}
function buildTravelOrderPaymentJournalDedupeKey(travelOrderId) {
return `${TRAVEL_ORDER_JOURNAL_DEDUPE_PREFIX}:${travelOrderId}:paid`;
}
async function enqueueReimbursementJournalExport(prisma, config, input) {
const payload =
input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement });
const payload = input?.payload ?? buildReimbursementJournalPayload({ reimbursement: input?.reimbursement });
const enqueued = await enqueueIntegrationEvent(prisma, {
eventType: 'erp.journal.export',
@@ -88,6 +139,25 @@ async function enqueueReimbursementJournalExport(prisma, config, input) {
};
}
async function enqueueTravelOrderPaymentJournalExport(prisma, config, input) {
const payload = input?.payload ?? buildTravelOrderPaymentJournalPayload({ travelOrder: input?.travelOrder });
const enqueued = await enqueueIntegrationEvent(prisma, {
eventType: 'erp.journal.export',
payload,
dedupeKey: buildTravelOrderPaymentJournalDedupeKey(payload.entityId),
maxAttempts: config.integrationOutboxMaxAttempts,
actorId: input?.actorId ?? null,
requeueOnFailure: true,
});
return {
...enqueued,
eventType: 'erp.journal.export',
payload,
};
}
function mapIntegrationBinding(record) {
if (!record) {
return null;
@@ -141,6 +211,16 @@ async function findLatestReimbursementJournalOutbox(prisma, reimbursementId) {
});
}
async function findLatestTravelOrderPaymentJournalOutbox(prisma, travelOrderId) {
return prisma.integrationOutbox.findFirst({
where: {
event_type: 'erp.journal.export',
dedupe_key: buildTravelOrderPaymentJournalDedupeKey(travelOrderId),
},
orderBy: { updated_at: 'desc' },
});
}
async function buildReimbursementJournalPreview(prisma, reimbursement) {
const [binding, outbox] = await Promise.all([
prisma.integrationBinding.findUnique({
@@ -172,11 +252,97 @@ async function buildReimbursementJournalPreview(prisma, reimbursement) {
};
}
async function buildTravelOrderPaymentJournalPreview(prisma, travelOrderInput) {
const travelOrder = travelOrderInput?.travel_request
? travelOrderInput
: await prisma.travelOrder.findUnique({
where: { id: travelOrderInput.id },
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,
payment_voucher_no: true,
payment_date: true,
payment_channel: true,
paid_at: 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 } },
},
},
},
});
if (!travelOrder) {
return {
journal: null,
exportState: {
externalId: null,
lastOutboxId: null,
lastOutboxStatus: null,
lastError: null,
exportedAt: null,
},
binding: null,
outbox: null,
};
}
const [binding, outbox] = await Promise.all([
prisma.integrationBinding.findUnique({
where: {
provider_entity_type_local_id: {
provider: 'erp',
entity_type: 'travel_order_payment_journal',
local_id: travelOrder.id,
},
},
}),
findLatestTravelOrderPaymentJournalOutbox(prisma, travelOrder.id),
]);
const journal = buildTravelOrderPaymentJournalPayload({ travelOrder });
const exportState = {
externalId: binding?.external_id ?? null,
lastOutboxId: outbox?.id ?? null,
lastOutboxStatus: outbox?.status ?? null,
lastError: outbox?.last_error ?? null,
exportedAt: binding?.last_synced_at?.toISOString?.() ?? outbox?.processed_at?.toISOString?.() ?? null,
};
return {
journal,
exportState,
binding: mapIntegrationBinding(binding),
outbox: mapIntegrationOutboxState(outbox),
};
}
module.exports = {
ERP_JOURNAL_EVENT_TYPES,
buildReimbursementJournalDedupeKey,
buildReimbursementJournalPayload,
buildReimbursementJournalPreview,
buildTravelOrderPaymentJournalDedupeKey,
buildTravelOrderPaymentJournalPayload,
buildTravelOrderPaymentJournalPreview,
enqueueReimbursementJournalExport,
enqueueTravelOrderPaymentJournalExport,
findLatestReimbursementJournalOutbox,
findLatestTravelOrderPaymentJournalOutbox,
};
+106 -8
查看文件
@@ -1,3 +1,30 @@
function toDateOnly(value) {
if (!value) {
return null;
}
const date = new Date(`${String(value).slice(0, 10)}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function toDateTime(value) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function toMoneyString(value) {
if (value == null || value === "") {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric.toFixed(2) : null;
}
function normalizeTravelImportRows(rows) {
if (!Array.isArray(rows)) {
return [];
@@ -12,12 +39,6 @@ function normalizeTravelImportRows(rows) {
return null;
}
const amountRaw = item?.amount;
const amount = amountRaw == null || amountRaw === "" ? null : Number(amountRaw);
const occurredAt = item?.occurredAt
? new Date(`${String(item.occurredAt).slice(0, 10)}T00:00:00.000Z`)
: null;
const hasTravelRequestId = Object.prototype.hasOwnProperty.call(item ?? {}, "travelRequestId");
const rawTravelRequestId = String(item?.travelRequestId ?? "").trim();
@@ -31,9 +52,84 @@ function normalizeTravelImportRows(rows) {
: null,
travelRequestId: rawTravelRequestId || null,
hasTravelRequestId,
amount: Number.isFinite(amount) ? amount.toFixed(2) : null,
amount: toMoneyString(item?.amount),
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
occurredAt,
occurredAt: toDateOnly(item?.occurredAt),
rawPayload: item ?? null,
};
})
.filter(Boolean);
}
function normalizeTravelAuthorizationRows(rows) {
if (!Array.isArray(rows)) {
return [];
}
return rows
.map((item) => {
const source = String(item?.source ?? "manual").trim().toLowerCase();
const externalAuthNo = String(item?.externalAuthNo ?? item?.externalAuthNumber ?? "").trim();
const employeeId = String(item?.employeeId ?? "").trim();
const travelRequestId = String(item?.travelRequestId ?? "").trim();
if (!externalAuthNo || !employeeId || !travelRequestId) {
return null;
}
return {
source,
externalAuthNo,
employeeId,
travelRequestId,
budgetPoolId:
item?.budgetPoolId && String(item.budgetPoolId).trim()
? String(item.budgetPoolId).trim()
: null,
authorizedAmount: toMoneyString(item?.authorizedAmount),
settledAmount: toMoneyString(item?.settledAmount) ?? "0.00",
reversedAmount: toMoneyString(item?.reversedAmount) ?? "0.00",
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
occurredAt: toDateTime(item?.occurredAt),
expiresAt: toDateTime(item?.expiresAt),
status: String(item?.status ?? "authorized").trim().toLowerCase() || "authorized",
rawPayload: item ?? null,
};
})
.filter(Boolean);
}
function normalizeTravelTransactionRows(rows) {
if (!Array.isArray(rows)) {
return [];
}
return rows
.map((item) => {
const source = String(item?.source ?? "manual").trim().toLowerCase();
const externalTxnId = String(item?.externalTxnId ?? item?.externalTransactionId ?? "").trim();
const employeeId = String(item?.employeeId ?? "").trim();
const travelRequestId = String(item?.travelRequestId ?? "").trim();
if (!externalTxnId || !employeeId || !travelRequestId) {
return null;
}
const rawCardAuthorizationId = String(item?.cardAuthorizationId ?? "").trim();
const rawTravelOrderId = String(item?.travelOrderId ?? "").trim();
return {
source,
externalTxnId,
employeeId,
travelRequestId,
cardAuthorizationId: rawCardAuthorizationId || null,
hasCardAuthorizationId: Object.prototype.hasOwnProperty.call(item ?? {}, "cardAuthorizationId"),
travelOrderId: rawTravelOrderId || null,
hasTravelOrderId: Object.prototype.hasOwnProperty.call(item ?? {}, "travelOrderId"),
amount: toMoneyString(item?.amount),
currency: String(item?.currency ?? "CNY").trim().toUpperCase() || "CNY",
txnType: String(item?.txnType ?? "charge").trim().toLowerCase() || "charge",
status: String(item?.status ?? "pending").trim().toLowerCase() || "pending",
occurredAt: toDateTime(item?.occurredAt),
rawPayload: item ?? null,
};
})
@@ -42,4 +138,6 @@ function normalizeTravelImportRows(rows) {
module.exports = {
normalizeTravelImportRows,
normalizeTravelAuthorizationRows,
normalizeTravelTransactionRows,
};
+725
查看文件
@@ -0,0 +1,725 @@
const { AppError } = require("../utils/AppError");
const { toMoneyString } = require("../utils/workflow");
const { summarizeBudgetLedgerByPool } = require("./purchaseBudget");
const { buildTravelOrderPaymentJournalPreview } = require("./integrations/erpJournal");
function buildTravelPaymentValidationError(details, statusCode = 409) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function toMoneyNumber(value) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric)) {
return 0;
}
return Number(numeric.toFixed(2));
}
function toMoneyFixed(value) {
return toMoneyNumber(value).toFixed(2);
}
function appendCorporateSummary(summary, entry) {
const poolId = entry?.budget_pool_id;
if (!poolId) {
return;
}
if (!summary.has(poolId)) {
summary.set(poolId, {
budgetPoolId: poolId,
authorize: 0,
settle: 0,
reverse: 0,
});
}
const current = summary.get(poolId);
const amount = toMoneyNumber(entry.amount);
if (entry.kind === "authorize") {
current.authorize = toMoneyNumber(current.authorize + amount);
} else if (entry.kind === "settle") {
current.settle = toMoneyNumber(current.settle + amount);
} else if (entry.kind === "reverse") {
current.reverse = toMoneyNumber(current.reverse + amount);
}
}
function summarizeCorporateBudgetEntries(entries = []) {
const summary = new Map();
for (const entry of entries) {
appendCorporateSummary(summary, entry);
}
return Array.from(summary.values()).map((item) => ({
...item,
outstanding: toMoneyNumber(Math.max(0, item.authorize - item.reverse)),
settlePending: toMoneyNumber(Math.max(0, item.authorize - item.reverse - item.settle)),
}));
}
async function listEntityBudgetEntries(tx, { entityType, entityId }) {
return tx.budgetLedgerEntry.findMany({
where: {
entity_type: entityType,
entity_id: entityId,
},
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
budget_pool_id: true,
entity_type: true,
entity_id: true,
kind: true,
amount: true,
created_at: true,
},
});
}
async function listCorporateBudgetEntries(tx, { authorizationIds = [], transactionIds = [] } = {}) {
const filters = [];
if (authorizationIds.length) {
filters.push({
entity_type: "card_authorization",
entity_id: { in: authorizationIds },
});
}
if (transactionIds.length) {
filters.push({
entity_type: "card_transaction",
entity_id: { in: transactionIds },
});
}
if (!filters.length) {
return [];
}
return tx.budgetLedgerEntry.findMany({
where: { OR: filters },
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
budget_pool_id: true,
entity_type: true,
entity_id: true,
kind: true,
amount: true,
created_at: true,
},
});
}
async function appendBudgetLedgerEntry(tx, {
budgetPoolId,
entityType,
entityId,
kind,
amount,
actorId,
note,
}) {
return tx.budgetLedgerEntry.create({
data: {
budget_pool_id: budgetPoolId,
entity_type: entityType,
entity_id: entityId,
kind,
amount: toMoneyFixed(amount),
note,
created_by: actorId,
},
});
}
async function applyBudgetPoolUsageDelta(tx, { budgetPoolId, deltaAmount, actorId }) {
if (!budgetPoolId || !deltaAmount) {
return;
}
const budgetPool = await tx.budgetPool.findUnique({
where: { id: budgetPoolId },
select: { id: true, used_amount: true },
});
if (!budgetPool) {
throw buildTravelPaymentValidationError({ budgetPoolId: "not_found" });
}
const nextUsedAmount = Math.max(0, toMoneyNumber(budgetPool.used_amount) + toMoneyNumber(deltaAmount));
await tx.budgetPool.update({
where: { id: budgetPoolId },
data: {
used_amount: toMoneyFixed(nextUsedAmount),
updated_by: actorId,
},
});
}
async function authorizeBudgetForEntity(tx, {
budgetPoolId,
entityId,
amount,
actorId,
note,
}) {
if (!budgetPoolId) {
return { applied: false, authorizedAmount: "0.00", duplicated: false };
}
const normalizedAmount = toMoneyNumber(amount);
if (normalizedAmount <= 0) {
return { applied: false, authorizedAmount: "0.00", duplicated: false };
}
const existingEntries = await listEntityBudgetEntries(tx, {
entityType: "card_authorization",
entityId,
});
if (existingEntries.some((item) => item.kind === "authorize")) {
const current = summarizeCorporateBudgetEntries(existingEntries)[0];
return {
applied: false,
duplicated: true,
authorizedAmount: toMoneyFixed(current?.authorize ?? normalizedAmount),
};
}
await appendBudgetLedgerEntry(tx, {
budgetPoolId,
entityType: "card_authorization",
entityId,
kind: "authorize",
amount: normalizedAmount,
actorId,
note,
});
await applyBudgetPoolUsageDelta(tx, {
budgetPoolId,
deltaAmount: normalizedAmount,
actorId,
});
return {
applied: true,
duplicated: false,
authorizedAmount: toMoneyFixed(normalizedAmount),
};
}
async function settleBudgetForEntity(tx, {
budgetPoolId,
entityId,
amount,
actorId,
note,
}) {
if (!budgetPoolId) {
return { applied: false, settledAmount: "0.00", duplicated: false };
}
const normalizedAmount = toMoneyNumber(amount);
if (normalizedAmount <= 0) {
return { applied: false, settledAmount: "0.00", duplicated: false };
}
const existingEntries = await listEntityBudgetEntries(tx, {
entityType: "card_transaction",
entityId,
});
if (existingEntries.some((item) => item.kind === "settle")) {
const current = summarizeCorporateBudgetEntries(existingEntries)[0];
return {
applied: false,
duplicated: true,
settledAmount: toMoneyFixed(current?.settle ?? normalizedAmount),
};
}
await appendBudgetLedgerEntry(tx, {
budgetPoolId,
entityType: "card_transaction",
entityId,
kind: "settle",
amount: normalizedAmount,
actorId,
note,
});
return {
applied: true,
duplicated: false,
settledAmount: toMoneyFixed(normalizedAmount),
};
}
async function reverseBudgetForEntity(tx, {
budgetPoolId,
entityId,
amount,
actorId,
note,
}) {
if (!budgetPoolId) {
return { applied: false, reversedAmount: "0.00", duplicated: false };
}
const normalizedAmount = toMoneyNumber(amount);
if (normalizedAmount <= 0) {
return { applied: false, reversedAmount: "0.00", duplicated: false };
}
const existingEntries = await listEntityBudgetEntries(tx, {
entityType: "card_transaction",
entityId,
});
if (existingEntries.some((item) => item.kind === "reverse")) {
const current = summarizeCorporateBudgetEntries(existingEntries)[0];
return {
applied: false,
duplicated: true,
reversedAmount: toMoneyFixed(current?.reverse ?? normalizedAmount),
};
}
await appendBudgetLedgerEntry(tx, {
budgetPoolId,
entityType: "card_transaction",
entityId,
kind: "reverse",
amount: normalizedAmount,
actorId,
note,
});
await applyBudgetPoolUsageDelta(tx, {
budgetPoolId,
deltaAmount: -normalizedAmount,
actorId,
});
return {
applied: true,
duplicated: false,
reversedAmount: toMoneyFixed(normalizedAmount),
};
}
function mapBudgetPoolSummary(summaryItems, budgetPoolMap, mode) {
return summaryItems.map((item) => {
const pool = budgetPoolMap.get(item.budgetPoolId);
const base = {
budgetPoolId: item.budgetPoolId,
budgetPoolCode: pool?.code ?? null,
budgetPoolName: pool?.name ?? null,
outstanding: toMoneyFixed(item.outstanding),
};
if (mode === "request") {
return {
...base,
reserve: toMoneyFixed(item.reserve),
confirm: toMoneyFixed(item.confirm),
release: toMoneyFixed(item.release),
confirmPending: toMoneyFixed(item.confirmPending),
};
}
return {
...base,
authorize: toMoneyFixed(item.authorize),
settle: toMoneyFixed(item.settle),
reverse: toMoneyFixed(item.reverse),
settlePending: toMoneyFixed(item.settlePending),
};
});
}
function buildTransactionAnomalies(transaction, order) {
const anomalies = [];
if (!transaction.travel_order_id) {
anomalies.push({
code: "missing_order",
level: "medium",
title: "未匹配差旅订单",
detail: `交易 ${transaction.external_txn_id} 尚未关联 travel_order。`,
transactionId: transaction.id,
travelOrderId: null,
});
}
if (order) {
if (String(order.currency ?? "") !== String(transaction.currency ?? "")) {
anomalies.push({
code: "currency_mismatch",
level: "high",
title: "币种不一致",
detail: `订单 ${order.external_order_no} 与交易 ${transaction.external_txn_id} 币种不一致。`,
transactionId: transaction.id,
travelOrderId: order.id,
});
}
if (toMoneyFixed(order.amount) !== toMoneyFixed(transaction.amount)) {
anomalies.push({
code: "amount_mismatch",
level: "high",
title: "金额不一致",
detail: `订单 ${order.external_order_no} 与交易 ${transaction.external_txn_id} 金额不一致。`,
transactionId: transaction.id,
travelOrderId: order.id,
});
}
}
if (transaction.freeze_reason_code) {
anomalies.push({
code: transaction.freeze_reason_code,
level: "medium",
title: "已标记人工冻结建议",
detail: transaction.freeze_reason_note || `交易 ${transaction.external_txn_id} 已被标记人工复核。`,
transactionId: transaction.id,
travelOrderId: transaction.travel_order_id ?? null,
});
}
return anomalies;
}
function isOrderAmountAndCurrencyMatched(order, transaction) {
if (!order || !transaction) {
return false;
}
return (
toMoneyFixed(order.amount) === toMoneyFixed(transaction.amount) &&
String(order.currency ?? "").toUpperCase() === String(transaction.currency ?? "").toUpperCase()
);
}
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: toMoneyFixed(item.authorized_amount),
settledAmount: toMoneyFixed(item.settled_amount),
reversedAmount: toMoneyFixed(item.reversed_amount),
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 mapTransaction(item, ordersById) {
const order = item.travel_order_id ? ordersById.get(item.travel_order_id) ?? null : null;
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: order
? {
id: order.id,
externalOrderNo: order.external_order_no,
status: order.status,
amount: toMoneyFixed(order.amount),
currency: order.currency,
exactMatch: isOrderAmountAndCurrencyMatched(order, item),
}
: null,
};
}
function mapInvoice(item) {
return {
id: item.id,
status: item.status,
originalName: item.original_name ?? null,
totalAmount: toMoneyString(item.total_amount),
invoiceCode: item.invoice_code ?? null,
invoiceNumber: item.invoice_number ?? null,
invoiceDate: item.invoice_date?.toISOString?.()?.slice(0, 10) ?? null,
linkedReimbursementId: item.linked_reimbursement_id ?? null,
linkedTravelOrderId: item.travel_order_id ?? null,
};
}
async function buildTravelPaymentTrack(prisma, travelRequestId, { canManagePayments = false } = {}) {
const request = await prisma.travelRequest.findUnique({
where: { id: travelRequestId },
select: {
id: true,
request_no: true,
budget_pool_id: true,
travel_orders: {
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
select: {
id: true,
source: true,
external_order_no: true,
employee_id: true,
department_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,
},
},
card_authorizations: {
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
select: {
id: true,
source: true,
external_auth_no: true,
employee_id: true,
department_id: true,
travel_request_id: true,
budget_pool_id: true,
legal_entity_id: true,
cost_center_id: true,
authorized_amount: true,
settled_amount: true,
reversed_amount: true,
currency: true,
occurred_at: true,
expires_at: true,
status: true,
created_at: true,
updated_at: true,
},
},
card_transactions: {
orderBy: [{ occurred_at: "desc" }, { created_at: "desc" }],
select: {
id: true,
source: true,
external_txn_id: true,
card_authorization_id: true,
employee_id: true,
department_id: true,
travel_request_id: true,
travel_order_id: true,
amount: true,
currency: true,
txn_type: true,
status: true,
occurred_at: true,
freeze_suggested_at: true,
freeze_suggested_by: true,
freeze_reason_code: true,
freeze_reason_note: true,
created_at: true,
updated_at: true,
},
},
},
});
if (!request) {
throw new AppError({
code: "SYS_404",
message: "Not Found",
statusCode: 404,
});
}
const orderIds = request.travel_orders.map((item) => item.id);
const authorizationIds = request.card_authorizations.map((item) => item.id);
const transactionIds = request.card_transactions.map((item) => item.id);
const [boundInvoices, requestBudgetEntries, corporateBudgetEntries] = await Promise.all([
orderIds.length
? prisma.invoicePool.findMany({
where: { travel_order_id: { in: orderIds } },
orderBy: [{ created_at: "desc" }],
select: {
id: true,
status: true,
original_name: true,
total_amount: true,
invoice_code: true,
invoice_number: true,
invoice_date: true,
linked_reimbursement_id: true,
travel_order_id: true,
},
})
: Promise.resolve([]),
prisma.budgetLedgerEntry.findMany({
where: {
entity_type: "travel_request",
entity_id: travelRequestId,
},
orderBy: [{ created_at: "asc" }, { id: "asc" }],
select: {
id: true,
budget_pool_id: true,
kind: true,
amount: true,
created_at: true,
},
}),
listCorporateBudgetEntries(prisma, { authorizationIds, transactionIds }),
]);
const budgetPoolIds = Array.from(new Set([
...requestBudgetEntries.map((item) => item.budget_pool_id).filter(Boolean),
...corporateBudgetEntries.map((item) => item.budget_pool_id).filter(Boolean),
...request.card_authorizations.map((item) => item.budget_pool_id).filter(Boolean),
]));
const budgetPools = budgetPoolIds.length
? await prisma.budgetPool.findMany({
where: { id: { in: budgetPoolIds } },
select: { id: true, code: true, name: true },
})
: [];
const budgetPoolMap = new Map(budgetPools.map((item) => [item.id, item]));
const orderInvoicesMap = new Map();
for (const invoice of boundInvoices) {
if (!invoice.travel_order_id) {
continue;
}
const items = orderInvoicesMap.get(invoice.travel_order_id) ?? [];
items.push(mapInvoice(invoice));
orderInvoicesMap.set(invoice.travel_order_id, items);
}
const ordersById = new Map(request.travel_orders.map((item) => [item.id, item]));
const transactionsByOrderId = new Map();
for (const transaction of request.card_transactions) {
if (!transaction.travel_order_id) {
continue;
}
const items = transactionsByOrderId.get(transaction.travel_order_id) ?? [];
items.push(transaction);
transactionsByOrderId.set(transaction.travel_order_id, items);
}
const orderStages = await Promise.all(
request.travel_orders.map(async (order) => {
const preview = await buildTravelOrderPaymentJournalPreview(prisma, order);
return [order.id, preview];
}),
);
const orderStageMap = new Map(orderStages);
const anomalies = request.card_transactions.flatMap((transaction) =>
buildTransactionAnomalies(transaction, transaction.travel_order_id ? ordersById.get(transaction.travel_order_id) : null),
);
return {
travelRequestId: request.id,
requestNo: request.request_no ?? null,
requestBudgetTrack: {
items: mapBudgetPoolSummary(summarizeBudgetLedgerByPool(requestBudgetEntries), budgetPoolMap, "request"),
},
corporateBudgetTrack: {
items: mapBudgetPoolSummary(summarizeCorporateBudgetEntries(corporateBudgetEntries), budgetPoolMap, "corporate"),
},
authorizations: request.card_authorizations.map(mapAuthorization),
transactions: request.card_transactions.map((item) => mapTransaction(item, ordersById)),
travelOrders: request.travel_orders.map((order) => {
const matchedTransactions = (transactionsByOrderId.get(order.id) ?? []).map((item) => mapTransaction(item, ordersById));
const stage = orderStageMap.get(order.id) ?? {
journal: null,
exportState: {
externalId: null,
lastOutboxId: null,
lastOutboxStatus: null,
lastError: null,
exportedAt: null,
},
binding: null,
outbox: null,
};
return {
id: order.id,
externalOrderNo: order.external_order_no,
source: order.source,
amount: toMoneyFixed(order.amount),
currency: order.currency,
status: order.status,
paymentVoucherNo: order.payment_voucher_no ?? null,
paymentDate: order.payment_date?.toISOString?.()?.slice(0, 10) ?? null,
paymentChannel: order.payment_channel ?? null,
paidAt: order.paid_at?.toISOString?.() ?? null,
paidBy: order.paid_by ?? null,
boundInvoices: orderInvoicesMap.get(order.id) ?? [],
matchedTransactions,
erpStage: {
binding: stage.binding,
outbox: stage.outbox,
exportState: stage.exportState,
},
actions: {
canMarkPaid: canManagePayments && order.status === "confirmed",
canBindInvoice: canManagePayments && ["confirmed", "paid"].includes(order.status),
},
};
}),
anomalies,
permissions: {
canManagePayments,
},
};
}
module.exports = {
authorizeBudgetForEntity,
settleBudgetForEntity,
reverseBudgetForEntity,
summarizeCorporateBudgetEntries,
buildTravelPaymentTrack,
buildTravelPaymentValidationError,
isOrderAmountAndCurrencyMatched,
};