This commit is contained in:
2026-10-12 17:59:54 +08:00
parent 633690b0c4
commit b958813406
7 changed files with 64 additions and 279 deletions
+13 -206
View File
@@ -1,216 +1,23 @@
const { log } = require('../../../logger')
function joinUrl(baseUrl, pathname) {
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`
return new URL(pathname.replace(/^\//, ''), normalizedBaseUrl).toString()
}
function createRequestOptions(method, body, headers, timeoutMs) {
return {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
timeoutMs,
}
}
async function requestJson(url, options) {
let response
try {
response = await fetch(url, options)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (error?.name === 'TimeoutError' || /aborted due to timeout/i.test(message)) {
throw new Error(
`Timeout after ${options.timeoutMs}ms on ${options.method || 'GET'} request for "${url}"`,
)
}
throw error
}
const rawBody = await response.text()
if (!response.ok) {
throw new Error(
`HTTP ${response.status} ${response.statusText} on ${options.method || 'GET'} request for "${url}"${
rawBody ? `: ${rawBody}` : ''
}`,
)
}
if (!rawBody) {
return null
}
try {
return JSON.parse(rawBody)
} catch (error) {
throw new Error(
`Invalid JSON response on ${options.method || 'GET'} request for "${url}": "${rawBody}"`,
)
}
}
function unwrapApiResponseBody(responseBody, requestLabel) {
if (!responseBody || typeof responseBody !== 'object') {
throw new Error(`${requestLabel} returned an empty response body`)
}
if (Object.prototype.hasOwnProperty.call(responseBody, 'success')) {
if (responseBody.success === false) {
const detail =
responseBody.error?.detail || responseBody.message || `${requestLabel} returned an error`
throw new Error(detail)
}
return responseBody.data
}
return responseBody
}
async function loginToEwallet(context) {
const baseUrl = context.promotionRedemptionBridgeBaseUrl
if (!baseUrl) {
throw new Error('promotionRedemptionBridgeBaseUrl is not configured; cannot bridge void to eWallet')
}
const loginUsername = context.promotionRedemptionBridgeLoginUsername || 'mock.gateway'
const authUrl = joinUrl(baseUrl, '/api/v1/auth/login')
const authResp = await requestJson(
authUrl,
createRequestOptions(
'POST',
{
username: loginUsername,
password: context.promotionRedemptionBridgeLoginPassword,
},
{ 'Content-Type': 'application/json' },
context.promotionRedemptionBridgeTimeoutMs,
),
)
const authData = unwrapApiResponseBody(authResp, 'Promotion void bridge login')
const accessToken = authData?.accessToken
if (!accessToken) {
throw new Error('Promotion void bridge login did not return an access token')
}
return accessToken
}
// Mirror playerPromotionRedeem.bridgePromotionRedeemToEwallet: in production the promotion /
// Bally void first calls eWallet's own prize-redemption void, and only after that succeeds does
// Bally mark its promotion record voided. This Mock simulates that cascade so the eWallet side
// (prize_redemption_transactions / prize_redemption_void_transactions) is voided before the
// recorded promotion file flips isVoided.
//
// A promotion outcome may contain multiple redeemed prizes, each with its own eWallet
// prize-redemption publicId (transId) nested under outcome.redeemedList. Void every one of them.
async function voidPrizeRedemptionInEwallet(context, { accessToken, transId, correlationId, voidReason, initiateType, initiateSource, schema, data }) {
const baseUrl = context.promotionRedemptionBridgeBaseUrl
const voidUrl = joinUrl(baseUrl, `/api/v1/prize-redemptions/${transId}/sync`)
log(`[BRIDGE][VOID] calling eWallet DELETE ${voidUrl} (correlationId=${correlationId}, transId=${transId})`)
const voidResp = await requestJson(
voidUrl,
createRequestOptions(
'DELETE',
{
correlationId,
voidReason,
initiateType,
initiateSource,
},
{
Authorization: `Bearer ${accessToken}`,
'Client-Machine-Id': schema?.computerName || 'mock-gateway',
'Content-Type': 'application/json',
'Location-Code': data?.locationCode || 'MOCK',
'Property-Code': data?.casinoCode || 'GLP',
'X-Real-IP': schema?.ipAddress || '',
},
context.promotionRedemptionBridgeTimeoutMs,
),
)
const voidData = unwrapApiResponseBody(voidResp, 'Promotion void bridge prize redemption void')
log(`[BRIDGE][VOID] eWallet void result overallStatus=${voidData?.overallStatus} (transId=${transId})`)
// eWallet treats FAILED / COMPENSATED / PENDING / PROCESSING as errors; a successful void
// comes back as VOIDED (or COMPLETED). Anything else is treated as success here.
const failedStatuses = ['FAILED', 'COMPENSATED', 'PENDING', 'PROCESSING']
if (voidData?.overallStatus && failedStatuses.includes(voidData.overallStatus)) {
throw new Error(`eWallet prize redemption void finished with status ${voidData.overallStatus} (transId=${transId})`)
}
return voidData
}
function registerRedeemedPromotionVoidRoute({ app, context }) {
const routes = [
'/ewl/promotion/v1/redeemedpromotion/void',
'/ewl/bal/v1/redeemedpromotion/void',
]
routes.forEach((route) =>
app.post(
route,
async (req, res) => {
const schema = req.body?.schema || {}
const data = req.body?.data || {}
const redeemedId = data.redeemedId
const loginId = data.loginId || ''
routes.forEach((route) => app.post(route, (req, res) => {
if (context.redeemedPromotionVoidResult) {
context.voidRecordedRedeemedPromotionOutcome(
req.body?.data?.redeemedId,
req.body?.data?.loginId || '',
)
}
// eWallet void payload. The Bally promotion void request does not carry these, so derive them.
const correlationId = `VOID_PROMO_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
const voidReason = data?.remark || 'PROMOTION_VOID'
const initiateType = data?.initiateType || 'SYSTEM'
const initiateSource = data?.initiateSource || 'WEB_APP'
try {
// Resolve the eWallet prize-redemption publicId(s) (transId) tied to this promotion
// redemption. They are nested under outcome.redeemedList[].transId in the recorded
// promotion (NOT at the top level of the recorded outcome entry). A promotion outcome
// may contain multiple redeemed prizes, each with its own transId.
const recordedOutcome = context.findRecordedRedeemedPromotionOutcome
? context.findRecordedRedeemedPromotionOutcome(redeemedId)
: null
const transIds = recordedOutcome?.outcome?.redeemedList
? recordedOutcome.outcome.redeemedList.map((r) => r.transId).filter(Boolean)
: []
if (transIds.length > 0 && context.promotionRedemptionBridgeBaseUrl) {
const accessToken = await loginToEwallet(context)
for (const transId of transIds) {
await voidPrizeRedemptionInEwallet(context, {
accessToken,
transId,
correlationId,
voidReason,
initiateType,
initiateSource,
schema,
data,
})
}
log(`[BRIDGE][VOID] eWallet prize redemption void succeeded for transIds=${transIds.join(',')}`)
} else {
log(
`[BRIDGE][VOID] skipped eWallet void bridge (transIds=${JSON.stringify(transIds)}, baseUrl=${context.promotionRedemptionBridgeBaseUrl})`,
)
}
// Only mark the recorded promotion as voided after the eWallet void succeeded.
if (context.redeemedPromotionVoidResult) {
context.voidRecordedRedeemedPromotionOutcome(redeemedId, loginId)
}
return res.status(200).json({
errorCode: context.redeemedPromotionVoidErrorCode,
errorMsg: context.redeemedPromotionVoidErrorMsg,
result: context.redeemedPromotionVoidResult,
})
} catch (error) {
log(`[BRIDGE][VOID] eWallet void bridge failed: ${error.message}`)
return res.status(200).json({
errorCode: context.redeemedPromotionVoidErrorCode || 'EWALLET_VOID_FAILED',
errorMsg: context.redeemedPromotionVoidErrorMsg || error.message,
result: false,
})
}
},
),
)
return res.status(200).json({
errorCode: context.redeemedPromotionVoidErrorCode,
errorMsg: context.redeemedPromotionVoidErrorMsg,
result: context.redeemedPromotionVoidResult,
})
}))
}
module.exports = registerRedeemedPromotionVoidRoute