/**
* The `hybris` module contains the `HybrisConnector` class, an implementation of the
* `CommerceConnector` interface using the Omni Channel Connect API (OCC API) for
* connecting to SAP Hybris Commerce.
* @module @mobify/commerce-integrations/dist/connectors/hybris
*/
import * as hybris from 'hybris-occ-client'
import * as errors from '../errors'
import superagent from 'superagent'
import URL from 'url-parse'
const clone = (x) => JSON.parse(JSON.stringify(x))
/**
* A Commerce Integrations connector for SAP Hybris Commerce backends.
*
* @implements {module:@mobify/commerce-integrations/dist/connectors/interfaces.CommerceConnector}
* @implements {module:@mobify/commerce-integrations/dist/connectors/interfaces.ParserHooks}
*/
export class HybrisConnector {
constructor({client, catalogId, catalogVersionId, authentication}) {
this.client = client
this.catalogId = catalogId
this.catalogVersionId = catalogVersionId
this.authentication = authentication
}
static fromConfig(config) {
return new this.prototype.constructor({
client: new hybris.ApiClient(config.clientConfig),
catalogId: config.catalogId,
catalogVersionId: config.catalogVersionId,
authentication: config.authentication
})
}
absoluteURL(path) {
return new URL(path, this.client.basePath || '').href
}
/**
* Register a new customer account and login.
*
* @param {module:@mobify/commerce-integrations/dist/types.HybrisCustomerRegistration} data
* @return {Promise<module:@mobify/commerce-integrations/dist/types.Customer>}
*/
registerCustomer(data) {
const api = new hybris.UsersApi(this.client)
return api
.postUsers({
firstName: data.firstName,
lastName: data.lastName,
password: data.password,
titleCode: data.titleCode,
uid: data.email // Must be an email
})
.then(() => this.login(data.email, data.password))
}
/**
* @inheritDoc
*/
login(username, password) {
const isRegisteredUser = username && password
const auth = this.authentication
const body = {
client_id: auth.clientId,
client_secret: auth.clientSecret,
grant_type: 'client_credentials'
}
if (isRegisteredUser) {
Object.assign(body, {
grant_type: 'password',
username,
password
})
}
return superagent
.post(auth.authorizationUrl)
.set('Content-Type', 'application/x-www-form-urlencoded')
.send(body)
.then((res) => {
const updatedAuth = {
accessToken: res.body.access_token,
expiresIn: res.body.expires_in,
refreshToken: res.body.refresh_token
}
Object.assign(this.client.authentications.auth, updatedAuth)
return isRegisteredUser
? this.getCustomer('current')
: this.getCustomer('anonymous')
})
.catch(() => {
throw new errors.ForbiddenError('Login failed')
})
}
/**
* @inheritDoc
*/
logout() {
const invalidatedAuth = {
accessToken: undefined,
expiresIn: undefined,
refreshToken: undefined
}
Object.assign(this.client.authentications.auth, invalidatedAuth)
return Promise.resolve(undefined)
}
/**
* @inheritDoc
*/
refreshSession() {
const refreshToken = this.client.authentications.auth.refreshToken
const grantType = refreshToken ? 'refresh_token' : 'client_credentials'
const auth = this.authentication
const body = {
client_id: auth.clientId,
client_secret: auth.clientSecret,
grant_type: grantType,
refresh_token: refreshToken
}
return superagent
.post(auth.authorizationUrl)
.set('Content-Type', 'application/x-www-form-urlencoded')
.send(body)
.then((res) => {
const newAuth = {
accessToken: res.body.access_token,
expiresIn: res.body.expires_in,
refreshToken: res.body.refresh_token
}
Object.assign(this.client.authentications.auth, newAuth)
return refreshToken ? this.getCustomer('current') : this.getCustomer('anonymous')
})
.catch(() => {
throw new errors.ForbiddenError('Failed To Refresh Session')
})
}
/**
* @inheritDoc
*/
getDefaultHeaders() {
return Object.assign({}, this.client.defaultHeaders)
}
/**
* @inheritDoc
*/
setDefaultHeaders(headers) {
this.client.defaultHeaders = Object.assign({}, headers)
}
/**
* Get the current authentications object, which can be used to
* save and restore auth state.
*
* @return {Object}
*/
getAuthentications() {
return clone(this.client.authentications)
}
/**
* Set the current authentications object, as returned from getAuthentications.
* Use to save and restore auth state.
*
* @param {Object} authentications
*/
setAuthentications(authentications) {
this.client.authentications = clone(authentications)
}
/**
* @inheritDoc
*/
getCustomer(id, opts = {}) {
const api = new hybris.UsersApi(this.client)
return api.getUser(id, {fields: 'FULL', ...opts}).then((res) => this.parseCustomer(res))
}
/**
* @inheritDoc
*/
parseCustomer(data) {
// Hybris validates that uid is an email for registered users.
const d = data
const isAnon = d.uid === 'anonymous'
const anonEmail = d.defaultAddress ? d.defaultAddress.email : undefined
return {
id: isAnon ? 'anonymous' : 'current',
firstName: d.firstName,
lastName: d.lastName,
email: isAnon ? anonEmail : d.uid
}
}
/**
* @inheritDoc
*/
searchProducts(query, opts = {}) {
const searchRequest = this.transformSearchParams(query)
const api = new hybris.ProductsApi(this.client)
return api
.getProductSearch(searchRequest, opts)
.then((data) => this.parseSearchProducts(data, query))
}
transformSearchParams(params) {
const {query, count = 20, start = 0, sort = 'relevance', filters = {}} = params
// Please refer to Hybris documentation for notes on the shape of the request object.
// https://help.hybris.com/6.4.0/api/ycommercewebservices/main.html#_products_search_get
return {
query: []
.concat(
...[
query ? [`${query}:`] : [],
['', sort],
Object.keys(filters)
.filter((key) => !['categoryId', 'query'].includes(key))
.filter((key) => Boolean(filters[key]))
.map((key) => `${key}:${filters[key]}`),
filters.categoryId ? [`allCategories:${filters.categoryId}`] : []
]
)
.join(':'),
pageSize: count,
currentPage: start / count,
sort,
fields: 'FULL'
}
}
parseFilterValue({count, name, query}) {
// Parse query string to get filter value.
const value = query.query.value.split(':').slice(-1)[0]
return {
count,
label: name,
value
}
}
parseFilter({name, values}) {
// Get the filter identifier from the first values query string. There should
// always be a value or else the filter wouldn't be listed, but to be sure
// return undefiend if there isn't one.
let filterId
const firstValue = values[0]
if (firstValue) {
// Parse query string to get filter identifier.
filterId = firstValue.query.query.value.split(':').slice(-2)[0]
}
return {
id: filterId,
label: name,
values: values.map(this.parseFilterValue.bind(this))
}
}
parseSearchProducts(data) {
const {
products = [],
sorts = [],
pagination,
freeTextSearch,
facets = [],
breadcrumbs = []
} = data
const getFilterCrumbs = () => {
const selectedFilterVals = [].concat(
...facets.map((filter) => filter.values.filter((f) => f.selected))
)
const selectedBreadcrumbs = [].concat(
...selectedFilterVals.map((fv) =>
breadcrumbs.filter((bc) => bc.facetValueName === fv.name)
)
)
const allCatsCrumb = breadcrumbs.find((bc) => bc.facetCode === 'allCategories')
allCatsCrumb && selectedBreadcrumbs.push(allCatsCrumb)
return selectedBreadcrumbs
}
return {
results: products.map(({stock = {}, images = [], ...product}) => ({
available: stock.stockLevelStatus === 'inStock',
productId: product.code,
productName: product.name,
link: product.url,
defaultImage: images[0] && {
alt: product.name,
src: this.absoluteURL(images[0].url)
},
price: product.price.value
})),
query: freeTextSearch,
count: pagination.pageSize,
start: pagination.currentPage * pagination.pageSize,
total: pagination.totalResults,
filters: facets.map(this.parseFilter.bind(this)),
selectedFilters: getFilterCrumbs().reduce((obj, curr) => {
obj[curr.facetCode] = curr.facetValueCode
return obj
}, {}),
sortingOptions: sorts.map(({code, name}) => ({
id: code,
label: name
})),
selectedSortingOption: pagination.sort
}
}
/**
* @inheritDoc
*/
getCategory(id, options = {}) {
const api = new hybris.CatalogsApi(this.client)
return api
.getCategory(this.catalogId, this.catalogVersionId, id, options)
.then((data) => this.parseCategory(data))
}
/**
* @inheritDoc
*/
getCategories(ids, options = {}) {
const getCategoryPromises = ids.map((id) => this.getCategory(id, options).catch(() => null))
return Promise.all(getCategoryPromises).then((categories) => {
const validCategories = categories.filter((category) => !!category)
return {
data: validCategories,
count: Object.keys(categories).length,
total: ids.length
}
})
}
/**
* Takes an array of OCC API Category objects and parses it into an
* commerce-integrations Category type.
* @param {Object[]} categories an array of OCC API {@link https://mobify.github.io/hybris-occ-client/module-models_CategoryHierarchy.html|Category} document
*
* @returns {Promise<module:@mobify/commerce-integrations/dist/types.Category>}
*/
/* eslint-disable camelcase */
parseCategories(categories) {
const parsedCategories = []
categories.forEach((category) => parsedCategories.push(this.parseCategory(category)))
return parsedCategories
}
/**
* Takes an OCC API Category objects and parses it into an
* commerce-integrations Category type.
* @param {Object[]} categories An OCC API {@link https://mobify.github.io/hybris-occ-client/module-models_CategoryHierarchy.html|Category} document
*
* @returns {Promise<module:@mobify/commerce-integrations/dist/types.Category>}
*/
parseCategory({id, name, subcategories}) {
const category = {
id,
name
}
if (subcategories) {
category.categories = this.parseCategories(subcategories)
}
return category
}
/**
* @inheritDoc
*/
getProduct(id, opts = {}) {
let options = {}
const api = new hybris.ProductsApi(this.client)
const defaultOptions = {
fields: 'FULL'
}
// Assign options to new object. This allows the user to override
// the defaults we set.
options = {
...defaultOptions,
...opts
}
// Get the base product by creating a promise chain recursively.
const productCache = []
const getBaseProduct = (id, options) =>
api.getProduct(id, options).then((product) => {
const {baseProduct} = product
// Maintain a cache of all products requested in this command.
// It will be used lated to de-dup any fetches.
productCache.push(product)
return baseProduct ? getBaseProduct(baseProduct, options) : product
})
return getBaseProduct(id, options)
.then((baseProduct) => {
// Overwrite the base product id with the id we initiality requested so it's parsed
// out correctly later on.
baseProduct.code = id
return Promise.all(
(baseProduct.variantOptions || []).map((variantOption) => {
// Search product cache and return products already fetched.
const cachedProduct = productCache.find(
({code}) => code === variantOption.code
)
return cachedProduct || api.getProduct(variantOption.code, options)
})
).then((variations) => ({baseProduct, variations}))
})
.then(({baseProduct, variations}) => this.parseProduct(baseProduct, variations))
.then((parsedProduct) => {
// Get the variation values from the first product requested.
const {baseOptions = []} = productCache[0]
const {selected = {}} = baseOptions[0] || {}
const {variantOptionQualifiers = []} = selected
const variationValues = variantOptionQualifiers.reduce(
(acc, {qualifier, value}) => ({
...acc,
[qualifier]: value
}),
{}
)
// Set the products values.
parsedProduct.variationValues = variationValues
return parsedProduct
})
.catch(() => {
throw new errors.NotFoundError('Product Not Found')
})
}
/**
* @inheritDoc
*/
getProducts(ids, opts = {}) {
if (!ids || ids.length <= 0) {
throw new Error('Please specify list of product ids to get.')
}
const getProductPromises = ids.map((id) => this.getProduct(id, opts).catch(() => null))
return Promise.all(getProductPromises).then((products) => {
const validProducts = products.filter((product) => !!product)
return {
count: validProducts.length,
data: validProducts,
total: validProducts.length
}
})
}
/**
* Takes an OCC API ImageGroup object and parses it into a commerce-integrations ImageSet type.
* @param {Object} data An OCC API {@link https://help.sap.com/doc/0e9065bfffdf4877a5aac1bf4ff9c32c/1811/en-US/ycommercewebservices/index.html#_productwsdto} document
*
* @returns {Promise<module:@mobify/commerce-integrations/dist/types.ImageSet>}
*/
/* eslint-disable camelcase */
parseImageSets(variation) {
const {images, variantOptions} = variation
const {variantOptionQualifiers} = variantOptions[0]
const variationProperties = variantOptionQualifiers.map(({qualifier, name, value}) => ({
id: qualifier,
label: name,
values: [
{
name: value,
value
}
]
}))
return ['zoom', 'product', 'thumbnail'].map((type) => ({
images: images
.filter((image) => image.format === type)
.map(({altText, url}) => ({
alt: altText,
description: altText,
src: this.absoluteURL(url),
title: altText
})),
variationProperties,
sizeType: type
}))
}
/* eslint-enable camelcase */
/**
* Takes an OCC Product object and parses it into a commerce-integrations Product type.
* @param {Object} data An OCC product document
*
* @returns {Promise<module:@mobify/commerce-integrations/dist/types.Product>}
*/
/* eslint-disable camelcase */
parseProduct({code, name, description, categories, price, variantOptions}, variations = []) {
// Parse all the image sets. We'll use these in our return value as well
// as for parsing property value images.
let allImageSets = []
variations.forEach((variation) => {
allImageSets = [...allImageSets, ...this.parseImageSets(variation)]
})
const allVariantOptions = [
...variantOptions,
...variations.reduce((acc, {variantOptions}) => [...acc, ...variantOptions], [])
]
const variationProperties = allVariantOptions
.reduce((acc, variantOption) => [...acc, ...variantOption.variantOptionQualifiers], [])
.reduce((acc, {qualifier, name, value}) => {
const variationProperty = acc.find(
(variationOptionQualifier) => variationOptionQualifier.id === qualifier
)
// Lets get the first image of the correct type from the image sets.
const variationImageSets = allImageSets.filter(({variationProperties}) =>
variationProperties.find(
({id, values}) => id === qualifier && values[0].value === value
)
)
const filteredProductImageSets = variationImageSets.filter(
({sizeType}) => sizeType === 'product'
)
const filteredThumbnailImageSets = variationImageSets.filter(
({sizeType}) => sizeType === 'thumbnail'
)
if (variationProperty) {
const isNew = !variationProperty.values.find((v) => v.value === value)
if (isNew) {
variationProperty.values.push({
name: value,
value,
mainImage:
filteredProductImageSets.length &&
filteredProductImageSets[0].images[0],
swatchImage:
filteredThumbnailImageSets.length &&
filteredThumbnailImageSets[0].images[0]
})
}
} else {
acc.push({
id: qualifier,
label: name,
values: [
{
name: value,
value,
mainImage:
filteredProductImageSets.length &&
filteredProductImageSets[0].images[0],
swatchImage:
filteredThumbnailImageSets.length &&
filteredThumbnailImageSets[0].images[0]
}
]
})
}
return acc
}, [])
const allVariations = variations
.map(({variantOptions}) =>
variantOptions.map(({code, priceData, stock, variantOptionQualifiers}) => ({
id: code,
price: priceData.value,
orderable: stock.stockLevelStatus === 'inStock',
values: variantOptionQualifiers.reduce((acc, curr) => {
acc[curr.qualifier] = curr.value
return acc
}, {})
}))
)
.reduce((acc, curr) => [...acc, ...curr], [])
return {
id: code,
name,
imageSets: allImageSets,
description,
categoryId: categories.length ? categories.pop().code : undefined,
minimumOrderQuantity: 1,
stepQuantity: 1,
price: price.value,
variations: allVariations,
variationProperties
}
}
/* eslint-enable camelcase */
/**
* @inheritDoc
*/
getStore(id) {
const api = new hybris.StoresApi(this.client)
return api.getStore(id, {fields: 'FULL'}).then((data) => {
return this.parseGetStore({...data, id})
})
}
/**
* Takes an OCC API WeekdayOpeningDay object and parses it into a commerce-integrations Store.hours type.
* @param {Object} hours An OCC API {@link https://help.hybris.com/6.7.0/api/ycommercewebservices/index.html#_weekdayopeningdaywsdto|WeekdayOpneningDay} document
*
* @returns {Promise<module:@mobify/commerce-integrations/dist/types.Store.hours>}
*/
parseOpeningHours(hours) {
const parsedHours = {}
hours.forEach((h) => {
parsedHours[h.weekDay] = {
openingTime: h.openingTime ? h.openingTime.formattedHour : undefined,
closingTime: h.closingTime ? h.closingTime.formattedHour : undefined
}
})
return parsedHours
}
/**
* Takes an OCC API PointOfService object and parses it into a commerce-integrations Store type.
* @param {Object} data An OCC API {@link https://help.hybris.com/6.7.0/api/ycommercewebservices/index.html#_pointofservicewsdto|PointOfService} document
*
* @returns {Promise<module:@mobify/commerce-integrations/dist/types.Store>}
*/
parseGetStore(data) {
const d = data
const address = d.address
return {
addressLine1: address ? address.line1 : undefined,
addressLine2: address ? address.line2 : undefined,
city: address ? address.town : undefined,
country: address && address.country ? address.country.isocode : undefined,
description: d.description,
features: d.features ? d.features.entry.map((feature) => feature.value) : undefined,
email: address ? address.email : undefined,
name: d.name,
phone:
address && address.phone
? address.phone.replace(new RegExp('[(]|[)]|[.]|[ ]|[-]|[#]|[x]', 'g'), '')
: undefined,
postalCode: address ? address.postalCode : undefined,
hours: this.parseOpeningHours(d.openingHours.weekDayOpeningList),
images: d.storeImages
? d.storeImages.map((image) => {
return {src: this.absoluteURL(image.url), alt: d.name}
})
: undefined,
id: d.id
}
}
transformSearchStoreParams(searchParams) {
const sp = searchParams
const count = sp.count > 0 ? sp.count : 20
const start = sp.start > 0 ? sp.start : 0
return {
pageSize: count.toString(),
currentPage: start.toString(),
sort: 'relevance',
latitude: !isNaN(sp.latlon.latitude) ? sp.latlon.latitude.toString() : undefined,
longitude: !isNaN(sp.latlon.longitude) ? sp.latlon.longitude.toString() : undefined,
fields: 'FULL'
}
}
/**
* @inheritDoc
*/
// eslint-disable-next-line no-unused-vars
searchStores(storeSearchRequest, opts = {}) {
if (
isNaN(storeSearchRequest.latlon.latitude) ||
isNaN(storeSearchRequest.latlon.longitude)
) {
throw new Error('Provide Latitude and Longitude coordinates')
}
const api = new hybris.StoresApi(this.client)
const searchRequest = this.transformSearchStoreParams(storeSearchRequest)
return api.getStores(searchRequest).then((data) => {
if (data.stores.length <= 0) {
throw new Error('No stores found')
}
return this.parseSearchStores(data)
})
}
parseSearchStores(data) {
const d = data
let count
const start = d.pagination.currentPage
const total = d.pagination.totalResults
const pageSize = d.pagination.pageSize
const totalPages = d.pagination.totalPages
if (totalPages === start + 1) {
// on last page
if (total > pageSize) {
const remainder = total % pageSize
count = remainder !== 0 ? remainder : pageSize
} else {
count = total
}
} else {
// middle pages
count = pageSize
}
return {
count,
start,
total,
stores:
d.stores.length > 0
? d.stores.map((store) => this.parseGetStore({...store, id: store.name}))
: undefined
}
}
isLoggedIn() {
return this.getAuthentications().auth !== undefined
}
/**
* Returns customer id 'current' if a refresh token is set in the auth
* Otherwise, returns customer id 'anonymous'
*
* @private
*/
getCustomerIdFromAuth() {
if (!this.isLoggedIn()) {
throw new errors.NotAuthenticatedError()
}
return this.getAuthentications().auth.refreshToken === undefined ? 'anonymous' : 'current'
}
/**
* @inheritDoc
*/
createCart(oldCart, opts = {}) {
const api = new hybris.UsersApi(this.client)
const customerId = this.getCustomerIdFromAuth()
return api
.postCart(customerId, {fields: 'FULL', ...opts})
.then((newCart) => {
if (customerId === 'current' && oldCart !== undefined) {
return api
.postCart(customerId, {
// postCart will merge and delete the old cart
fields: 'FULL',
toMergeCartGuid: newCart.guid,
oldCartId: oldCart.id
})
.catch(() => {
throw new errors.ServerError('Could not merge old cart')
})
}
return newCart
})
.then((cart) => this.parseCart(cart))
.catch(() => {
throw new errors.ServerError('Could not create cart')
})
}
/**
* @inheritDoc
*/
getCart(cartId, opts = {}) {
const api = new hybris.UsersApi(this.client)
if (!cartId) {
throw new errors.InvalidArgumentError(`Parameter 'cartId' is required`)
}
return api
.getCart(this.getCustomerIdFromAuth(), cartId, {fields: 'FULL', ...opts})
.then((data) => this.parseCart(data))
.catch(() => {
throw new errors.ServerError('Could not get cart')
})
}
/**
* @inheritDoc
*/
// eslint-disable-next-line no-unused-vars
deleteCart(cartId, opts = {}) {
if (!cartId) {
throw new errors.InvalidArgumentError(`Parameter 'cartId' is required`)
}
const api = new hybris.UsersApi(this.client)
return api.deleteCart(this.getCustomerIdFromAuth(), cartId).catch(() => {
throw new errors.ServerError('Could not delete cart')
})
}
/**
* Parse a coupon item
*/
parseCouponEntry(data) {
return {
id: data.code,
code: data.code
}
}
/**
* @inheritDoc
*/
parseCart(data) {
const d = data
if (!d.guid && !d.code) {
throw new Error('Error parsing cart')
}
const customerId = this.getCustomerIdFromAuth()
const cartId = customerId === 'anonymous' ? d.guid : d.code
const parseNumberValue = (price) => {
return price ? price.value : undefined
}
const parsedDiscounts = parseNumberValue(d.totalDiscounts)
const discounts = parsedDiscounts === 0 ? parsedDiscounts : Math.abs(parsedDiscounts) * -1 // Our interface requires a negative value
return {
id: cartId,
couponEntries: d.appliedVouchers ? d.appliedVouchers.map(this.parseCouponEntry) : [],
customerInfo: {
id: customerId,
email: d.email
},
items: d.entries ? d.entries.map(this.parseCartItem) : [],
shippingAddress: d.deliveryAddress
? this.parseOrderAddress(d.deliveryAddress)
: undefined,
billingAddress:
d.paymentInfo && d.paymentInfo.billingAddress
? this.parseOrderAddress(d.paymentInfo.billingAddress)
: undefined,
selectedShippingMethodId: d.deliveryMode ? d.deliveryMode.code : undefined,
payments: d.paymentInfo
? [this.parsePayment({...d.paymentInfo, amount: d.totalPriceWithTax.value})]
: [],
subtotal: parseNumberValue(d.subTotal),
shipping: parseNumberValue(d.deliveryCost),
discounts,
tax: parseNumberValue(d.totalTax),
total: parseNumberValue(d.totalPriceWithTax)
}
}
/**
* @inheritDoc
*/
parseCartItem(data) {
const d = data
const quantity = d.quantity || 0
return {
id: `${d.entryNumber}`,
productId: d.product ? d.product.code : undefined,
productName: d.product ? d.product.name : undefined,
quantity,
baseItemPrice: d.basePrice ? d.basePrice.value : 0,
baseLinePrice: d.basePrice ? d.basePrice.value * quantity : 0,
itemPrice: d.totalPrice && quantity !== 0 ? d.totalPrice.value / quantity : 0,
linePrice: d.totalPrice ? d.totalPrice.value : 0
}
}
/**
* @inheritDoc
*/
parseShippingMethod(data) {
return {
id: data.code,
name: data.name,
cost: data.deliveryCost || 0
}
}
/**
* @inheritDoc
*/
parseOrderAddress(data) {
const d = data
return {
id: d.id,
titleCode: d.titleCode,
firstName: d.firstName,
lastName: d.lastName,
phone: d.phone, // BUG: fixed in hybris 6.5, phone field is not saved and returned
addressLine1: d.line1,
addressLine2: d.line2,
countryCode: d.country ? d.country.isocode : undefined,
stateCode: d.region ? d.region.isocode : undefined,
city: d.town,
postalCode: d.postalCode
}
}
/**
* @inheritDoc
*/
parsePayment(data) {
const d = data
return {
id: d.id,
amount: d.amount, // amount is always cart total for now.
methodId: 'CREDIT_CARD', // only 1 type of payment method, we'll call it 'CREDIT_CARD'
details: {
type: d.cardType.code,
holderName: d.accountHolderName,
number: undefined, // to add/update payment, provide full number.
username: undefined,
maskedNumber: d.cardNumber, // hybris will give back masked number.
expiryMonth: parseInt(d.expiryMonth),
expiryYear: parseInt(d.expiryYear),
ccv: undefined // hybris doesnt have ccv?
}
}
}
/**
* @inheritDoc
*/
parsePaymentMethod(data) {
return {
id: data.id,
name: data.name,
types: data.types.map((types) => ({id: types.code, name: types.name}))
}
}
/**
* Transform an orderAddress into hybris' models: billingAddress or shippingAddress
*/
transformOrderAddress(orderAddress) {
return {
country: {
isocode: orderAddress.countryCode
},
firstName: orderAddress.firstName,
lastName: orderAddress.lastName,
id: orderAddress.id,
line1: orderAddress.addressLine1,
line2: orderAddress.addressLine2,
phone: orderAddress.phone,
postalCode: orderAddress.postalCode,
region: {
isocode: orderAddress.stateCode
},
titleCode: orderAddress.titleCode,
town: orderAddress.city
}
}
/**
* Transform a shippingMethod into hybris' model deliveryMethod.
*/
transformShippingMethod(shippingMethod) {
return {
code: shippingMethod.id,
name: shippingMethod.name,
deliveryCost: shippingMethod.cost
}
}
/**
* Transform a cartItem into hybris' model OrderEntryOCC.
*/
transformCartItem(cartItem) {
return {
product: {
code: cartItem.productId
},
quantity: cartItem.quantity
}
}
/**
* Transform a payment into hybris' model paymentDetail.
*/
transformPayment(payment, billingAddress) {
const details = payment.details
return {
id: payment.id,
accountHolderName: details ? details.holderName : undefined,
billingAddress: this.transformOrderAddress(billingAddress),
cardNumber: details ? details.number : undefined,
cardType: {
code: details ? details.type : undefined
},
expiryMonth: details ? details.expiryMonth : undefined,
expiryYear: details ? details.expiryYear : undefined,
subscriptionId: details ? details.username : undefined
}
}
/**
* @inheritDoc
*/
addCartItem(cart, cartItem, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!cartItem) {
throw new errors.InvalidArgumentError(`Parameter 'cartItem' is required`)
}
const api = new hybris.UsersApi(this.client)
const entryOrder = this.transformCartItem(cartItem, opts)
return api
.postCartEntries(cart.customerInfo.id, cart.id, entryOrder, {...opts, fields: 'FULL'})
.then(() => api.getCart(cart.customerInfo.id, cart.id, {...opts, fields: 'FULL'}))
.then((data) => {
const updatedCart = this.parseCart({...data, email: cart.customerInfo.email})
// BUG: does not save billing address region when sending billing address through a POST cart payment detail.
// So lets persist this field on the incoming cart parameter to the returned order.
if (
updatedCart.billingAddress &&
!updatedCart.billingAddress.stateCode &&
cart.billingAddress.stateCode
) {
updatedCart.billingAddress.stateCode = cart.billingAddress.stateCode
}
return updatedCart
})
.catch(() => {
throw new errors.ServerError('Could not add item to cart')
})
}
/**
* @inheritDoc
*/
removeCartItem(cart, cartItemId, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!cartItemId) {
throw new errors.InvalidArgumentError(`Parameter 'cartItemId' is required`)
}
const api = new hybris.UsersApi(this.client)
return api
.deleteCartEntry(cart.customerInfo.id, cart.id, cartItemId)
.then(() => api.getCart(cart.customerInfo.id, cart.id, {...opts, fields: 'FULL'}))
.then((data) => {
const updatedCart = this.parseCart({...data, email: cart.customerInfo.email})
// BUG: does not save billing address region when sending billing address through a POST cart payment detail.
// So lets persist this field on the incoming cart parameter to the returned order.
if (
updatedCart.billingAddress &&
!updatedCart.billingAddress.stateCode &&
cart.billingAddress.stateCode
) {
updatedCart.billingAddress.stateCode = cart.billingAddress.stateCode
}
return updatedCart
})
.catch(() => {
throw new errors.ServerError('Could not delete cart item')
})
}
/**
* @inheritDoc
*/
updateCartItem(cart, cartItem, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!cartItem) {
throw new errors.InvalidArgumentError(`Parameter 'cartItem' is required`)
}
const api = new hybris.UsersApi(this.client)
const entryOrder = this.transformCartItem(cartItem, opts)
return api
.patchCartEntry(cart.customerInfo.id, cart.id, cartItem.id, entryOrder, {
fields: 'FULL'
})
.then(() => api.getCart(cart.customerInfo.id, cart.id, {fields: 'FULL'}))
.then((data) => {
const updatedCart = this.parseCart({...data, email: cart.customerInfo.email})
// BUG: does not save billing address region when sending billing address through a POST cart payment detail.
// So lets persist this field on the incoming cart parameter to the returned order.
if (
updatedCart.billingAddress &&
!updatedCart.billingAddress.stateCode &&
cart.billingAddress.stateCode
) {
updatedCart.billingAddress.stateCode = cart.billingAddress.stateCode
}
return updatedCart
})
.catch(() => {
throw new errors.ServerError('Could not update cart item')
})
}
/**
* Add a supported payment type to the cart
* @param {module:@mobify/commerce-integrations/dist/types.Cart} cart The cart to add the payment to
* @param {module:@mobify/commerce-integrations/dist/types.Payment} payment The payment to add
* @param {Object} opts
* @return {Promise<module:@mobify/commerce-integrations/dist/types.Cart>}
*
* Need to set a billing address before setting a payment.
* Must call setCustomerInformation() to add an email before setting payment for an anonymous cart.user.
* cart.paymentDetails.number will be returned as undefined and set as a masked number in cart.paymentDetails.maskedNumber.
*/
setPayment(cart, payment, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!payment) {
throw new errors.InvalidArgumentError(`Parameter 'payment' is required`)
}
const api = new hybris.UsersApi(this.client)
if (cart.customerInfo.id === 'anonymous' && cart.customerInfo.email === undefined) {
throw new errors.InvalidArgumentError('Please set email for guest user cart')
}
if (!cart.billingAddress) {
throw new errors.InvalidArgumentError('Please set billing address first')
}
const isMissingFields =
!payment.details.type ||
!payment.details.expiryMonth ||
!payment.details.expiryYear ||
!payment.details.holderName ||
!payment.details.number
if (isMissingFields) {
throw new errors.InvalidArgumentError('Missing hybris required fields')
}
const paymentDetails = this.transformPayment(payment, cart.billingAddress)
// If the customer is a registered user and a payment already exists on the cart,
// then the payment is has also been added to the user account. So, update the
// corresponding payment on the user account to see the reflected changes on the cart.
const apiCommand =
cart.payments.length > 0 && cart.customerInfo.id === 'current'
? api
.putPaymentDetail(cart.customerInfo.id, cart.payments[0].id, paymentDetails)
.then(() => this.getCart(cart.id))
: api
.postCartPaymentDetail(cart.customerInfo.id, cart.id, paymentDetails, {
...opts,
fields: 'FULL'
})
.then((data) => {
cart.payments[0] = this.parsePayment({...data, amount: cart.total})
return cart
})
return apiCommand.catch(() => {
throw new errors.ServerError('Could not set payment')
})
}
/**
* Set the customer information.
* @param {module:@mobify/commerce-integrations/dist/types.Cart} cart The customer's cart
* @param {module:@mobify/commerce-integrations/dist/types.CustomerInformation} customerInformation The new or modified customer information
* @param {Object} opts
* @return {Promise<module:@mobify/commerce-integrations/dist/types.Cart>}
*
* Email can be set for a guest user but not a registered user.
*/
setCustomerInformation(cart, customerInfo, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!customerInfo) {
throw new errors.InvalidArgumentError(`Parameter 'customerInformation' is required`)
}
const api = new hybris.UsersApi(this.client)
const customerId = cart.customerInfo.id
return api
.putCartEmail(customerId, cart.id, {...opts, email: customerInfo.email})
.then(() => {
cart.customerInfo = {...customerInfo, id: customerId}
return cart
})
.catch(() => {
throw new errors.ServerError('Could not set customer information')
})
}
/**
* @inheritDoc
*/
getShippingMethods(cart, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
const api = new hybris.UsersApi(this.client)
return api
.getCartDeliveryModes(cart.customerInfo.id, cart.id, {...opts, fields: 'FULL'})
.then(({deliveryModes}) => deliveryModes.map(this.parseShippingMethod.bind(this)))
}
/**
* @inheritDoc
*/
setShippingMethod(cart, shippingMethod) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!shippingMethod) {
throw new errors.InvalidArgumentError(`Parameter 'shippingMethod' is required`)
}
const api = new hybris.UsersApi(this.client)
return api
.putCartDeliveryMode(cart.customerInfo.id, cart.id, shippingMethod.id)
.then(() => {
cart.selectedShippingMethodId = shippingMethod.id
return cart
})
.catch(() => {
throw new errors.ServerError('Could not set shipping method')
})
}
/**
* Set the billing address on the cart.
*
* Billing Address model : this order address needs to have a titleCode defined.
* The default titleCodes are: ['mr', 'ms', 'miss', 'mrs', 'dr', 'rev']
* But these may change depending on your backend, please refer to your backend instance to confirm valid titleCodes.
*
* If the billing address is set before adding a payment, the billing address will not yet be saved in the hybris instance.
* Instead, the billing address will be saved in the hybris instance upon setting a payment.
* @param {module:@mobify/commerce-integrations/dist/types.Cart} cart The cart to update the billing address for
* @param {module:@mobify/commerce-integrations/dist/types.OrderAddress} billingAddress The new or modified address
* @param {Object} opts
* @return {Promise<module:@mobify/commerce-integrations/dist/types.Cart>}
*/
setBillingAddress(cart, billingAddress, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!billingAddress) {
throw new errors.InvalidArgumentError(`Parameter 'billingAddress' is required`)
}
const isMissingFields =
!billingAddress.countryCode ||
!billingAddress.addressLine1 ||
!billingAddress.stateCode ||
!billingAddress.titleCode ||
!billingAddress.postalCode ||
!billingAddress.city ||
!billingAddress.firstName ||
!billingAddress.lastName
if (isMissingFields) {
throw new errors.InvalidArgumentError('Missing hybris required fields')
}
// if the cart does not have a payment yet, give back a cart with the billing address set.
// else, there a payment already exists and it's billing address needs to be updated
if (cart.payments.length <= 0) {
cart.billingAddress = billingAddress
return Promise.resolve(cart)
}
const api = new hybris.UsersApi(this.client)
// For a registered user, the existing payment can be patched to update it's billing address
if (cart.customerInfo.id === 'current') {
return api
.patchPaymentDetail(cart.customerInfo.id, cart.payments[0].id, {
billingAddress: this.transformOrderAddress(billingAddress)
})
.then(() => {
cart.billingAddress = billingAddress
return cart
})
.catch(() => {
throw new errors.ServerError('Could not set billing address')
})
}
// For a guest user, the existing payment can only be updated if the payment detail number is re-injected.
if (cart.payments[0].details.number === undefined) {
throw new errors.InvalidArgumentError(
'As a guest user, please re-inject payment details number before updating billing address'
)
}
return api
.postCartPaymentDetail(
cart.customerInfo.id,
cart.id,
this.transformPayment(cart.payments[0], billingAddress),
{...opts, fields: 'FULL'}
)
.then((data) => {
cart.billingAddress = billingAddress
cart.payments[0] = this.parsePayment({...data, amount: cart.total})
return cart
})
.catch(() => {
throw new errors.ServerError('Could not set billing address')
})
}
/**
* Set the shipping method for the cart.
* @param {module:@mobify/commerce-integrations/dist/types.Cart} cart The cart to set the shipping method for
* @param {module:@mobify/commerce-integrations/dist/types.ShippingMethod} shippingMethod The shipping method to setid
* @param {Object} opts
* @return {Promise<module:@mobify/commerce-integrations/dist/types.Cart>}
*
* Must call setCustomerInformation() to add an email before setting payment for a guest user.
*/
setShippingAddress(cart, shippingAddress, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!shippingAddress) {
throw new errors.InvalidArgumentError(`Parameter 'shippingAddress' is required`)
}
if (cart.customerInfo.id === 'anonymous' && cart.customerInfo.email === undefined) {
throw new errors.InvalidArgumentError('Please set email for guest user cart')
}
const isMissingFields =
!shippingAddress.firstName ||
!shippingAddress.lastName ||
!shippingAddress.addressLine1 ||
!shippingAddress.city ||
!shippingAddress.countryCode ||
!shippingAddress.stateCode ||
!shippingAddress.postalCode
if (isMissingFields) {
throw new errors.InvalidArgumentError('Missing hybris required fields')
}
const api = new hybris.UsersApi(this.client)
// For a registered user, api.postCartDeliveryAddress() returns an unauthorized error (unknown reason)
// A workaround: post or put (if already exists a shipping address) the user address and then put that address to the cart by its id.
let apiCommand
if (cart.customerInfo.id === 'current') {
const userAddressApiCommand =
cart.shippingAddress === undefined
? api.postUserAddress(
cart.customerInfo.id,
this.transformOrderAddress(shippingAddress),
{fields: 'FULL'}
)
: api.putUserAddress(cart.customerInfo.id, shippingAddress.id, {
...this.transformOrderAddress(shippingAddress),
id: shippingAddress.id
})
apiCommand = userAddressApiCommand.then((data) => {
return api.putCartDeliveryAddress(cart.customerInfo.id, cart.id, data.id)
})
} else {
apiCommand = api.postCartDeliveryAddress(
cart.customerInfo.id,
cart.id,
this.transformOrderAddress(shippingAddress),
{...opts, fields: 'FULL'}
)
}
return apiCommand
.then(() => {
cart.shippingAddress = shippingAddress
return cart
})
.catch(() => {
throw new errors.ServerError('Could not set shipping address')
})
}
/**
* Get the available payment methods for the cart
* @param {module:@mobify/commerce-integrations/dist/types.Cart} cart The customer's cart
* @param {Object} opts
* @return {Promise<Array.module:@mobify/commerce-integrations/dist/types.PaymentMethod>}
*
* Hybris only supports payments by credit card attained from their cardtypes endpoint
* so this command will return only one payment method with the id 'CREDIT_CARD', name 'Credit Card'
* and all of it's valid credit card types.
*/
getPaymentMethods(cart, opts = {}) {
const api = new hybris.CardtypesApi(this.client)
return api
.getCardTypes({...opts, fields: 'FULL'})
.then((data) => [
this.parsePaymentMethod({
id: 'CREDIT_CARD',
name: 'Credit Card',
types: data.cardTypes
})
])
.catch(() => {
throw new errors.ServerError('Could not get payment methods')
})
}
/**
* @inheritDoc
*/
parseOrder(data) {
const order = this.parseCart(data)
return {
...order,
id: data.code,
creationDate: new Date(data.created),
status: data.statusDisplay,
customerInfo: {
id: data.guestCustomer ? 'anonymous' : 'current',
email: data.paymentInfo.billingAddress.email
}
}
}
/**
* @inheritDoc
*/
getOrder(id, opts = {}) {
if (!id) {
throw new errors.InvalidArgumentError(`Parameter 'id' is required`)
}
let getOrderPromise
// BUG: does not save billing address region when sending billing address through a POST cart payment detail.
// So when retrieving an order by id, region (stateCode field) may be missing in the returned order.
if (this.getCustomerIdFromAuth() === 'current') {
const api = new hybris.UsersApi(this.client)
getOrderPromise = api.getOrder('current', id, {...opts, fields: 'FULL'})
} else {
const api = new hybris.OrdersApi(this.client)
getOrderPromise = api.getOrder(id, {...opts, fields: 'FULL'})
}
return getOrderPromise
.then((data) => this.parseOrder(data))
.catch(() => {
throw new errors.ServerError('Order not found')
})
}
/**
* @inheritDoc
*/
getOrders(ids, opts = {}) {
if (!ids || ids.length <= 0) {
throw new errors.InvalidArgumentError('Please specify list of order ids to get.')
}
const getOrderPromises = ids.map((id) => this.getOrder(id, opts).catch(() => null))
return Promise.all(getOrderPromises).then((orders) => {
const validOrders = orders.filter((order) => !!order)
return {
count: validOrders.length,
data: validOrders,
total: validOrders.length
}
})
}
/**
* @inheritDoc
*/
createOrder(cart, opts = {}) {
const api = new hybris.UsersApi(this.client)
return api
.postOrder(cart.customerInfo.id, cart.id, {...opts, fields: 'FULL'})
.then((data) => {
const order = this.parseOrder(data)
// BUG: does not save billing address region when sending billing address through a POST cart payment detail.
// So lets persist this field on the incoming cart parameter to the returned order.
if (order.billingAddress && !order.billingAddress.stateCode) {
order.billingAddress.stateCode = cart.billingAddress.stateCode
}
return order
})
.catch(() => {
throw new errors.ServerError('Could not create order')
})
}
/**
* @inheritDoc
*/
// eslint-disable-next-line no-unused-vars
addCouponEntry(cart, couponCode, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!couponCode) {
throw new errors.InvalidArgumentError(`Parameter 'couponCode' is required`)
}
const api = new hybris.UsersApi(this.client)
return api
.postCartVoucher(cart.customerInfo.id, cart.id, couponCode)
.then(() => this.getCart(cart.id))
.catch(() => {
throw new errors.ServerError('Could Not Add Coupon')
})
}
/**
* @inheritDoc
*/
// eslint-disable-next-line no-unused-vars
removeCouponEntry(cart, couponEntryId, opts = {}) {
if (!cart) {
throw new errors.InvalidArgumentError(`Parameter 'cart' is required`)
}
if (!couponEntryId) {
throw new errors.InvalidArgumentError(`Parameter 'couponEntryId' is required`)
}
const api = new hybris.UsersApi(this.client)
return api
.deleteCartVouchers(cart.customerInfo.id, cart.id, couponEntryId)
.then(() => this.getCart(cart.id))
.catch(() => {
throw new errors.ServerError('Could Not Remove Coupon')
})
}
}