<?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: Chargebacks911</title>
    <description>The latest articles on DEV Community by Chargebacks911 (chargebacks911).</description>
    <link>https://dev.to/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%2Forganization%2Fprofile_image%2F15281%2F63e7d5e5-8a3c-4bd1-8fad-d0a3a89dc15b.jpg</url>
      <title>DEV Community: Chargebacks911</title>
      <link>https://dev.to/chargebacks911</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chargebacks911"/>
    <language>en</language>
    <item>
      <title>Finding Fraud Rings with Link Analysis: A Union-Find Approach</title>
      <dc:creator>Jarrod Wright</dc:creator>
      <pubDate>Wed, 28 Oct 2026 18:56:35 +0000</pubDate>
      <link>https://dev.to/chargebacks911/finding-fraud-rings-with-link-analysis-a-union-find-approach-1li8</link>
      <guid>https://dev.to/chargebacks911/finding-fraud-rings-with-link-analysis-a-union-find-approach-1li8</guid>
      <description>&lt;p&gt;Most fraud checks evaluate one order at a time: is this card valid, does the AVS match, does the device look risky? That works when the attacker is using stolen credentials. It breaks down when the credentials are real, such as when a fraudster operates through accounts that belong to cooperating or recruited people. Every individual record looks clean.&lt;/p&gt;

&lt;p&gt;What a single operator cannot easily hide is reuse. They ship to the same drop address, check out from the same laptop, or cycle the same handful of payment instruments across "unrelated" accounts. Those connections only show up when you stop looking at rows and start looking at relationships. For the full set of order-, account-, and portfolio-level warning signs in this kind of scheme, see the overview of &lt;a href="https://chargebacks911.com/ecommerce-fraud/second-party-fraud/second-party-fraud-red-flags/" rel="noopener noreferrer"&gt;second-party fraud red flags&lt;/a&gt;. This article focuses on one of them: building the link-analysis layer that surfaces shared-infrastructure clusters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model: accounts and identifiers as a graph
&lt;/h2&gt;

&lt;p&gt;Model your customer base as a bipartite graph:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Account nodes:&lt;/strong&gt; one per customer account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identifier nodes:&lt;/strong&gt; one per distinct device fingerprint, normalized shipping address, payment instrument fingerprint, phone number, or email.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edges:&lt;/strong&gt; an account is connected to every identifier it has used.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two accounts are "linked" if a path exists between them through shared identifiers. A connected component in this graph is a candidate cluster.&lt;/p&gt;

&lt;p&gt;You don't need a graph database to start. A table of &lt;code&gt;(account_id, identifier_type, identifier_value, first_seen, last_seen)&lt;/code&gt; is enough, and a union-find structure can compute components over millions of edges on a single machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Normalize identifiers before you link anything
&lt;/h2&gt;

&lt;p&gt;Graph quality depends almost entirely on identifier normalization. Two strings that refer to the same thing must collapse to the same node, and two strings that don't must stay separate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Identifier&lt;/th&gt;
&lt;th&gt;Normalization&lt;/th&gt;
&lt;th&gt;Pitfall&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shipping address&lt;/td&gt;
&lt;td&gt;Canonicalize via an address standardization service, keep unit/apartment numbers&lt;/td&gt;
&lt;td&gt;Dropping the unit merges every resident of an apartment building&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;Lowercase; for providers that ignore dots or &lt;code&gt;+tags&lt;/code&gt;, strip them&lt;/td&gt;
&lt;td&gt;Applying Gmail rules to every domain creates false merges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;Convert to E.164&lt;/td&gt;
&lt;td&gt;Shared household landlines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment instrument&lt;/td&gt;
&lt;td&gt;Use the processor-supplied card fingerprint or token, never the raw PAN&lt;/td&gt;
&lt;td&gt;Network tokens can differ per wallet for the same underlying card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Device&lt;/td&gt;
&lt;td&gt;Use a stable fingerprint ID, not raw user-agent strings&lt;/td&gt;
&lt;td&gt;Popular device models with default settings produce near-identical fingerprints&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Using a processor fingerprint for cards keeps raw card numbers out of your fraud pipeline entirely, which also keeps that system out of a large chunk of PCI DSS scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Handle supernodes before they swallow your graph
&lt;/h2&gt;

&lt;p&gt;This is the failure mode that kills most first attempts. Some identifiers are legitimately shared by thousands of accounts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IP addresses behind carrier-grade NAT or corporate proxies&lt;/li&gt;
&lt;li&gt;Office mailrooms, university dorms, and package lockers&lt;/li&gt;
&lt;li&gt;Freight forwarders (which are both a red flag &lt;em&gt;and&lt;/em&gt; a supernode)&lt;/li&gt;
&lt;li&gt;Your own warehouse address used for in-store pickup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you union through any of these, you get one giant component containing most of your customers, and the analysis becomes useless.&lt;/p&gt;

&lt;p&gt;Two standard defenses:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Degree caps per identifier type.&lt;/strong&gt; Count how many distinct accounts touch each identifier. Skip identifiers above a type-specific threshold when building components. An address with 4 accounts is interesting; one with 4,000 is infrastructure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allow/deny lists.&lt;/strong&gt; Explicitly exclude known infrastructure (your stores, known lockers). Track known reshippers separately: don't use them to link accounts, but do flag any account that ships to them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;IPs are usually too noisy to link on at all. Use them as cluster &lt;em&gt;features&lt;/em&gt;, not edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Compute components with union-find
&lt;/h2&gt;

&lt;p&gt;Union-find (disjoint set union) is a good fit: it processes edges in a single pass, with path compression and union by rank giving near-constant amortized time per operation.&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;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DSU&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&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="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&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="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# path compression
&lt;/span&gt;            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&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="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&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;ra&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ra&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rb&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

&lt;span class="n"&gt;DEGREE_CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;device&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;card&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_clusters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
    &lt;span class="c1"&gt;# edges: iterable of (account_id, id_type, id_value)
