Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions cartridges/int_affirm/cartridge/scripts/utils/affirmUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,79 @@

return validDiscountCodes;
};
/**
* Verify HMAC-SHA512 signature from Affirm's X-Affirm-Signature header.
* Supports key rotation format: t={timestamp},v0={key1_hash}={key2_hash}
*
* @param {dw.system.Request} req the HTTP request
* @returns {Object} { valid: boolean, error: string|null }
*/
self.verifyHMAC = function (req) {
var Mac = require('dw/crypto/Mac');
var Encoding = require('dw/crypto/Encoding');

var signatureHeader = req.httpHeaders.get('x-affirm-signature');
if (!signatureHeader) {
return { valid: false, error: 'Missing X-Affirm-Signature header' };
}

// Parse header: t={timestamp},v0={hash} or t={timestamp},v0={hash1}={hash2}
var parts = signatureHeader.split(',');
if (parts.length < 2) {
return { valid: false, error: 'Malformed signature header' };
}

var timestampPart = parts[0].trim();
var hashPart = parts[1].trim();

if (timestampPart.indexOf('t=') !== 0 || hashPart.indexOf('v0=') !== 0) {
return { valid: false, error: 'Malformed signature header format' };
}

var timestamp = timestampPart.substring(2);
var timestampFloat = parseFloat(timestamp);
if (isNaN(timestampFloat)) {
return { valid: false, error: 'Invalid timestamp' };
}

// Validate timestamp is not older than 5 minutes
var currentTime = Date.now() / 1000;
if (currentTime - timestampFloat > 300) {
return { valid: false, error: 'Signature timestamp expired' };
}

// Extract hash(es) — v0={hash} or v0={hash1}={hash2} for key rotation
var hashValue = hashPart.substring(3);
var hashes = hashValue.split('=');

var requestBody = req.httpParameterMap.requestBodyAsString;
var message = timestamp + '.' + requestBody;
var privateKey = affirmData.getPrivateKey();

var mac = new Mac(Mac.HMAC_SHA_512);
var computedHash = Encoding.toHex(mac.digest(message, privateKey));

// IMPORTANT
// DEBUG — remove after testing
var Logger = require('dw/system').Logger.getLogger('Affirm', 'HMAC');
Logger.error('HMAC DEBUG: timestamp={0}', timestamp);
Logger.error('HMAC DEBUG: body length={0}', requestBody.length);
Logger.error('HMAC DEBUG: body={0}', requestBody);
Logger.error('HMAC DEBUG: key length={0}', privateKey.length);
Logger.error('HMAC DEBUG: computedHash={0}', computedHash);
Logger.error('HMAC DEBUG: receivedHash={0}', hashes[0]);
// DEBUG — remove after testing
// IMPORTANT

// Check against all provided hashes (supports key rotation)
for (var i = 0; i < hashes.length; i++) {
if (hashes[i] === computedHash) {
return { valid: true, error: null };
}
}

return { valid: false, error: 'Signature mismatch' };
};
};

module.exports = new Utils();
Expand Down
226 changes: 226 additions & 0 deletions cartridges/int_affirm_sfra/cartridge/controllers/Affirm.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,232 @@ server.get('ExpressCheckout', function (req, res, next) {
return next();
});

