Radixia

AI · serverless · e-commerce

Commerce Layer Integration

In the fourth part of this multi-part series, we present Radixia Maca, our open-source solution for AI-composable commerce.

Commerce Layer Integration

In this episode, we'll implement the integration with Commerce Layer, a headless e-commerce platform that will handle the product catalog, orders, pricing, and inventory for our solution.

What is Commerce Layer

Commerce Layer is a headless e-commerce platform that provides APIs to manage all aspects of an online store. Unlike traditional monolithic e-commerce systems, Commerce Layer focuses exclusively on the business logic of e-commerce, leaving content management and presentation to other systems like Strapi CMS and our frontend.

Commerce Layer Account Setup

Before getting started, you need to create a Commerce Layer account:

  1. Sign up at Commerce Layer
  2. Create a new organization
  3. Set up your store (market)
  4. Get your API credentials (client ID and client secret)

Integration Between Strapi and Commerce Layer

We've already implemented a Strapi plugin for Commerce Layer integration in the previous section. Now, let's examine how to implement the Lambda function that handles synchronization between the two systems.

Lambda Function for Synchronization

The strapi-cl-sync Lambda function handles bidirectional synchronization between Strapi and Commerce Layer:

// lambda/strapi-cl-sync/index.js
const AWS = require('aws-sdk');
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const { DynamoDB } = require('@aws-sdk/client-dynamodb');
const { DynamoDBDocument } = require('@aws-sdk/lib-dynamodb');
const fetch = require('node-fetch');

// Initialize AWS clients
const secretsManager = new SecretsManager();
const dynamoDB = DynamoDBDocument.from(new DynamoDB());

// Function to get secrets from AWS Secrets Manager
async function getSecret(secretArn) {
  const response = await secretsManager.getSecretValue({ SecretId: secretArn });
  return JSON.parse(response.SecretString);
}

// Function to get a Commerce Layer token
async function getCommerceLayerToken(credentials) {
  const { clientId, clientSecret, endpoint } = credentials;
  
  const response = await fetch(`${endpoint}/oauth/token`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    },
    body: JSON.stringify({
      grant_type: 'client_credentials',
      client_id: clientId,
      client_secret: clientSecret
    })
  });
  
  const data = await response.json();
  return data.access_token;
}

// Function to get products from Strapi
async function getProductsFromStrapi(strapiCredentials) {
  const { apiKey, endpoint } = strapiCredentials;
  
  const response = await fetch(`${endpoint}/api/products?populate=*`, {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    }
  });
  
  const data = await response.json();
  return data.data;
}

// Function to create or update a product in Commerce Layer
async function upsertProductInCommerceLayer(product, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  // Check if the product already exists in Commerce Layer
  const checkResponse = await fetch(`${endpoint}/api/skus?filter[code]=${product.attributes.sku}`, {
    method: 'GET',
    headers: {
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  const checkData = await checkResponse.json();
  
  if (checkData.data && checkData.data.length > 0) {
    // Update existing product
    const existingProduct = checkData.data[0];
    
    const updateResponse = await fetch(`${endpoint}/api/skus/${existingProduct.id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'skus',
          id: existingProduct.id,
          attributes: {
            code: product.attributes.sku,
            name: product.attributes.name,
            description: product.attributes.description,
            image_url: product.attributes.image?.data?.attributes?.url || '',
            metadata: {
              category: product.attributes.category?.data?.attributes?.name || '',
              colors: product.attributes.colors || '',
              sizes: product.attributes.sizes || '',
              strapiId: product.id
            }
          }
        }
      })
    });
    
    return await updateResponse.json();
  } else {
    // Create a new product
    const createResponse = await fetch(`${endpoint}/api/skus`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'skus',
          attributes: {
            code: product.attributes.sku,
            name: product.attributes.name,
            description: product.attributes.description,
            image_url: product.attributes.image?.data?.attributes?.url || '',
            metadata: {
              category: product.attributes.category?.data?.attributes?.name || '',
              colors: product.attributes.colors || '',
              sizes: product.attributes.sizes || '',
              strapiId: product.id
            }
          }
        }
      })
    });
    
    return await createResponse.json();
  }
}

// Function to create or update a price in Commerce Layer
async function upsertPriceInCommerceLayer(product, skuId, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  // Check if a price already exists for this SKU
  const checkResponse = await fetch(`${endpoint}/api/prices?filter[sku_code]=${product.attributes.sku}`, {
    method: 'GET',
    headers: {
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  const checkData = await checkResponse.json();
  
  if (checkData.data && checkData.data.length > 0) {
    // Update existing price
    const existingPrice = checkData.data[0];
    
    const updateResponse = await fetch(`${endpoint}/api/prices/${existingPrice.id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'prices',
          id: existingPrice.id,
          attributes: {
            amount_cents: Math.round(product.attributes.price * 100),
            currency_code: 'EUR'
          }
        }
      })
    });
    
    return await updateResponse.json();
  } else {
    // Create a new price
    const createResponse = await fetch(`${endpoint}/api/prices`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'prices',
          attributes: {
            amount_cents: Math.round(product.attributes.price * 100),
            currency_code: 'EUR',
            sku_code: product.attributes.sku
          },
          relationships: {
            sku: {
              data: {
                type: 'skus',
                id: skuId
              }
            }
          }
        }
      })
    });
    
    return await createResponse.json();
  }
}