&lt;/span&gt;    &lt;span class="n"&gt;edges&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;degree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&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;acct&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;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;degree&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;v&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;dsu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DSU&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;acct&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;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;dsu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# register isolated accounts
&lt;/span&gt;        &lt;span class="n"&gt;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;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&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;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;excluded&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;DEGREE_CAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;degree&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DEGREE_CAP&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="k"&gt;continue&lt;/span&gt;                           &lt;span class="c1"&gt;# supernode: skip as a link
&lt;/span&gt;        &lt;span class="n"&gt;dsu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;union&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;clusters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&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;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&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;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;clusters&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dsu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;clusters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The degree caps here are placeholders. Set them from your own data by looking at the degree distribution for each identifier type and choosing a cutoff above the household-sharing range.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked example
&lt;/h2&gt;

&lt;p&gt;Six accounts, with these edges:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Account&lt;/th&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;Ship-to&lt;/th&gt;
&lt;th&gt;Card&lt;/th&gt;
&lt;th&gt;IP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A1&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;S1&lt;/td&gt;
&lt;td&gt;C1&lt;/td&gt;
&lt;td&gt;carrier NAT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A2&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;S2&lt;/td&gt;
&lt;td&gt;C2&lt;/td&gt;
&lt;td&gt;carrier NAT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A3&lt;/td&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;C3&lt;/td&gt;
&lt;td&gt;home ISP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A4&lt;/td&gt;
&lt;td&gt;D9&lt;/td&gt;
&lt;td&gt;S3&lt;/td&gt;
&lt;td&gt;C4&lt;/td&gt;
&lt;td&gt;home ISP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A5&lt;/td&gt;
&lt;td&gt;D5&lt;/td&gt;
&lt;td&gt;S5&lt;/td&gt;
&lt;td&gt;C5&lt;/td&gt;
&lt;td&gt;carrier NAT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A6&lt;/td&gt;
&lt;td&gt;D6&lt;/td&gt;
&lt;td&gt;S6&lt;/td&gt;
&lt;td&gt;C6&lt;/td&gt;
&lt;td&gt;carrier NAT&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Walking through it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A1, A2, A3 share device D1, so they merge.&lt;/li&gt;
&lt;li&gt;A3 and A4 share address S3, which pulls A4 into the same component, even though A4 never touched D1. This transitive link is exactly what row-level review misses.&lt;/li&gt;
&lt;li&gt;A5 and A6 share only the carrier NAT IP, which isn't an edge type at all. They stay separate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: one cluster &lt;code&gt;{A1, A2, A3, A4}&lt;/code&gt;. Four accounts with four different cards and four different names, connected by one laptop and one drop address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Score clusters, not just membership
&lt;/h2&gt;

&lt;p&gt;A component is a candidate, not a verdict. Families share devices. Roommates share addresses. Compute features per cluster and score them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Benign pattern&lt;/th&gt;
&lt;th&gt;Suspicious pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Distinct surnames / billing names&lt;/td&gt;
&lt;td&gt;1 to 2&lt;/td&gt;
&lt;td&gt;Many, unrelated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distinct billing addresses&lt;/td&gt;
&lt;td&gt;1 to 2&lt;/td&gt;
&lt;td&gt;Many, geographically spread&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared ship-to vs. billing&lt;/td&gt;
&lt;td&gt;Ship-to matches someone's billing&lt;/td&gt;
&lt;td&gt;Shared ship-to matches no one's billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account creation/reactivation spread&lt;/td&gt;
&lt;td&gt;Months or years&lt;/td&gt;
&lt;td&gt;Narrow window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Promo redemptions per account&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Every account redeemed a new-customer offer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disputes in cluster&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Any fraud dispute on any member&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Link type mix&lt;/td&gt;
&lt;td&gt;Address only&lt;/td&gt;
&lt;td&gt;Device and address and card&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row matters. A cluster held together by a single shared address is weak evidence. A cluster where accounts share devices &lt;em&gt;and&lt;/em&gt; addresses &lt;em&gt;and&lt;/em&gt; cards is much harder to explain innocently.&lt;/p&gt;

&lt;p&gt;A dispute on any member is also a strong propagation signal. When one account in a cluster files a fraud claim, review the open orders of the others before they ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Add time to the edges
&lt;/h2&gt;

&lt;p&gt;Identifier reuse that's years apart means something different from reuse within the same week. Store &lt;code&gt;first_seen&lt;/code&gt; and &lt;code&gt;last_seen&lt;/code&gt; on each edge and either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;build components only from edges active within a rolling window (say, 90 days), or&lt;/li&gt;
&lt;li&gt;weight edges by recency and compute clusters with a weighted method instead of plain union-find.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without this, an address that changed hands two tenants ago will keep linking strangers forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch vs. real-time
&lt;/h2&gt;

&lt;p&gt;Union-find supports incremental unions but not deletions. A practical split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nightly batch:&lt;/strong&gt; rebuild components from scratch with current degree caps and time windows. This handles expiring edges cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At checkout:&lt;/strong&gt; look up the incoming order's device, address, and card in the precomputed cluster index. If any of them belongs to a flagged cluster, add that to the order's risk score.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need fully real-time linking, a graph database or a streaming connected-components job is the next step, but most teams get substantial value from the batch-plus-lookup pattern first.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One giant component.&lt;/strong&gt; Check the size of your largest cluster after every run. If it suddenly jumps, a new supernode slipped under the cap. Log the top identifiers by degree each run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linking on IP.&lt;/strong&gt; Almost always too noisy. Use it as a feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-normalizing addresses.&lt;/strong&gt; Spot-check clusters linked only by address; if they're all apartment buildings, your unit parsing is broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-declining cluster members.&lt;/strong&gt; Feed cluster risk into scoring or step-up verification, not hard blocks. Many cluster members will be legitimate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never measuring outcomes.&lt;/strong&gt; Track dispute rates for flagged vs. unflagged clusters over time. If flagged clusters don't dispute at a higher rate, your features or thresholds are wrong.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;You rarely have clean labels, so seed your own. Inject synthetic rings into a copy of production data: N accounts sharing a device and a drop address, with varied names and cards. Then verify that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;each synthetic ring comes back as exactly one cluster,&lt;/li&gt;
&lt;li&gt;it doesn't merge with real customers through a supernode, and&lt;/li&gt;
&lt;li&gt;its cluster score lands above your review threshold.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also inject benign patterns, like a family of four on one device with one shared address, and confirm they score low. Both test sets belong in CI, so a change to normalization or degree caps can't silently break detection.&lt;/p&gt;

