Skip to content

Instantly share code, notes, and snippets.

@r0yfire
Created January 20, 2025 11:52
Show Gist options
  • Save r0yfire/5eab419ab81296df2d8bced561881d79 to your computer and use it in GitHub Desktop.
Save r0yfire/5eab419ab81296df2d8bced561881d79 to your computer and use it in GitHub Desktop.
Adyen webhook event to the Autohost Payment Event expected format
/** https://docs.autohost.ai/api#operation/reservations-payment-event **/
/**
* Map 3DS status from Adyen to our format
* @param additionalData Additional data from Adyen notification
* @returns Mapped 3DS status
*/
function map3DSStatus(additionalData: Record<string, any>): string | undefined {
// Check 3DS2 status first (preferred)
const threeDSVersion = additionalData.threeDSVersion;
if (threeDSVersion?.startsWith('2.')) {
const transStatus = additionalData.threeDS2Result?.transStatus;
switch (transStatus) {
case 'Y': return 'authenticated';
case 'N': return 'failed';
case 'U': return 'processing_error';
case 'A': return 'attempt_acknowledged';
case 'C':
case 'D': return 'authenticated';
case 'R': return 'failed';
case 'I': return 'exempted';
default: return undefined;
}
}
// Fall back to 3DS1
const threeDAuthenticated = additionalData.threeDAuthenticated === 'true';
const threeDOffered = additionalData.threeDOffered === 'true';
const threeDAuthenticatedResponse = additionalData.threeDAuthenticatedResponse;
const threeDOfferedResponse = additionalData.threeDOfferedResponse;
if (!threeDOffered) {
return 'not_supported';
}
if (threeDOfferedResponse === 'N' || threeDOfferedResponse === 'U') {
return 'not_supported';
}
if (!threeDAuthenticated) {
return 'failed';
}
switch (threeDAuthenticatedResponse) {
case 'Y': return 'authenticated';
case 'A': return 'attempt_acknowledged';
case 'U': return 'processing_error';
case 'N': return 'failed';
default: return undefined;
}
}
/**
* Build a payment event object from an Adyen notification
* @param notification Adyen notification request item
* @param reservationId Reservation ID
* @returns Payment event object
*/
function buildPaymentEvent(
notification: any,
reservationId: string,
): any {
const additionalData = notification.additionalData || {};
// Map Adyen event codes to our event types
const eventTypeMap = new Map([
['AUTHORISATION', 'authorization'],
['CAPTURE', 'charge'],
['REFUND', 'refund'],
['CAPTURE_FAILED', 'charge'],
['REFUND_FAILED', 'refund'],
['CANCELLATION', 'refund'],
['CHARGEBACK', 'chargeback'],
['CHARGEBACK_REVERSED', 'chargeback'],
['NOTIFICATION_OF_CHARGEBACK', 'chargeback'],
['NOTIFICATION_OF_FRAUD', 'dispute'],
]);
// Map network status codes
const networkStatusMap = {
Approved: 'approved_by_network',
Refused: 'declined_by_network',
Error: 'not_sent_to_network',
Cancelled: 'reversed_after_approval',
};
// Get billing address from additionalData
const billingAddress = typeof additionalData.billingAddress === 'string'
? JSON.parse(additionalData.billingAddress || '{}')
: additionalData.billingAddress || {};
// Map card verification results
const cvcResult = additionalData.cvcResult;
const avsResult = additionalData.avsResult;
const eventData = {
reservation_id: reservationId,
event_id: notification.pspReference,
event_source: 'adyen',
event_type: eventTypeMap.get(String(notification.eventCode)) || 'authorization',
event_status: notification.success ? 'success' : 'failure',
processor_status_code: String(notification.eventCode),
processor_message: notification.reason || additionalData.refusalReasonRaw,
network_status_code: networkStatusMap[additionalData.responseCode as string] || 'not_sent_to_network',
three_d_secure: map3DSStatus(additionalData),
amount: notification.amount?.value ? notification.amount.value / 100 : undefined,
currency: notification.amount?.currency?.toLowerCase(),
charge_descriptor: additionalData.shopperStatement,
name_on_card: additionalData.cardHolderName || additionalData['issuerComments.cardholderName'],
payment_method: 'card',
card_type: additionalData.fundingSource?.toLowerCase(),
card_provider: additionalData.paymentMethod?.toLowerCase(),
card_country: additionalData.issuerCountry || additionalData.cardIssuingCountry || additionalData.countryCode,
card_iin: additionalData.cardBin,
card_last4: additionalData.cardSummary,
card_expiry_month: additionalData.expiryDate?.split('/')[0],
card_expiry_year: additionalData.expiryDate?.split('/')[1],
card_cvc_check: cvcResult === '1' ? 'pass' : cvcResult === '2' ? 'fail' : 'unavailable',
card_postal_code_check: avsResult?.includes('P') ? 'pass' : avsResult?.includes('N') ? 'fail' : 'unavailable',
card_line1_check: avsResult?.includes('M') ? 'pass' : avsResult?.includes('N') ? 'fail' : 'unavailable',
billing_country_code: billingAddress.country,
billing_address: billingAddress.street,
billing_city: billingAddress.city,
billing_state_code: billingAddress.stateOrProvince,
billing_postal_code: billingAddress.postalCode,
billing_phone: additionalData.shopperTelephone,
billing_email: additionalData.shopperEmail,
billing_name: additionalData.shopperName,
ip_address: additionalData.shopperIP,
user_agent: additionalData.shopperUserAgent,
timestamp: new Date(notification.eventDate).getTime(),
};
// Trim user-controlled fields
['name_on_card', 'billing_email', 'billing_name'].forEach(field => {
if (typeof eventData[field] === 'string') {
eventData[field] = eventData[field].trim();
}
});
return eventData;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment