<?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: Jarrod Wright</title>
    <description>The latest articles on DEV Community by Jarrod Wright (@jw-chargebacks911).</description>
    <link>https://dev.to/jw-chargebacks911</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%2F4182483%2F32dd62d8-b8b7-4c3a-9076-8a56f527d12c.jpg</url>
      <title>DEV Community: Jarrod Wright</title>
      <link>https://dev.to/jw-chargebacks911</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jw-chargebacks911"/>
    <language>en</language>
    <item>
      <title>Detecting Abnormal Keyed-Entry Rates from POS Entry Mode Data</title>
      <dc:creator>Jarrod Wright</dc:creator>
      <pubDate>Wed, 14 Oct 2026 14:13:26 +0000</pubDate>
      <link>https://dev.to/chargebacks911/detecting-abnormal-keyed-entry-rates-from-pos-entry-mode-data-23a4</link>
      <guid>https://dev.to/chargebacks911/detecting-abnormal-keyed-entry-rates-from-pos-entry-mode-data-23a4</guid>
      <description>&lt;p&gt;Every card authorization carries a small field that records &lt;em&gt;how&lt;/em&gt; the card data reached the terminal: chip, contactless, magnetic stripe, fallback swipe, or typed in by hand. Most engineering teams never look at it. They should. A terminal or cashier that keys in far more sales than its peers usually points to a broken reader, a training gap, or someone deliberately bypassing the chip. Keyed sales also cost more and are harder to defend in disputes.&lt;/p&gt;

&lt;p&gt;This article covers building a keyed-rate monitor: normalizing entry mode data, separating keyed-by-design traffic, scoring terminals and employees without drowning in small-sample noise, and triaging alerts. For the business side of why keyed sales cost more and how to document them, see &lt;a href="https://chargebacks911.com/key-entered-transactions/" rel="noopener noreferrer"&gt;Key-Entered Transactions: Why Manually Keyed Transactions Cost More &amp;amp; How to Reduce Them&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the signal lives
&lt;/h2&gt;

&lt;p&gt;In ISO 8583-based messaging, the POS entry mode is carried in data element 22. Card networks define the values, and processors expose them in settlement files, reporting APIs, or webhook payloads, often renamed. Commonly seen values include:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;01&lt;/td&gt;
&lt;td&gt;Manual key entry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;keyed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;05&lt;/td&gt;
&lt;td&gt;Chip (contact EMV)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chip&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;07&lt;/td&gt;
&lt;td&gt;Contactless chip&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contactless&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;Chip failed, fell back to mag stripe&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fallback&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;Full mag stripe read&lt;/td&gt;
&lt;td&gt;&lt;code&gt;swipe&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;Contactless mag stripe mode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contactless&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exact codes vary by network and processor, so build a mapping table rather than hard-coding them. Your processor's spec is the source of truth.&lt;/p&gt;

&lt;p&gt;Two things matter for monitoring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keyed&lt;/strong&gt; is the outcome you're measuring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fallback&lt;/strong&gt; is the leading indicator. A chip reader that's dying usually produces fallback swipes before cashiers give up and start typing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: Normalize into a clean fact table
&lt;/h2&gt;

&lt;p&gt;Raw transaction feeds include refunds, voids, reversals, and declined attempts. Counting those inflates or distorts rates. Start with a view that keeps only approved sales and maps entry mode to a bucket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;sale_entry&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;txn_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;terminal_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;-- 'counter', 'moto', 'ecom'&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;card_fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- processor token, never the PAN&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;entry_bucket&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;entry_mode_map&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processor&lt;/span&gt;
 &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;raw_code&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entry_mode&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;txn_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sale'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'approved'&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;LEFT JOIN&lt;/code&gt; plus &lt;code&gt;'unknown'&lt;/code&gt; matters. When a processor adds a new code or a firmware update starts sending blanks, you want those rows visible, not silently dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Exclude keyed-by-design traffic
&lt;/h2&gt;

&lt;p&gt;Phone and mail orders are keyed every time. If a store's back-office terminal handles MOTO orders, it will dominate any keyed-rate ranking and hide the counter terminal that's actually broken.&lt;/p&gt;

