{"id":14923,"date":"2026-09-29T17:30:00","date_gmt":"2026-09-29T12:00:00","guid":{"rendered":"https:\/\/www.allerin.com\/blog\/?p=14923"},"modified":"2026-09-25T11:20:28","modified_gmt":"2026-09-25T05:50:28","slug":"rails-adapter-currency-catalog-contract-tests","status":"publish","type":"post","link":"https:\/\/www.allerin.com\/blog\/rails-adapter-currency-catalog-contract-tests\/","title":{"rendered":"Rails adapter tests for currency fields and catalog changes"},"content":{"rendered":"<style>.allerin-rails-question{max-width:100%;overflow-wrap:anywhere}.allerin-rails-question h2{scroll-margin-top:125px}.allerin-rails-question nav{border-left:3px solid #17665b;padding:10px 18px;margin:24px 0}.allerin-rails-question nav ol{padding-left:22px}.allerin-rails-question nav a{display:inline-block}.allerin-rails-question code{white-space:normal;overflow-wrap:anywhere}.allerin-rails-question pre{max-width:100%;overflow:auto}.allerin-rails-question pre code{white-space:pre;overflow-wrap:normal}.allerin-rails-question .table-wrap{max-width:100%;overflow-x:auto;margin:22px 0}.allerin-rails-question table{width:100%;border-collapse:collapse;line-height:1.5}.allerin-rails-question th,.allerin-rails-question td{border:1px solid #d1d9d5;padding:10px;text-align:left;vertical-align:top}.allerin-rails-question th{background:#edf2ef}.allerin-rails-question[data-question=\"q05\"] th:nth-child(-n+2),.allerin-rails-question[data-question=\"q05\"] td:nth-child(-n+2){white-space:nowrap;overflow-wrap:normal}.allerin-rails-question .experiment-download{margin-top:28px;border-top:1px solid #d1d9d5;padding-top:18px;font-size:0.93em}<\/style>\n<div class=\"allerin-rails-question\" data-question=\"q06\">\n<nav aria-label=\"In this article\"><strong>In this article<\/strong><\/p>\n<ol>\n<li><a href=\"#carry-the-unit-with-the-limit\">Carry the unit with the limit<\/a><\/li>\n<li><a href=\"#be-deliberate-about-old-and-new-fields\">Be deliberate about old and new fields<\/a><\/li>\n<li><a href=\"#test-who-expands-the-catalog-rule\">Test who expands the catalog rule<\/a><\/li>\n<li><a href=\"#keep-the-proof-smaller-than-the-promise\">Keep the proof smaller than the promise<\/a><\/li>\n<\/ol>\n<\/nav>\n<p>An API can add a field without breaking a JSON parser and still expose a wrong application assumption. A Rails adapter that treats every product limit as US dollars may accept an amount against euro limits. Another adapter may keep sending yesterday&#8217;s product IDs even though the requested selection means today&#8217;s catalog.<\/p>\n<p>Two Tremendous documentation changes make those assumptions concrete. Its product changelog says each SKU now includes <code>currency_code<\/code>, identifying the currency of that SKU&#8217;s minimum and maximum. Its campaign changelog describes <code>ALL_FEE_FREE<\/code> as a selection expanded when the API call occurs. These were existing documented changes when checked on 20 September 2026, not predicted releases. <a href=\"https:\/\/developers.tremendous.com\/changelog\/product-skus-now-include-currency-code\" target=\"_blank\" rel=\"noopener\">Currency change<\/a>, <a href=\"https:\/\/developers.tremendous.com\/changelog\/campaign-products-accept-all_fee_free\" target=\"_blank\" rel=\"noopener\">catalog selection change<\/a><\/p>\n<h2 id=\"carry-the-unit-with-the-limit\">Carry the unit with the limit<\/h2>\n<p>The local experiment uses a synthetic SKU with euro limits of 10.25 through 100.00. Its unsafe adapter compares the numbers but assumes the local currency is USD. It accepts a USD amount of 20. That is the reproduced defect: a consumer&#8217;s default replaced information available in the response.<\/p>\n<p>The corrected adapter stores the SKU&#8217;s currency alongside its limits in Active Record. Eligibility requires matching currencies and an amount inside the inclusive range. The same USD request is rejected, while EUR 20 is accepted. Separate checks accept 10.25 and 100.00 and reject 10.24 and 100.01.<\/p>\n<p>The fixture defines its amount strings as major currency units and uses <code>BigDecimal<\/code> for the comparison. That is an explicit local contract, not a claim about the units of every Tremendous endpoint. Before connecting an adapter, verify the specific endpoint&#8217;s amount representation and rounding requirements. Neither a currency code nor a successful decimal comparison establishes an exchange rate. This example performs no currency conversion.<\/p>\n<h2 id=\"be-deliberate-about-old-and-new-fields\">Be deliberate about old and new fields<\/h2>\n<p>An extra descriptive property and an extra SKU flag do not prevent the corrected import. Missing <code>currency_code<\/code>, invalid numeric values and reversed limits do. A failed replacement leaves the previously imported SKU data available.<\/p>\n<p>This is a chosen consumer policy for a complete product snapshot. Some applications should retain a record as unavailable until its contract is resolved instead. What matters is that the application does not silently attach its default currency to an unknown amount. The provider&#8217;s additive field is not inherently a breaking change; the test exposes the particular interpretation that was wrong.<\/p>\n<p>The <a href=\"https:\/\/developers.tremendous.com\/reference\/list-products\" target=\"_blank\" rel=\"noopener\">product-list reference<\/a> separately documents currency filtering. A request filter and the units on a returned SKU serve different purposes. Keeping both assumptions explicit makes a saved fixture more useful than checking only that the response has an array of products.<\/p>\n<h2 id=\"test-who-expands-the-catalog-rule\">Test who expands the catalog rule<\/h2>\n<p>Tremendous documents <code>ALL_FEE_FREE<\/code> on campaign create\/update requests as expanding to concrete product IDs at call time. Products launched later are not automatically included by that selection alone. Its documentation identifies <code>auto_add_product_rule<\/code> for automatically adding new fee-free gift cards, and says the special selection value is not returned on read. Explicit product IDs may accompany the selection. <a href=\"https:\/\/developers.tremendous.com\/changelog\/campaign-products-accept-all_fee_free\" target=\"_blank\" rel=\"noopener\">Campaign behavior<\/a><\/p>\n<p>The second negative control caches one fee-free product locally. A second becomes available before campaign creation, but the unsafe adapter sends only its cached ID. The local fake partner therefore creates a campaign missing the new product.<\/p>\n<p>The corrected adapter sends the selection rule, then records the resolved IDs returned by a separate read. The test obtains both currently available products. After another product is added, reading the existing campaign still returns its original selection. Calling the update again expands the rule against the new catalog. Another test includes an explicit product with a fee and verifies that its ID is retained.<\/p>\n<div class=\"table-wrap\" tabindex=\"0\" role=\"region\" aria-label=\"Experiment comparison table\">\n<table>\n<thead>\n<tr>\n<th>Assumption under test<\/th>\n<th>Result in the local experiment<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Numeric limits always use USD<\/td>\n<td>Unsafe control accepts a mismatched currency<\/td>\n<\/tr>\n<tr>\n<td>Unknown optional fields must break import<\/td>\n<td>Corrected importer accepts the added fields<\/td>\n<\/tr>\n<tr>\n<td>A cached free-product list equals current selection<\/td>\n<td>Unsafe control misses a newly available product<\/td>\n<\/tr>\n<tr>\n<td>A selection rule stays dynamic after creation<\/td>\n<td>Existing campaign remains unchanged until updated<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<h2 id=\"keep-the-proof-smaller-than-the-promise\">Keep the proof smaller than the promise<\/h2>\n<p>Six tests passed with 31 assertions using Ruby 3.3.3, Active Record 8.1.3.1 and a temporary SQLite database. AI assistance was used to prepare and execute the fixture. Its partner is entirely local. No Tremendous account, API request, payout or customer data was involved.<\/p>\n<p>The fake implements only the catalog behavior described above. It does not implement automatic-add rules, provider authentication, pagination, caching headers or recovery after a remote write and failed local save. These results do not establish production interoperability or financial correctness.<\/p>\n<p>Use the <a href=\"#download-experiments\">fixture<\/a> and <a href=\"#download-experiments\">evidence record<\/a> to choose the cases your own adapter needs. For a contained integration change, <a href=\"https:\/\/www.allerin.com\/services\/ruby-on-rails\">Allerin&#8217;s Rails practice<\/a> can start the discussion from the interface, the failing assumption and the acceptance check that should replace it.<\/p>\n<section class=\"experiment-download\" aria-labelledby=\"download-experiments\">\n<h2 id=\"download-experiments\">Download the experiment<\/h2>\n<p><a href=\"https:\/\/www.allerin.com\/blog\/wp-content\/uploads\/2026\/09\/allerin-rails-synthetic-experiments-2026-09-20.zip\">Download the runnable Rails examples and evidence summary (ZIP, 36 KB)<\/a>. This article\u2019s example is in <code>q06\/<\/code>; the shared setup and reproduction instructions are in <code>README.md<\/code>. The package contains eight synthetic examples, checked on 20 September 2026. The recorded runtime, provider fakes and limits are documented inside.<\/p>\n<\/section>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Reproduce wrong-currency validation and stale catalog selection in a Rails adapter, with explicit provider contracts and a local fake.<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","_links_to":"","_links_to_target":""},"categories":[2037],"tags":[2077,2061,20],"class_list":["post-14923","post","type-post","status-publish","format-standard","hentry","category-ruby-on-rails","tag-rails-integrations","tag-rails-testing","tag-ruby-on-rails"],"_links":{"self":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14923","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/comments?post=14923"}],"version-history":[{"count":5,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14923\/revisions"}],"predecessor-version":[{"id":14966,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/posts\/14923\/revisions\/14966"}],"wp:attachment":[{"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/media?parent=14923"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/categories?post=14923"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.allerin.com\/blog\/wp-json\/wp\/v2\/tags?post=14923"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}