&lt;p&gt;Link analysis won't catch a lone actor using a single borrowed account. What it does catch is scale, which is where most of the losses are. Once reuse becomes visible as structure, it's very hard for an operator to hide.&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>data</category>
      <category>security</category>
    </item>
    <item>
      <title>Modeling PayPal's Tiered Dispute Fee With a Trailing-Window Monitor</title>
      <dc:creator>Jarrod Wright</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:55:51 +0000</pubDate>
      <link>https://dev.to/chargebacks911/modeling-paypals-tiered-dispute-fee-with-a-trailing-window-monitor-207a</link>
      <guid>https://dev.to/chargebacks911/modeling-paypals-tiered-dispute-fee-with-a-trailing-window-monitor-207a</guid>
      <description>&lt;p&gt;Most fee logic in a payments integration is stateless. You take a transaction amount, apply a percentage and a fixed component, and you're done. PayPal's US dispute fee doesn't work that way. The fee charged on a dispute depends on your account's recent history, not on the dispute itself.&lt;/p&gt;

&lt;p&gt;As of the fee schedule cited in this &lt;a href="https://chargebacks911.com/paypal-statistics/" rel="noopener noreferrer"&gt;roundup of PayPal statistics on volume, fees, and fraud&lt;/a&gt;, disputes on PayPal-account and Guest Checkout payments cost &lt;strong&gt;$15&lt;/strong&gt; by default and &lt;strong&gt;$30&lt;/strong&gt; once your dispute rate reaches &lt;strong&gt;1.5% or more&lt;/strong&gt; across &lt;strong&gt;more than 100 sales&lt;/strong&gt; in the previous three full months. Card chargebacks that don't go through a PayPal account or Guest Checkout are handled separately, with a flat $20 fee.&lt;/p&gt;

&lt;p&gt;This article treats that rule as an engineering problem. The goal is a small service that computes your current tier, forecasts next month's tier, and tells you how much headroom you have before the fee doubles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this deserves code instead of a spreadsheet
&lt;/h2&gt;

&lt;p&gt;A tiered fee driven by a rolling window has three properties that make it easy to get wrong by hand:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lag.&lt;/strong&gt; A bad month doesn't hurt you immediately. It hurts you for the next three months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A volume gate.&lt;/strong&gt; Below 101 sales in the window, the rate doesn't matter at all. Cross that line and a rate you've been running for months suddenly counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard threshold.&lt;/strong&gt; At 1.5% exactly, you're in the higher tier. One dispute can flip it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you already ingest dispute events, through webhooks such as &lt;code&gt;CUSTOMER.DISPUTE.CREATED&lt;/code&gt; or by polling the Disputes API, you have everything you need to compute this yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining the window precisely
&lt;/h2&gt;

&lt;p&gt;"Previous three full months" means calendar months that have fully closed. It doesn't mean the last 90 days. For a date in March, the window is December 1 through the end of February.&lt;/p&gt;

&lt;p&gt;That has a consequence that's easy to miss. A spike in January sits in the window for February, March, and April:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Billing month&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Includes January?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;February&lt;/td&gt;
&lt;td&gt;Nov–Jan&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;March&lt;/td&gt;
&lt;td&gt;Dec–Feb&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;April&lt;/td&gt;
&lt;td&gt;Jan–Mar&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;May&lt;/td&gt;
&lt;td&gt;Feb–Apr&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One bad month can mean a full quarter of doubled dispute fees. Your monitor should surface that horizon explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data you need
&lt;/h2&gt;

&lt;p&gt;Per account, per calendar month:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sales_count&lt;/code&gt;: completed sales that count toward the denominator.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dispute_count&lt;/code&gt;: disputes that count toward the numerator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two open questions you must answer against PayPal's current terms before trusting your numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attribution.&lt;/strong&gt; Is a dispute counted in the month it was &lt;em&gt;opened&lt;/em&gt;, or in the month of the &lt;em&gt;original sale&lt;/em&gt;? These give different results when disputes arrive weeks after purchase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope.&lt;/strong&gt; Which dispute stages count (inquiries, claims, chargebacks), and which payment types count as "sales"?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't hardcode either answer. Make them configuration, because the definition can change and you'll want to recompute history when it does.&lt;/p&gt;

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

&lt;p&gt;Here's a minimal Python version. It uses integer arithmetic for the threshold comparison so floating-point rounding can't put you on the wrong side of 1.5%.&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;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ceil&lt;/span&gt;

&lt;span class="c1"&gt;# Configurable: verify against the current PayPal fee page.
&lt;/span&gt;&lt;span class="n"&gt;THRESHOLD_BPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;          &lt;span class="c1"&gt;# 1.5% expressed in basis points
&lt;/span&gt;&lt;span class="n"&gt;MIN_SALES_EXCLUSIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;    &lt;span class="c1"&gt;# "more than 100" =&amp;gt; 101+
&lt;/span&gt;&lt;span class="n"&gt;STANDARD_FEE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15_00&lt;/span&gt;         &lt;span class="c1"&gt;# cents
&lt;/span&gt;&lt;span class="n"&gt;HIGH_FEE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30_00&lt;/span&gt;             &lt;span class="c1"&gt;# cents
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MonthStats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;disputes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;window_months&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;billing_month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Three full calendar months preceding billing_month.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;billing_month&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;billing_month&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&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;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&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="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&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;12&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;y&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tier_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;billing_month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;MonthStats&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;months&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;window_months&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;billing_month&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sales&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;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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="nc"&gt;MonthStats&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="n"&gt;sales&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;months&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;disputes&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;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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="nc"&gt;MonthStats&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="n"&gt;disputes&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;months&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;gated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MIN_SALES_EXCLUSIVE&lt;/span&gt;
    &lt;span class="c1"&gt;# disputes / sales &amp;gt;= 1.5%  &amp;lt;=&amp;gt;  disputes * 10000 &amp;gt;= 150 * sales
