diff --git a/packages/analytics-js-integrations/.size-limit.mjs b/packages/analytics-js-integrations/.size-limit.mjs index 8eb6e594d..dcb4711f0 100644 --- a/packages/analytics-js-integrations/.size-limit.mjs +++ b/packages/analytics-js-integrations/.size-limit.mjs @@ -6,7 +6,7 @@ export default [ { name: 'All Integrations - Legacy - CDN', path: 'dist/cdn/legacy/js-integrations/*.min.js', - limit: '97 KiB', + limit: '98.5 KiB', }, { name: 'All Integrations - Modern - CDN', diff --git a/packages/analytics-js-integrations/__tests__/integrations/Braze/browser.test.js b/packages/analytics-js-integrations/__tests__/integrations/Braze/browser.test.js index d894383f9..c1bc8d3b4 100644 --- a/packages/analytics-js-integrations/__tests__/integrations/Braze/browser.test.js +++ b/packages/analytics-js-integrations/__tests__/integrations/Braze/browser.test.js @@ -1077,6 +1077,488 @@ describe('track', () => { }); }); +describe('track - recommended ecommerce events', () => { + const baseConfig = { + appKey: 'APP_KEY', + trackAnonymousUser: true, + enableBrazeLogging: false, + dataCenter: 'US-03', + allowUserSuppliedJavascript: false, + useEcommerceRecommendedEvents: true, + }; + + const buildBraze = (configOverrides = {}) => { + const braze = new Braze({ ...baseConfig, ...configOverrides }, {}, {}); + mockBrazeSDK(); + return braze; + }; + + const trackEvent = (braze, event, properties) => { + braze.track({ message: { userId: 'user123', event, properties } }); + }; + + it('maps Product Viewed to ecommerce.product_viewed (flat, no products array)', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Viewed', { + product_id: 'p1', + name: 'Game', + variant: 'v1', + price: 15.99, + currency: 'USD', + image_url: 'https://img/p1.png', + url: 'https://shop/p1', + type: ['price_drop'], + campaign: 'summer', + }); + + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledTimes(1); + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledWith('ecommerce.product_viewed', { + product_id: 'p1', + product_name: 'Game', + variant_id: 'v1', + price: 15.99, + currency: 'USD', + image_url: 'https://img/p1.png', + product_url: 'https://shop/p1', + type: ['price_drop'], + source: 'web', + metadata: { campaign: 'summer' }, + }); + expect(warnMock).not.toHaveBeenCalled(); + }); + + it('maps Product Added to ecommerce.cart_updated with action add (single-product wrap)', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Added', { + cart_id: 'c1', + currency: 'USD', + product_id: 'p1', + name: 'Game', + variant: 'v1', + quantity: 2, + price: 15.99, + url: 'https://shop/p1', + list_id: 'wishlist', + }); + + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledWith('ecommerce.cart_updated', { + cart_id: 'c1', + currency: 'USD', + source: 'web', + action: 'add', + products: [ + { + product_id: 'p1', + product_name: 'Game', + variant_id: 'v1', + quantity: 2, + price: 15.99, + product_url: 'https://shop/p1', + }, + ], + metadata: { list_id: 'wishlist' }, + }); + expect(warnMock).not.toHaveBeenCalled(); + }); + + it('maps Product Removed to ecommerce.cart_updated with action remove', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Removed', { + cart_id: 'c1', + currency: 'USD', + product_id: 'p1', + name: 'Game', + variant: 'v1', + quantity: 1, + price: 15.99, + }); + + const [eventName, props] = globalThis.braze.logCustomEvent.mock.calls[0]; + expect(eventName).toBe('ecommerce.cart_updated'); + expect(props.action).toBe('remove'); + }); + + it('cart_updated with an explicit products[] maps the array and keeps top-level fields in metadata', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Added', { + cart_id: 'c1', + currency: 'USD', + // top-level product-like fields coexisting with an explicit products[] array + product_id: 'top-level-pid', + name: 'top-level-name', + products: [{ product_id: 'p1', name: 'Game', variant: 'v1', quantity: 2, price: 15.99 }], + }); + + const props = globalThis.braze.logCustomEvent.mock.calls[0][1]; + // array is mapped, not the top-level wrap + expect(props.products).toEqual([ + { product_id: 'p1', product_name: 'Game', variant_id: 'v1', quantity: 2, price: 15.99 }, + ]); + // top-level product-like fields were NOT consumed, so they flow to metadata + expect(props.metadata).toEqual({ product_id: 'top-level-pid', name: 'top-level-name' }); + }); + + it('does not leak a caller-provided properties.action into metadata', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Added', { + cart_id: 'c1', + currency: 'USD', + product_id: 'p1', + name: 'Game', + variant: 'v1', + quantity: 1, + price: 15.99, + action: 'caller-supplied', + }); + + const props = globalThis.braze.logCustomEvent.mock.calls[0][1]; + expect(props.action).toBe('add'); + expect(props.metadata).toBeUndefined(); + }); + + it('accepts total_discounts under either properties.discount or properties.total_discounts', () => { + const braze = buildBraze(); + trackEvent(braze, 'Order Refunded', { + order_id: 'o1', + total: 10, + currency: 'USD', + total_discounts: 3, + products: [{ product_id: 'p1', name: 'Game', variant: 'v1', quantity: 1, price: 15.99 }], + }); + + expect(globalThis.braze.logCustomEvent.mock.calls[0][1].total_discounts).toBe(3); + }); + + it('does not emit an empty product object when cart_updated has no product fields', () => { + const braze = buildBraze(); + // `Product Added` maps to ecommerce.cart_updated; with no product fields the + // single-product wrap must not emit a degenerate `[{}]`. + trackEvent(braze, 'Product Added', { cart_id: 'c1' }); + + const [eventName, props] = globalThis.braze.logCustomEvent.mock.calls[0]; + expect(eventName).toBe('ecommerce.cart_updated'); + // no degenerate `[{}]` — the empty products array is scrubbed off entirely + expect(props.products).toBeUndefined(); + }); + + it('scrubs null/empty values out of event-level and per-product metadata', () => { + const braze = buildBraze(); + trackEvent(braze, 'Order Completed', { + order_id: 'o1', + total: 31.98, + currency: 'USD', + coupon: '', + note: null, + campaign: 'spring', + products: [ + { + product_id: 'p1', + name: 'Game', + variant: 'v1', + quantity: 2, + price: 15.99, + color: '', + shade: null, + finish: 'matte', + }, + ], + }); + + const props = globalThis.braze.logCustomEvent.mock.calls[0][1]; + expect(props.metadata).toEqual({ campaign: 'spring' }); + expect(props.products[0].metadata).toEqual({ finish: 'matte' }); + }); + + it('maps Checkout Started to ecommerce.checkout_started with products array', () => { + const braze = buildBraze(); + trackEvent(braze, 'Checkout Started', { + checkout_id: 'ck1', + cart_id: 'c1', + total: 31.98, + currency: 'USD', + products: [{ product_id: 'p1', name: 'Game', variant: 'v1', quantity: 2, price: 15.99 }], + }); + + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledWith('ecommerce.checkout_started', { + checkout_id: 'ck1', + cart_id: 'c1', + total_value: 31.98, + currency: 'USD', + source: 'web', + products: [ + { product_id: 'p1', product_name: 'Game', variant_id: 'v1', quantity: 2, price: 15.99 }, + ], + }); + expect(warnMock).not.toHaveBeenCalled(); + }); + + it('maps Order Completed to ecommerce.order_placed (precedence over legacy purchases)', () => { + const braze = buildBraze(); + trackEvent(braze, 'order completed', { + order_id: 'o1', + total: 31.98, + currency: 'USD', + coupon: 'SAVE10', + products: [ + { product_id: 'p1', name: 'Game', variant: 'v1', quantity: 2, price: 15.99, color: 'red' }, + ], + }); + + expect(globalThis.braze.logPurchase).not.toHaveBeenCalled(); + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledWith('ecommerce.order_placed', { + order_id: 'o1', + total_value: 31.98, + currency: 'USD', + source: 'web', + products: [ + { + product_id: 'p1', + product_name: 'Game', + variant_id: 'v1', + quantity: 2, + price: 15.99, + metadata: { color: 'red' }, + }, + ], + metadata: { coupon: 'SAVE10' }, + }); + }); + + it('maps Order Refunded with optional total_discounts and discounts passthrough', () => { + const braze = buildBraze(); + trackEvent(braze, 'Order Refunded', { + order_id: 'o1', + total: 31.98, + currency: 'USD', + total_discounts: 5, + discounts: [{ code: 'SAVE10', amount: 5 }], + products: [{ product_id: 'p1', name: 'Game', variant: 'v1', quantity: 2, price: 15.99 }], + }); + + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledWith('ecommerce.order_refunded', { + order_id: 'o1', + total_value: 31.98, + currency: 'USD', + total_discounts: 5, + discounts: [{ code: 'SAVE10', amount: 5 }], + source: 'web', + products: [ + { product_id: 'p1', product_name: 'Game', variant_id: 'v1', quantity: 2, price: 15.99 }, + ], + }); + expect(warnMock).not.toHaveBeenCalled(); + }); + + it('maps Order Cancelled with cancel_reason fallback to reason', () => { + const braze = buildBraze(); + trackEvent(braze, 'Order Cancelled', { + order_id: 'o1', + total: 31.98, + currency: 'USD', + reason: 'changed mind', + products: [{ product_id: 'p1', name: 'Game', variant: 'v1', quantity: 2, price: 15.99 }], + }); + + const [eventName, props] = globalThis.braze.logCustomEvent.mock.calls[0]; + expect(eventName).toBe('ecommerce.order_cancelled'); + expect(props.cancel_reason).toBe('changed mind'); + expect(warnMock).not.toHaveBeenCalled(); + }); + + it('warns with the missing required field names but still sends the event', () => { + const braze = buildBraze(); + // Product Added missing required `currency`, and product missing `quantity`/`price`. + trackEvent(braze, 'Product Added', { + cart_id: 'c1', + product_id: 'p1', + name: 'Game', + variant: 'v1', + }); + + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledTimes(1); + expect(warnMock).toHaveBeenCalledTimes(1); + const warnMessage = warnMock.mock.calls[0][0]; + expect(warnMessage).toContain('ecommerce.cart_updated'); + expect(warnMessage).toContain('currency'); + expect(warnMessage).toContain('products.quantity'); + expect(warnMessage).toContain('products.price'); + }); + + it('reports empty products array as a missing field and strips it from the payload', () => { + const braze = buildBraze(); + trackEvent(braze, 'Order Completed', { + order_id: 'o1', + total: 10, + currency: 'USD', + products: [], + }); + + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledTimes(1); + expect(warnMock.mock.calls[0][0]).toContain('products'); + // empty products[] is scrubbed before send + expect(globalThis.braze.logCustomEvent.mock.calls[0][1]).not.toHaveProperty('products'); + }); + + it('honors an explicit valid properties.source override', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Viewed', { + product_id: 'p1', + name: 'Game', + variant: 'v1', + price: 15.99, + currency: 'USD', + source: 'ios', + }); + + expect(globalThis.braze.logCustomEvent.mock.calls[0][1].source).toBe('ios'); + }); + + it('honors an explicit properties.source with surrounding whitespace', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Viewed', { + product_id: 'p1', + name: 'Game', + variant: 'v1', + price: 15.99, + currency: 'USD', + source: 'ios ', + }); + + expect(globalThis.braze.logCustomEvent.mock.calls[0][1].source).toBe('ios'); + }); + + it('warns and scrubs a required field provided as an empty object', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Viewed', { + product_id: 'p1', + name: 'Game', + variant: 'v1', + price: 15.99, + currency: {}, + }); + + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledTimes(1); + // empty-object required value is reported as missing... + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock.mock.calls[0][0]).toContain('currency'); + // ...and never reaches the sent payload. + expect(globalThis.braze.logCustomEvent.mock.calls[0][1]).not.toHaveProperty('currency'); + }); + + it('coerces safe/lossless type mismatches and does not warn', () => { + const braze = buildBraze(); + trackEvent(braze, 'Order Completed', { + order_id: 12345, // number -> string + total: '99.99', // numeric string -> float + currency: 'USD', + products: [{ product_id: 'p1', name: 'Game', variant: 'v1', quantity: '2', price: '15.99' }], + }); + + const props = globalThis.braze.logCustomEvent.mock.calls[0][1]; + expect(props.order_id).toBe('12345'); + expect(props.total_value).toBe(99.99); + expect(props.products[0].quantity).toBe(2); + expect(props.products[0].price).toBe(15.99); + expect(warnMock).not.toHaveBeenCalled(); + }); + + it('sends un-coercible type mismatches as-is and warns (event-level and per-product)', () => { + const braze = buildBraze(); + trackEvent(braze, 'Order Completed', { + order_id: 'o1', + total: 'free', // non-numeric string -> stays, float mismatch + currency: 'USD', + products: [ + { product_id: 'p1', name: 'Game', variant: 'v1', quantity: 2.5, price: 15.99 }, // 2.5 not integer + ], + }); + + const props = globalThis.braze.logCustomEvent.mock.calls[0][1]; + // un-coercible values are sent verbatim + expect(props.total_value).toBe('free'); + expect(props.products[0].quantity).toBe(2.5); + // ...and surfaced via the type-mismatch warning + expect(warnMock).toHaveBeenCalledTimes(1); + const warnMessage = warnMock.mock.calls[0][0]; + expect(warnMessage).toContain('type-mismatched'); + expect(warnMessage).toContain('total_value (expected float)'); + expect(warnMessage).toContain('products.quantity (expected integer)'); + }); + + it('leaves an integer-valued string with a fractional part un-coerced for an integer field', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Added', { + cart_id: 'c1', + currency: 'USD', + product_id: 'p1', + name: 'Game', + variant: 'v1', + quantity: '2.5', // not a pure integer literal -> stays "2.5" + price: '15.99', // numeric string -> 15.99 + }); + + const props = globalThis.braze.logCustomEvent.mock.calls[0][1]; + expect(props.products[0].quantity).toBe('2.5'); + expect(props.products[0].price).toBe(15.99); + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock.mock.calls[0][0]).toContain('products.quantity (expected integer)'); + }); + + it('warns when product_viewed type is not an array of strings', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Viewed', { + product_id: 'p1', + name: 'Game', + variant: 'v1', + price: 15.99, + currency: 'USD', + type: 'price_drop', // bare string, expected array of strings + }); + + const props = globalThis.braze.logCustomEvent.mock.calls[0][1]; + expect(props.type).toBe('price_drop'); // sent as-is + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock.mock.calls[0][0]).toContain('type (expected stringArray)'); + }); + + it('does not coerce a boolean to string; sends it as-is and warns', () => { + const braze = buildBraze(); + trackEvent(braze, 'Product Viewed', { + product_id: 'p1', + name: 'Game', + variant: 'v1', + price: 15.99, + currency: true, // boolean on a string field — not coerced + }); + + const props = globalThis.braze.logCustomEvent.mock.calls[0][1]; + expect(props.currency).toBe(true); // sent verbatim, not "true" + expect(warnMock).toHaveBeenCalledTimes(1); + expect(warnMock.mock.calls[0][0]).toContain('currency (expected string)'); + }); + + it('falls through to legacy custom-event path for unmapped events (Cart Updated)', () => { + const braze = buildBraze(); + trackEvent(braze, 'Cart Updated', { cart_id: 'c1', value: 10 }); + + expect(globalThis.braze.logCustomEvent).toHaveBeenCalledWith('Cart Updated', { + cart_id: 'c1', + value: 10, + }); + }); + + it('falls through to legacy purchase path when the flag is off', () => { + const braze = buildBraze({ useEcommerceRecommendedEvents: false }); + trackEvent(braze, 'order completed', { + currency: 'USD', + products: [{ product_id: 'p1', price: 15.99, quantity: 1 }], + }); + + expect(globalThis.braze.logPurchase).toHaveBeenCalledTimes(1); + expect(globalThis.braze.logCustomEvent).not.toHaveBeenCalled(); + }); +}); + describe('page', () => { it('should call the necessary Braze methods to custom event', () => { const config = { diff --git a/packages/analytics-js-integrations/src/integrations/Braze/browser.js b/packages/analytics-js-integrations/src/integrations/Braze/browser.js index 8857cc02d..4565e0775 100644 --- a/packages/analytics-js-integrations/src/integrations/Braze/browser.js +++ b/packages/analytics-js-integrations/src/integrations/Braze/browser.js @@ -7,6 +7,7 @@ import Logger from '../../utils/logger'; import { isObject } from '../../utils/utils'; import { isNotEmpty } from '../../utils/commonUtils'; import { handlePurchase, formatGender, handleReservedProperties } from './utils'; +import { getEcommerceMapping, buildEcommerceEventProperties } from './ecommerceUtil'; import { loadNativeSdk } from './nativeSdkLoader'; const logger = new Logger(DISPLAY_NAME); @@ -37,6 +38,7 @@ class Braze { } } this.endPoint = ''; + this.useEcommerceRecommendedEvents = config.useEcommerceRecommendedEvents || false; this.isHybridModeEnabled = config.connectionMode === 'hybrid'; this.isReadyStatus = { hasLoggedErrorForAlias: false, @@ -357,7 +359,19 @@ class Braze { canSendCustomEvent = true; } if (eventName && canSendCustomEvent) { - if (eventName.toLowerCase() === 'order completed') { + const ecommerceMapping = this.useEcommerceRecommendedEvents + ? getEcommerceMapping(eventName) + : undefined; + if (ecommerceMapping) { + const { brazeEvent, action } = ecommerceMapping; + const ecommerceProperties = buildEcommerceEventProperties( + rudderElement.message, + brazeEvent, + action, + logger, + ); + globalThis.braze.logCustomEvent(brazeEvent, ecommerceProperties); + } else if (eventName.toLowerCase() === 'order completed') { handlePurchase(properties); } else { properties = handleReservedProperties(properties); diff --git a/packages/analytics-js-integrations/src/integrations/Braze/ecommerceUtil.js b/packages/analytics-js-integrations/src/integrations/Braze/ecommerceUtil.js new file mode 100644 index 000000000..520c3ea1d --- /dev/null +++ b/packages/analytics-js-integrations/src/integrations/Braze/ecommerceUtil.js @@ -0,0 +1,523 @@ +import { constructPayload } from '../../utils/utils'; +import { + isDefinedAndNotNullAndNotEmpty, + removeUndefinedAndNullAndEmptyValues, +} from '../../utils/commonUtils'; + +/** + * Braze recommended ecommerce event names. + * https://www.braze.com/docs/user_guide/data/activation/events/recommended_events + */ +export const BRAZE_ECOMMERCE_EVENTS = { + PRODUCT_VIEWED: 'ecommerce.product_viewed', + CART_UPDATED: 'ecommerce.cart_updated', + CHECKOUT_STARTED: 'ecommerce.checkout_started', + ORDER_PLACED: 'ecommerce.order_placed', + ORDER_REFUNDED: 'ecommerce.order_refunded', + ORDER_CANCELLED: 'ecommerce.order_cancelled', +}; + +const BRAZE_SOURCE_VALUES = ['web', 'ios', 'android']; + +// Case-insensitive RS event name -> Braze recommended event mapping. +// Keys are lowercased RS event names. `Cart Viewed` and `Cart Updated` are +// intentionally absent — both fall through to the legacy custom-event path. +const EVENT_NAME_TO_BRAZE = { + 'product viewed': { brazeEvent: BRAZE_ECOMMERCE_EVENTS.PRODUCT_VIEWED }, + 'product added': { brazeEvent: BRAZE_ECOMMERCE_EVENTS.CART_UPDATED, action: 'add' }, + 'product removed': { brazeEvent: BRAZE_ECOMMERCE_EVENTS.CART_UPDATED, action: 'remove' }, + 'checkout started': { brazeEvent: BRAZE_ECOMMERCE_EVENTS.CHECKOUT_STARTED }, + 'order completed': { brazeEvent: BRAZE_ECOMMERCE_EVENTS.ORDER_PLACED }, + 'order refunded': { brazeEvent: BRAZE_ECOMMERCE_EVENTS.ORDER_REFUNDED }, + 'order cancelled': { brazeEvent: BRAZE_ECOMMERCE_EVENTS.ORDER_CANCELLED }, +}; + +// The type Braze expects for a recommended-event field. Resolved values are coerced to +// this type where the conversion is safe and lossless; an un-coercible value is sent +// verbatim and surfaced via the type-mismatch warning. +const FIELD_TYPE = { + STRING: 'string', + INTEGER: 'integer', + FLOAT: 'float', + STRING_ARRAY: 'stringArray', + ARRAY: 'array', +}; + +// --------------------------------------------------------------------------- +// Per-event field mappings (mirror of the cloud `Braze*Config.json` files). +// Each entry is built via `m(destKey, sourceKeys, required, type)`: +// - `destKey`/`sourceKeys` are the `constructPayload` contract. +// - `req` flags Braze-required fields (consumed by collectMissingRequiredFields). +// - `type` is the Braze-expected type (default String); drives coercion + the +// type-mismatch warning. +// `sourceKeys` arrays are ordered fallback chains (first resolved value wins). +// --------------------------------------------------------------------------- + +const m = (destKey, sourceKeys, req = false, type = FIELD_TYPE.STRING) => ({ + destKey, + sourceKeys, + req, + type, +}); + +// Shared fallback chains reused across checkout/order events. +const TOTAL_VALUE_SOURCES = ['properties.total', 'properties.revenue', 'properties.value']; +const TOTAL_DISCOUNTS_SOURCES = ['properties.discount', 'properties.total_discounts']; + +const PRODUCT_VIEWED_MAPPING = [ + m('product_id', ['properties.product_id', 'properties.sku'], true), + m('product_name', 'properties.name', true), + m('variant_id', ['properties.variant', 'properties.sku', 'properties.product_id'], true), + m('price', 'properties.price', true, FIELD_TYPE.FLOAT), + m('currency', 'properties.currency', true), + m('image_url', 'properties.image_url'), + m('product_url', 'properties.url'), + m('type', 'properties.type', false, FIELD_TYPE.STRING_ARRAY), +]; + +const CART_UPDATED_MAPPING = [ + m('cart_id', 'properties.cart_id', true), + m('total_value', ['properties.total', 'properties.value'], false, FIELD_TYPE.FLOAT), + m('subtotal_value', 'properties.subtotal_value', false, FIELD_TYPE.FLOAT), + m('tax', 'properties.tax', false, FIELD_TYPE.FLOAT), + m('shipping', 'properties.shipping', false, FIELD_TYPE.FLOAT), + m('currency', 'properties.currency', true), +]; + +const CHECKOUT_STARTED_MAPPING = [ + m('checkout_id', ['properties.checkout_id', 'properties.order_id'], true), + m('cart_id', 'properties.cart_id'), + m('total_value', TOTAL_VALUE_SOURCES, true, FIELD_TYPE.FLOAT), + m('subtotal_value', 'properties.subtotal_value', false, FIELD_TYPE.FLOAT), + m('tax', 'properties.tax', false, FIELD_TYPE.FLOAT), + m('shipping', 'properties.shipping', false, FIELD_TYPE.FLOAT), + m('currency', 'properties.currency', true), +]; + +const ORDER_PLACED_MAPPING = [ + m('order_id', 'properties.order_id', true), + m('total_value', TOTAL_VALUE_SOURCES, true, FIELD_TYPE.FLOAT), + m('currency', 'properties.currency', true), + m('cart_id', 'properties.cart_id'), + m('tax', 'properties.tax', false, FIELD_TYPE.FLOAT), + m('shipping', 'properties.shipping', false, FIELD_TYPE.FLOAT), + m('total_discounts', TOTAL_DISCOUNTS_SOURCES, false, FIELD_TYPE.FLOAT), + m('subtotal_value', 'properties.subtotal_value', false, FIELD_TYPE.FLOAT), + m('discounts', 'properties.discounts', false, FIELD_TYPE.ARRAY), +]; + +const ORDER_REFUNDED_MAPPING = [ + m('order_id', 'properties.order_id', true), + m('total_value', TOTAL_VALUE_SOURCES, true, FIELD_TYPE.FLOAT), + m('currency', 'properties.currency', true), + m('total_discounts', TOTAL_DISCOUNTS_SOURCES, false, FIELD_TYPE.FLOAT), + m('discounts', 'properties.discounts', false, FIELD_TYPE.ARRAY), +]; + +const ORDER_CANCELLED_MAPPING = [ + m('order_id', 'properties.order_id', true), + m('total_value', TOTAL_VALUE_SOURCES, true, FIELD_TYPE.FLOAT), + m('currency', 'properties.currency', true), + m('cancel_reason', ['properties.cancel_reason', 'properties.reason'], true), + m('tax', 'properties.tax', false, FIELD_TYPE.FLOAT), + m('shipping', 'properties.shipping', false, FIELD_TYPE.FLOAT), + m('total_discounts', TOTAL_DISCOUNTS_SOURCES, false, FIELD_TYPE.FLOAT), + m('subtotal_value', 'properties.subtotal_value', false, FIELD_TYPE.FLOAT), + m('discounts', 'properties.discounts', false, FIELD_TYPE.ARRAY), +]; + +// Shared per-product mapping (bare keys — read from each `products[i]` / +// `properties` directly, no `properties.` prefix). +const ECOMMERCE_PRODUCT_MAPPING = [ + m('product_id', ['product_id', 'sku'], true), + m('product_name', 'name', true), + m('variant_id', ['variant', 'sku', 'product_id'], true), + m('quantity', 'quantity', true, FIELD_TYPE.INTEGER), + m('price', 'price', true, FIELD_TYPE.FLOAT), + m('image_url', 'image_url'), + m('product_url', 'url'), +]; + +const PER_EVENT_MAPPING = { + [BRAZE_ECOMMERCE_EVENTS.PRODUCT_VIEWED]: PRODUCT_VIEWED_MAPPING, + [BRAZE_ECOMMERCE_EVENTS.CART_UPDATED]: CART_UPDATED_MAPPING, + [BRAZE_ECOMMERCE_EVENTS.CHECKOUT_STARTED]: CHECKOUT_STARTED_MAPPING, + [BRAZE_ECOMMERCE_EVENTS.ORDER_PLACED]: ORDER_PLACED_MAPPING, + [BRAZE_ECOMMERCE_EVENTS.ORDER_REFUNDED]: ORDER_REFUNDED_MAPPING, + [BRAZE_ECOMMERCE_EVENTS.ORDER_CANCELLED]: ORDER_CANCELLED_MAPPING, +}; + +// --------------------------------------------------------------------------- +// Private helpers +// --------------------------------------------------------------------------- + +/** + * A field counts as "resolved" iff it survives the outgoing payload scrub + * (`removeUndefinedAndNullAndEmptyValues`). Reuse that exact predicate so the + * missing-required-field warning can never drift from what's actually sent — e.g. a + * required field of `{}`/`[]`/`''` is both stripped from the payload AND warned, while + * `0`/`false`/numbers stay valid. + */ +const isResolvedValue = isDefinedAndNotNullAndNotEmpty; + +// A safe, lossless numeric-string conversion accepts only plain decimal literals (no +// scientific notation, Infinity, or NaN). Integer additionally forbids a fractional part. +const FLOAT_STRING_REGEX = /^[+-]?(\d+\.?\d*|\.\d+)$/; +const INTEGER_STRING_REGEX = /^[+-]?\d+$/; + +/** + * Coerce a resolved primitive to the type Braze expects, when the conversion is safe and + * lossless; otherwise return it unchanged (the residual mismatch is surfaced by the + * type-mismatch warning). Mirrors the cloud coercion table: + * - numeric string -> float (`"29.99"` -> `29.99`) + * - integer string -> integer (`"2"` -> `2`; `"2.5"`/`"2.0"` left as-is) + * - number -> string (`12345` -> `"12345"`) + * Integer numbers are left as-is for float fields (Braze accepts an int where a float is + * expected, and JSON cannot express `2.0`). Booleans are NOT coerced to string, and + * arrays/objects are never coerced. + */ +const coerceValue = (value, type) => { + if (value === null || typeof value === 'object') { + return value; + } + switch (type) { + case FIELD_TYPE.STRING: + return typeof value === 'number' ? String(value) : value; + case FIELD_TYPE.FLOAT: + return typeof value === 'string' && FLOAT_STRING_REGEX.test(value.trim()) + ? Number(value.trim()) + : value; + case FIELD_TYPE.INTEGER: + // Only a pure integer literal is a safe, lossless conversion ("2", not "2.5"/"2.0"). + return typeof value === 'string' && INTEGER_STRING_REGEX.test(value.trim()) + ? Number(value.trim()) + : value; + default: + // stringArray / array — never coerced. + return value; + } +}; + +/** + * Whether `value` already matches the type Braze expects. A numeric written as a string + * (e.g. `"29.99"`) does NOT match a numeric type, so an un-coercible value still warns. + * `0`/`false` are valid for their respective types. + */ +const matchesType = (value, type) => { + switch (type) { + case FIELD_TYPE.STRING: + return typeof value === 'string'; + case FIELD_TYPE.INTEGER: + // `value % 1 === 0` is true only for whole numbers; NaN/Infinity yield NaN and fail. + return typeof value === 'number' && value % 1 === 0; + case FIELD_TYPE.FLOAT: + return typeof value === 'number' && !Number.isNaN(value); + case FIELD_TYPE.STRING_ARRAY: + return Array.isArray(value) && value.every(item => typeof item === 'string'); + case FIELD_TYPE.ARRAY: + return Array.isArray(value); + default: + return true; + } +}; + +/** + * Coerce every mapped field present in `obj` to its Braze-expected type, in place. + * Returns `obj` for chaining. + */ +const coerceMappedFields = (obj, mapping) => { + mapping.forEach(entry => { + if (entry.destKey in obj) { + obj[entry.destKey] = coerceValue(obj[entry.destKey], entry.type); + } + }); + return obj; +}; + +/** + * Return the subset of `source` whose keys are not in `consumed`, with undefined/null/empty + * values scrubbed out. Used to derive the `metadata` pass-through — the top-level + * `removeUndefinedAndNullAndEmptyValues` is shallow, so this is the only place nested + * metadata gets cleaned. + */ +const pickUnmappedKeys = (source, consumed) => { + const result = {}; + Object.keys(source).forEach(key => { + if (!consumed.has(key)) { + result[key] = source[key]; + } + }); + return removeUndefinedAndNullAndEmptyValues(result); +}; + +/** + * Compute the set of source keys referenced by a per-product mapping. Product mappings + * use bare keys (`product_id`, `name`, ...), so no prefix-stripping is required. + */ +const consumedKeysFromMapping = mapping => { + const consumed = new Set(); + mapping.forEach(entry => { + const sources = Array.isArray(entry.sourceKeys) ? entry.sourceKeys : [entry.sourceKeys]; + sources.forEach(src => { + if (typeof src === 'string') { + consumed.add(src); + } + }); + }); + return consumed; +}; + +/** + * Compute the set of message-property keys "consumed" by the event-level mapping (so + * they aren't duplicated into `metadata`). Includes the `properties.`-prefixed source + * keys, the `source` key (always derived), the `products` key for product-bearing + * events, and (for cart_updated without an explicit `products[]`) the top-level product + * field keys folded into products[0]. + */ +const consumedTopLevelKeysForEvent = (brazeEvent, eventMapping, hasProducts, properties) => { + const consumed = new Set(); + consumed.add('source'); + + eventMapping.forEach(entry => { + const sources = Array.isArray(entry.sourceKeys) ? entry.sourceKeys : [entry.sourceKeys]; + sources.forEach(src => { + if (typeof src === 'string' && src.startsWith('properties.')) { + consumed.add(src.slice('properties.'.length)); + } + }); + }); + + if (hasProducts) { + consumed.add('products'); + } + + // cart_updated wraps top-level product fields into a single product ONLY when no + // explicit `products[]` is provided; in that case mark those keys as consumed so they + // don't duplicate into event-level metadata. When `products[]` is present, the top-level + // fields are untouched and must flow through to metadata. + if (brazeEvent === BRAZE_ECOMMERCE_EVENTS.CART_UPDATED && !Array.isArray(properties.products)) { + consumedKeysFromMapping(ECOMMERCE_PRODUCT_MAPPING).forEach(key => consumed.add(key)); + } + + return consumed; +}; + +/** + * Build the `products[]` array for the outgoing payload. + * - cart_updated WITHOUT an explicit `products[]`: read top-level product fields directly + * from `properties` into a 1-element products[]. No per-product metadata — unmapped + * event-level keys flow through the event-level metadata pass. + * - all other cases (cart_updated WITH `products[]`, and other product-bearing events): + * map each item in `properties.products` and route per-product unmapped keys to + * `products[i].metadata`. + */ +const buildProductsArray = (properties, brazeEvent) => { + const isCartUpdated = brazeEvent === BRAZE_ECOMMERCE_EVENTS.CART_UPDATED; + + if (isCartUpdated && !Array.isArray(properties.products)) { + const product = removeUndefinedAndNullAndEmptyValues( + coerceMappedFields( + constructPayload(properties, ECOMMERCE_PRODUCT_MAPPING) || {}, + ECOMMERCE_PRODUCT_MAPPING, + ), + ); + return Object.keys(product).length > 0 ? [product] : []; + } + + const rawProducts = Array.isArray(properties.products) ? properties.products : []; + const consumedKeys = consumedKeysFromMapping(ECOMMERCE_PRODUCT_MAPPING); + return rawProducts + .map(raw => { + const item = raw && typeof raw === 'object' ? raw : {}; + const product = removeUndefinedAndNullAndEmptyValues( + coerceMappedFields( + constructPayload(item, ECOMMERCE_PRODUCT_MAPPING) || {}, + ECOMMERCE_PRODUCT_MAPPING, + ), + ); + const productMetadata = pickUnmappedKeys(item, consumedKeys); + if (Object.keys(productMetadata).length > 0) { + product.metadata = productMetadata; + } + return product; + }) + .filter(product => Object.keys(product).length > 0); +}; + +/** + * Collect the Braze-required fields missing from the constructed payload — event-level + * and per-product. An empty `products[]` on a product-bearing event is reported as a + * missing `products` field. Returns a flat list of human-readable field labels. + */ +const collectMissingRequiredFields = (eventMapping, hasProducts, payload) => { + const missing = []; + + eventMapping.forEach(entry => { + if (entry.req && !isResolvedValue(payload[entry.destKey])) { + missing.push(entry.destKey); + } + }); + + if (hasProducts) { + const products = Array.isArray(payload.products) ? payload.products : []; + if (products.length === 0) { + missing.push('products'); + } else { + const missingProductFields = new Set(); + products.forEach(product => { + ECOMMERCE_PRODUCT_MAPPING.forEach(entry => { + if (entry.req && !isResolvedValue(product[entry.destKey])) { + missingProductFields.add(`products.${entry.destKey}`); + } + }); + }); + missingProductFields.forEach(field => missing.push(field)); + } + } + + return missing; +}; + +/** + * Whether a resolved value is present but doesn't match its Braze-expected type. Only + * resolved values are flagged — an empty/missing value is scrubbed before send and is the + * missing-required warning's job, so it must not double-warn here. + */ +const isTypeMismatch = (value, type) => isResolvedValue(value) && !matchesType(value, type); + +/** + * Collect the mapped fields whose (already-coerced) value is present but still doesn't + * match Braze's expected type — event-level and per-product. Returns labels of the form + * `destKey (expected )` / `products.destKey (expected )`. + */ +const collectTypeMismatchedFields = (eventMapping, hasProducts, payload) => { + const mismatched = []; + + eventMapping.forEach(entry => { + if (isTypeMismatch(payload[entry.destKey], entry.type)) { + mismatched.push(`${entry.destKey} (expected ${entry.type})`); + } + }); + + if (hasProducts) { + const products = Array.isArray(payload.products) ? payload.products : []; + const mismatchedProductFields = new Set(); + products.forEach(product => { + ECOMMERCE_PRODUCT_MAPPING.forEach(entry => { + if (isTypeMismatch(product[entry.destKey], entry.type)) { + mismatchedProductFields.add(`products.${entry.destKey} (expected ${entry.type})`); + } + }); + }); + mismatchedProductFields.forEach(field => mismatched.push(field)); + } + + return mismatched; +}; + +// --------------------------------------------------------------------------- +// Public surface +// --------------------------------------------------------------------------- + +/** + * Resolve the Braze recommended event for a given RS event name. + * Returns `undefined` for unmapped events — caller falls back to the legacy path. + * Matching is case-insensitive on the trimmed event name. + */ +export const getEcommerceMapping = eventName => { + if (typeof eventName !== 'string') { + return undefined; + } + return EVENT_NAME_TO_BRAZE[eventName.trim().toLowerCase()]; +}; + +/** + * Derive the Braze `source` field. On the web SDK this resolves to `'web'`, unless the + * event carries an explicit, valid `properties.source` (`web`/`ios`/`android`), which + * takes precedence. The explicit value is trimmed and lowercased before matching, so + * surrounding whitespace doesn't defeat it. Always returns one of Braze's enum values; + * never undefined. + */ +export const deriveSource = message => { + const properties = message.properties || {}; + const explicit = String(properties.source || '') + .trim() + .toLowerCase(); + if (BRAZE_SOURCE_VALUES.indexOf(explicit) !== -1) { + return explicit; + } + return 'web'; +}; + +/** + * Build the `properties` object for a Braze recommended ecommerce event. + * + * Algorithm: + * 1. Run `constructPayload` against the message event-level mapping (never throws — + * send-anyway is enforced via the validation warning instead). + * 2. For events with a `products[]`, build the array (single-product wrap for + * `cart_updated` without an explicit `products[]`, iterate `properties.products` + * otherwise). + * 3. Set `source` via `deriveSource` and `action` when present. + * 4. Route unmapped event-level keys to `properties.metadata` (excluding `action`, which is + * set explicitly), and unmapped per-product keys to `products[].metadata`. + * 5. Emit a single `logger.warn` listing any missing Braze-required fields. + * 6. Emit a single `logger.warn` listing any field whose value type doesn't match Braze's + * schema after safe coercion (Braze rejects type-mismatched events; the value is sent + * as-is so it's not silently dropped). + * + * Never throws on data shape; the warnings + the (still-sent) payload are the contract. + */ +export const buildEcommerceEventProperties = (message, brazeEvent, action, logger) => { + const properties = message.properties || {}; + const eventMapping = PER_EVENT_MAPPING[brazeEvent] || []; + const hasProducts = brazeEvent !== BRAZE_ECOMMERCE_EVENTS.PRODUCT_VIEWED; + + // Step 1: event-level field mapping, with each mapped value coerced to its Braze type. + const payload = coerceMappedFields(constructPayload(message, eventMapping) || {}, eventMapping); + + // Step 2: products[] (skipped for product_viewed — flat, single-product event). + if (hasProducts) { + payload.products = buildProductsArray(properties, brazeEvent); + } + + // Step 3: source + action. + payload.source = deriveSource(message); + if (action) { + payload.action = action; + } + + // Step 4: route unmapped event-level keys to metadata. Exclude `action` when it's set + // explicitly (Step 3) so a caller-provided `properties.action` can't conflict with it. + const consumedEventKeys = consumedTopLevelKeysForEvent( + brazeEvent, + eventMapping, + hasProducts, + properties, + ); + if (action) { + consumedEventKeys.add('action'); + } + const eventMetadata = pickUnmappedKeys(properties, consumedEventKeys); + if (Object.keys(eventMetadata).length > 0) { + payload.metadata = eventMetadata; + } + + // Step 5: single warning for any missing Braze-required field. + const missingFields = collectMissingRequiredFields(eventMapping, hasProducts, payload); + if (missingFields.length > 0 && logger) { + logger.warn( + `${brazeEvent}: missing recommended Braze-required field(s): ${missingFields.join(', ')}. Event sent anyway.`, + ); + } + + // Step 6: single warning for any field whose type still doesn't match Braze's schema. + const mismatchedFields = collectTypeMismatchedFields(eventMapping, hasProducts, payload); + if (mismatchedFields.length > 0 && logger) { + logger.warn( + `${brazeEvent}: type-mismatched field(s) (sent as-is): ${mismatchedFields.join(', ')}.`, + ); + } + + return removeUndefinedAndNullAndEmptyValues(payload); +};