<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: hans</title>
    <description>The latest articles on DEV Community by hans (@hanswys).</description>
    <link>https://dev.to/hanswys</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3804880%2F2df71985-72af-4134-8ba1-fa3c1d1bfee7.jpg</url>
      <title>DEV Community: hans</title>
      <link>https://dev.to/hanswys</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hanswys"/>
    <language>en</language>
    <item>
      <title>Localizing a Shopify Customer Account Extension: 42 Languages, One Rails Server, and a Cache That Never Hit</title>
      <dc:creator>hans</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:16:13 +0000</pubDate>
      <link>https://dev.to/hanswys/localizing-a-shopify-customer-account-extension-42-languages-one-rails-server-and-a-cache-that-oh6</link>
      <guid>https://dev.to/hanswys/localizing-a-shopify-customer-account-extension-42-languages-one-rails-server-and-a-cache-that-oh6</guid>
      <description>&lt;p&gt;A customer in São Paulo opens their order status page. Shopify renders the whole page in Portuguese — and then our order editing widget shows up in the middle of it speaking English.&lt;/p&gt;

&lt;p&gt;Worse: the merchant can't fix it. The copy is baked into the extension bundle at deploy time. "Cancel order" reads the same for a boutique in Berlin and a streetwear brand in Tokyo, in a language most of their customers don't shop in.&lt;/p&gt;

&lt;p&gt;This is the story of how I built localization for PostCo's Order Editing app end-to-end — and the two edge cases that taught me more than the happy path did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;PostCo Order Editing is a Shopify app that lets customers edit their own orders after checkout — add products, cancel the order, change the shipping address, switch payment methods — directly from the order status page. The customer-facing part is a &lt;strong&gt;Shopify customer account UI extension&lt;/strong&gt; running in a sandboxed iframe; the backend and merchant admin are Rails 8 with Inertia and React.&lt;/p&gt;

&lt;p&gt;Shopify UI extensions have built-in i18n: you bundle static JSON locale files and call &lt;code&gt;shopify.i18n.translate(key)&lt;/code&gt;. We shipped with English and French that way. It works, but the files are baked in at build time and identical for every shop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Locale files are compiled into the extension bundle at deploy time.&lt;/li&gt;
&lt;li&gt;Every shop receives the exact same strings.&lt;/li&gt;
&lt;li&gt;Adding a language means a new release of the extension.&lt;/li&gt;
&lt;li&gt;Merchants have no way to touch any of the copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Two requirements converged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coverage.&lt;/strong&gt; Merchants sell globally and Shopify localizes everything around our extension. We needed the extension to render in the customer's language — for the full set of locales Shopify supports, not two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customization.&lt;/strong&gt; Merchants wanted to change the copy. Tone of voice, legal phrasing, a CTA that matches their theme. Bundled JSON gives them nothing — every shop gets identical strings.&lt;/p&gt;

&lt;p&gt;Bundled locale files solve neither. Any solution had to put the copy somewhere our server controls, per shop, per language.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;A new &lt;strong&gt;server-owned translation system&lt;/strong&gt; that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Makes Rails own the catalog — one YAML file per locale under &lt;code&gt;config/order_editing_locales/&lt;/code&gt;, 42 locales, 378 keys each, with &lt;code&gt;en.yml&lt;/code&gt; as the source of truth.&lt;/li&gt;
&lt;li&gt;Stores merchant overrides as sparse rows in a &lt;code&gt;shop_translations&lt;/code&gt; table. Defaults are never copied into the database.&lt;/li&gt;
&lt;li&gt;Adds a Localization page in the admin where merchants edit any string in any language, plus the existing Customize tab writing to the same rows.&lt;/li&gt;
&lt;li&gt;Has the extension fetch resolved translations from Rails at runtime — &lt;code&gt;GET /shopify_api/translations?locale=fr&lt;/code&gt; — falling back to its bundled copy if the request fails.&lt;/li&gt;
&lt;li&gt;Layers three small caches to make the runtime fetch cheap, because two Shopify platform constraints kill the standard playbook (more on this below).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The deliberate trade: we chose server-delivered translations over publishing to Shopify metafields. Metafields would give zero-network reads, but every merchant edit would need a background publish job, every new key a backfill across all shops, and the admin "save and preview" flow would inherit eventual-consistency bugs. Serving from Rails means one source of truth and instantly visible edits — at the cost of a network request we then have to make cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The catalog and locale resolution
&lt;/h3&gt;