&lt;/span&gt;    &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gated&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;disputes&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;THRESHOLD_BPS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;

    &lt;span class="c1"&gt;# Largest dispute count that stays below the threshold for this volume
&lt;/span&gt;    &lt;span class="n"&gt;max_safe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;THRESHOLD_BPS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;gated&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;window&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;months&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sales&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sales&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disputes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;disputes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&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;over&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;standard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fee_cents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;HIGH_FEE&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;over&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;STANDARD_FEE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;headroom&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_safe&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;disputes&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;max_safe&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&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 that &lt;code&gt;ceil&lt;/code&gt; of a float can misbehave at exact boundaries. For production, compute &lt;code&gt;max_safe&lt;/code&gt; with integer math: &lt;code&gt;(THRESHOLD_BPS * sales + 9999) // 10000 - 1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked example
&lt;/h2&gt;

&lt;p&gt;It's March. Your window is December through February:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month&lt;/th&gt;
&lt;th&gt;Sales&lt;/th&gt;
&lt;th&gt;Disputes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;December&lt;/td&gt;
&lt;td&gt;950&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;January&lt;/td&gt;
&lt;td&gt;780&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;February&lt;/td&gt;
&lt;td&gt;670&lt;/td&gt;
&lt;td&gt;8&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;2,400&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rate is 34 / 2,400 = 1.417%. You're in the standard tier.&lt;/p&gt;

&lt;p&gt;Now the headroom: 1.5% of 2,400 is exactly 36, and 36 disputes would &lt;em&gt;meet&lt;/em&gt; the threshold. So the maximum safe count is 35, and your headroom is &lt;strong&gt;one dispute&lt;/strong&gt;. If a single late dispute gets attributed back into February, March flips to $30 per dispute.&lt;/p&gt;

&lt;p&gt;That's the number worth alerting on. Rate alone (1.42%, comfortably under 1.5%) gives a false sense of safety.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forecasting April
&lt;/h3&gt;

&lt;p&gt;April's window drops December and adds March. December had a relatively low rate (1.26%) on high volume. If March comes in at 700 sales and 15 disputes, April's window is 2,150 sales and 37 disputes, which is 1.72%. You'd enter the high tier in April even though no single month changed dramatically.&lt;/p&gt;

&lt;p&gt;Your monitor should compute the &lt;em&gt;next&lt;/em&gt; window using month-to-date figures and a projection for the rest of the current month.&lt;/p&gt;

&lt;h2&gt;
  
  
  The volume-gate trap
&lt;/h2&gt;

&lt;p&gt;Small merchants have a specific failure mode. Consider a window with 98 sales and 6 disputes, a 6.1% rate. That's standard tier, because sales don't exceed 100.&lt;/p&gt;

&lt;p&gt;Grow to 101 sales with the same disputes and you're instantly at 5.9% and in the high tier. Growth itself triggered the fee increase.&lt;/p&gt;

&lt;p&gt;If you're near the gate, track two alerts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Projected window sales crossing 100.&lt;/li&gt;
&lt;li&gt;Projected rate at that point exceeding 1.5%.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Decision table for alerts
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Window sales&lt;/th&gt;
&lt;th&gt;Rate&lt;/th&gt;
&lt;th&gt;Headroom&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≤ 100&lt;/td&gt;
&lt;td&gt;any&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Watch projected sales vs. gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;gt; 100&lt;/td&gt;
&lt;td&gt;&amp;lt; 1.0%&lt;/td&gt;
&lt;td&gt;large&lt;/td&gt;
&lt;td&gt;No action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;gt; 100&lt;/td&gt;
&lt;td&gt;1.0–1.5%&lt;/td&gt;
&lt;td&gt;≤ 3 disputes&lt;/td&gt;
&lt;td&gt;Warn: one bad week flips tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;gt; 100&lt;/td&gt;
&lt;td&gt;≥ 1.5%&lt;/td&gt;
&lt;td&gt;negative&lt;/td&gt;
&lt;td&gt;Alert: compute months until the spike rolls out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the last row, find the earliest billing month where removing the oldest months brings the rate under 1.5%, assuming projected volume. That tells finance how long elevated fees will last.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Using a rolling 90 days.&lt;/strong&gt; It produces a rate that's close but not identical, and it disagrees with the actual fee most at month boundaries. Unit-test &lt;code&gt;window_months&lt;/code&gt; around January to catch year wraparound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Float comparison at the threshold.&lt;/strong&gt; &lt;code&gt;0.015 * 2400&lt;/code&gt; is not guaranteed to equal &lt;code&gt;36.0&lt;/code&gt; exactly. Compare integers, as above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Off-by-one on "more than 100."&lt;/strong&gt; Write a test that asserts 100 sales is ungated and 101 is gated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixing card chargebacks into the count.&lt;/strong&gt; Card payments outside PayPal accounts and Guest Checkout carry their own $20 chargeback fee. Whether they count toward this dispute rate is a definitional question. Keep them in a separate column so you can include or exclude them by config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Double-counting escalations.&lt;/strong&gt; A single case can move from inquiry to claim, or get reopened. Key your dispute table on the dispute ID and dedupe on it. Don't count event rows, because one dispute can emit several webhooks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dropping late webhooks.&lt;/strong&gt; Webhooks retry and can arrive out of order. Write disputes idempotently, then recompute affected windows when a dispute lands in an already-closed month. Run a nightly reconciliation against the Disputes API to catch anything the webhook path missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ignoring sandbox and test traffic.&lt;/strong&gt; Filter by environment before aggregation, or test sales will inflate your denominator and hide a real problem.&lt;/p&gt;

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