&lt;p&gt;Filter on channel, not on terminal ID. Terminal assignments change; channel flags (if your POS sets them correctly) travel with the transaction. Track MOTO volume as its own metric.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;terminal_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                              &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;entry_bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'keyed'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;keyed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;entry_bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'fallback'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;entry_bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;unknown&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sale_entry&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'counter'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'14 days'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;terminal_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;employee_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roll this up separately by terminal and by employee. The two views answer different questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Score without fooling yourself
&lt;/h2&gt;

&lt;p&gt;The naive approach, ranking by &lt;code&gt;keyed / n&lt;/code&gt;, fails immediately. A terminal with 2 keyed sales out of 40 shows 5%, while a busy one with 40 out of 900 shows 4.4%. The first looks worse but is almost certainly noise.&lt;/p&gt;

&lt;p&gt;Use a confidence interval on the proportion and flag only when the &lt;strong&gt;lower bound&lt;/strong&gt; exceeds the peer baseline. The Wilson score interval behaves well at small n and near zero, which is exactly where keyed rates live.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;wilson_interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.96&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;n&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
    &lt;span class="n"&gt;denom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
    &lt;span class="n"&gt;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;denom&lt;/span&gt;
    &lt;span class="n"&gt;half&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;denom&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;center&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;half&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;center&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;half&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wilson_interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;baseline&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Worked example
&lt;/h3&gt;

&lt;p&gt;A store's counter terminals collectively key 1.5% of approved sales over 14 days. That's the baseline.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terminal A:&lt;/strong&gt; 40 keyed of 900. Raw rate 4.4%. Wilson 95% interval ≈ 3.3% to 6.0%. Lower bound 3.3% &amp;gt; 1.5%, so &lt;strong&gt;flag&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terminal B:&lt;/strong&gt; 2 keyed of 40. Raw rate 5.0%. Interval ≈ 1.4% to 16.5%. Lower bound 1.4% &amp;lt; 1.5%, so &lt;strong&gt;no flag yet&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Terminal B has the higher raw rate but not enough evidence. If it keeps keying at that pace, the interval tightens and it gets flagged within a week or two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing the baseline
&lt;/h3&gt;