/**
* Shipping & Totals HTTP Endpoint for Express Checkout.
* Called server-to-server by Affirm's backend (no browser session).
* Validates HMAC, looks up cart via Custom Object, calculates shipping options.
*/
server.post('ShippingTotals', function (req, res, next) {
res.setContentType('application/json');

if (!affirm.data.getExpressCheckoutEnabled()) {
res.setStatusCode(404);
res.json({ error: true });
return next();
}

// Verify HMAC signature
var hmacResult = affirmUtils.verifyHMAC(request);
if (!hmacResult.valid) {
Logger.error('Affirm Express Checkout: HMAC verification failed - {0}', hmacResult.error);
res.setStatusCode(401);
res.json({ error: true, message: 'Unauthorized' });
return next();
}

// Parse request body
var requestBody;
try {
requestBody = JSON.parse(request.httpParameterMap.requestBodyAsString);
} catch (e) {
res.setStatusCode(400);
res.json({ error: true, message: 'Invalid JSON' });
return next();
}

var orderId = requestBody.order_id;
var currency = requestBody.currency;
var shippingAddress = requestBody.shipping;

// Look up AffirmExpressCart Custom Object
var expressCart = CustomObjectMgr.getCustomObject('AffirmExpressCart', orderId);
if (!expressCart) {
res.setStatusCode(422);
res.json({
errors: [{
error_code: 'ORDER_NOT_FOUND',
message: 'Cart session not found or expired.'
}]
});
return next();
}

// Validate currency
if (currency !== 'USD') {
Copy link
Copy Markdown
Collaborator Author

@daniellzl daniellzl Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Express checkout only supports the United States and USD for initial launch.

res.setStatusCode(422);
res.json({
errors: [{
error_code: 'CURRENCY_MISMATCH',
message: 'Only USD transactions are supported.'
}]
});
return next();
}

// Validate address via hook or default validation
if (HookMgr.hasHook('app.affirm.express.validateAddress')) {
var cartData = JSON.parse(expressCart.custom.cartData);
var addressValidation = HookMgr.callHook('app.affirm.express.validateAddress', 'validateAddress', shippingAddress, cartData);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Custom merchant-defined validation hook called here, otherwise default to simple validation on line 415.

if (addressValidation && !addressValidation.valid) {
res.setStatusCode(422);
res.json({
errors: [{
error_code: addressValidation.error_code || 'INVALID_SHIPPING_ADDRESS',
message: addressValidation.message || 'The provided address is not valid.',
fields: addressValidation.fields || []
}]
});
return next();
}
} else {
// Default validation: US addresses only
if (shippingAddress && shippingAddress.country && shippingAddress.country !== 'US') {
res.setStatusCode(422);
res.json({
errors: [{
error_code: 'UNSUPPORTED_SHIPPING_ZONE',
message: 'Only US shipping addresses are supported.',
fields: ['shipping_address.country']
}]
});
return next();
}
}

// Build SFCC address object for shipping method lookup
var shippingAddressForLookup = {
countryCode: shippingAddress.country || 'US',
stateCode: shippingAddress.state || '',
postalCode: shippingAddress.zipcode || '',
city: shippingAddress.city || '',
address1: shippingAddress.line1 || '',
address2: shippingAddress.line2 || ''
};

// We need a basket to calculate shipping. Look up by basketUUID via the Custom Object.
// Since this is a sessionless call, we use a temporary basket approach:
// calculate from the stored cart data + SFCC shipping method lookup.
var expressCartData = JSON.parse(expressCart.custom.cartData);

// Build a temporary basket from stored cart data for accurate shipping/tax calculation
var shippingOptions = [];

try {
var tempBasket = BasketMgr.getCurrentOrNewBasket();

// Populate basket with products from the cart snapshot
Transaction.wrap(function () {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want 1 transaction block between this line and the Transaction.begin() on line 490?

var tempShipment = tempBasket.getDefaultShipment();

// Clear any pre-existing line items
var existingItems = tempBasket.getAllProductLineItems().iterator();
while (existingItems.hasNext()) {
tempBasket.removeProductLineItem(existingItems.next());
}

// Recreate product line items from stored cart data
var items = expressCartData.items || [];
for (var i = 0; i < items.length; i++) {
var item = items[i];
if (item.sku) {
var lineItem = tempBasket.createProductLineItem(item.sku, tempShipment);
lineItem.setQuantityValue(item.qty || 1);
}
}

// Set shipping address (needed for applicable-method lookup and tax calc)
var shippingAddressFromTempBasket = tempShipment.createShippingAddress();
shippingAddressFromTempBasket.setCountryCode(shippingAddressForLookup.countryCode);
shippingAddressFromTempBasket.setStateCode(shippingAddressForLookup.stateCode);
shippingAddressFromTempBasket.setPostalCode(shippingAddressForLookup.postalCode);
shippingAddressFromTempBasket.setCity(shippingAddressForLookup.city);
shippingAddressFromTempBasket.setAddress1(shippingAddressForLookup.address1);
shippingAddressFromTempBasket.setAddress2(shippingAddressForLookup.address2);

HookMgr.callHook('dw.order.calculate', 'calculate', tempBasket);
});

// Get shipping methods applicable to this address
// Note: Even thoughtempShipment address is set above for basket calculation, method lookup still needs shippingAddressForLookup because getApplicableShippingMethods expects a normal JS object with address fields, not OrderAddress.
var tempShipment = tempBasket.getDefaultShipment();
var applicableShippingMethods = ShippingMgr.getShipmentShippingModel(tempShipment)
.getApplicableShippingMethods(shippingAddressForLookup);

// Cycle each shipping method: set it, recalculate, capture totals, then roll back
Transaction.begin();

for (var j = 0; j < applicableShippingMethods.length; j++) {
var method = applicableShippingMethods[j];

affirmUtils.updateShipmentShippingMethod(
tempShipment.getID(), method.getID(), method, applicableShippingMethods
);
HookMgr.callHook('dw.order.calculate', 'calculate', tempBasket);

var shippingAmount = Math.round(tempBasket.getAdjustedShippingTotalPrice().getValue() * 100);
var taxAmount = Math.round(tempBasket.getTotalTax().getValue() * 100);
var totalAmount = Math.round(tempBasket.getTotalGrossPrice().getValue() * 100);

// Allow custom hook to override calculated totals
if (HookMgr.hasHook('app.affirm.express.calculateTotals')) {
var totalsResult = HookMgr.callHook(
'app.affirm.express.calculateTotals', 'calculateTotals',
method, shippingAddress, expressCartData
);
if (totalsResult) {
shippingAmount = totalsResult.shipping_amount !== undefined ? totalsResult.shipping_amount : shippingAmount;
taxAmount = totalsResult.tax_amount !== undefined ? totalsResult.tax_amount : taxAmount;
totalAmount = totalsResult.total !== undefined ? totalsResult.total : totalAmount;
}
}

shippingOptions.push({
shipping_type: method.getID(),
shipping_label: method.getDisplayName(),
shipping_amount: shippingAmount,
tax_amount: taxAmount,
total: totalAmount
});
}

Transaction.rollback();
} catch (e) {
Logger.error('Affirm Express: Error calculating shipping options - {0}', e);
res.setStatusCode(422);
res.json({
errors: [{
error_code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred. Please try again.'
}]
});
return next();
}

// Apply hook filter if available
if (HookMgr.hasHook('app.affirm.express.filterShippingMethods')) {
shippingOptions = HookMgr.callHook('app.affirm.express.filterShippingMethods', 'filterShippingMethods', shippingOptions, shippingAddress, expressCartData);
}

if (!shippingOptions || shippingOptions.length === 0) {
res.setStatusCode(422);
res.json({
errors: [{
error_code: 'SHIPPING_METHOD_UNAVAILABLE',
message: 'No shipping options are available for this address.'
}]
});
return next();
}

res.json({
order_id: orderId,
currency: 'USD',
subtotal: expressCartData.subtotal,
shipping_options: shippingOptions
});
return next();
});

/**
* Adds Affirm discount coupon
*/
Expand Down