&lt;p&gt;A few property-style tests go a long way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For any &lt;code&gt;sales &amp;gt; 100&lt;/code&gt;, &lt;code&gt;tier == "high"&lt;/code&gt; exactly when &lt;code&gt;disputes &amp;gt;= ceil(0.015 * sales)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Adding sales without disputes never moves you from standard to high, unless it crosses the 100 gate.&lt;/li&gt;
&lt;li&gt;Shifting all data forward by one month shifts the tier output by exactly one month.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run these against generated data, then backtest against your actual fee statements. If your computed tier disagrees with what PayPal charged in any month, your attribution or scope assumptions are wrong. Fix the configuration before trusting the forecasts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The tiered dispute fee is a small line item per case, but it's a stateful, lagging, threshold-based rule. Rules like that punish anyone who only looks at the current month. A few dozen lines of code give you three things a monthly statement won't: current tier, headroom in absolute disputes, and how many months a bad stretch will cost you.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>fintech</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Don't Refund on 'Delivered': Hardening Return Workflows Against FTID Fraud</title>
      <dc:creator>Jarrod Wright</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:29:33 +0000</pubDate>
      <link>https://dev.to/chargebacks911/dont-refund-on-delivered-hardening-return-workflows-against-ftid-fraud-145o</link>
      <guid>https://dev.to/chargebacks911/dont-refund-on-delivered-hardening-return-workflows-against-ftid-fraud-145o</guid>
      <description>&lt;p&gt;Many e-commerce backends ship with a return flow like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Don't do this&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/carrier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;evt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELIVERED&lt;/span&gt;&lt;span class="dl"&gt;'&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;rma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;returns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByTracking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;It is fast and cheap to run, and customers like it. It also contains a clear security bug. It treats a carrier status as proof that your merchandise came back. Fraudsters exploit this with &lt;strong&gt;FTID (fake tracking ID) attacks&lt;/strong&gt;. They take the prepaid return label you issued, keep the barcode intact, and edit the visible address or move part of the label onto a decoy package. The carrier scans the barcode and moves &lt;em&gt;something&lt;/em&gt; to &lt;em&gt;somewhere&lt;/em&gt;. Your webhook fires &lt;code&gt;DELIVERED&lt;/code&gt;, and your system pays out.&lt;/p&gt;

&lt;p&gt;For the wider fraud landscape (label editing, account address switching, courier impersonation), see &lt;a href="https://chargebacks911.com/package-redirection-scam/" rel="noopener noreferrer"&gt;this overview of package redirection scams and tracking red flags&lt;/a&gt;. This article stays narrow. It covers how to engineer the refund trigger so a single upstream event cannot release money.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a 'delivered' event actually asserts
&lt;/h2&gt;

&lt;p&gt;A delivery scan tells you very little. In practice it means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A barcode encoding your tracking number was scanned.&lt;/li&gt;
&lt;li&gt;The scanning party recorded a delivery event at some location and time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; assert:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That the package contents match the RMA.&lt;/li&gt;
&lt;li&gt;That the physical address on the box matched your returns facility.&lt;/li&gt;
&lt;li&gt;That the package passed through your receiving dock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Model the carrier as an untrusted, eventually consistent data source. Its events are &lt;em&gt;hints&lt;/em&gt; that move a return forward in your workflow. They should never authorize a payment by themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signals you can actually check
&lt;/h2&gt;

&lt;p&gt;The carrier event is useful once you cross-check it against data you control. Depending on the carrier and your integration, these fields may be available:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delivery scan location (city/postal code)&lt;/td&gt;
&lt;td&gt;Carrier tracking API scan events&lt;/td&gt;
&lt;td&gt;Label edited to send the package to a different destination&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scan path / origin postal code&lt;/td&gt;
&lt;td&gt;Carrier scan history&lt;/td&gt;
&lt;td&gt;Label reused from a different origin than the customer's address&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Carrier-reported or billed weight&lt;/td&gt;
&lt;td&gt;Carrier API or invoice data&lt;/td&gt;
&lt;td&gt;Empty envelope or junk shipped as a decoy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service type / package type&lt;/td&gt;
&lt;td&gt;Carrier metadata&lt;/td&gt;
&lt;td&gt;Envelope or letter service on an RMA for a boxed item&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your own dock intake scan&lt;/td&gt;
&lt;td&gt;Your WMS&lt;/td&gt;
&lt;td&gt;'Delivered' events that never physically arrived at your facility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intake weight and SKU scan&lt;/td&gt;
&lt;td&gt;Your WMS&lt;/td&gt;
&lt;td&gt;Weight and content mismatch, item swap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time between label issue and first scan&lt;/td&gt;
&lt;td&gt;Your DB + carrier&lt;/td&gt;
&lt;td&gt;Labels scanned implausibly fast or from an unusual region&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two rows are the only ones fully under your control. They are also the strongest signals. Everything above them is supporting evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model returns as a state machine
&lt;/h2&gt;

&lt;p&gt;Do not keep a boolean &lt;code&gt;refunded&lt;/code&gt; flag. Make the states explicit and limit which actors can trigger each transition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REQUESTED -&amp;gt; LABEL_ISSUED -&amp;gt; IN_TRANSIT -&amp;gt; CARRIER_DELIVERED
                                              |
                                              v
                              RECEIVED_AT_DOCK -&amp;gt; INSPECTED -&amp;gt; REFUNDED
                                              |               
                                              +--&amp;gt; HELD_FOR_REVIEW -&amp;gt; REFUNDED | REJECTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key rule is that &lt;strong&gt;carrier webhooks can only advance the return to &lt;code&gt;CARRIER_DELIVERED&lt;/code&gt;.&lt;/strong&gt; Only an internal event can move it to &lt;code&gt;RECEIVED_AT_DOCK&lt;/code&gt;: a WMS scan, a staff action, or an automated scale. The refund transition requires &lt;code&gt;INSPECTED&lt;/code&gt; or an explicit review decision.&lt;/p&gt;

