@@ -39,8 +39,9 @@ const EVENT_NAME_TO_BRAZE = {
3939
4040const 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.
4343const TOTAL_VALUE_SOURCES = [ 'properties.total' , 'properties.revenue' , 'properties.value' ] ;
44+ const TOTAL_DISCOUNTS_SOURCES = [ 'properties.discount' , 'properties.total_discounts' ] ;
4445
4546const 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 */
213221const 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 */
312321export 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