&lt;p&gt;YAML is nested for maintainability, but the extension consumes flat dotted keys (matching what &lt;code&gt;shopify.i18n.translate&lt;/code&gt; already used), so the loader flattens on read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
  &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Hash&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_with_object&lt;/span&gt;&lt;span class="p"&gt;({})&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;translations&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;flattened_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;translations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flattened_key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loader also owns locale resolution, because what browsers report and what catalogs exist are different vocabularies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;LOCALE_ALIASES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"pt"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"pt-BR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"zh"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"zh-CN"&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_locale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;DEFAULT_LOCALE&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blank?&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;supported?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;LOCALE_ALIASES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;LOCALE_ALIASES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;key?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;   &lt;span class="c1"&gt;# "fr-FR" -&amp;gt; "fr"&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;supported?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="no"&gt;DEFAULT_LOCALE&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A CI test asserts every one of the 42 catalogs has exactly the same flattened key set as &lt;code&gt;en.yml&lt;/code&gt;, every value is non-blank, and every &lt;code&gt;{{interpolation}}&lt;/code&gt; placeholder in English survives in the translation. That parity test turns "did I remember to update all 42 files?" from a code-review prayer into a red build.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sparse overrides
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;shop_translations&lt;/code&gt; table has a unique index on &lt;code&gt;(shop_id, locale, key)&lt;/code&gt; and one rule that shapes everything: &lt;strong&gt;defaults never get copied into the database.&lt;/strong&gt; A row exists only when a merchant changed something. Clearing a field deletes the row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blank?&lt;/span&gt;
  &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_all&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="no"&gt;ShopTranslation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;shop_id: &lt;/span&gt;&lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;locale: &lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;key: &lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;value: &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="ss"&gt;unique_by: &lt;/span&gt;&lt;span class="sx"&gt;%i[shop_id locale key]&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the table tiny (most shops have zero rows), means updated default copy ships to everyone on deploy with no backfill, and gives the admin UI a free "Default vs. Customized" badge — a row's existence &lt;em&gt;is&lt;/em&gt; the customization state.&lt;/p&gt;

&lt;p&gt;The merge is then one expression:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;translations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;defaults&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overrides&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Caching against the grain of the platform
&lt;/h3&gt;

