Skip to main content

Launching my wife's cookie shop web store with Drupal 10 and Square

Published on

My wife owns her custom cookie shop, which sells decorated sugar cookies. On top of her regular custom orders, she does special cookies and cookie sets for each holiday. Recently she moved from PayPal to Square for her invoicing to leverage their more extensive offering of tools to merchants. We were also hoping Square could simplify sales for these limited-quality holiday sets (tracking first come, first serve and inventory via Facebook messages and comments is a nightmare.) In this blog post, I will run through my decisions and how I did them. If you're curious about these details, feel free to reach out!

The inventory management conundrum: variations and options

We hit one problem: handling inventory for sets with different designs, and the inventory constraint is on the packaging material. For instance, my wife sold cookie Advent calendars for Christmas. For Valentine's Day, she has these books, which hold two cookies. She's offering three different designs. but is constrained by the packaging. The cookie set is her product, and each design is a variation. Each design is more like an option or modifier since it does not directly impact the inventory overall. Square, and generally all commerce platforms, track inventory at the variation level. Square does have an Item Modifiers feature that could address this requirement. The problem is that item modifiers come in sets intended to be reused across products – like pizza toppings or other common options. This would be pretty painstaking for her to create one-off item modifier sets for each of these special products.

And that is why I flipped open my laptop on Friday evening and began to build my wife a web store.

Why Drupal and Drupal Commerce

First off: I am a Drupal developer. And despite its "rough out of the box" edges, Drupal Commerce is extremely flexible and powerful. For those who don't know, I worked at Centarro for five years, helping build Drupal Commerce and implementing it for various organizations that didn't fit into the off-the-shelf platforms.

Before I even ran composer create-project drupal/recommended-project:^10 I evaluated requirements to ensure I wasn't going full developer mode and overcomplicating things.

  • Ability to sell a product with inventory based on options, not its variations
  • Ability to create and manage content
  • Ability to add features for customers to submit order requests
  • Flexibility to meet and scale with the unique needs of selling handmade products

I already knew that Square had the capability, but it would be cumbersome for her use case. Square provides websites via their Weebly acquisition, but the features are limited and wouldn't meet the need to scale with any unique requirements.

I did consider WordPress and WooCommerce. I recently helped a friend write a WordPress plugin and got too hung up on the inability to create a path that is a custom landing page programmatically and the effort to create custom forms. Thank you, Drupal contributors over the years, for the Form API.

I also considered Laravel since creating a simple web store could be quick. But then, I'd have to create user interfaces and build content management features.

Honestly, Drupal fits an excellent content management system and developer framework niche. Yes, sometimes I wish there were more framework-ish features, but nothing show-stopping.

Combining Drupal Commerce with Square

I have some familiarity with Square and Drupal Commerce. In 2017 I was tasked with creating the Drupal Commerce and Square Connect integration for the Square Payment Form (now the Web Payments SDK.) However, my wife's site does not use that module. The standard connector provides integration with the Drupal Commerce Payment APIs and uses the native checkout experience inside of the Drupal site. I wanted my wife's web store to leverage Square's Checkout API. This allows customers to check out off-site on Square. The benefit? If their phone number is already in Square's system, it will send them a confirmation code to reuse their existing information. Off-site checkouts like this also gain customer trust when providing payment information. Just about everyone has experienced this when shopping on a Shopify store. The website is customized, but each merchant's checkout experience is the same.

Drupal Commerce is modular, allowing you to use some of its default components or plug in your own. Her site does not use the Payment or Checkout module. We're relying on Square for checkout, and I decided not to sync back payment transaction information on the website. The Commerce module's product architecture uses products and variations with variation attributes – matching Square. I wrote an importer to sync products from Square into Drupal Commerce – including their attached images.

For checkout, I wrote a custom controller that takes the customer's cart and converts it to an order for Square to consumer with its Checkout API. The order line items have the catalog item IDs for Square to make sure all content and images match up.

Here is a screenshot of the cart page, powered by Drupal Commerce.

And this is a screenshot of the checkout on Square with the customer's shopping cart.

For the curious, here is the mapping function which converts the order item in Drupal Commerce to a line item in a Square order.

