What I learned writing a Shopify Scripts Functions parser

ruby dev.to

Shopify is killing Scripts on June 30, 2026. That's 28 days from when I'm writing this. After that, every Shopify store running Ruby Scripts to customize their checkout — hide payment methods, rename gateways, apply discount rules, route shipping — will see those Scripts stop executing.

Most stores still haven't migrated.

I built PayLogic, a Shopify Functions-based payment-customization app that's now live on the Shopify App Store (disclosure up front — I'm not pretending to be a disinterested observer). To help merchants migrate to it, I needed an importer that could take a Ruby Script as input and produce equivalent rule definitions. I figured: how hard could parsing a small Ruby DSL be?

Harder than I expected.

I extracted the parser into a free standalone tool — paste a Script, see what's translatable, what's partial, what won't. Runs entirely in your browser, no signup, no upload. It's at paylogic.dev/scripts-converter if you want to play with it.

But what I want to share here is what I learned by reading a lot of real Shopify Scripts, not the tool. Some of these patterns are useful if you're migrating manually. Some of the surprises are useful if you're ever building anything that integrates with Shopify webhooks. And the war story about an eight-hour debugging session is something I wish someone had written before I had to figure it out.

The deceptively-hard parsing problems

Shopify Scripts are written in a Ruby DSL with a small surface area. From the outside this looks like the easiest possible parsing target — limited syntax, limited semantics, limited shape of business logic. A few regexes and you're done, right?

Reader, you are not done.

Two block syntaxes for the same operation

The first thing that breaks a naive parser is Ruby's two block syntaxes. These are semantically identical:

# Single-line brace form
Input.payment_gateways.delete_if { |gw| gw.name == "Cash on Delivery" }

# Multi-line do/end form
Input.payment_gateways.delete_if do |gw|
  gw.name == "Cash on Delivery"
end
Enter fullscreen mode Exit fullscreen mode

In my sample of real merchant Scripts, the multi-line form is way more common. Idiomatic Ruby prefers do...end for multi-line and {...} for single-line. So a parser that only handles one form misses the majority of real Scripts.

The naive approach — regex matching for delete_if[^|]*\|... — works for the single-line form because the whole pattern is on one line. For the multi-line form, the pattern spans lines, and you have to find the matching end, which means tracking block depth.

Which gets harder because...

Modifier-if looks just like block-if

Ruby has modifier if, which trails an expression and conditionally evaluates it:

gw.name = "Credit Card" if gw.name == "Bogus Gateway"
Enter fullscreen mode Exit fullscreen mode

This is one statement. There's no end.

It's syntactically near-identical to block if, which opens a scope that requires an end:

if gw.name == "Bogus Gateway"
  gw.name = "Credit Card"
end
Enter fullscreen mode Exit fullscreen mode

If your block-depth counter sees if and increments depth, you over-count modifier-ifs and never find the matching end for your outer block. Distinguishing block-if from modifier-if without writing a full Ruby tokenizer required a heuristic: check whether if/unless appears at the start of a stripped line. If so, it's block-opening. If it's mid-line after an expression, it's a modifier.

This isn't bulletproof — a Script could have if true; do_thing; end on one line — but in the wild I haven't seen merchant Scripts that complex.

Compound conditions

Real merchant conditions tend to compose:

if Input.customer.orders_count >= 5 && Input.customer.total_spent >= Money.new(cents: 100000)
  Input.payment_gateways.delete_if { |gw| gw.name == "Cash on Delivery" }
end
Enter fullscreen mode Exit fullscreen mode

So I split the condition expression on top-level && and ||, then parse each side as its own predicate. For AND/OR mixed cases, I bail with a warning — Functions rules support uniform AND or OR via match_mode, but parenthesized mixing requires more sophisticated AST work that I haven't done.

unless is negation, sometimes invertible

unless cond is if !cond. For simple predicates with clean inverses, this maps cleanly:

# Original:
unless Input.customer.tags.include?("VIP")
  Input.payment_gateways.delete_if { |gw| gw.name == "Premium Card" }
end

# Becomes:
# "Hide Premium Card when customer is NOT tagged VIP"
Enter fullscreen mode Exit fullscreen mode

For some conditions there's no clean inverse — for example, "cart total ≥ X" inverts to "cart total < X," but boundary handling matters (≤ vs <), and I'd rather warn than approximate. So my parser has a small invertSingleCondition function that handles tag presence/absence, order-count thresholds, and first-time/returning customer flipping, and warns on anything it can't invert cleanly.

Three actions, three patterns

The three Script actions for payment customization map to three Ruby patterns:

# HIDE: delete_if
Input.payment_gateways.delete_if { |gw| gw.name == "X" }

# RENAME: map with conditional assignment
Output.payment_gateways = Input.payment_gateways.map do |gw|
  gw.name = "Credit Card" if gw.name == "Bogus Gateway"
  gw
end

# MOVE: partition into preferred + rest, then concat
preferred, rest = Input.payment_gateways.partition { |gw| gw.name == "Shop Pay" }
Output.payment_gateways = preferred + rest
Enter fullscreen mode Exit fullscreen mode

Detecting which is happening requires recognizing the structure, not just the method name. map can do other things; in payment Scripts it's specifically the conditional gw.name = inside the block that means "rename." partition only counts as "move" if followed by a concat assignment. Etc.

The good news: in practice, merchants reuse these patterns almost verbatim because they're the canonical Shopify-documented ways to do each operation. So pattern recognition is reasonable. The parser detects all three.

The PCD-redacted-payload trap that took 8 hours

This one isn't about parsing, but it's the most expensive bug I hit while building this app, and it's relevant for anyone building Shopify webhooks.