&lt;p&gt;If the business needs instant refunds for low-risk customers, implement them as a policy that operates on a &lt;em&gt;risk score&lt;/em&gt;. Do not bypass the state machine for them.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  1. Webhook handler: record, don't act
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/carrier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;evt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalizeCarrierEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// map carrier-specific codes&lt;/span&gt;

  &lt;span class="c1"&gt;// Idempotency: carriers retry, and events arrive out of order&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;carrierEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;evt&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inserted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;return.carrier_event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;evt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trackingNumber&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;The handler stores the event and enqueues a job. It never calls the payments service. This also keeps the handler fast, so the carrier does not time out and retry.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Evaluator: compare against the RMA
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Finding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;evaluateDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Return&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CarrierEvent&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nx"&gt;Finding&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;findings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Finding&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delivered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;latestByTimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELIVERED&lt;/span&gt;&lt;span class="dl"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;delivered&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Destination check&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delivered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postalCode&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;facility&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postalCodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delivered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DEST_MISMATCH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Origin check: first scan should be near the customer&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;earliestByTimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;events&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="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;postalCode&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isNearby&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customerPostalCode&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ORIGIN_UNUSUAL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Weight check, if the carrier reports one&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toGrams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delivered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;weight&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="nx"&gt;reported&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reported&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expectedShippedWeightGrams&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="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WEIGHT_LOW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WEIGHT_SOFT_LOW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Service type: envelope for a boxed item&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delivered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packageType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ENVELOPE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expectedPackageType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BOX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PACKAGE_TYPE_MISMATCH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;block&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;findings&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;The thresholds are placeholders. Calibrate them against your own historical returns (see below).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Intake: the authoritative check
&lt;/h3&gt;

&lt;p&gt;At the dock, a scan of the return label should pull up the RMA and log the scale weight automatically. Requiring manual weight entry produces bad data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onDockIntake&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scaleGrams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;scannedSkus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;rma&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;returns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByTracking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RECEIVED_AT_DOCK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;scaleGrams&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;skuOk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sameMultiset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scannedSkus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skus&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;weightOk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;within&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scaleGrams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expectedShippedWeightGrams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.15&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="nx"&gt;skuOk&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;weightOk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSPECTED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rma&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HELD_FOR_REVIEW&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;skuOk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;weightOk&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;h2&gt;
  
  
  Decision table for the refund trigger
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Carrier findings&lt;/th&gt;
&lt;th&gt;Dock intake&lt;/th&gt;
&lt;th&gt;Customer risk&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Not yet&lt;/td&gt;
&lt;td&gt;Low, established history&lt;/td&gt;
&lt;td&gt;Optional provisional refund, with clawback flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Not yet&lt;/td&gt;
&lt;td&gt;New or elevated&lt;/td&gt;
&lt;td&gt;Wait for intake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any &lt;code&gt;warn&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not yet&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;Wait for intake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any &lt;code&gt;block&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not yet&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;Hold, alert fraud queue, do not refund&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;Passed (SKU + weight)&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;Refund&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;Failed&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;Hold for manual review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;'Delivered' but no intake after N business days&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;Investigate: search the dock, open a carrier trace&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row matters. A &lt;code&gt;DELIVERED&lt;/code&gt; event with no matching dock scan is a strong FTID indicator on its own. It means the carrier claims delivery to you and your building has no record of the package.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worked example
&lt;/h2&gt;

&lt;p&gt;A customer returns headphones. The RMA expects about 900 g shipped weight from postal code 30301, delivered to your facility at 75201.&lt;/p&gt;

&lt;p&gt;Events received:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;PICKED_UP&lt;/code&gt; at 30301. Weight is reported as 0.12 lb.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;IN_TRANSIT&lt;/code&gt; through a regional hub.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DELIVERED&lt;/code&gt; at postal code 75204.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Evaluator output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DEST_MISMATCH&lt;/code&gt; (block): 75204 is not one of your facility codes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WEIGHT_LOW&lt;/code&gt; (block): 0.12 lb is about 54 g, roughly 6% of the expected weight.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The naive handler would have refunded at step 3. With the evaluator, the return goes to &lt;code&gt;HELD_FOR_REVIEW&lt;/code&gt;, the fraud queue gets the tracking history, and no dock intake arrives. You have evidence ready if the customer escalates or files a dispute.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Mixing units.&lt;/strong&gt; Carriers report weight in pounds, ounces, or kilograms, sometimes depending on the service. Normalize at ingestion. A unit bug can mark every legitimate return as an empty envelope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparing against item weight instead of shipped weight.&lt;/strong&gt; Packaging adds mass. Store an expected &lt;em&gt;shipped&lt;/em&gt; weight per SKU or per outbound shipment. Your outbound label data is often the best source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Using billed weight as actual weight.&lt;/strong&gt; Dimensional weight can be much larger than the real mass. Treat carrier weight as a soft signal unless you know which field you are reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting event order.&lt;/strong&gt; Webhooks arrive late, duplicated, or out of order. Sort by the carrier's event timestamp and dedupe on event ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refunding inside the webhook transaction.&lt;/strong&gt; If the refund call succeeds and the DB write fails, the retry can refund twice. Use an outbox pattern and an idempotency key on the payment request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard-coding a single facility postal code.&lt;/strong&gt; Large facilities may span several codes, and you may add 3PL locations later. Make the list configurable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Build a set of fixture event streams and run them through the evaluator in CI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clean return: all signals match.&lt;/li&gt;
&lt;li&gt;Edited destination: the delivery postal code differs.&lt;/li&gt;
&lt;li&gt;Decoy envelope: correct destination with a very low weight.&lt;/li&gt;
&lt;li&gt;Out-of-order duplicates: &lt;code&gt;DELIVERED&lt;/code&gt; arrives before &lt;code&gt;IN_TRANSIT&lt;/code&gt;, twice.&lt;/li&gt;
&lt;li&gt;Delivered, never received: no intake event after the timeout.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Assert both the findings and the resulting state. Then replay several months of your real historical returns through the evaluator in shadow mode. Use that run to tune thresholds before any of them can block a refund. The aim is to catch decoys without holding refunds for every customer who used a heavier box.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>security</category>
    </item>
    <item>
      <title>Detecting Mastercard 2-Series Cards: BIN Range Logic That Doesn't Break</title>
      <dc:creator>Jarrod Wright</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:19:40 +0000</pubDate>
      <link>https://dev.to/chargebacks911/detecting-mastercard-2-series-cards-bin-range-logic-that-doesnt-break-1ejl</link>
      <guid>https://dev.to/chargebacks911/detecting-mastercard-2-series-cards-bin-range-logic-that-doesnt-break-1ejl</guid>
      <description>&lt;h2&gt;
  
  
  The bug that never shows up in your logs