$line_items = array_map(
    function (OrderItemInterface $order_item) {
    $purchased_entity = $order_item->getPurchasedEntity();
    assert($purchased_entity instanceof ProductVariationInterface);
    $money = new Money();
    $money->setAmount($this->minorUnitsConverter->toMinorUnits($order_item->getUnitPrice()));
    $money->setCurrency($order_item->getUnitPrice()?->getCurrencyCode());

    $line_item = new OrderLineItem($order_item->getQuantity());
    $line_item->setItemType('ITEM');
    $line_item->setUid($order_item->id());
    $line_item->setBasePriceMoney($money);

    $square_id = (string) $purchased_entity->get('square_id')->value;
    if ($square_id !== '') {
        $line_item->setCatalogObjectId($square_id);
    }
    else {
        $line_item->setName($purchased_entity->getProduct()?->label());
        $line_item->setVariationName($order_item->getTitle());
    }
    return $line_item;
    },
    $cart->getItems()
);

I then have a custom return controller upon checkout completion on Square which places the order within Drupal Commerce and renders an order summary for the customer.

Inventory: solving the root problem

I went for a very simple approach on inventory. My wife doesn't need transactional inventory capabilities. Transaction inventory involves tracking when inventory comes in, is sold, is lost, or is restocked from refunds, etc. Each time inventory goes up or down it is part of a series of transactions. For my wife, her main inventory concerns are the raw materials for creating cookies and not batches of cookies already baked. Her main inventory are specialty boxes. She also won't be going back to add new inventory, as it's nearly impossible to replenish those speciality boxes in time.

Drupal Commerce provides an Availability Manager API to enable inventory control for products. Various checks inside of Drupal Commerce ask the Availability Manager if a product is still available for purchase. This can prevent adding a product to the customer's cart or automatically remove items from the cart before checkout. The latter's user experience is pretty raw out of the box and requires some finesse to match something like Amazon, which displays messages and moves items for a later time.

I created a field_stock integer field that is associated on products. The availability manager checks the number in field_stock against the requested quantity. If the requested quantity is higher than what exists, the product is not available.

public function check(OrderItemInterface $order_item, Context $context) {
  $purchased_entity = $order_item->getPurchasedEntity();
  if (!$purchased_entity instanceof ProductVariationInterface) {
    return AvailabilityResult::neutral();
  }

  $requested_stock = (int) $order_item->getQuantity();

  $available_stock = (int) $purchased_entity?->getProduct()->get('stock')->value;

  if ($available_stock >= $requested_stock) {
    return AvailabilityResult::neutral();
  } 
  return AvailabilityResult::unavailable();
}

The neutral return value allows other availability checkers to process the product.

When an order is placed (payment has been received and Square has sent the user back to the site) I decrement the inventory amount.

$product_stock = (int) $product->get('stock')->value;
$product_stock -= (int) $item->getQuantity();
$product->set('stock', $product_stock);
$product->save();

There are a few red flags in this implementation. But that is for larger sites with a higher sales volume. But, it works!

One of the red flags is lacking of inventory holds. When the user enters the cart and goes off to Square, the customer does not have a hold on those items in the inventory. This customer could have to step away to get their credit card and be distracted by their dog. Then another customer could come through with saved payment information and beat out the first customer in payment. This could lead to selling more than what is allowed. While a bad situation, that's a great one for a business that product has that kind of demand.

Another is storing inventory values on the product entity. Drupal's caching system is extremely robust. When you save a product, the system will invalidate any caches which have that product as a dependency via cache tags. By storing the inventory value on the product, the product has to be saved each time there is a transaction. That causes invalidation of Drupal's page cache and dynamic page cache. Instead, it'd be best to track inventory in another table which has the current stock level and any holds or committed inventory changes from a purchase. When the calculated inventory for the product reaches zero, that is when you would modify the product to mark it as out of stock and invalidate it's cache tags. If you're curious about cache tags, I wrote about Drupal's cache tags in a previous blog "Drupal: cache tags for all, regardless your backend."

Optimizing for social media shares

Drupal has a wonderful module named Metatag for managing meta tags. Leveraging Drupal's token system, I was able to define dynamic meta values for the products on the site. There is a bit of a "hack" when posting on Facebook that it's beneficial to post with images and then comment with any relevant links. I wanted to make sure those link previews looked just right 🤌.

My wife uploaded some really high quality images to Square. I didn't want those to be used by Facebook and hope they trimmed and cropped them properly. You're able to specify a specific image style in the token. I used the following token to get the first image from the field_images field on my product types with the Large image style:

[commerce_product:field_images:0:entity:field_media_image:large:url]

Drupal's media system treats media as a container around the actual file or image being referenced. I definitely needed to reference the Metatag module's documentation for images.