试试
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user