Unpublish products that have been out of stock for x days, with Mechanic.

Mechanic is an automation development platform for Shopify. :)

Unpublish products that have been out of stock for x days

by Isaac Bowen (team@usemechanic.com)

This task monitors for inventory changes, and records the time when a product's inventory falls to 0 or less. Then, on an hourly basis, the task will unpublish any products with a recorded out-of-stock time of at least x days ago.

Runs when an inventory level is updated and every hour. Configuration includes number of days to wait before unpublishing, sales channel names, and test mode.

15-day free trial – unlimited tasks

Documentation

This task watches for inventory updates. When a product's total inventory becomes 0 or less, the current time will be recorded for that product. Then, on an hourly basis, this task will unpublish any products with a recorded out-of-stock time of at least x days ago.

Notes:

  • The first time the hourly scan runs, it may encounter products that were already out of stock, before this task was installed. For those products, the current time will be recorded as their out-of-stock time. This means that these products will wait for another x days before being automatically unpublished.
  • If a product that this task unpublishes is manually published, and its total inventory is still 0 or less, this task will unpublish it again during its next hourly scan.
  • This task includes a test mode. Enable it to have the task report what it would do, if test mode was disabled.

Developer details

Mechanic is designed to benefit everybody: merchants, customers, developers, agencies, Gurus, everybody.

That’s why we make it easy to configure automation without code, why we make it easy to tweak the underlying code once tasks are installed, and why we publish it all here for everyone to learn from.

Events
when an inventory level is updated (shopify/inventory_levels/update)
every hour (mechanic/scheduler/hourly)
Options
number of days to wait before unpublishing (number, required), sales channel names (required, array), test mode (boolean)
Script
{% comment %}
  Preferred option order:

  {{ options.number_of_days_to_wait_before_unpublishing__number_required }}
  {{ options.sales_channel_names__required_array }}
  {{ options.test_mode__boolean }}
{% endcomment %}

{% comment %}
  1. Watch for updates to inventory levels.
  2. Look up the associated product, when an update comes in.
  3. If the product's total inventory is 0, record the current time in a metafield. If the
     total inventory is not 0, delete that metafield, if it exists.
  4. Scan all products, on a schedule, retrieving each product's out-of-stock time metafield.
     For products found with a total inventory of 0, with a recorded time that's at least a
     configurable distance in days from the current time, unpublish the product.
{% endcomment %}

{% assign time_format = "%FT%T%z" %}