&lt;p&gt;Translations are read-heavy, so the instinct is "put it behind a CDN." Two Shopify constraints break that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session tokens rotate every ~5 minutes&lt;/strong&gt; and travel in the &lt;code&gt;Authorization&lt;/code&gt; header. HTTP caches key on the request — a rotated token is a new cache key, so browser &lt;code&gt;Cache-Control&lt;/code&gt; and CDN caching are guaranteed misses. Cross-customer hit rate is zero by construction (every customer has a unique token).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The extension sandbox has no &lt;code&gt;localStorage&lt;/code&gt; and no service workers.&lt;/strong&gt; The only client-side persistence is JavaScript module scope — survives component re-mounts, dies on full page reload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So instead of one big cache, three small layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — process memoization.&lt;/strong&gt; YAML files are static between deploys, so the loader memoizes the parsed-and-flattened result in a class-level hash. One subtlety: the result is a &lt;code&gt;Data.define&lt;/code&gt; struct, and &lt;code&gt;Data&lt;/code&gt; freezes the struct but not the hash inside it. Without an explicit &lt;code&gt;.freeze&lt;/code&gt; on the translations hash, any caller could mutate the shared cached copy for every subsequent request in that process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — Redis for the merged payload.&lt;/strong&gt; With YAML in memory, the expensive repeated work is the Postgres query confirming "this shop has no overrides" — which is the answer for most shops on most requests. We cache the merged result per &lt;code&gt;translations:merged:{shop_id}:{locale}&lt;/code&gt; with a 1-hour TTL. The key uses the &lt;em&gt;resolved&lt;/em&gt; locale, so &lt;code&gt;pt&lt;/code&gt;, &lt;code&gt;pt-anything&lt;/code&gt;, and &lt;code&gt;pt-BR&lt;/code&gt; all share one entry instead of proliferating keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — client-side skip.&lt;/strong&gt; The extension keeps the translation map in module scope and records which locale it loaded. On re-mount (customers bounce between order pages more than you'd think), it skips the fetch entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;translationsPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isLocaleLoaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fetchTranslations&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;backendUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sessionToken&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.catch(() =&amp;gt; null)&lt;/code&gt; matters. Translations load in a &lt;code&gt;Promise.all&lt;/code&gt; alongside the order-actions request, and a translations failure must never block the whole block from rendering — the extension degrades to its bundled &lt;code&gt;shopify.i18n.translate&lt;/code&gt; copy instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Invalidation: the callback that would never fire
&lt;/h3&gt;

&lt;p&gt;My first instinct for cache invalidation was an &lt;code&gt;after_commit&lt;/code&gt; callback on the model. It would have compiled, looked correct in review, and never fired once — because both write paths use &lt;code&gt;upsert&lt;/code&gt; and &lt;code&gt;delete_all&lt;/code&gt;, which bypass ActiveRecord callbacks entirely.&lt;/p&gt;

&lt;p&gt;Worse than not working, a callback would actively mislead the next developer into assuming invalidation is automatic. So each write site calls the bust explicitly after its transaction commits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Translations&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;MergeLocaleService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bust_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_shop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Less magic, impossible to misread. The merchant who saves a translation and immediately previews the extension sees the new copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cache that never hit
&lt;/h3&gt;

&lt;p&gt;After shipping Layer 3, everything worked — copy rendered correctly in every language. But for some locales, the client-side cache never hit. The extension re-fetched translations on every single mount, and nothing in the UI looked wrong.&lt;/p&gt;

&lt;p&gt;The cause: the client and server speak different locale dialects. Shopify reports the customer's language as, say, &lt;code&gt;pt&lt;/code&gt;. The server resolves that to &lt;code&gt;pt-BR&lt;/code&gt; and returns the resolved locale in the payload. My skip-check stored the server's value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before: stores "pt-BR"&lt;/span&gt;
&lt;span class="nf"&gt;markLocaleAsFetched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// next mount: client asks about "pt"&lt;/span&gt;
&lt;span class="nf"&gt;isLocaleLoaded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// "pt" !== "pt-BR" → miss, forever&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For any aliased locale, the cache could never hit. And because a miss just means "do the fetch," the failure was invisible: correct output, wasted requests.&lt;/p&gt;

&lt;p&gt;The fix clarified something I'd been conflating — there are &lt;strong&gt;two locale values with two different jobs&lt;/strong&gt;. &lt;code&gt;translationsLoadedForLocale&lt;/code&gt; must store the client-side locale, because its job is cache identity: "did I already fetch for what the client is asking?" A separate &lt;code&gt;serverLocale&lt;/code&gt; stores the server-resolved locale, because its job is plural selection. Which brings me to —&lt;/p&gt;

&lt;h3&gt;
  
  
  Pluralization doesn't come for free anymore
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;shopify.i18n.translate&lt;/code&gt; handles plurals automatically from the bundled JSON structure. The moment you serve a flat key-value map from your own server, you've opted out of that, and nobody warns you. "1 Day" / "3 Days" quietly breaks.&lt;/p&gt;

&lt;p&gt;The catalog stores plural forms as separate keys (&lt;code&gt;time.days.one&lt;/code&gt;, &lt;code&gt;time.days.other&lt;/code&gt;), and the client helper reimplements selection with the platform primitive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;count&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PluralRules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverLocale&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pluralRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;serverTranslations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;pluralRaw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;interpolate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pluralRaw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;serverLocale&lt;/code&gt; — the resolved one. &lt;code&gt;Intl.PluralRules&lt;/code&gt; needs to know it's doing Brazilian Portuguese plural rules regardless of what the browser reported. The same normalization that broke my cache is load-bearing for plurals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions worth calling out
&lt;/h2&gt;

&lt;p&gt;A few choices that punched above their weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two-phase rollout for the contract change.&lt;/strong&gt; The extension previously read its labels from the order-actions endpoint. PR one made the extension read from the new translations endpoint while the old endpoint kept shipping the copy nobody read — dead payload, instant rollback if anything broke. Only after that baked in production did PR two strip the copy server-side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One deprecated field survived the cleanup.&lt;/strong&gt; The extension and backend deploy independently, so an old extension version can hit a new backend. We kept &lt;code&gt;label&lt;/code&gt; in the order-actions payload as a fallback for stale clients; removing it is its own ticket, not a rider on this one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interpolation validation at write time.&lt;/strong&gt; If the English source contains &lt;code&gt;{{count}}&lt;/code&gt;, a merchant's override must too — otherwise the save is rejected with the missing placeholders listed. Catching this at write time beats a customer seeing a literal &lt;code&gt;{{count}}&lt;/code&gt; on their order page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Customize tab and Localization page share rows via one mapping module.&lt;/strong&gt; A single &lt;code&gt;KeyMapping&lt;/code&gt; constant is the source of truth for "this Customize-tab field is that translation key." Edit a label in one surface, the other shows it customized. No sync logic, because there's nothing to sync.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The admin editor classifies rows as short, long, or plural&lt;/strong&gt; and renders the right input for each — plus highlights &lt;code&gt;{{interpolation}}&lt;/code&gt; tokens as non-editable segments, so merchants can't accidentally translate a variable name.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The three cache layers are holding, but the escape hatches are documented if load outgrows them: a public no-auth URL for shops with zero overrides (CDN-cacheable), or metafield delivery as the nuclear option.&lt;/li&gt;
&lt;li&gt;Generating the extension's bundled fallback JSON from the server YAML in CI, so the two can't drift.&lt;/li&gt;
&lt;li&gt;Removing the deprecated &lt;code&gt;label&lt;/code&gt; field once old extension versions age out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The feature is live across 42 locales — 378 keys each, roughly 15,800 translated strings — with merchants able to override any one of them per language.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks for reading. If you've built on Shopify customer account extensions: how did you handle the rotating session tokens? The "your auth design makes HTTP caching impossible" constraint felt like the most underdocumented part of the platform, and I'd love to hear how others worked around it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>shopify</category>
      <category>react</category>
      <category>i18n</category>
    </item>
    <item>
      <title>Building a Conditional Fee Engine for Returns: How We Stopped Refunding Cash</title>
      <dc:creator>hans</dc:creator>
      <pubDate>Thu, 07 May 2026 03:18:00 +0000</pubDate>
      <link>https://dev.to/hanswys/building-a-conditional-fee-engine-for-returns-how-we-stopped-refunding-cash-56lj</link>
      <guid>https://dev.to/hanswys/building-a-conditional-fee-engine-for-returns-how-we-stopped-refunding-cash-56lj</guid>
      <description>&lt;p&gt;When a customer returns a $50 item with a $5 restocking fee, who actually pays the $5?&lt;/p&gt;

&lt;p&gt;In most returns systems, the answer is: the retailer does. The customer gets $45 back to their original card, and the $5 the retailer was supposed to recover never lands anywhere — it's just absorbed into the refund. Multiply that across thousands of returns a month and the leak adds up to real money.&lt;/p&gt;

&lt;p&gt;This is the story of how we fixed that at PostCo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;PostCo is a returns management platform that sits between Shopify retailers and their customers. When a customer returns an item, we orchestrate the whole flow: validating the return, calculating refund amounts, picking the right refund method, and posting the result back to Shopify.&lt;/p&gt;

&lt;p&gt;For a long time, "fees" on returns were modeled as &lt;strong&gt;Return Amount Adjustments (RAA)&lt;/strong&gt; — outcomes attached to &lt;code&gt;PolicyRule&lt;/code&gt;s, evaluated at return creation time, hardcoded to deduct from the original payment method. Retailers could configure them, but the system was rigid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adjustments were tied 1:1 with &lt;code&gt;PolicyRule&lt;/code&gt; outcomes.&lt;/li&gt;
&lt;li&gt;The deduction always came out of the refund the customer received in cash.&lt;/li&gt;
&lt;li&gt;There was no way to apply a flat fee unconditionally, or scope a fee to "exchanges only," or split a fee across multiple refund methods.&lt;/li&gt;
&lt;li&gt;Manual ad-hoc adjustments in the resolve modal didn't exist — every adjustment had to round-trip through the policy engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Two business problems converged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retailer cash leakage.&lt;/strong&gt; When a $50 return had a $5 restocking fee, the customer still got $45 refunded to their original card. The retailer never recovered the cash that went out the door. If instead the $5 came out of an existing gift card balance, the retailer kept the entire $50 in their ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration ergonomics.&lt;/strong&gt; Retailers wanted a Fees page where they could say "all bag returns are charged 15%" or "all returns ship at $5" without learning the PolicyRule UI. They also wanted ops staff to slap an ad-hoc credit or charge onto a return at resolve time without reopening configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;A new &lt;strong&gt;Return Amount Adjustment Preset&lt;/strong&gt; system that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lives alongside RAA during rollout, gated by a &lt;code&gt;return_fees_enabled&lt;/code&gt; shop flag.&lt;/li&gt;
&lt;li&gt;Supports unconditional ("apply to all") or conditional (scoped to a &lt;code&gt;PolicyRule&lt;/code&gt;) presets.&lt;/li&gt;
&lt;li&gt;Distributes the fee across return methods in a priority order, only falling through to the original payment method (OPM) as a last resort.&lt;/li&gt;
&lt;li&gt;Exposes a manual adjustment API for the resolve modal — POST/DELETE, sign derived server-side.&lt;/li&gt;
&lt;li&gt;Provides a one-click migration from legacy RAA rules to presets.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Data model
&lt;/h3&gt;

&lt;p&gt;Two tables.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;return_amount_adjustment_presets&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;shop_id&lt;/code&gt;, &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;amount_cents&lt;/code&gt;, &lt;code&gt;unit&lt;/code&gt; (&lt;code&gt;"percentage"&lt;/code&gt; or &lt;code&gt;"amount"&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apply_to_all&lt;/code&gt; (bool), &lt;code&gt;policy_rule_id&lt;/code&gt; (nullable FK)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;applies_to&lt;/code&gt; (&lt;code&gt;"refund"&lt;/code&gt; or &lt;code&gt;"exchange"&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;return_amount_adjustment_priorities&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one row per shop, JSONB array of return methods in priority order&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The clever bit is the relationship with &lt;code&gt;PolicyRule&lt;/code&gt;. Rather than build a parallel rules engine, we extended the existing one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;PRESET_OUTCOME_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Return Amount Adjustment Preset"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="no"&gt;VALID_OUTCOME_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="no"&gt;PRESET_OUTCOME_TYPES&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;validates_presence_of&lt;/span&gt; &lt;span class="ss"&gt;:outcome_values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;unless: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;outcome_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Return Amount Adjustment Preset"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;has_one&lt;/span&gt; &lt;span class="ss"&gt;:return_amount_adjustment_preset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;foreign_key: :policy_rule_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;outcome_values&lt;/code&gt; skip is the trick — preset rules store their data on the associated record, not in the rule's &lt;code&gt;outcome_values&lt;/code&gt; JSON. That keeps the preset editable from the Fees UI without round-tripping through the policy engine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Distribution priority
&lt;/h3&gt;

&lt;p&gt;The core service, &lt;code&gt;EvaluatePresetAdjustmentsService&lt;/code&gt;, builds an "adjustment pool" from all matching presets and distributes it across the customer's enabled return methods in this hardcoded order:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gift_card&lt;/code&gt; → &lt;code&gt;discount_code&lt;/code&gt; → &lt;code&gt;shopify_store_credit&lt;/code&gt; → &lt;code&gt;original_payment_method&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Why hardcoded? Because the PM wanted to validate the E2E flow before exposing the priority order as a configurable knob. Shipping the engine first and the configuration UI later was the lower-risk path. The priority table exists in the schema; the admin UI for it is deferred.&lt;/p&gt;

&lt;p&gt;There's one safety guard worth calling out: orders sourced from Facebook can't receive adjustments routed to the original payment method, because that source can't honor a cash-back scenario.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;adjustments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject!&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;return_method&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="ss"&gt;:opm&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_facebook?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Manual adjustments
&lt;/h3&gt;

&lt;p&gt;The resolve modal needed a way to attach ad-hoc credits/charges without going through the preset engine. Two decisions worth highlighting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server-derived sign.&lt;/strong&gt; The API takes &lt;code&gt;type: "credit" | "charge"&lt;/code&gt; and &lt;code&gt;amount_cents&lt;/code&gt; (positive integer). The sign is determined on the server based on &lt;code&gt;type&lt;/code&gt;. Floats never enter the picture, and a malicious or buggy client can't flip a charge into a credit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual flag to survive rebuilds.&lt;/strong&gt; The &lt;code&gt;UpdateService&lt;/code&gt; rebuilds preset-derived adjustments every time a return order changes. To prevent that rebuild from blowing away manual rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;existing_refund_adjustments&lt;/span&gt;
  &lt;span class="n"&gt;return_order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;return_amount_adjustments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;manual: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Manual rows are invisible to the rebuild. Only preset/policy rows churn.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exchange-side charges
&lt;/h3&gt;

&lt;p&gt;Exchanges introduced a wrinkle: lower-value exchanges can produce a negative refund balance.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Customer exchanges a $50 item for a $30 item with a $5 fee → retailer owes them -$25 + needs to charge $5 = customer pays $5 via Stripe.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two new attributes on &lt;code&gt;ExchangeResource&lt;/code&gt; made the resolve modal math correct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="ss"&gt;:bonus_credit_cents&lt;/span&gt;      &lt;span class="c1"&gt;# extra credit awarded for the exchange&lt;/span&gt;
&lt;span class="n"&gt;attribute&lt;/span&gt; &lt;span class="ss"&gt;:price_difference_cents&lt;/span&gt;  &lt;span class="c1"&gt;# delta between returned and incoming items&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These get folded into the resolve modal's charge calculation so the displayed total matches what Stripe will actually charge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concurrent migration safety
&lt;/h3&gt;

&lt;p&gt;The migration from legacy RAA to presets has a nasty race: if a return order is created mid-migration, it might evaluate against a &lt;code&gt;return_fees_enabled&lt;/code&gt; shop with no presets yet — or worse, with both old RAA rules and new presets active.&lt;/p&gt;

&lt;p&gt;The fix in &lt;code&gt;CreateService&lt;/code&gt; and &lt;code&gt;UpdateService&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;return_fees_enabled?&lt;/span&gt;
  &lt;span class="n"&gt;evaluate_presets&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;evaluate_raa&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;reload&lt;/code&gt; looks ugly. It is. But the cost of a stale association during a 30-second migration window is wrong fees on real returns, which is unrecoverable. The reload is cheap insurance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Preview endpoint
&lt;/h3&gt;

&lt;p&gt;The resolve modal needed live fee preview as ops staff toggle which items to reject. New endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /return_orders/:id/return_amount_adjustments/preview
{ scoped_item_ids: [...] }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service evaluates presets against only the non-rejected items, returning unsaved adjustment instances for display. &lt;code&gt;filter_merchant_deleted_adjustments&lt;/code&gt; removes adjustments tied to presets the merchant deleted after the return was created — important for historical accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  UI choices worth calling out
&lt;/h2&gt;

&lt;p&gt;A few small decisions that punched above their weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;applies_to&lt;/code&gt; is a radio with two options&lt;/strong&gt; (&lt;code&gt;"refund"&lt;/code&gt; or &lt;code&gt;"exchange"&lt;/code&gt;), not a "both" checkbox. The original spec had "Both" as a third option; we removed it because the two flows have different sign semantics (refunds deduct, exchanges charge) and conflating them confused early testers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags are case-insensitive&lt;/strong&gt;, even though Shopify treats them case-sensitively in some contexts. We lowercase both sides at compare time. Retailers tag inconsistently, and a strict comparison would silently miss matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag input saves on blur, not on every keystroke.&lt;/strong&gt; Earlier behavior wiped the field when focus moved — a regression caught in QA. Now &lt;code&gt;onBlur&lt;/code&gt; commits, &lt;code&gt;onChange&lt;/code&gt; updates local state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete preset surfaces a confirmation modal.&lt;/strong&gt; Deletes are destructive (the preset is FK'd from historical adjustments via the policy rule), so we confirm before executing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve modal hides the manual adjustment UI for legacy RAA orders.&lt;/strong&gt; Mixing manual rows into a return that was created under the old engine produces inconsistent totals. Easier to lock those returns to the legacy flow and only enable manual adjustments on preset-era returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation error UX uses fixed-height field containers&lt;/strong&gt; so error messages don't shift modal layout when they appear/disappear. Tiny detail, big quality-of-life difference.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Configurable priority order (currently hardcoded).&lt;/li&gt;
&lt;li&gt;Stripe charge for negative refund balances on the refund flow (the exchange flow already supports it).&lt;/li&gt;
&lt;li&gt;Trend dashboards: how much retailers retain in store credit vs refund out as cash, which is the actual business metric we're trying to move.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration is rolling out cohort by cohort. Real numbers in a month or two.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Thanks for reading. If you've shipped something on a cash path before, I'd love to hear how you handled the migration race conditions — that part felt the most "anything could go wrong" of the whole project.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>webdev</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Bridging the Tracking Gap: Implementing Manual Returns for Retailers and Customers</title>
      <dc:creator>hans</dc:creator>
      <pubDate>Mon, 16 Mar 2026 03:15:00 +0000</pubDate>
      <link>https://dev.to/hanswys/bridging-the-tracking-gap-implementing-manual-returns-for-retailers-and-customers-2il5</link>
      <guid>https://dev.to/hanswys/bridging-the-tracking-gap-implementing-manual-returns-for-retailers-and-customers-2il5</guid>
      <description>&lt;p&gt;In the world of e-commerce returns, "Mail Returns" (where a customer ships a parcel themselves) are often a black hole. Because the system doesn't generate a label, there is no automatic tracking. No one knows where the package is, and the "he-said-she-said" over shipping proof becomes a support nightmare.&lt;/p&gt;

&lt;p&gt;We recently tackled this by building a manual tracking flow that serves two distinct masters: &lt;strong&gt;Retailers&lt;/strong&gt; needing oversight and &lt;strong&gt;Customers&lt;/strong&gt; needing peace of mind. Here is how we built a unified tracking system across two different UIs with a shared backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: The "Mail Return" Black Hole
&lt;/h2&gt;

&lt;p&gt;When a return is marked as &lt;strong&gt;Mail&lt;/strong&gt;, the customer uses their own shipping method (e.g., national post or a local courier).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;For Staff:&lt;/strong&gt; They can't see when a return is coming or if it's even been sent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;For Customers:&lt;/strong&gt; They have no way to "prove" they shipped it within the platform.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Technical Gap:&lt;/strong&gt; Since no label is generated by our system, no tracking data is auto-filled.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We needed a way for both parties to manually input &lt;strong&gt;Tracking Numbers&lt;/strong&gt; and &lt;strong&gt;Courier Names&lt;/strong&gt; into a shared source of truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture: One Logic, Two Gates
&lt;/h2&gt;

&lt;p&gt;Instead of building two separate tracking services, we opted for a &lt;strong&gt;shared form object&lt;/strong&gt; pattern. This ensures that whether a retailer updates a number or a customer does it, the validation and persistence logic remain identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The Backend Foundation
&lt;/h3&gt;

&lt;p&gt;We leveraged a &lt;code&gt;Shipment::UpdateForm&lt;/code&gt; object to handle the heavy lifting. This kept our controllers thin and our models clean.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;colgroup&gt;
&lt;col&gt;
&lt;col&gt;
&lt;col&gt;
&lt;/colgroup&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Feature&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Implementation Detail&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Endpoints&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;code&gt;PATCH .../customer/shipment/tracking&lt;/code&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;code&gt;PUT .../retailer/shipment_tracking&lt;/code&gt;&lt;/p&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Validation&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Max 64 chars for tracking; Max 128 for courier; Alphanumeric only.&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;State Guard&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Only allows updates if the return is in &lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;pending_action&lt;/code&gt;.&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;&lt;strong&gt;Data Model&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;p&gt;Updates &lt;code&gt;tracking_number&lt;/code&gt; and &lt;code&gt;custom_courier_name&lt;/code&gt; on the &lt;code&gt;Shipment&lt;/code&gt; model.&lt;/p&gt;&lt;/td&gt;
&lt;td colspan="1" rowspan="1"&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Pro-Tip:&lt;/strong&gt; Avoid heavy model callbacks. By performing the update within the Form Object’s &lt;code&gt;submit&lt;/code&gt; method, we ensured that side effects (like invalidating cache) only happened when the form was actually valid.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Frontend Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Retailer Experience (React + Polaris)
&lt;/h3&gt;

&lt;p&gt;In the retailer dashboard, we focused on speed and clarity for support agents.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Contextual UI:&lt;/strong&gt; If the shipping method is "Mail," an "Add Tracking Number" button appears.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Modal Flow:&lt;/strong&gt; Clicking the button triggers a Polaris modal that pre-fills existing data if the agent is editing rather than adding.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Immediate Feedback:&lt;/strong&gt; Upon saving, the app invalidates the return order query, triggering an instant UI refresh to show the new "Track" button (which links to the external tracking URL).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Customer Experience (Remix + React)
&lt;/h3&gt;

&lt;p&gt;The customer-facing side needed to be more "walk-through" style. We integrated this directly into the &lt;strong&gt;Return Summary&lt;/strong&gt; page.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Progressive Disclosure:&lt;/strong&gt; We show the "Tracking Number" and "Courier" labels even if empty to signal that this information is expected.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Inline Editing:&lt;/strong&gt; Instead of a modal, we used an inline form. The "Save" button remains disabled until both fields are filled to ensure data integrity.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;I18n Ready:&lt;/strong&gt; Since we operate globally, all labels (&lt;code&gt;add_tracking_button_label&lt;/code&gt;, etc.) are served via the API to support multi-language storefronts.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Key Technical Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shared Validation is King:&lt;/strong&gt; By using a single Form Object, we prevented "data drift" where a retailer might be able to enter a character that a customer’s UI would reject.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Status-Awareness:&lt;/strong&gt; Don't let users edit tracking after a return is "Completed" or "Cancelled." Hard-coding these guards into the service layer prevents API abuse.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The "Track" Button Logic:&lt;/strong&gt; We implemented a fallback mechanism. The UI displays the &lt;code&gt;custom_courier_name&lt;/code&gt; if present, but falls back to the system's &lt;code&gt;courierName&lt;/code&gt; for legacy data, ensuring the "Track Shipment" link never breaks.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Manual tracking isn't just about adding a text field; it's about creating a reliable audit trail. By building a unified backend and tailoring the UI to the specific needs of retailers and customers, we turned a "black hole" into a transparent part of the return lifecycle.&lt;/p&gt;

</description>
      <category>returns</category>
      <category>ecommerce</category>
      <category>api</category>
      <category>learning</category>
    </item>
    <item>
      <title>Beyond Manual Coding: Implementing an Agentic CI/CD Workflow at PostCo</title>
      <dc:creator>hans</dc:creator>
      <pubDate>Thu, 12 Mar 2026 03:15:00 +0000</pubDate>
      <link>https://dev.to/hanswys/beyond-manual-coding-implementing-an-agentic-cicd-workflow-at-postco-1nc3</link>
      <guid>https://dev.to/hanswys/beyond-manual-coding-implementing-an-agentic-cicd-workflow-at-postco-1nc3</guid>
      <description>&lt;p&gt;In a high-growth startup environment like PostCo, the goal is always the same: &lt;strong&gt;Ship high-quality features, faster.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As a Junior Software Engineer, I quickly realized that traditional "manual" workflows—where every line of boilerplate and every initial refactor is typed by hand—are no longer the most efficient way to deliver value. Instead, I’ve been developing an "Agentic Workflow" that uses AI agents as force multipliers within our CI/CD pipeline.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flrx37weice76tr1ap7e8.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flrx37weice76tr1ap7e8.jpg" alt="CI/CD Diagram" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  The Problem: The "Boilerplate" Bottleneck
&lt;/h4&gt;

&lt;p&gt;Most developers spend a significant portion of their day on repetitive tasks: setting up file structures, writing unit tests, or refactoring existing methods to fit new requirements. These tasks are necessary but often delay the critical "thinking" work.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Solution: The 4-Stage Agentic Loop
&lt;/h4&gt;

&lt;p&gt;Our workflow at PostCo is designed to automate the execution phase while keeping the "Intent" and "Quality Control" firmly in human hands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Requirements and Context (Notion + Linear)&lt;/strong&gt; Every feature starts with a deep dive. We use Notion for Implementation Docs that define the &lt;em&gt;why&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt;. These are synced to Linear, providing a clear map for the development process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Agentic Execution (GitHub + Claude)&lt;/strong&gt; The moment a PR is created, a &lt;strong&gt;Claude Agent (Planner/Executor)&lt;/strong&gt; analyzes the Implementation Doc and the existing codebase. It doesn’t just suggest code; it executes it, pushing a functional starting point to the branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The "Checker" Loop&lt;/strong&gt; Code shouldn't just be written; it should be verified. We employ a second &lt;strong&gt;Claude Agent (Code Checker)&lt;/strong&gt; that reviews the first agent’s work. They loop bidirectionally:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Executor&lt;/strong&gt; pushes code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Checker&lt;/strong&gt; identifies logic gaps or style inconsistencies.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Executor&lt;/strong&gt; refines the code.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;4. The Human-in-the-Loop: Tech Lead Review&lt;/strong&gt; Once the agents have iterated to a point of stability, the "Review" phase begins. I take the refined code and walk through it with my &lt;strong&gt;Tech Lead&lt;/strong&gt;. This is where we discuss the "Big Picture"—scalability, edge cases the agents might have missed, and alignment with our broader engineering goals at PostCo.&lt;/p&gt;

&lt;p&gt;By the time my Tech Lead sees a PR, the "noise" has been filtered out. The code is already linted, tested, and structurally aligned with the requirements. This allows our senior engineers to spend their time mentoring and architecting rather than correcting syntax or boilerplate.&lt;/p&gt;

&lt;p&gt;In 2026, being a "good developer" isn't just about how well you write code—it's about how well you manage the systems that produce it. This agentic workflow is just the beginning of how we’re scaling our engineering efforts.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>beginners</category>
      <category>devops</category>
    </item>
    <item>
      <title>Syncing Rails Validations with Formik: A Practical Approach</title>
      <dc:creator>hans</dc:creator>
      <pubDate>Tue, 10 Mar 2026 03:12:00 +0000</pubDate>
      <link>https://dev.to/hanswys/syncing-rails-validations-with-formik-a-practical-approach-nhh</link>
      <guid>https://dev.to/hanswys/syncing-rails-validations-with-formik-a-practical-approach-nhh</guid>
      <description>&lt;p&gt;In many SaaS apps, we allow "placeholder" data during onboarding to reduce friction. But what happens when that data needs to be validated later? I recently tackled this at PostCo by building a bridge between our Rails backend and React frontend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key Technical Takeaways:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Backend Validator:&lt;/strong&gt; Don't just rely on &lt;code&gt;presence: true&lt;/code&gt;. I created a &lt;code&gt;PlaceholderValidator&lt;/code&gt; to check against specific strings like "Address pending" or "00000".&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The Error Bridge:&lt;/strong&gt; Formik expects a specific shape. I wrote a utility to transform Rails' &lt;code&gt;errors.messages&lt;/code&gt; into a flattened object that Formik can consume, including a case-mapping layer.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The UX Trick:&lt;/strong&gt; If a field has a placeholder, the user shouldn't have to delete it manually. I implemented a "clear on edit" utility that treats placeholders as &lt;code&gt;""&lt;/code&gt; in the UI, triggering immediate Yup validation.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Handling nested errors is messy, but a little bit of mapping logic goes a long way in making a Junior-built feature feel Senior-grade.&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>react</category>
      <category>learning</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
