<?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: Ismail Iusifli</title>
    <description>The latest articles on DEV Community by Ismail Iusifli (@alt-f6).</description>
    <link>https://dev.to/alt-f6</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4202480%2F1dd9ca3f-0d47-400b-a86f-a0b20e358525.jpg</url>
      <title>DEV Community: Ismail Iusifli</title>
      <link>https://dev.to/alt-f6</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alt-f6"/>
    <language>en</language>
    <item>
      <title>Every step was correct. The student still got refunded twice.</title>
      <dc:creator>Ismail Iusifli</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:32:32 +0000</pubDate>
      <link>https://dev.to/alt-f6/every-step-was-correct-the-student-still-got-refunded-twice-3bjg</link>
      <guid>https://dev.to/alt-f6/every-step-was-correct-the-student-still-got-refunded-twice-3bjg</guid>
      <description>&lt;p&gt;&lt;em&gt;How a randomized invariant test found a billing bug none of my example tests could, and why the fix needed a lock.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;An admin cancels a lesson, so the student's charge is reversed. Later a teacher tidies up the attendance journal and clears the mark, and the charge is reversed again. The student ends up credited for a lesson they never paid for.&lt;/p&gt;

&lt;p&gt;Each of those three operations was correct on its own, and every example test I'd written passed. A test that threw random operations at a real database found the bug on its first run. Here's how.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I'm the sole engineer of the platform behind an online school with 300+ active students a month. Teachers and admins mark attendance, and every billable mark charges the student for the lesson. Lessons get re-marked, cleared and cancelled, often by different people at the same time.&lt;/p&gt;

&lt;p&gt;I pulled the money-handling core out into a public repo, &lt;a href="https://github.com/alt-f6/edtech-billing-core" rel="noopener noreferrer"&gt;edtech-billing-core&lt;/a&gt;, rewritten on plain PostgreSQL so every lock and constraint is visible. Two design rules matter for this story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ledger is append-only.&lt;/strong&gt; A database trigger rejects &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; on ledger rows. You never edit a charge: you append a reversal, then a new charge if needed. For each student and lesson there's a &lt;em&gt;net&lt;/em&gt;, the sum of its rows, and it should always be either 0 or minus the lesson's price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writes can't form a lock cycle.&lt;/strong&gt; Marks and clears lock the student row (&lt;code&gt;FOR NO KEY UPDATE&lt;/code&gt;), then the lesson row (&lt;code&gt;FOR SHARE&lt;/code&gt;). A cancellation locks only the lesson (&lt;code&gt;FOR UPDATE&lt;/code&gt;). When it writes reversals, the foreign-key checks take &lt;code&gt;KEY SHARE&lt;/code&gt; on each student, and &lt;code&gt;NO KEY UPDATE&lt;/code&gt; doesn't block that, so there's no cycle and no deadlock. In practice: two writes for the same student run one after the other, marks on the same lesson coexist, and a cancellation waits for marks already in flight.&lt;/p&gt;

&lt;p&gt;With those two rules in place, I had example tests for every operation: mark, re-mark, clear, cancel. They all passed. Then I wrote a different kind of test.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test that doesn't know the answer
&lt;/h2&gt;

&lt;p&gt;Example tests check outcomes you already thought of. The bug I was worried about was the one I hadn't thought of, so I wrote a test that throws random operations at the ledger and checks rules that must hold &lt;em&gt;no matter what happened&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The rules are invariants, each written as a SQL query that returns the rows breaking it. An empty result means the rule holds:&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="c1"&gt;-- "a cancelled lesson nets to zero for every student"&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;student_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ledger_transaction&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;class_session&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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;class_session_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_cancelled&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;student_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;class_session_id&lt;/span&gt;
&lt;span class="k"&gt;HAVING&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;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="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are eight of them, for example: a lesson's reversals never exceed its charges, a free lesson never nets to a charge, each provider payment is credited at most once.&lt;/p&gt;

&lt;p&gt;The test itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;creates 4 students and 10 lessons with random prices, some of them free, and puts one student on a billing freeze;&lt;/li&gt;
&lt;li&gt;runs 200 random operations for each of 8 seeds: marks, clears, cancellations, payments, and sometimes a redelivered payment to exercise idempotency;&lt;/li&gt;
&lt;li&gt;runs them &lt;strong&gt;five at a time in parallel&lt;/strong&gt;, against a real PostgreSQL;&lt;/li&gt;
&lt;li&gt;checks all eight invariants after every batch, and that the ledger never shrinks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The randomness is seeded, so a failure replays exactly:&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;prng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;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;return &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="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1664525&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1013904223&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;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;return&lt;/span&gt; &lt;span class="nx"&gt;s&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;32&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;One more rule keeps the test honest: the only error it accepts is touching a lesson that's already cancelled. A lost race or a deadlock fails the test, and a guard at the end checks that at least 100 operations really succeeded, so the test can't pass by refusing everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  It failed on the first run
&lt;/h2&gt;

