Skip to content

Commit 3d9ef30

Browse files
committed
feat: braze ecommerce events onboarding
1 parent 8d7d21e commit 3d9ef30

2 files changed

Lines changed: 89 additions & 20 deletions

File tree

packages/analytics-js-integrations/__tests__/integrations/Braze/browser.test.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,6 +1178,57 @@ describe('track - recommended ecommerce events', () => {
11781178
expect(props.action).toBe('remove');
11791179
});
11801180

1181+
it('cart_updated with an explicit products[] maps the array and keeps top-level fields in metadata', () => {
1182+
const braze = buildBraze();
1183+
trackEvent(braze, 'Product Added', {
1184+
cart_id: 'c1',
1185+
currency: 'USD',
1186+
// top-level product-like fields coexisting with an explicit products[] array
1187+
product_id: 'top-level-pid',
1188+
name: 'top-level-name',
1189+
products: [{ product_id: 'p1', name: 'Game', variant: 'v1', quantity: 2, price: 15.99 }],
1190+
});
1191+
1192+
const props = window.braze.logCustomEvent.mock.calls[0][1];
1193+
// array is mapped, not the top-level wrap
1194+
expect(props.products).toEqual([
1195+
{ product_id: 'p1', product_name: 'Game', variant_id: 'v1', quantity: 2, price: 15.99 },
1196+
]);
1197+
// top-level product-like fields were NOT consumed, so they flow to metadata
1198+
expect(props.metadata).toEqual({ product_id: 'top-level-pid', name: 'top-level-name' });
1199+
});
1200+
1201+
it('does not leak a caller-provided properties.action into metadata', () => {
1202+
const braze = buildBraze();
1203+
trackEvent(braze, 'Product Added', {
1204+
cart_id: 'c1',
1205+
currency: 'USD',
1206+
product_id: 'p1',
1207+
name: 'Game',
1208+
variant: 'v1',
1209+
quantity: 1,
1210+
price: 15.99,
1211+
action: 'caller-supplied',
1212+
});
1213+
1214+
const props = window.braze.logCustomEvent.mock.calls[0][1];
1215+
expect(props.action).toBe('add');
1216+
expect(props.metadata).toBeUndefined();
1217+
});
1218+
1219+
it('accepts total_discounts under either properties.discount or properties.total_discounts', () => {
1220+
const braze = buildBraze();
1221+
trackEvent(braze, 'Order Refunded', {
1222+
order_id: 'o1',
1223+
total: 10,
1224+
currency: 'USD',
1225+
total_discounts: 3,
1226+
products: [{ product_id: 'p1', name: 'Game', variant: 'v1', quantity: 1, price: 15.99 }],
1227+
});
1228+
1229+
expect(window.braze.logCustomEvent.mock.calls[0][1].total_discounts).toBe(3);
1230+
});
1231+
11811232
it('maps Checkout Started to ecommerce.checkout_started with products array', () => {
11821233
const braze = buildBraze();
11831234
trackEvent(braze, 'Checkout Started', {

packages/analytics-js-integrations/src/integrations/Braze/ecommerceUtil.js

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ const EVENT_NAME_TO_BRAZE = {
3939

4040
const m = (destKey, sourceKeys, req = false) => ({ destKey, sourceKeys, req });
4141

42-
// Shared fallback chain reused across checkout/order events.
42+
// Shared fallback chains reused across checkout/order events.
4343
const TOTAL_VALUE_SOURCES = ['properties.total', 'properties.revenue', 'properties.value'];
44+
const TOTAL_DISCOUNTS_SOURCES = ['properties.discount', 'properties.total_discounts'];
4445

4546
const PRODUCT_VIEWED_MAPPING = [
4647
m('product_id', ['properties.product_id', 'properties.sku'], true),
@@ -79,7 +80,7 @@ const ORDER_PLACED_MAPPING = [
7980
m('cart_id', 'properties.cart_id'),
8081
m('tax', 'properties.tax'),
8182
m('shipping', 'properties.shipping'),
82-
m('total_discounts', 'properties.discount'),
83+
m('total_discounts', TOTAL_DISCOUNTS_SOURCES),
8384
m('subtotal_value', 'properties.subtotal_value'),
8485
m('discounts', 'properties.discounts'),
8586
];
@@ -88,7 +89,7 @@ const ORDER_REFUNDED_MAPPING = [
8889
m('order_id', 'properties.order_id', true),
8990
m('total_value', TOTAL_VALUE_SOURCES, true),
9091
m('currency', 'properties.currency', true),
91-
m('total_discounts', 'properties.total_discounts'),
92+
m('total_discounts', TOTAL_DISCOUNTS_SOURCES),
9293
m('discounts', 'properties.discounts'),
9394
];
9495

@@ -99,7 +100,7 @@ const ORDER_CANCELLED_MAPPING = [
99100
m('cancel_reason', ['properties.cancel_reason', 'properties.reason'], true),
100101
m('tax', 'properties.tax'),
101102
m('shipping', 'properties.shipping'),
102-
m('total_discounts', 'properties.discount'),
103+
m('total_discounts', TOTAL_DISCOUNTS_SOURCES),
103104
m('subtotal_value', 'properties.subtotal_value'),
104105
m('discounts', 'properties.discounts'),
105106
];
@@ -174,9 +175,10 @@ const consumedKeysFromMapping = mapping => {
174175
* Compute the set of message-property keys "consumed" by the event-level mapping (so
175176
* they aren't duplicated into `metadata`). Includes the `properties.`-prefixed source
176177
* keys, the `source` key (always derived), the `products` key for product-bearing
177-
* events, and (for cart_updated) the top-level product field keys folded into products[0].
178+
* events, and (for cart_updated without an explicit `products[]`) the top-level product
179+
* field keys folded into products[0].
178180
*/
179-
const consumedTopLevelKeysForEvent = (brazeEvent, eventMapping, hasProducts) => {
181+
const consumedTopLevelKeysForEvent = (brazeEvent, eventMapping, hasProducts, properties) => {
180182
const consumed = new Set();
181183
consumed.add('source');
182184

@@ -193,9 +195,14 @@ const consumedTopLevelKeysForEvent = (brazeEvent, eventMapping, hasProducts) =>
193195
consumed.add('products');
194196
}
195197

196-
// cart_updated uses top-level product fields as the single wrapped product;
197-
// mark those keys as consumed so they don't duplicate into event-level metadata.
198-
if (brazeEvent === BRAZE_ECOMMERCE_EVENTS.CART_UPDATED && hasProducts) {
198+
// cart_updated wraps top-level product fields into a single product ONLY when no
199+
// explicit `products[]` is provided; in that case mark those keys as consumed so they
200+
// don't duplicate into event-level metadata. When `products[]` is present, the top-level
201+
// fields are untouched and must flow through to metadata.
202+
if (
203+
brazeEvent === BRAZE_ECOMMERCE_EVENTS.CART_UPDATED &&
204+
!Array.isArray(properties.products)
205+
) {
199206
consumedKeysFromMapping(ECOMMERCE_PRODUCT_MAPPING).forEach(key => consumed.add(key));
200207
}
201208

@@ -204,11 +211,12 @@ const consumedTopLevelKeysForEvent = (brazeEvent, eventMapping, hasProducts) =>
204211

205212
/**
206213
* Build the `products[]` array for the outgoing payload.
207-
* - cart_updated: read top-level product fields directly from `properties` into a
208-
* 1-element products[]. No per-product metadata — unmapped event-level keys flow
209-
* through the event-level metadata pass.
210-
* - other product-bearing events: map each item in `properties.products` and route
211-
* per-product unmapped keys to `products[i].metadata`.
214+
* - cart_updated WITHOUT an explicit `products[]`: read top-level product fields directly
215+
* from `properties` into a 1-element products[]. No per-product metadata — unmapped
216+
* event-level keys flow through the event-level metadata pass.
217+
* - all other cases (cart_updated WITH `products[]`, and other product-bearing events):
218+
* map each item in `properties.products` and route per-product unmapped keys to
219+
* `products[i].metadata`.
212220
*/
213221
const buildProductsArray = (properties, brazeEvent) => {
214222
const isCartUpdated = brazeEvent === BRAZE_ECOMMERCE_EVENTS.CART_UPDATED;
@@ -301,17 +309,18 @@ export const deriveSource = message => {
301309
* 1. Run `constructPayload` against the message event-level mapping (never throws —
302310
* send-anyway is enforced via the validation warning instead).
303311
* 2. For events with a `products[]`, build the array (single-product wrap for
304-
* `cart_updated`, iterate `properties.products` otherwise).
312+
* `cart_updated` without an explicit `products[]`, iterate `properties.products`
313+
* otherwise).
305314
* 3. Set `source` via `deriveSource` and `action` when present.
306-
* 4. Route unmapped event-level keys to `properties.metadata`, and unmapped per-product
307-
* keys to `products[].metadata`.
315+
* 4. Route unmapped event-level keys to `properties.metadata` (excluding `action`, which is
316+
* set explicitly), and unmapped per-product keys to `products[].metadata`.
308317
* 5. Emit a single `logger.warn` listing any missing Braze-required fields.
309318
*
310319
* Never throws on data shape; the warning + the (still-sent) payload is the contract.
311320
*/
312321
export const buildEcommerceEventProperties = (message, brazeEvent, action, logger) => {
313322
const properties = message.properties || {};
314-
const eventMapping = PER_EVENT_MAPPING[brazeEvent];
323+
const eventMapping = PER_EVENT_MAPPING[brazeEvent] || [];
315324
const hasProducts = brazeEvent !== BRAZE_ECOMMERCE_EVENTS.PRODUCT_VIEWED;
316325

317326
// Step 1: event-level field mapping.
@@ -328,8 +337,17 @@ export const buildEcommerceEventProperties = (message, brazeEvent, action, logge
328337
payload.action = action;
329338
}
330339

331-
// Step 4: route unmapped event-level keys to metadata.
332-
const consumedEventKeys = consumedTopLevelKeysForEvent(brazeEvent, eventMapping, hasProducts);
340+
// Step 4: route unmapped event-level keys to metadata. Exclude `action` when it's set
341+
// explicitly (Step 3) so a caller-provided `properties.action` can't conflict with it.
342+
const consumedEventKeys = consumedTopLevelKeysForEvent(
343+
brazeEvent,
344+
eventMapping,
345+
hasProducts,
346+
properties,
347+
);
348+
if (action) {
349+
consumedEventKeys.add('action');
350+
}
333351
const eventMetadata = pickUnmappedKeys(properties, consumedEventKeys);
334352
if (Object.keys(eventMetadata).length > 0) {
335353
payload.metadata = eventMetadata;

0 commit comments

Comments
 (0)