&lt;p&gt;Compare against a peer group, not a global number. A grocery store, a hardware store, and a bar have different card mixes and different damaged-card rates. Reasonable options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Same store, other terminals (best for catching hardware faults)&lt;/li&gt;
&lt;li&gt;Same store format across the chain (best for catching a whole store's habits)&lt;/li&gt;
&lt;li&gt;The entity's own trailing 90 days (best for catching sudden change)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run more than one. A terminal that's normal for its store but the store is 3x the chain baseline is a different problem than one bad terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Triage with the terminal × employee matrix
&lt;/h2&gt;

&lt;p&gt;Once something is flagged, cross-reference both dimensions. The pattern usually tells you the cause before anyone visits the store.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;First action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One terminal high across all employees; fallback rose first&lt;/td&gt;
&lt;td&gt;Failing or dirty chip reader&lt;/td&gt;
&lt;td&gt;Swap or service the device&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One terminal high across all employees; fallback normal&lt;/td&gt;
&lt;td&gt;Reader disabled, misconfigured, or physically obstructed&lt;/td&gt;
&lt;td&gt;Check terminal config and setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One employee high across every terminal they use&lt;/td&gt;
&lt;td&gt;Training gap or habit of skipping the chip&lt;/td&gt;
&lt;td&gt;Retrain; review receipts and imprints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One employee high, keyed sales cluster on a few card fingerprints&lt;/td&gt;
&lt;td&gt;Possible misuse of stolen card numbers&lt;/td&gt;
&lt;td&gt;Escalate to loss prevention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole store high, all terminals and staff&lt;/td&gt;
&lt;td&gt;Store-level practice, or MOTO traffic tagged as counter&lt;/td&gt;
&lt;td&gt;Audit channel tagging, then store procedures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;unknown&lt;/code&gt; bucket spikes&lt;/td&gt;
&lt;td&gt;Processor code change or firmware bug&lt;/td&gt;
&lt;td&gt;Update &lt;code&gt;entry_mode_map&lt;/code&gt;; check release notes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fallback-then-keyed sequence is worth automating. A simple rule: alert when a terminal's fallback rate crosses its threshold, even if keyed is still normal. That catches hardware decay early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Add card-level checks for keyed sales
&lt;/h2&gt;

&lt;p&gt;Rates catch systemic problems. Some abuse only shows at the card level. Two cheap queries:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple keyed sales on one card, same store, same day.&lt;/strong&gt; This can indicate an improperly split purchase, which card network rules generally prohibit, or repeated use of a compromised number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;card_fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;keyed_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sale_entry&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;entry_bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'keyed'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'counter'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;card_fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Keyed sale on a card previously read by chip.&lt;/strong&gt; If the same fingerprint was dipped successfully last week and keyed today, the card is probably fine. Today's keyed entry needs an explanation.&lt;/p&gt;

&lt;p&gt;Both rely on a stable card fingerprint from your processor's tokenization. Never store or join on raw card numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Counting declines.&lt;/strong&gt; A cashier who tries the chip three times and gets three declines before keying produces four rows. Only count approved sales, and track failed attempts separately if you want reader-health data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixing channels.&lt;/strong&gt; If MOTO traffic is mislabeled as counter, your top offender is a phone-order desk. Validate channel tagging before trusting any ranking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alerting on day-level data.&lt;/strong&gt; Keyed sales are rare. Daily windows produce mostly noise. Use rolling 7 to 14 day windows with a minimum transaction count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring shared logins.&lt;/strong&gt; If employees share POS credentials, employee-level analysis is meaningless. Detecting a login used across simultaneous terminals is a useful data quality check on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treating every flag as fraud.&lt;/strong&gt; Most high keyed rates come from hardware and training. Design the alert to route to operations first and loss prevention only when card-level signals point that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Logging sensitive fields.&lt;/strong&gt; Keyed card-not-present flows often collect the card security code. PCI DSS prohibits storing it after authorization, including in debug logs, analytics events, or call recordings. Audit your pipeline so the monitor doesn't become a compliance problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the monitor
&lt;/h2&gt;

&lt;p&gt;Seed a staging dataset with known patterns: one terminal with elevated fallback followed by keyed, one employee keying across three terminals, a low-volume terminal with a high raw rate, and a batch of rows with an unmapped entry code. Assert that the first two flag, the third doesn't, and the fourth surfaces in the &lt;code&gt;unknown&lt;/code&gt; metric. Run it whenever you change thresholds, baselines, or the mapping table.&lt;/p&gt;

&lt;p&gt;The whole system is a view, a few aggregate queries, and a twenty-line scoring function. What makes it useful is the discipline around it: clean inputs, sensible peer groups, and a triage path that sends hardware problems to hardware people.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>data</category>
      <category>monitoring</category>
      <category>security</category>
    </item>
    <item>
      <title>Building a VAMP Ratio Monitor: Merchant and Portfolio Math in Practice</title>
      <dc:creator>Jarrod Wright</dc:creator>
      <pubDate>Tue, 13 Oct 2026 17:45:00 +0000</pubDate>
      <link>https://dev.to/chargebacks911/building-a-vamp-ratio-monitor-merchant-and-portfolio-math-in-practice-42o6</link>
      <guid>https://dev.to/chargebacks911/building-a-vamp-ratio-monitor-merchant-and-portfolio-math-in-practice-42o6</guid>
      <description>&lt;p&gt;The Visa Acquirer Monitoring Program (VAMP) ratio fits on a napkin: fraud reports plus disputes, divided by transactions. It is easy to get the formula right and the number wrong. A monitor that is off by a few basis points can show an acquirer as safe when it is not.&lt;/p&gt;

&lt;p&gt;This article walks through how to compute the ratio correctly. It covers the merchant-level calculation, the portfolio rollup, a what-if model for remediation, and the mistakes that most often corrupt the number. For the business context, including why acquirers now carry this risk and how little many merchants know about it, see &lt;a href="https://chargebacks911.com/vamp-acquirer-data/" rel="noopener noreferrer"&gt;The VAMP Awareness Gap: Financial Institutions Need to Know About Major Changes to Visa Disputes&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formula and what each term means
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VAMP ratio = (TC40 count + TC15 count) / settled transaction count
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TC40&lt;/strong&gt;: an issuer-reported fraud notification. It does not have to lead to a chargeback. The cardholder may say "that wasn't me" and the issuer files a TC40 even if no dispute follows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TC15&lt;/strong&gt;: a dispute (chargeback) notification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Denominator&lt;/strong&gt;: settled transactions for the period. VAMP focuses on card-not-present activity, so the denominator should match that scope. Check the current Visa rules for the exact definition rather than assuming.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key detail: &lt;strong&gt;one fraudulent transaction that is also charged back counts twice&lt;/strong&gt;. It produces one TC40 and one TC15. Nobody made a mistake. That is how the metric works, and a pipeline that deduplicates across the two types will understate the ratio.&lt;/p&gt;

&lt;h3&gt;
  
  
  Worked example: one merchant, one month
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;TC40&lt;/th&gt;
&lt;th&gt;TC15&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;40 fraud reports, no chargeback&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;60 fraud reports that became chargebacks&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50 non-fraud disputes (e.g., item not received)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;110&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With 20,000 settled CNP transactions, the ratio is 210 / 20,000 = &lt;strong&gt;1.05%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you count unique problem transactions instead (150), you get 0.75%. That is a 30-basis-point understatement, enough to hide a merchant that is drifting toward trouble.&lt;/p&gt;

&lt;h2&gt;
  
  
  The portfolio rollup
&lt;/h2&gt;

&lt;p&gt;Acquirers are measured on the whole portfolio, not on any single merchant. The portfolio ratio is a volume-weighted sum. It is &lt;strong&gt;not&lt;/strong&gt; the average of the merchant ratios.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;portfolio_ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tc40&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;tc15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settled_txns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Worked example: four merchants
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Merchant&lt;/th&gt;
&lt;th&gt;Settled txns&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;th&gt;TC40+TC15&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A (low-risk retail)&lt;/td&gt;
&lt;td&gt;2,000,000&lt;/td&gt;
&lt;td&gt;0.10%&lt;/td&gt;
&lt;td&gt;2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;500,000&lt;/td&gt;
&lt;td&gt;0.30%&lt;/td&gt;
&lt;td&gt;1,500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C (subscription)&lt;/td&gt;
&lt;td&gt;300,000&lt;/td&gt;
&lt;td&gt;1.20%&lt;/td&gt;
&lt;td&gt;3,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D (marketplace)&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;1.40%&lt;/td&gt;
&lt;td&gt;2,800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Portfolio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,000,000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.33%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9,900&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No merchant breaches the 1.5% merchant threshold. Yet the portfolio sits at 0.33%, above the 0.3% level that acquirer tiers can start at.&lt;/p&gt;

&lt;p&gt;Merchants C and D account for 16.7% of volume but 64.6% of the counts. This concentration is the normal pattern, and your tooling should surface it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data model and pipeline
&lt;/h2&gt;

&lt;p&gt;You need three feeds, each keyed to a merchant and a reporting period:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Settled transactions&lt;/strong&gt;: merchant ID, settlement date, card-present/CNP flag, network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TC40 records&lt;/strong&gt;: merchant ID, report date, ARN (acquirer reference number), fraud type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TC15 records&lt;/strong&gt;: merchant ID, dispute date, ARN, reason code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pipeline flows like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw feeds -&amp;gt; normalize merchant IDs -&amp;gt; dedupe within type -&amp;gt; bucket by period
          -&amp;gt; merchant_month aggregates -&amp;gt; portfolio_month aggregate -&amp;gt; alerts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Dedupe within a type, never across types
&lt;/h3&gt;

&lt;p&gt;Network files sometimes resend the same record. Deduplicate TC40s against other TC40s, and TC15s against other TC15s, using the ARN plus a type-specific key. A TC40 and a TC15 that share an ARN are both legitimate counts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;tc40&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fraud_report_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;report_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_tc40&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;tc15&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dispute_case_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dispute_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;raw_tc15&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;excluded_from_vamp&lt;/span&gt;   &lt;span class="c1"&gt;-- flag set per current Visa rules&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tc40&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;tc15&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&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="n"&gt;txns&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settle_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;settled&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;settlements&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;network&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'VISA'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CNP'&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;vamp_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;vamp_ratio&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;txns&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merchant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;excluded_from_vamp&lt;/code&gt; flag. Some dispute resolution paths may be treated differently under current rules. Put that logic in one place and keep it configurable. Do not hardcode it into the query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modeling remediation: marginal impact
&lt;/h2&gt;

&lt;p&gt;The most useful question a risk team can ask is: "If we fix or exit merchant X, where does the portfolio land?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;portfolio_without&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merchants&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exclude_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;merchants&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&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;exclude_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vamp_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;txns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;settled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;txns&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ranked_impact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merchants&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="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vamp_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;merchants&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;settled&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;merchants&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;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;portfolio_without&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;merchants&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;merchants&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;Applied to the example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removing &lt;strong&gt;D&lt;/strong&gt; moves the portfolio from 0.33% to 7,100 / 2,800,000 = &lt;strong&gt;0.254%&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Removing &lt;strong&gt;C&lt;/strong&gt; gives 6,300 / 2,700,000 = &lt;strong&gt;0.233%&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Removing merchant A would actually &lt;em&gt;raise&lt;/em&gt; the ratio, because its clean volume dilutes everyone else's counts. Rank merchants by marginal impact, not by raw ratio. A small merchant with a terrible ratio may matter less than a mid-size one that is merely bad.&lt;/p&gt;

&lt;p&gt;A more realistic model reduces a merchant's counts by an expected remediation rate instead of deleting the merchant. The same function works with adjusted counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Internal alert tiers
&lt;/h2&gt;

&lt;p&gt;Visa's thresholds are a ceiling. Internal tiers give you time to act before you reach it. Keep every number in config, because the network adjusts thresholds over time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;merchant_tiers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ratio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.0075&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;min_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ratio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.0100&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;min_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;300&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;critical&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;   &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ratio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.0130&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;min_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;750&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;visa_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ratio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.0150&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;min_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;1500&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;portfolio_tiers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ratio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.0025&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;     &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;ratio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;0.0030&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Typical response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Watch&lt;/td&gt;
&lt;td&gt;Ratio and count over watch values, 1 month&lt;/td&gt;
&lt;td&gt;Notify merchant, expose ratio in dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;Over action values, or watch for 2+ months&lt;/td&gt;
&lt;td&gt;Remediation plan, increased reserve or pricing review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Critical&lt;/td&gt;
&lt;td&gt;Over critical values&lt;/td&gt;
&lt;td&gt;Weekly review, volume caps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visa limit&lt;/td&gt;
&lt;td&gt;Over network threshold&lt;/td&gt;
&lt;td&gt;Formal program handling; consider exit if no progress&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;min_count&lt;/code&gt; gate matters. Without it, a merchant with 50 transactions and 2 disputes trips a 4% alarm every month and buries real signals. Visa applies a similar count minimum at the merchant level, so a ratio-only alert does not match how the program works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Projecting mid-month
&lt;/h2&gt;

&lt;p&gt;Waiting for month-end means alerting after the damage is done. Project forward instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;projected_ratio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count_mtd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settled_mtd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days_in_month&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag_factor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# lag_factor &amp;gt; 1 compensates for TC40/TC15 that arrive after the transaction
&lt;/span&gt;    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count_mtd&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;lag_factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;settled_mtd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fraud reports and disputes trail the original transaction, often by weeks. Early in the month the counts look artificially low. Calibrate &lt;code&gt;lag_factor&lt;/code&gt; per merchant category from your own history: compare day-N counts to the final monthly count across past periods.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes and how to catch them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Deduping across TC40 and TC15.&lt;/strong&gt; Write a test fixture with a single fraud chargeback and assert that it counts as 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Wrong denominator.&lt;/strong&gt; Including card-present transactions, other networks, or authorizations instead of settlements all inflate the denominator and deflate the ratio. Reconcile your settled-transaction count against acquirer settlement reports every month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Averaging ratios.&lt;/strong&gt; &lt;code&gt;AVG(vamp_ratio)&lt;/code&gt; across merchants is meaningless at portfolio level. Add a test where one large clean merchant and one small dirty merchant produce different weighted and unweighted results, and assert you return the weighted one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Merchant ID fragmentation.&lt;/strong&gt; One business with several MIDs can look healthy across each MID individually. Maintain a merchant-entity mapping and compute ratios at both levels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Period misalignment.&lt;/strong&gt; Counts are bucketed by report date, while transactions are bucketed by settlement date. That mismatch is inherent to the metric, but your periods must use the same timezone and cutoff as the network's reporting. Off-by-one-day bucketing at month boundaries is a frequent source of discrepancies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Silent feed gaps.&lt;/strong&gt; A missing TC40 file produces a beautiful ratio. Alert on volume anomalies in the inputs, such as zero records for a merchant that normally has dozens, before trusting the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Fraud chargeback counts as 2&lt;/li&gt;
&lt;li&gt;Duplicate TC40 resent in a later file counts as 1&lt;/li&gt;
&lt;li&gt;Card-present transactions are excluded from the denominator&lt;/li&gt;
&lt;li&gt;Portfolio ratio equals the summed numerator over the summed denominator&lt;/li&gt;
&lt;li&gt;Merchants below &lt;code&gt;min_count&lt;/code&gt; never raise merchant-tier alerts&lt;/li&gt;
&lt;li&gt;Excluded dispute paths are removed only via the config flag&lt;/li&gt;
&lt;li&gt;Reconciliation diff against the network's monthly figures stays within tolerance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The formula is trivial. The value is in matching the network's definitions exactly and getting the number early enough to act on it.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>fintech</category>
      <category>security</category>
    </item>
    <item>
      <title>Designing Account Holds for Chargebacks</title>
      <dc:creator>Jarrod Wright</dc:creator>
      <pubDate>Tue, 13 Oct 2026 15:09:57 +0000</pubDate>
      <link>https://dev.to/chargebacks911/playstation-chargebacks-14m0</link>
      <guid>https://dev.to/chargebacks911/playstation-chargebacks-14m0</guid>
      <description>&lt;h2&gt;
  
  
  A State Machine for Digital Goods
&lt;/h2&gt;

&lt;p&gt;When a bank reverses a payment for a digital purchase, your system has to decide what happens to the account that made it. Most platforms treat that as a binary choice: do nothing, or lock the account. Sony's handling on PlayStation Network is a well-known example of the second option. A reversed PlayStation Store charge suspends the account, the player sees error WS-37368-7 at sign-in, and access comes back once the debt is repaid. The policy, and how players have reacted to it, is covered in &lt;a href="https://chargebacks911.com/playstation-chargebacks/" rel="noopener noreferrer"&gt;PlayStation Chargebacks: Sony's Chargeback Policy &amp;amp; the Hard Choices Behind the Ruleset&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The interesting engineering problem is the one that article raises near the end. A single dispute event can come from three very different customers. If your system treats all three the same way, you either leak revenue or punish fraud victims. This post walks through modeling that difference in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three customers behind one webhook
&lt;/h2&gt;

&lt;p&gt;Your payment processor sends you the same event in every case (Stripe, for example, emits &lt;code&gt;charge.dispute.created&lt;/code&gt;). Behind it is one of these people:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The friendly fraudster.&lt;/strong&gt; They made the purchase, used it, and disputed it anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The account takeover victim.&lt;/strong&gt; Someone else got into the account and spent on the stored card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The false positive.&lt;/strong&gt; The issuer's fraud system flagged a legitimate order without the cardholder asking.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only the first should end in a debt hold. The second needs account recovery. The third needs the order defended and the customer left alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model account access as a state machine
&lt;/h2&gt;

&lt;p&gt;Instead of a boolean &lt;code&gt;is_banned&lt;/code&gt;, give accounts explicit states with defined transitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ACTIVE        --dispute received------------------&amp;gt; UNDER_REVIEW
UNDER_REVIEW  --takeover signals------------------&amp;gt; RECOVERY
UNDER_REVIEW  --issuer-initiated, low risk--------&amp;gt; ACTIVE   (defend the charge, no customer impact)
UNDER_REVIEW  --friendly fraud signals------------&amp;gt; DEBT_HOLD
DEBT_HOLD     --balance repaid--------------------&amp;gt; ACTIVE
DEBT_HOLD     --dispute resolved for merchant-----&amp;gt; ACTIVE
RECOVERY      --credentials reset and verified----&amp;gt; ACTIVE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design choice is making &lt;code&gt;UNDER_REVIEW&lt;/code&gt; a real state with its own permissions. A reasonable default: the customer can sign in and use content they already own, but can't make new purchases or move stored value until the review finishes. That limits further exposure without locking a possible victim out of their library on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classifying the dispute
&lt;/h2&gt;

&lt;p&gt;The routing decision out of &lt;code&gt;UNDER_REVIEW&lt;/code&gt; comes from signals you almost certainly already collect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signals that point to account takeover&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Password, email, or 2FA changed shortly before the disputed purchases&lt;/li&gt;
&lt;li&gt;A new device or new IP region at the time of purchase&lt;/li&gt;
&lt;li&gt;A purchase pattern unlike the account's history, such as bulk wallet top-ups or items that resell easily&lt;/li&gt;
&lt;li&gt;The customer contacted support about unauthorized activity before or alongside the dispute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Signals that point to friendly fraud&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The purchase came from the account's established device and location&lt;/li&gt;
&lt;li&gt;The content was downloaded and used after purchase (playtime, consumption logs)&lt;/li&gt;
&lt;li&gt;Prior disputes on the same account or card&lt;/li&gt;
&lt;li&gt;The customer never contacted support first&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Signals that point to an issuer false positive&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A fraud reason code with no customer contact on your side&lt;/li&gt;
&lt;li&gt;A long, clean account history and normal purchase behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason code helps but doesn't decide the outcome. Fraud-category codes like Visa 10.4 and Mastercard 4837 cover both real takeovers and friendly fraud, so consumption and device evidence carry most of the weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal implementation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route_dispute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dispute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signals&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;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recent_credential_change&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;new_device_at_purchase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RECOVERY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content_consumed&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;known_device&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;signals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;support_contact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DEBT_HOLD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prior_disputes&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;age_days&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ACTIVE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;        &lt;span class="c1"&gt;# defend the charge, leave the customer alone
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNDER_REVIEW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;# ambiguous: send to a human
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the thresholds in config, log every transition along with the signals that drove it, and send ambiguous cases to a person. A wrong &lt;code&gt;DEBT_HOLD&lt;/code&gt; means a fraud victim locked out of games they paid for, which is exactly the outcome that generates angry forum threads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases worth handling
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recurring billing during a hold.&lt;/strong&gt; Sony doesn't bill suspended accounts, so a PlayStation Plus renewal simply lapses. Decide explicitly whether subscriptions pause, lapse, or keep billing, and tell the customer which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Family and child accounts.&lt;/strong&gt; A parent disputing a child's purchase is common. Check for linked accounts and spending limits before you classify anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dispute outcome events.&lt;/strong&gt; When a dispute is won or lost, your processor sends another event. Wire it to your transitions so holds lift automatically instead of waiting on a support ticket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Communication.&lt;/strong&gt; Show a specific message and a clear path back (pay the balance, verify identity, or contact support) instead of a bare error code.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;An account lockout changes what a customer stands to lose. It doesn't recover the money, and it doesn't prevent the next dispute. Treat the lockout as one possible output of a classification step rather than the default response to every chargeback, and your system can protect revenue without punishing the people fraud already hurt.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>stripe</category>
      <category>fintech</category>
      <category>banking</category>
    </item>
  </channel>
</rss>
