Mastering State-Specific Fees: Shopify Functions & Express Checkout Challenges Solved

Hey there, fellow store owners and Shopify enthusiasts!

One of the most common challenges we face as our businesses grow is navigating complex pricing rules, especially when they depend on something like a customer's location. We've all been there, trying to figure out how to add a specific fee – maybe a regulatory charge, an environmental fee, or a delivery surcharge – that varies from state to state or province to province. It gets even trickier when you throw in the convenience of accelerated checkouts like Apple Pay or Google Pay.

Recently, a fantastic discussion popped up in the Shopify community that really hammered this point home. Our friend, JiangMintun, was wrestling with a classic scenario: how to implement an "Environmental Protection Recycling Fee" that needed to be charged differently based on the customer's US state. And, as often happens, the express checkout flow was throwing a wrench into their otherwise clever solution.

The State-Specific Fee Dilemma: When Express Checkout Throws a Curveball

JiangMintun’s initial approach was pretty smart, and it’s a path many of us might consider. The idea was to use a UI Extension to grab the customer's shipping address as they typed it in, then write that address information into cart attributes. From there, a Discount Function API script would read these cart attributes to determine the delivery area and apply the correct fee. Sounds logical, right?

But here’s where the real-world complexities kicked in. This method, while functional for standard checkouts, completely fell apart with express checkout options like Apple Pay. Why? Because in those scenarios, the UI Extension might not always fire in time or correctly, leading to missing cart attributes. JiangMintun even tried a fallback – using the billing address – only to discover that even the billing address could be null during an express checkout. Talk about frustrating!

This led to a proposed workaround: a prompt on the checkout page, reminding users to fill in their shipping address before using express payment. While a decent UX fix, it’s not ideal for a seamless experience.

Why Your UI Extension & Cart Attribute Strategy Might Be a "Weak Link"

This is where the collective wisdom of the community truly shines. Another expert, v.marychenka, jumped in with a critical piece of advice that really cut to the chase. They highlighted that relying on applyAttributeChange() to write attributes from a UI Extension can be the "weak link," especially during accelerated checkouts. It’s simply not guaranteed to work reliably in those fast-paced payment flows.

JiangMintun's observation about deliveryGroups returning an empty array in [cart.lines.discounts.generate.run] was also a key clue. It turns out, this is often a symptom of using an older API or context.

The Robust Solution: Reading State Directly with Newer Discount Functions

So, what’s the better way to handle this? The answer lies in leveraging the power of Shopify Functions directly, specifically the newer Discount Function API. Here’s the core insight:

Instead of trying to pass shipping address data through cart attributes, you can directly access the customer's shipping address information within your Discount Function. The Discount Function delivery groups are explicitly built from the customer’s shipping address and expose a deliveryAddress object, which includes a handy provinceCode.

This is crucial because it bypasses the unreliable applyAttributeChange() method for accelerated checkouts. However, there's a catch: this functionality is only available with the newer API. If you're still on the legacy Product Discount or Order Discount APIs, your cart.deliveryGroups will indeed be empty. This was likely the reason for JiangMintun's initial observation.

Here’s how you can approach this, conceptually, within your Discount Function:


import { DiscountApplicationStrategy } from "@shopify/discount-app-extension";

// ... other imports and setup ...

export default function (input) {
  // Check if deliveryGroups exist and have a deliveryAddress
  const shippingAddress = input.cart.deliveryGroups?.[0]?.deliveryAddress;

  if (shippingAddress?.provinceCode) {
    const provinceCode = shippingAddress.provinceCode;

    // Now you can apply your logic based on the provinceCode
    // For example, if provinceCode is "CA" (California)
    if (provinceCode === "CA") {
      // Apply a specific fee or discount adjustment for California
      return {
        discountApplicationStrategy: DiscountApplicationStrategy.First,
        discounts: [
          {
            value: {
              fixedAmount: {
                amount: "5.00", // Example: $5 fee
                appliesToEachLineItem: false,
              },
            },
            targets: [
              {
                productVariant: {
                  id: "gid://shopify/ProductVariant/YOUR_PRODUCT_VARIANT_ID",
                  quantity: 1, // Or adjust as needed
                },
              },
            ],
          },
        ],
      };
    }
    // ... add more conditions for other states ...
  }

  // If no specific condition met, or no shipping address, return no discount/fee adjustment
  return {
    discountApplicationStrategy: DiscountApplicationStrategy.First,
    discounts: [],
  };
}

Important Note: The deliveryAddress field itself can still be empty in some scenarios (as v.marychenka pointed out with the deliveryAddress?.city example). So, always implement robust null-checking, as shown with shippingAddress?.provinceCode. And critically, make sure you thoroughly test your implementation with various accelerated checkout methods like Apple Pay and Google Pay, as the exact timing and population of these fields can sometimes be undocumented or nuanced.

Putting It All Together: Your Action Plan

If you're facing a similar challenge with state-specific fees and express checkouts, here's a step-by-step approach based on the community's insights:

  1. Migrate to Newer Discount Functions: Ensure your fee logic is built using the latest Shopify Functions API. If you're using older discount APIs, you'll need to update.
  2. Access deliveryAddress Directly: Within your Discount Function, access input.cart.deliveryGroups[0].deliveryAddress.provinceCode (or .stateCode for US states). Remember to add null-checking (e.g., ?.) to handle cases where the address might not be immediately available.
  3. Implement Conditional Logic: Use the provinceCode (or stateCode) to apply your specific fee adjustments for different states.
  4. Thorough Testing: This is non-negotiable! Test your entire checkout flow, especially with Apple Pay, Google Pay, and other accelerated checkout options, to ensure your fees are correctly applied and no unexpected errors occur.

This approach moves the logic to where the data is reliably available during the checkout process, making your solution much more resilient to the quirks of accelerated payments. It also highlights the power and flexibility that Shopify Functions bring to the table for truly custom commerce logic.

It's discussions like these in the developer community (and yes, sometimes you need to know which community to ask!) that really help us push the boundaries of what's possible on Shopify. Diving into the developer docs and engaging with others who are building on the platform is invaluable. If you're just getting started or looking to expand your Shopify store's capabilities, remember that a strong foundation and understanding of the platform's tools, like Shopify itself, can make all the difference when tackling these kinds of complex requirements.

Keep building, keep learning, and don't be afraid to dig into those APIs!

Share:

Use cases

Explore use cases

Agencies, store owners, enterprise — find the migration path that fits.

Explore use cases