&lt;/h2&gt;

&lt;p&gt;Most card-brand detection code is old. Someone wrote &lt;code&gt;if (/^5[1-5]/.test(pan)) return 'mastercard'&lt;/code&gt; years ago and it shipped. It still works for most cards. It also rejects a valid class of Mastercard numbers, and you will probably never see an error for it.&lt;/p&gt;

&lt;p&gt;Since 2017, Mastercard has issued cards in the &lt;strong&gt;222100–272099&lt;/strong&gt; range in addition to the classic &lt;strong&gt;51–55&lt;/strong&gt; prefixes. If your checkout form, gateway plugin, fraud rules, or analytics pipeline classifies brands by leading digits, it needs to handle both ranges. For the business background, including the official range, the 2017 acceptance assessments, and an end-to-end test plan, see &lt;a href="https://chargebacks911.com/mastercard-2-series-bins/" rel="noopener noreferrer"&gt;Mastercard 2-Series BINs: The Official Range, What the Fines Were &amp;amp; How to Test Your Checkout&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This article covers one narrow piece: &lt;strong&gt;the classification logic itself.&lt;/strong&gt; It walks through how to write it, how it typically goes wrong, and how to test it so it can't regress.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this failure is silent
&lt;/h2&gt;

&lt;p&gt;Trace the data flow for a card that starts with &lt;code&gt;2223&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user types the number into the card field.&lt;/li&gt;
&lt;li&gt;Client-side code runs Luhn, then brand detection.&lt;/li&gt;
&lt;li&gt;Brand detection returns &lt;code&gt;unknown&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The form shows "Invalid card number" and disables submit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing is sent to the server.&lt;/strong&gt; There is no authorization request and no decline code. The processor dashboard shows nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same pattern repeats further down the stack. A fraud rule that treats &lt;code&gt;unknown&lt;/code&gt; brand as high risk blocks the order. An analytics job that buckets cards by first digit puts these transactions in an "other" category. Each layer fails locally and reports nothing upstream.&lt;/p&gt;

&lt;p&gt;That is why the fix has two parts: correct classification, and instrumentation on rejection paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use numeric ranges, not regex
&lt;/h2&gt;

&lt;p&gt;The 2-series range is a contiguous numeric interval. Regex is a poor fit for intervals, which is how most bugs get in. Compare the leading digits as an integer against explicit bounds instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Brand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;amex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PrefixRange&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Brand&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// how many leading digits to compare&lt;/span&gt;
  &lt;span class="nl"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// inclusive&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Ordered most-specific first. Extend for the brands you accept.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RANGES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PrefixRange&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2221&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2720&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&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="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;   &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;amex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="na"&gt;length&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="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt;   &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;amex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="na"&gt;length&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="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;37&lt;/span&gt;   &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="na"&gt;length&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="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectBrand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawPan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Brand&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;pan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawPan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\s&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&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="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;for &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;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;RANGES&lt;/span&gt;&lt;span class="p"&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="nx"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&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;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&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 the 4-digit bounds. A 6-digit range of &lt;code&gt;222100–272099&lt;/code&gt; is equivalent to a 4-digit range of &lt;code&gt;2221–2720&lt;/code&gt;, because every 6-digit value from &lt;code&gt;272000&lt;/code&gt; to &lt;code&gt;272099&lt;/code&gt; starts with &lt;code&gt;2720&lt;/code&gt;. Using four digits means the brand resolves earlier while the user is typing, so the card icon appears sooner.&lt;/p&gt;

&lt;p&gt;The range table is data, not code. You can load it from config, version it, and review changes in a diff without parsing a regex in your head.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you must use regex
&lt;/h3&gt;

