Copying products from Live to Sandbox

Is there any way to copy the products and pricing table to a sandbox environment?

4 Likes

Hi there, thanks for your question! Sandboxes does not support copying data other than settings from live to your sandbox. We’ve heard a similar feature request and will consider supporting this in our future roadmap!

1 Like

We’ve just checked out the sandbox functionality - it’d be very beneficial to have an option to copy all products & pricing when creating a sandbox, or possibly adhoc after creation by product/price like the copy to live functionality - e.g. Copy to Sandbox

2 Likes

Seriously, what a hassle to have to duplicate all our products just to do testing in a new sandbox.

4 Likes

Yeah, this was a surprise to me. I just wrote a command for my ops CLI that syncs products and prices from the main account to a sandbox. Here’s the relevant source file (you’ll just have to adjust where you get sandboxSecretKey and prodSecretKey from, as well as the method for matching products (I’m using a metadata key quality) and prices (I’m just using the recurrence interval).

Hopefully this is helpful for others trying to do the same thing.

import { isString } from "@shared/validator";
import inquirer from "inquirer";
import { Stripe } from "stripe";
import { getParameterValue } from "../aws/parameter";

function getPriceUpdatableDataForComparison(price: Stripe.Price) {
  return {
    lookup_key: price.lookup_key ?? undefined,
    metadata: price.metadata,
    nickname: price.nickname ?? undefined,
    tax_behavior: price.tax_behavior ?? undefined,
  };
}

function getPriceDataForComparison(price: Stripe.Price) {
  return {
    currency: price.currency,
    active: price.active,
    billing_scheme: price.billing_scheme,
    //TODO: currency_options: ???,
    custom_unit_amount: price.custom_unit_amount
      ? {
          enabled: true,
          minimum: price.custom_unit_amount.minimum ?? undefined,
          maximum: price.custom_unit_amount.maximum ?? undefined,
          preset: price.custom_unit_amount.preset ?? undefined,
        }
      : undefined,
    recurring: price.recurring
      ? {
          aggregate_usage: price.recurring.aggregate_usage ?? undefined,
          interval: price.recurring.interval,
          interval_count: price.recurring.interval_count,
          meter: price.recurring.meter ?? undefined,
          trial_period_days: price.recurring.trial_period_days ?? undefined,
          usage_type: price.recurring.usage_type ?? undefined,
        }
      : undefined,
    tiers:
      price.tiers?.map(
        (tier) =>
          ({
            flat_amount_decimal: tier.flat_amount_decimal ?? undefined,
            unit_amount_decimal: tier.unit_amount_decimal ?? undefined,
            up_to: tier.up_to ?? "inf",
          } as const)
      ) ?? undefined,
    tiers_mode: price.tiers_mode ?? undefined,
    transfer_lookup_key: true,
    transform_quantity: price.transform_quantity ?? undefined,
    unit_amount_decimal: price.unit_amount_decimal ?? undefined,
  } as const;
}

function getProductDataForComparison(product: Stripe.Product) {
  return {
    name: product.name,
    active: product.active,
    description: product.description ?? undefined,
    images: product.images,
    metadata: product.metadata,
    package_dimensions: product.package_dimensions ?? undefined,
    shippable: product.shippable ?? undefined,
    statement_descriptor: product.statement_descriptor ?? undefined,
    tax_code: isString(product.tax_code) ? product.tax_code : undefined,
    type: product.type,
    unit_label: product.unit_label ?? undefined,
    url: product.url ?? undefined,
  } as const;
}

export async function setupStripeSandbox(sandboxSecretKey: string | undefined) {
  const prodSecretKey = await getParameterValue("/stripe/prod/secret");
  if (!prodSecretKey.startsWith("sk_live")) {
    throw new Error("This is not a production key");
  }

  if (!sandboxSecretKey) {
    ({ sandboxSecretKey } = await inquirer.prompt({
      type: "input",
      name: "sandboxSecretKey",
      message: "Enter sandbox Stripe key",
    }));
  }
  if (!sandboxSecretKey?.startsWith("sk_test")) {
    throw new Error("This is not a sandbox key");
  }

  const prodStripe = new Stripe(prodSecretKey);
  const sandboxStripe = new Stripe(sandboxSecretKey);

  const prodProducts = (
    await prodStripe.products.list({
      active: true,
    })
  ).data;
  const prodPrices = (
    await prodStripe.prices.list({
      active: true,
      expand: ["data.tiers"],
    })
  ).data;

  console.log(
    `Found ${prodProducts.length} products and ${prodPrices.length} prices in prod`
  );

  const sandboxProducts = (
    await sandboxStripe.products.list({
      active: true,
    })
  ).data;
  const sandboxPrices = (
    await sandboxStripe.prices.list({
      active: true,
      expand: ["data.tiers"],
    })
  ).data;

  console.log(
    `Found ${sandboxProducts.length} products and ${sandboxPrices.length} prices in sandbox`
  );

  const sandboxProductIdsUsed = new Set<string>();
  const sandboxPriceIdsUsed = new Set<string>();

  for (const prodProduct of prodProducts) {
    const prodProductData = getProductDataForComparison(prodProduct);

    let sandboxProduct = sandboxProducts.find(
      (p) => p.metadata["quality"] === prodProduct.metadata["quality"]
    );
    if (sandboxProduct) {
      if (
        JSON.stringify(getProductDataForComparison(sandboxProduct)) ===
        JSON.stringify(prodProductData)
      ) {
        console.log(`Product ${prodProduct.name} in sandbox is up to date`);
      } else {
        // Update sandbox product to match all fields on prod product
        console.log(`Updating product ${prodProduct.name} in sandbox`);
        sandboxProduct = await sandboxStripe.products.update(
          sandboxProduct.id,
          prodProductData
        );
      }
    } else {
      // Create sandbox product
      console.log(`Creating product ${prodProduct.name} in sandbox`);
      sandboxProduct = await sandboxStripe.products.create(prodProductData);
    }
    sandboxProductIdsUsed.add(sandboxProduct.id);

    for (const prodPrice of prodPrices.filter(
      (p) => p.product === prodProduct.id
    )) {
      let prodPriceData = getPriceDataForComparison(prodPrice);
      let prodUpdatableData = getPriceUpdatableDataForComparison(prodPrice);

      let sandboxPrice = sandboxPrices.find(
        (p) =>
          p.product === sandboxProduct.id &&
          p.recurring?.interval === prodPrice.recurring?.interval &&
          p.recurring?.interval_count === prodPrice.recurring?.interval_count
      );
      if (
        sandboxPrice &&
        JSON.stringify(getPriceDataForComparison(sandboxPrice)) ===
          JSON.stringify(prodPriceData)
      ) {
        if (
          JSON.stringify(getPriceUpdatableDataForComparison(sandboxPrice)) ===
          JSON.stringify(prodUpdatableData)
        ) {
          console.log(`Price ${prodPrice.id} in sandbox is up to date`);
        } else {
          console.log(`Updating price ${prodPrice.id} in sandbox`);
          sandboxPrice = await sandboxStripe.prices.update(sandboxPrice.id, {
            ...prodUpdatableData,
          });
        }
      } else {
        console.log(`Creating/replacing price ${prodPrice.id} in sandbox`);

        sandboxPrice = await sandboxStripe.prices.create({
          product: sandboxProduct.id,
          ...prodPriceData,
          ...prodUpdatableData,
        });
      }

      sandboxPriceIdsUsed.add(sandboxPrice.id);
    }
  }

  for (const sandboxProduct of sandboxProducts) {
    if (!sandboxProductIdsUsed.has(sandboxProduct.id)) {
      console.log(`Deleting product ${sandboxProduct.id} from sandbox`);
      await sandboxStripe.products.del(sandboxProduct.id);
    }
  }

  for (const sandboxPrice of sandboxPrices) {
    if (!sandboxPriceIdsUsed.has(sandboxPrice.id)) {
      console.log(`Deleting price ${sandboxPrice.id} from sandbox`);
      await sandboxStripe.prices.update(sandboxPrice.id, {
        active: false,
      });
    }
  }
}

I replied here with a script I wrote to sync products and pricing from our main account to a sandbox, but the spam filter removed it, so good luck I guess.