&lt;p&gt;The invariant that broke was &lt;em&gt;"a cancelled lesson nets to zero for every student"&lt;/em&gt;. Replaying the seed gave this sequence. Here it is as ledger rows for one student and one lesson priced at 500:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Row appended&lt;/th&gt;
&lt;th&gt;Net&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Student marked present&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LESSON_CHARGE&lt;/code&gt; −500&lt;/td&gt;
&lt;td&gt;−500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lesson cancelled&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CHARGE_REVERSAL&lt;/code&gt; +500&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attendance cleared&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CHARGE_REVERSAL&lt;/code&gt; +500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+500&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The net should be 0. Instead the student was credited 500 for a lesson they were never charged for.&lt;/p&gt;

&lt;p&gt;Each step on its own was correct. Marking charges. Cancelling reverses outstanding charges. Clearing reverses the lesson's charge. The bug only exists in that one order.&lt;/p&gt;

&lt;p&gt;Here's the part that surprised me: &lt;strong&gt;this isn't a race.&lt;/strong&gt; The three steps reproduce one call at a time, in order. What found it was the randomness, not the parallelism: random sequences hit orders I'd never write by hand. The parallel part of the test is there for the other class of bug, the real races, and as the next section shows, the fix for this one created exactly such a race.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and why it needs a lock
&lt;/h2&gt;

&lt;p&gt;The obvious fix is a rule: clearing refuses a cancelled lesson, because the cancellation already reversed the charge.&lt;/p&gt;

&lt;p&gt;But the rule alone opens a real race. If the clear checks &lt;code&gt;is_cancelled&lt;/code&gt;, then a cancellation commits, then the clear writes its reversal, the double refund is back. So the check has to run under the lesson's share lock, the lock a cancellation must wait for:&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;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;clearAttendance&lt;/span&gt;&lt;span class="p"&gt;(&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;Db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;classSessionId&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;studentId&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;inTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&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;tx&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="c1"&gt;// FOR NO KEY UPDATE on the student&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lockStudent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;studentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// FOR SHARE on the lesson&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lockSessionShared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;classSessionId&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;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;is_cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BillingRuleError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cannot clear attendance for a cancelled lesson&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;span class="c1"&gt;// ... reverse the charge&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;While the clear holds &lt;code&gt;FOR SHARE&lt;/code&gt; on the lesson, a cancellation, which needs &lt;code&gt;FOR UPDATE&lt;/code&gt;, waits. When the clear commits, the cancellation reads the nets after its lock wait, finds nothing outstanding, and writes nothing. If the cancellation gets there first, the clear sees &lt;code&gt;is_cancelled&lt;/code&gt; and refuses. Either way, the net stays at zero.&lt;/p&gt;

&lt;p&gt;Both cases are now permanent tests: the plain sequence, and the race. The race test fails if the share lock is removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just SERIALIZABLE?
&lt;/h2&gt;

&lt;p&gt;It's the first question people ask, so two answers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It wouldn't have caught this bug.&lt;/strong&gt; The double refund happens with the steps run one after another, which is a perfectly serializable history. Isolation levels protect you from bad interleavings, not from wrong rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I measured it.&lt;/strong&gt; My production code wraps billing in &lt;code&gt;SERIALIZABLE&lt;/code&gt; and also takes &lt;code&gt;FOR UPDATE&lt;/code&gt; on the student, and the first version of this extraction copied that. A concurrency probe during code review showed roughly 9 in 10 concurrent writes aborting with serialization failures, and &lt;code&gt;FOR UPDATE&lt;/code&gt; deadlocking against a cancellation's foreign-key checks. You can retry serialization failures, but when most writes fail under ordinary contention, retries become the workload. With one explicit lock order under &lt;code&gt;READ COMMITTED&lt;/code&gt;, every concurrent call in the burst tests succeeds. That one deserves its own post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Example tests check what you thought of.&lt;/strong&gt; I had tests for clearing and tests for cancelling. I didn't have one for clearing &lt;em&gt;after&lt;/em&gt; cancelling, because I hadn't thought of it. A randomized test doesn't need you to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write invariants, not expected values.&lt;/strong&gt; "The balance should be 500" only holds for a case you designed. "A cancelled lesson nets to zero" holds after &lt;em&gt;any&lt;/em&gt; sequence, which is what makes it useful against sequences you didn't design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test on the real database.&lt;/strong&gt; The guarantees here live in locks, constraints and a trigger. A mocked database would have passed this test forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make failures replayable.&lt;/strong&gt; A random test that fails once and never again is noise. With a seed, the failure became a three-step story I could read, fix, and turn into two permanent tests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check that your tests can fail.&lt;/strong&gt; The repo has a mutation script that removes each defence in turn (either lock, the cancelled-lesson guard, the idempotency key and more) and reruns the suite. All 12 mutants are killed, so I know the tests aren't passing by accident.&lt;/p&gt;

&lt;p&gt;The full code, the invariant test and the mutation script are in &lt;a href="https://github.com/alt-f6/edtech-billing-core" rel="noopener noreferrer"&gt;edtech-billing-core&lt;/a&gt;. If you build billing, or anything else where a number has to be right, I'd like to hear how you test it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Ismail, a backend engineer focused on billing, payments and integrations. &lt;a href="https://iusifli.dev" rel="noopener noreferrer"&gt;iusifli.dev&lt;/a&gt; · &lt;a href="https://linkedin.com/in/ismail-iusifli" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>testing</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