PayLogic listens for customers/update webhooks to maintain a tag cache (so the payment customization Function can read customer tags at checkout without a round-trip). The webhook payload has a tags field. So I read it, wrote it to a metafield, and shipped.

The bug: the metafield was getting written with empty tags, even for customers who clearly had tags in Shopify admin.

Four hours of debugging:

  1. Hour 1: assumed my webhook handler was buggy. Logged the payload. Tags showed as []. So the handler was getting empty tags. Confused.
  2. Hour 2: assumed it was a serialization issue between Prisma and the JSON metafield value. Wasn't.
  3. Hour 3: assumed Shopify's webhook delivery was buggy. Tested with a different webhook topic — customer fields were similarly empty.
  4. Hour 4: hit on the actual answer. Shopify's Protected Customer Data (PCD) policy redacts customer fields from webhook payloads independently of whether your app has Admin API access to those same fields. Your app might have read_customers scope and full Admin API access, but webhook payloads strip PCD-protected fields by default.

The fix: ignore payload.tags entirely. On webhook receipt, take the customer ID from the payload, query Admin GraphQL with that ID, get the real customer record with the real tags.

// Wrong:
await writeTagCache(payload.customer.id, payload.customer.tags);

// Right:
const customer = await admin.graphql(
  `query GetCustomer($id: ID!) { customer(id: $id) { id tags } }`,
  { variables: { id: `gid://shopify/Customer/${payload.customer.id}` } }
);
await writeTagCache(
  customer.data.customer.id,
  customer.data.customer.tags
);
Enter fullscreen mode Exit fullscreen mode

It's a one-line conceptual fix that took me hours to find because the failure mode — payload looks valid, just empty arrays for the PCD fields — doesn't look like a permissions error. There's no helpful "field redacted" indicator. The fields just look like the customer has no tags.

If you're building Shopify webhooks: never trust PCD-protected fields in webhook payloads. Always re-query Admin GraphQL with the entity ID. That's the lesson.

What patterns merchants actually use

Reading real Scripts showed me what merchants actually do, which is narrower than the universe of what Scripts could do. A handful of patterns cover the bulk:

Hide COD over a threshold — by far the most common single pattern:

if Input.cart.subtotal_price >= Money.new(cents: 30000)
  Input.payment_gateways.delete_if { |gw| gw.name == "Cash on Delivery" }
end
Enter fullscreen mode Exit fullscreen mode

Hide multiple methods for first-time customers:

if Input.customer.orders_count.zero?
  Input.payment_gateways.delete_if { |gw|
    ["Cash on Delivery", "Bank Deposit"].include?(gw.name)
  }
end
Enter fullscreen mode Exit fullscreen mode

Country-gated hides (very common in international stores):

if Input.cart.shipping_address.country_code == "BR"
  Input.payment_gateways.delete_if { |gw| gw.name == "PayPal" }
end
Enter fullscreen mode Exit fullscreen mode

Tag-driven hides (B2B / wholesale flows):

if Input.customer && Input.customer.tags.include?("wholesale")
  Input.payment_gateways.delete_if { |gw|
    ["Credit Card", "Shop Pay"].include?(gw.name)
  }
end
Enter fullscreen mode Exit fullscreen mode

Renames for friendlier labels (less common, but recurring):

Output.payment_gateways = Input.payment_gateways.map do |gw|
  gw.name = "Pay on Delivery" if gw.name == "Cash on Delivery"
  gw
end
Enter fullscreen mode Exit fullscreen mode

These five patterns cover maybe 80% of the payment-customization Scripts I've seen. If you're migrating manually, prioritize these in your Function and don't get distracted optimizing for edge cases that may never appear.

What doesn't translate

Some Script patterns don't have a clean Functions equivalent, and the parser flags them as warnings rather than silently dropping them:

  • Stateful logic like "this discount can only be applied once per customer per session." Functions are stateless — they evaluate the current cart and return operations. Per-customer or per-session state has to live in your app's database, not in the Function.
  • External HTTP calls for fraud checks, inventory lookups, etc. Functions can't make outbound HTTP. Pre-compute the data into a metafield via webhooks, then read the metafield from the Function input.
  • Tax line modifications. Functions can't modify tax lines directly. Discount lines yes, tax lines no.
  • Substring matching on payment names (gw.name.downcase.include?("cash")). Functions match exact names. The parser translates these to approximate exact-name matches with a warning.
  • Deeply nested compound logic that doesn't fit a single AND or OR pass. A && (B || C) requires manual rule decomposition.

For these, the converter outputs a warning explaining what was skipped so the merchant knows what they'd need to rebuild manually. I chose this over silently translating to "close enough" rules. Close-enough silently-modified payment logic is exactly the wrong thing to ship at checkout.

The takeaway

Three things I'd tell my past self:

  1. The deadline (June 30, 2026) is real and tight. If you're a merchant or building for merchants, don't underestimate how many stores haven't started.
  2. Most Scripts ARE mechanically translatable. The patterns above cover most of what real merchants do.
  3. What doesn't translate is worse than you'd guess. Stateful, external-API, and tax-line-modifying Scripts are common enough to matter, and silently-wrong translation is worse than no translation. Be explicit about limits.

The converter is at paylogic.dev/scripts-converter — no signup, runs entirely client-side. Try it on your own Script if you have one running.

If you're another Shopify Functions / app dev who's been parsing Scripts (or running into PCD quirks, or anything else in this space) — I'd love to compare notes. The "real Scripts in the wild" data set is small enough right now that anything you've seen is useful.

Source: dev.to

arrow_back Back to Tutorials