// Function to create or update a stock item in Commerce Layer
async function upsertStockItemInCommerceLayer(product, skuId, accessToken, commerceLayerCredentials) {
  const { endpoint } = commerceLayerCredentials;
  
  // Check if a stock item already exists for this SKU
  const checkResponse = await fetch(`${endpoint}/api/stock_items?filter[sku_code]=${product.attributes.sku}`, {
    method: 'GET',
    headers: {
      'Accept': 'application/vnd.api+json',
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  const checkData = await checkResponse.json();
  
  if (checkData.data && checkData.data.length > 0) {
    // Update existing stock item
    const existingStockItem = checkData.data[0];
    
    const updateResponse = await fetch(`${endpoint}/api/stock_items/${existingStockItem.id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'stock_items',
          id: existingStockItem.id,
          attributes: {
            quantity: product.attributes.stock || 0
          }
        }
      })
    });
    
    return await updateResponse.json();
  } else {
    // Create a new stock item
    const createResponse = await fetch(`${endpoint}/api/stock_items`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/vnd.api+json',
        'Accept': 'application/vnd.api+json',
        'Authorization': `Bearer ${accessToken}`
      },
      body: JSON.stringify({
        data: {
          type: 'stock_items',
          attributes: {
            quantity: product.attributes.stock || 0,
            sku_code: product.attributes.sku
          },
          relationships: {
            sku: {
              data: {
                type: 'skus',
                id: skuId
              }
            },
            stock_location: {
              data: {
                type: 'stock_locations',
                id: 'your-stock-location-id' // Replace with your actual location ID
              }
            }
          }
        }
      })
    });
    
    return await createResponse.json();
  }
}

// Function to save the product to DynamoDB
async function saveProductToDynamoDB(product, commerceLayerProduct, tableName) {
  const item = {
    id: commerceLayerProduct.data.id,
    strapiId: product.id,
    name: product.attributes.name,
    description: product.attributes.description,
    sku: product.attributes.sku,
    price: product.attributes.price,
    category: product.attributes.category?.data?.attributes?.name || '',
    imageUrl: product.attributes.image?.data?.attributes?.url || '',
    colors: product.attributes.colors || '',
    sizes: product.attributes.sizes || '',
    stock: product.attributes.stock || 0,
    updatedAt: new Date().toISOString()
  };
  
  await dynamoDB.put({
    TableName: tableName,
    Item: item
  });
  
  return item;
}

// Main Lambda handler
exports.handler = async (event) => {
  try {
    console.log('Event received:', JSON.stringify(event));
    
    // Get credentials from secrets
    const commerceLayerSecretArn = process.env.COMMERCE_LAYER_SECRET_ARN;
    const strapiSecretArn = process.env.STRAPI_SECRET_ARN;
    const productsTableName = process.env.PRODUCTS_TABLE;
    
    const commerceLayerCredentials = await getSecret(commerceLayerSecretArn);
    const strapiCredentials = await getSecret(strapiSecretArn);
    
    // Get an access token for Commerce Layer
    const accessToken = await getCommerceLayerToken(commerceLayerCredentials);
    
    // Get products from Strapi
    const strapiProducts = await getProductsFromStrapi(strapiCredentials);
    
    console.log(`Found ${strapiProducts.length} products in Strapi`);
    
    // Synchronize each product with Commerce Layer
    const results = [];
    
    for (const product of strapiProducts) {
      console.log(`Synchronizing product: ${product.attributes.name}`);
      
      // Create or update the product in Commerce Layer
      const commerceLayerProduct = await upsertProductInCommerceLayer(product, accessToken, commerceLayerCredentials);
      
      // Create or update the price in Commerce Layer
      await upsertPriceInCommerceLayer(product, commerceLayerProduct.data.id, accessToken, commerceLayerCredentials);
      
      // Create or update the stock item in Commerce Layer
      await upsertStockItemInCommerceLayer(product, commerceLayerProduct.data.id, accessToken, commerceLayerCredentials);
      
      // Save the product to DynamoDB
      const savedProduct = await saveProductToDynamoDB(product, commerceLayerProduct, productsTableName);
      
      results.push(savedProduct);
    }
    
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      },
      body: JSON.stringify({
        message: `Successfully synchronized ${results.length} products`,
        products: results
      })
    };
  } catch (error) {
    console.error('Error:', error);
    
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      },
      body: JSON.stringify({ error: error.message })
    };
  }
};

Webhook for Commerce Layer

To handle updates from Commerce Layer to Strapi, we implement a Lambda function that receives Commerce Layer webhooks:

// lambda/strapi-cl-sync/commerce-layer-webhook.js
'use strict';

const AWS = require('aws-sdk');
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
const fetch = require('node-fetch');

// Initialize AWS Secrets Manager client
const secretsManager = new SecretsManager();

// Function to get secrets from AWS Secrets Manager
async function getSecret(secretArn) {
  const response = await secretsManager.getSecretValue({ SecretId: secretArn });
  return JSON.parse(response.SecretString);
}

// Main Lambda handler
exports.handler = async (event) => {
  try {
    console.log('Event received from Commerce Layer:', JSON.stringify(event));
    
    // Get credentials from secrets
    const strapiSecretArn = process.env.STRAPI_SECRET_ARN;
    const strapiCredentials = await getSecret(strapiSecretArn);
    
    // Extract data from Commerce Layer event
    const { data } = JSON.parse(event.body);
    
    if (!data) {
      throw new Error('Missing data in Commerce Layer event');
    }
    
    // Determine event type
    const eventType = event.headers['X-CL-Webhook-Event'] || 'unknown';
    
    // Handle different event types
    switch (eventType) {
      case 'orders.create':
        await handleOrderCreated(data, strapiCredentials);
        break;
      case 'orders.update':
        await handleOrderUpdated(data, strapiCredentials);
        break;
      case 'skus.update':
        await handleProductUpdated(data, strapiCredentials);
        break;
      default:
        console.log(`Unhandled event type: ${eventType}`);
    }
    
    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ message: 'Webhook processed successfully' })
    };
  } catch (error) {
    console.error('Error processing webhook:', error);
    
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ error: error.message })
    };
  }
};

// Function to handle order creation
async function handleOrderCreated(orderData, strapiCredentials) {
  const { apiKey, endpoint } = strapiCredentials;
  
  // Extract order data
  const order = {
    commerceLayerId: orderData.id,
    number: orderData.attributes.number,
    status: orderData.attributes.status,
    customerEmail: orderData.attributes.customer_email,
    totalAmount: orderData.attributes.total_amount_with_taxes_cents / 100,
    currency: orderData.attributes.currency_code,
    items: orderData.relationships.line_items.data.map(item => ({
      skuCode: item.attributes.sku_code,
      name: item.attributes.name,
      quantity: item.attributes.quantity,
      price: item.attributes.unit_amount_cents / 100
    })),
    createdAt: orderData.attributes.created_at
  };
  
  // Send order data to Strapi
  const response = await fetch(`${endpoint}/api/orders`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: order
    })
  });
  
  if (!response.ok) {
    const errorData = await response.json();
    throw new Error(`Error sending order to Strapi: ${JSON.stringify(errorData)}`);
  }
  
  return await response.json();
}

// Function to handle order updates
async function handleOrderUpdated(orderData, strapiCredentials) {
  const { apiKey, endpoint } = strapiCredentials;
  
  // Find the order in Strapi
  const findResponse = await fetch(`${endpoint}/api/orders?filters[commerceLayerId][$eq]=${orderData.id}`, {
    method: 'GET',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    }
  });
  
  const findData = await findResponse.json();
  
  if (!findData.data || findData.data.length === 0) {
    console.log(`Order with commerceLayerId ${orderData.id} not found in Strapi`);
    return await handleOrderCreated(orderData, strapiCredentials);
  }
  
  const strapiOrderId = findData.data[0].id;
  
  // Update the order in Strapi
  const updateResponse = await fetch(`${endpoint}/api/orders/${strapiOrderId}`, {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: {
        status: orderData.attributes.status,
        updatedAt: new Date().toISOString()
      }
    })
  });
  
  if (!updateResponse.ok) {
    const errorData = await updateResponse.json();
    throw new Error(`Error updating order in Strapi: ${JSON.stringify(errorData)}`);
  }
  
  return await updateResponse.json();
}

// Function to handle product updates
async function handleProductUpdated(productData, strapiCredentials) {
  const { apiKey, endpoint } = strapiCredentials;
  
  // Extract the Strapi ID from metadata
  const strapiId = productData.attributes.metadata?.strapiId;
  
  if (!strapiId) {
    console.log('strapiId not found in product metadata');
    return;
  }
  
  // Update the product in Strapi
  const updateResponse = await fetch(`${endpoint}/api/products/${strapiId}`, {
    method: 'PUT',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      data: {
        name: productData.attributes.name,
        description: productData.attributes.description,
        stock: productData.relationships.stock_items?.data[0]?.attributes?.quantity || 0,
        updatedAt: new Date().toISOString()
      }
    })
  });
  
  if (!updateResponse.ok) {
    const errorData = await updateResponse.json();
    throw new Error(`Error updating product in Strapi: ${JSON.stringify(errorData)}`);
  }
  
  return await updateResponse.json();
}

Commerce Layer Configuration

Creating Necessary Resources

Before using Commerce Layer, we need to create some basic resources:

  1. Market: Defines the sales region, currency, and tax settings
  2. Stock Location: Defines where products are stored
  3. Shipping Methods: Defines how products are shipped
  4. Payment Methods: Defines how orders are paid

These resources can be created through the Commerce Layer admin interface or via API.

You're halfway in — the full article lives on our blog.

Continue reading on blog.radixia.ai →

Free to read. Subscribe there to get new posts by email.