&lt;p&gt;Some form libraries only accept a pattern. The correct regex for 2221–2720 is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^(222[1-9]|22[3-9]\d|2[3-6]\d{2}|27[01]\d|2720)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with the classic range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^(5[1-5]|222[1-9]|22[3-9]\d|2[3-6]\d{2}|27[01]\d|2720)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is easy to get one alternation wrong here. That is the main argument for range comparison.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;How to catch it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;^5[1-5]&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;All 2-series cards rejected&lt;/td&gt;
&lt;td&gt;Boundary test at 2221 and 2720&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;^2[2-7]&lt;/code&gt; (too broad)&lt;/td&gt;
&lt;td&gt;Non-Mastercard numbers starting 22xx/27xx labeled Mastercard&lt;/td&gt;
&lt;td&gt;Negative tests at 2220 and 2721&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;^2[2-7][0-9]{2}&lt;/code&gt; with an off-by-one&lt;/td&gt;
&lt;td&gt;Either 2221 or 2720 misclassified&lt;/td&gt;
&lt;td&gt;Explicit min/max boundary cases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lexicographic string compare (&lt;code&gt;pan &amp;gt;= '2221'&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'3'&lt;/code&gt; &amp;gt; &lt;code&gt;'2720'&lt;/code&gt; is true, so unrelated prefixes match&lt;/td&gt;
&lt;td&gt;Compare fixed-length numeric slices only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brand derived from first digit in analytics&lt;/td&gt;
&lt;td&gt;2-series counted as "other" or "airline"&lt;/td&gt;
&lt;td&gt;Reconcile brand counts against processor reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;unknown&lt;/code&gt; brand mapped to decline in fraud rules&lt;/td&gt;
&lt;td&gt;Valid cards blocked post-submit&lt;/td&gt;
&lt;td&gt;Rule review plus a 2-series test transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BIN column stored as 6 digits&lt;/td&gt;
&lt;td&gt;Lookups break after 8-digit BIN adoption&lt;/td&gt;
&lt;td&gt;Schema check: BIN fields should hold 8 digits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The too-broad case matters. The prefix &lt;code&gt;22&lt;/code&gt; is not Mastercard's alone. Other networks have been allocated ranges that start with &lt;code&gt;2&lt;/code&gt;. If your code labels anything starting with &lt;code&gt;22&lt;/code&gt; as Mastercard, you will send the wrong brand to downstream systems. Match the exact interval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't forget the 8-digit BIN change
&lt;/h2&gt;

&lt;p&gt;The ISO/IEC 7812-1 standard extended the issuer identification number from six digits to eight, and card networks adopted it in 2022. Brand detection still works on the shorter 2- or 4-digit prefixes above. &lt;strong&gt;BIN lookup tables&lt;/strong&gt; are a different matter. A table keyed on 6 digits can still resolve a brand, but it may return the wrong issuer, product, or country for an 8-digit range.&lt;/p&gt;

&lt;p&gt;Check these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Database columns and API fields that store a "BIN": are they at least 8 characters?&lt;/li&gt;
&lt;li&gt;Lookup code: does it try 8 digits first, then fall back to 6?&lt;/li&gt;
&lt;li&gt;Data retention: can you legally store 8 leading digits? PCI DSS guidance on truncation changed alongside 8-digit BINs. Confirm the current rules with your QSA before you widen a stored field.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Testing: generate boundary numbers instead of hardcoding them
&lt;/h2&gt;

&lt;p&gt;Processor sandboxes publish 2-series test cards. Stripe's, for example, is &lt;code&gt;2223003122003222&lt;/code&gt;. Use them for integration tests against that processor. For unit tests on your own classifier, generate Luhn-valid numbers at each boundary so the suite covers the edges directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;luhnCheckDigit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sum&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="c1"&gt;// Walk right-to-left; the check digit will be appended, so&lt;/span&gt;
  &lt;span class="c1"&gt;// the rightmost digit of `partial` is doubled.&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&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="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makePan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&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="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;luhnCheckDigit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;detectBrand: Mastercard 2-series boundaries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2220&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;     &lt;span class="c1"&gt;// just below range&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2221&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;// lower bound&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2500&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;// interior&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2720&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;// upper bound&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2721&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;     &lt;span class="c1"&gt;// just above range&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;50&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;51&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;55&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;56&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prefix %s -&amp;gt; %s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;pan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makePan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isLuhnValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// sanity check&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;detectBrand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;known sandbox card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;detectBrand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2223 0031 2200 3222&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastercard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&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;A &lt;code&gt;'unknown'&lt;/code&gt; expectation only holds if no other range in your table covers that prefix. If you add brands, update the negative cases.&lt;/p&gt;

&lt;p&gt;For broader coverage, add a property-based test. Generate random integers in &lt;code&gt;[2221, 2720]&lt;/code&gt;, build Luhn-valid PANs, and assert they always classify as Mastercard. Then do the same just outside the bounds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test every layer that classifies
&lt;/h3&gt;

&lt;p&gt;Unit tests only cover the function you wrote. List every component that reads leading digits and give each one its own check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Client form or hosted fields:&lt;/strong&gt; run the boundary cases in an end-to-end browser test that types each number and asserts submit is enabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway plugin:&lt;/strong&gt; if it does its own validation, test it separately. It may not call your function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side validation:&lt;/strong&gt; the same boundary suite, run against the API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fraud and routing rules:&lt;/strong&gt; feed a 2-series test PAN through rule evaluation and assert it isn't routed to &lt;code&gt;unknown&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics and reporting:&lt;/strong&gt; assert that brand derivation in your ETL produces &lt;code&gt;mastercard&lt;/code&gt; for a 2-series fixture.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole path still needs a real transaction in production. Sandbox and unit tests can't prove that your acquirer configuration accepts the card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instrument the rejection path
&lt;/h2&gt;

&lt;p&gt;Because client-side rejections never reach a server, add a lightweight event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isLuhnValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;card_brand_unrecognized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;prefix4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// never send the full PAN&lt;/span&gt;
    &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&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;A Luhn-valid number with an unrecognized brand is a strong signal that your range table is incomplete. Alert when this event's rate rises. Review your data-handling policy before logging any part of a PAN, and keep the prefix as short as is useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Brand detection uses explicit numeric ranges, including 2221–2720&lt;/li&gt;
&lt;li&gt;[ ] Boundary tests cover 2220, 2221, 2720, 2721, 50, 51, 55, 56&lt;/li&gt;
&lt;li&gt;[ ] Gateway plugins and hosted fields tested independently&lt;/li&gt;
&lt;li&gt;[ ] Fraud rules don't treat &lt;code&gt;unknown&lt;/code&gt; brand as an automatic decline without review&lt;/li&gt;
&lt;li&gt;[ ] BIN storage and lookup support 8 digits&lt;/li&gt;
&lt;li&gt;[ ] Client-side rejections of Luhn-valid numbers are tracked&lt;/li&gt;
&lt;li&gt;[ ] One live 2-series transaction has cleared through the full stack&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>programming</category>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
    </item>
    <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>