{% if event.topic == "shopify/inventory_levels/update" %}
  {% capture query %}
    query {
      inventoryLevel(
        id: {{ inventory_level.admin_graphql_api_id | json }}
      ) {
        id
        item {
          variant {
            product {
              id
              title
              totalInventory
              metafield(
                namespace: "mechanic"
                key: "out_of_stock_at"
              ) {
                id
                value
              }
            }
          }
        }
      }
    }
  {% endcapture %}

  {% assign result = query | shopify %}

  {% if event.preview %}
    {% capture result_json %}
      {
        "data": {
          "inventoryLevel": {
            "id": "gid://shopify/InventoryLevel/1234567890?inventory_item_id=1234567890",
            "item": {
              "variant": {
                "product": {
                  "id": "gid://shopify/Product/1234567890",
                  "title": "Short sleeve t-shirt",
                  "totalInventory": 0,
                  "metafield": null
                }
              }
            }
          }
        }
      }
    {% endcapture %}

    {% assign result = result_json | parse_json %}
  {% endif %}

  {% assign product = result.data.inventoryLevel.item.variant.product %}

  {% if product.totalInventory <= 0 and product.metafield == nil %}
    {% if options.test_mode__boolean %}
      {% action "echo" %}
        {% capture message %}Product {{ product.title | json }} ({{ product.id }}) is out of stock. Its out-of-stock time should be recorded.{% endcapture %}
        {{ message | json }}
      {% endaction %}
    {% else %}
      {% action "shopify" %}
        mutation {
          productUpdate(
            input: {
              id: {{ product.id | json }}
              metafields: [
                {
                  namespace: "mechanic"
                  key: "out_of_stock_at"
                  valueType: STRING
                  value: {{ "now" | date: time_format | json }}
                }
              ]
            }
          ) {
            userErrors {
              field
              message
            }
          }
        }
      {% endaction %}
    {% endif %}
  {% elsif product.totalInventory > 0 and product.metafield != nil %}
    {% if options.test_mode__boolean %}
      {% action "echo" %}
        {% capture message %}Product {{ product.title | json }} ({{ product.id }}) is back in stock. Its out-of-stock time should be cleared.{% endcapture %}
        {{ message | json }}
      {% endaction %}
    {% else %}
      {% action "shopify" %}
        mutation {
          metafieldDelete(
            input: {
              id: {{ product.metafield.id | json }}
            }
          ) {
            userErrors {
              field
              message
            }
          }
        }
      {% endaction %}
    {% endif %}
  {% endif %}
{% elsif event.topic contains "mechanic/scheduler/" or event.topic == "mechanic/user/trigger" %}
  {% assign now_s = "now" | date: "%s" | times: 1 %}
  {% assign minimum_out_of_stock_at_distance_s = options.number_of_days_to_wait_before_unpublishing__number_required | times: 24 | times: 60 | times: 60 %}

  {% capture query %}
    query {
      publications(first: 250) {
        edges {
          node {
            id
            name
          }
        }
      }
    }
  {% endcapture %}

  {% assign result = query | shopify %}

  {% assign publications = array %}

  {% for publication_edge in result.data.publications.edges %}
    {% if options.sales_channel_names__required_array contains publication_edge.node.name %}
      {% assign publications[publications.size] = publication_edge.node %}
    {% endif %}
  {% endfor%}

  {% if event.preview %}
    {% assign publications[0] = hash %}
    {% assign publications[0]["id"] = "gid://shopify/Publication/1234567890" %}
  {% elsif publications.size != options.sales_channel_names__required_array.size %}
    {% log publications_named: options.sales_channel_names__required_array, publications_available: result.data.publications.edges,  publications_matched: publications %}
    {% error "Unable to find all named publications. Double-check your task configuration." %}
  {% endif %}

  {% assign cursor = nil %}

  {% for n in (0..100) %}
    {% capture query %}
      query {
        products(
          first: 250
          query: "inventory_total:<=0"
          after: {{ cursor | json }}
        ) {
          pageInfo {
            hasNextPage
          }
          edges {
            cursor
            node {
              id
              title
              totalInventory
              {% for publication in publications %}
                publishedOnPublication{{ forloop.index }}: publishedOnPublication(publicationId: {{ publication.id | json }})
              {% endfor %}
              metafield(
                namespace: "mechanic"
                key: "out_of_stock_at"
              ) {
                id
                value
              }
            }
          }
        }
      }
    {% endcapture %}

    {% assign result = query | shopify %}

    {% if event.preview %}
      {% capture result_json %}
        {
          "data": {
            "products": {
              "pageInfo": {
                "hasNextPage": false
              },
              "edges": [
                {
                  "cursor": "eyJsYXN0X2lkIjo0MzQzOTQxNDMxMzMxLCJsYXN0X3ZhbHVlIjo0MzQzOTQxNDMxMzMxfQ==",
                  "node": {
                    "id": "gid://shopify/Product/1234567890",
                    "title": "Short sleeve t-shirt",
                    "totalInventory": 0,
                    {% for publication in publications %}
                      {{ "publishedOnPublication" | append: forloop.index | json }}: true,
                    {% endfor %}
                    "metafield": {
                      "value": {{ now_s | minus: minimum_out_of_stock_at_distance_s | minus: 1 | append: "" | json }}
                    }
                  }
                }
              ]
            }
          }
        }
      {% endcapture %}

      {% assign result = result_json | parse_json %}
    {% endif %}

    {% for product_edge in result.data.products.edges %}
      {% assign product = product_edge.node %}

      {% if product.metafield == nil %}
        {% log %}
          {% capture message %}Product {{ product.id }} is out of stock, but its out-of-stock time was not recorded. This product will not be unpublished now, but its out-of-stock time will be set to the current time.{% endcapture %}
          {{ message | json }}
        {% endlog %}

        {% unless options.test_mode__boolean %}
          {% action "shopify" %}
            mutation {
              productUpdate(
                input: {
                  id: {{ product.id | json }}
                  metafields: [
                    {
                      namespace: "mechanic"
                      key: "out_of_stock_at"
                      valueType: STRING
                      value: {{ "now" | date: time_format | json }}
                    }
                  ]
                }
              ) {
                userErrors {
                  field
                  message
                }
              }
            }
          {% endaction %}
        {% endunless %}

        {% continue %}
      {% endif %}

      {% assign out_of_stock_at_s = product.metafield.value | date: "%s" | times: 1 %}
      {% assign out_of_stock_at_distance_s = now_s | minus: out_of_stock_at_s %}

      {% assign unpublishings = array %}

      {% for publication in publications %}
        {% assign key = "publishedOnPublication" | append: forloop.index %}
        {% if product[key] %}
          {% assign unpublishing = array %}
          {% assign unpublishing[0] = product.id %}
          {% assign unpublishing[1] = publication.id %}
          {% assign unpublishings[unpublishings.size] = unpublishing %}
        {% endif %}
      {% endfor %}

      {% if unpublishings == empty %}
        {% log %}
          {% capture message %}Product {{ product.title | json }} ({{ product.id }}) has been out of stock for {{ out_of_stock_at_distance_s | divided_by: 60 | divided_by: 60 | divided_by: 24 | round: 2 }} day(s), but is not published - nothing to do.{% endcapture %}
          {{ message | json }}
        {% endlog %}
        {% continue %}
      {% endif %}

      {% if out_of_stock_at_distance_s < minimum_out_of_stock_at_distance_s %}
        {% log %}
          {% capture message %}Product {{ product.title | json }} ({{ product.id }}) is out of stock, and is published, but has only been out of stock for {{ out_of_stock_at_distance_s | divided_by: 60 | divided_by: 60 | divided_by: 24 | round: 2 }} day(s).{% endcapture %}
          {{ message | json }}
        {% endlog %}
        {% continue %}
      {% endif %}

      {% if options.test_mode__boolean %}
        {% action "echo" %}
          {% capture message %}Product {{ product.title | json }} ({{ product.id }}) is out of stock, and has been out of stock for {{ out_of_stock_at_distance_s | divided_by: 60 | divided_by: 60 | divided_by: 24 | round: 2 }} day(s). It should be unpublished.{% endcapture %}
          {{ message | json }}
        {% endaction %}
      {% else %}
        {% action "shopify" %}
          mutation {
            {% for unpublishing in unpublishings %}
              publishableUnpublish{{ forloop.index }}: publishableUnpublish(
                id: {{ unpublishing[0] | json }}
                input: {
                  publicationId: {{ unpublishing[1] | json }}
                }
              ) {
                userErrors {
                  field
                  message
                }
              }
            {% endfor %}
          }
        {% endaction %}
      {% endif %}
    {% endfor %}

    {% if result.data.products.pageInfo.hasNextPage %}
      {% assign cursor = result.data.products.edges.last.cursor %}
    {% else %}
      {% break %}
    {% endif %}
  {% endfor %}
{% endif %}
Mechanic tasks are written in Liquid, which makes them easy to write and easy to modify. Learn more about our platform.
Defaults
Sales channel names
["Online Store"]
Test mode
true