212 行
6.2 KiB
JavaScript
212 行
6.2 KiB
JavaScript
const {
|
|
TRAVEL_RECONCILIATION_MATCH_STATUSES,
|
|
TRAVEL_RECONCILIATION_RESOLUTION_STATUSES,
|
|
buildReconciliationItemsSnapshot,
|
|
} = require('../../../src/services/travelReconciliation');
|
|
|
|
function buildOrder(overrides = {}) {
|
|
return {
|
|
id: 'order-1',
|
|
external_order_no: 'EXT-001',
|
|
linked_reimbursement_id: null,
|
|
travel_request_id: 'travel-1',
|
|
amount: '100.00',
|
|
currency: 'CNY',
|
|
status: 'paid',
|
|
travel_request: {
|
|
id: 'travel-1',
|
|
request_no: 'CL-20260311-0001',
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildTransaction(overrides = {}) {
|
|
return {
|
|
id: 'txn-1',
|
|
external_txn_id: 'TXN-001',
|
|
travel_request_id: 'travel-1',
|
|
travel_order_id: 'order-1',
|
|
amount: '100.00',
|
|
currency: 'CNY',
|
|
txn_type: 'charge',
|
|
status: 'settled',
|
|
travel_request: {
|
|
id: 'travel-1',
|
|
request_no: 'CL-20260311-0001',
|
|
},
|
|
travel_order: {
|
|
id: 'order-1',
|
|
external_order_no: 'EXT-001',
|
|
linked_reimbursement_id: null,
|
|
status: 'paid',
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildInvoice(overrides = {}) {
|
|
return {
|
|
id: 'inv-1',
|
|
travel_order_id: 'order-1',
|
|
total_amount: '100.00',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('travelReconciliation service', () => {
|
|
it('auto-confirms exact matched order items', () => {
|
|
const now = new Date('2026-03-11T00:00:00.000Z');
|
|
const snapshot = buildReconciliationItemsSnapshot({
|
|
orders: [buildOrder()],
|
|
transactions: [buildTransaction()],
|
|
invoices: [buildInvoice()],
|
|
actorId: 'actor-1',
|
|
now,
|
|
});
|
|
|
|
expect(snapshot.summary).toEqual(
|
|
expect.objectContaining({
|
|
totalOrders: 1,
|
|
totalTransactions: 1,
|
|
matchedItems: 1,
|
|
pendingItems: 0,
|
|
anomalyItems: 0,
|
|
totalOrderAmount: '100.00',
|
|
totalSettledAmount: '100.00',
|
|
totalReversedAmount: '0.00',
|
|
matchRate: '100.00',
|
|
}),
|
|
);
|
|
|
|
expect(snapshot.items[0]).toEqual(
|
|
expect.objectContaining({
|
|
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.matched,
|
|
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.confirmed,
|
|
confirmed_by: 'actor-1',
|
|
confirmed_at: now,
|
|
snapshot_json: expect.objectContaining({
|
|
boundInvoiceCount: 1,
|
|
boundInvoiceAmount: '100.00',
|
|
matchedTransactionIds: ['txn-1'],
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('marks amount mismatch when settled charge amount differs', () => {
|
|
const snapshot = buildReconciliationItemsSnapshot({
|
|
orders: [buildOrder()],
|
|
transactions: [buildTransaction({ amount: '88.00' })],
|
|
invoices: [],
|
|
actorId: 'actor-1',
|
|
now: new Date('2026-03-11T00:00:00.000Z'),
|
|
});
|
|
|
|
expect(snapshot.items[0]).toEqual(
|
|
expect.objectContaining({
|
|
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch,
|
|
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
|
|
anomaly_code: TRAVEL_RECONCILIATION_MATCH_STATUSES.amountMismatch,
|
|
transaction_amount: '88.00',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('marks currency mismatch when settled charge currency differs', () => {
|
|
const snapshot = buildReconciliationItemsSnapshot({
|
|
orders: [buildOrder()],
|
|
transactions: [buildTransaction({ currency: 'USD' })],
|
|
invoices: [],
|
|
actorId: 'actor-1',
|
|
now: new Date('2026-03-11T00:00:00.000Z'),
|
|
});
|
|
|
|
expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.currencyMismatch);
|
|
expect(snapshot.items[0].resolution_status).toBe(TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending);
|
|
});
|
|
|
|
it('marks missing transaction when no settled charge exists', () => {
|
|
const snapshot = buildReconciliationItemsSnapshot({
|
|
orders: [buildOrder()],
|
|
transactions: [],
|
|
invoices: [],
|
|
actorId: 'actor-1',
|
|
now: new Date('2026-03-11T00:00:00.000Z'),
|
|
});
|
|
|
|
expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.missingTransaction);
|
|
});
|
|
|
|
it('marks duplicate charge when multiple settled charges exist', () => {
|
|
const snapshot = buildReconciliationItemsSnapshot({
|
|
orders: [buildOrder()],
|
|
transactions: [
|
|
buildTransaction({ id: 'txn-1', external_txn_id: 'TXN-001' }),
|
|
buildTransaction({ id: 'txn-2', external_txn_id: 'TXN-002' }),
|
|
],
|
|
invoices: [],
|
|
actorId: 'actor-1',
|
|
now: new Date('2026-03-11T00:00:00.000Z'),
|
|
});
|
|
|
|
expect(snapshot.items[0]).toEqual(
|
|
expect.objectContaining({
|
|
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.duplicateCharge,
|
|
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('marks reversed after payment when refund or reversal appears', () => {
|
|
const snapshot = buildReconciliationItemsSnapshot({
|
|
orders: [buildOrder()],
|
|
transactions: [buildTransaction({ txn_type: 'refund', status: 'settled' })],
|
|
invoices: [],
|
|
actorId: 'actor-1',
|
|
now: new Date('2026-03-11T00:00:00.000Z'),
|
|
});
|
|
|
|
expect(snapshot.items[0].match_status).toBe(TRAVEL_RECONCILIATION_MATCH_STATUSES.reversedAfterPayment);
|
|
});
|
|
|
|
it('creates orphan transaction items for unlinked settled or reversed transactions', () => {
|
|
const snapshot = buildReconciliationItemsSnapshot({
|
|
orders: [buildOrder()],
|
|
transactions: [
|
|
buildTransaction(),
|
|
buildTransaction({
|
|
id: 'txn-orphan',
|
|
external_txn_id: 'TXN-ORPHAN',
|
|
travel_order_id: null,
|
|
txn_type: 'charge',
|
|
status: 'settled',
|
|
travel_order: null,
|
|
}),
|
|
],
|
|
invoices: [],
|
|
actorId: 'actor-1',
|
|
now: new Date('2026-03-11T00:00:00.000Z'),
|
|
});
|
|
|
|
const orphan = snapshot.items.find((item) => item.card_transaction_id === 'txn-orphan');
|
|
expect(orphan).toEqual(
|
|
expect.objectContaining({
|
|
item_kind: 'orphan_transaction',
|
|
match_status: TRAVEL_RECONCILIATION_MATCH_STATUSES.orphanTransaction,
|
|
resolution_status: TRAVEL_RECONCILIATION_RESOLUTION_STATUSES.pending,
|
|
}),
|
|
);
|
|
expect(snapshot.summary).toEqual(
|
|
expect.objectContaining({
|
|
totalOrders: 1,
|
|
totalTransactions: 2,
|
|
matchedItems: 1,
|
|
pendingItems: 1,
|
|
anomalyItems: 1,
|
|
matchRate: '50.00',
|
|
}),
|
|
);
|
|
});
|
|
});
|